Compare commits

...

3 Commits

13 changed files with 9417 additions and 9622 deletions
+29 -29
View File
@@ -1,29 +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
# 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
+94 -94
View File
@@ -1,94 +1,94 @@
<?php
// Digital Ocean server monitoring — read local /proc directly (no SSH loopback)
// CPU usage (sample over 200ms)
function getCpuPct(): float {
$s1 = file_get_contents("/proc/stat");
usleep(200000);
$s2 = file_get_contents("/proc/stat");
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s1, $m1);
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s2, $m2);
$idle1 = $m1[4]; $total1 = $m1[1]+$m1[2]+$m1[3]+$m1[4];
$idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4];
$dt = $total2 - $total1;
return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0;
}
$memLines = [];
foreach (file("/proc/meminfo") as $l) {
[$k, $v] = explode(":", $l, 2) + [null, null];
if ($k) $memLines[trim($k)] = (int)trim($v);
}
$memTotal = $memLines["MemTotal"] ?? 0;
$memFree = $memLines["MemAvailable"] ?? 0;
$memUsed = $memTotal - $memFree;
$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0];
$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0];
$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
$diskPct = trim($dfOut);
// Services
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
$svcMap = [];
foreach ($svcNames as $s) {
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
if ($status === "active") $svcMap[$s] = true;
}
// Site health from kb_facts
$siteLabels = [
"jarvis" => "jarvis.orbishosting.com",
"tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com",
"orbishosting" => "orbishosting.com",
"orbisportal" => "orbis.orbishosting.com",
"tomtomgames" => "tomtomgames.com",
];
$sites = [];
$rows = JarvisDB::query(
"SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' AND updated_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE) ORDER BY fact_key"
);
foreach ($rows as $r) {
$label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"];
$sites[$label] = $r["fact_value"];
}
$uptimeDays = intdiv($uptime, 86400);
$uptimeHrs = intdiv($uptime % 86400, 3600);
// DO server agent metrics (jarvis-do agent reporting via Tailscale)
$doAgent = JarvisDB::query(
"SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
);
$doMet = [];
if (!empty($doAgent[0]['metric_data'])) {
$dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
$doMet = [
"cpu" => $dm['cpu_percent'] ?? 0,
"mem" => $dm['memory']['percent'] ?? 0,
"disk" => (int)($dm['disk'][0]['percent'] ?? 0),
"uptime" => $dm['uptime']['human'] ?? "--",
"online" => true,
];
}
echo json_encode([
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
"reachable" => true,
"cpu_pct" => getCpuPct(),
"memory" => [
"total_mb" => round($memTotal / 1024),
"used_mb" => round($memUsed / 1024),
"percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0,
],
"disk_used_pct" => $diskPct,
"load_1m" => $load,
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
"services" => $svcMap,
"sites" => $sites,
"do_server" => $doMet,
"timestamp" => date("c"),
]);
<?php
// Digital Ocean server monitoring — read local /proc directly (no SSH loopback)
// CPU usage (sample over 200ms)
function getCpuPct(): float {
$s1 = file_get_contents("/proc/stat");
usleep(200000);
$s2 = file_get_contents("/proc/stat");
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s1, $m1);
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s2, $m2);
$idle1 = $m1[4]; $total1 = $m1[1]+$m1[2]+$m1[3]+$m1[4];
$idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4];
$dt = $total2 - $total1;
return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0;
}
$memLines = [];
foreach (file("/proc/meminfo") as $l) {
[$k, $v] = explode(":", $l, 2) + [null, null];
if ($k) $memLines[trim($k)] = (int)trim($v);
}
$memTotal = $memLines["MemTotal"] ?? 0;
$memFree = $memLines["MemAvailable"] ?? 0;
$memUsed = $memTotal - $memFree;
$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0];
$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0];
$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
$diskPct = trim($dfOut);
// Services
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
$svcMap = [];
foreach ($svcNames as $s) {
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
if ($status === "active") $svcMap[$s] = true;
}
// Site health from kb_facts
$siteLabels = [
"jarvis" => "jarvis.orbishosting.com",
"tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com",
"orbishosting" => "orbishosting.com",
"orbisportal" => "orbis.orbishosting.com",
"tomtomgames" => "tomtomgames.com",
];
$sites = [];
$rows = JarvisDB::query(
"SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' AND updated_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE) ORDER BY fact_key"
);
foreach ($rows as $r) {
$label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"];
$sites[$label] = $r["fact_value"];
}
$uptimeDays = intdiv($uptime, 86400);
$uptimeHrs = intdiv($uptime % 86400, 3600);
// DO server agent metrics (jarvis-do agent reporting via Tailscale)
$doAgent = JarvisDB::query(
"SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
);
$doMet = [];
if (!empty($doAgent[0]['metric_data'])) {
$dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
$doMet = [
"cpu" => $dm['cpu_percent'] ?? 0,
"mem" => $dm['memory']['percent'] ?? 0,
"disk" => (int)($dm['disk'][0]['percent'] ?? 0),
"uptime" => $dm['uptime']['human'] ?? "--",
"online" => true,
];
}
echo json_encode([
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
"reachable" => true,
"cpu_pct" => getCpuPct(),
"memory" => [
"total_mb" => round($memTotal / 1024),
"used_mb" => round($memUsed / 1024),
"percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0,
],
"disk_used_pct" => $diskPct,
"load_1m" => $load,
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
"services" => $svcMap,
"sites" => $sites,
"do_server" => $doMet,
"timestamp" => date("c"),
]);
+256 -256
View File
@@ -1,256 +1,256 @@
<?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
// Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh.
// Comparison is done entirely in SQL (via NOW()) rather than PHP's time()/strtotime()
// — this file's config.php sets date_default_timezone_set('America/Chicago'), which
// makes strtotime() misinterpret MySQL's naive (UTC) datetime strings as being in
// Chicago time, throwing every freshness check off by the UTC offset (previously
// caused "sites" to always look artificially fresh and never actually refresh).
$fresh = function(string $cat, int $secs): bool {
$row = JarvisDB::query(
'SELECT (updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)) AS is_fresh FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
[$secs, $cat]
);
if (empty($row)) return false;
return (bool) $row[0]['is_fresh'];
};
// ── 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 — read from agent DB (agents push status, DO can't ping LAN IPs) ──
try {
$rows = JarvisDB::query(
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
$total = count($rows);
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 (TTL 10 min) ─────────────────────────────────────────────
if ($fresh('proxmox', 600)) {
$results['proxmox'] = 'skipped (fresh)';
} else try {
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://10.48.200.90:' . 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 — skipped (HA agent pushes entities every 30s) ────
$results['ha'] = 'skipped (agent push active)';
// ── Digital Ocean ─────────────────────────────────────────────────────
try {
exec("ping -c1 -W1 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 (TTL 15 min) ───────────────────────────────────────────────
if ($fresh('ollama', 900)) {
$results['ollama'] = 'skipped (fresh)';
} else 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_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$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();
}
// ── Site Health (TTL 5 min) ───────────────────────────────────────────
if ($fresh('sites', 300)) {
$results['sites'] = 'skipped (fresh)';
} else try {
$sites = [
"jarvis" => "http://127.0.0.1",
'tomsjavajive' => 'https://tomsjavajive.com',
'epictravelexp'=> 'https://epictravelexpeditions.com',
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
'orbishosting' => 'https://orbishosting.com',
'orbisportal' => 'https://orbis.orbishosting.com',
'tomtomgames' => 'https://tomtomgames.com',
];
$down = [];
foreach ($sites as $key => $url) {
$parsed = parse_url($url);
$host = $parsed['host'] ?? $url;
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
// All JARVIS-hosted sites are served from this same OLS instance.
$localUrl = $url; // external check
$ch = curl_init($localUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_NOBODY => true,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
KBEngine::storeFact('sites', $key, $status, $url, 180);
if ($status !== 'up') $down[] = "$key($code)";
}
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down);
} catch (Exception $e) {
$results['sites'] = 'error: ' . $e->getMessage();
}
// Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
// which POSTs nmap results to /api/netscan every 3 minutes.
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
return $results;
}
function pve_api_get(string $url, string $authHeader): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 5,
]);
$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')]);
}
<?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
// Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh.
// Comparison is done entirely in SQL (via NOW()) rather than PHP's time()/strtotime()
// — this file's config.php sets date_default_timezone_set('America/Chicago'), which
// makes strtotime() misinterpret MySQL's naive (UTC) datetime strings as being in
// Chicago time, throwing every freshness check off by the UTC offset (previously
// caused "sites" to always look artificially fresh and never actually refresh).
$fresh = function(string $cat, int $secs): bool {
$row = JarvisDB::query(
'SELECT (updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)) AS is_fresh FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
[$secs, $cat]
);
if (empty($row)) return false;
return (bool) $row[0]['is_fresh'];
};
// ── 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 — read from agent DB (agents push status, DO can't ping LAN IPs) ──
try {
$rows = JarvisDB::query(
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
$total = count($rows);
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 (TTL 10 min) ─────────────────────────────────────────────
if ($fresh('proxmox', 600)) {
$results['proxmox'] = 'skipped (fresh)';
} else try {
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://10.48.200.90:' . 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 — skipped (HA agent pushes entities every 30s) ────
$results['ha'] = 'skipped (agent push active)';
// ── Digital Ocean ─────────────────────────────────────────────────────
try {
exec("ping -c1 -W1 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 (TTL 15 min) ───────────────────────────────────────────────
if ($fresh('ollama', 900)) {
$results['ollama'] = 'skipped (fresh)';
} else 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_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$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();
}
// ── Site Health (TTL 5 min) ───────────────────────────────────────────
if ($fresh('sites', 300)) {
$results['sites'] = 'skipped (fresh)';
} else try {
$sites = [
"jarvis" => "http://127.0.0.1",
'tomsjavajive' => 'https://tomsjavajive.com',
'epictravelexp'=> 'https://epictravelexpeditions.com',
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
'orbishosting' => 'https://orbishosting.com',
'orbisportal' => 'https://orbis.orbishosting.com',
'tomtomgames' => 'https://tomtomgames.com',
];
$down = [];
foreach ($sites as $key => $url) {
$parsed = parse_url($url);
$host = $parsed['host'] ?? $url;
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
// All JARVIS-hosted sites are served from this same OLS instance.
$localUrl = $url; // external check
$ch = curl_init($localUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_NOBODY => true,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
KBEngine::storeFact('sites', $key, $status, $url, 180);
if ($status !== 'up') $down[] = "$key($code)";
}
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down);
} catch (Exception $e) {
$results['sites'] = 'error: ' . $e->getMessage();
}
// Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
// which POSTs nmap results to /api/netscan every 3 minutes.
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
return $results;
}
function pve_api_get(string $url, string $authHeader): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 5,
]);
$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')]);
}
+329 -270
View File
@@ -1,270 +1,329 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() — this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $recentRun && $recentRun['is_recent']) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() — this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $recentRun && $recentRun['is_recent']) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── system prompt ── */
/* RESTORED 2026-07-07: this and safe_insert() below were lost during the 2026-07-05 rotation-engine
refactor (moving from a hardcoded $BATCHES array to the kb_generator_topics table). Their absence
caused an uncaught TypeError on every run since (undefined $SYSTEM passed to groq()'s non-nullable
string param), silently swallowed by config.php's error_reporting(0) — the cron looked like it was
running fine but died instantly on batch 1 every single time. Restored from kb_intent_generator.php.bak2,
with the intent count adjusted from 40 to 20 to match this version's actual per-topic request below. */
$SYSTEM = <<<'SYS'
You are an expert educator generating KB (knowledge-base) intents for an AI assistant called JARVIS.
Each intent is a question/phrase a student might ask, paired with a clear educational answer.
Respond ONLY with a valid JSON array (no markdown, no backticks, no commentary).
Each element must have exactly these keys:
"n" intent_name: unique snake_case identifier ≤ 60 chars, prefixed with the batch id given
"p" pattern: a PHP PCRE regex (use (?i) for case-insensitive) that matches the question
"r" response: a thorough but concise educational answer (25 sentences or a short structured list)
"c" category: the category string provided
Rules:
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
- Patterns should NOT start with ^ or end with $ (they are substring matches)
- Responses must be factually accurate
- Do not duplicate intent names; every "n" must be unique within this batch
- Return exactly 20 intents
SYS;
/* ── insert helper ── */
$inserted = 0;
$skipped = 0;
$errors = 0;
function safe_insert(array $intent, string $batchCategory): void {
global $inserted, $skipped, $errors;
$name = trim($intent['n'] ?? '');
$pattern = trim($intent['p'] ?? '');
$response = trim($intent['r'] ?? '');
$category = trim($intent['c'] ?? $batchCategory);
if (!$name || !$pattern || !$response) { $errors++; return; }
if (strlen($name) > 64) $name = substr($name, 0, 64);
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
if (strlen($response) < 30) { $skipped++; return; } // too short
try {
JarvisDB::execute(
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
pattern=VALUES(pattern),
response_template=VALUES(response_template),
fact_category=VALUES(fact_category)',
[$name, $pattern, $response, $category, 'response', 5, 1]
);
$inserted++;
} catch (Exception $e) {
$errors++;
}
}
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
+284
View File
@@ -0,0 +1,284 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Weekly Sunday 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000): ?string {
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err || !$raw) return null;
$d = json_decode($raw, true);
return $d['choices'][0]['message']['content'] ?? null;
}
/* ── daily guard: skip if already ran within last 20 hours ── */
$lastRun = JarvisDB::single(
"SELECT updated_at FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
if ($lastRun && (time() - strtotime($lastRun['updated_at'])) < 72000) {
log_line('Skipping ran within last 6 days (next run Sunday 3am).');
exit(0);
}
log_line('Starting weekly KB intent generation run.');
/* ── topic batches (25 topics × ~40 intents = 1,000+) ── */
$BATCHES = [
['id' => 'math_elem', 'category' => 'math_elementary', 'topic' => 'Elementary school mathematics',
'desc' => 'counting, basic arithmetic, place value, simple fractions, 2D/3D shapes, measurement, telling time, money, patterns'],
['id' => 'math_mid', 'category' => 'math_middle', 'topic' => 'Middle school mathematics',
'desc' => 'ratios, percentages, integers, exponents, pre-algebra equations, coordinate planes, probability, statistics (mean/median/mode), geometry area/volume'],
['id' => 'math_high', 'category' => 'math_high', 'topic' => 'High school mathematics',
'desc' => 'quadratic equations, polynomials, functions, logarithms, trigonometry (SOH-CAH-TOA, unit circle), sequences, permutations and combinations, matrices'],
['id' => 'math_college', 'category' => 'math_college', 'topic' => 'College-level mathematics',
'desc' => 'limits and continuity, derivatives, integrals, chain rule, L\'Hopital, differential equations, vectors, eigenvalues, hypothesis testing, normal distribution'],
['id' => 'bio_cell', 'category' => 'biology', 'topic' => 'Cell biology and genetics',
'desc' => 'organelles, mitosis vs meiosis, DNA structure, protein synthesis (transcription/translation), Mendelian genetics, dominant/recessive, mutations, genetic disorders'],
['id' => 'bio_body', 'category' => 'biology', 'topic' => 'Human body systems',
'desc' => 'skeletal, muscular, cardiovascular, respiratory, digestive, nervous, endocrine, immune, reproductive, and excretory systems'],
['id' => 'bio_ecology', 'category' => 'biology', 'topic' => 'Ecology and evolution',
'desc' => 'ecosystems, biomes, food webs, trophic levels, symbiosis (mutualism/commensalism/parasitism), natural selection, adaptation, speciation, biodiversity'],
['id' => 'chem_basics', 'category' => 'chemistry', 'topic' => 'Chemistry fundamentals',
'desc' => 'atomic structure, periodic table trends, ionic vs covalent bonds, Lewis structures, polarity, molecular geometry (VSEPR), intermolecular forces'],
['id' => 'chem_rxns', 'category' => 'chemistry', 'topic' => 'Chemical reactions and thermochemistry',
'desc' => 'balancing equations, stoichiometry, limiting reagents, reaction types, enthalpy, entropy, Gibbs free energy, Le Chatelier\'s principle, equilibrium constants'],
['id' => 'phys_mech', 'category' => 'physics', 'topic' => 'Physics mechanics and energy',
'desc' => 'kinematics, Newton\'s three laws, friction, momentum, conservation of energy, work and power, circular motion, gravitation, projectile motion'],
['id' => 'phys_em', 'category' => 'physics', 'topic' => 'Physics waves, electricity and magnetism',
'desc' => 'wave properties (amplitude, frequency, wavelength), sound, light spectrum, reflection/refraction, Ohm\'s law, series vs parallel circuits, magnetic fields, electromagnetic induction'],
['id' => 'earth_sci', 'category' => 'earth_science', 'topic' => 'Earth and environmental science',
'desc' => 'rock cycle, plate tectonics, earthquakes, volcanoes, atmosphere layers, weather vs climate, greenhouse effect, water cycle, ocean currents, soil formation'],
['id' => 'astronomy', 'category' => 'astronomy', 'topic' => 'Astronomy and space science',
'desc' => 'solar system planets, star life cycles, galaxies, Big Bang theory, light-years, telescopes, space exploration milestones, dark matter/energy, black holes'],
['id' => 'hist_us', 'category' => 'history_us', 'topic' => 'United States history',
'desc' => 'colonial era, American Revolution, Constitution, westward expansion, Civil War, Reconstruction, Gilded Age, WWI, Great Depression, WWII, Civil Rights, Cold War, modern era'],
['id' => 'hist_world', 'category' => 'history_world', 'topic' => 'World history',
'desc' => 'ancient civilisations (Egypt, Greece, Rome, Mesopotamia, China, India), Medieval period, Renaissance, Reformation, age of exploration, colonialism, Industrial Revolution, WWI, WWII, decolonisation, Cold War'],
['id' => 'geo_world', 'category' => 'geography', 'topic' => 'World geography',
'desc' => 'continents and oceans, countries and capitals, physical features (mountains, rivers, deserts), climate zones, latitude/longitude, map projections, time zones, population distribution'],
['id' => 'civics', 'category' => 'civics', 'topic' => 'Civics and US government',
'desc' => 'three branches of government, checks and balances, Bill of Rights, Constitutional amendments, federalism, Electoral College, legislative process, Supreme Court landmark cases, political parties'],
['id' => 'econ', 'category' => 'economics', 'topic' => 'Economics',
'desc' => 'supply and demand, market equilibrium, elasticity, GDP, inflation, unemployment, fiscal vs monetary policy, comparative advantage, market structures (monopoly, oligopoly, perfect competition), opportunity cost'],
['id' => 'lit_writing', 'category' => 'literature', 'topic' => 'Literature and writing',
'desc' => 'figurative language (simile, metaphor, personification, irony), plot structure, literary themes, point of view, genre types, essay structure, thesis statements, grammar rules, poetry forms, citing sources'],
['id' => 'cs_prog', 'category' => 'computer_science', 'topic' => 'Computer science and programming',
'desc' => 'variables, data types, loops, conditionals, functions, OOP concepts, arrays/lists, sorting and searching algorithms, time/space complexity, binary, hexadecimal, recursion, debugging'],
['id' => 'cs_systems', 'category' => 'computer_science', 'topic' => 'Computer systems and networking',
'desc' => 'CPU/RAM/storage, operating systems, file systems, TCP/IP, DNS, HTTP/HTTPS, databases (SQL vs NoSQL), cybersecurity threats (phishing, SQL injection, XSS, malware), encryption basics, cloud computing'],
['id' => 'psych', 'category' => 'psychology', 'topic' => 'Psychology',
'desc' => 'classical and operant conditioning, Maslow\'s hierarchy, Piaget\'s stages, Erikson\'s stages, memory types, sleep stages, cognitive biases, Freud\'s theory, social influence, abnormal psychology basics'],
['id' => 'phil', 'category' => 'philosophy', 'topic' => 'Philosophy and logic',
'desc' => 'Socrates/Plato/Aristotle, ethical theories (utilitarianism, deontology, virtue ethics), epistemology, deductive vs inductive reasoning, logical fallacies, existentialism, free will vs determinism, political philosophy'],
['id' => 'health_sci', 'category' => 'health', 'topic' => 'Health and life skills',
'desc' => 'nutrition (macronutrients, vitamins, minerals), exercise physiology, mental health (anxiety, depression, stress response), reproductive health, substance abuse, first aid, disease prevention, sleep hygiene'],
['id' => 'arts_music', 'category' => 'arts', 'topic' => 'Arts and music',
'desc' => 'elements of art (line, shape, color, texture), colour theory, major art movements, famous artists and their works, music notes and scales, rhythm and meter, instrument families, major composers and genres'],
];
/* ── system prompt ── */
$SYSTEM = <<<'SYS'
You are an expert educator generating KB (knowledge-base) intents for an AI assistant called JARVIS.
Each intent is a question/phrase a student might ask, paired with a clear educational answer.
Respond ONLY with a valid JSON array (no markdown, no backticks, no commentary).
Each element must have exactly these keys:
"n" intent_name: unique snake_case identifier ≤ 60 chars, prefixed with the batch id given
"p" pattern: a PHP PCRE regex (use (?i) for case-insensitive) that matches the question
"r" response: a thorough but concise educational answer (25 sentences or a short structured list)
"c" category: the category string provided
Rules:
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
- Patterns should NOT start with ^ or end with $ (they are substring matches)
- Responses must be factually accurate
- Do not duplicate intent names; every "n" must be unique within this batch
- Return exactly 40 intents
SYS;
/* ── insert helper ── */
$inserted = 0;
$skipped = 0;
$errors = 0;
function safe_insert(array $intent, string $batchCategory): void {
global $inserted, $skipped, $errors;
$name = trim($intent['n'] ?? '');
$pattern = trim($intent['p'] ?? '');
$response = trim($intent['r'] ?? '');
$category = trim($intent['c'] ?? $batchCategory);
if (!$name || !$pattern || !$response) { $errors++; return; }
if (strlen($name) > 64) $name = substr($name, 0, 64);
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
if (strlen($response) < 30) { $skipped++; return; } // too short
try {
JarvisDB::execute(
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
pattern=VALUES(pattern),
response_template=VALUES(response_template),
fact_category=VALUES(fact_category)',
[$name, $pattern, $response, $category, 'response', 5, 1]
);
$inserted++;
} catch (Exception $e) {
$errors++;
}
}
/* ── main generation loop ── */
$totalBatches = count($BATCHES);
foreach ($BATCHES as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 40 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 3500);
if ($raw === null) {
log_line(" ✗ API call failed skipping batch.");
$errors += 40;
sleep(3);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array (even if the model added preamble text)
if (!preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
log_line(" ✗ No JSON array found in response skipping batch.");
$errors += 40;
sleep(2);
continue;
}
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 40;
sleep(2);
continue;
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls to avoid rate limiting
if ($num < $totalBatches) sleep(2);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
// Delete all but the first (longest response)
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars) — likely garbage
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all text columns for the auto-generated ones
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Deactivate intents whose pattern is invalid PCRE
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE active=1 AND priority=5');
$badPattern = 0;
foreach ($all as $row) {
if (@preg_match($row['pattern'], '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
}
}
log_line(" Bad PCRE patterns deactivated: {$badPattern}");
// 5. Stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp in kb_facts ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
@@ -0,0 +1,270 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() — this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $recentRun && $recentRun['is_recent']) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
Regular → Executable
View File
@@ -1,991 +0,0 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
**Last Updated:** 2026-07-06 (Section 14: added a 4th WiFi extender + corrected Pioneer VSX-822 attribution)
**Owner:** Myron Blair — myronblair@outlook.com
---
## TABLE OF CONTENTS
1. [Network Overview](#1-network-overview)
2. [Cloud Servers](#2-cloud-servers)
3. [On-Premise — Proxmox Hypervisors](#3-on-premise--proxmox-hypervisors)
4. [On-Premise — Virtual Machines](#4-on-premise--virtual-machines)
5. [NAS Storage](#5-nas-storage)
6. [Websites (all on DO)](#6-websites--all-on-do)
7. [JARVIS AI System](#7-jarvis-ai-system)
8. [Phone System (FusionPBX)](#8-phone-system-fusionpbx)
9. [Networking & VPN](#9-networking--vpn)
10. [Backup Systems](#10-backup-systems)
11. [SSH Quick Reference](#11-ssh-quick-reference)
12. [Critical Credentials Master List](#12-critical-credentials-master-list)
13. [Git & Repository Management](#13-git--repository-management)
14. [Network Equipment & Client Device Inventory](#14-network-equipment--client-device-inventory)
---
## 1. NETWORK OVERVIEW
```
INTERNET
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
│ (proxied DNS for public sites)
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (7 sites)
└─► [FusionPBX 134.209.72.226] — FreeSWITCH PBX (SSH via DO relay)
HOME NETWORK (FortiGate router at 10.48.200.1)
WAN: 97.154.109.245 (dynamic, DDNS: orbisne.fortiddns.com)
├─► PVE1 Proxmox 10.48.200.90 (primary hypervisor)
│ ├── VM 101 10.48.200.97 Home Assistant
│ ├── VM 112 10.48.200.33 Jellyfin
│ ├── VM 103 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
│ ├── VM 118 10.48.200.18 Homebridge
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
│ ├── VM 106 10.48.200.210 Ollama (local LLM + vision) — llama3.1:8b, llava:7b
│ └── CT110 10.48.200.19/.67 WireGuard exit container (disabled at boot 2026-07-06, legacy/unused)
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
│ └── VM 302 10.48.200.99 NetworkBackup
├─► Synology NAS 10.48.200.249 — Media & backup storage
├─► Yealink T48S 10.48.200.2 — Ext 1000 (Myron Blair, Desk)
├─► Yealink T48S 10.48.200.43 — Ext 1001 (Tommy Ivy, Desk)
├─► Yealink AX86R 10.48.200.65 — Ext 1002 (Myron Blair, WiFi Work)
├─► Yealink T57W 10.48.200.3 — External SIP (United Mirror & Glass)
├─► Yealink T57W 10.48.200.83 — Ext 1003 (Kitchen)
└─► Yealink T57W 10.48.200.85 — Ext 1004 (Master Bedroom)
FortiGate Port Forwards:
orbisne.fortiddns.com:8006 → PVE1:8006 (Proxmox web UI)
orbisne.fortiddns.com:8123 → HA:8123 (Home Assistant)
orbisne.fortiddns.com:22 → HA VM:22 (SSH — key only, unreliable)
```
---
## 2. CLOUD SERVERS
### 2A. DigitalOcean — Main Server
| Field | Value |
|-------|-------|
| **IP** | 165.22.1.228 |
| **OS** | Ubuntu 22.04 LTS |
| **Panel** | CyberPanel (OpenLiteSpeed) |
| **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` |
| **Purpose** | All public websites (7 sites) — webhook deploy for websites |
**Key Paths:**
- All sites: `/home/<domain>/public_html/`
- Deploy log: per-site (website deploys only)
- Watchdog log: `/usr/local/lsws/logs/watchdog.log`
- Infra repo: `/opt/infra`
**Services running:**
- OpenLiteSpeed web server (`lsws`) — serves all 7 sites
- MySQL 8 — all site databases on localhost
- Redis — session/cache
- PHP 8.5 (`lsphp85`) — runtime for all sites
- Cron jobs: website deploy runner (every 1 min), watchdog (every 5 min)
**CyberPanel Web UI:** `https://165.22.1.228:8090`
Login: `myron / Joker1974!!!`
**phpMyAdmin:** `https://165.22.1.228/phpmyadmin`
Login: `myron / Joker1974!!!`
---
### 2B. FusionPBX / FreeSWITCH — PBX Server
| Field | Value |
|-------|-------|
| **IP** | 134.209.72.226 |
| **OS** | Debian (DigitalOcean droplet) |
| **SSH** | Direct via Tailscale: `ssh root@100.74.46.120` — password: `Joker1974!@#` |
| **Direct SSH** | Only from: 107.178.2.130 / 97.154.109.245 |
| **Purpose** | VoIP phone system — handles all inbound/outbound calls |
**Web UI:** `https://fusion.orbishosting.com`
Login: `admin / fY7XP5swgtpbzrYLhkeVYkA4744`
**Database:** PostgreSQL
User: `fusionpbx` / Password: `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` / Host: 127.0.0.1
**SIP Trunk:** SignalWire
DID: +1 (817) 764-5007
Gateway: `signalwire` on external profile (port 5080, UDP)
**How calls flow:**
```
Caller → SignalWire SIP → FusionPBX:5080 → IVR (ext 900) → Ring extensions
Outbound: Phone → FusionPBX:5080 → SignalWire → PSTN
```
**SSH Relay Command:**
```bash
sshpass -p 'Gonewalk1974!@#' ssh -o StrictHostKeyChecking=no root@165.22.1.228 \
'sshpass -p "Joker1974!@#" ssh -o StrictHostKeyChecking=no root@134.209.72.226 "COMMAND"'
```
---
## 3. ON-PREMISE — PROXMOX HYPERVISORS
### PVE1 — Primary Hypervisor
| Field | Value |
|-------|-------|
| **Local IP** | 10.48.200.90 |
| **External** | orbisne.fortiddns.com (FortiGate DDNS — auto-updates on WAN IP change) |
| **OS** | Proxmox VE 8.x |
| **SSH** | `ssh root@orbisne.fortiddns.com` OR `ssh root@10.48.200.90` — password: `Joker1974!!!` |
| **Web UI** | `https://orbisne.fortiddns.com:8006``root / Joker1974!!!` |
| **Purpose** | Runs VMs 101, 112, 113, 118, 120, 210, CT110 |
**Useful commands:**
```bash
qm list # list all VMs
qm start/stop/restart <VMID> # control VMs
qm guest exec <VMID> -- bash -c "cmd" # run command inside VM (requires QEMU agent)
```
**JARVIS API Token:** `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b`
---
### PVE2 — Secondary Hypervisor
| Field | Value |
|-------|-------|
| **Local IP** | 10.48.200.91 |
| **OS** | Proxmox VE 8.x |
| **SSH** | `ssh root@10.48.200.91` — password: `Joker1974!!!` |
| **Web UI** | `https://10.48.200.91:8006``root / Joker1974!!!` |
| **Purpose** | Runs VM 302 (NetworkBackup); part of shared Proxmox cluster with PVE1 |
---
## 4. ON-PREMISE — VIRTUAL MACHINES
### VM 100 — SynchroNet (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.112 |
| **OS** | Windows |
| **Purpose** | SynchroNet BBS (bulletin board system) |
| **Note** | VM is named "SynchroNet-50" in Proxmox but its real IP is `.112`, not `.50``.50` is an unrelated Raspberry Pi 5 hobby device (see Section 14). Confirmed 2026-07-06. |
---
### VM 105 — Nginx Proxy Manager (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.200 |
| **Purpose** | Reverse proxy management (NPM) |
| **Note** | Confirmed 2026-07-06; VM name `NPM-200` matches its IP correctly (unlike SynchroNet/Ollama above). |
---
### VM 101 — Home Assistant (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.97 |
| **OS** | Ubuntu + Home Assistant OS/Supervised |
| **Web UI** | `http://orbisne.fortiddns.com:8123``myron / [HA password]` |
| **SSH** | Via HA web terminal only (Settings → Add-ons → Advanced SSH & Web Terminal) |
| **Purpose** | Smart home automation — 212 entities (lights, switches, scenes, sensors) |
| **JARVIS Agent** | ID: `homeassistant_ha` — pushes entity states to JARVIS every 10s |
**JARVIS ↔ HA Integration:**
- HA custom component at `/config/custom_components/jarvis_agent/`
- Pushes all entity state changes to JARVIS `/api/agent/ha_state` (debounced 2s)
- JARVIS admin toggles → queued in `agent_commands` table → HA executes natively
- HA Long-lived Token (Jarvis2): `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE`
---
### VM 112 — Jellyfin Media Server (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.33 |
| **OS** | Ubuntu 24.04.4 LTS (verified 2026-07-06; previously logged as 22.04 — reinstalled/upgraded at some point) |
| **SSH** | `ssh root@10.48.200.33` — password: `Joker1974!!!` (enabled 2026-06-14) |
| **Web UI** | `http://10.48.200.33:8096` (Jellyfin 10.11.11) |
| **Purpose** | Media streaming server — Movies and TV shows |
| **JARVIS Agent** | `jarvis-agent.service` running (verified 2026-07-06) |
| **Remote access** | Tailscale installed, node `jellyfin-112` at `100.81.145.48` — used for off-LAN streaming access |
**Media Libraries:**
- Movies: `/mnt/mediastack/movies` — NFS from MediaStack (10.48.200.35:/media/movies)
- TV: `/mnt/mediastack/tv` — NFS from MediaStack (10.48.200.35:/media/tv)
**NFS chain:** Jellyfin → MediaStack → Synology NAS (`/volume1/video/movies` and `/volume1/video/tv`)
**Admin token:** `635142c218f2457e813a2de8dec75b05` (regenerated 2026-07-06 — previous token `7c0ccf78...` had gone stale/invalid. The account's real username is **admin**, not myron — confirmed via the `Users` table in `/var/lib/jellyfin/data/jellyfin.db`; it's hidden from the public login list, which is why `/Users/Public` returns empty.)
**If library scan needed:**
```bash
curl -X POST "http://10.48.200.33:8096/Library/Refresh" \
-H "X-Emby-Token: 635142c218f2457e813a2de8dec75b05"
```
**If NFS stale after MediaStack changes:**
```bash
umount -l /mnt/mediastack/movies && umount -l /mnt/mediastack/tv
mount /mnt/mediastack/movies && mount /mnt/mediastack/tv
```
---
### VM 103 — MediaStack (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.35 |
| **OS** | Ubuntu 24.04.4 LTS |
| **SSH** | Via PVE1: `ssh -i /root/.ssh/id_rsa root@10.48.200.35` (no direct access from DO) |
| **Purpose** | Automated media download pipeline + NFS server to Jellyfin |
| **JARVIS Agent** | ID: `MediaStack_2c00b1b8` |
| **Not Docker** | Despite the name, all services below run bare-metal via systemd, not docker-compose |
**Services:**
| Service | Port | Login | API Key |
|---------|------|-------|---------|
| qBittorrent | :8080 | `admin / Joker1974!!!` | — |
| Sonarr | :8989 | `admin / Joker1974!!!` | `b43e04350a594846b4ee95261c29e9e0` |
| Radarr | :7878 | `admin / Joker1974!!!` | `53c4268360444feeae5f98c0cc24e0e3` |
| Prowlarr | :9696 | `admin / Joker1974!!!` | `9d0ce6c5660743b5bf1c7951efc62252` |
**All services run as root** — required by Synology NFS ACL (only root can write).
**VPN:** NordVPN — `nordlynx` WireGuard interface — exit IP rotates (US Dallas servers), not a fixed IP (previous "181.214.226.188" was just a snapshot, not stable). All download/general traffic exits via NordVPN with LAN traffic exempted (Kill Switch + Firewall + LAN Discovery all `enabled` as of 2026-07-06 — see Section 9 for the full incident/fix history). If downloads stall, check `nordvpn status` first, then `ip rule show` for rules 32764/32765/table 205.
**Media Flow:**
```
IPTorrents (Prowlarr) → Sonarr/Radarr search → qBittorrent download
→ /mnt/nas/video/downloads (NAS)
→ Sonarr/Radarr import → /mnt/nas/video/tv or /mnt/nas/video/movies (NAS)
→ NFS → Jellyfin /mnt/mediastack/movies or /mnt/mediastack/tv
```
**Indexer:** IPTorrents via Prowlarr cookie auth
Cookie: `uid=2237410; pass=JzLP2niTWxBJAZIU3yvtLbJzD55kdLeB`
(Expires — if search fails, log into iptorrents.com, copy uid+pass cookies)
**If Radarr/Sonarr shows "0 active indexers":**
```bash
systemctl stop radarr
sqlite3 /var/lib/radarr/radarr.db "DELETE FROM IndexerStatus WHERE ProviderId=1;"
systemctl start radarr
```
**SSH from DO:**
```bash
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
'ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_rsa root@10.48.200.35 "COMMAND"'
```
---
### VM 118 — Homebridge (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.18 |
| **OS** | Linux |
| **SSH** | `ssh myron@10.48.200.18` — password: `Joker1974!` |
| **Purpose** | Apple HomeKit bridge — exposes non-HomeKit devices to Apple Home app |
| **JARVIS Agent** | ID: `homebridge_b57cbaea` |
---
### VM 120 — NovaCPX Hosting Panel (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.110 |
| **OS** | Ubuntu 24.04 LTS |
| **SSH** | `ssh root@10.48.200.110` — password: `Joker1974!!!` (direct, no PVE hop) |
| **Purpose** | Custom web hosting control panel (cPanel alternative), v1.0.27 |
| **JARVIS Agent** | ID: `novacpx_e3b07264` |
**Ports:**
| Port | Panel |
|------|-------|
| :8880 | User panel |
| :8881 | Reseller panel |
| :8882 | Admin panel |
| :8883 | Roundcube webmail |
**Admin:** `https://10.48.200.110:8882``admin / Admin2026!`
**phpMyAdmin:** `http://10.48.200.110/phpmyadmin`
**File Paths:**
- Web root: `/srv/novacpx/public/`
- DB (SQLite): `/var/lib/novacpx/panel.db`
- Config: `/etc/novacpx/config.ini`
- Git repo: `/opt/novacpx-src/`
- GitHub: `myronblair/novacpx` (auto-deploy on push to `main`)
---
### VM 106 — Ollama Local LLM + Vision (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.210 |
| **OS** | Ubuntu (cloud image) |
| **SSH** | `ssh root@10.48.200.210` via PVE1 hop — password: `Joker1974!!!` (also reachable as `ssh myron@10.48.200.210` — password `Joker1974!`, then `sudo`). VM's name is `Ollama-95` but its real IP is `.210`, not `.95` — a prior version of this doc had a stray SSH line pointed at `.95` (nothing listens there); confirmed 2026-07-06 that `.210` is correct. |
| **Purpose** | Local AI inference — chat (llama3.1:8b) + vision (llava:7b) |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
| **Models** | `llama3.1:8b` (chat/Tier 1), `llava:7b` (vision cascade) |
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
**Vision cascade:** Arc Reactor calls Claude first; if Claude credits depleted, falls back to llava:7b via Ollama.
Vision is enabled via: `/etc/systemd/system/jarvis-arc.service.d/vision.conf``OLLAMA_VISION_MODEL=llava:7b`
---
### VM 302 — NetworkBackup (PVE2)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.99 |
| **OS** | Ubuntu/Linux |
| **SSH** | `ssh myron@10.48.200.99` — password: `Joker1974!` (then `sudo`) |
| **Purpose** | Network backup storage / backup operations |
| **JARVIS Agent** | ID: `networkbackup_NetworkB` |
---
### CT110 — WireGuard Exit Container (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.19 / 10.48.200.67 |
| **Purpose** | Legacy WireGuard exit tunnel to DO (10.200.0.4 via wg-exit) — currently NOT used by MediaStack/Jellyfin |
| **Note** | MediaStack uses NordVPN directly; Jellyfin uses wg1 peer on MediaStack for NFS only |
| **2026-07-06 incident** | MediaStack's `wg0` client for this tunnel (`/etc/wireguard/wg0.conf`) had a `PostUp` hook installing its own iptables kill-switch (`REJECT` all output not via `wg0` or marked `51820`), and `wg-quick@wg0.service` was still **enabled at boot** on MediaStack despite this tunnel being unused — this caused a full LAN/SSH lockout to MediaStack the moment that service came up. **Disabled `wg-quick@wg0` at boot on MediaStack** to prevent recurrence; the tunnel itself and CT110 are untouched. See NordVPN section below for the related (separate) LAN Discovery bug found in the same incident. |
---
## 5. NAS STORAGE
### Synology NAS
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.249 |
| **Login** | `nas / Joker1974!!!` |
| **DSM Web UI** | `http://10.48.200.249:5000` |
| **Purpose** | Primary media and download storage |
**NFS Share:** `/volume1/video` and its subpaths `/volume1/video/movies`, `/volume1/video/tv` — all exported to MediaStack (10.48.200.35) only, per `/etc/exports` on the NAS (verified 2026-07-06)
**Directory structure:**
```
/volume1/video/
movies/ ← Radarr imports here; NFS-exported to Jellyfin via MediaStack
tv/ ← Sonarr imports here; NFS-exported to Jellyfin via MediaStack
downloads/ ← qBittorrent downloads here (temp)
incomplete/ ← in-progress torrents
```
**Important:** Synology NFS ACL only allows root to write. All services on MediaStack run as root.
---
## 6. WEBSITES (ALL ON DO)
All sites are at `/home/<domain>/public_html/` on DO (165.22.1.228).
**Auto-deploy:** Push to `main` on GitHub → webhook → server pulls in ~1 min.
**GitHub PAT:** `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` (rotated 2026-07-05 — old PAT `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` was found exposed in `.git/config` on all 6 original sites and must be treated as compromised/revoked)
---
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
| Field | Value |
|-------|-------|
| **URL** | http://jarvis.orbishosting.com (port 80 — old `:1972` reference was wrong, corrected 2026-07-04) |
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
| **GitHub** | `myronblair/jarvis` |
| **Login** | `myron / Joker1974!!!` |
| **Purpose** | Iron Man-style AI home dashboard with voice control, smart home, media, planner |
See Section 7 for full JARVIS details.
---
### worktracking.orbishosting.com — ChuckCo Time Keeper
| Field | Value |
|-------|-------|
| **URL** | https://worktracking.orbishosting.com |
| **Path** | `/home/worktracking.orbishosting.com/public_html/` |
| **GitHub** | `myronblair/chucko` (private) |
| **Gitea** | `myron/chucko` (pull-mirror of GitHub) |
| **Local clone** | `C:\Users\myron\repos\chucko` on admin Windows machine |
| **Purpose** | Work-tracking app for a flat-rate 5-day (FriThu) work week — self-reported hours via personal secret-URL tokens (no login), single shared admin password, phone-friendly screenshot pages for texting workers/payer |
| **Admin URL** | `https://worktracking.orbishosting.com/admin/login.php` — password `Joker1974!!!` |
| **DB** | `workt_track_db` / `workt_track_user` / `ZWCNMRP2N5NVPsghmve5aRS9` |
| **Linked from Blair HQ** | `web.orbishosting.com` dashboard's "Websites" card has direct links to the admin login and the all-workers overview page (site-wide token `972f82cbf7832fdb2cffcdcc84129a4af69e30bd`) |
| **Note** | Built 2026-07-05. `includes/config.php` (DB creds, admin password hash, site token) lives outside `public_html`/webroot and is intentionally NOT in the git repo. |
| **Admin login rate limiting** | Added 2026-07-06 — `login_attempts` table (`ip_address`, `attempts`, `last_attempt`) in `workt_track_db`; 5 failed attempts locks that IP out for 15 minutes. |
| **Code review (2026-07-06)** | 5 findings fixed and deployed: `w.php` mark_paid now rejects any `week_start` that isn't the true current week (was trusting client input); `admin/worker.php` no longer double-HTML-escapes the page title; `all.php` now shows a Paid/Unpaid badge per worker for the displayed week; `s.php`/`p.php`/`all.php` validate the `week` param before building dates (malformed input used to throw an uncaught DateTime exception); admin login rate-limited (see above). |
---
### tomsjavajive.com — Tom's Java Jive
| Field | Value |
|-------|-------|
| **URL** | https://tomsjavajive.com |
| **Path** | `/home/tomsjavajive.com/public_html/` |
| **GitHub** | `myronblair/tomsjavajive` |
| **Purpose** | Coffee shop e-commerce — products, orders, loyalty, wallet, reviews |
| **Admin URL** | `https://tomsjavajive.com/admin/` |
| **Admin Login** | `admin@tomsjavajive.com / Joker1974!!!` OR `myronblair@outlook.com / Joker1974!!!` |
| **DB** | `toms_tjj_db / toms_tjj_user / +60wlPc+55e@gFq4` |
| **Email** | CyberMail API key: `sk_live_7f9b0f9a29f6de31a0d229d4af75d56b094ad724fc58a57d` |
| **Email From** | `noreply@tomsjavajive.com` / `Toms Java Jive` (set in DB settings table) |
---
### epictravelexpeditions.com — Epic Travel Expeditions
| Field | Value |
|-------|-------|
| **URL** | https://epictravelexpeditions.com |
| **Path** | `/home/epictravelexpeditions.com/public_html/` |
| **GitHub** | `myronblair/epictravelexpeditions` |
| **Purpose** | Travel booking / expeditions website |
| **DB** | `epic_travel_db` (see `api/config.php`) |
---
### parkerslingshot.epictravelexpeditions.com — Parker Slingshot (OLD)
| Field | Value |
|-------|-------|
| **URL** | https://parkerslingshot.epictravelexpeditions.com |
| **Path** | `/home/epictravelexpeditions.com/parkerslingshot/` |
| **GitHub** | `myronblair/parkerslingshot` |
| **Purpose** | Old slingshot rental site (superseded by parkerslingshotrentals.com) |
---
### parkerslingshotrentals.com — Parker Slingshot Rentals (LIVE)
| Field | Value |
|-------|-------|
| **URL** | https://www.parkerslingshotrentals.com |
| **Path** | `/home/parkerslingshotrentals.com/public_html/` |
| **GitHub** | `myronblair/parkerslingshotrentals` |
| **Purpose** | Polaris Slingshot rental — bookings, e-signature waiver, admin management |
| **Admin** | `/admin/index.php``admin / Parker2026!` |
| **DB** | `park_slingshot / park_slingshotuser / 4@rxg*8kovxCr7w6` |
| **Square** | Production token: `EAAAl3FsAu_2ri8kZE_ENEyi2T_C8HXXm5XQFY6Lbnd8SX6FqYp8J_upUeXNYh7v` |
---
### orbishosting.com — Orbis Hosting (Landing Page)
| Field | Value |
|-------|-------|
| **URL** | https://orbishosting.com |
| **Path** | `/home/orbishosting.com/public_html/` |
| **GitHub** | `myronblair/orbishosting` |
| **Purpose** | Public landing page for Orbis Hosting brand |
---
### orbis.orbishosting.com — Orbis Hosting Portal
| Field | Value |
|-------|-------|
| **URL** | https://orbis.orbishosting.com |
| **Path** | `/home/orbis.orbishosting.com/public_html/` |
| **GitHub** | `myronblair/orbis-hosting-portal` |
| **Purpose** | Customer-facing hosting portal |
---
### tomtomgames.com — TomTom Games
| Field | Value |
|-------|-------|
| **URL** | https://tomtomgames.com |
| **Path** | `/home/tomtomgames.com/public_html/` |
| **GitHub** | `myronblair/tomtomgames` |
| **Purpose** | Gaming website |
| **DB** | `tomtom_games_db` (see config) |
| **Email** | CyberMail API key: `sk_live_7f9b...` |
---
### Code review pass — all 4 DO-hosted business sites (2026-07-06)
Full security/correctness review of tomsjavajive.com, tomtomgames.com, parkerslingshotrentals.com, epictravelexpeditions.com (orbishosting.com apex and orbis.orbishosting.com excluded — not live/do-not-touch per earlier note). 10 findings, all fixed, tested, committed, and pushed to each site's `main` branch same day.
**Critical (live exploitable, now fixed):**
- **tomsjavajive.com `api/orders.php`** had a literal `// Admin check would go here` comment — anyone who knew/found an `order_id` could silently change any order's status (cancelled/delivered/refunded) or overwrite tracking numbers, and read another customer's full order (name, email, address, items), with zero auth. Fixed: `update_status` now requires `AdminAuth::isLoggedIn()`; the `GET ?id=` lookup now requires admin or the order's own customer.
- **parkerslingshotrentals.com `uploads/`** — customer driver's license and insurance-card photos were directly downloadable with no login at all (the `Order deny,allow`/`Require all denied` pattern in `uploads/.htaccess` doesn't work on this OpenLiteSpeed setup, same class of gotcha as the `.git` exposure found earlier). Verified live via a throwaway test file before fixing. Fixed with the working `RewriteRule .* - [F,L]` pattern in both the nested `uploads/.htaccess` and the root `.htaccess`**required an actual `systemctl restart lshttpd`** to take effect (touching `/usr/local/lsws/cgid` alone, which only prevents the cron's own restart trigger, was NOT sufficient for a *new* rewrite rule to be picked up — worth remembering for future `.htaccess` changes on this server).
**High (fixed):**
- **tomsjavajive.com `admin/orders.php`** — the exact "same named PDO param reused twice" bug that already bit `awardPoints()` had recurred in the order search box (`:search` bound once, referenced 3 times), causing a fatal `SQLSTATE[HY093]` on every admin search. Fixed with distinct `:search1`/`:search2`/`:search3`.
- **tomtomgames.com `admin/index.php`** — stored XSS: `renderGamerOverview()` inserted username/alias/email into `innerHTML` without the `escHtmlA()` helper used correctly everywhere else in the same file. Alias has no character restriction, so a malicious alias could execute script in an admin's session the moment they open that user's profile. Fixed.
**Medium (fixed):**
- **tomtomgames.com `includes/square.php`** (untracked by git — lives outside `public_html`, fix deployed to the server only) — `charge()`/`refund()` generated a fresh `uniqid()` idempotency key on every call, defeating Square's duplicate-protection entirely. Fixed: keyed off `md5(source_id)` for charges and `md5(payment_id . amount)` for refunds, so retries/double-clicks are recognized as duplicates.
- **parkerslingshotrentals.com `contact.php`** — booking availability check + insert had no locking, allowing a double-booking race under concurrent submissions; the deposit-hold idempotency key was suffixed with `time()` (changes every second, so retries aren't deduped). Fixed: wrapped the check+insert in a MySQL `GET_LOCK`/`RELEASE_LOCK` pair scoped to the requested date range, and made the idempotency key stable (`{ref}-dep`, no time suffix).
- **epictravelexpeditions.com `api/config.php`** — DB credentials, JWT secret, admin password hash, and mail API key sat in plaintext inside the webroot (protected only by an `.htaccess` rewrite rule, unlike every sibling site where secrets already live outside `public_html`). Relocated to `/home/epictravelexpeditions.com/api-secrets.php` (was already gitignored, so no git history exposure). `.git` itself was already correctly relocated to `git-data/` (just a 49-byte pointer file in the webroot, blocked by `.htaccess`) — no action needed there.
**Low (fixed):**
- **tomtomgames.com `api/purchase.php`** — `logActivity()` referenced undefined `$paymentMethod`/`$amountDollars` (should be `$method`/`$priceCents`) in two places, producing PHP warnings and blank values in the purchase audit log for every transaction. Fixed.
- **epictravelexpeditions.com `api/api/testimonials.php`** — the public image-upload endpoint (no login required, by design) had no rate limiting, allowing storage/bandwidth abuse via scripted repeat uploads. Added a `upload_rate_limits` table + per-IP cap (5 uploads/hour).
---
## 7. JARVIS AI SYSTEM
**URL:** http://jarvis.orbishosting.com
**Files:** `/var/www/jarvis/` on JARVIS VM (PVE1 VM 211 — 10.48.200.211, 8 cores, 16GB RAM)
**DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
**Login:** `myron / Joker1974!!!`
**Admin portal:** http://jarvis.orbishosting.com/admin
### Security hardening (2026-07-06 code review)
Full front-end + admin panel review found and fixed 8 issues, 2 of them live critical exposures:
- **INFRASTRUCTURE-REFERENCE.md was publicly downloadable with zero auth** (static nginx path bypassed the admin session check entirely). Fixed: file moved to `/var/www/jarvis-private/INFRASTRUCTURE-REFERENCE.md` (owned `www-data:www-data`, mode 640, NOT under `public_html` so nginx never serves it directly), and the DOCS tab now downloads it via a new authenticated `docs_download` action in `admin/index.php` that gates on the existing `loggedIn()` check and streams the file with `readfile()`.
- **Two full backup copies of the 5000-line admin panel (`index.php.bak2`, `index.php.bak.<date>`) were sitting in `public_html/admin/` and downloadable with no auth**, exposing the entire admin source/attack surface. Relocated to `/root/jarvis-old-backups/` (no secrets were found in them, so no credential rotation was needed).
- `esc()` (the admin panel's JS HTML-escaper) doesn't escape `'`, so it doesn't protect values embedded inside a single-quoted JS string within an `onclick` attribute — HTML-decoding happens before the JS parser sees it. Added a proper `escJs()` helper (backslash + quote + newline escaping, then HTML-escape) and applied it to the Network/Alerts/Intents/Custom-News/Calendar-Feeds edit-modal `onclick` handlers, which were reachable by e.g. any device on the LAN setting a malicious DHCP/mDNS hostname.
- Same class of bug on the front-end dashboard (`assets/js/jarvis-app.js`, `assets/js/panels/jarvis-agents.js`): device names and news article titles/sources were inserted into `innerHTML` completely unescaped — a rogue LAN device or a malicious/compromised news feed could inject script that runs with the logged-in session. Added `escHtml()`/`escJs()` helpers directly in `jarvis-app.js` and applied them to device names, VM names, agent hostnames, and news content.
- `session.cookie_httponly` was **Off** server-wide (PHP default), meaning the actual session cookie — not just the app's own bearer token — was readable via `document.cookie` from any of the above XSS bugs. Fixed at the PHP-FPM level (`/etc/php/8.3/fpm/php.ini`): `session.cookie_httponly = 1`, `session.cookie_samesite = Lax`, `php8.3-fpm` restarted. Verified live: `Set-Cookie` now includes `HttpOnly; SameSite=Lax`.
- `api.php` had `Access-Control-Allow-Origin: *` — tightened to an explicit allow-list of the real JARVIS origin only, with `Access-Control-Allow-Credentials: true` only sent when the origin matches.
- Two admin actions (Arc Reactor restart/setup) called an undefined function `k()` instead of the real JSON responder `j()`, causing a PHP fatal error even though the underlying `systemctl` command still fired. Fixed (verified via direct API test — clean `{"ok":true,...}` response now).
- Calendar feed passwords were stored and returned in plaintext via `cal_feeds_list`'s `SELECT *`. Changed to return a `has_password` boolean instead of the raw password (the edit UI never actually displayed the password back anyway — write-only field, "leave blank to keep").
All fixes verified via direct API testing (SSH + curl through the login/action flow) since this doesn't have a staging environment. Pushed to `myronblair/jarvis` master, commit `24bc876`.
### Functional bugs fixed (2026-07-06)
- **"WEB HOST" card on the front dashboard always showed `--%`/offline.** Root cause: the DO server (165.22.1.228) never had the JARVIS monitoring agent installed — every other host in the fleet had one, this one didn't. Installed it with `curl -sk http://10.48.200.211/install-agent.sh | bash -s jarvis-do linux` (hostname arg `jarvis-do` + the DO server's actual machine hostname `orbis` produces the expected `agent_id=jarvis-do_orbis` that `do_server.php` queries for). Also found and fixed two secondary issues hit along the way: the agent's config pointed at a **dead/stale Tailscale peer** (`jarvis-211`, 100.77.178.42, offline 9+ days) instead of the current active one (`jarvis-211-1`, 100.78.153.71) — likely left over from a VM Tailscale re-auth at some point; and a **stale cached API key** in `/var/lib/jarvis-agent/state.json` from a registration attempt that never actually completed server-side, which had to be deleted to force a clean re-registration. Verified live: `do_server` field in `/api/do` now returns real `cpu`/`mem`/`disk`/`online:true` instead of an empty array.
- **"WEBSITES" list (part of the same JARVIS SERVER panel) was always empty**, and the KB intent generator's 4-hour "don't run again too soon" guard was potentially never actually throttling correctly. Root cause, found while investigating the above: `api/config.php` sets `date_default_timezone_set('America/Chicago')`, and several places compute "how old is this DB timestamp" via PHP's `time() - strtotime($mysqlDatetimeString)`. Since MySQL's `NOW()`/stored datetimes are naive UTC strings, `strtotime()` under a non-UTC default timezone misinterprets them as being in Chicago time, which throws every such comparison off by the UTC offset (5-6 hours) — in this case making `facts_collector.php`'s freshness gate for the `sites` (and incidentally `proxmox`/`ollama`) categories always look artificially fresh, so the site-health checks that populate the WEBSITES list stopped actually running. Fixed in `facts_collector.php`'s `$fresh()` helper and `kb_intent_generator.php`'s run-guard by moving the elapsed-time comparison entirely into SQL (`updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)`), which sidesteps PHP timezone handling altogether. Also fixed a leftover cosmetic label (`do_server.php`) still showing `jarvis.orbishosting.com:1972` from before the JARVIS port fix.
- **Note for future work**: the `time() - strtotime($dbTimestamp)` anti-pattern appears in a couple of other files (`chat.php`, `email.php`, `planner.php`) but only for *display formatting* of dates, not elapsed-time threshold checks — lower priority, not fixed in this pass, but worth a look if any displayed timestamps look off by a few hours.
### Architecture (end-to-end)
```
Voice (browser mic)
→ SpeechRecognition API
→ Wake phrase: "wake up JARVIS" / "daddy's home"
→ "JARVIS [command]" triggers action
→ /api/chat.php (4-tier AI)
Tier 0.7: KB intents / planner (tasks, appointments)
Tier 1: Knowledge Base (MySQL)
Tier 1.5: Ollama (10.48.200.210:11434, llama3.1:8b) — local LLM
Vision: Ollama llava:7b (via Arc Reactor _vision_call cascade)
Tier 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker
```
### Arc Reactor (AI Job Processor)
**Service:** `jarvis-arc` (systemd) — port 7474
**Runtime:** `/opt/jarvis-arc/` (Python venv, `reactor.py`)
**Log:** `/var/log/jarvis/arc.log`
**Admin button:** Workers → Daemons → `SETUP` (live popup) / `RESTART`
**Vision:** Claude → Ollama llava:7b → graceful fallback
**Vision config:** `/etc/systemd/system/jarvis-arc.service.d/vision.conf`
```bash
systemctl status jarvis-arc
systemctl restart jarvis-arc
journalctl -u jarvis-arc -f
```
To re-deploy Arc Reactor from source:
Use **Workers → Daemons → SETUP** in JARVIS admin (live log popup shows progress).
### Planner: Tasks / Directives / Missions
Admin UI sections backed by real schema + live API — verified working 2026-07-02 (created/read/deleted a test row in each, end-to-end).
**Tasks** — simple to-do list, stored directly in `jarvis_db`.
- Table: `tasks` (title, notes, category, priority [urgent/high/normal/low], status [pending/in_progress/done/cancelled], due_date, due_time)
- API: `task_list` (GET), `task_save` (POST, **form-encoded**), `task_done` (POST), `task_delete` (POST)
**Directives** — OKR-style goals with key results, stored directly in `jarvis_db`.
- Tables: `directives` (title, description, category, status, priority, target_date) + `directive_key_results` (directive_id, title, current_value, target_value, unit) + `directive_links` (directive_id, link_type, link_id — links a directive to a task/etc.)
- API: `directive_list`, `directive_get`, `directive_save` (POST, **JSON body** via `php://input`, id passed as `?id=` query param on update), `directive_delete`
**Missions** — automation workflows, NOT stored in `jarvis_db` — proxied through **Arc Reactor** (port 7474) which owns the mission state.
- Arc Reactor endpoints used by admin: `GET/POST /missions`, `GET /missions/{id}`, `GET /missions/{id}/runs`, `PUT/POST /missions/{id}`, `DELETE /missions/{id}`, `POST /missions/{id}/run`
- JARVIS-side mirror tables exist (`missions`, `mission_runs`, `mission_steps`) but the admin panel reads/writes live via Arc Reactor's HTTP API, not directly against these tables
- API: `mission_list`, `mission_get`, `mission_runs`, `mission_save` (POST, **JSON body**, id as `?id=` on update), `mission_delete`, `mission_run`, `mission_toggle`
- If Arc Reactor is down, these calls return `{"error":"Arc Reactor unreachable"}` — check `systemctl status jarvis-arc` first
As of 2026-07-02: all three tables are empty (0 rows) — features are fully functional, just unused so far.
### Deploy Pipeline
```
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
→ /tmp/jarvis-deploy-queue.txt → /usr/local/bin/jarvis-deploy.sh (cron 1min)
→ git pull + PHP syntax check → deploy or auto-revert
```
Webhook secret: `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1`
### Agent System
Agents installed on all servers — phone home every 10s (heartbeat) / 30s (metrics).
Registration key: `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518`
Install command: `curl -sk http://10.48.200.211/install-agent.sh | bash -s <hostname> <linux|proxmox>`
### Self-Healing Watchdog
`/usr/local/bin/jarvis-watchdog.sh` — runs every 5 min (root cron on DO)
Restarts: lsws, mysql, redis if down
Restarts offline Proxmox VM agents via `qm guest exec`
### Cron Jobs (DO server)
| Schedule | Script | Purpose |
|----------|--------|---------|
| Every 1 min | `jarvis-deploy.sh` | Process GitHub deploy queue |
| Every 3 min | `facts_collector.php` | Collect agent metrics, KB facts, site health |
| Every 5 min | `stats_cache.php` | Weather, news, Proxmox stats refresh |
| Every 5 min | `jarvis-watchdog.sh` | Self-healing: restart dead services |
---
## 8. PHONE SYSTEM (FUSIONPBX)
### Extensions
| Ext | Name | Phone | IP | SIP Password |
|-----|------|-------|----|-------------|
| 1000 | Myron Blair — Desk | Yealink T48S | 10.48.200.2 | `Xk9mPw3nQv7rLs2t` |
| 1001 | Tommy Ivy — Desk | Yealink T48S | 10.48.200.43 | `Tv8xNm4pWq6rZs3k` |
| 1002 | Myron Blair — WiFi Work | Yealink AX86R | 10.48.200.65 | `yXHaJTwa8rj?$GkrVFQB` |
| 1003 | Kitchen | Yealink T57W | 10.48.200.83 | — |
| 1004 | Master Bedroom | Yealink T57W | 10.48.200.85 | — |
| 1010 | Parker County Slingshot | Virtual (voicemail only) | — | — |
| 1011 | Epic Travel Expeditions | Virtual (voicemail only) | — | — |
| 1012 | Tom's Java Jive | Virtual (voicemail only) | — | — |
| 900 | IVR | — | — | (auto-attendant) |
**Phone SIP Settings (all phones):**
- Server: `134.209.72.226`
- Port: `5080`
- Transport: UDP
**Provisioning URL:** `https://fusion.orbishosting.com/app/provision/`
(Username: `provision-master`, Password: `Joker1974!!!`)
### Call Flow
```
Inbound (+18177645007)
→ SignalWire → FusionPBX:5080 (UDP)
→ signalwire-inbound dialplan (catch-all ^.*$)
→ IVR ext 900 (ivr_menu_16k.wav)
→ Routes to extensions 1000/1001/1002/1003/1004
Outbound
→ Phone → FusionPBX:5080
→ signalwire gateway → SignalWire → PSTN
```
### FreeSWITCH CLI Commands
```bash
fs_cli -x "sofia status profile external reg" # check registrations
fs_cli -x "sofia xmlstatus gateway" # check SignalWire gateway
fs_cli -x "reloadxml" # reload config (safe)
fs_cli -x "reloadacl" # reload ACL (safe)
# AVOID: sofia profile external restart (drops all phone registrations)
```
---
## 9. NETWORKING & VPN
### FortiGate Firewall
- WAN IP: 97.154.109.245 (dynamic)
- DDNS: `orbisne.fortiddns.com` (FortiGate auto-updates on IP change)
- Blocks: outbound port 53 (DNS) — MediaStack uses PVE1 dnsmasq (10.48.200.90) as resolver → 100.100.100.100
- **Upstream DNS (changed 2026-07-05):** Network → DNS set to "Specify" mode — Primary `1.1.1.1` (Cloudflare), Secondary `8.8.4.4` (Google). Previously defaulted to the router itself (`10.48.200.1`)/ISP-provided servers. Admin: `https://10.48.200.1:9443``admin / Joker1974!!!`. Note: `8.8.8.8` specifically showed as "Unreachable" during setup (transient — ISP's own DNS servers were also showing high latency at that moment); `1.1.1.1`/`8.8.4.4` tested healthy and are what's live now.
**Port Forwards:**
| External Port | Internal Destination | Purpose |
|--------------|---------------------|---------|
| :8006 | PVE1:8006 | Proxmox web UI |
| :8123 | HA VM:8123 | Home Assistant |
| :22 | HA VM:22 | HA SSH (unreliable) |
### WireGuard — Jellyfin ↔ MediaStack
- MediaStack runs WireGuard server on `wg1` (port 51820, subnet 10.200.0.1/24)
- Jellyfin peer: 10.200.0.3 (active handshake)
- Used for NFS media file access ONLY — not internet VPN
### NordVPN — MediaStack Internet Traffic
- Interface: `nordlynx` on MediaStack
- Exit: rotating US (Dallas) NordVPN servers, not a fixed IP
- Policy routing: table 205 (non-LAN traffic via nordlynx), managed partly by NordVPN itself and partly by a custom `nordvpn-routing.service` unit (`/etc/systemd/system/nordvpn-routing.service`) that adds the `ip rule` for fwmark `0xe1f1` → table 205
- Required for IPTorrents access (blocks non-VPN IPs)
**2026-07-06 incident (fixed):** `nordvpnd` had been crash-looping since ~2026-06-07 (`/var/lib/nordvpn/data/settings.dat` was corrupted/empty) — meaning NordVPN was **not actually protecting MediaStack's traffic for about a month**; downloads were exiting on the plain home IP. Fixed by clearing the corrupt file, restarting the daemon, and re-logging in.
While fixing this, found NordVPN's own **"LAN Discovery" setting was `disabled`**. With `Routing: enabled` and LAN Discovery off, connecting NordVPN's full-tunnel routing suppresses the main routing table's resolution for anything that would exit via `eth0`**including same-subnet LAN traffic** — so the entire VM became unreachable (SSH/ping) from the rest of the LAN the moment NordVPN connected. Fixed with `nordvpn set lan-discovery on`, which adds explicit priority rules for private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`) ahead of the tunnel catch-all. Verified LAN reachability holds with Kill Switch + Firewall both re-enabled after this fix.
**If MediaStack becomes unreachable after a NordVPN reconnect again:** check `nordvpn settings` for `LAN Discovery: enabled` first. If SSH is already dead, the network path itself is unusable — recover via the Proxmox host instead: `qm guest exec 103 -- /bin/bash -c 'export HOME=/root; nordvpn ...'` (guest-exec needs `HOME` set manually or the `nordvpn` CLI fails).
Current confirmed-good settings (2026-07-06): Firewall `enabled`, Kill Switch `enabled`, Auto-connect `enabled`, LAN Discovery `enabled`, Routing `enabled`.
---
## 10. BACKUP SYSTEMS
### JARVIS Database Backup
- **Script:** `/usr/local/bin/jarvis-backup.sh` (also at `/var/www/jarvis/deploy/`)
- **Output:** `/var/backups/jarvis/jarvis_backup_TIMESTAMP.tar.gz`
- **Log:** `/var/backups/jarvis/backup.log`
- **Retention:** 7 days (auto-purge)
- **Trigger:** JARVIS admin → Backups → RUN BACKUP NOW, or run script directly
- **DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
### DO Server Backup
- **Repo:** `myronblair/do-server-config`
- **Schedule:** Weekly, Sunday 4am
- **Launcher:** `/usr/local/bin/do-server-backup` on DO
- **Covers:** Scripts, systemd units, WireGuard, OLS vhosts, cron, MySQL credentials
- **Restore:** 8-phase wizard in `restore.sh`
- **DB backups:** `jarvis-backup.sh` runs daily (separate)
### Proxmox Config Backup
- **Repo:** `myronblair/proxmox-config`
- **Schedule:** Weekly, Sunday 3am (both PVE1 and PVE2)
- **Launcher:** `/usr/local/bin/proxmox-backup` on each node
- **Covers:** VM .conf files, network, cron, systemd, scripts
- **VM disks:** Covered by Proxmox Backup Server (PBS)
### FusionPBX Backup
- **Repo:** `myronblair/fusionpbx-config`
- **Schedule:** Weekly, Sunday 5am
- **Launcher:** `/usr/local/bin/fusionpbx-backup`
- **Covers:** PostgreSQL dump (gzip, ~29-60MB) + FreeSWITCH configs
- **Restore:** 10-phase wizard in `restore.sh`
- **Known issue (found 2026-07-05):** this repo has grown to ~166MB on GitHub / ~196MB on Gitea because the DB dump gets committed directly into git history on every backup run (3 copies in history as of this writing, each ~60MB) rather than being excluded/rotated. Options not yet decided: gitignore the dump going forward, or purge it from history with `git filter-repo` + force-push (destructive, needs explicit sign-off).
### MSP360 Backup Status (Dashboard Integration)
- **Client software:** MSP360 (CloudBerry) Backup CLI installed on all 6 hosts — PVE1, JARVIS (211), NovaCPX (110), Jellyfin (33), MediaStack (35), Homebridge (18)
- **Storage target:** `NAS-MSPBackups` destination → Synology NAS CIFS share, mounted at `/mnt/nas-backups/MSPBackups`
- **Mount reliability:** `/usr/local/bin/msp360-mount-ensure.sh` (cron `*/15 * * * *` on hosts using the NAS mount) — bind-mounts the MSPBackups subdir onto itself since MSP360's pre-flight `mountpoint` check fails on a subdirectory of a CIFS mount otherwise
- **Collector:** `/usr/local/bin/backup-status-collect.sh` on PVE1 (runs via key-trusted root SSH — PVE1 is the only host with passwordless SSH to all 6 targets; other hosts use password auth via `sshpass`)
- Queries each host's plan via `cbb plan -l` (legacy v1 CLI — outputs `State:` / `Last result:` fields directly, unlike `cbbV2`/`cbbCommandLineV2` which needs `plan list -b` and different parsing)
- Writes `/tmp/backup-status.json`, then `scp`s it to `root@10.48.200.110:/home/webacct/public_html/downloads/backup-status.json`
- **Schedule:** daily `0 6 * * *` on PVE1 (`>> /var/log/backup-status-collect.log`)
- **Dashboard card:** `web.orbishosting.com` "BACKUP STATUS" card (`index.html`) fetches `/downloads/backup-status.json` client-side (`loadBackupStatus()`), color-codes dots by `result` (green=Success, yellow=Warning, red=Fail, cyan=Running, gray=unknown)
- **JSON schema:** `{"updated": "<ISO8601 UTC>", "hosts": [{"name","ip","state","result"}, ...]}`
- **Homebridge (2026-07-04): dropped MSP360 entirely.** After extensive troubleshooting (RAM starvation, a bug where its account scanned every other host's shared backup data, missing bind-mount depths, CIFS tuning, a full plan recreation) Homebridge's MSP360 agent kept failing with a false "storage drive not mounted" error at a consistent ~60-75s mark, root cause never conclusively identified (survived every environmental fix, looked like an app-level bug tied to any custom/non-default account path). Since Homebridge (VM 118) was already being backed up successfully every night by the cluster-wide Proxmox vzdump job (`backup-aa6b1890-23c0`, all VMs, 21:00 daily, keep-last=3, to `SynologyProx` storage), MSP360 was stopped/disabled on Homebridge (`systemctl disable msp360-backup.service msp360-backupWA.service`) and removed from the dashboard collector's per-host MSP360 check. The collector now reads Homebridge's status directly from `/mnt/pve/SynologyProx/dump/vzdump-qemu-118-*.vma.zst` on PVE1 instead of querying an in-guest agent.
- **Known state (2026-07-04):** 4/5 remaining MSP360 hosts report `Warning`, NovaCPX reports `Fail` — plan-level result, not investigated further; worth checking each host's MSP360 GUI/log for root cause if backups need to be trusted for restore. Homebridge reports `Success` via Proxmox.
---
## 11. SSH QUICK REFERENCE
```bash
# DO (main web server)
sshpass -p 'Gonewalk1974!@#' ssh -o StrictHostKeyChecking=no root@165.22.1.228
# FusionPBX (must relay via DO)
sshpass -p 'Gonewalk1974!@#' ssh root@165.22.1.228 \
'sshpass -p "Joker1974!@#" ssh root@134.209.72.226 "CMD"'
# PVE1 (direct or via DDNS)
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@orbisne.fortiddns.com
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90
# PVE2
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.91
# MediaStack (via PVE1)
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
'ssh -i /root/.ssh/id_rsa root@10.48.200.35 "CMD"'
# Jellyfin (direct, password enabled 2026-06-14)
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.33
# NovaCPX (direct)
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.110
# Ollama / Homebridge / NetworkBackup (myron user, then sudo)
sshpass -p 'Joker1974!' ssh myron@10.48.200.210 # Ollama (NOT .95 - that's a naming leftover, nothing listens there)
sshpass -p 'Joker1974!' ssh myron@10.48.200.18 # Homebridge
sshpass -p 'Joker1974!' ssh myron@10.48.200.99 # NetworkBackup
# Run command inside VM via Proxmox (requires QEMU agent installed; use VMID not IP)
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
'qm guest exec 106 -- bash -c "export HOME=/root; CMD"' # Ollama = VMID 106
# Other VMIDs: Jellyfin=112, MediaStack=103, JARVIS=211, NovaCPX=120, HomeAssistant=101, Homebridge=118
```
**Password fallback order:** `Joker1974!@#``Joker1974!!!``Joker1974!`
---
## 12. CRITICAL CREDENTIALS MASTER LIST
### SSH / Root Access
| System | User | Password | Notes |
|--------|------|----------|-------|
| DO (165.22.1.228) | root | `Gonewalk1974!@#` | Main web server |
| FusionPBX (134.209.72.226) | root | `Joker1974!@#` | Via DO relay |
| PVE1 (10.48.200.90) | root | `Joker1974!!!` | Also via DDNS |
| PVE2 (10.48.200.91) | root | `Joker1974!!!` | |
| MediaStack (10.48.200.35) | root | key only | Via PVE1 (`/root/.ssh/id_rsa`) |
| Jellyfin (10.48.200.33) | root | `Joker1974!!!` | Enabled 2026-06-14 |
| NovaCPX (10.48.200.110) | root | `Joker1974!!!` | Direct SSH works |
| Ollama / Homebridge / Backup VMs | myron | `Joker1974!` | Then sudo |
### Web Panels & Admin
| System | URL | User | Password |
|--------|-----|------|----------|
| CyberPanel | https://165.22.1.228:8090 | myron | `Joker1974!!!` |
| phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` |
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
| JARVIS | http://jarvis.orbishosting.com | myron | `Joker1974!!!` |
| JARVIS Admin | http://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
| ChuckCo Time Keeper Admin | https://worktracking.orbishosting.com/admin/login.php | — | `Joker1974!!!` |
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
| Jellyfin | http://10.48.200.33:8096 | — | token: `635142c218f2457e813a2de8dec75b05` |
| qBittorrent | http://10.48.200.35:8080 | admin | `Joker1974!!!` |
| Sonarr | http://10.48.200.35:8989 | admin | `Joker1974!!!` |
| Radarr | http://10.48.200.35:7878 | admin | `Joker1974!!!` |
| Prowlarr | http://10.48.200.35:9696 | admin | `Joker1974!!!` |
| Synology NAS | http://10.48.200.249:5000 | nas | `Joker1974!!!` |
| Parker Slingshot Admin | https://parkerslingshotrentals.com/admin | admin | `Parker2026!` |
| TJJ Admin | https://tomsjavajive.com/admin | `admin@tomsjavajive.com` OR `myronblair@outlook.com` | `Joker1974!!!` |
### Databases
| Site | DB Name | DB User | DB Password |
|------|---------|---------|-------------|
| JARVIS | `jarvis_db` | `jarvis_user` | `J4rv1s_Pr0t0c0l_2026!` |
| Tom's Java Jive | `toms_tjj_db` | `toms_tjj_user` | `+60wlPc+55e@gFq4` |
| Parker Slingshot Rentals | `park_slingshot` | `park_slingshotuser` | `4@rxg*8kovxCr7w6` |
| Epic Travel | `epic_travel_db` | (see config.php) | (see config.php) |
| Epic/Parker Slingshot | `epic_parkersling` | `epic_parkersling` | `Joker1974!!!` |
| NovaCPX | SQLite: `/var/lib/novacpx/panel.db` | — | — |
| ChuckCo Time Keeper | `workt_track_db` | `workt_track_user` | `ZWCNMRP2N5NVPsghmve5aRS9` |
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
### API Keys
| Service | Key |
|---------|-----|
| GitHub PAT | `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` (rotated 2026-07-05, scopes `repo`+`workflow`) |
| JARVIS Agent Registration | `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518` |
| Proxmox API Token | `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b` |
| HA Long-lived Token | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE` |
| Sonarr API | `b43e04350a594846b4ee95261c29e9e0` |
| Radarr API | `53c4268360444feeae5f98c0cc24e0e3` |
| Prowlarr API | `9d0ce6c5660743b5bf1c7951efc62252` |
| Jellyfin Admin Token | `635142c218f2457e813a2de8dec75b05` |
| Square (Parker) Production | `EAAAl3FsAu_2ri8kZE_ENEyi2T_C8HXXm5XQFY6Lbnd8SX6FqYp8J_upUeXNYh7v` |
| Square App ID (Parker) | `sq0idp-YSM7BU9IVyOWSzpeP-0nzQ` |
| Webhook HMAC Secret | `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1` |
### SIP / Phone
| Extension | Name | SIP Password |
|-----------|------|-------------|
| 1000 | Myron Blair — Desk (10.48.200.2) | `Xk9mPw3nQv7rLs2t` |
| 1001 | Tommy Ivy — Desk (10.48.200.43) | `Tv8xNm4pWq6rZs3k` |
| 1002 | Myron Blair — WiFi Work (10.48.200.65) | `yXHaJTwa8rj?$GkrVFQB` |
| 1003 | Kitchen (10.48.200.83) | — |
| 1004 | Master Bedroom (10.48.200.85) | — |
| 1010 | Parker County Slingshot (voicemail only) | — |
| 1011 | Epic Travel Expeditions (voicemail only) | — |
| 1012 | Tom's Java Jive (voicemail only) | — |
---
## 13. GIT & REPOSITORY MANAGEMENT
**GitHub** (`myronblair`, 26 private repos as of 2026-07-05) is the permanent/source-of-truth storage. **Gitea** (`gitea.orbishosting.com`, hosted on the Synology NAS, login `myron / Joker1974!!!`) mirrors it one-way — GitHub → Gitea only, via pull-mirrors triggered by `POST /api/v1/repos/<owner>/<repo>/mirror-sync`. Never push directly to a Gitea remote.
**5 repos are Gitea-only by design, never pushed to GitHub** (deliberately kept off a third-party cloud service since they hold real credentials): `fortigate-config`, `infra-private`, `jarvis-secrets`, `msp360-config`, `proxmox-secrets`.
**Local clones** live on the admin Windows machine at `C:\Users\myron\repos\` (currently `chucko`, `web-dashboard` — more cloned there as needed). Periodic maintenance: `git gc --aggressive --prune=now` to keep loose objects packed.
**Per-site deploy pattern (the 7 DO-hosted sites + jarvis + web-dashboard):** `.git` metadata is relocated outside the public webroot (e.g. `/home/<site>/git-data` with a `gitdir:` pointer file left in `public_html/.git`) so `.git` itself is never web-accessible, while `git` commands run normally from inside `public_html`. Most sites deploy via a GitHub webhook → queue file → cron puller (~1 min); `chucko` (ChuckCo Time Keeper) currently has no auto-deploy hook — pushes are manual (SSH in, `git pull`/`push` directly).
**Known repo-hygiene issue:** `fusionpbx-config` bloated to ~166-196MB from repeatedly committing a large DB dump straight into history — see Section 10.
---
## 14. NETWORK EQUIPMENT & CLIENT DEVICE INVENTORY
**Compiled 2026-07-06** from a live ARP scan off PVE1 (~90 hosts), MAC-vendor lookups, and direct confirmation from Myron. Built to support a future VLAN segmentation project — see `VLAN-Segmentation-Plan.docx` in the home folder for the full plan; this section is the durable factual record to carry forward (e.g. into JARVIS) independent of that plan's status.
### 14.1 Core Network Equipment
| Device | Model | Role |
|--------|-------|------|
| Firewall | **FortiGate 60F** | Primary/active firewall — confirmed the top unit in the rack. A second Fortinet unit is stacked below it; its role is not yet identified — not confirmed as an HA pair. |
| Primary switch | **Cisco Catalyst 3560-E Series PoE-48** | 48-port, full PoE, enterprise-managed — full 802.1Q VLAN/trunk support |
| Secondary switch | **FortiSwitch 108F-FPOE** | 8-port PoE, FortiLink-managed |
| KVM switch | **TRENDnet TK-802R** | Physical console access to rack servers — not networked |
| WiFi extender 1 | **TP-Link RE305** — 10.48.200.16 | WiFi clients only, no wired devices |
| WiFi extender 2 | **TP-Link RE305** — 10.48.200.89 | WiFi clients only, no wired devices |
| WiFi extender 3 | **TP-Link RE305** — 10.48.200.93 | Wired network printers plugged into its Ethernet port; no WiFi clients on this unit |
| WiFi extender 4 | Brand unconfirmed (Shenzhen Xunman-branded/OEM) — MAC `fc:22:1c:30:60:14` seen at 10.48.200.100/.64 | Wired Pioneer VSX-822 AV receiver plugged into its Ethernet port; no wireless clients of its own — same bridge-mode pattern as extender 3 above |
| Wireless bridge | **Good Story Networks WB610H** — 10.48.200.80 | Links the main house network to the storage shed (a detached building). OEM manufacturer is Shenzhen LiWiFi Technology Co., Ltd (rebranded by Good Story Networks). Plan: once fully deployed, this bridge replaces the need for the RE305 units at .16 and .89 — see the VLAN plan doc for details. VLAN/802.1Q trunk capability not yet confirmed — check before relying on it for segmented WiFi. |
**Note on consumer extenders and VLANs:** budget range extenders like the RE305 (and likely extender 4 above) generally cannot map multiple SSIDs to separate VLANs over a trunk — they repeat one network. This matters if/when wireless VLAN segmentation is implemented; see the VLAN plan doc's "Wireless VLAN Feasibility" section.
### 14.2 Client & Peripheral Device Inventory (by category)
**Printers:**
| IP | Device |
|----|--------|
| 10.48.200.76 | Epson ET-3750 |
| 10.48.200.204 | HP LaserJet 500 Color MFP M570dn (wired to the RE305 at .93) |
| 10.48.200.205 | HP LaserJet M1536dnf MFP (wired to the RE305 at .93) |
| 10.48.200.201, .202 | Unidentified — likely more printers/peripherals on the same RE305 port, given the pattern above (not yet confirmed) |
**Storage:**
| IP | Device |
|----|--------|
| 10.48.200.249 | Synology NAS |
| 10.48.200.41 | WD My Cloud — a second NAS alongside the Synology |
**AV / Entertainment:**
| IP | Device |
|----|--------|
| 10.48.200.42, .72 | Vizio smart TVs |
| 10.48.200.100 | Pioneer VSX-822 AV Receiver — confirmed 2026-07-06, wired into a 4th WiFi range extender/AP (Shenzhen Xunman-branded/OEM, MAC `fc:22:1c:30:60:14`, no wireless clients of its own). This explains the earlier MAC discrepancy cleanly: the extender reports its own MAC in ARP for the wired Pioneer behind it, same bridge-mode pattern seen with the RE305 at `.93` and its wired printers — the Pioneer's own labeled MAC (`74:5E:1C:0E:7C:0B`, genuine Pioneer Corporation OUI) simply never appears on the wire. 10.48.200.64 shares this same extender's MAC — likely the same device (Pioneer or the extender itself) at a prior IP, not a separate device. |
**Smart home / IoT:**
| IP | Device |
|----|--------|
| 10.48.200.38 | Samsung SmartThings hub (MAC vendor: Physical Graph Corporation, the original SmartThings company) |
| 10.48.200.250, .251 | Goalake Smart Switch 1 and 2 |
| 10.48.200.5, .7, .8, .9, .36, .61, .74 | Generic ESP32/ESP8266-based smart plugs/sensors (Espressif chipset) |
| 10.48.200.14, .60 | Tuya Smart plugs/switches |
| 10.48.200.34 | Bouffalo Lab-chipset IoT device |
| 10.48.200.116 | FN-LINK-chipset IoT device |
| 10.48.200.6, .10, .15, .23, .24, .27, .28, .30, .31, .32, .37, .62, .82, .86, .87, .105 | TP-Link Tapo smart devices (16 total) |
| 10.48.200.17, .39, .40, .46, .53, .68, .71, .106, .115, .118 | Amazon devices (Echo/Fire TV/Kindle) |
**Security cameras:**
| IP | Device |
|----|--------|
| 10.48.200.57, .78, .94, .95, .101, .103, .104 | Reolink cameras + NVR (7 addresses total — one of these is the NVR itself, not confirmed which). Cameras are PoE-connected directly to the Catalyst 3560-E, not cabled to the NVR; the NVR pulls streams over the network like any other client. Feeds are viewed both locally on the LAN and remotely via the Reolink app. |
| 10.48.200.21, .22 | Ring doorbell/camera — also viewed both locally and via the Ring app remotely |
**VoIP (Yealink) — see Section 8 for extension details:**
`.2, .3, .43, .65, .83, .85`
**Personal computers / hobby devices:**
| IP | Device |
|----|--------|
| 10.48.200.54 | Apple device (iPhone/iPad/Mac) |
| 10.48.200.66 | Dell PC |
| 10.48.200.52 | Intel-NIC PC |
| 10.48.200.45 | Microsoft device (Surface or Xbox — not yet confirmed which) |
| 10.48.200.50 | Raspberry Pi 5 — hobby/tinkering only, no production workload |
**Still unidentified as of 2026-07-06** (MAC vendor lookup only, no direct confirmation yet):
| IP | Vendor signature | Notes |
|----|----|----|
| 10.48.200.13 | Murata Manufacturing | Embedded WiFi module — device unknown |
| 10.48.200.59 | Guangzhou Shiyuan Electronic | Often AV/display equipment — device unknown |
| 10.48.200.119 | Macherey-Nagel GmbH & Co. KG | A lab-equipment brand (chromatography/filtration) — unusual on a home network, device unknown |
| 10.48.200.77 | Liteon Technology | Could be a PC PSU with network mgmt, or a peripheral — device unknown |
| 10.48.200.201, .202 | (shares MAC with the RE305 at .93 and the confirmed printers at .204/.205) | Likely more printers/peripherals, not confirmed |
**Minor known artifact, low priority:** MAC `7a:e1:7e:44:08:29` is shared across 10.48.200.93 (RE305), .204/.205 (confirmed printers, benign RE305 bridge-mode behavior), and .251 (Goalake Smart Switch 2 — does NOT fit the "wired to the RE305" explanation, so this one specific overlap looks like a genuine duplicate/cloned MAC, common in ultra-budget IoT hardware). Worth a quick check of the Goalake switch's real MAC via its own admin UI at some point; not urgent.
---
*This document contains sensitive credentials. Store securely and do not share.*
+5475 -5303
View File
File diff suppressed because it is too large Load Diff
+128 -128
View File
@@ -1,128 +1,128 @@
<?php
/**
* JARVIS API Router — fault-isolated per endpoint
* A ParseError or fatal in any endpoint file returns JSON 500 for that
* endpoint only; all other endpoints continue to work normally.
*/
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../api/lib/db.php';
require_once __DIR__ . '/../api/lib/kb_engine.php';
// Skip session for machine-agent calls and netscan/ping — each heartbeat would
// otherwise create an empty session file, producing millions of files that slow
// session GC for all requests. Browser-facing agent sub-actions (list/status/myip)
// still need a session to verify auth, so we only skip for machine-agent actions.
$_earlyParts = explode('/', trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'));
if (($_earlyParts[0] ?? '') === 'api') array_shift($_earlyParts);
$_e0 = $_earlyParts[0] ?? '';
$_e1 = $_earlyParts[1] ?? '';
$_skipSession = match(true) {
$_e0 === 'ping' => true,
$_e0 === 'netscan' => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true,
default => false,
};
if (!$_skipSession) {
session_start();
}
header('Content-Type: application/json');
$_allowedOrigins = ['https://jarvis.orbishosting.com', 'http://jarvis.orbishosting.com'];
$_origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($_origin, $_allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $_origin);
header('Access-Control-Allow-Credentials: true');
}
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);
if (($parts[0] ?? '') === 'api') array_shift($parts);
$endpoint = $parts[0] ?? '';
$action = $parts[1] ?? '';
// ── Auth check (skip for auth / agent / netscan) ──────────────────────
if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) {
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
$isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? '');
if (!$isValid) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$isLocal = \in_array($ip, ['127.0.0.1', '::1', JARVIS_IP], true);
if (!$isLocal && $endpoint !== 'ping') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized', 'code' => 401]);
exit;
}
}
}
if ($endpoint !== 'auth') session_write_close();
$body = file_get_contents('php://input');
$data = json_decode($body, true) ?? [];
// ── Fast ping (no file dispatch needed) ──────────────────────────────
if ($endpoint === 'ping') {
echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]);
exit;
}
// ── Endpoint → file map ───────────────────────────────────────────────
$endpoints = [
'auth' => 'auth.php',
'chat' => 'chat.php',
'system' => 'system.php',
'netscan' => 'netscan.php',
'network' => 'network.php',
'proxmox' => 'proxmox.php',
'ha' => 'ha.php',
'tts' => 'tts.php',
'email' => 'email.php',
'do' => 'do_server.php',
'alerts' => 'alerts.php',
'facts' => 'facts_collector.php',
'weather' => 'weather.php',
'news' => 'news.php',
'sites' => 'sites.php',
'agent' => 'agent.php',
'planner' => 'planner.php',
'jellyfin' => 'jellyfin.php',
'history' => 'history.php',
'metrics' => 'metrics.php',
'suggestions' => 'suggestions.php',
'arc' => 'arc.php',
'directives' => 'directives.php',
'memory' => 'memory.php',
'calendar' => 'calendar_sync.php',
];
if (!isset($endpoints[$endpoint])) {
http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
exit;
}
$file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint];
// ── Fault-isolated dispatch ───────────────────────────────────────────
// ob_start() buffers any partial output so a mid-execution fatal doesn't
// send a broken response. catch(Throwable) catches ParseError, TypeError,
// and all other Errors + Exceptions in PHP 7+.
ob_start();
try {
require $file;
ob_end_flush();
} catch (\Throwable $e) {
ob_end_clean();
http_response_code(500);
echo json_encode(['error' => 'Endpoint unavailable', 'endpoint' => $endpoint, 'code' => 500]);
error_log(sprintf('JARVIS API [%s] %s: %s in %s:%d',
$endpoint, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()
));
}
<?php
/**
* JARVIS API Router — fault-isolated per endpoint
* A ParseError or fatal in any endpoint file returns JSON 500 for that
* endpoint only; all other endpoints continue to work normally.
*/
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../api/lib/db.php';
require_once __DIR__ . '/../api/lib/kb_engine.php';
// Skip session for machine-agent calls and netscan/ping — each heartbeat would
// otherwise create an empty session file, producing millions of files that slow
// session GC for all requests. Browser-facing agent sub-actions (list/status/myip)
// still need a session to verify auth, so we only skip for machine-agent actions.
$_earlyParts = explode('/', trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'));
if (($_earlyParts[0] ?? '') === 'api') array_shift($_earlyParts);
$_e0 = $_earlyParts[0] ?? '';
$_e1 = $_earlyParts[1] ?? '';
$_skipSession = match(true) {
$_e0 === 'ping' => true,
$_e0 === 'netscan' => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true,
default => false,
};
if (!$_skipSession) {
session_start();
}
header('Content-Type: application/json');
$_allowedOrigins = ['https://jarvis.orbishosting.com', 'http://jarvis.orbishosting.com'];
$_origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($_origin, $_allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $_origin);
header('Access-Control-Allow-Credentials: true');
}
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);
if (($parts[0] ?? '') === 'api') array_shift($parts);
$endpoint = $parts[0] ?? '';
$action = $parts[1] ?? '';
// ── Auth check (skip for auth / agent / netscan) ──────────────────────
if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) {
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
$isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? '');
if (!$isValid) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$isLocal = \in_array($ip, ['127.0.0.1', '::1', JARVIS_IP], true);
if (!$isLocal && $endpoint !== 'ping') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized', 'code' => 401]);
exit;
}
}
}
if ($endpoint !== 'auth') session_write_close();
$body = file_get_contents('php://input');
$data = json_decode($body, true) ?? [];
// ── Fast ping (no file dispatch needed) ──────────────────────────────
if ($endpoint === 'ping') {
echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]);
exit;
}
// ── Endpoint → file map ───────────────────────────────────────────────
$endpoints = [
'auth' => 'auth.php',
'chat' => 'chat.php',
'system' => 'system.php',
'netscan' => 'netscan.php',
'network' => 'network.php',
'proxmox' => 'proxmox.php',
'ha' => 'ha.php',
'tts' => 'tts.php',
'email' => 'email.php',
'do' => 'do_server.php',
'alerts' => 'alerts.php',
'facts' => 'facts_collector.php',
'weather' => 'weather.php',
'news' => 'news.php',
'sites' => 'sites.php',
'agent' => 'agent.php',
'planner' => 'planner.php',
'jellyfin' => 'jellyfin.php',
'history' => 'history.php',
'metrics' => 'metrics.php',
'suggestions' => 'suggestions.php',
'arc' => 'arc.php',
'directives' => 'directives.php',
'memory' => 'memory.php',
'calendar' => 'calendar_sync.php',
];
if (!isset($endpoints[$endpoint])) {
http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
exit;
}
$file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint];
// ── Fault-isolated dispatch ───────────────────────────────────────────
// ob_start() buffers any partial output so a mid-execution fatal doesn't
// send a broken response. catch(Throwable) catches ParseError, TypeError,
// and all other Errors + Exceptions in PHP 7+.
ob_start();
try {
require $file;
ob_end_flush();
} catch (\Throwable $e) {
ob_end_clean();
http_response_code(500);
echo json_encode(['error' => 'Endpoint unavailable', 'endpoint' => $endpoint, 'code' => 500]);
error_log(sprintf('JARVIS API [%s] %s: %s in %s:%d',
$endpoint, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()
));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -14,7 +14,7 @@ if (!defined('WEBHOOK_SECRET')) {
exit;
}
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
define('DEPLOY_LOG', '/var/www/jarvis/logs/deploy.log');
define('DEPLOY_LOG', '/var/log/jarvis/deploy.log');
header('Content-Type: application/json');
@@ -33,9 +33,10 @@ $repo = $data['repository']['name'] ?? '';
$ref = $data['ref'] ?? '';
$pusher = $data['pusher']['name'] ?? 'unknown';
// Only deploy on pushes to main
if ($ref !== 'refs/heads/main') {
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not main"]);
// Only deploy on pushes to the repo's actual default branch (master, not main
// this was checking 'main' for a while even though the jarvis repo has always used 'master')
if ($ref !== 'refs/heads/master') {
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not master"]);
exit;
}