Initial commit: JARVIS AI dashboard v2.3

- 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
This commit is contained in:
2026-05-25 13:22:57 +00:00
commit dc55e6c45b
27 changed files with 5835 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Credentials - never commit
api/config.php
backup/
# Logs
logs/
*.log
# OS
.DS_Store
+29
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* JARVIS Configuration Template
* Copy to config.php and fill in your values.
*/
define(chr(39).'JARVIS_USER_NAME'.chr(39), chr(39).'Your Name'.chr(39));
define(chr(39).'JARVIS_USER_TITLE'.chr(39), chr(39).'Mr. Blair'.chr(39));
define(chr(39).'JARVIS_CODENAME'.chr(39), chr(39).'JARVIS'.chr(39));
define(chr(39).'DB_HOST'.chr(39), chr(39).'localhost'.chr(39));
define(chr(39).'DB_NAME'.chr(39), chr(39).'jarvis_db'.chr(39));
define(chr(39).'DB_USER'.chr(39), chr(39).'jarvis_user'.chr(39));
define(chr(39).'DB_PASS'.chr(39), chr(39).'YOUR_DB_PASSWORD'.chr(39));
define(chr(39).'CLAUDE_API_KEY'.chr(39), chr(39).'sk-ant-api03-...'.chr(39));
define(chr(39)+'CLAUDE_MODEL'.chr(39), chr(39)+'claude-sonnet-4-6'.chr(39));
define(chr(39)+'CLAUDE_MAX_TOKENS'.chr(39), 1024);
define(chr(39)+'GROQ_API_KEY'.chr(39), chr(39)+'gsk_...'.chr(39));
define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'groq/compound-mini'.chr(39));
define(chr(39)+'GROQ_MODEL_GENERAL'.chr(39), chr(39)+'llama-3.3-70b-versatile'.chr(39));
define(chr(39)+'GROQ_TIMEOUT'.chr(39), 30);
define(chr(39)+'OLLAMA_HOST'.chr(39), chr(39)+'http://10.48.200.95:11434'.chr(39));
define(chr(39)+'OLLAMA_MODEL_PRIMARY'.chr(39), chr(39)+'llama3.2:1b'.chr(39));
define(chr(39)+'OLLAMA_MODEL_HEAVY'.chr(39), chr(39)+'llama3.1:70b'.chr(39));
define(chr(39)+'OLLAMA_TIMEOUT'.chr(39), 90);
define(chr(39)+'LOCAL_SUBNET'.chr(39), chr(39)+'10.48.200'.chr(39));
define(chr(39)+'LOCAL_GATEWAY'.chr(39), chr(39)+'10.48.200.1'.chr(39));
define(chr(39)+'JARVIS_IP'.chr(39), chr(39)+'10.48.200.84'.chr(39));
define(chr(39)+'DO_SERVER_IP'.chr(39), chr(39)+'0.0.0.0'.chr(39));
define(chr(39)+'DO_SSH_USER'.chr(39), chr(39)+'root'.chr(39));
define(chr(39)+'DO_SSH_PASS'.chr(39), chr(39)+'YOUR_DO_SSH_PASSWORD'.chr(39));
define(chr(39)+'PROXMOX_HOST'.chr(39), chr(39)+'10.48.200.90'.chr(39));
define(chr(39)+'PROXMOX_PORT'.chr(39), 8006);
define(chr(39)+'PROXMOX_NODE'.chr(39), chr(39)+'pve'.chr(39));
define(chr(39)+'PROXMOX_USER'.chr(39), chr(39)+'root@pam'.chr(39));
define(chr(39)+'PROXMOX_TOKEN_ID'.chr(39), chr(39)+'jarvis'.chr(39));
define(chr(39)+'PROXMOX_TOKEN_VAL'.chr(39), chr(39)+'YOUR_PROXMOX_API_TOKEN'.chr(39));
define(chr(39)+'HA_URL'.chr(39), chr(39)+'http://10.48.200.97:8123'.chr(39));
define(chr(39)+'HA_TOKEN'.chr(39), chr(39)+'YOUR_HA_LONG_LIVED_TOKEN'.chr(39));
define(chr(39)+'SESSION_LIFETIME'.chr(39), 86400 * 7);
define(chr(39)+'SITE_URL'.chr(39), chr(39)+'https://jarvis.orbishosting.com'.chr(39));
error_reporting(0);
ini_set(chr(39)+'display_errors'.chr(39), 0);
ini_set(chr(39)+'log_errors'.chr(39), 1);
ini_set(chr(39)+'error_log'.chr(39), chr(39)+'/var/log/apache2/jarvis_errors.log'.chr(39));
date_default_timezone_set(chr(39)+'America/Chicago'.chr(39));
+244
View File
@@ -0,0 +1,244 @@
<?php
/**
* JARVIS Agent API
* Handles registration, heartbeats, metrics, HA state pushes, and command polling
* from remote jarvis-agent clients on external networks.
*
* Auth: POST /api/agent/register → uses AGENT_REGISTRATION_KEY
* All other actions → uses X-Agent-Key header (per-agent key)
*/
header('Content-Type: application/json');
$agentAction = $action ?? ($parts[1] ?? '');
// ── Helpers ───────────────────────────────────────────────────────────────────
function agent_error(int $code, string $msg): void {
http_response_code($code);
echo json_encode(['error' => $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);
}
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* JARVIS Alerts API
* GET /api/alerts — return active alerts (auto-generates agent alerts first)
* POST /api/alerts/resolve — resolve an alert by id
* POST /api/alerts — manually create an alert
*/
// ── Auto-generate alerts from agent data ─────────────────────────────────────
function refresh_agent_alerts(): void {
// Thresholds
$CPU_WARN = 85;
$MEM_WARN = 85;
$DISK_WARN = 88;
$DISK_CRIT = 95;
// ── Mark auto-resolve alerts whose condition cleared ──────────────────────
// We'll re-evaluate below and upsert; first collect keys that are still active
$still_active = [];
// ── Offline agents ────────────────────────────────────────────────────────
$offline = JarvisDB::query(
"SELECT agent_id, hostname FROM registered_agents
WHERE status='offline' OR last_seen < DATE_SUB(NOW(), INTERVAL 3 MINUTE)"
);
foreach ($offline as $ag) {
$key = 'agent:' . $ag['agent_id'] . ':offline';
upsert_alert($key, 'critical', 'Agent Offline: ' . $ag['hostname'],
'JARVIS Agent on ' . $ag['hostname'] . ' is not responding. Last contact was more than 3 minutes ago.');
$still_active[$key] = true;
}
// ── Metric-based alerts ───────────────────────────────────────────────────
// Get latest system metrics for each agent
$latest = JarvisDB::query(
"SELECT m.agent_id, m.metric_data, a.hostname
FROM agent_metrics m
JOIN registered_agents a ON a.agent_id = m.agent_id
WHERE m.metric_type = 'system'
AND (m.agent_id, m.recorded_at) IN (
SELECT agent_id, MAX(recorded_at) FROM agent_metrics
WHERE metric_type = 'system'
GROUP BY agent_id
)
AND m.recorded_at > 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]);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Auth endpoint
if ($method === 'POST') {
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if (!$username || !$password) {
http_response_code(400);
echo json_encode(['error' => '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,
]);
}
+717
View File
@@ -0,0 +1,717 @@
<?php
// JARVIS Chat — Intent Engine → Ollama → Claude fallback chain
if ($method !== 'POST') {
echo json_encode(['error' => 'POST only']); exit;
}
$message = trim($data['message'] ?? '');
$sessionId = $data['session_id'] ?? session_id();
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
if (!$message) {
echo json_encode(['error' => 'Message required']); exit;
}
// Build context string from selected panel item
$ctxSnippet = '';
if ($panelCtx && is_array($panelCtx)) {
$type = $panelCtx['type'] ?? '';
switch ($type) {
case 'vm':
$ctxSnippet = sprintf(
'[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]',
$panelCtx['name'] ?? '?',
$panelCtx['vmid'] ?? '?',
$panelCtx['status'] ?? '?',
$panelCtx['cpu'] ?? '?',
$panelCtx['mem_mb'] ?? '?',
$panelCtx['maxmem_mb'] ?? '?',
$panelCtx['type_label'] ?? 'qemu'
);
break;
case 'network':
$ctxSnippet = sprintf(
'[Selected Device: %s — IP: %s, Status: %s%s]',
$panelCtx['name'] ?? '?',
$panelCtx['ip'] ?? '?',
$panelCtx['status'] ?? '?',
$panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : ''
);
break;
case 'alert':
$ctxSnippet = sprintf(
'[Selected Alert: %s — Severity: %s, Message: %s]',
$panelCtx['title'] ?? '?',
$panelCtx['severity'] ?? '?',
$panelCtx['message'] ?? '?'
);
break;
case 'news':
$ctxSnippet = sprintf(
'[Selected News Story: "%s" — Source: %s, Published: %s]',
$panelCtx['title'] ?? '?',
$panelCtx['source'] ?? '?',
$panelCtx['pub'] ?? 'unknown'
);
break;
case 'ha':
$ctxSnippet = sprintf(
'[Selected Home Device: %s (%s) — Current State: %s]',
$panelCtx['name'] ?? '?',
$panelCtx['entity_id'] ?? '?',
$panelCtx['state'] ?? '?'
);
break;
}
}
// Save user message
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'user', $message]
);
// Conversation history
$history = JarvisDB::query(
'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10',
[$sessionId]
);
$history = array_reverse($history);
$reply = null;
$source = 'unknown';
// ── Load user address preference ──────────────────────────────────────────
$prefRows = JarvisDB::query(
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
);
$prefs = [];
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
$userName = $prefs['user_name'] ?? 'Myron';
$userTitle = $prefs['user_title'] ?? 'Mr. Blair';
// Address to use in responses
$userAddr = $userTitle;
// ── Tier 0.1: Name preference change ─────────────────────────────────────
if (!$reply) {
$lc = strtolower($message);
// Patterns: "call me X", "refer to me as X", "address me as X", "my name is X",
// "don't call me X", "stop calling me X", "just call me X"
if (preg_match(
'/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i',
$message, $m
)) {
$newName = trim(preg_replace('/\s+/', ' ', $m[1]));
// Strip trailing punctuation
$newName = rtrim($newName, '.!?,;');
if (strlen($newName) >= 2 && strlen($newName) <= 30) {
JarvisDB::execute(
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
[$newName]
);
$userTitle = $newName;
$userAddr = $newName;
$reply = "Understood. I'll address you as {$newName} from now on.";
$source = 'intent:name_pref';
}
} elseif (preg_match(
'/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i',
$message, $m
)) {
// "don't call me {$userAddr}" — switch to first name
JarvisDB::execute(
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
[$userName]
);
$userTitle = $userName;
$userAddr = $userName;
$reply = "Of course. I'll call you {$userName} going forward.";
$source = 'intent:name_pref';
}
}
// ── Tier 0: Home Assistant Control ───────────────────────────────────────
// Uses entity_map stored by facts_collector to resolve natural language → entity
$haEntityMapRow = JarvisDB::query(
'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=? LIMIT 1',
['ha', 'entity_map']
);
$haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value']))
? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? [])
: [];
// Scene keywords for one-shot activations (no on/off needed)
$sceneKeywords = [
'good night' => 'scene.good_night',
'goodnight' => 'scene.good_night',
'good morning' => 'scene.good_morning',
'goodmorning' => 'scene.good_morning',
'goodbye' => 'scene.goodbye',
'bye' => 'scene.goodbye',
'kitchen lights on' => 'scene.kitchen_lights_on',
'kitchen on' => 'scene.kitchen_lights_on',
'kitchen lights off' => 'scene.kitchen_lights_off',
'kitchen off' => 'scene.kitchen_lights_off',
'front porch lights' => 'scene.outdoors_front_porch_lights',
'porch scene' => 'scene.outdoors_front_porch_lights',
'office dawn' => 'scene.office_ocean_dawn',
];
$msgLower = strtolower(trim($message));
// Check for scene activation first
$sceneId = null;
foreach ($sceneKeywords as $kw => $sid) {
if (strpos($msgLower, $kw) !== false) {
$sceneId = $sid;
break;
}
}
if ($sceneId) {
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
$ch = curl_init($haUrl . '/api/services/scene/turn_on');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
]);
$haResp = curl_exec($ch);
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($haCode === 200) {
$sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId));
$reply = "Activating {$sceneName}, {$userAddr}.";
$source = 'ha:scene';
}
}
// Check for device on/off control
if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch)
|| (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) {
$turnOn = (bool) preg_match('/\bon\b/i', $message);
$turnOff = (bool) preg_match('/\boff\b/i', $message);
$haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null);
if ($haService && !empty($haEntityMap)) {
// Find best matching entity
$bestEid = null;
$bestScore = 0;
$bestName = '';
// Special: "all lights" / "everything"
if (preg_match('/(all|everything|every)/i', $message)) {
$bestEid = '__all_lights__';
$bestName = 'All lights';
$bestScore = 1;
}
if (!$bestEid) {
// Build search terms from message (remove control words with word boundaries)
$searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
// Score: exact substring match = 10, word overlap = words matched
if ($searchMsg && strpos($nameLower, $searchMsg) !== false) {
$score = 10;
} else {
$words = array_filter(explode(' ', $searchMsg));
$score = 0;
foreach ($words as $w) {
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestEid = $eid;
$bestName = $info['name'];
}
}
}
if ($bestEid && $bestScore > 0) {
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
if ($bestEid === '__all_lights__') {
// Turn all lights on/off via domain targeting
$lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light'));
$payload = ['entity_id' => $lightIds];
$svcDomain = 'light';
} else {
$domain = $haEntityMap[$bestEid]['domain'] ?? 'switch';
$payload = ['entity_id' => $bestEid];
$svcDomain = $domain;
}
$ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
]);
$haResp = curl_exec($ch);
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($haCode === 200) {
$action = ($haService === 'turn_on') ? 'activated' : 'deactivated';
$label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName;
$reply = "{$label} {$action}, {$userAddr}.";
$source = 'ha:' . $haService;
}
}
}
}
// Status query for HA entities
if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message)
&& !preg_match('/(turn|switch|put)/i', $message)) {
// Status query - find the entity and report its state
$searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
$bestEid = null; $bestScore = 0; $bestName = ''; $bestState = '';
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
$words = array_filter(explode(' ', $searchMsg));
$score = 0;
foreach ($words as $w) {
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
}
if ($score > $bestScore) {
$bestScore = $score; $bestEid = $eid;
$bestName = $info['name']; $bestState = $info['state'];
}
}
if ($bestEid && $bestScore > 0) {
$reply = "The {$bestName} is currently {$bestState}, {$userAddr}.";
$source = 'ha:status';
}
}
// ── Tier 0.5: Network Device Management ──────────────────────────────────
if (!$reply) {
// Flow state stored in kb_facts (session_write_close() is called before this runs)
$flowKey = substr(session_id() ?: 'anon', 0, 32);
// Expire stale chat flows older than 10 minutes
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)");
$flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]);
$devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null;
// Continue an active multi-step add-device flow
if ($devState && isset($devState['step'])) {
switch ($devState['step']) {
case 'waiting_ip':
if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) {
$newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']);
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
$reply = "Got it — {$m[1]}. What should I call this device?";
} else {
$reply = "Please give me a valid IP address such as 192.168.1.100.";
}
$source = 'device:flow';
break;
case 'waiting_name':
$cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message));
$newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']);
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
$reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip.";
$source = 'device:flow';
break;
case 'waiting_type':
$rawType = strtolower(trim($message));
$allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown'];
$devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown');
$devIp = $devState['ip'] ?? '';
$devName = $devState['name'] ?? '';
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
if ($devIp && $devName) {
JarvisDB::execute(
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
VALUES (?,?,?,'unknown',NOW())
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
[$devIp, $devName, $devType]
);
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status.";
} else {
$reply = "Something went wrong with the device registration. Please try again.";
}
$source = 'device:added';
break;
default:
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
}
}
// Start a new add-device flow or handle inline add
if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) {
$hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM);
$hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM);
$hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM);
if ($hasIp && $hasName) {
$devIp = $ipM[1];
$devName = trim($nameM[1]);
$devType = $hasType ? trim($typeM[1]) : 'unknown';
JarvisDB::execute(
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
VALUES (?,?,?,'unknown',NOW())
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
[$devIp, $devName, $devType]
);
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring.";
$source = 'device:added';
} elseif ($hasIp) {
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]);
$reply = "I found the address {$ipM[1]}. What should I call this device?";
$source = 'device:flow';
} else {
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]);
$reply = "I'll add a new device to network monitoring. What's its IP address?";
$source = 'device:flow';
}
}
// Remove / delete device
if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) {
$target = trim($dm[2]);
$isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target);
$rows = $isIp
? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target])
: JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
if (count($rows)) {
$dev = $rows[0];
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]);
$label = $dev['alias'] ?? $dev['ip'];
$reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring.";
$source = 'device:removed';
}
}
// Update device name or type
if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) {
$target = trim($um[2]);
$newVal = trim($um[3]);
$rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
if (count($rows)) {
$dev = $rows[0];
JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]);
$reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'.";
$source = 'device:updated';
}
}
// List devices
if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) {
$rows = JarvisDB::query(
"SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias"
);
if (count($rows)) {
$items = array_map(fn($r) =>
($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'),
$rows
);
$reply = "Monitored devices: " . implode('; ', $items) . '.';
} else {
$reply = "No named devices in network monitoring yet. Say 'add a device' to get started.";
}
$source = 'device:list';
}
}
// ── Tier 0.8: Weather & News intents ─────────────────────────────────────
if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) {
$wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1");
if ($wRow && !empty($wRow[0]['data'])) {
$wd = json_decode($wRow[0]['data'], true);
$c = $wd['current'] ?? [];
$fc = $wd['forecast'] ?? [];
$today = $fc[0] ?? [];
$tomorrow = $fc[1] ?? [];
$reply = sprintf(
'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' .
'Today\'s range: %d%d°F. ' .
'Tomorrow: %s, %d%d°F, %d%% chance of rain.',
$c['icon'] ?? '',
$c['desc'] ?? '',
$c['temp'] ?? 0,
$c['feels'] ?? 0,
$c['humidity'] ?? 0,
$c['wind'] ?? 0,
$today['low'] ?? 0,
$today['high'] ?? 0,
$tomorrow['desc'] ?? '',
$tomorrow['low'] ?? 0,
$tomorrow['high'] ?? 0,
$tomorrow['rain_pct'] ?? 0
);
$source = 'intent:weather';
}
}
if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) {
$nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1");
if ($nRow && !empty($nRow[0]['data'])) {
$nd = json_decode($nRow[0]['data'], true);
$cats = $nd['categories'] ?? [];
$lines = [];
foreach ($cats as $cat => $articles) {
if (!empty($articles)) {
$top3 = array_slice($articles, 0, 3);
foreach ($top3 as $a) {
$lines[] = '[' . $a['source'] . '] ' . $a['title'];
}
}
}
if ($lines) {
$reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.';
} else {
$reply = 'News feed is still loading, Sir. Please try again in a moment.';
}
$source = 'intent:news';
}
}
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
if (!$reply) {
$matched = KBEngine::match($message);
if ($matched && $matched['action'] === 'response') {
$reply = $matched['reply'];
$source = 'intent:' . $matched['intent'];
}
}
// ── Tier 2: Ollama local LLM (fast local fallback) ───────────────────────
if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) {
$ollamaHost = OLLAMA_HOST;
$ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b';
$timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45;
$ollamaMessages = [];
$ollamaMessages[] = ['role' => 'system', 'content' =>
"You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " .
'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.'];
$ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
$promptParts = [];
foreach ($ollamaMessages as $msg) {
$role = ucfirst($msg['role']);
$promptParts[] = "{$role}: {$msg['content']}";
}
$promptParts[] = 'Assistant:';
$promptStr = implode("\n\n", $promptParts);
$payload = [
'model' => $ollamaModel,
'prompt' => $promptStr,
'stream' => false,
'options' => ['temperature' => 0.7, 'num_predict' => 150],
];
$ch = curl_init($ollamaHost . '/api/generate');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $resp) {
$decoded = json_decode($resp, true);
$text = $decoded['response'] ?? null;
if ($text) {
$reply = trim($text);
$source = 'ollama:' . $ollamaModel;
}
}
// Silently fall through to Groq if Ollama fails or times out
}
// ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ─────────────
if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
$needsSearch = (bool) preg_match(
'/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i',
$message
);
$groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL;
$groqMessages = [['role' => 'system', 'content' =>
"You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " .
"(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " .
'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') . '.'],
];
foreach (array_slice($history, -6) as $h) {
$groqMessages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
$groqMessages[] = ['role' => 'user', 'content' => $userMsg];
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => $groqModel,
'messages' => $groqMessages,
'max_tokens' => 400,
'temperature' => 0.7,
]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => GROQ_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $resp) {
$decoded = json_decode($resp, true);
$text = $decoded['choices'][0]['message']['content'] ?? null;
if ($text) {
$reply = trim($text);
$source = 'groq:' . $groqModel;
}
}
// Silently fall through to Claude if Groq fails
}
// ── Tier 4: Claude API (final fallback) ──────────────────────────────────
if (!$reply) {
// Live context for Claude
$systemContext = '';
try {
$memLines = file('/proc/meminfo');
$mem = [];
foreach ($memLines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$memPct = $mem['MemTotal'] > 0
? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100)
: '?';
$sec = (int) file_get_contents('/proc/uptime');
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
$load = explode(' ', file_get_contents('/proc/loadavg'));
$systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
} catch (Exception $e) {}
$alerts = JarvisDB::query(
'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3'
);
if ($alerts) {
$systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n";
}
$kbContext = KBEngine::getContextSummary();
$systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision.
Infrastructure:
- Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04)
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
- Proxmox Host: 10.48.200.90 (manages all VMs)
- Home Assistant: 10.48.200.97:8123
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
- Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
- Network: 10.48.200.0/24, FortiGate firewall
Live data:
{$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') . "
Today: " . date('l, F j Y, g:i A T') . "
Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers.";
$messages = [];
foreach ($history as $h) {
$messages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') {
$reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands.";
$source = 'fallback:no-key';
} else {
$payload = [
'model' => CLAUDE_MODEL,
'max_tokens' => CLAUDE_MAX_TOKENS,
'system' => $systemPrompt,
'messages' => $messages,
];
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'x-api-key: ' . CLAUDE_API_KEY,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
$decoded = json_decode($resp, true);
$reply = $decoded['content'][0]['text'] ?? null;
$source = 'claude';
} else {
$err = json_decode($resp, true);
$errMsg = $err['error']['message'] ?? '';
if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) {
$reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational.";
} else {
$reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg;
}
$source = 'claude:error';
}
}
}
// ── Final fallback ─────────────────────────────────────────────────────────
if (!$reply) {
$reply = "My systems are processing your request, {$userAddr}. Please try again momentarily.";
$source = 'fallback';
}
// Save reply and learn
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'assistant', $reply]
);
KBEngine::learnFromConversation($message, $reply);
echo json_encode([
'reply' => $reply,
'source' => $source,
'session_id' => $sessionId,
'timestamp' => date('c'),
]);
+77
View File
@@ -0,0 +1,77 @@
<?php
// Digital Ocean server monitoring via SSH
function sshCommand(string $cmd): string {
$sshCmd = sprintf(
'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 %s@%s %s 2>/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'),
]);
+306
View File
@@ -0,0 +1,306 @@
<?php
/**
* JARVIS Facts Collector
* HTTP endpoint: /api/facts/collect (POST or GET)
* CLI/cron: php facts_collector.php
* Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table.
*/
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed');
// Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
if (!class_exists('KBEngine')) {
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/kb_engine.php';
}
function collect_all(): array {
$results = [];
$ttl = 300; // 5-minute TTL on live facts
// ── System ────────────────────────────────────────────────────────────
try {
$stat1 = file_get_contents('/proc/stat');
usleep(200000);
$stat2 = file_get_contents('/proc/stat');
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d");
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d");
$dIdle = $cpu2[3] - $cpu1[3];
$dTotal = array_sum($cpu2) - array_sum($cpu1);
$cpuPct = $dTotal > 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')]);
}
+77
View File
@@ -0,0 +1,77 @@
<?php
// Home Assistant endpoint — entities served from api_cache (refreshed every 5 min by cron)
// Live service calls (turn on/off) still go direct to HA.
function haRequest(string $path, string $method = 'GET', array $payload = []): ?array {
if (HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false) {
return null;
}
$ch = curl_init(HA_URL . '/api' . $path);
$headers = [
'Authorization: Bearer ' . HA_TOKEN,
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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.',
]);
}
+169
View File
@@ -0,0 +1,169 @@
<?php
// Network monitoring endpoint
function pingHost(string $ip): array {
$cmd = 'ping -c 1 -W 1 ' . escapeshellarg($ip) . ' 2>/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')]);
}
+20
View File
@@ -0,0 +1,20 @@
<?php
// News endpoint — serves from api_cache (refreshed every 30 min by cron)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
['news']
);
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
echo json_encode($out);
} else {
echo json_encode([
'categories' => [],
'total' => 0,
'cache_age_s' => -1,
'message' => 'News feed warming up — available within 5 minutes.',
]);
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// Proxmox API endpoint — serves from api_cache, refreshed every 5 min by cron
$isConfigured = !(PROXMOX_HOST === '10.48.200.X' || PROXMOX_TOKEN_VAL === 'YOUR_TOKEN_VALUE_HERE');
if (!$isConfigured) {
echo json_encode([
'configured' => 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.',
]);
}
+298
View File
@@ -0,0 +1,298 @@
<?php
/**
* JARVIS Stats Cache Collector
* Runs every 5 min via cron. Fetches Proxmox + HA data and stores in api_cache.
* Keeps live API calls out of the request path.
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../api/lib/db.php';
function curlGet(string $url, array $headers, int $timeout = 10): ?string {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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";
+136
View File
@@ -0,0 +1,136 @@
<?php
// System stats endpoint — reads /proc directly, no shell injection risk
function getCpuUsage(): float {
$s1 = file('/proc/stat')[0];
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s1, $m1);
usleep(200000);
$s2 = file('/proc/stat')[0];
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s2, $m2);
$idle1 = $m1[4] + $m1[5];
$total1 = array_sum(array_slice($m1, 1));
$idle2 = $m2[4] + $m2[5];
$total2 = array_sum(array_slice($m2, 1));
$dTotal = $total2 - $total1;
$dIdle = $idle2 - $idle1;
return $dTotal > 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);
+20
View File
@@ -0,0 +1,20 @@
<?php
// Weather endpoint — serves from api_cache (refreshed every 30 min by cron)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
['weather']
);
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
echo json_encode($out);
} else {
echo json_encode([
'current' => null,
'forecast' => [],
'cache_age_s' => -1,
'message' => 'Weather data warming up — available within 5 minutes.',
]);
}
+40
View File
@@ -0,0 +1,40 @@
<?php
class JarvisDB {
private static ?PDO $pdo = null;
public static function get(): PDO {
if (self::$pdo === null) {
self::$pdo = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => 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();
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* JARVIS Knowledge Base + Intent Engine
* Matches user input against intent patterns, substitutes live facts from kb_facts.
* Returns a response if matched, or null to escalate to Ollama/Claude.
*/
class KBEngine {
/**
* Try to match the input against known intents.
* Returns ['reply' => 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);
}
}
+11
View File
@@ -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]
+122
View File
@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$SERVICE_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$PYTHON3</string>
<string>$INSTALL_DIR/jarvis-agent.py</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>JARVIS_CONFIG</key>
<string>$CONFIG_DIR/config.json</string>
<key>JARVIS_STATE</key>
<string>$STATE_PATH</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$INSTALL_DIR/jarvis-agent.log</string>
<key>StandardErrorPath</key>
<string>$INSTALL_DIR/jarvis-agent.log</string>
</dict>
</plist>
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
+152
View File
@@ -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 ""
+117
View File
@@ -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
+346
View File
@@ -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()
+454
View File
@@ -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()
+36
View File
@@ -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
}
+90
View File
@@ -0,0 +1,90 @@
<?php
/**
* JARVIS API Router
*/
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../api/lib/db.php';
require_once __DIR__ . '/../api/lib/kb_engine.php';
session_start();
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$method = $_SERVER['REQUEST_METHOD'];
$path = trim(parse_url($uri, PHP_URL_PATH), '/');
$parts = explode('/', $path);
// Remove 'api' prefix if present
if (($parts[0] ?? '') === 'api') array_shift($parts);
$endpoint = $parts[0] ?? '';
$action = $parts[1] ?? '';
// Auth check (except login and ping)
if ($endpoint !== 'auth' && $endpoint !== 'agent') {
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
if (empty($token) || $token !== ($_SESSION['jarvis_token'] ?? '')) {
$localIP = $_SERVER['REMOTE_ADDR'] ?? '';
$isLocal = in_array($localIP, ['127.0.0.1', '::1', JARVIS_IP]);
if (!$isLocal && $endpoint !== 'ping') {
http_response_code(401);
echo json_encode(['error' => '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]);
}
File diff suppressed because it is too large Load Diff