Compare commits

31 Commits

Author SHA1 Message Date
root f8a095f783 Fix Works-tab last-run detection bugs, add generic live-log popup for Facts/Stats/Calendar workers, fix webhook branch check (master not main) and log path 2026-07-07 07:58:55 -05:00
root e060ff0c63 Merge origin/master history (ours: live production content is authoritative, origin was stale) 2026-07-07 07:55:58 -05:00
root 588cfe3f10 Adopt live production state as git baseline 2026-07-07 07:55:47 -05:00
root 3db329e925 Correct Pioneer VSX-822: wired through a 4th WiFi extender, not dual-NIC 2026-07-06 23:41:34 -05:00
root c01805316a Correct Pioneer VSX-822 attribution: confirmed at .100, note dual-MAC discrepancy 2026-07-06 23:38:34 -05:00
root 20b510186a Add network equipment and client device inventory (Section 14) from live ARP scan + manual confirmation 2026-07-06 23:34:31 -05:00
root 9b5e16660a Jellyfin: regenerate stale admin API token, document real admin username 2026-07-06 14:29:43 -05:00
root 7020d445b3 Update infra doc: MediaStack/Jellyfin verified detail, NordVPN LAN Discovery + wg0 lockout fix, VMID corrections 2026-07-06 13:09:02 -05:00
myron 80b0dfc583 Document code review + fixes across all 4 DO-hosted business sites (2026-07-06) 2026-07-06 02:58:28 -05:00
myron 30e2bc093c Document WEB HOST agent install and timezone freshness-check fix 2026-07-05 21:56:23 -05:00
myron a29670e93d Fix WEB HOST stats never populating (DO server had no monitoring agent installed) and a timezone bug in freshness checks (America/Chicago default timezone + strtotime() on naive UTC MySQL timestamps caused sites/proxmox/ollama facts to never refresh, and could have suppressed the KB intent generator's scheduled runs) 2026-07-05 21:30:53 -05:00
myron 10350cbcd8 Update infrastructure reference: document 2026-07-06 security review and fixes 2026-07-05 20:13:21 -05:00
myron 24bc876c1d Fix code review findings: unauthenticated infra doc + backup file exposure, onclick-attribute XSS breakouts (admin panel and front-end), plaintext calendar passwords, k()->j() typo, wildcard CORS, session cookie hardening (HttpOnly/SameSite) 2026-07-05 20:09:52 -05:00
myron cfb5b2a3f9 Document ChuckCo Time Keeper code review fixes and admin login rate limiting 2026-07-05 19:17:14 -05:00
myron 0b1a19d9de Update infrastructure reference: fix stale JARVIS port, rotate GitHub PAT reference, add ChuckCo Time Keeper site, FortiGate DNS change, git/repo management section 2026-07-05 18:58:56 -05:00
myron 66a5443f22 Document Homebridge backup switch from MSP360 to Proxmox vzdump 2026-07-04 12:21:53 -05:00
myron e5536b077c Document MSP360 backup status dashboard integration 2026-07-03 08:03:54 -05:00
myron a292411d52 Stop hardcoding a generic watch_services list on every agent install
The installer wrote the same service list (ollama, homeassistant, mysql,
mariadb, nginx, apache2, docker) onto every host regardless of what it
actually runs, causing false "Service Down" alerts on hosts that never
installed those services. Default is now empty; watch_services should be
set per-host to match what's actually running there.
2026-07-02 20:28:42 -05:00
myron 26b501b600 fix: address 8 code-review findings in KB topic manager
- Fix 1 (ORDER BY): use table alias t.id to force integer PK ordering;
  topic_id alias was causing alphabetical sort, breaking rotation offsets
- Fix 2 (PDOException): wrap INSERT/UPDATE in try/catch in kb_topic_save;
  duplicate topic_id now returns clean JSON error instead of HTTP 500
- Fix 3 (XSS/onclick): DEL button passes only t.id; tmDelete looks up
  name from _topicsData, removing JSON.stringify from onclick attribute
- Fix 4 (validation): topic_id must contain at least one [a-z0-9] after
  sanitization; "@@@" to "___" no longer passes required-field check
- Fix 5 (stale logs): guard comment and log messages updated from 20h to
  4h to match the actual 14400s threshold
- Fix 6 (dedup): tmEsc() replaced with existing esc() throughout; removed
  duplicate function definition
- Fix 7 (toggle rollback): tmToggle uses inline fetch to revert el.checked
  on API failure instead of leaving the UI in wrong state
- Fix 8 (confirm): tmDelete uses openModal confirmation instead of
  confirm() dialog, consistent with project live-popup convention

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 10:26:47 -05:00
myron 7327104612 feat: DB-driven KB topic manager — admin UI + migration
- Create kb_generator_topics table (140 topics migrated from hardcoded array)
- kb_intent_generator.php now loads active topics from DB instead of $BATCHES array
- Admin panel: MANAGE TOPICS button opens full topic manager modal
  - Browse all 140 topics with search, category, and active/disabled filters
  - Inline active toggle per topic (checkbox)
  - Add new topic form (topic_id, category, name, description)
  - Edit existing topics (all fields)
  - Delete topics with confirmation
  - Run count display
- Backend API cases: kb_topics, kb_topic_save, kb_topic_delete, kb_topic_toggle
- Topics can now be added/managed without any code changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 07:04:20 -05:00
myron a13f750846 feat(kb-intent): expand to 140 topics, 109 KB topic library
Rich topic library across mathematics (12), sciences (13), history (10),
government/economics, literature, life skills (13), technology (5),
Texas/local (4), national US (3), world affairs (5), human sexuality (2),
astronomy (3), space (2), culture/arts (8), sports (8), food/drink (4),
home/DIY (3), wellness (4), tech continued (3), national continued (2),
world continued (2), medicine (3), math continued (2), communication (2).

Each topic has detailed multi-subtopic descriptions (~200 chars each)
vs prior 8-word descriptions — significantly richer Groq prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 06:50:12 -05:00
myron 3c8dd8e206 feat(kb-intent): 122-topic rotation engine, every-6h cron
- Expanded $BATCHES from 25 to 122 topics across: life skills, personal
  finance, Texas/local, US national, world geopolitics, human sexuality
  (educational), deep astronomy (15 topics), and space exploration (15 topics)
- Rotation engine: 25 topics per run cycling through all 122 in order;
  wraps to topic 1 on cycle complete (~30h full cycle at 6h interval)
- batch_offset tracked in kb_facts for cross-run persistence
- Cron changed from 0 3 * * * to 0 */6 * * * (every 6 hours)
- 20h skip guard reduced to 4h so 6h cron isn't blocked
- max_tokens bumped 3500 → 5000 to prevent JSON truncation errors
- JSON extraction: partial recovery + raw snippet logged on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:53:57 -05:00
myron 554488aefa fix(kb-intent): reduce batch size 40→20 intents, raise max_tokens to 5000
Token budget was too low for 40 intents — responses were truncated mid-JSON,
causing "No JSON array found" on ~88% of batches. Fixes 880 errors/run.
Also adds partial-JSON truncation recovery and raw response debug logging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:44:22 -05:00
myron e99c3aa171 feat(admin): KB intent generator — force run, background on close, history panel
- Always runs when triggered from admin (JARVIS_FORCE_RUN=1 bypasses 20h guard)
- Closing popup stops UI polling but server job continues; toast confirms background run
- History panel at top of popup shows last success time/count + failures from last 7 days
- New intent_gen_history backend case parses cron.log for status summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:39:41 -05:00
myron 2528b37ea1 docs: update infrastructure reference for July 2026 session
- VM 210: correct SSH path, add llava:7b vision model
- Add Arc Reactor section (service, logs, SETUP button, vision cascade)
- JARVIS AI tiers: llama3.2 → llama3.1:8b, add vision tier
- Backup Systems: add JARVIS database backup (path, format, retention)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:32:31 -05:00
myron 46dabe3c31 feat(admin): Arc Reactor SETUP button — live log popup
- Replace arcSetup() confirm/toast with openModal() live popup
- Add arc_setup_log PHP backend (polls /var/log/jarvis/arc-setup.log)
- Color-coded line output: green=ok/done/active, red=error/fail, yellow=warn
- Auto-detects completion keywords and updates status header
- CLOSE button stops polling and refreshes worker table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:30:39 -05:00
myron d6a1fc9456 Fix backup script: tar.gz output, lockfile, correct log path, install at /usr/local/bin
- Renamed output to jarvis_backup_TIMESTAMP.tar.gz (admin panel pattern)
- Log now goes to /var/backups/jarvis/backup.log (admin panel reads this)
- Lockfile at /var/backups/jarvis/backup.lock (prevents concurrent runs)
- tmpdir + trap cleanup for safe temp handling
- Install at /usr/local/bin/jarvis-backup.sh (admin panel trigger path)
2026-07-01 22:20:58 -05:00
myron 2f74b98bbc Admin panel: add Arc Reactor SETUP button, fix RESTART to use systemctl
- SETUP button in Workers > Daemons runs full deploy:
  copies reactor.py + requirements.txt from deploy/, creates venv,
  pip install, installs jarvis-arc.service, systemctl enable + restart
- RESTART button now calls systemctl restart jarvis-arc (was nohup pkill)
- arcSetup() JS function with confirm dialog and loading state
- Setup log written to /var/log/jarvis/arc-setup.log
2026-07-01 22:13:28 -05:00
myron ed15ff12dd Add _vision_call() helper with Claude -> Ollama -> graceful fallback
- Centralises all vision logic in one place instead of duplicated inline blocks
- Claude remains primary; set OLLAMA_VISION_MODEL env var to enable a local
  vision model (e.g. llava, moondream) as automatic fallback
- Graceful degradation message when no vision provider is available
- Both handle_screenshot and handle_vision now use _vision_call()
2026-07-01 21:37:11 -05:00
myron 3f18cec739 Add missing email_sent/email_actions/email_triage tables to schema; add sent_at column 2026-07-01 21:29:48 -05:00
myron 8911645c20 Add jarvis-backup.sh — daily mysqldump with 7-day retention
Backup script was never migrated when JARVIS moved from DO to VM 211.
Runs daily at 3am, stores compressed dumps in /var/backups/jarvis/,
logs to /var/log/jarvis/backup.log.
2026-07-01 21:22:02 -05:00
19 changed files with 9574 additions and 8646 deletions
+29 -29
View File
@@ -1,29 +1,29 @@
# JARVIS # JARVIS
Iron Man-style AI assistant for home and network management. Iron Man-style AI assistant for home and network management.
## Features ## Features
- Home Assistant control (lights, climate, scenes, switches) - Home Assistant control (lights, climate, scenes, switches)
- Proxmox VM management (start/stop/status) - Proxmox VM management (start/stop/status)
- 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API - 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API
- Real-time status bar (HA, Proxmox, DigitalOcean) - Real-time status bar (HA, Proxmox, DigitalOcean)
- Iron Man HUD at jarvis.orbishosting.com - Iron Man HUD at jarvis.orbishosting.com
## Stack ## Stack
- PHP 8.x / Apache / MySQL on Ubuntu 24.04 - PHP 8.x / Apache / MySQL on Ubuntu 24.04
- Ollama VM at 10.48.200.95 (llama3.2:1b) - Ollama VM at 10.48.200.95 (llama3.2:1b)
- Groq API (llama-3.3-70b / compound-mini with web search) - Groq API (llama-3.3-70b / compound-mini with web search)
- Claude API (Anthropic) final fallback - Claude API (Anthropic) final fallback
## Setup ## Setup
cp api/config.example.php api/config.php cp api/config.example.php api/config.php
Fill in all credentials in config.php before running. Fill in all credentials in config.php before running.
## Key Files ## Key Files
- public/index.html Iron Man HUD frontend - public/index.html Iron Man HUD frontend
- public/api.php API router - public/api.php API router
- api/config.example.php Config template - api/config.example.php Config template
- api/endpoints/chat.php 4-tier chat handler - api/endpoints/chat.php 4-tier chat handler
- api/endpoints/facts_collector.php HA entity sync cron - api/endpoints/facts_collector.php HA entity sync cron
- api/lib/kb_engine.php KB intent engine - api/lib/kb_engine.php KB intent engine
- api/lib/db.php PDO database wrapper - api/lib/db.php PDO database wrapper
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {} return {}
def get_services(cfg: dict) -> list: def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"]) watch = cfg.get("watch_services", [])
statuses = [] statuses = []
for svc in watch: for svc in watch:
try: try:
+94 -94
View File
@@ -1,94 +1,94 @@
<?php <?php
// Digital Ocean server monitoring — read local /proc directly (no SSH loopback) // Digital Ocean server monitoring — read local /proc directly (no SSH loopback)
// CPU usage (sample over 200ms) // CPU usage (sample over 200ms)
function getCpuPct(): float { function getCpuPct(): float {
$s1 = file_get_contents("/proc/stat"); $s1 = file_get_contents("/proc/stat");
usleep(200000); usleep(200000);
$s2 = file_get_contents("/proc/stat"); $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", $s1, $m1);
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s2, $m2); 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]; $idle1 = $m1[4]; $total1 = $m1[1]+$m1[2]+$m1[3]+$m1[4];
$idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4]; $idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4];
$dt = $total2 - $total1; $dt = $total2 - $total1;
return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0; return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0;
} }
$memLines = []; $memLines = [];
foreach (file("/proc/meminfo") as $l) { foreach (file("/proc/meminfo") as $l) {
[$k, $v] = explode(":", $l, 2) + [null, null]; [$k, $v] = explode(":", $l, 2) + [null, null];
if ($k) $memLines[trim($k)] = (int)trim($v); if ($k) $memLines[trim($k)] = (int)trim($v);
} }
$memTotal = $memLines["MemTotal"] ?? 0; $memTotal = $memLines["MemTotal"] ?? 0;
$memFree = $memLines["MemAvailable"] ?? 0; $memFree = $memLines["MemAvailable"] ?? 0;
$memUsed = $memTotal - $memFree; $memUsed = $memTotal - $memFree;
$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0]; $uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0];
$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0]; $load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0];
$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? ""; $dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
$diskPct = trim($dfOut); $diskPct = trim($dfOut);
// Services // Services
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"]; $svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
$svcMap = []; $svcMap = [];
foreach ($svcNames as $s) { foreach ($svcNames as $s) {
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? ""); $status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
if ($status === "active") $svcMap[$s] = true; if ($status === "active") $svcMap[$s] = true;
} }
// Site health from kb_facts // Site health from kb_facts
$siteLabels = [ $siteLabels = [
"jarvis" => "jarvis.orbishosting.com:1972", "jarvis" => "jarvis.orbishosting.com",
"tomsjavajive" => "tomsjavajive.com", "tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com", "epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com", "parkersling" => "parkerslingshotrentals.com",
"orbishosting" => "orbishosting.com", "orbishosting" => "orbishosting.com",
"orbisportal" => "orbis.orbishosting.com", "orbisportal" => "orbis.orbishosting.com",
"tomtomgames" => "tomtomgames.com", "tomtomgames" => "tomtomgames.com",
]; ];
$sites = []; $sites = [];
$rows = JarvisDB::query( $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" "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) { foreach ($rows as $r) {
$label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"]; $label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"];
$sites[$label] = $r["fact_value"]; $sites[$label] = $r["fact_value"];
} }
$uptimeDays = intdiv($uptime, 86400); $uptimeDays = intdiv($uptime, 86400);
$uptimeHrs = intdiv($uptime % 86400, 3600); $uptimeHrs = intdiv($uptime % 86400, 3600);
// DO server agent metrics (jarvis-do agent reporting via Tailscale) // DO server agent metrics (jarvis-do agent reporting via Tailscale)
$doAgent = JarvisDB::query( $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" "SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
); );
$doMet = []; $doMet = [];
if (!empty($doAgent[0]['metric_data'])) { if (!empty($doAgent[0]['metric_data'])) {
$dm = json_decode($doAgent[0]['metric_data'], true) ?? []; $dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
$doMet = [ $doMet = [
"cpu" => $dm['cpu_percent'] ?? 0, "cpu" => $dm['cpu_percent'] ?? 0,
"mem" => $dm['memory']['percent'] ?? 0, "mem" => $dm['memory']['percent'] ?? 0,
"disk" => (int)($dm['disk'][0]['percent'] ?? 0), "disk" => (int)($dm['disk'][0]['percent'] ?? 0),
"uptime" => $dm['uptime']['human'] ?? "--", "uptime" => $dm['uptime']['human'] ?? "--",
"online" => true, "online" => true,
]; ];
} }
echo json_encode([ echo json_encode([
"ip" => "10.48.200.211", // JARVIS VM (PVE1) "ip" => "10.48.200.211", // JARVIS VM (PVE1)
"reachable" => true, "reachable" => true,
"cpu_pct" => getCpuPct(), "cpu_pct" => getCpuPct(),
"memory" => [ "memory" => [
"total_mb" => round($memTotal / 1024), "total_mb" => round($memTotal / 1024),
"used_mb" => round($memUsed / 1024), "used_mb" => round($memUsed / 1024),
"percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0, "percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0,
], ],
"disk_used_pct" => $diskPct, "disk_used_pct" => $diskPct,
"load_1m" => $load, "load_1m" => $load,
"uptime" => "{$uptimeDays}d {$uptimeHrs}h", "uptime" => "{$uptimeDays}d {$uptimeHrs}h",
"services" => $svcMap, "services" => $svcMap,
"sites" => $sites, "sites" => $sites,
"do_server" => $doMet, "do_server" => $doMet,
"timestamp" => date("c"), "timestamp" => date("c"),
]); ]);
+256 -251
View File
@@ -1,251 +1,256 @@
<?php <?php
/** /**
* JARVIS Facts Collector * JARVIS Facts Collector
* HTTP endpoint: /api/facts/collect (POST or GET) * HTTP endpoint: /api/facts/collect (POST or GET)
* CLI/cron: php facts_collector.php * CLI/cron: php facts_collector.php
* Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table. * Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table.
*/ */
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed'); $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) // Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
if (!class_exists('KBEngine')) { if (!class_exists('KBEngine')) {
require_once __DIR__ . '/../config.php'; require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php'; require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/kb_engine.php'; require_once __DIR__ . '/../lib/kb_engine.php';
} }
function collect_all(): array { function collect_all(): array {
$results = []; $results = [];
$ttl = 300; // 5-minute TTL on live facts $ttl = 300; // 5-minute TTL on live facts
// Returns true if a fact category has been updated within $secs seconds. // Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh. // Prevents expensive external calls when data is still fresh.
$fresh = function(string $cat, int $secs): bool { // Comparison is done entirely in SQL (via NOW()) rather than PHP's time()/strtotime()
$row = JarvisDB::query( // — this file's config.php sets date_default_timezone_set('America/Chicago'), which
'SELECT updated_at FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1', // makes strtotime() misinterpret MySQL's naive (UTC) datetime strings as being in
[$cat] // Chicago time, throwing every freshness check off by the UTC offset (previously
); // caused "sites" to always look artificially fresh and never actually refresh).
if (empty($row[0]['updated_at'])) return false; $fresh = function(string $cat, int $secs): bool {
return (time() - strtotime($row[0]['updated_at'])) < $secs; $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]
);
// ── System ──────────────────────────────────────────────────────────── if (empty($row)) return false;
try { return (bool) $row[0]['is_fresh'];
$stat1 = file_get_contents('/proc/stat'); };
usleep(200000);
$stat2 = file_get_contents('/proc/stat');
// ── System ────────────────────────────────────────────────────────────
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d"); try {
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d"); $stat1 = file_get_contents('/proc/stat');
$dIdle = $cpu2[3] - $cpu1[3]; usleep(200000);
$dTotal = array_sum($cpu2) - array_sum($cpu1); $stat2 = file_get_contents('/proc/stat');
$cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0;
KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl); $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");
$memLines = file('/proc/meminfo'); $dIdle = $cpu2[3] - $cpu1[3];
$mem = []; $dTotal = array_sum($cpu2) - array_sum($cpu1);
foreach ($memLines as $l) { $cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0;
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl);
}
$total = round($mem['MemTotal'] / 1048576, 1); $memLines = file('/proc/meminfo');
$avail = round($mem['MemAvailable'] / 1048576, 1); $mem = [];
$used = round($total - $avail, 1); foreach ($memLines as $l) {
$free = round($mem['MemFree'] / 1048576, 1); if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
$memPct = $total > 0 ? round($used / $total * 100) : 0; }
KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl); $total = round($mem['MemTotal'] / 1048576, 1);
KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl); $avail = round($mem['MemAvailable'] / 1048576, 1);
KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl); $used = round($total - $avail, 1);
KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl); $free = round($mem['MemFree'] / 1048576, 1);
$memPct = $total > 0 ? round($used / $total * 100) : 0;
$la = explode(' ', file_get_contents('/proc/loadavg')); KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl);
KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl); KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl);
KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl); KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl);
KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl); KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl);
$sec = (int) file_get_contents('/proc/uptime'); $la = explode(' ', file_get_contents('/proc/loadavg'));
KBEngine::storeFact('system', 'uptime', KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl);
intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours', KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl);
'local', $ttl); KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl);
$df = disk_free_space('/'); $sec = (int) file_get_contents('/proc/uptime');
$dt = disk_total_space('/'); KBEngine::storeFact('system', 'uptime',
KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl); intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours',
KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl); 'local', $ttl);
KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl);
$df = disk_free_space('/');
$results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)"; $dt = disk_total_space('/');
} catch (Exception $e) { KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl);
$results['system'] = 'error: ' . $e->getMessage(); 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);
// ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ── $results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)";
try { } catch (Exception $e) {
$rows = JarvisDB::query( $results['system'] = 'error: ' . $e->getMessage();
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)" }
);
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online')); // ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ──
$total = count($rows); try {
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl); $rows = JarvisDB::query(
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl); "SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl); );
$results['network'] = "ok ({$online}/{$total} online)"; $online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
} catch (Exception $e) { $total = count($rows);
$results['network'] = 'error: ' . $e->getMessage(); 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);
// ── Proxmox (TTL 10 min) ───────────────────────────────────────────── $results['network'] = "ok ({$online}/{$total} online)";
if ($fresh('proxmox', 600)) { } catch (Exception $e) {
$results['proxmox'] = 'skipped (fresh)'; $results['network'] = 'error: ' . $e->getMessage();
} else try { }
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json'; // ── Proxmox (TTL 10 min) ─────────────────────────────────────────────
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL; if ($fresh('proxmox', 600)) {
$results['proxmox'] = 'skipped (fresh)';
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth); } else try {
$vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth); if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth); $base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
if (isset($nd['data'])) {
$cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1); $nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
$memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1); $vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth);
$memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1); $cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth);
$memPct = $memT > 0 ? round($memU / $memT * 100) : 0;
KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl); if (isset($nd['data'])) {
KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl); $cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1);
KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl); $memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1);
KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl); $memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1);
} $memPct = $memT > 0 ? round($memU / $memT * 100) : 0;
$all = array_merge($vms['data'] ?? [], $cts['data'] ?? []); KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl);
$running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running')); KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl); KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl); KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl);
$results['proxmox'] = "ok ({$running}/" . count($all) . " running)"; }
} else { $all = array_merge($vms['data'] ?? [], $cts['data'] ?? []);
$results['proxmox'] = 'skipped (no token)'; $running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running'));
} KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl);
} catch (Exception $e) { KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl);
$results['proxmox'] = 'error: ' . $e->getMessage(); $results['proxmox'] = "ok ({$running}/" . count($all) . " running)";
} } else {
$results['proxmox'] = 'skipped (no token)';
// ── Home Assistant — skipped (HA agent pushes entities every 30s) ──── }
$results['ha'] = 'skipped (agent push active)'; } catch (Exception $e) {
$results['proxmox'] = 'error: ' . $e->getMessage();
}
// ── Digital Ocean ─────────────────────────────────────────────────────
try { // ── Home Assistant — skipped (HA agent pushes entities every 30s) ────
exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);; $results['ha'] = 'skipped (agent push active)';
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
$results['do_server'] = "ok ({$doStatus})"; // ── Digital Ocean ─────────────────────────────────────────────────────
} catch (Exception $e) { try {
$results['do_server'] = 'error: ' . $e->getMessage(); 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);
// ── Ollama (TTL 15 min) ─────────────────────────────────────────────── $results['do_server'] = "ok ({$doStatus})";
if ($fresh('ollama', 900)) { } catch (Exception $e) {
$results['ollama'] = 'skipped (fresh)'; $results['do_server'] = 'error: ' . $e->getMessage();
} else try { }
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
$ch = curl_init($ollamaHost . '/api/tags'); // ── Ollama (TTL 15 min) ───────────────────────────────────────────────
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]); if ($fresh('ollama', 900)) {
$resp = curl_exec($ch); $results['ollama'] = 'skipped (fresh)';
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); } else try {
curl_close($ch); $ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
$ch = curl_init($ollamaHost . '/api/tags');
if ($code === 200) { curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$models = json_decode($resp, true)['models'] ?? []; $resp = curl_exec($ch);
$names = array_column($models, 'name'); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null); curl_close($ch);
KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl);
KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl); if ($code === 200) {
foreach ($models as $m) { $models = json_decode($resp, true)['models'] ?? [];
JarvisDB::execute( $names = array_column($models, 'name');
'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?) KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null);
ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()', KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl);
[$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)] KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl);
); foreach ($models as $m) {
} JarvisDB::execute(
$results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')'; 'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?)
} else { ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()',
KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl); [$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)]
$results['ollama'] = 'unreachable (VM may be booting)'; );
} }
} catch (Exception $e) { $results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')';
$results['ollama'] = 'error: ' . $e->getMessage(); } else {
} KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl);
$results['ollama'] = 'unreachable (VM may be booting)';
// ── Site Health (TTL 5 min) ─────────────────────────────────────────── }
if ($fresh('sites', 300)) { } catch (Exception $e) {
$results['sites'] = 'skipped (fresh)'; $results['ollama'] = 'error: ' . $e->getMessage();
} else try { }
$sites = [
"jarvis" => "http://127.0.0.1", // ── Site Health (TTL 5 min) ───────────────────────────────────────────
'tomsjavajive' => 'https://tomsjavajive.com', if ($fresh('sites', 300)) {
'epictravelexp'=> 'https://epictravelexpeditions.com', $results['sites'] = 'skipped (fresh)';
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com', } else try {
'orbishosting' => 'https://orbishosting.com', $sites = [
'orbisportal' => 'https://orbis.orbishosting.com', "jarvis" => "http://127.0.0.1",
'tomtomgames' => 'https://tomtomgames.com', 'tomsjavajive' => 'https://tomsjavajive.com',
]; 'epictravelexp'=> 'https://epictravelexpeditions.com',
$down = []; 'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
foreach ($sites as $key => $url) { 'orbishosting' => 'https://orbishosting.com',
$parsed = parse_url($url); 'orbisportal' => 'https://orbis.orbishosting.com',
$host = $parsed['host'] ?? $url; 'tomtomgames' => 'https://tomtomgames.com',
// Check sites on the local server directly to avoid Cloudflare CDN timeouts. ];
// All JARVIS-hosted sites are served from this same OLS instance. $down = [];
$localUrl = $url; // external check foreach ($sites as $key => $url) {
$ch = curl_init($localUrl); $parsed = parse_url($url);
curl_setopt_array($ch, [ $host = $parsed['host'] ?? $url;
CURLOPT_RETURNTRANSFER => true, // Check sites on the local server directly to avoid Cloudflare CDN timeouts.
CURLOPT_CONNECTTIMEOUT => 3, // All JARVIS-hosted sites are served from this same OLS instance.
CURLOPT_FOLLOWLOCATION => true, $localUrl = $url; // external check
CURLOPT_TIMEOUT => 10, $ch = curl_init($localUrl);
CURLOPT_CONNECTTIMEOUT => 5, curl_setopt_array($ch, [
CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true,
]); CURLOPT_CONNECTTIMEOUT => 3,
curl_exec($ch); CURLOPT_FOLLOWLOCATION => true,
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); CURLOPT_TIMEOUT => 10,
curl_close($ch); CURLOPT_CONNECTTIMEOUT => 5,
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code"; CURLOPT_NOBODY => true,
KBEngine::storeFact('sites', $key, $status, $url, 180); ]);
if ($status !== 'up') $down[] = "$key($code)"; curl_exec($ch);
} $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down); curl_close($ch);
} catch (Exception $e) { $status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
$results['sites'] = 'error: ' . $e->getMessage(); KBEngine::storeFact('sites', $key, $status, $url, 180);
} if ($status !== 'up') $down[] = "$key($code)";
}
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down);
// Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh) } catch (Exception $e) {
// which POSTs nmap results to /api/netscan every 3 minutes. $results['sites'] = 'error: ' . $e->getMessage();
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)'; }
return $results;
} // Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
// which POSTs nmap results to /api/netscan every 3 minutes.
function pve_api_get(string $url, string $authHeader): array { $results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
$ch = curl_init($url);
curl_setopt_array($ch, [ return $results;
CURLOPT_RETURNTRANSFER => true, }
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader], function pve_api_get(string $url, string $authHeader): array {
CURLOPT_SSL_VERIFYPEER => false, $ch = curl_init($url);
CURLOPT_TIMEOUT => 5, curl_setopt_array($ch, [
]); CURLOPT_RETURNTRANSFER => true,
$resp = curl_exec($ch); CURLOPT_CONNECTTIMEOUT => 3,
curl_close($ch); CURLOPT_HTTPHEADER => [$authHeader],
return $resp ? (json_decode($resp, true) ?? []) : []; CURLOPT_SSL_VERIFYPEER => false,
} CURLOPT_TIMEOUT => 5,
]);
// ── Entry point ─────────────────────────────────────────────────────────── $resp = curl_exec($ch);
$results = collect_all(); curl_close($ch);
if ($isCLI) { return $resp ? (json_decode($resp, true) ?? []) : [];
echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n"; }
foreach ($results as $k => $v) {
echo " {$k}: {$v}\n"; // ── Entry point ───────────────────────────────────────────────────────────
} $results = collect_all();
} else { if ($isCLI) {
echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]); 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
View File
@@ -0,0 +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.");
/* ── 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.');
+55
View File
@@ -479,3 +479,58 @@ CREATE TABLE IF NOT EXISTS `guardian_events` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Dump completed on 2026-06-29 23:15:44 -- Dump completed on 2026-06-29 23:15:44
CREATE TABLE IF NOT EXISTS `email_triage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) NOT NULL,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`date_received` datetime DEFAULT NULL,
`category` varchar(32) NOT NULL DEFAULT 'info',
`priority` int(11) NOT NULL DEFAULT 3,
`summary` text DEFAULT NULL,
`draft_reply` text DEFAULT NULL,
`action_taken` varchar(32) NOT NULL DEFAULT 'none',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_msg` (`msg_id`),
KEY `idx_category` (`category`),
KEY `idx_action` (`action_taken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) DEFAULT NULL,
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`received_at` datetime DEFAULT NULL,
`suggested_title` varchar(255) DEFAULT NULL,
`suggested_date` date DEFAULT NULL,
`task_id` int(11) DEFAULT NULL,
`appointment_id` int(11) DEFAULT NULL,
`dismissed` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_dismissed` (`dismissed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_sent` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`to_email` varchar(255) NOT NULL,
`to_name` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`body` text DEFAULT NULL,
`triage_id` int(11) DEFAULT NULL,
`status` varchar(32) NOT NULL DEFAULT 'sent',
`sent_at` timestamp NULL DEFAULT current_timestamp(),
`error` text DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_account` (`account`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# JARVIS backup — DB dump as tar.gz, admin-panel compatible
BACKUP_DIR="/var/backups/jarvis"
LOG="$BACKUP_DIR/backup.log"
LOCK="$BACKUP_DIR/backup.lock"
DB_NAME="jarvis_db"
DB_USER="jarvis_user"
DB_PASS="J4rv1s_Pr0t0c0l_2026!"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
OUTFILE="$BACKUP_DIR/jarvis_backup_${TIMESTAMP}.tar.gz"
TMPDIR=$(mktemp -d)
mkdir -p "$BACKUP_DIR"
touch "$LOCK"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup..." >> "$LOG"
cleanup() { rm -rf "$TMPDIR"; rm -f "$LOCK"; }
trap cleanup EXIT
if mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$TMPDIR/jarvis_db.sql" 2>>"$LOG"; then
tar -czf "$OUTFILE" -C "$TMPDIR" jarvis_db.sql
SIZE=$(du -sh "$OUTFILE" | cut -f1)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup OK: $(basename "$OUTFILE") ($SIYE)" >> "$LOG"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: mysqldump failed" >> "$LOG"
exit 1
fi
find "$BACKUP_DIR" -name "jarvis_backup_*.tar.gz" -mtime +7 -delete
COUNT=$(ls "$BACKUP_DIR"/jarvis_backup_*.tar.gz 2>/dev/null | wc -l)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done. Files retained: $COUNT" >> "$LOG"
+67 -107
View File
@@ -41,16 +41,17 @@ DB_PORT = 3306
DB_USER = "jarvis_user" DB_USER = "jarvis_user"
DB_PASS = "J4rv1s_Pr0t0c0l_2026!" DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
DB_NAME = "jarvis_db" DB_NAME = "jarvis_db"
LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log" LOG_FILE = "/var/log/jarvis/arc_reactor.log"
POLL_INTERVAL = 3 POLL_INTERVAL = 3
HEARTBEAT_INTERVAL = 30 HEARTBEAT_INTERVAL = 30
CLAUDE_API_KEY = "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" CLAUDE_API_KEY = "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"
CLAUDE_MODEL = "claude-sonnet-4-6" CLAUDE_MODEL = "claude-sonnet-4-6"
GROQ_API_KEY = "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" GROQ_API_KEY = "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"
GROQ_MODEL = "llama-3.3-70b-versatile" GROQ_MODEL = "llama-3.3-70b-versatile"
OLLAMA_HOST = "http://10.48.200.210:11434" OLLAMA_HOST = "http://10.48.200.210:11434"
OLLAMA_MODEL = "llama3.1:8b" OLLAMA_MODEL = "llama3.1:8b"
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "") # e.g. "llava" or "moondream" -- empty = disabled
GMAIL_USER = "myronblair@gmail.com" GMAIL_USER = "myronblair@gmail.com"
GMAIL_PASS = "demsvdylwweacbcx" GMAIL_PASS = "demsvdylwweacbcx"
@@ -175,15 +176,56 @@ async def _ollama_call(messages: list, system: str = "") -> str:
data = await resp.json() data = await resp.json()
return data.get("response", "") return data.get("response", "")
async def _ollama_vision_call(image_b64: str, prompt: str) -> str:
async with aiohttp.ClientSession() as session: # -- VISION CALL ----------------------------------------------------------
async with session.post( async def _vision_call(image_b64: str, prompt: str) -> tuple:
f"{OLLAMA_HOST}/api/generate", """
json={"model": "llava:7b", "prompt": prompt, "images": [image_b64], "stream": False}, Vision provider cascade: Claude -> Ollama vision model -> graceful fallback.
timeout=aiohttp.ClientTimeout(total=120), Returns (analysis: str, provider: str).
) as resp: To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava").
data = await resp.json() """
return data.get("response", "").strip() # 1. Claude (primary)
if CLAUDE_API_KEY:
try:
import anthropic as _anthropic
_client = _anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
_msg = await _client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
]}],
)
text = _msg.content[0].text if _msg.content else ""
log.info("[VISION] Claude vision OK")
return text, "claude"
except Exception as e:
log.warning(f"[VISION] Claude failed ({e}), trying next provider")
# 2. Ollama vision model (if configured via OLLAMA_VISION_MODEL env var)
if OLLAMA_VISION_MODEL:
try:
async with aiohttp.ClientSession() as _sess:
_payload = {"model": OLLAMA_VISION_MODEL, "prompt": prompt,
"images": [image_b64], "stream": False}
async with _sess.post(f"{OLLAMA_HOST}/api/generate", json=_payload,
timeout=aiohttp.ClientTimeout(total=120)) as _resp:
if _resp.status == 200:
_data = await _resp.json()
text = _data.get("response", "")
log.info(f"[VISION] Ollama vision OK ({OLLAMA_VISION_MODEL})")
return text, f"ollama/{OLLAMA_VISION_MODEL}"
raise RuntimeError(f"Ollama HTTP {_resp.status}")
except Exception as e:
log.warning(f"[VISION] Ollama vision failed ({e})")
# 3. No vision provider available
msg = ("[Vision unavailable] Claude credits depleted and no local vision model configured. "
"To enable: pull a vision model on Ollama (e.g. 'ollama pull llava') then set "
"OLLAMA_VISION_MODEL=llava in the jarvis-arc systemd environment.")
log.warning("[VISION] All vision providers unavailable")
return msg, "none"
async def handle_llm(payload: dict) -> dict: async def handle_llm(payload: dict) -> dict:
message = payload.get("message", "") message = payload.get("message", "")
@@ -668,44 +710,19 @@ async def handle_screenshot(payload: dict) -> dict:
height = result.get("height", 0) height = result.get("height", 0)
file_size = result.get("file_size", 0) file_size = result.get("file_size", 0)
# Run vision analysis if we have an image — llava first, Claude fallback # Run Claude vision analysis if we have an image
analysis = "" analysis = ""
provider_used = "" provider_used = ""
if do_analyze and image_b64: if do_analyze and image_b64:
try: analysis, provider_used = await _vision_call(image_b64, analyze_prompt)
analysis = await _ollama_vision_call(image_b64, analyze_prompt) log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)")
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": analyze_prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
log.info(f"[VISION] Claude fallback analysis complete ({len(analysis)} chars)")
except Exception as e2:
log.warning(f"[VISION] Claude fallback also failed: {e2}")
analysis = f"Vision analysis unavailable: {e2}"
elif do_analyze and not image_b64 and result.get("snapshot_type") == "text": elif do_analyze and not image_b64 and result.get("snapshot_type") == "text":
# Text-only sysinfo snapshot — summarize with Ollama # Text-only sysinfo snapshot — summarize with LLM
try: try:
snap_text = json.dumps(result, indent=2)[:3000] snap_text = json.dumps(result, indent=2)[:3000]
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}" prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
analysis = await llm_call([{"role": "user", "content": prompt}], "ollama") analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
provider_used = "ollama" provider_used = "groq"
except Exception as e: except Exception as e:
analysis = f"Analysis unavailable: {e}" analysis = f"Analysis unavailable: {e}"
@@ -758,41 +775,9 @@ async def handle_vision(payload: dict) -> dict:
if not image_b64: if not image_b64:
raise ValueError("No image data provided") raise ValueError("No image data provided")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname} provider={provider}") log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}")
analysis = "" analysis, provider_used = await _vision_call(image_b64, prompt)
provider_used = provider
if provider == "ollama" or provider == "llava":
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
else:
# Default: llava first, Claude fallback
try:
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
except Exception as e2:
raise RuntimeError(f"Vision analysis failed (llava: {e}, claude: {e2})")
# Update stored screenshot if we have an ID # Update stored screenshot if we have an ID
if screenshot_id: if screenshot_id:
@@ -1053,7 +1038,7 @@ async def guardian_loop() -> None:
"for Myron. Be direct about severity and what action to take. " "for Myron. Be direct about severity and what action to take. "
"No markdown, no headers." "No markdown, no headers."
) )
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "ollama") ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
# Update the most recent guardian event with AI analysis # Update the most recent guardian event with AI analysis
await db_execute( await db_execute(
"""UPDATE guardian_events SET ai_analysis=%s """UPDATE guardian_events SET ai_analysis=%s
@@ -1082,7 +1067,7 @@ async def _guardian_inject_chat(message: str) -> None:
"""Write a proactive JARVIS message into the conversations table so the HUD picks it up.""" """Write a proactive JARVIS message into the conversations table so the HUD picks it up."""
try: try:
await db_execute( await db_execute(
"""INSERT INTO conversations (session_id, role, content, created_at) """INSERT INTO conversations (session_id, role, message, created_at)
VALUES ('guardian', 'assistant', %s, NOW())""", VALUES ('guardian', 'assistant', %s, NOW())""",
(message,) (message,)
) )
@@ -1096,7 +1081,7 @@ async def handle_sitrep(payload: dict) -> dict:
payload: { detail: brief|full, provider: groq } payload: { detail: brief|full, provider: groq }
""" """
detail = payload.get("detail", "full") detail = payload.get("detail", "full")
provider = payload.get("provider", "ollama") provider = payload.get("provider", "groq")
log.info(f"[GUARDIAN] SITREP requested (detail={detail})") log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
@@ -1837,7 +1822,7 @@ Keep the tone confident and action-oriented. Format with clear sections. Under 4
# Store in conversations so HUD can surface it # Store in conversations so HUD can surface it
await db_execute( await db_execute(
"INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())", "INSERT INTO conversations (session_id, role, message, created_at) VALUES ('guardian','assistant',%s,NOW())",
(review_text,) (review_text,)
) )
@@ -2240,31 +2225,6 @@ async def lifespan(app: FastAPI):
log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}") log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}")
await get_pool() await get_pool()
await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,)) await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,))
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_config (
id INT AUTO_INCREMENT PRIMARY KEY,
key_name VARCHAR(64) NOT NULL,
value VARCHAR(255) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_key (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info',
agent_id VARCHAR(64) NOT NULL DEFAULT '',
hostname VARCHAR(128) NOT NULL DEFAULT '',
metric VARCHAR(64) NOT NULL DEFAULT '',
value FLOAT NOT NULL DEFAULT 0,
threshold FLOAT NOT NULL DEFAULT 0,
message TEXT NOT NULL,
ai_analysis TEXT NOT NULL DEFAULT '',
acknowledged TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_severity (severity),
KEY idx_ack (acknowledged),
KEY idx_created (created_at),
KEY idx_agent (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
asyncio.create_task(job_poller()) asyncio.create_task(job_poller())
asyncio.create_task(heartbeat_loop()) asyncio.create_task(heartbeat_loop())
asyncio.create_task(guardian_loop()) asyncio.create_task(guardian_loop())
@@ -2784,13 +2744,13 @@ async def guardian_chat_events(since: str = ""):
"""Return proactive guardian messages injected into conversations.""" """Return proactive guardian messages injected into conversations."""
if since: if since:
rows = await db_fetchall( rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations " "SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC", "WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC",
(since,) (since,)
) )
else: else:
rows = await db_fetchall( rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations " "SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10" "WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10"
) )
return rows or [] return rows or []
@@ -1,738 +0,0 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
**Last Updated:** 2026-06-18
**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)
---
## 1. NETWORK OVERVIEW
```
INTERNET
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
│ (proxied DNS for public sites)
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (6 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 113 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 210 10.48.200.210 Ollama (local LLM) (local LLM)
│ └── CT110 10.48.200.19 WireGuard exit container
├─► 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 (6 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 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 22.04 LTS |
| **SSH** | `ssh root@10.48.200.33` — password: `Joker1974!!!` (enabled 2026-06-14) |
| **Web UI** | `http://10.48.200.33:8096` |
| **Purpose** | Media streaming server — Movies and TV shows |
| **JARVIS Agent** | Not yet installed |
**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:** `7c0ccf78b91d4b5bafa607f585f24f2d`
**If library scan needed:**
```bash
curl -X POST "http://10.48.200.33:8096/Library/Refresh" \
-H "X-Emby-Token: 7c0ccf78b91d4b5bafa607f585f24f2d"
```
**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 113 — MediaStack (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.35 |
| **OS** | Ubuntu 24.04 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` |
**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 181.214.226.188 (US Dallas)
All download traffic exits via NordVPN. If downloads stall, check: `ip rule show` for rules 32764/32765.
**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 210 — Ollama Local LLM (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.210 |
| **OS** | Ubuntu (cloud image) |
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
---
### 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 |
---
## 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` (exported to MediaStack only)
**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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` (expires ~2026-08-20)
---
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
| Field | Value |
|-------|-------|
| **URL** | http://jarvis.orbishosting.com:1972 |
| **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.
---
### 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...` |
---
## 7. JARVIS AI SYSTEM
**URL:** http://jarvis.orbishosting.com:1972
**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:1972/admin
### 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.2) — local LLM
Tier 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker
```
### 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
**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 IP: 181.214.226.188 (US Dallas)
- Policy routing: table 205 (all traffic via nordlynx), managed by `nordvpn-routing.service`
- Required for IPTorrents access (blocks non-VPN IPs)
---
## 10. BACKUP SYSTEMS
### 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, ~29MB) + FreeSWITCH configs
- **Restore:** 10-phase wizard in `restore.sh`
---
## 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.95 # Ollama
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)
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
'qm guest exec 210 -- bash -c "CMD"'
```
**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:1972 | myron | `Joker1974!!!` |
| JARVIS Admin | http://jarvis.orbishosting.com:1972/admin | myron | `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: `7c0ccf78b91d4b5bafa607f585f24f2d` |
| 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` | — | — |
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
### API Keys
| Service | Key |
|---------|-----|
| GitHub PAT | `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` (exp ~2026-08-20) |
| 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 | `7c0ccf78b91d4b5bafa607f585f24f2d` |
| 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) | — |
---
*This document contains sensitive credentials. Store securely and do not share.*
+5475 -4760
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -57,7 +57,7 @@ else
"poll_interval": 30, "poll_interval": 30,
"heartbeat_every": 10, "heartbeat_every": 10,
"update_check_hours": 24, "update_check_hours": 24,
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"] "watch_services": []
} }
JSONEOF JSONEOF
chmod 600 "$CONFIG_DIR/config.json" chmod 600 "$CONFIG_DIR/config.json"
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {} return {}
def get_services(cfg: dict) -> list: def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"]) watch = cfg.get("watch_services", [])
statuses = [] statuses = []
for svc in watch: for svc in watch:
try: try:
+128 -123
View File
@@ -1,123 +1,128 @@
<?php <?php
/** /**
* JARVIS API Router fault-isolated per endpoint * JARVIS API Router fault-isolated per endpoint
* A ParseError or fatal in any endpoint file returns JSON 500 for that * A ParseError or fatal in any endpoint file returns JSON 500 for that
* endpoint only; all other endpoints continue to work normally. * endpoint only; all other endpoints continue to work normally.
*/ */
require_once __DIR__ . '/../api/config.php'; require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../api/lib/db.php'; require_once __DIR__ . '/../api/lib/db.php';
require_once __DIR__ . '/../api/lib/kb_engine.php'; require_once __DIR__ . '/../api/lib/kb_engine.php';
// Skip session for machine-agent calls and netscan/ping — each heartbeat would // Skip session for machine-agent calls and netscan/ping — each heartbeat would
// otherwise create an empty session file, producing millions of files that slow // 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) // 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. // 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), '/')); $_earlyParts = explode('/', trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'));
if (($_earlyParts[0] ?? '') === 'api') array_shift($_earlyParts); if (($_earlyParts[0] ?? '') === 'api') array_shift($_earlyParts);
$_e0 = $_earlyParts[0] ?? ''; $_e0 = $_earlyParts[0] ?? '';
$_e1 = $_earlyParts[1] ?? ''; $_e1 = $_earlyParts[1] ?? '';
$_skipSession = match(true) { $_skipSession = match(true) {
$_e0 === 'ping' => true, $_e0 === 'ping' => true,
$_e0 === 'netscan' => true, $_e0 === 'netscan' => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true, $_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true,
default => false, default => false,
}; };
if (!$_skipSession) { if (!$_skipSession) {
session_start(); session_start();
} }
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *'); $_allowedOrigins = ['https://jarvis.orbishosting.com', 'http://jarvis.orbishosting.com'];
header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); $_origin = $_SERVER['HTTP_ORIGIN'] ?? '';
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token'); if (in_array($_origin, $_allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $_origin);
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } header('Access-Control-Allow-Credentials: true');
}
$uri = $_SERVER['REQUEST_URI'] ?? '/'; header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
$method = $_SERVER['REQUEST_METHOD']; header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
$path = trim(parse_url($uri, PHP_URL_PATH), '/');
$parts = explode('/', $path); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if (($parts[0] ?? '') === 'api') array_shift($parts); $uri = $_SERVER['REQUEST_URI'] ?? '/';
$endpoint = $parts[0] ?? ''; $method = $_SERVER['REQUEST_METHOD'];
$action = $parts[1] ?? ''; $path = trim(parse_url($uri, PHP_URL_PATH), '/');
$parts = explode('/', $path);
// ── Auth check (skip for auth / agent / netscan) ──────────────────────
if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) { if (($parts[0] ?? '') === 'api') array_shift($parts);
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? ''); $endpoint = $parts[0] ?? '';
$isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? ''); $action = $parts[1] ?? '';
if (!$isValid) {
$ip = $_SERVER['REMOTE_ADDR'] ?? ''; // ── Auth check (skip for auth / agent / netscan) ──────────────────────
$isLocal = \in_array($ip, ['127.0.0.1', '::1', JARVIS_IP], true); if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) {
if (!$isLocal && $endpoint !== 'ping') { $token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
http_response_code(401); $isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? '');
echo json_encode(['error' => 'Unauthorized', 'code' => 401]); if (!$isValid) {
exit; $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]);
if ($endpoint !== 'auth') session_write_close(); exit;
}
$body = file_get_contents('php://input'); }
$data = json_decode($body, true) ?? []; }
// ── Fast ping (no file dispatch needed) ────────────────────────────── if ($endpoint !== 'auth') session_write_close();
if ($endpoint === 'ping') {
echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]); $body = file_get_contents('php://input');
exit; $data = json_decode($body, true) ?? [];
}
// ── Fast ping (no file dispatch needed) ──────────────────────────────
// ── Endpoint → file map ─────────────────────────────────────────────── if ($endpoint === 'ping') {
$endpoints = [ echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]);
'auth' => 'auth.php', exit;
'chat' => 'chat.php', }
'system' => 'system.php',
'netscan' => 'netscan.php', // ── Endpoint → file map ───────────────────────────────────────────────
'network' => 'network.php', $endpoints = [
'proxmox' => 'proxmox.php', 'auth' => 'auth.php',
'ha' => 'ha.php', 'chat' => 'chat.php',
'tts' => 'tts.php', 'system' => 'system.php',
'email' => 'email.php', 'netscan' => 'netscan.php',
'do' => 'do_server.php', 'network' => 'network.php',
'alerts' => 'alerts.php', 'proxmox' => 'proxmox.php',
'facts' => 'facts_collector.php', 'ha' => 'ha.php',
'weather' => 'weather.php', 'tts' => 'tts.php',
'news' => 'news.php', 'email' => 'email.php',
'sites' => 'sites.php', 'do' => 'do_server.php',
'agent' => 'agent.php', 'alerts' => 'alerts.php',
'planner' => 'planner.php', 'facts' => 'facts_collector.php',
'jellyfin' => 'jellyfin.php', 'weather' => 'weather.php',
'history' => 'history.php', 'news' => 'news.php',
'metrics' => 'metrics.php', 'sites' => 'sites.php',
'suggestions' => 'suggestions.php', 'agent' => 'agent.php',
'arc' => 'arc.php', 'planner' => 'planner.php',
'directives' => 'directives.php', 'jellyfin' => 'jellyfin.php',
'memory' => 'memory.php', 'history' => 'history.php',
'calendar' => 'calendar_sync.php', 'metrics' => 'metrics.php',
]; 'suggestions' => 'suggestions.php',
'arc' => 'arc.php',
if (!isset($endpoints[$endpoint])) { 'directives' => 'directives.php',
http_response_code(404); 'memory' => 'memory.php',
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]); 'calendar' => 'calendar_sync.php',
exit; ];
}
if (!isset($endpoints[$endpoint])) {
$file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint]; http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
// ── Fault-isolated dispatch ─────────────────────────────────────────── exit;
// 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+. $file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint];
ob_start();
try { // ── Fault-isolated dispatch ───────────────────────────────────────────
require $file; // ob_start() buffers any partial output so a mid-execution fatal doesn't
ob_end_flush(); // send a broken response. catch(Throwable) catches ParseError, TypeError,
} catch (\Throwable $e) { // and all other Errors + Exceptions in PHP 7+.
ob_end_clean(); ob_start();
http_response_code(500); try {
echo json_encode(['error' => 'Endpoint unavailable', 'endpoint' => $endpoint, 'code' => 500]); require $file;
error_log(sprintf('JARVIS API [%s] %s: %s in %s:%d', ob_end_flush();
$endpoint, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine() } 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
+1 -1
View File
@@ -57,7 +57,7 @@ else
"poll_interval": 30, "poll_interval": 30,
"heartbeat_every": 10, "heartbeat_every": 10,
"update_check_hours": 24, "update_check_hours": 24,
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"] "watch_services": []
} }
JSONEOF JSONEOF
chmod 600 "$CONFIG_DIR/config.json" chmod 600 "$CONFIG_DIR/config.json"
+5 -4
View File
@@ -14,7 +14,7 @@ if (!defined('WEBHOOK_SECRET')) {
exit; exit;
} }
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt'); 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'); header('Content-Type: application/json');
@@ -33,9 +33,10 @@ $repo = $data['repository']['name'] ?? '';
$ref = $data['ref'] ?? ''; $ref = $data['ref'] ?? '';
$pusher = $data['pusher']['name'] ?? 'unknown'; $pusher = $data['pusher']['name'] ?? 'unknown';
// Only deploy on pushes to main // Only deploy on pushes to the repo's actual default branch (master, not main
if ($ref !== 'refs/heads/main') { // this was checking 'main' for a while even though the jarvis repo has always used 'master')
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not main"]); if ($ref !== 'refs/heads/master') {
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not master"]);
exit; exit;
} }