From 588cfe3f10caa7c8da1d6091059b53e1e55399a0 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 7 Jul 2026 07:55:47 -0500 Subject: [PATCH] Adopt live production state as git baseline --- .gitignore | 16 + README.md | 29 + agent/install-mac.sh | 135 + agent/install-windows.ps1 | 151 + agent/jarvis-agent-mac.py | 576 ++ agent/jarvis-agent-windows.py | 607 ++ agent/jarvis-agent.py | 505 ++ agent/jarvis-arc-reactor.py | 2773 +++++++++ agent/jarvis-ha-poller.py | 235 + agent/pve1-probes/jarvis-netscan.sh | 67 + agent/pve1-probes/jarvis-phone-probe.sh | 74 + agent/pve1-probes/jarvis-ping-probe.py | 99 + api/config.example.php | 54 + api/endpoints/agent.php | 261 + api/endpoints/alerts.php | 238 + api/endpoints/arc.php | 344 ++ api/endpoints/auth.php | 53 + api/endpoints/calendar_sync.php | 283 + api/endpoints/chat.php | 2425 ++++++++ api/endpoints/directives.php | 171 + api/endpoints/do_server.php | 94 + api/endpoints/email.php | 238 + api/endpoints/facts_collector.php | 256 + api/endpoints/ha.php | 141 + api/endpoints/history.php | 21 + api/endpoints/jellyfin.php | 109 + api/endpoints/kb_intent_generator.php | 329 + api/endpoints/kb_intent_generator.php.bak2 | 284 + ...ntent_generator.php.bak3-broken-2026-07-07 | 270 + api/endpoints/memory.php | 81 + api/endpoints/metrics.php | 49 + api/endpoints/netscan.php | 84 + api/endpoints/network.php | 188 + api/endpoints/news.php | 37 + api/endpoints/planner.php | 159 + api/endpoints/proxmox.php | 41 + api/endpoints/sites.php | 176 + api/endpoints/stats_cache.php | 347 ++ api/endpoints/suggestions.php | 42 + api/endpoints/system.php | 136 + api/endpoints/tts.php | 53 + api/endpoints/weather.php | 20 + api/lib/db.php | 40 + api/lib/kb_engine.php | 154 + config/vhost/vhost.conf | 94 + config/vhost/vhost.conf0 | 94 + config/vhost/vhost.conf0,v | 147 + db/schema.sql | 536 ++ db/seed_kb.sql | 70 + deploy/cron-jarvis.txt | 2 + deploy/jarvis-agent.service | 14 + deploy/jarvis-arc.service | 17 + deploy/jarvis-backup.sh | 31 + deploy/jarvis-deploy.sh | 122 + deploy/jarvis-watchdog.sh | 118 + deploy/reactor.py | 2789 +++++++++ deploy/requirements.txt | 6 + public_html/.gitignore | 1 + public_html/.htaccess | 11 + public_html/admin/index.php | 5303 +++++++++++++++++ public_html/agent/install-mac.sh | 135 + public_html/agent/install-windows.ps1 | 151 + public_html/agent/install.sh | 98 + public_html/agent/jarvis-agent-mac.py | 576 ++ public_html/agent/jarvis-agent-mac.py.sha256 | 1 + public_html/agent/jarvis-agent-windows.py | 612 ++ .../agent/jarvis-agent-windows.py.sha256 | 1 + public_html/agent/jarvis-agent.py | 505 ++ public_html/agent/jarvis-agent.py.sha256 | 1 + public_html/agent/jarvis-ha-poller.py | 236 + public_html/agent/setup-task.ps1 | 36 + public_html/api.php | 128 + public_html/assets/css/jarvis.css | 1381 +++++ public_html/assets/js/jarvis-app.js | 1832 ++++++ public_html/assets/js/jarvis-effects.js | 590 ++ public_html/assets/js/jarvis-overlays.js | 384 ++ public_html/assets/js/panels/jarvis-agents.js | 715 +++ public_html/assets/js/panels/jarvis-arc.js | 608 ++ .../assets/js/panels/jarvis-assistant.js | 345 ++ public_html/bridge.php | 10 + public_html/index.html | 478 ++ public_html/index.php | 38 + public_html/install-agent.sh | 98 + public_html/login.php | 79 + public_html/webhook.php | 58 + 85 files changed, 30896 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent/install-mac.sh create mode 100644 agent/install-windows.ps1 create mode 100644 agent/jarvis-agent-mac.py create mode 100644 agent/jarvis-agent-windows.py create mode 100755 agent/jarvis-agent.py create mode 100644 agent/jarvis-arc-reactor.py create mode 100644 agent/jarvis-ha-poller.py create mode 100644 agent/pve1-probes/jarvis-netscan.sh create mode 100644 agent/pve1-probes/jarvis-phone-probe.sh create mode 100644 agent/pve1-probes/jarvis-ping-probe.py create mode 100644 api/config.example.php create mode 100644 api/endpoints/agent.php create mode 100644 api/endpoints/alerts.php create mode 100644 api/endpoints/arc.php create mode 100644 api/endpoints/auth.php create mode 100644 api/endpoints/calendar_sync.php create mode 100644 api/endpoints/chat.php create mode 100644 api/endpoints/directives.php create mode 100644 api/endpoints/do_server.php create mode 100644 api/endpoints/email.php create mode 100644 api/endpoints/facts_collector.php create mode 100644 api/endpoints/ha.php create mode 100644 api/endpoints/history.php create mode 100644 api/endpoints/jellyfin.php create mode 100644 api/endpoints/kb_intent_generator.php create mode 100644 api/endpoints/kb_intent_generator.php.bak2 create mode 100644 api/endpoints/kb_intent_generator.php.bak3-broken-2026-07-07 create mode 100644 api/endpoints/memory.php create mode 100644 api/endpoints/metrics.php create mode 100644 api/endpoints/netscan.php create mode 100644 api/endpoints/network.php create mode 100644 api/endpoints/news.php create mode 100644 api/endpoints/planner.php create mode 100644 api/endpoints/proxmox.php create mode 100644 api/endpoints/sites.php create mode 100644 api/endpoints/stats_cache.php create mode 100644 api/endpoints/suggestions.php create mode 100644 api/endpoints/system.php create mode 100644 api/endpoints/tts.php create mode 100644 api/endpoints/weather.php create mode 100644 api/lib/db.php create mode 100644 api/lib/kb_engine.php create mode 100755 config/vhost/vhost.conf create mode 100755 config/vhost/vhost.conf0 create mode 100755 config/vhost/vhost.conf0,v create mode 100644 db/schema.sql create mode 100644 db/seed_kb.sql create mode 100644 deploy/cron-jarvis.txt create mode 100644 deploy/jarvis-agent.service create mode 100644 deploy/jarvis-arc.service create mode 100755 deploy/jarvis-backup.sh create mode 100755 deploy/jarvis-deploy.sh create mode 100755 deploy/jarvis-watchdog.sh create mode 100644 deploy/reactor.py create mode 100644 deploy/requirements.txt create mode 100644 public_html/.gitignore create mode 100644 public_html/.htaccess create mode 100644 public_html/admin/index.php create mode 100644 public_html/agent/install-mac.sh create mode 100644 public_html/agent/install-windows.ps1 create mode 100644 public_html/agent/install.sh create mode 100644 public_html/agent/jarvis-agent-mac.py create mode 100644 public_html/agent/jarvis-agent-mac.py.sha256 create mode 100644 public_html/agent/jarvis-agent-windows.py create mode 100644 public_html/agent/jarvis-agent-windows.py.sha256 create mode 100644 public_html/agent/jarvis-agent.py create mode 100644 public_html/agent/jarvis-agent.py.sha256 create mode 100644 public_html/agent/jarvis-ha-poller.py create mode 100644 public_html/agent/setup-task.ps1 create mode 100644 public_html/api.php create mode 100644 public_html/assets/css/jarvis.css create mode 100644 public_html/assets/js/jarvis-app.js create mode 100644 public_html/assets/js/jarvis-effects.js create mode 100644 public_html/assets/js/jarvis-overlays.js create mode 100644 public_html/assets/js/panels/jarvis-agents.js create mode 100644 public_html/assets/js/panels/jarvis-arc.js create mode 100644 public_html/assets/js/panels/jarvis-assistant.js create mode 100644 public_html/bridge.php create mode 100644 public_html/index.html create mode 100644 public_html/index.php create mode 100755 public_html/install-agent.sh create mode 100644 public_html/login.php create mode 100644 public_html/webhook.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bab85c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Credentials - never commit +api/config.php +backup/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +.imunify_patch_id +.ssh/ +mail.*/ +logs/ +backup/ +arc-reactor-secrets.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..b95f541 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# JARVIS + +Iron Man-style AI assistant for home and network management. + +## Features +- Home Assistant control (lights, climate, scenes, switches) +- Proxmox VM management (start/stop/status) +- 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API +- Real-time status bar (HA, Proxmox, DigitalOcean) +- Iron Man HUD at jarvis.orbishosting.com + +## Stack +- PHP 8.x / Apache / MySQL on Ubuntu 24.04 +- Ollama VM at 10.48.200.95 (llama3.2:1b) +- Groq API (llama-3.3-70b / compound-mini with web search) +- Claude API (Anthropic) final fallback + +## Setup +cp api/config.example.php api/config.php +Fill in all credentials in config.php before running. + +## Key Files +- public/index.html Iron Man HUD frontend +- public/api.php API router +- api/config.example.php Config template +- api/endpoints/chat.php 4-tier chat handler +- api/endpoints/facts_collector.php HA entity sync cron +- api/lib/kb_engine.php KB intent engine +- api/lib/db.php PDO database wrapper diff --git a/agent/install-mac.sh b/agent/install-mac.sh new file mode 100644 index 0000000..9f5fda0 --- /dev/null +++ b/agent/install-mac.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# JARVIS Agent Installer — macOS +# Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY +# Or one-liner: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY + +set -e + +JARVIS_URL="https://jarvis.orbishosting.com" +REG_KEY="" +INSTALL_DIR="$HOME/.jarvis-agent" +PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist" +SERVICE_LABEL="com.jarvis.agent" + +while [[ $# -gt 0 ]]; do + case "$1" in + --jarvis-url) JARVIS_URL="$2"; shift 2 ;; + --key) REG_KEY="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +if [[ -z "$REG_KEY" ]]; then + read -rp "Registration key: " REG_KEY +fi + +JARVIS_URL="${JARVIS_URL%/}" + +echo "" +echo " ====================================" +echo " JARVIS Agent Installer v3.0 " +echo " ====================================" +echo "" + +# Check for Python3 +PYTHON3=$(command -v python3 2>/dev/null || "") +if [[ -z "$PYTHON3" ]]; then + echo "Python 3 is required. Install it with:" + echo " brew install python3" + echo " or download from https://www.python.org/downloads/" + exit 1 +fi +echo "Using Python: $PYTHON3 ($($PYTHON3 --version 2>&1))" + +mkdir -p "$INSTALL_DIR" + +# Download macOS-native agent script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" +if [[ -f "$SCRIPT_DIR/jarvis-agent-mac.py" ]]; then + cp "$SCRIPT_DIR/jarvis-agent-mac.py" "$INSTALL_DIR/jarvis-agent.py" + echo "Copied agent from local directory." +else + echo "Downloading macOS agent..." + curl -sSL "$JARVIS_URL/agent/jarvis-agent-mac.py" -o "$INSTALL_DIR/jarvis-agent.py" + echo "Downloaded." +fi +chmod +x "$INSTALL_DIR/jarvis-agent.py" + +HOSTNAME=$(hostname -f 2>/dev/null || hostname) +AGENT_ID="${HOSTNAME}_mac" + +# Write config (preserve existing) +if [[ ! -f "$INSTALL_DIR/config.json" ]]; then +cat > "$INSTALL_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME", + "agent_id": "$AGENT_ID", + "agent_type": "macos", + "poll_interval": 30, + "heartbeat_every": 10, + "update_check_hours": 24, + "watch_services": [], + "allow_shell_commands": false +} +JSONEOF + chmod 600 "$INSTALL_DIR/config.json" + echo "Config written to $INSTALL_DIR/config.json" +else + echo "Config already exists — preserving $INSTALL_DIR/config.json" +fi + +# Write launchd plist +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$PLIST_PATH" << PLISTEOF + + + + + Label + $SERVICE_LABEL + ProgramArguments + + $PYTHON3 + $INSTALL_DIR/jarvis-agent.py + + EnvironmentVariables + + JARVIS_CONFIG + $INSTALL_DIR/config.json + JARVIS_STATE + $INSTALL_DIR/state.json + + RunAtLoad + + KeepAlive + + StandardOutPath + $INSTALL_DIR/jarvis-agent.log + StandardErrorPath + $INSTALL_DIR/jarvis-agent.log + + +PLISTEOF + +# Load/reload the service +launchctl unload "$PLIST_PATH" 2>/dev/null || true +launchctl load "$PLIST_PATH" + +sleep 2 +if launchctl list 2>/dev/null | grep -q "$SERVICE_LABEL"; then + echo "" + echo " JARVIS Agent installed and running." + echo " Machine : $HOSTNAME ($AGENT_ID)" + echo " JARVIS : $JARVIS_URL" + echo " Logs : tail -f $INSTALL_DIR/jarvis-agent.log" + echo " Config : $INSTALL_DIR/config.json" + echo " Stop : launchctl unload $PLIST_PATH" + echo " Update : curl -sSL $JARVIS_URL/agent/install-mac.sh | bash -s -- --key $REG_KEY" +else + echo "" + echo " Agent installed but not detected as running. Check logs:" + echo " tail -f $INSTALL_DIR/jarvis-agent.log" +fi +echo "" diff --git a/agent/install-windows.ps1 b/agent/install-windows.ps1 new file mode 100644 index 0000000..f37b1ed --- /dev/null +++ b/agent/install-windows.ps1 @@ -0,0 +1,151 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + JARVIS Agent installer for Windows. + +.DESCRIPTION + Installs JARVIS Agent as a Windows Service that auto-starts at boot. + Requires: PowerShell 5.1+, internet access, and Administrator rights. + +.EXAMPLE + # Interactive install (prompts for registration key): + irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + + # Silent install with key: + $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +#> + +$ErrorActionPreference = 'Stop' +$JARVIS_URL = 'https://jarvis.orbishosting.com' +$INSTALL_DIR = 'C:\ProgramData\jarvis-agent' +$SERVICE_NAME = 'JARVISAgent' +$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py" +$CONFIG_FILE = "$INSTALL_DIR\config.json" + +function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan } +function Write-OK { param($msg) Write-Host " OK: $msg" -ForegroundColor Green } +function Write-Fail { param($msg) Write-Host " ERROR: $msg" -ForegroundColor Red; exit 1 } + +Write-Host "`n========================================" -ForegroundColor Yellow +Write-Host " JARVIS Agent Installer for Windows" -ForegroundColor Yellow +Write-Host "========================================`n" -ForegroundColor Yellow + +# ── Stop existing service if running ───────────────────────────────────────── +$existing = Get-Service -Name $SERVICE_NAME -ErrorAction SilentlyContinue +if ($existing) { + Write-Step "Stopping existing JARVIS Agent service..." + if ($existing.Status -eq 'Running') { + Stop-Service -Name $SERVICE_NAME -Force + Start-Sleep 2 + } + try { + & python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null + } catch {} + Write-OK "Existing service removed." +} + +# ── Check / install Python ──────────────────────────────────────────────────── +Write-Step "Checking Python..." +$py = Get-Command python -ErrorAction SilentlyContinue +if (-not $py) { + Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run." + } + winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements + $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User") + $py = Get-Command python -ErrorAction SilentlyContinue + if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" } +} +$pyVersion = & python --version 2>&1 +Write-OK $pyVersion + +# ── Install pywin32 ─────────────────────────────────────────────────────────── +Write-Step "Checking pywin32..." +$checkWin32 = & python -c "import win32service; print('ok')" 2>&1 +if ($checkWin32 -ne 'ok') { + Write-Host " Installing pywin32..." -ForegroundColor Yellow + & python -m pip install --quiet pywin32 + & python -m pywin32_postinstall -install 2>$null + Write-OK "pywin32 installed." +} else { + Write-OK "pywin32 already installed." +} + +# ── Create install dir ──────────────────────────────────────────────────────── +Write-Step "Creating install directory..." +New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null +Write-OK $INSTALL_DIR + +# ── Download agent script ───────────────────────────────────────────────────── +Write-Step "Downloading JARVIS agent..." +try { + Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing + Write-OK "Agent downloaded to $AGENT_SCRIPT" +} catch { + Write-Fail "Failed to download agent: $_" +} + +# ── Get registration key ────────────────────────────────────────────────────── +$regKey = $env:JARVIS_REG_KEY +if (-not $regKey -and (Test-Path $CONFIG_FILE)) { + $existingCfg = Get-Content $CONFIG_FILE | ConvertFrom-Json + $regKey = $existingCfg.registration_key + if ($regKey) { Write-OK "Using existing registration key from config." } +} +if (-not $regKey) { + $regKey = Read-Host "`n Enter JARVIS registration key" + if (-not $regKey) { Write-Fail "Registration key required." } +} + +# ── Get hostname ────────────────────────────────────────────────────────────── +$hostname = $env:COMPUTERNAME +$customHostname = $env:JARVIS_HOSTNAME +if ($customHostname) { $hostname = $customHostname } + +# ── Write config ────────────────────────────────────────────────────────────── +Write-Step "Writing config..." +$cfg = @{ + jarvis_url = $JARVIS_URL + registration_key = $regKey + hostname = $hostname + agent_type = 'windows' + ssl_verify = $true + poll_interval = 30 + heartbeat_every = 10 + update_check_hours = 24 + watch_services = @('WinDefend', 'Spooler', 'wuauserv') +} | ConvertTo-Json -Depth 5 +$cfg | Out-File -FilePath $CONFIG_FILE -Encoding utf8 +Write-OK "Config written to $CONFIG_FILE" + +# ── Install Windows Service ─────────────────────────────────────────────────── +Write-Step "Installing Windows service..." +$pyPath = (Get-Command python).Source +& $pyPath "$AGENT_SCRIPT" --startup auto install +if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." } +Write-OK "Service '$SERVICE_NAME' installed." + +# ── Start service ───────────────────────────────────────────────────────────── +Write-Step "Starting service..." +Start-Service -Name $SERVICE_NAME +Start-Sleep 3 +$svc = Get-Service -Name $SERVICE_NAME +if ($svc.Status -ne 'Running') { Write-Fail "Service failed to start. Check C:\ProgramData\jarvis-agent\jarvis-agent.log" } +Write-OK "Service is running." + +# ── Test connectivity ───────────────────────────────────────────────────────── +Write-Step "Testing JARVIS connection..." +try { + $ping = Invoke-RestMethod -Uri "$JARVIS_URL/api/ping" -TimeoutSec 10 + Write-OK "JARVIS is online: $($ping.codename)" +} catch { + Write-Host " WARNING: Could not reach JARVIS at $JARVIS_URL - check connectivity." -ForegroundColor Yellow +} + +Write-Host "`n========================================" -ForegroundColor Green +Write-Host " JARVIS Agent installed successfully!" -ForegroundColor Green +Write-Host " Hostname: $hostname" -ForegroundColor Green +Write-Host " Service: $SERVICE_NAME (auto-start at boot)" -ForegroundColor Green +Write-Host " Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Green +Write-Host "========================================`n" -ForegroundColor Green diff --git a/agent/jarvis-agent-mac.py b/agent/jarvis-agent-mac.py new file mode 100644 index 0000000..69f9247 --- /dev/null +++ b/agent/jarvis-agent-mac.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for macOS — system monitor that reports metrics to JARVIS HUD. +Install: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY +Config: ~/.jarvis-agent/config.json (or $JARVIS_CONFIG) +Logs: ~/.jarvis-agent/jarvis-agent.log (or journalctl via launchd) +""" + +import json +import os +import platform +import re +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +AGENT_VERSION = "3.0" + +# Config/state paths — env vars let the launchd plist override them +_default_dir = Path.home() / ".jarvis-agent" +CONFIG_PATH = Path(os.environ.get("JARVIS_CONFIG", str(_default_dir / "config.json"))) +STATE_PATH = Path(os.environ.get("JARVIS_STATE", str(_default_dir / "state.json"))) +LOG_PATH = _default_dir / "jarvis-agent.log" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +_last_cpu_times = None + +def get_cpu_percent() -> float: + """Delta-based CPU % using top (two samples).""" + global _last_cpu_times + try: + # top -l 2 gives two snapshots; second line has the real delta + r = subprocess.run( + ["top", "-l", "2", "-s", "0", "-n", "0"], + capture_output=True, text=True, timeout=12 + ) + idle = None + for line in r.stdout.splitlines(): + if "CPU usage:" in line: + m = re.search(r"([\d.]+)%\s+idle", line) + if m: + idle = float(m.group(1)) + if idle is not None: + return round(100.0 - idle, 1) + except Exception: + pass + return 0.0 + +def get_memory() -> dict: + try: + # Total physical memory + r = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=3) + total_bytes = int(r.stdout.strip()) + + # vm_stat for page counts; default page size on Apple Silicon and Intel = 4096 + page_size = 4096 + try: + ps_r = subprocess.run(["sysctl", "-n", "hw.pagesize"], capture_output=True, text=True, timeout=3) + page_size = int(ps_r.stdout.strip()) + except Exception: + pass + + vm_r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5) + pages = {} + for line in vm_r.stdout.splitlines(): + m = re.match(r"Pages\s+(.+?):\s+([\d]+)", line) + if m: + pages[m.group(1).strip().lower()] = int(m.group(2)) + + free_pages = pages.get("free", 0) + pages.get("speculative", 0) + # "available" = free + inactive (can be reclaimed) + avail_pages = free_pages + pages.get("inactive", 0) + used_pages = (total_bytes // page_size) - avail_pages + + total_mb = round(total_bytes / (1024 * 1024), 1) + used_mb = round(used_pages * page_size / (1024 * 1024), 1) + avail_mb = round(avail_pages * page_size / (1024 * 1024), 1) + used_mb = max(0, min(used_mb, total_mb)) + + return { + "total_mb": total_mb, + "used_mb": used_mb, + "free_mb": avail_mb, + "percent": round(used_mb / total_mb * 100, 1) if total_mb else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + r = subprocess.run(["df", "-H", "-l"], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines()[1:]: + parts = line.split() + if len(parts) >= 6: + mount = parts[5] + if not any(mount.startswith(x) for x in ["/dev", "/private/var/vm", "/System/Volumes/VM"]): + disks.append({ + "mount": mount, + "size": parts[1], + "used": parts[2], + "avail": parts[3], + "percent": parts[4].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + r = subprocess.run(["sysctl", "-n", "kern.boottime"], capture_output=True, text=True, timeout=3) + m = re.search(r"sec\s*=\s*(\d+)", r.stdout) + if m: + boot_ts = int(m.group(1)) + secs = time.time() - boot_ts + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + pass + return {} + +def get_load() -> list: + try: + r = subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True, timeout=3) + # format: "{ 0.12 0.34 0.56 }" + nums = re.findall(r"[\d.]+", r.stdout) + if len(nums) >= 3: + return [float(nums[0]), float(nums[1]), float(nums[2])] + except Exception: + pass + return [0, 0, 0] + +def get_services(cfg: dict) -> list: + """Check macOS LaunchDaemon/LaunchAgent services via launchctl.""" + watch = cfg.get("watch_services", []) + if not watch: + return [] + statuses = [] + try: + r = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=5) + running = set() + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 3: + running.add(parts[2]) + except Exception: + running = set() + for svc in watch: + status = "active" if any(svc in s for s in running) else "inactive" + statuses.append({"service": svc, "status": status}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + import shutil + caps = ["metrics", "commands"] + if shutil.which("docker"): + caps.append("docker") + if shutil.which("ollama"): + caps.append("ollama") + # screencapture is always available on macOS + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": "macOS", + "os_version": platform.mac_ver()[0], + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "macos") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_mac") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "version": AGENT_VERSION, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile, shutil + tmp = tempfile.mktemp(suffix=".png") + method = "unknown" + + # screencapture -x = no sound, always available on macOS + try: + r = subprocess.run(["screencapture", "-x", "-t", "png", tmp], + capture_output=True, timeout=15) + if r.returncode == 0 and os.path.exists(tmp): + method = "screencapture" + except Exception: + pass + + # Fallback: PIL + if method == "unknown": + try: + from PIL import ImageGrab + img = ImageGrab.grab() + img.save(tmp, "PNG") + method = "pil" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + snap = _sysinfo_snapshot() + snap["screenshot_available"] = False + snap["method"] = "text_only" + return snap + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "macOS"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + load = get_load() + data["load_1m"], data["load_5m"], data["load_15m"] = load[0], load[1], load[2] + except Exception: + pass + try: + r = subprocess.run(["df", "-H", "/"], capture_output=True, text=True, timeout=5) + data["disk"] = r.stdout.splitlines()[1] if r.stdout else "" + except Exception: + pass + try: + r = subprocess.run(["ps", "aux", "-r"], capture_output=True, text=True, timeout=5) + data["top_procs"] = r.stdout.splitlines()[1:8] + except Exception: + pass + try: + r = subprocess.run(["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, timeout=5) + listening = [l for l in r.stdout.splitlines() if "LISTEN" in l] + data["listening_ports"] = "\n".join(listening[:20]) + except Exception: + pass + return data + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2000", host], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + _cfg = load_config() + updated = self_update(_cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["launchctl", "kickstart", "-k", f"system/{svc}"], + capture_output=True, text=True, timeout=15) + if r.returncode != 0: + r = subprocess.run(["brew", "services", "restart", svc], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + r = subprocess.run(["log", "show", "--predicate", f'process == "{svc}"', + "--last", "1h", "--style", "compact"], + capture_output=True, text=True, timeout=15) + output = "\n".join(r.stdout.splitlines()[-lines:]) + return {"success": True, "output": output} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-mac.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (macOS) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while True: + tick_start = time.time() + now = tick_start + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + time.sleep(heartbeat_every) + + +if __name__ == "__main__": + main() diff --git a/agent/jarvis-agent-windows.py b/agent/jarvis-agent-windows.py new file mode 100644 index 0000000..48369ce --- /dev/null +++ b/agent/jarvis-agent-windows.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD. +Runs as a Windows Service (Win 8.1+) via pywin32. + +Install (run PowerShell as Admin): + irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + +Service management: + python jarvis-agent-windows.py --startup auto install # register service + python jarvis-agent-windows.py start # start + python jarvis-agent-windows.py stop # stop + python jarvis-agent-windows.py remove # uninstall + python jarvis-agent-windows.py debug # run in console (for testing) + +Config: C:\ProgramData\jarvis-agent\config.json +Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import threading +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") +CONFIG_PATH = INSTALL_DIR / "config.json" +STATE_PATH = INSTALL_DIR / "state.json" +LOG_PATH = INSTALL_DIR / "jarvis-agent.log" +AGENT_VERSION = "3.1" + +# Set by the service wrapper so self_update knows to stop instead of exec +_is_service = False +_stop_event = threading.Event() + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + with open(LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH, encoding="utf-8") as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH, encoding="utf-8") as f: + return json.load(f) + return {} + +def save_state(state: dict): + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── PowerShell helper ────────────────────────────────────────────────────────── + +def _ps(script: str, timeout: int = 8) -> str: + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=timeout + ) + return r.stdout.strip() + except Exception: + return "" + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def get_cpu_percent() -> float: + try: + out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average") + return round(float(out), 1) + except Exception: + return 0.0 + +def get_memory() -> dict: + try: + out = _ps("$o=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$o.TotalVisibleMemorySize;free=$o.FreePhysicalMemory}|ConvertTo-Json") + d = json.loads(out) + total_kb = int(d.get("total", 0)) + free_kb = int(d.get("free", 0)) + used_kb = total_kb - free_kb + if total_kb == 0: + return {} + return { + "total_mb": round(total_kb / 1024, 1), + "used_mb": round(used_kb / 1024, 1), + "free_mb": round(free_kb / 1024, 1), + "percent": round(used_kb / total_kb * 100, 1), + } + except Exception: + return {} + +def get_disk() -> list: + try: + out = _ps("Get-PSDrive -PSProvider FileSystem | Where-Object{$_.Used -ne $null} | Select-Object Name,@{n='used';e={[math]::Round($_.Used/1GB,2)}},@{n='free';e={[math]::Round($_.Free/1GB,2)}} | ConvertTo-Json") + if not out: + return [] + items = json.loads(out) + if isinstance(items, dict): + items = [items] + disks = [] + for d in items: + used = float(d.get("used", 0)) + free = float(d.get("free", 0)) + total = used + free + pct = round(used / total * 100, 1) if total else 0 + disks.append({ + "mount": d.get("Name", "?") + ":\\", + "size": f"{round(total, 1)}G", + "used": f"{used}G", + "avail": f"{free}G", + "percent": str(int(pct)), + }) + return disks + except Exception: + return [] + +def get_uptime() -> dict: + try: + out = _ps("(Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalSeconds") + secs = float(out) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", ["WinDefend", "Spooler"]) + statuses = [] + for svc in watch: + try: + out = _ps(f"(Get-Service -Name '{svc}' -ErrorAction SilentlyContinue).Status") + status = "active" if out.lower() == "running" else (out.lower() or "unknown") + statuses.append({"service": svc, "status": status}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + import shutil + if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists(): + caps.append("docker") + # Screenshot via PIL or PowerShell .NET (always available on Windows) + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": [0, 0, 0], + "services": get_services(cfg), + "platform": "Windows", + "os_version": platform.version(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname().lower()) + agent_type = cfg.get("agent_type", "windows") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_windows") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "version": AGENT_VERSION, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile + tmp = str(INSTALL_DIR / "screenshot_tmp.png") + method = "unknown" + + # Try PIL/Pillow first + try: + from PIL import ImageGrab + img = ImageGrab.grab(all_screens=True) + img.save(tmp, "PNG") + method = "pil" + except ImportError: + pass + except Exception: + pass + + # Fallback: PowerShell .NET screenshot (no extra packages needed) + if method == "unknown": + ps_script = ( + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; " + "$s=[System.Windows.Forms.SystemInformation]::VirtualScreen; " + "$bmp=New-Object System.Drawing.Bitmap($s.Width,$s.Height); " + "$g=[System.Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($s.Location,[System.Drawing.Point]::Empty,$s.Size); " + f"$bmp.Save('{tmp}'); $g.Dispose(); $bmp.Dispose()" + ) + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script], + capture_output=True, timeout=15 + ) + if r.returncode == 0 and os.path.exists(tmp): + method = "powershell" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + return _sysinfo_snapshot() + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "Windows"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + data["cpu_percent"] = get_cpu_percent() + except Exception: + pass + try: + data["disk"] = get_disk() + except Exception: + pass + try: + ut = get_uptime() + data["uptime"] = ut.get("human", "") + except Exception: + pass + try: + out = _ps("Get-Process | Sort-Object CPU -Descending | Select-Object -First 8 Name,CPU,WorkingSet | ConvertTo-Json") + data["top_procs"] = json.loads(out) if out else [] + except Exception: + pass + try: + out = _ps("netstat -an | findstr LISTENING | head -20") + data["listening_ports"] = out[:800] + except Exception: + pass + return data + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + # Verify hash + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + if _is_service: + # Signal the main loop to exit; SCM failure-recovery will restart us + log("Running as service — stopping for SCM-managed restart after update.") + _stop_event.set() + else: + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict, cfg: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-n", "3", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + log("[CMD] Self-update requested") + updated = self_update(cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + # Guard reads LOCAL config — never trust server-supplied flags + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd_str], + capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Restart-Service -Name '{svc}' -Force -ErrorAction Stop; 'ok'") + return {"success": "ok" in out.lower(), "output": out} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Get-EventLog -LogName Application -Source '{svc}' -Newest {lines} -ErrorAction SilentlyContinue | Format-List TimeGenerated,EntryType,Message | Out-String") + return {"success": True, "output": out[:3000]} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + if _stop_event.wait(60): + return + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (Windows) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while not _stop_event.is_set(): + now = time.time() + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd, cfg) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + if _stop_event.wait(heartbeat_every): + break # service stop was requested + + log("Agent stopped.") + + +# ── Windows Service wrapper ──────────────────────────────────────────────────── + +try: + import win32serviceutil + import win32service + import win32event + import servicemanager + _HAS_WIN32 = True +except ImportError: + _HAS_WIN32 = False + +if _HAS_WIN32: + class JarvisAgentService(win32serviceutil.ServiceFramework): + _svc_name_ = "JARVISAgent" + _svc_display_name_ = "JARVIS AI Agent" + _svc_description_ = "JARVIS system monitoring and AI agent — reports metrics to JARVIS HUD" + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self._svc_stop_event = win32event.CreateEvent(None, 0, 0, None) + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + _stop_event.set() + win32event.SetEvent(self._svc_stop_event) + + def SvcDoRun(self): + global _is_service + _is_service = True + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + main() + + +if __name__ == "__main__": + if _HAS_WIN32: + if len(sys.argv) == 1: + # No args — SCM is starting us as a service + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(JarvisAgentService) + servicemanager.StartServiceCtrlDispatcher() + else: + # install / start / stop / remove / debug + win32serviceutil.HandleCommandLine(JarvisAgentService) + else: + # pywin32 not available — run directly (useful for testing) + main() diff --git a/agent/jarvis-agent.py b/agent/jarvis-agent.py new file mode 100755 index 0000000..d237438 --- /dev/null +++ b/agent/jarvis-agent.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent — lightweight system monitor for Linux machines. +Registers with JARVIS, reports metrics, and executes commands. + +Install: sudo bash /opt/jarvis-agent/install.sh +Config: /etc/jarvis-agent/config.json +Logs: journalctl -u jarvis-agent -f +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import uuid +from datetime import datetime +from pathlib import Path + +CONFIG_PATH = "/etc/jarvis-agent/config.json" +STATE_PATH = "/var/lib/jarvis-agent/state.json" +AGENT_VERSION = "3.1" + +# ── Config helpers ──────────────────────────────────────────────────────────── + +def load_config() -> dict: + legacy_path = "/opt/jarvis-agent/config.json" + + if not os.path.exists(CONFIG_PATH): + if os.path.exists(legacy_path): + print(f"[JARVIS] Config found at legacy path {legacy_path} - migrating...", flush=True) + Path(CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(legacy_path) as f: + cfg = json.load(f) + else: + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True) + sys.exit(1) + else: + with open(CONFIG_PATH) as f: + cfg = json.load(f) + + # Migrate old key names so the agent self-heals instead of crash-looping + import re as _re + changed = False + if "server_url" in cfg and "jarvis_url" not in cfg: + cfg["jarvis_url"] = cfg.pop("server_url") + print("[JARVIS] Config migrated: server_url -> jarvis_url", flush=True) + changed = True + if "api_key" in cfg and "registration_key" not in cfg: + cfg["registration_key"] = cfg.pop("api_key") + print("[JARVIS] Config migrated: api_key -> registration_key", flush=True) + changed = True + if "hostname" not in cfg: + cfg["hostname"] = socket.gethostname() + changed = True + if "ssl_verify" not in cfg: + cfg["ssl_verify"] = not bool(_re.match(r"https?://\d+\.\d+\.\d+\.\d+", cfg.get("jarvis_url", ""))) + changed = True + + if changed: + with open(CONFIG_PATH, "w") as f: + json.dump(cfg, f, indent=2) + print("[JARVIS] Config saved after migration.", flush=True) + + return cfg + +def load_state() -> dict: + if os.path.exists(STATE_PATH): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool) -> _ssl.SSLContext | None: + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" # set from config at startup + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Registration ────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + # Check for Proxmox + if os.path.exists("/usr/bin/pvesh") or os.path.exists("/usr/sbin/pveversion"): + caps.append("proxmox") + # Check for Docker + if os.path.exists("/usr/bin/docker") or os.path.exists("/usr/local/bin/docker"): + caps.append("docker") + # Check for Ollama + if os.path.exists("/usr/local/bin/ollama") or os.path.exists("/usr/bin/ollama"): + caps.append("ollama") + # Check for Home Assistant + if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"): + caps.append("homeassistant") + return caps + +def register(cfg: dict, state: dict) -> str: + """Register with JARVIS. Returns api_key.""" + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "linux") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_{socket.gethostname()[:8]}") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + print(f"[JARVIS] Registering as '{agent_id}' ({agent_type}) from {ip}...", flush=True) + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + { + "hostname": hostname, + "version": AGENT_VERSION, + "agent_type": agent_type, + "ip_address": ip, + "capabilities": capabilities, + "agent_id": agent_id, + }, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + print(f"[ERROR] Registration failed: {result['error']}", flush=True) + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + print(f"[JARVIS] Registered. agent_id={state['agent_id']}", flush=True) + return api_key + +# ── Metrics collection ──────────────────────────────────────────────────────── + +def read_cpu_percent() -> float: + try: + with open("/proc/stat") as f: + line = f.readline() + fields = list(map(int, line.split()[1:])) + idle = fields[3] + total = sum(fields) + return round((1 - idle / total) * 100, 1) if total else 0.0 + except Exception: + return 0.0 + +_last_cpu = None + +def get_cpu_percent() -> float: + global _last_cpu + try: + with open("/proc/stat") as f: + line = f.readline() + fields = list(map(int, line.split()[1:])) + idle = fields[3] + fields[4] # idle + iowait + total = sum(fields) + if _last_cpu: + d_idle = idle - _last_cpu[0] + d_total = total - _last_cpu[1] + result = round((1 - d_idle / d_total) * 100, 1) if d_total else 0.0 + else: + result = 0.0 + _last_cpu = (idle, total) + return result + except Exception: + return 0.0 + +def get_memory() -> dict: + mem = {} + try: + with open("/proc/meminfo") as f: + for line in f: + parts = line.split() + if parts[0] in ("MemTotal:", "MemAvailable:", "MemFree:", "Buffers:", "Cached:"): + mem[parts[0].rstrip(":")] = int(parts[1]) + total = mem.get("MemTotal", 0) + available = mem.get("MemAvailable", 0) + used = total - available + return { + "total_mb": round(total / 1024, 1), + "used_mb": round(used / 1024, 1), + "free_mb": round(available / 1024, 1), + "percent": round(used / total * 100, 1) if total else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + result = subprocess.run(["df", "-h", "--output=source,fstype,size,used,avail,pcent,target"], + capture_output=True, text=True, timeout=5) + lines = result.stdout.strip().split("\n")[1:] + for line in lines: + parts = line.split() + if len(parts) >= 7: + mount = parts[6] + if not any(mount.startswith(x) for x in ["/sys", "/proc", "/dev/pts", "/run", "/snap"]): + disks.append({ + "mount": mount, + "size": parts[2], + "used": parts[3], + "avail": parts[4], + "percent": parts[5].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + with open("/proc/uptime") as f: + secs = float(f.read().split()[0]) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", []) + statuses = [] + for svc in watch: + try: + r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3) + statuses.append({"service": svc, "status": r.stdout.strip()}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def get_load() -> list: + try: + with open("/proc/loadavg") as f: + parts = f.read().split() + return [float(parts[0]), float(parts[1]), float(parts[2])] + except Exception: + return [0, 0, 0] + +def get_nordvpn_status() -> dict | None: + """Check nordlynx WireGuard interface. Returns None if nordlynx not present on this host.""" + try: + r = subprocess.run(["ip", "link", "show", "nordlynx"], + capture_output=True, text=True, timeout=3) + if r.returncode != 0: + return None + active = "UP,LOWER_UP" in r.stdout or "state UP" in r.stdout + return {"active": active, "interface": "nordlynx"} + except Exception: + return None + +def collect_metrics(cfg: dict) -> dict: + # First reading for CPU delta + get_cpu_percent() + time.sleep(1) + metrics = { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": platform.system(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + nordvpn = get_nordvpn_status() + if nordvpn is not None: + metrics["nordvpn"] = nordvpn + return metrics + +# ── Proxmox metrics ─────────────────────────────────────────────────────────── + +def collect_proxmox_metrics(cfg: dict) -> dict | None: + try: + result = subprocess.run( + ["pvesh", "get", "/nodes/pve/status", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + node_status = json.loads(result.stdout) + vms_result = subprocess.run( + ["pvesh", "get", "/nodes/pve/qemu", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + vms = json.loads(vms_result.stdout) + return {"node": node_status, "vms": vms} + except Exception as e: + return {"error": str(e)} + +# ── Command execution ───────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + + try: + if cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["systemctl", "restart", svc], capture_output=True, text=True, timeout=30) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["journalctl", "-u", svc, "-n", str(lines), "--no-pager"], + capture_output=True, text=True, timeout=15) + return {"success": True, "output": r.stdout} + + elif cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + updated = self_update(cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + # Only allow if explicitly enabled in config + if not cmd_data.get("allowed", False): + return {"success": False, "error": "Shell commands not enabled"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ───────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + + # Register if no API key yet + api_key = state.get("api_key", "") + if not api_key: + api_key = register(cfg, state) + if not api_key: + print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) + time.sleep(60) + main() + return + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + tick = 0 + + print(f"[JARVIS] Agent v{AGENT_VERSION} running. Polling {jarvis_url} every {heartbeat_every}s.", flush=True) + + while True: + tick += 1 + now = time.time() + + try: + # Heartbeat + get commands + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {"version": AGENT_VERSION}, headers, ssl_verify=ssl_verify) + if "error" in hb: + print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True) + else: + commands = hb.get("commands", []) + for cmd in commands: + print(f"[CMD] Executing: {cmd['command_type']}", flush=True) + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check (every update_interval seconds, default 24h) + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) # restarts process if update found + + # Push metrics every poll_interval seconds + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + + # Proxmox metrics if available + if "proxmox" in detect_capabilities(cfg): + px = collect_proxmox_metrics(cfg) + if px: + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "proxmox", "data": px}, headers, ssl_verify=ssl_verify) + + last_metrics = now + + except Exception as e: + print(f"[ERROR] Loop error: {e}", flush=True) + + time.sleep(heartbeat_every) + + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + """Check JARVIS server for a newer version of this script. If different, replace and restart.""" + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + try: + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent/1.0") + with urllib.request.urlopen(req, timeout=30) as resp: + new_content = resp.read() + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + print(f"[JARVIS] Self-update check failed: {e}", flush=True) + return False + + +if __name__ == "__main__": + main() diff --git a/agent/jarvis-arc-reactor.py b/agent/jarvis-arc-reactor.py new file mode 100644 index 0000000..5bbba82 --- /dev/null +++ b/agent/jarvis-arc-reactor.py @@ -0,0 +1,2773 @@ +#!/usr/bin/env python3 +""" +JARVIS Arc Reactor — Core Daemon v9.0 +Phase 1: Job queue, ping/echo/shell +Phase 2: Intel Protocol (research), Iron Protocol (tool loop), LLM router +Phase 3: Gmail Triage (Comms Protocol), Remote Exec (Field Protocol) +Phase 4: Vision Protocol (screenshot, vision, sysinfo) +Phase 5: Guardian Mode (continuous awareness, proactive alerts, SITREP) +Phase 6: Comms v2 — send email, compose, schedule event, meeting prep +Phase 7: Mission Ops — multi-step automated workflows with trigger engine +Phase 8: Mission Directives — OKR/goal tracking with AI progress review +Phase 9: Clearance Protocol — approval gating for high-risk operations +""" + +import asyncio +import email as _email_lib +import imaplib +import json +import logging +import os +import re +import sys +import traceback +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from email.header import decode_header as _decode_header +from email.utils import parsedate_to_datetime +from typing import Any, Optional + +import aiomysql +import aiohttp +import uvicorn +from fastapi import FastAPI, HTTPException, Request + +# ── CONFIG ──────────────────────────────────────────────────────────────────── +HOST = "127.0.0.1" +PORT = 7474 +VERSION = "9.0.0" +DB_HOST = "localhost" +DB_PORT = 3306 +DB_USER = "jarvis_user" +DB_PASS = "J4rv1s_Pr0t0c0l_2026!" +DB_NAME = "jarvis_db" +LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log" +POLL_INTERVAL = 3 +HEARTBEAT_INTERVAL = 30 + +CLAUDE_API_KEY = "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" +CLAUDE_MODEL = "claude-sonnet-4-6" +GROQ_API_KEY = "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" +GROQ_MODEL = "llama-3.3-70b-versatile" +OLLAMA_HOST = "http://10.48.200.95:11434" +OLLAMA_MODEL = "llama3.2:1b" + +GMAIL_USER = "myronblair@gmail.com" +GMAIL_PASS = "demsvdylwweacbcx" +ICLOUD_USER = "myronblair@icloud.com" +ICLOUD_PASS = "yxfi-yvzu-geqk-japr" + +# ── LOGGING ─────────────────────────────────────────────────────────────────── +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger("arc_reactor") + +# ── DB POOL ─────────────────────────────────────────────────────────────────── +_pool: Optional[aiomysql.Pool] = None + +async def get_pool() -> aiomysql.Pool: + global _pool + if _pool is None or _pool.closed: + _pool = await aiomysql.create_pool( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS, + db=DB_NAME, autocommit=True, minsize=2, maxsize=10, charset="utf8mb4", + ) + return _pool + +async def db_execute(sql: str, args: tuple = ()) -> int: + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(sql, args) + return cur.lastrowid + +async def db_fetchone(sql: str, args: tuple = ()) -> Optional[dict]: + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(sql, args) + return await cur.fetchone() + +async def db_fetchall(sql: str, args: tuple = ()) -> list: + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(sql, args) + return await cur.fetchall() + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 1 HANDLERS +# ═══════════════════════════════════════════════════════════════════════════════ + +async def handle_ping(payload: dict) -> dict: + return {"pong": True, "timestamp": datetime.utcnow().isoformat(), "version": VERSION} + +async def handle_echo(payload: dict) -> dict: + return {"echo": payload.get("message", ""), "timestamp": datetime.utcnow().isoformat()} + +async def handle_shell(payload: dict) -> dict: + cmd = payload.get("command", "").strip() + allowed = ("df ", "free ", "uptime", "hostname", "uname", "ps ", "cat /proc/") + if not any(cmd.startswith(p) for p in allowed): + raise ValueError(f"Command not in whitelist: {cmd[:40]}") + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15) + return {"stdout": stdout.decode().strip(), "stderr": stderr.decode().strip(), "exit_code": proc.returncode} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 2: LLM ROUTER +# ═══════════════════════════════════════════════════════════════════════════════ + +async def llm_call(messages: list, provider: str = "claude", system: str = "") -> str: + if provider == "claude" and CLAUDE_API_KEY: + return await _claude_call(messages, system) + elif provider == "groq" and GROQ_API_KEY: + return await _groq_call(messages, system) + elif provider == "ollama": + return await _ollama_call(messages, system) + for p in ["groq", "ollama"]: + try: + return await llm_call(messages, p, system) + except Exception: + continue + raise RuntimeError("All LLM providers failed") + +async def _claude_call(messages: list, system: str = "") -> str: + payload = {"model": CLAUDE_MODEL, "max_tokens": 4096, "messages": messages} + if system: + payload["system"] = system + headers = {"x-api-key": CLAUDE_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"} + async with aiohttp.ClientSession() as session: + async with session.post("https://api.anthropic.com/v1/messages", json=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=60)) as resp: + data = await resp.json() + if resp.status != 200: + raise RuntimeError(f"Claude API error {resp.status}: {data.get('error',{}).get('message','')}") + return data["content"][0]["text"] + +async def _groq_call(messages: list, system: str = "") -> str: + all_msgs = ([{"role": "system", "content": system}] if system else []) + messages + payload = {"model": GROQ_MODEL, "messages": all_msgs, "max_tokens": 4096} + headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"} + async with aiohttp.ClientSession() as session: + async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload, + headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp: + data = await resp.json() + if resp.status != 200: + raise RuntimeError(f"Groq error {resp.status}") + return data["choices"][0]["message"]["content"] + +async def _ollama_call(messages: list, system: str = "") -> str: + prompt = (system + "\n\n" if system else "") + "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages) + async with aiohttp.ClientSession() as session: + async with session.post(f"{OLLAMA_HOST}/api/generate", json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False}, + timeout=aiohttp.ClientTimeout(total=30)) as resp: + data = await resp.json() + return data.get("response", "") + +async def handle_llm(payload: dict) -> dict: + message = payload.get("message", "") + system = payload.get("system", "You are JARVIS, an Iron Man-style AI assistant.") + provider = payload.get("provider", "claude") + if not message: + raise ValueError("Missing message") + result = await llm_call([{"role": "user", "content": message}], provider, system) + return {"response": result, "provider": provider} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 2: INTEL PROTOCOL — RESEARCH ENGINE +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _web_search(query: str, max_results: int = 6) -> list: + try: + from duckduckgo_search import DDGS + results = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + results.append({"url": r.get("href",""), "title": r.get("title",""), "snippet": r.get("body","")}) + return results + except Exception as e: + log.warning(f"DDG search failed: {e}") + return [] + +async def _fetch_url_content(url: str, timeout: int = 12) -> str: + try: + import trafilatura + headers = {"User-Agent": "Mozilla/5.0 (compatible; JARVIS-Research/3.0)"} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout), + allow_redirects=True, ssl=False) as resp: + if resp.status != 200: + return "" + html = await resp.text(errors="replace") + text = trafilatura.extract(html, include_links=False, include_images=False, no_fallback=False, favor_precision=True) + return (text or "")[:4000] + except Exception as e: + log.debug(f"Fetch failed {url[:60]}: {e}") + return "" + +async def handle_research(payload: dict) -> dict: + query = payload.get("query", "").strip() + depth = payload.get("depth", "standard") + provider = payload.get("provider", "claude") + if not query: + raise ValueError("Missing research query") + + max_sources = {"quick": 3, "standard": 5, "deep": 8}.get(depth, 5) + log.info(f"[INTEL] Research: '{query}' depth={depth} sources={max_sources}") + + search_results = await _web_search(query, max_results=max_sources + 2) + if not search_results: + search_results = [{"url": "", "title": query, "snippet": f"No search results for: {query}"}] + + fetch_tasks = [_fetch_url_content(r["url"]) for r in search_results if r.get("url")] + page_texts = await asyncio.gather(*fetch_tasks, return_exceptions=True) + + sources = [] + for i, r in enumerate(search_results[:max_sources]): + text = page_texts[i] if i < len(page_texts) and isinstance(page_texts[i], str) else "" + sources.append({"url": r["url"], "title": r["title"], "snippet": r["snippet"], "content": text}) + + context_parts = [] + for i, s in enumerate(sources, 1): + body = s["content"] or s["snippet"] + if body: + context_parts.append(f"SOURCE {i}: {s['title']}\nURL: {s['url']}\n{body[:3000]}\n") + + context_text = "\n---\n".join(context_parts) if context_parts else "No page content available." + synthesis_prompt = ( + f'You are JARVIS, an advanced AI research assistant. The user asked: "{query}"\n\n' + f"You have retrieved the following source material:\n\n{context_text}\n\n" + f"Provide a comprehensive research briefing with:\n" + f"1. **EXECUTIVE SUMMARY** (2-3 sentences)\n" + f"2. **KEY FINDINGS** (5-8 bullet points)\n" + f"3. **DETAILS** (thorough synthesis, 2-4 paragraphs)\n" + f"4. **CONFIDENCE** (High/Medium/Low based on source quality)\n\n" + f"Be precise, factual, and cite source numbers where relevant." + ) + + try: + synthesis = await llm_call([{"role": "user", "content": synthesis_prompt}], provider=provider) + except Exception as e: + log.warning(f"[INTEL] Synthesis failed, falling back: {e}") + synthesis = f"**RESEARCH BRIEFING: {query}**\n\n" + "\n\n".join(f"**{s['title']}**\n{s['snippet']}" for s in sources) + + key_points = [] + for line in synthesis.split("\n"): + line = line.strip() + if line.startswith(("- ", "• ", "* ")) and len(line) > 10: + key_points.append(re.sub(r'^[-•*]\s*', '', line)) + elif re.match(r'^\d+\.\s+', line) and len(line) > 10: + key_points.append(re.sub(r'^\d+\.\s+', '', line)) + + clean_sources = [{"url": s["url"], "title": s["title"] or s["url"]} for s in sources if s.get("url")] + log.info(f"[INTEL] Research complete: '{query}' — {len(sources)} sources") + return {"query": query, "depth": depth, "synthesis": synthesis, "key_points": key_points[:10], + "sources": clean_sources, "source_count": len(clean_sources), "provider": provider} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 2: IRON PROTOCOL — MULTI-STEP TOOL LOOP +# ═══════════════════════════════════════════════════════════════════════════════ + +AGENT_TOOLS = [ + {"name": "web_search", "description": "Search the web for current information.", + "input_schema": {"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer", "default": 5}}, "required": ["query"]}}, + {"name": "fetch_url", "description": "Fetch a web page and extract its main text content.", + "input_schema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}, + {"name": "jarvis_agents", "description": "Get status of all JARVIS agents.", + "input_schema": {"type": "object", "properties": {}}}, + {"name": "jarvis_alerts", "description": "Get current active JARVIS alerts.", + "input_schema": {"type": "object", "properties": {}}}, + {"name": "current_time", "description": "Get current date and time.", + "input_schema": {"type": "object", "properties": {}}}, +] + +async def execute_agent_tool(tool_name: str, tool_input: dict) -> str: + try: + if tool_name == "web_search": + results = await _web_search(tool_input.get("query", ""), tool_input.get("max_results", 5)) + if not results: + return "No search results found." + return "\n".join(f"{i+1}. {r['title']}\n URL: {r['url']}\n {r['snippet']}" for i, r in enumerate(results)) + elif tool_name == "fetch_url": + url = tool_input.get("url", "") + if not url: + return "No URL provided." + return await _fetch_url_content(url) or "Could not extract content." + elif tool_name == "jarvis_agents": + agents = await db_fetchall("SELECT hostname, agent_type, ip_address, status, last_seen FROM registered_agents ORDER BY status='online' DESC, hostname") + if not agents: + return "No agents registered." + return "\n".join(f"- {a['hostname']} ({a['agent_type']}) @ {a['ip_address']} — {a['status'].upper()}" for a in agents) + elif tool_name == "jarvis_alerts": + alerts = await db_fetchall("SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10") + return "\n".join(f"- [{a['severity'].upper()}] {a['title']}: {a['message']}" for a in alerts) or "No active alerts." + elif tool_name == "current_time": + return datetime.utcnow().strftime("UTC: %Y-%m-%d %H:%M:%S") + else: + return f"Unknown tool: {tool_name}" + except Exception as e: + return f"Tool error ({tool_name}): {str(e)[:200]}" + +async def handle_tool_loop(payload: dict) -> dict: + task = payload.get("task", "").strip() + max_iterations = min(int(payload.get("max_iterations", 15)), 200) + system_prompt = payload.get("system", ( + "You are JARVIS, an advanced autonomous AI assistant with access to tools. " + "Use tools methodically to complete the task. Be thorough but concise. " + "When you have enough information, provide a clear final answer." + )) + if not task: + raise ValueError("Missing task") + + log.info(f"[IRON] Tool loop: '{task[:80]}' max_iter={max_iterations}") + + import anthropic + client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY) + messages = [{"role": "user", "content": task}] + iteration = 0 + final_text = "" + + while iteration < max_iterations: + iteration += 1 + response = await client.messages.create( + model=CLAUDE_MODEL, max_tokens=4096, system=system_prompt, + tools=AGENT_TOOLS, messages=messages, + ) + assistant_content = [] + text_parts = [] + tool_uses = [] + for block in response.content: + if block.type == "text": + assistant_content.append({"type": "text", "text": block.text}) + text_parts.append(block.text) + elif block.type == "tool_use": + assistant_content.append({"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}) + tool_uses.append(block) + messages.append({"role": "assistant", "content": assistant_content}) + if text_parts: + final_text = "\n".join(text_parts) + if response.stop_reason == "end_turn" or not tool_uses: + log.info(f"[IRON] Complete after {iteration} iterations") + break + tool_results = [] + for tu in tool_uses: + log.info(f"[IRON] Tool: {tu.name}") + result = await execute_agent_tool(tu.name, tu.input) + tool_results.append({"type": "tool_result", "tool_use_id": tu.id, "content": result[:3000]}) + messages.append({"role": "user", "content": tool_results}) + + tools_used = list({b["name"] for m in messages if isinstance(m["content"], list) + for b in m["content"] if isinstance(b, dict) and b.get("type") == "tool_use"}) + return {"task": task, "result": final_text, "iterations": iteration, "tools_used": tools_used} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 3: COMMS PROTOCOL — GMAIL TRIAGE +# ═══════════════════════════════════════════════════════════════════════════════ + +def _imap_fetch_sync(account: str, max_msgs: int) -> list: + """Synchronous IMAP fetch — called via run_in_executor.""" + if account == "icloud": + host, port, user, passwd = "imap.mail.me.com", 993, ICLOUD_USER, ICLOUD_PASS + else: + host, port, user, passwd = "imap.gmail.com", 993, GMAIL_USER, GMAIL_PASS + if not user or not passwd: + return [] + try: + mbox = imaplib.IMAP4_SSL(host, port) + mbox.login(user, passwd) + mbox.select("INBOX") + _, data = mbox.search(None, "ALL") + ids = data[0].split()[-max_msgs:] + msgs = [] + for mid in reversed(ids): + _, raw = mbox.fetch(mid, "(RFC822)") + if not raw or not raw[0]: + continue + msg = _email_lib.message_from_bytes(raw[0][1]) + # Subject + subj_raw = msg.get("Subject", "") + subject = "" + for part, enc in _decode_header(subj_raw): + if isinstance(part, bytes): + subject += part.decode(enc or "utf-8", errors="replace") + else: + subject += str(part) + # From + from_raw = msg.get("From", "") + from_name = "" + for part, enc in _decode_header(from_raw): + if isinstance(part, bytes): + from_name += part.decode(enc or "utf-8", errors="replace") + else: + from_name += str(part) + from_name = from_name.strip().strip('"') + em = re.search(r"<([^>]+)>", from_raw) + from_email = em.group(1) if em else from_raw + # Date + date_str = msg.get("Date", "") + # Body preview + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + body = payload.decode(part.get_content_charset() or "utf-8", errors="replace") + break + else: + payload = msg.get_payload(decode=True) + if payload: + body = payload.decode(msg.get_content_charset() or "utf-8", errors="replace") + body = " ".join(body.split())[:500] + msg_uid = (msg.get("Message-ID", "") or f"{from_email}:{subject}:{date_str}").strip("<>") + msgs.append({ + "msg_id": msg_uid[:255], + "from_name": from_name[:100], + "from_email": from_email[:100], + "subject": subject[:255], + "date_raw": date_str, + "body": body, + }) + mbox.logout() + return msgs + except Exception as e: + log.warning(f"IMAP fetch failed ({account}): {e}") + return [] + +async def handle_gmail_triage(payload: dict) -> dict: + account = payload.get("account", "gmail") + max_emails = min(int(payload.get("max_emails", 20)), 40) + provider = payload.get("provider", "claude") + + log.info(f"[COMMS] Gmail triage: account={account} max={max_emails}") + + loop = asyncio.get_event_loop() + emails = await loop.run_in_executor(None, lambda: _imap_fetch_sync(account, max_emails)) + + if not emails: + return {"account": account, "triaged": 0, "urgent": 0, "action": 0, + "meeting": 0, "items": [], "error": "No emails fetched or IMAP unavailable"} + + email_list = "" + for i, e in enumerate(emails, 1): + email_list += ( + f"EMAIL {i}:\n" + f"From: {e['from_name']} <{e['from_email']}>\n" + f"Subject: {e['subject']}\n" + f"Date: {e['date_raw']}\n" + f"Body: {e['body'][:400]}\n\n" + ) + + triage_prompt = ( + f"You are JARVIS, triaging {len(emails)} emails for Myron Blair.\n\n" + "For each email assign:\n" + "- category: urgent | action | reply | meeting | info | promo | spam\n" + "- priority: 1-10 (10 = drop everything)\n" + "- summary: one sentence\n" + "- draft_reply: for urgent/action/reply/meeting ONLY — brief professional reply. Empty string otherwise.\n\n" + "Return ONLY a valid JSON array. Example:\n" + '[{"index":1,"category":"urgent","priority":9,"summary":"Contract needs signing today","draft_reply":"Hi, I will review and sign today."}]\n\n' + f"EMAILS TO TRIAGE:\n{email_list}" + ) + + try: + raw = await llm_call([{"role": "user", "content": triage_prompt}], provider) + raw = raw.strip() + if raw.startswith("```"): + raw = "\n".join(raw.split("\n")[1:]) + if raw.endswith("```"): + raw = raw[:-3] + triage_items = json.loads(raw.strip()) + except Exception as e: + log.warning(f"[COMMS] Triage parse error: {e}") + triage_items = [{"index": i+1, "category": "info", "priority": 3, + "summary": f"Subject: {e2['subject']}", "draft_reply": ""} + for i, e2 in enumerate(emails)] + + # Save to email_triage table + saved = 0 + for item in triage_items: + idx = int(item.get("index", 0)) - 1 + if idx < 0 or idx >= len(emails): + continue + e = emails[idx] + try: + date_parsed = None + if e.get("date_raw"): + try: + date_parsed = parsedate_to_datetime(e["date_raw"]).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + pass + await db_execute( + """INSERT INTO email_triage + (msg_id, account, from_name, from_email, subject, date_received, + category, priority, summary, draft_reply, action_taken) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'none') + ON DUPLICATE KEY UPDATE + category=VALUES(category), priority=VALUES(priority), + summary=VALUES(summary), draft_reply=VALUES(draft_reply)""", + (e["msg_id"], account, e["from_name"], e["from_email"], e["subject"], + date_parsed, item.get("category", "info"), int(item.get("priority", 3)), + item.get("summary", "")[:500], item.get("draft_reply", "")[:3000]) + ) + saved += 1 + except Exception as ex: + log.debug(f"[COMMS] Save triage error: {ex}") + + counts = {} + for item in triage_items: + c = item.get("category", "info") + counts[c] = counts.get(c, 0) + 1 + + urgent_items = [] + for it in triage_items: + idx = int(it.get("index", 0)) - 1 + if 0 <= idx < len(emails) and it.get("category") in ("urgent", "action", "reply", "meeting"): + urgent_items.append({ + "from": emails[idx]["from_name"], + "from_email": emails[idx]["from_email"], + "subject": emails[idx]["subject"][:80], + "summary": it.get("summary", ""), + "priority": it.get("priority", 0), + "draft_reply": it.get("draft_reply", ""), + "category": it.get("category", ""), + }) + urgent_items.sort(key=lambda x: -x["priority"]) + + log.info(f"[COMMS] Triage complete: {len(emails)} emails, {saved} saved, counts={counts}") + return { + "account": account, + "triaged": len(emails), + "saved": saved, + "counts": counts, + "urgent": counts.get("urgent", 0), + "action": counts.get("action", 0), + "meeting": counts.get("meeting", 0), + "reply": counts.get("reply", 0), + "items": urgent_items[:10], + } + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 3: FIELD PROTOCOL — REMOTE EXEC +# ═══════════════════════════════════════════════════════════════════════════════ + +async def handle_remote_exec(payload: dict) -> dict: + """ + Queue a command to a JARVIS Field Station agent and wait for the result. + payload: { agent, command_type, command_data, timeout } + """ + agent_name = payload.get("agent", "").strip() + cmd_type = payload.get("command_type", "ping") + cmd_data = payload.get("command_data", {}) + timeout_secs = min(int(payload.get("timeout", 35)), 120) + + if not agent_name: + raise ValueError("Missing agent name") + + log.info(f"[FIELD] remote_exec {cmd_type} on {agent_name}") + + # NOTE: _dispatch_agent_command is defined in Phase 4 block below. + # Forward declaration — called at runtime, not at import. + dispatch = await _dispatch_agent_command(agent_name, cmd_type, cmd_data, timeout_secs) + return { + "agent": dispatch["agent"], + "agent_id": dispatch["agent_id"], + "command_type": cmd_type, + "command_data": cmd_data, + "cmd_id": dispatch["cmd_id"], + "status": dispatch["status"], + "result": dispatch["result"], + } + +# ── PHASE 4: VISION PROTOCOL ────────────────────────────────────────────────── + +async def _dispatch_agent_command(agent_name: str, cmd_type: str, cmd_data: dict, + timeout_secs: int = 45) -> dict: + """Shared helper: find agent, queue command, poll for result.""" + agent = await db_fetchone( + """SELECT agent_id, hostname, status FROM registered_agents + WHERE (hostname LIKE %s OR agent_id LIKE %s) AND status='online' LIMIT 1""", + (f"%{agent_name}%", f"%{agent_name}%") + ) + if not agent: + all_agents = await db_fetchall("SELECT hostname FROM registered_agents WHERE status='online'") + names = [a["hostname"] for a in all_agents] + raise ValueError(f"No online agent matching '{agent_name}'. Online: {', '.join(names) or 'none'}") + + cmd_id = await db_execute( + "INSERT INTO agent_commands (agent_id, command_type, command_data) VALUES (%s, %s, %s)", + (agent["agent_id"], cmd_type, json.dumps(cmd_data)) + ) + elapsed = 0 + while elapsed < timeout_secs: + await asyncio.sleep(2) + elapsed += 2 + row = await db_fetchone("SELECT status, result FROM agent_commands WHERE id=%s", (cmd_id,)) + if row and row["status"] in ("executed", "failed"): + result = row.get("result") + if result and isinstance(result, str): + try: + result = json.loads(result) + except Exception: + pass + return {"agent": agent["hostname"], "agent_id": agent["agent_id"], + "cmd_id": cmd_id, "status": row["status"], "result": result or {}} + await db_execute("UPDATE agent_commands SET status='failed' WHERE id=%s AND status='pending'", (cmd_id,)) + raise TimeoutError(f"No response from {agent['hostname']} within {timeout_secs}s") + + +async def handle_screenshot(payload: dict) -> dict: + """ + Capture a screenshot (or system snapshot) from a field agent, then optionally + run vision analysis via Claude. + payload: { agent, analyze: true|false, analyze_prompt: "...", timeout: 45 } + """ + agent_name = payload.get("agent", "").strip() + do_analyze = bool(payload.get("analyze", True)) + analyze_prompt = payload.get("analyze_prompt", "Describe what you see on this screen in detail. Note any important status indicators, errors, running processes, or interesting information.") + timeout_secs = min(int(payload.get("timeout", 45)), 120) + + if not agent_name: + raise ValueError("Missing agent name") + + log.info(f"[VISION] Screenshot request: agent={agent_name} analyze={do_analyze}") + + # Dispatch screenshot command to field agent + dispatch = await _dispatch_agent_command(agent_name, "screenshot", {}, timeout_secs) + result = dispatch.get("result", {}) + hostname = dispatch.get("agent", agent_name) + + if dispatch.get("status") == "failed" or not result.get("success"): + err = result.get("error", "Agent returned failure") if isinstance(result, dict) else str(result) + return {"agent": hostname, "success": False, "error": err} + + image_b64 = result.get("image_b64", "") + method = result.get("method", "unknown") + width = result.get("width", 0) + height = result.get("height", 0) + file_size = result.get("file_size", 0) + + # Run Claude vision analysis if we have an image + analysis = "" + provider_used = "" + if do_analyze and image_b64: + 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 analysis complete ({len(analysis)} chars)") + except Exception as e: + log.warning(f"[VISION] Claude vision failed: {e}") + analysis = f"Vision analysis unavailable: {e}" + elif do_analyze and not image_b64 and result.get("snapshot_type") == "text": + # Text-only sysinfo snapshot — summarize with LLM + try: + snap_text = json.dumps(result, indent=2)[:3000] + prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}" + analysis = await llm_call([{"role": "user", "content": prompt}], "claude") + provider_used = "claude" + except Exception as e: + analysis = f"Analysis unavailable: {e}" + + # Store screenshot + screenshot_id = await db_execute( + """INSERT INTO agent_screenshots + (agent_id, hostname, method, image_b64, width, height, file_size, + vision_analysis, vision_provider) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (dispatch.get("agent_id", ""), hostname, method, + image_b64, width, height, file_size, analysis, provider_used) + ) + + log.info(f"[VISION] Screenshot saved: id={screenshot_id} agent={hostname} method={method}") + return { + "agent": hostname, + "screenshot_id": screenshot_id, + "method": method, + "width": width, + "height": height, + "file_size": file_size, + "has_image": bool(image_b64), + "analysis": analysis, + "provider": provider_used, + } + + +async def handle_vision(payload: dict) -> dict: + """ + Run Claude vision on an already-stored screenshot or a provided base64 image. + payload: { screenshot_id OR image_b64, prompt, provider } + """ + screenshot_id = payload.get("screenshot_id") + image_b64 = payload.get("image_b64", "") + prompt = payload.get("prompt", "Describe this image in detail.") + provider = payload.get("provider", "claude") + + if screenshot_id: + row = await db_fetchone( + "SELECT image_b64, hostname, method FROM agent_screenshots WHERE id=%s", + (int(screenshot_id),) + ) + if not row or not row["image_b64"]: + raise ValueError(f"Screenshot {screenshot_id} not found or has no image data") + image_b64 = row["image_b64"] + hostname = row.get("hostname", "unknown") + else: + hostname = "direct" + + if not image_b64: + raise ValueError("No image data provided") + + log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}") + + 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 "" + except Exception as e: + raise RuntimeError(f"Vision analysis failed: {e}") + + # Update stored screenshot if we have an ID + if screenshot_id: + await db_execute( + "UPDATE agent_screenshots SET vision_analysis=%s, vision_provider=%s WHERE id=%s", + (analysis, "claude", int(screenshot_id)) + ) + + return { + "agent": hostname, + "screenshot_id": screenshot_id, + "prompt": prompt, + "analysis": analysis, + "provider": "claude", + } + + +async def handle_sysinfo(payload: dict) -> dict: + """ + Get a structured system snapshot from a field agent (no image). + Optionally ask Claude to summarize/assess the health of the machine. + payload: { agent, analyze: true|false, timeout: 30 } + """ + agent_name = payload.get("agent", "").strip() + do_analyze = bool(payload.get("analyze", True)) + timeout_secs = min(int(payload.get("timeout", 30)), 60) + + if not agent_name: + raise ValueError("Missing agent name") + + dispatch = await _dispatch_agent_command(agent_name, "sysinfo", {}, timeout_secs) + result = dispatch.get("result", {}) + hostname = dispatch.get("agent", agent_name) + + if dispatch.get("status") == "failed": + return {"agent": hostname, "success": False, "error": result.get("error", "Command failed")} + + analysis = "" + if do_analyze: + try: + snap = json.dumps(result, indent=2)[:3000] + summary_prompt = ( + f"You are JARVIS analyzing a field station sysinfo snapshot from '{hostname}'.\n\n" + f"Snapshot:\n{snap}\n\n" + "Provide a concise health assessment: status (healthy/warning/critical), " + "key metrics, any concerns, and recommended actions if needed. " + "Keep it under 150 words, JARVIS style." + ) + analysis = await llm_call([{"role": "user", "content": summary_prompt}], "claude") + except Exception as e: + analysis = f"Analysis unavailable: {e}" + + return { + "agent": hostname, + "success": True, + "snapshot": result, + "analysis": analysis, + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# JOB HANDLER REGISTRY +# ═══════════════════════════════════════════════════════════════════════════════ + +JOB_HANDLERS = { + "ping": handle_ping, + "echo": handle_echo, + "shell": handle_shell, + "llm": handle_llm, + "research": handle_research, + "tool_loop": handle_tool_loop, + "gmail_triage": handle_gmail_triage, + "remote_exec": handle_remote_exec, + # Phase 4 + "screenshot": handle_screenshot, + "vision": handle_vision, + "sysinfo": handle_sysinfo, + # Phase 5 + "sitrep": None, # registered after guardian functions are defined + "guardian_config": None, +} + +# ── PHASE 5: GUARDIAN MODE ──────────────────────────────────────────────────── + +# In-memory state: tracks last alert time per (agent_id, metric) to debounce +_guardian_state: dict = { + "enabled": True, + "last_scan": None, + "last_sitrep": None, + "alert_cooldown": {}, # key: "agent_id:metric" → last_alert_epoch + "online_snapshot": {}, # agent_id → was_online bool +} +GUARDIAN_COOLDOWN = 600 # seconds between repeat alerts for same metric + + +async def _guardian_get_config() -> dict: + rows = await db_fetchall("SELECT key_name, value FROM guardian_config") + return {r["key_name"]: r["value"] for r in rows} if rows else {} + + +async def _guardian_emit(event_type: str, severity: str, agent_id: str, + hostname: str, metric: str, value: float, + threshold: float, message: str, ai_analysis: str = "") -> int: + return await db_execute( + """INSERT INTO guardian_events + (event_type, severity, agent_id, hostname, metric, value, threshold, + message, ai_analysis) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (event_type, severity, agent_id, hostname, metric, value, threshold, + message, ai_analysis) + ) + + +async def _guardian_cooldown_ok(agent_id: str, metric: str) -> bool: + """Return True if enough time has passed since last alert for this agent+metric.""" + import time + key = f"{agent_id}:{metric}" + last = _guardian_state["alert_cooldown"].get(key, 0) + if (time.time() - last) >= GUARDIAN_COOLDOWN: + _guardian_state["alert_cooldown"][key] = time.time() + return True + return False + + +async def guardian_loop() -> None: + """ + Background task — runs every SCAN_INTERVAL seconds. + Checks all agents for anomalies, emits guardian_events, optionally + generates AI analysis for critical findings. + """ + import time + log.info("[GUARDIAN] Guardian Mode activated") + + while True: + try: + cfg = await _guardian_get_config() + if cfg.get("enabled", "1") == "0": + await asyncio.sleep(60) + continue + + scan_interval = int(cfg.get("scan_interval", 120)) + cpu_thresh = float(cfg.get("cpu_threshold", 85)) + mem_thresh = float(cfg.get("mem_threshold", 88)) + disk_thresh = float(cfg.get("disk_threshold", 88)) + offline_mins = int(cfg.get("offline_minutes", 3)) + ai_on = cfg.get("ai_analysis", "1") == "1" + proactive_chat = cfg.get("proactive_chat", "1") == "1" + + _guardian_state["last_scan"] = datetime.utcnow().isoformat() + critical_findings = [] + + # ── 1. Agent online/offline transitions ─────────────────────────── + agents = await db_fetchall( + "SELECT agent_id, hostname, status, last_seen FROM registered_agents" + ) + current_snapshot = {} + for ag in agents: + aid = ag["agent_id"] + hostname = ag["hostname"] + is_online = ag["status"] == "online" + current_snapshot[aid] = is_online + was_online = _guardian_state["online_snapshot"].get(aid) + + if was_online is True and not is_online: + # Agent went offline + if await _guardian_cooldown_ok(aid, "offline"): + eid = await _guardian_emit("agent_offline", "critical", aid, hostname, + "status", 0, 1, f"Field station '{hostname}' went OFFLINE") + critical_findings.append(f"⚠ {hostname} OFFLINE") + log.warning(f"[GUARDIAN] {hostname} went offline → event #{eid}") + + elif was_online is False and is_online: + # Agent came back + if await _guardian_cooldown_ok(aid, "online"): + await _guardian_emit("agent_online", "info", aid, hostname, + "status", 1, 0, f"Field station '{hostname}' back ONLINE") + log.info(f"[GUARDIAN] {hostname} came back online") + + _guardian_state["online_snapshot"] = current_snapshot + + # ── 2. Metrics analysis ─────────────────────────────────────────── + # Get latest metric per online agent + metrics_rows = await db_fetchall( + """SELECT m.agent_id, r.hostname, m.metric_data + FROM agent_metrics m + JOIN registered_agents r ON r.agent_id = m.agent_id + WHERE r.status = 'online' + AND m.metric_type = 'system' + AND m.recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE) + ORDER BY m.recorded_at DESC""" + ) + seen_agents: set = set() + for row in metrics_rows: + aid = row["agent_id"] + if aid in seen_agents: + continue + seen_agents.add(aid) + hostname = row["hostname"] + + try: + sys_data = json.loads(row["metric_data"]) if isinstance(row["metric_data"], str) else row["metric_data"] + + cpu = sys_data.get("cpu_percent") + if cpu is not None and float(cpu) >= cpu_thresh: + sev = "critical" if float(cpu) >= 95 else "warning" + if await _guardian_cooldown_ok(aid, "cpu"): + msg = f"{hostname}: CPU at {cpu:.1f}% (threshold: {cpu_thresh}%)" + await _guardian_emit("cpu_high", sev, aid, hostname, + "cpu_percent", float(cpu), cpu_thresh, msg) + critical_findings.append(f"CPU {hostname} {cpu:.0f}%") + + mem = sys_data.get("memory", {}) + mem_pct = mem.get("percent") if isinstance(mem, dict) else None + if mem_pct is not None and float(mem_pct) >= mem_thresh: + sev = "critical" if float(mem_pct) >= 95 else "warning" + if await _guardian_cooldown_ok(aid, "memory"): + msg = f"{hostname}: Memory at {mem_pct:.1f}% (threshold: {mem_thresh}%)" + await _guardian_emit("mem_high", sev, aid, hostname, + "mem_percent", float(mem_pct), mem_thresh, msg) + critical_findings.append(f"MEM {hostname} {mem_pct:.0f}%") + + disks = sys_data.get("disk", []) + if isinstance(disks, list): + for disk in disks: + dpct = disk.get("percent", 0) + if dpct and float(str(dpct).rstrip("%")) >= disk_thresh: + mount = disk.get("mount", disk.get("mountpoint", "/")) + key = f"disk_{mount}" + if await _guardian_cooldown_ok(aid, key): + msg = f"{hostname}: Disk {mount} at {dpct}% (threshold: {disk_thresh}%)" + sev = "critical" if float(str(dpct).rstrip("%")) >= 95 else "warning" + await _guardian_emit("disk_high", sev, aid, hostname, + key, float(str(dpct).rstrip("%")), disk_thresh, msg) + critical_findings.append(f"DISK {hostname}{mount} {dpct}%") + + # Service anomalies + services = sys_data.get("services", []) + for svc in services: + if svc.get("status") == "failed": + svc_name = svc.get("service", "unknown") + if await _guardian_cooldown_ok(aid, f"svc_{svc_name}"): + msg = f"{hostname}: Service '{svc_name}' is FAILED" + await _guardian_emit("service_down", "critical", aid, hostname, + f"service:{svc_name}", 0, 1, msg) + critical_findings.append(f"SVC {hostname}/{svc_name} FAILED") + + except Exception as e: + log.debug(f"[GUARDIAN] Metrics parse error for {row['hostname']}: {e}") + + # ── 3. AI analysis for critical findings ────────────────────────── + if critical_findings and ai_on: + try: + findings_text = "\n".join(f"- {f}" for f in critical_findings) + ai_prompt = ( + f"You are JARVIS. The following anomalies were just detected:\n\n" + f"{findings_text}\n\n" + "Provide a brief (2-3 sentences), Iron Man-style alert message " + "for Myron. Be direct about severity and what action to take. " + "No markdown, no headers." + ) + ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "claude") + # Update the most recent guardian event with AI analysis + await db_execute( + """UPDATE guardian_events SET ai_analysis=%s + WHERE ai_analysis='' AND created_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE) + ORDER BY id DESC LIMIT 1""", + (ai_msg,) + ) + # Inject proactive chat message if configured + if proactive_chat: + await _guardian_inject_chat(f"◈ GUARDIAN ALERT — {ai_msg}") + except Exception as e: + log.warning(f"[GUARDIAN] AI analysis error: {e}") + + if critical_findings: + log.warning(f"[GUARDIAN] Scan complete — {len(critical_findings)} findings: {', '.join(critical_findings)}") + else: + log.debug(f"[GUARDIAN] Scan complete — all clear") + + except Exception as exc: + log.error(f"[GUARDIAN] Loop error: {exc}") + + await asyncio.sleep(scan_interval) + + +async def _guardian_inject_chat(message: str) -> None: + """Write a proactive JARVIS message into the conversations table so the HUD picks it up.""" + try: + await db_execute( + """INSERT INTO conversations (session_id, role, content, created_at) + VALUES ('guardian', 'assistant', %s, NOW())""", + (message,) + ) + except Exception as e: + log.debug(f"[GUARDIAN] Chat inject error: {e}") + + +async def handle_sitrep(payload: dict) -> dict: + """ + Situation Report — comprehensive health briefing across all field stations. + payload: { detail: brief|full, provider: claude } + """ + detail = payload.get("detail", "full") + provider = payload.get("provider", "claude") + + log.info(f"[GUARDIAN] SITREP requested (detail={detail})") + + # 1. Gather agent status + agents = await db_fetchall( + """SELECT agent_id, hostname, status, ip_address, agent_type, + capabilities, last_seen + FROM registered_agents ORDER BY status DESC, hostname ASC""" + ) + + # 2. Get latest metrics per agent + metrics_map = {} + metrics_rows = await db_fetchall( + """SELECT m.agent_id, m.metric_data, m.recorded_at + FROM agent_metrics m + WHERE m.metric_type = 'system' + AND m.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE) + ORDER BY m.recorded_at DESC""" + ) + for row in metrics_rows: + if row["agent_id"] not in metrics_map: + try: + metrics_map[row["agent_id"]] = json.loads(row["metric_data"]) if isinstance(row["metric_data"], str) else row["metric_data"] + except Exception: + pass + + # 3. Recent guardian events (last 24h) + recent_events = await db_fetchall( + """SELECT event_type, severity, hostname, metric, value, threshold, message, created_at + FROM guardian_events + WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) + ORDER BY created_at DESC LIMIT 20""" + ) + + # 4. Arc Reactor job stats + arc_stats = await db_fetchone( + """SELECT + SUM(status='queued') AS queued, + SUM(status='running') AS running, + SUM(status='done') AS done, + SUM(status='failed') AS failed + FROM arc_jobs WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)""" + ) + + # 5. Build context for Claude + agent_lines = [] + for ag in agents: + m = metrics_map.get(ag["agent_id"], {}) + sys = m.get("system", {}) if m else {} + cpu = sys.get("cpu_percent", "?") + mem = sys.get("memory", {}).get("percent", "?") if isinstance(sys.get("memory"), dict) else "?" + disk = next((d.get("percent","?") for d in (sys.get("disk") or []) if d.get("mount","").rstrip("/") == "" or d.get("mountpoint","") == "/"), "?") + line = (f" {ag['hostname']} ({ag['status'].upper()}) — " + f"CPU:{cpu}% MEM:{mem}% DISK:{disk}%") + agent_lines.append(line) + + event_lines = [ + f" [{e['severity'].upper()}] {e['hostname']}: {e['message']} ({e['created_at']})" + for e in recent_events + ] or [" No events in last 24h"] + + sitrep_context = ( + f"JARVIS SITREP — {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}\n\n" + f"FIELD STATIONS ({len(agents)} total):\n" + "\n".join(agent_lines) + "\n\n" + f"ARC REACTOR (last 24h): queued={arc_stats.get('queued',0)} " + f"running={arc_stats.get('running',0)} done={arc_stats.get('done',0)} " + f"failed={arc_stats.get('failed',0)}\n\n" + f"GUARDIAN EVENTS (last 24h):\n" + "\n".join(event_lines) + ) + + prompt = ( + f"{sitrep_context}\n\n" + f"You are JARVIS. Deliver a {'brief 3-sentence' if detail == 'brief' else 'comprehensive'} " + f"situation report for Myron Blair. Iron Man style — direct, confident, actionable. " + f"Highlight: overall health status, any critical issues requiring immediate attention, " + f"and key metrics. " + + ("Keep it under 80 words." if detail == "brief" else "Structure: Overall Status → Issues → Metrics → Recommendation.") + ) + + analysis = await llm_call([{"role": "user", "content": prompt}], provider) + _guardian_state["last_sitrep"] = datetime.utcnow().isoformat() + + # Log SITREP as guardian event + await _guardian_emit("sitrep", "info", "system", "system", "sitrep", 0, 0, + f"SITREP generated ({detail})", analysis) + + online_count = sum(1 for a in agents if a["status"] == "online") + offline_count = len(agents) - online_count + critical_events = sum(1 for e in recent_events if e["severity"] == "critical") + + return { + "sitrep": analysis, + "agents_online": online_count, + "agents_offline": offline_count, + "agents_total": len(agents), + "events_24h": len(recent_events), + "critical_24h": critical_events, + "arc_stats": dict(arc_stats) if arc_stats else {}, + "timestamp": datetime.utcnow().isoformat(), + "detail": detail, + } + + +async def handle_guardian_config(payload: dict) -> dict: + """ + Get or set Guardian Mode configuration. + payload: { action: get|set, key: ..., value: ... } or { action: set, config: {...} } + """ + action = payload.get("action", "get") + + if action == "get": + cfg = await _guardian_get_config() + return {"config": cfg, "guardian_state": { + "enabled": _guardian_state.get("enabled"), + "last_scan": _guardian_state.get("last_scan"), + "last_sitrep": _guardian_state.get("last_sitrep"), + }} + + elif action == "set": + updates = payload.get("config", {}) + if payload.get("key") and payload.get("value") is not None: + updates[payload["key"]] = str(payload["value"]) + for k, v in updates.items(): + await db_execute( + "INSERT INTO guardian_config (key_name, value) VALUES (%s, %s) " + "ON DUPLICATE KEY UPDATE value=%s, updated_at=NOW()", + (k, str(v), str(v)) + ) + if k == "enabled": + _guardian_state["enabled"] = v not in ("0", "false", "off") + return {"ok": True, "updated": list(updates.keys())} + + raise ValueError(f"Unknown guardian_config action: {action}") + + +# Register Phase 5 handlers +JOB_HANDLERS["sitrep"] = handle_sitrep +JOB_HANDLERS["guardian_config"] = handle_guardian_config + +# ── PHASE 6: COMMS v2 — SEND EMAIL + SCHEDULE + MEETING PREP ───────────────── + +import smtplib +import ssl as _ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr, make_msgid + +SMTP_SETTINGS = { + "gmail": ("smtp.gmail.com", 587, GMAIL_USER, GMAIL_PASS), + "icloud": ("smtp.mail.me.com", 587, ICLOUD_USER, ICLOUD_PASS), +} + +def _smtp_send_sync(account: str, to_email: str, to_name: str, + subject: str, body: str, + reply_to_msg_id: str = "") -> dict: + """Synchronous SMTP send — run in executor.""" + host, port, user, passwd = SMTP_SETTINGS.get(account, SMTP_SETTINGS["gmail"]) + if not user or not passwd: + return {"success": False, "error": "No credentials configured for account"} + + try: + msg = MIMEMultipart("alternative") + from_addr = formataddr(("JARVIS / Myron Blair", user)) + msg["From"] = from_addr + msg["To"] = formataddr((to_name, to_email)) if to_name else to_email + msg["Subject"] = subject + msg_id = make_msgid(domain=user.split("@")[1]) + msg["Message-ID"] = msg_id + if reply_to_msg_id: + msg["In-Reply-To"] = f"<{reply_to_msg_id.strip('<>')}>" + msg["References"] = f"<{reply_to_msg_id.strip('<>')}>" + + msg.attach(MIMEText(body, "plain", "utf-8")) + + ctx = _ssl.create_default_context() + with smtplib.SMTP(host, port, timeout=20) as smtp: + smtp.ehlo() + smtp.starttls(context=ctx) + smtp.login(user, passwd) + smtp.sendmail(user, [to_email], msg.as_bytes()) + + return {"success": True, "message_id": msg_id} + except Exception as e: + return {"success": False, "error": str(e)} + + +async def handle_send_email(payload: dict) -> dict: + """ + Send an email from Gmail or iCloud. + payload: { + account: gmail|icloud, + to_email: recipient address, + to_name: recipient display name (optional), + subject: subject line, + body: plain-text body, + triage_id: int (optional — if replying to a triage item), + reply_to_msg_id: original Message-ID for threading (optional), + compose: true — ask Claude to draft the body from a prompt first + } + """ + account = payload.get("account", "gmail") + to_email = payload.get("to_email", "").strip() + to_name = payload.get("to_name", "").strip() + subject = payload.get("subject", "").strip() + body = payload.get("body", "").strip() + triage_id = payload.get("triage_id") + reply_msg_id = payload.get("reply_to_msg_id", "") + compose_prompt = payload.get("compose_prompt", "") + + # If triage_id is given, pull recipient + subject + draft from email_triage + if triage_id and not to_email: + row = await db_fetchone( + "SELECT from_email, from_name, subject, draft_reply, msg_id FROM email_triage WHERE id=%s", + (int(triage_id),) + ) + if row: + to_email = to_email or row["from_email"] + to_name = to_name or row["from_name"] + subject = subject or ("Re: " + (row["subject"] or "")) + body = body or row.get("draft_reply", "") + reply_msg_id = reply_msg_id or row.get("msg_id", "") + + if not to_email: + raise ValueError("Missing to_email") + + # If compose requested, use Claude to write the body + if compose_prompt and not body: + draft_prompt = ( + f"You are drafting an email on behalf of Myron Blair.\n" + f"To: {to_name or to_email}\n" + f"Subject: {subject}\n\n" + f"Instructions: {compose_prompt}\n\n" + "Write a professional, concise email body. No subject line, no salutation header. " + "Start with 'Hi [name],' or similar. Sign off as 'Myron Blair'." + ) + body = await llm_call([{"role": "user", "content": draft_prompt}], "claude") + + if not subject: + raise ValueError("Missing subject") + if not body: + raise ValueError("Missing email body — provide body or compose_prompt") + + log.info(f"[COMMS] Sending email: {account} → {to_email} | {subject[:60]}") + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, lambda: _smtp_send_sync(account, to_email, to_name, subject, body, reply_msg_id) + ) + + # Record in email_sent + status = "sent" if result["success"] else "failed" + await db_execute( + """INSERT INTO email_sent + (account, to_email, to_name, subject, body, triage_id, status, error, message_id) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (account, to_email, to_name, subject, body, + triage_id or None, status, + result.get("error", ""), result.get("message_id", "")) + ) + + # Update triage item if this was a reply + if triage_id and result["success"]: + await db_execute( + "UPDATE email_triage SET action_taken='replied' WHERE id=%s", (int(triage_id),) + ) + + log.info(f"[COMMS] Send result: {status} → {to_email}") + return { + "success": result["success"], + "account": account, + "to_email": to_email, + "subject": subject, + "status": status, + "message_id": result.get("message_id", ""), + "error": result.get("error", ""), + "body": body, + } + + +async def handle_compose_email(payload: dict) -> dict: + """ + Compose an email from natural-language instructions — draft only (no auto-send). + payload: { to_email, to_name, subject_hint, instructions, account, send: false } + """ + to_email = payload.get("to_email", "").strip() + to_name = payload.get("to_name", "").strip() + subject_hint = payload.get("subject_hint", "").strip() + instructions = payload.get("instructions", "").strip() + account = payload.get("account", "gmail") + auto_send = bool(payload.get("send", False)) + + if not instructions: + raise ValueError("Missing instructions for compose") + + # Generate subject if not given + if not subject_hint: + subj_prompt = f"Write a concise email subject line (max 8 words) for an email about: {instructions}" + subject_hint = (await llm_call([{"role": "user", "content": subj_prompt}], "claude")).strip().strip('"') + + # Draft full body + draft_prompt = ( + f"You are drafting an email for Myron Blair.\n" + f"To: {to_name or to_email or 'the recipient'}\n" + f"Subject: {subject_hint}\n\n" + f"Instructions: {instructions}\n\n" + "Write a professional, concise email. Start with 'Hi [name],' or 'Hello,' " + "and sign off as 'Myron Blair'. Return only the email body text." + ) + body = await llm_call([{"role": "user", "content": draft_prompt}], "claude") + + if auto_send and to_email: + return await handle_send_email({ + "account": account, + "to_email": to_email, + "to_name": to_name, + "subject": subject_hint, + "body": body, + }) + + # Draft-only — save to email_sent with status='queued' for review + draft_id = await db_execute( + """INSERT INTO email_sent + (account, to_email, to_name, subject, body, status) + VALUES (%s,%s,%s,%s,%s,'queued')""", + (account, to_email, to_name, subject_hint, body) + ) + + log.info(f"[COMMS] Composed draft #{draft_id}: {to_email} | {subject_hint[:60]}") + return { + "draft_id": draft_id, + "to_email": to_email, + "to_name": to_name, + "subject": subject_hint, + "body": body, + "account": account, + "sent": False, + "status": "queued", + } + + +async def handle_schedule_event(payload: dict) -> dict: + """ + Create or find an appointment in the JARVIS planner from natural language. + payload: { title, description, start_at, end_at, location, category, + natural_language, all_day, reminder_min, generate_ics } + """ + nl = payload.get("natural_language", "").strip() + title = payload.get("title", "").strip() + start_at = payload.get("start_at", "") + end_at = payload.get("end_at", "") + location = payload.get("location", "") + category = payload.get("category", "work") + all_day = bool(payload.get("all_day", False)) + reminder = int(payload.get("reminder_min", 30)) + description = payload.get("description", "") + gen_ics = bool(payload.get("generate_ics", True)) + + # If natural language given, parse with Claude + if nl and (not title or not start_at): + now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + parse_prompt = ( + f"Current datetime: {now_str}\n\n" + f"Parse this scheduling request into JSON:\n\"{nl}\"\n\n" + "Return ONLY valid JSON with these fields (no markdown):\n" + '{"title":"...","start_at":"YYYY-MM-DD HH:MM:SS","end_at":"YYYY-MM-DD HH:MM:SS or null",' + '"location":"...","category":"personal|work|medical|other","all_day":false,' + '"description":"..."}\n' + "Use 24h time. Default duration 1 hour if not specified. " + "If only a date (no time) is given, set all_day=true." + ) + raw = await llm_call([{"role": "user", "content": parse_prompt}], "claude") + raw = raw.strip().strip("```json").strip("```").strip() + try: + parsed = json.loads(raw) + title = title or parsed.get("title", nl[:100]) + start_at = start_at or parsed.get("start_at", "") + end_at = end_at or parsed.get("end_at") or "" + location = location or parsed.get("location", "") + category = parsed.get("category", category) + all_day = parsed.get("all_day", all_day) + description = description or parsed.get("description", "") + except Exception as e: + log.warning(f"[COMMS] Schedule parse failed: {e} — raw: {raw[:100]}") + if not title: + title = nl[:100] + + if not title: + raise ValueError("Could not determine event title from input") + if not start_at: + raise ValueError("Could not determine start time from input") + + # Insert into appointments table + appt_id = await db_execute( + """INSERT INTO appointments + (title, description, category, start_at, end_at, location, + all_day, reminder_min, external_source) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'manual')""", + (title, description, category, start_at, + end_at or None, location, int(all_day), reminder) + ) + + # Generate ICS if requested + ics_content = "" + if gen_ics and start_at: + try: + from datetime import datetime as dt + uid = f"jarvis-{appt_id}@orbishosting.com" + dtstart = start_at.replace(" ", "T").replace("-", "").replace(":", "") + dtend = (end_at or start_at).replace(" ", "T").replace("-", "").replace(":", "") + dtstamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + ics_content = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//JARVIS//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"DTSTAMP:{dtstamp}\r\n" + f"DTSTART:{dtstart}\r\n" + f"DTEND:{dtend}\r\n" + f"SUMMARY:{title}\r\n" + f"DESCRIPTION:{description}\r\n" + f"LOCATION:{location}\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + except Exception: + pass + + log.info(f"[COMMS] Event scheduled: #{appt_id} '{title}' @ {start_at}") + return { + "appointment_id": appt_id, + "title": title, + "start_at": start_at, + "end_at": end_at, + "location": location, + "category": category, + "all_day": all_day, + "ics": ics_content, + "description": description, + } + + +async def handle_meeting_prep(payload: dict) -> dict: + """ + Prepare a briefing for an upcoming meeting. + payload: { appointment_id OR title OR timeframe, research: true } + """ + appt_id = payload.get("appointment_id") + title = payload.get("title", "").strip() + timeframe = payload.get("timeframe", "today") + do_research = bool(payload.get("research", True)) + + # Find the appointment + appt = None + if appt_id: + appt = await db_fetchone("SELECT * FROM appointments WHERE id=%s", (int(appt_id),)) + elif title: + appt = await db_fetchone( + "SELECT * FROM appointments WHERE title LIKE %s AND start_at >= NOW() ORDER BY start_at LIMIT 1", + (f"%{title}%",) + ) + else: + appt = await db_fetchone( + """SELECT * FROM appointments + WHERE start_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 24 HOUR) + ORDER BY start_at LIMIT 1""" + ) + + if not appt: + # Check tasks too + task = await db_fetchone( + "SELECT * FROM tasks WHERE title LIKE %s AND status != 'done' ORDER BY due_date LIMIT 1", + (f"%{title}%",) + ) + if task: + appt = {"title": task["title"], "description": task.get("notes",""), + "start_at": str(task.get("due_date") or ""), "location": ""} + + if not appt: + return {"success": False, "error": f"No upcoming appointment found matching '{title or timeframe}'"} + + appt_title = appt.get("title", "") + appt_start = str(appt.get("start_at", "")) + appt_desc = appt.get("description", "") + appt_loc = appt.get("location", "") + + # Optional: kick off a research job on the meeting topic/attendees + research_summary = "" + if do_research and appt_title: + try: + research_result = await handle_research({ + "query": f"background information on: {appt_title}", + "depth": "quick", + "provider": "claude", + }) + research_summary = research_result.get("synthesis", "")[:1500] + except Exception: + pass + + # Build meeting briefing + context = ( + f"Meeting: {appt_title}\n" + f"Time: {appt_start}\n" + f"Location: {appt_loc or 'Not specified'}\n" + f"Notes: {appt_desc or 'None'}\n" + ) + if research_summary: + context += f"\nBackground Research:\n{research_summary}" + + prompt = ( + f"You are JARVIS. Prepare a concise meeting briefing for Myron Blair.\n\n" + f"{context}\n\n" + "Format: Meeting summary → Key context/background → Suggested talking points → " + "Action items to prepare. Keep it sharp and actionable. Iron Man style." + ) + briefing = await llm_call([{"role": "user", "content": prompt}], "claude") + + log.info(f"[COMMS] Meeting prep complete: '{appt_title}'") + return { + "appointment_id": appt.get("id"), + "title": appt_title, + "start_at": appt_start, + "location": appt_loc, + "briefing": briefing, + "has_research": bool(research_summary), + } + + +# Register Phase 6 handlers +JOB_HANDLERS["send_email"] = handle_send_email +JOB_HANDLERS["compose_email"] = handle_compose_email +JOB_HANDLERS["schedule_event"] = handle_schedule_event +JOB_HANDLERS["meeting_prep"] = handle_meeting_prep + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 7: MISSION OPS — multi-step automated workflows +# ═══════════════════════════════════════════════════════════════════════════════ + +import re as _re + +def _render_template(text: str, context: dict) -> str: + """Replace {{key.subkey}} tokens in a string/JSON with values from context.""" + if not isinstance(text, str): + return text + def replacer(m): + path = m.group(1).strip().split(".") + val = context + for p in path: + if isinstance(val, dict): + val = val.get(p, m.group(0)) + else: + return m.group(0) + return str(val) if not isinstance(val, (dict, list)) else json.dumps(val) + return _re.sub(r'\{\{([^}]+)\}\}', replacer, text) + +def _apply_templates(payload: Any, context: dict) -> Any: + """Recursively apply template substitution to a payload dict/list/str.""" + if isinstance(payload, str): + return _render_template(payload, context) + if isinstance(payload, dict): + return {k: _apply_templates(v, context) for k, v in payload.items()} + if isinstance(payload, list): + return [_apply_templates(v, context) for v in payload] + return payload + +async def _execute_mission(mission_id: int, trigger_source: str = "manual") -> dict: + """Run all steps of a mission sequentially, log results, return summary.""" + mission = await db_fetchone("SELECT * FROM missions WHERE id=%s", (mission_id,)) + if not mission: + return {"error": f"Mission {mission_id} not found"} + + steps = await db_fetchall( + "SELECT * FROM mission_steps WHERE mission_id=%s ORDER BY step_order ASC", + (mission_id,) + ) + if not steps: + return {"error": "Mission has no steps"} + + # Create run record + run_id = await db_execute( + "INSERT INTO mission_runs (mission_id, status, trigger_source, steps_log) VALUES (%s,'running',%s,'[]')", + (mission_id, trigger_source) + ) + await db_execute( + "UPDATE missions SET last_run_at=NOW(), run_count=run_count+1 WHERE id=%s", + (mission_id,) + ) + + context = {"trigger": {"source": trigger_source, "mission_id": mission_id}} + steps_log = [] + overall_status = "done" + + for i, step in enumerate(steps): + step_label = step.get("label") or step["job_type"] + raw_payload = step.get("job_payload") or {} + if isinstance(raw_payload, str): + try: + raw_payload = json.loads(raw_payload) + except Exception: + raw_payload = {} + + payload = _apply_templates(raw_payload, context) + step_entry = { + "step": i, + "label": step_label, + "job_type": step["job_type"], + "status": "running", + "result": None, + "error": None, + } + + try: + handler = JOB_HANDLERS.get(step["job_type"]) + if not handler: + raise ValueError(f"Unknown job type: {step['job_type']}") + result = await handler(payload) + step_entry["status"] = "done" + step_entry["result"] = result + context[f"step_{i}"] = result + log.info(f"[MISSION {mission_id} RUN {run_id}] Step {i} ({step_label}) done") + except Exception as exc: + step_entry["status"] = "failed" + step_entry["error"] = str(exc)[:500] + log.warning(f"[MISSION {mission_id} RUN {run_id}] Step {i} ({step_label}) failed: {exc}") + if not step.get("continue_on_failure"): + overall_status = "failed" + steps_log.append(step_entry) + break + + steps_log.append(step_entry) + + await db_execute( + "UPDATE mission_runs SET status=%s, steps_log=%s, completed_at=NOW() WHERE id=%s", + (overall_status, json.dumps(steps_log, default=str), run_id) + ) + return { + "run_id": run_id, + "status": overall_status, + "steps": len(steps_log), + "steps_log": steps_log, + } + +async def handle_run_mission(payload: dict) -> dict: + mission_id = int(payload.get("mission_id") or 0) + if not mission_id: + return {"error": "Missing mission_id"} + source = payload.get("trigger_source", "manual") + return await _execute_mission(mission_id, source) + +JOB_HANDLERS["run_mission"] = handle_run_mission + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 8: MISSION DIRECTIVES — OKR / goal tracking with AI review +# ═══════════════════════════════════════════════════════════════════════════════ + +async def handle_directive_review(payload: dict) -> dict: + """AI-powered review of active directives: progress analysis + recommendations.""" + directive_id = payload.get("directive_id") + provider = payload.get("provider", "claude") + + # Fetch directives + if directive_id: + dirs = await db_fetchall( + "SELECT * FROM directives WHERE id=%s", (directive_id,) + ) + else: + dirs = await db_fetchall( + "SELECT * FROM directives WHERE status='active' ORDER BY priority DESC LIMIT 10" + ) + + if not dirs: + return {"error": "No active directives found"} + + dir_ids = [d["id"] for d in dirs] + placeholders = ",".join(["%s"] * len(dir_ids)) + + key_results = await db_fetchall( + f"SELECT * FROM directive_key_results WHERE directive_id IN ({placeholders}) ORDER BY directive_id,id", + dir_ids + ) or [] + + links = await db_fetchall( + f"""SELECT dl.directive_id, dl.link_type, dl.note, + COALESCE(t.title, a.title) AS linked_title, + COALESCE(t.status, 'scheduled') AS linked_status + FROM directive_links dl + LEFT JOIN tasks t ON dl.link_type='task' AND t.id=dl.link_id + LEFT JOIN appointments a ON dl.link_type='appointment' AND a.id=dl.link_id + WHERE dl.directive_id IN ({placeholders})""", + dir_ids + ) or [] + + # Build context block + context_lines = [] + for d in dirs: + d_krs = [kr for kr in key_results if kr["directive_id"] == d["id"]] + d_links = [lk for lk in links if lk["directive_id"] == d["id"]] + kr_sum_cur = sum(float(kr["current_value"]) for kr in d_krs) + kr_sum_tgt = sum(float(kr["target_value"]) for kr in d_krs) or 1 + pct = round(kr_sum_cur / kr_sum_tgt * 100, 1) + + context_lines.append(f"\n## DIRECTIVE: {d['title']} ({d['category'].upper()}) — {pct}% complete") + context_lines.append(f" Status: {d['status']} | Priority: {d['priority']}/10 | Target: {d.get('target_date') or 'no date'}") + if d.get("description"): + context_lines.append(f" Description: {d['description']}") + for kr in d_krs: + context_lines.append(f" KR: {kr['title']} — {kr['current_value']}/{kr['target_value']} {kr['unit']}") + for lk in d_links: + status = lk.get("linked_status") or "" + context_lines.append(f" Linked {lk['link_type']}: {lk.get('linked_title') or lk.get('note') or '—'} [{status}]") + + context = "\n".join(context_lines) + today = datetime.utcnow().strftime("%Y-%m-%d") + + prompt = f"""You are JARVIS, an AI assistant conducting a Mission Directives review for your principal. +Today is {today}. + +{context} + +Provide a concise executive briefing covering: +1. Overall progress summary (2-3 sentences) +2. For each directive: current status, what's on track, what needs attention +3. Top 3 recommended next actions to move the highest-priority directives forward +4. Any directives at risk of missing their target date + +Keep the tone confident and action-oriented. Format with clear sections. Under 400 words.""" + + review_text = "" + try: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.anthropic.com/v1/messages", + headers={"x-api-key": CLAUDE_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}, + json={"model": CLAUDE_MODEL, "max_tokens": 600, "messages": [{"role": "user", "content": prompt}]}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + rdata = await resp.json() + review_text = rdata.get("content", [{}])[0].get("text", "") + except Exception as e: + review_text = f"[Review generation failed: {e}]" + + # Store in conversations so HUD can surface it + await db_execute( + "INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())", + (review_text,) + ) + + return { + "review": review_text, + "directives": len(dirs), + "provider": provider, + } + +JOB_HANDLERS["directive_review"] = handle_directive_review + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 10: MEMORY CORE — auto-extraction knowledge graph +# ═══════════════════════════════════════════════════════════════════════════════ + +_MEMORY_EXTRACT_PROMPT = """Extract factual information about the user from this conversation exchange. Focus on: +- Preferences (likes/dislikes, preferred ways of working) +- People they mention (names, relationships, roles) +- Places (home, work, locations) +- Routines (schedules, habits) +- Goals (things they want to achieve) +- Instructions to JARVIS (always/never rules) +- Key facts about their life/work/projects + +Return a JSON array. Each element: {{"category": "", "subject": "", "predicate": "", "object": "", "confidence": <0.0-1.0>}} + +Rules: +- Only extract clearly stated facts, not speculation +- subject/predicate should be concise (under 50 chars each) +- confidence: 0.95 for explicit statements, 0.75-0.90 for clear implications +- Skip ephemeral info (what they asked just now, current queries) +- Max 8 facts per exchange +- Return [] if nothing durable worth remembering + +Exchange: +USER: {user_msg} +JARVIS: {asst_msg} + +Return ONLY valid JSON array, nothing else.""" + +async def handle_memory_extract(payload: dict) -> dict: + user_msg = str(payload.get("user_message", "")).strip() + asst_msg = str(payload.get("assistant_message", "")).strip() + conv_id = payload.get("conversation_id") + + if not user_msg and not asst_msg: + return {"ok": False, "reason": "empty exchange"} + + prompt = _MEMORY_EXTRACT_PROMPT.format( + user_msg=user_msg[:800], + asst_msg=asst_msg[:800] + ) + + raw = "" + try: + # Use Haiku for speed/cost; fall back to Groq + headers = {"x-api-key": CLAUDE_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"} + body = { + "model": "claude-haiku-4-5-20251001", + "max_tokens": 800, + "messages": [{"role": "user", "content": prompt}] + } + async with aiohttp.ClientSession() as session: + async with session.post("https://api.anthropic.com/v1/messages", json=body, + headers=headers, timeout=aiohttp.ClientTimeout(total=20)) as resp: + if resp.status == 200: + data = await resp.json() + raw = data["content"][0]["text"].strip() + else: + raise RuntimeError(f"Claude {resp.status}") + except Exception as e: + log.warning(f"[MEMORY] Claude extract failed ({e}), trying Groq") + try: + raw = await llm_call([{"role": "user", "content": prompt}], provider="groq") + except Exception as e2: + log.error(f"[MEMORY] All providers failed: {e2}") + return {"ok": False, "reason": str(e2)} + + # Parse JSON — strip code fences if present + if "```" in raw: + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + raw = raw.strip() + + try: + facts = json.loads(raw) + if not isinstance(facts, list): + facts = [] + except Exception: + log.warning(f"[MEMORY] JSON parse failed. Raw: {raw[:200]}") + return {"ok": False, "reason": "json_parse_failed"} + + valid_cats = {"preference", "person", "place", "routine", "goal", "fact", "instruction"} + inserted = 0 + for f in facts: + if not isinstance(f, dict): + continue + subj = str(f.get("subject", "")).strip()[:255] + pred = str(f.get("predicate", "is")).strip()[:255] + obj = str(f.get("object", "")).strip() + cat = f.get("category", "fact") + conf = min(1.0, max(0.0, float(f.get("confidence", 0.85)))) + if not subj or not obj or cat not in valid_cats: + continue + try: + await db_execute( + """INSERT INTO memory_facts (category, subject, predicate, object, confidence, source, conversation_id) + VALUES (%s, %s, %s, %s, %s, 'conversation', %s) + ON DUPLICATE KEY UPDATE + object=VALUES(object), + confidence=GREATEST(confidence, VALUES(confidence)), + confirmed_count=confirmed_count+1, + last_confirmed_at=NOW(), + active=1""", + (cat, subj, pred, obj, conf, conv_id) + ) + inserted += 1 + except Exception as e: + log.warning(f"[MEMORY] Insert ({subj},{pred}) failed: {e}") + + log.info(f"[MEMORY] Stored {inserted}/{len(facts)} facts from conv {conv_id}") + return {"ok": True, "extracted": inserted, "candidates": len(facts)} + + +async def handle_memory_store(payload: dict) -> dict: + """Explicit memory insertion — from 'remember that X' voice commands.""" + subject = str(payload.get("subject", "user")).strip()[:255] + predicate = str(payload.get("predicate", "note")).strip()[:255] + obj = str(payload.get("object", "")).strip() + category = payload.get("category", "fact") + if not obj: + return {"ok": False, "reason": "empty object"} + valid_cats = {"preference", "person", "place", "routine", "goal", "fact", "instruction"} + if category not in valid_cats: + category = "fact" + fid = await db_execute( + """INSERT INTO memory_facts (category, subject, predicate, object, confidence, source) + VALUES (%s, %s, %s, %s, 1.0, 'explicit') + ON DUPLICATE KEY UPDATE + object=VALUES(object), confidence=1.0, + confirmed_count=confirmed_count+1, last_confirmed_at=NOW(), active=1""", + (category, subject, predicate, obj) + ) + log.info(f"[MEMORY] Explicit store: [{category}] {subject} {predicate}: {obj}") + return {"ok": True, "id": fid} + + +JOB_HANDLERS["memory_extract"] = handle_memory_extract +JOB_HANDLERS["memory_store"] = handle_memory_store + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 9: CLEARANCE PROTOCOL — approval gating for high-risk operations +# ═══════════════════════════════════════════════════════════════════════════════ + +def _clearance_describe(job_type: str, payload: dict) -> str: + """Human-readable description of what the job would do.""" + if job_type == "shell": + cmd = payload.get("command", payload.get("cmd", "?")) + agent = payload.get("agent_id", payload.get("agent", "unknown")) + return f"Execute shell command on agent '{agent}': {str(cmd)[:120]}" + if job_type == "send_email": + to = payload.get("to_email") or payload.get("target", "?") + subj = payload.get("subject", "") + tid = payload.get("triage_id") + if tid: + return f"Send SMTP reply to triage item #{tid} (to: {to})" + return f"Send email to {to}" + (f" — {subj}" if subj else "") + if job_type == "remote_exec": + cmd_type = payload.get("command_type", payload.get("command", "?")) + agent = payload.get("agent_id", payload.get("agent", "?")) + return f"Remote execute '{cmd_type}' on agent '{agent}'" + if job_type == "purge": + return "Purge all completed/failed jobs from the Arc Reactor queue" + return f"Execute {job_type} job" + +async def _check_clearance(job_type: str, payload: dict, created_by: str) -> Optional[dict]: + """ + Returns a clearance-pending response dict if approval is required, + or None if the job can proceed immediately. + """ + rule = await db_fetchone( + "SELECT * FROM clearance_rules WHERE job_type=%s AND enabled=1 AND require_approval=1", + (job_type,) + ) + if not rule: + return None + desc = _clearance_describe(job_type, payload) + expires = None + if rule.get("auto_approve_after_min") is not None: + expires = datetime.utcnow() + timedelta(minutes=int(rule["auto_approve_after_min"])) + cr_id = await db_execute( + "INSERT INTO clearance_requests (job_type,job_payload,risk_level,description,requested_by,status,expires_at) " + "VALUES (%s,%s,%s,%s,%s,'pending',%s)", + (job_type, json.dumps(payload), rule["risk_level"], desc, created_by, expires) + ) + log.warning(f"[CLEARANCE] Request #{cr_id} — {rule['risk_level'].upper()} — {desc}") + return { + "status": "pending_clearance", + "clearance_id": cr_id, + "risk_level": rule["risk_level"], + "description": desc, + "message": f"◈ CLEARANCE REQUIRED — {rule['risk_level'].upper()} risk operation intercepted. Approval needed before execution.", + } + +async def _dispatch_cleared_job(cr_id: int, job_type: str, payload: dict, priority: int = 7, decided_by: str = "admin") -> dict: + """Approve a clearance request and dispatch the job into the queue.""" + jid = await db_execute( + "INSERT INTO arc_jobs (job_type, payload, priority, created_by) VALUES (%s, %s, %s, %s)", + (job_type, json.dumps(payload), priority, f"clearance:{cr_id}") + ) + await db_execute( + "UPDATE clearance_requests SET status='approved', decided_by=%s, arc_job_id=%s, decided_at=NOW() WHERE id=%s", + (decided_by, jid, cr_id) + ) + log.info(f"[CLEARANCE] Request #{cr_id} approved by {decided_by} → Job #{jid}") + return {"job_id": jid, "clearance_id": cr_id, "status": "approved"} + +# ── Clearance watchdog ───────────────────────────────────────────────────────── + +async def clearance_watchdog() -> None: + """Expire timed-out pending requests; auto-approve those with auto_approve_after_min set.""" + log.info("Clearance watchdog started") + await asyncio.sleep(20) + while True: + try: + now = datetime.utcnow() + # Expire requests past their expiry with no auto-approve (expires_at IS NULL means never expires) + expired = await db_fetchall( + "SELECT cr.*, cru.auto_approve_after_min FROM clearance_requests cr " + "JOIN clearance_rules cru ON cru.job_type=cr.job_type " + "WHERE cr.status='pending' AND cr.expires_at IS NOT NULL AND cr.expires_at <= %s", + (now,) + ) or [] + for req in expired: + if req.get("auto_approve_after_min") is not None: + # Auto-approve: dispatch the job + payload = req.get("job_payload") or {} + if isinstance(payload, str): + try: payload = json.loads(payload) + except Exception: payload = {} + await _dispatch_cleared_job(req["id"], req["job_type"], payload, decided_by="auto_approve") + log.info(f"[CLEARANCE] Auto-approved request #{req['id']} ({req['job_type']})") + else: + await db_execute( + "UPDATE clearance_requests SET status='expired', decided_at=NOW() WHERE id=%s", + (req["id"],) + ) + log.info(f"[CLEARANCE] Expired request #{req['id']} ({req['job_type']})") + except Exception as exc: + log.error(f"Clearance watchdog error: {exc}") + await asyncio.sleep(60) + +# ── Mission trigger loop ─────────────────────────────────────────────────────── + +_mission_trigger_state: dict = {} # mission_id → last_triggered ISO + +async def mission_trigger_loop() -> None: + """Check scheduled and event-based mission triggers every 30 seconds.""" + log.info("Mission trigger loop started") + await asyncio.sleep(15) # stagger startup + while True: + try: + missions = await db_fetchall( + "SELECT * FROM missions WHERE enabled=1 AND trigger_type != 'manual'" + ) + for m in missions: + mid = m["id"] + ttype = m["trigger_type"] + cfg = m.get("trigger_config") or {} + if isinstance(cfg, str): + try: cfg = json.loads(cfg) + except Exception: cfg = {} + + should_run = False + source = ttype + + if ttype == "schedule": + interval_min = int(cfg.get("interval_minutes", 60)) + last_run = m.get("last_run_at") + if last_run is None: + should_run = True + else: + last_dt = last_run if isinstance(last_run, datetime) else datetime.fromisoformat(str(last_run)) + elapsed = (datetime.utcnow() - last_dt).total_seconds() / 60 + should_run = elapsed >= interval_min + + elif ttype == "guardian_event": + severity = cfg.get("severity", "") + etype = cfg.get("event_type", "") + last_key = f"ge_{mid}" + last_seen = _mission_trigger_state.get(last_key, "1970-01-01") + wheres = ["acknowledged=0", "created_at > %s"] + params: list = [last_seen] + if severity: wheres.append("severity=%s"); params.append(severity) + if etype: wheres.append("event_type=%s"); params.append(etype) + row = await db_fetchone( + f"SELECT id, created_at FROM guardian_events WHERE {' AND '.join(wheres)} ORDER BY created_at DESC LIMIT 1", + params + ) + if row: + should_run = True + _mission_trigger_state[last_key] = str(row["created_at"]) + source = f"guardian_event:{row['id']}" + + elif ttype == "email_keyword": + keywords = cfg.get("keywords", []) + category = cfg.get("category", "") + last_key = f"ek_{mid}" + last_seen = _mission_trigger_state.get(last_key, "1970-01-01") + if keywords: + kw_conds = " OR ".join(["subject LIKE %s OR summary LIKE %s"] * len(keywords)) + kw_params = [] + for kw in keywords: + kw_params += [f"%{kw}%", f"%{kw}%"] + where = f"created_at > %s AND ({kw_conds})" + params = [last_seen] + kw_params + if category: + where += " AND category=%s" + params.append(category) + row = await db_fetchone( + f"SELECT id, created_at FROM email_triage WHERE {where} ORDER BY created_at DESC LIMIT 1", + params + ) + if row: + should_run = True + _mission_trigger_state[last_key] = str(row["created_at"]) + source = f"email_triage:{row['id']}" + + if should_run: + log.info(f"[MISSION TRIGGER] Mission {mid} ({m['name']}) triggered by {source}") + asyncio.create_task(_execute_mission(mid, source)) + + except Exception as exc: + log.error(f"Mission trigger loop error: {exc}") + await asyncio.sleep(30) + +# ═══════════════════════════════════════════════════════════════════════════════ +# JOB RUNNER + BACKGROUND TASKS +# ═══════════════════════════════════════════════════════════════════════════════ + +_running_jobs: set = set() +_stats = {"done": 0, "failed": 0} + +async def run_job(job: dict) -> None: + jid = job["id"] + jtype = job["job_type"] + _running_jobs.add(jid) + try: + payload = job.get("payload") or {} + if isinstance(payload, str): + payload = json.loads(payload) + await db_execute("UPDATE arc_jobs SET status='running', started_at=NOW() WHERE id=%s", (jid,)) + log.info(f"[JOB {jid}] Starting {jtype}") + handler = JOB_HANDLERS.get(jtype) + if not handler: + raise ValueError(f"Unknown job type: {jtype}") + result = await handler(payload) + result_str = json.dumps(result, default=str) + await db_execute("UPDATE arc_jobs SET status='done', result=%s, completed_at=NOW() WHERE id=%s", (result_str, jid)) + _stats["done"] += 1 + log.info(f"[JOB {jid}] Done: {jtype}") + except Exception as exc: + err = str(exc) + await db_execute("UPDATE arc_jobs SET status='failed', error=%s, completed_at=NOW() WHERE id=%s", (err[:2000], jid)) + _stats["failed"] += 1 + log.warning(f"[JOB {jid}] Failed: {err[:120]}") + finally: + _running_jobs.discard(jid) + +async def job_poller() -> None: + log.info("Arc Reactor job poller started") + while True: + try: + jobs = await db_fetchall("SELECT * FROM arc_jobs WHERE status='queued' ORDER BY priority DESC, id ASC LIMIT 5") + for job in jobs: + if job["id"] not in _running_jobs: + asyncio.create_task(run_job(job)) + except Exception as exc: + log.error(f"Poller error: {exc}") + await asyncio.sleep(POLL_INTERVAL) + +async def heartbeat_loop() -> None: + while True: + try: + await db_execute( + "UPDATE arc_status SET last_heartbeat=NOW(), active_jobs=%s, jobs_done=%s, jobs_failed=%s WHERE id=1", + (len(_running_jobs), _stats["done"], _stats["failed"]) + ) + except Exception as exc: + log.error(f"Heartbeat error: {exc}") + await asyncio.sleep(HEARTBEAT_INTERVAL) + +# ═══════════════════════════════════════════════════════════════════════════════ +# FASTAPI APP +# ═══════════════════════════════════════════════════════════════════════════════ + +@asynccontextmanager +async def lifespan(app: FastAPI): + log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}") + await get_pool() + await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,)) + asyncio.create_task(job_poller()) + asyncio.create_task(heartbeat_loop()) + asyncio.create_task(guardian_loop()) + asyncio.create_task(mission_trigger_loop()) + asyncio.create_task(clearance_watchdog()) + log.info(f"◈ Arc Reactor online — {len(JOB_HANDLERS)} handlers: {', '.join(JOB_HANDLERS)}") + yield + log.info("◈ Arc Reactor shutting down") + if _pool and not _pool.closed: + _pool.close() + await _pool.wait_closed() + +app = FastAPI(title="JARVIS Arc Reactor", version=VERSION, lifespan=lifespan) + +# ── Mission Ops endpoints ────────────────────────────────────────────────────── + +@app.get("/missions") +async def missions_list(enabled: Optional[int] = None): + if enabled is not None: + rows = await db_fetchall("SELECT * FROM missions WHERE enabled=%s ORDER BY id DESC", (enabled,)) + else: + rows = await db_fetchall("SELECT * FROM missions ORDER BY id DESC") + return rows or [] + +@app.get("/missions/{mission_id}") +async def mission_get(mission_id: int): + m = await db_fetchone("SELECT * FROM missions WHERE id=%s", (mission_id,)) + if not m: + raise HTTPException(status_code=404, detail="Not found") + steps = await db_fetchall( + "SELECT * FROM mission_steps WHERE mission_id=%s ORDER BY step_order ASC", + (mission_id,) + ) + runs = await db_fetchall( + "SELECT id, status, trigger_source, started_at, completed_at FROM mission_runs WHERE mission_id=%s ORDER BY id DESC LIMIT 10", + (mission_id,) + ) + return {**m, "steps": steps or [], "recent_runs": runs or []} + +@app.post("/missions") +async def mission_create(req: Request): + body = await req.json() + name = body.get("name", "").strip() + if not name: + raise HTTPException(status_code=400, detail="name required") + desc = body.get("description", "") + ttype = body.get("trigger_type", "manual") + tcfg = json.dumps(body.get("trigger_config") or {}) + enabled = int(body.get("enabled", 1)) + mid = await db_execute( + "INSERT INTO missions (name,description,trigger_type,trigger_config,enabled) VALUES (%s,%s,%s,%s,%s)", + (name, desc, ttype, tcfg, enabled) + ) + steps = body.get("steps") or [] + for i, s in enumerate(steps): + await db_execute( + "INSERT INTO mission_steps (mission_id,step_order,label,job_type,job_payload,continue_on_failure) VALUES (%s,%s,%s,%s,%s,%s)", + (mid, i, s.get("label",""), s["job_type"], json.dumps(s.get("payload",{})), int(s.get("continue_on_failure",0))) + ) + return {"id": mid, "ok": True} + +@app.put("/missions/{mission_id}") +async def mission_update(mission_id: int, req: Request): + body = await req.json() + fields: list = [] + params: list = [] + for col in ("name", "description", "trigger_type", "enabled"): + if col in body: + fields.append(f"{col}=%s") + params.append(body[col]) + if "trigger_config" in body: + fields.append("trigger_config=%s") + params.append(json.dumps(body["trigger_config"])) + if fields: + params.append(mission_id) + await db_execute(f"UPDATE missions SET {','.join(fields)} WHERE id=%s", params) + if "steps" in body: + await db_execute("DELETE FROM mission_steps WHERE mission_id=%s", (mission_id,)) + for i, s in enumerate(body["steps"]): + await db_execute( + "INSERT INTO mission_steps (mission_id,step_order,label,job_type,job_payload,continue_on_failure) VALUES (%s,%s,%s,%s,%s,%s)", + (mission_id, i, s.get("label",""), s["job_type"], json.dumps(s.get("payload",{})), int(s.get("continue_on_failure",0))) + ) + return {"ok": True} + +@app.delete("/missions/{mission_id}") +async def mission_delete(mission_id: int): + await db_execute("DELETE FROM mission_steps WHERE mission_id=%s", (mission_id,)) + await db_execute("DELETE FROM mission_runs WHERE mission_id=%s", (mission_id,)) + await db_execute("DELETE FROM missions WHERE id=%s", (mission_id,)) + return {"ok": True} + +@app.post("/missions/{mission_id}/run") +async def mission_run_endpoint(mission_id: int, req: Request): + try: + body = await req.json() + except Exception: + body = {} + source = body.get("trigger_source", "manual") if isinstance(body, dict) else "manual" + return await _execute_mission(mission_id, source) + +@app.get("/missions/{mission_id}/runs") +async def mission_runs_list(mission_id: int, limit: int = 20): + rows = await db_fetchall( + "SELECT * FROM mission_runs WHERE mission_id=%s ORDER BY id DESC LIMIT %s", + (mission_id, limit) + ) + return rows or [] + +# ── Clearance FastAPI endpoints ──────────────────────────────────────────────── + +@app.get("/clearance/pending") +async def clearance_pending(): + rows = await db_fetchall( + "SELECT * FROM clearance_requests WHERE status='pending' ORDER BY created_at DESC" + ) + return rows or [] + +@app.get("/clearance/history") +async def clearance_history(limit: int = 50): + rows = await db_fetchall( + "SELECT * FROM clearance_requests ORDER BY created_at DESC LIMIT %s", (limit,) + ) + return rows or [] + +@app.post("/clearance/{cr_id}/approve") +async def clearance_approve(cr_id: int, req: Request): + try: body = await req.json() + except Exception: body = {} + decided_by = body.get("decided_by", "admin") if isinstance(body, dict) else "admin" + cr = await db_fetchone("SELECT * FROM clearance_requests WHERE id=%s AND status='pending'", (cr_id,)) + if not cr: + raise HTTPException(status_code=404, detail="Clearance request not found or not pending") + payload = cr.get("job_payload") or {} + if isinstance(payload, str): + try: payload = json.loads(payload) + except Exception: payload = {} + return await _dispatch_cleared_job(cr_id, cr["job_type"], payload, decided_by=decided_by) + +@app.post("/clearance/{cr_id}/deny") +async def clearance_deny(cr_id: int, req: Request): + try: body = await req.json() + except Exception: body = {} + decided_by = body.get("decided_by", "admin") if isinstance(body, dict) else "admin" + note = body.get("note", "") if isinstance(body, dict) else "" + await db_execute( + "UPDATE clearance_requests SET status='denied', decided_by=%s, decision_note=%s, decided_at=NOW() WHERE id=%s", + (decided_by, note, cr_id) + ) + log.info(f"[CLEARANCE] Request #{cr_id} denied by {decided_by}") + return {"ok": True, "clearance_id": cr_id, "status": "denied"} + +@app.get("/clearance/rules") +async def clearance_rules_list(): + rows = await db_fetchall("SELECT * FROM clearance_rules ORDER BY risk_level DESC, job_type ASC") + return rows or [] + +@app.put("/clearance/rules/{rule_id}") +async def clearance_rule_update(rule_id: int, req: Request): + body = await req.json() + fields: list = [] + params: list = [] + for col in ("risk_level", "require_approval", "auto_approve_after_min", "enabled", "description"): + if col in body: + fields.append(f"{col}=%s") + params.append(body[col]) + if not fields: + raise HTTPException(status_code=400, detail="Nothing to update") + params.append(rule_id) + await db_execute(f"UPDATE clearance_rules SET {','.join(fields)} WHERE id=%s", params) + return {"ok": True} + +@app.post("/clearance/rules") +async def clearance_rule_create(req: Request): + body = await req.json() + job_type = body.get("job_type", "").strip() + if not job_type: + raise HTTPException(status_code=400, detail="job_type required") + rid = await db_execute( + "INSERT INTO clearance_rules (job_type,risk_level,require_approval,auto_approve_after_min,description,enabled) " + "VALUES (%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE " + "risk_level=%s,require_approval=%s,auto_approve_after_min=%s,description=%s,enabled=%s", + (job_type, + body.get("risk_level","high"), int(body.get("require_approval",1)), + body.get("auto_approve_after_min"), body.get("description",""), int(body.get("enabled",1)), + body.get("risk_level","high"), int(body.get("require_approval",1)), + body.get("auto_approve_after_min"), body.get("description",""), int(body.get("enabled",1))) + ) + return {"ok": True, "id": rid} + +# ── Memory Core FastAPI endpoints ────────────────────────────────────────────── + +@app.get("/memory/facts") +async def memory_facts_list(limit: int = 100, category: str = "", search: str = ""): + where = "WHERE active=1" + params: list = [] + if category: + where += " AND category=%s" + params.append(category) + if search: + where += " AND (subject LIKE %s OR predicate LIKE %s OR object LIKE %s)" + params += [f"%{search}%"] * 3 + rows = await db_fetchall( + f"SELECT * FROM memory_facts {where} ORDER BY confirmed_count DESC, last_confirmed_at DESC LIMIT %s", + params + [limit] + ) + return rows or [] + +@app.post("/memory/facts") +async def memory_fact_create(req: Request): + body = await req.json() + subj = str(body.get("subject", "")).strip()[:255] + pred = str(body.get("predicate", "is")).strip()[:255] + obj = str(body.get("object", "")).strip() + cat = body.get("category", "fact") + if not subj or not obj: + raise HTTPException(status_code=400, detail="subject and object required") + valid = {"preference","person","place","routine","goal","fact","instruction"} + if cat not in valid: cat = "fact" + fid = await db_execute( + """INSERT INTO memory_facts (category, subject, predicate, object, confidence, source) + VALUES (%s,%s,%s,%s,1.0,'explicit') + ON DUPLICATE KEY UPDATE + object=VALUES(object), confidence=1.0, + confirmed_count=confirmed_count+1, last_confirmed_at=NOW(), active=1""", + (cat, subj, pred, obj) + ) + return {"ok": True, "id": fid} + +@app.delete("/memory/facts/{fact_id}") +async def memory_fact_delete(fact_id: int): + await db_execute("UPDATE memory_facts SET active=0 WHERE id=%s", (fact_id,)) + return {"ok": True} + +@app.delete("/memory/facts") +async def memory_facts_clear(category: str = ""): + if category: + await db_execute("UPDATE memory_facts SET active=0 WHERE category=%s", (category,)) + else: + await db_execute("UPDATE memory_facts SET active=0 WHERE active=1", ()) + return {"ok": True} + +@app.get("/memory/context") +async def memory_context(message: str = "", limit: int = 15): + """Return relevant memory facts for a given message (for prompt injection).""" + params: list = [] + if message.strip(): + words = [w for w in message.lower().split() if len(w) > 3][:10] + if words: + like_clauses = " OR ".join(["subject LIKE %s OR object LIKE %s"] * len(words)) + for w in words: + params += [f"%{w}%", f"%{w}%"] + rows = await db_fetchall( + f"SELECT * FROM memory_facts WHERE active=1 AND ({like_clauses}) " + f"ORDER BY confirmed_count DESC, confidence DESC LIMIT %s", + params + [limit] + ) or [] + # Pad with top general facts if sparse + if len(rows) < 5: + existing = [r["id"] for r in rows] + excl = ("AND id NOT IN (" + ",".join(["%s"]*len(existing)) + ")") if existing else "" + extra = await db_fetchall( + f"SELECT * FROM memory_facts WHERE active=1 {excl} " + f"ORDER BY confirmed_count DESC LIMIT %s", + existing + [max(1, limit - len(rows))] + ) or [] + rows = rows + extra + else: + rows = await db_fetchall( + "SELECT * FROM memory_facts WHERE active=1 ORDER BY confirmed_count DESC LIMIT %s", (limit,) + ) or [] + else: + rows = await db_fetchall( + "SELECT * FROM memory_facts WHERE active=1 ORDER BY confirmed_count DESC LIMIT %s", (limit,) + ) or [] + lines = [f"[{r['category']}] {r['subject']} {r['predicate']}: {r['object']}" for r in rows] + return {"facts": rows, "context": "\n".join(lines) if lines else ""} + +@app.get("/memory/stats") +async def memory_stats(): + total = await db_fetchone("SELECT COUNT(*) cnt FROM memory_facts WHERE active=1") + by_cat = await db_fetchall( + "SELECT category, COUNT(*) cnt FROM memory_facts WHERE active=1 GROUP BY category ORDER BY cnt DESC" + ) + recent = await db_fetchall( + "SELECT * FROM memory_facts WHERE active=1 ORDER BY last_confirmed_at DESC LIMIT 5" + ) + return { + "total": total["cnt"] if total else 0, + "by_category": by_cat or [], + "recent": recent or [], + } + +@app.get("/status") +async def status(): + row = await db_fetchone("SELECT * FROM arc_status WHERE id=1") + queued = await db_fetchone("SELECT COUNT(*) cnt FROM arc_jobs WHERE status='queued'") + running = await db_fetchone("SELECT COUNT(*) cnt FROM arc_jobs WHERE status='running'") + return { + "online": True, "version": VERSION, + "last_heartbeat": row["last_heartbeat"].isoformat() if row and row["last_heartbeat"] else None, + "started_at": row["started_at"].isoformat() if row and row["started_at"] else None, + "jobs_done": row["jobs_done"] if row else 0, + "jobs_failed": row["jobs_failed"] if row else 0, + "active_jobs": len(_running_jobs), + "queued_jobs": queued["cnt"] if queued else 0, + "running_jobs": running["cnt"] if running else 0, + "handlers": list(JOB_HANDLERS.keys()), + } + +@app.post("/job") +async def create_job(request: Request): + body = await request.json() + jtype = body.get("type", "") + payload = body.get("payload", {}) + priority = int(body.get("priority", 5)) + created_by = body.get("created_by", "jarvis") + if not jtype: + raise HTTPException(status_code=400, detail="Missing job type") + clearance = await _check_clearance(jtype, payload, created_by) + if clearance: + return clearance + jid = await db_execute( + "INSERT INTO arc_jobs (job_type, payload, priority, created_by) VALUES (%s, %s, %s, %s)", + (jtype, json.dumps(payload), priority, created_by) + ) + return {"job_id": jid, "status": "queued"} + +@app.get("/job/{job_id}") +async def get_job(job_id: int): + job = await db_fetchone("SELECT * FROM arc_jobs WHERE id=%s", (job_id,)) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + result = job.get("result") + if result and isinstance(result, str): + try: + result = json.loads(result) + except Exception: + pass + return { + "id": job["id"], "job_type": job["job_type"], "status": job["status"], + "result": result, "error": job.get("error"), + "created_at": job["created_at"].isoformat() if job["created_at"] else None, + "started_at": job["started_at"].isoformat() if job["started_at"] else None, + "completed_at": job["completed_at"].isoformat() if job["completed_at"] else None, + } + +@app.delete("/job/{job_id}") +async def cancel_job(job_id: int): + await db_execute("UPDATE arc_jobs SET status='cancelled' WHERE id=%s AND status='queued'", (job_id,)) + return {"ok": True} + +@app.get("/jobs") +async def list_jobs(limit: int = 50, status: str = ""): + if status: + rows = await db_fetchall( + "SELECT id,job_type,status,priority,created_by,created_at,completed_at FROM arc_jobs WHERE status=%s ORDER BY id DESC LIMIT %s", + (status, limit) + ) + else: + rows = await db_fetchall( + "SELECT id,job_type,status,priority,created_by,created_at,completed_at FROM arc_jobs ORDER BY id DESC LIMIT %s", + (limit,) + ) + for r in rows: + if r.get("created_at"): r["created_at"] = r["created_at"].isoformat() + if r.get("completed_at"): r["completed_at"] = r["completed_at"].isoformat() + return rows + +@app.get("/jobs/recent") +async def recent_jobs(limit: int = 10, job_type: str = ""): + if job_type: + rows = await db_fetchall( + "SELECT id,job_type,status,result,created_at,completed_at FROM arc_jobs WHERE job_type=%s ORDER BY id DESC LIMIT %s", + (job_type, limit) + ) + else: + rows = await db_fetchall( + "SELECT id,job_type,status,result,created_at,completed_at FROM arc_jobs ORDER BY id DESC LIMIT %s", + (limit,) + ) + for r in rows: + if r.get("created_at"): r["created_at"] = r["created_at"].isoformat() + if r.get("completed_at"): r["completed_at"] = r["completed_at"].isoformat() + if r.get("result") and isinstance(r["result"], str): + try: + r["result"] = json.loads(r["result"]) + except Exception: + pass + return rows + +@app.get("/triage") +async def get_triage(limit: int = 30, account: str = "gmail"): + rows = await db_fetchall( + """SELECT id, from_name, from_email, subject, date_received, category, priority, + summary, draft_reply, action_taken, created_at + FROM email_triage WHERE account=%s + ORDER BY priority DESC, created_at DESC LIMIT %s""", + (account, limit) + ) + for r in rows: + if r.get("date_received"): r["date_received"] = str(r["date_received"]) + if r.get("created_at"): r["created_at"] = str(r["created_at"]) + return rows + +@app.post("/triage/{triage_id}/action") +async def triage_action(triage_id: int, request: Request): + body = await request.json() + action = body.get("action", "dismissed") + await db_execute("UPDATE email_triage SET action_taken=%s WHERE id=%s", (action, triage_id)) + return {"ok": True} + +@app.delete("/jobs/purge") +async def purge_jobs(): + await db_execute("DELETE FROM arc_jobs WHERE status IN ('done','failed','cancelled') AND completed_at < DATE_SUB(NOW(), INTERVAL 7 DAY)") + return {"ok": True} + +# ── VISION PROTOCOL endpoints ───────────────────────────────────────────────── + +@app.get("/screenshots") +async def list_screenshots(limit: int = 20, agent: str = ""): + if agent: + rows = await db_fetchall( + "SELECT id, agent_id, hostname, method, width, height, file_size, " + " vision_analysis, vision_provider, created_at " + "FROM agent_screenshots WHERE hostname LIKE %s " + "ORDER BY created_at DESC LIMIT %s", + (f"%{agent}%", limit) + ) + else: + rows = await db_fetchall( + "SELECT id, agent_id, hostname, method, width, height, file_size, " + " vision_analysis, vision_provider, created_at " + "FROM agent_screenshots ORDER BY created_at DESC LIMIT %s", + (limit,) + ) + return rows or [] + +@app.get("/screenshots/{screenshot_id}") +async def get_screenshot(screenshot_id: int): + row = await db_fetchone( + "SELECT * FROM agent_screenshots WHERE id=%s", (screenshot_id,) + ) + if not row: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Screenshot not found") + return row + +@app.delete("/screenshots/{screenshot_id}") +async def delete_screenshot(screenshot_id: int): + await db_execute("DELETE FROM agent_screenshots WHERE id=%s", (screenshot_id,)) + return {"ok": True} + +@app.delete("/screenshots/purge") +async def purge_screenshots(): + await db_execute("DELETE FROM agent_screenshots WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)") + return {"ok": True} + +# ── GUARDIAN MODE endpoints ─────────────────────────────────────────────────── + +@app.get("/guardian/status") +async def guardian_status(): + cfg = await _guardian_get_config() + counts = await db_fetchone( + """SELECT + SUM(acknowledged=0) AS unread, + SUM(severity='critical' AND acknowledged=0) AS critical_unread, + SUM(severity='warning' AND acknowledged=0) AS warning_unread, + SUM(created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS events_24h + FROM guardian_events""" + ) + return { + "enabled": cfg.get("enabled", "1") == "1", + "scan_interval": int(cfg.get("scan_interval", 120)), + "last_scan": _guardian_state.get("last_scan"), + "last_sitrep": _guardian_state.get("last_sitrep"), + "thresholds": { + "cpu": float(cfg.get("cpu_threshold", 85)), + "memory": float(cfg.get("mem_threshold", 88)), + "disk": float(cfg.get("disk_threshold", 88)), + "offline_minutes": int(cfg.get("offline_minutes", 3)), + }, + "counts": dict(counts) if counts else {}, + } + +@app.get("/guardian/events") +async def guardian_events_list(limit: int = 30, unread: bool = False, + severity: str = "", since: str = ""): + wheres = [] + params: list = [] + if unread: + wheres.append("acknowledged = 0") + if severity: + wheres.append("severity = %s"); params.append(severity) + if since: + wheres.append("created_at > %s"); params.append(since) + where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else "" + params.append(limit) + rows = await db_fetchall( + f"SELECT * FROM guardian_events {where_sql} ORDER BY created_at DESC LIMIT %s", + params + ) + return rows or [] + +@app.post("/guardian/events/{event_id}/ack") +async def guardian_ack(event_id: int): + await db_execute("UPDATE guardian_events SET acknowledged=1 WHERE id=%s", (event_id,)) + return {"ok": True} + +@app.post("/guardian/events/ack_all") +async def guardian_ack_all(): + await db_execute("UPDATE guardian_events SET acknowledged=1 WHERE acknowledged=0") + return {"ok": True} + +@app.get("/guardian/chat") +async def guardian_chat_events(since: str = ""): + """Return proactive guardian messages injected into conversations.""" + if since: + rows = await db_fetchall( + "SELECT id, content, created_at FROM conversations" + "WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC", + (since,) + ) + else: + rows = await db_fetchall( + "SELECT id, content, created_at FROM conversations" + "WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10" + ) + return rows or [] + +# ── COMMS v2 endpoints ─────────────────────────────────────────────────────── + +@app.get("/comms/sent") +async def comms_sent(limit: int = 50, status: str = ""): + if status: + rows = await db_fetchall( + "SELECT id,account,to_email,to_name,subject,status,sent_at,triage_id " + "FROM email_sent WHERE status=%s ORDER BY sent_at DESC LIMIT %s", + (status, limit) + ) + else: + rows = await db_fetchall( + "SELECT id,account,to_email,to_name,subject,status,sent_at,triage_id " + "FROM email_sent ORDER BY sent_at DESC LIMIT %s", + (limit,) + ) + return rows or [] + +@app.get("/comms/sent/{sent_id}") +async def comms_sent_get(sent_id: int): + row = await db_fetchone("SELECT * FROM email_sent WHERE id=%s", (sent_id,)) + if not row: + raise HTTPException(status_code=404, detail="Not found") + return row + +@app.delete("/comms/sent/{sent_id}") +async def comms_sent_delete(sent_id: int): + await db_execute("DELETE FROM email_sent WHERE id=%s", (sent_id,)) + return {"ok": True} + +if __name__ == "__main__": + uvicorn.run("reactor:app", host=HOST, port=PORT, log_level="info", access_log=False) diff --git a/agent/jarvis-ha-poller.py b/agent/jarvis-ha-poller.py new file mode 100644 index 0000000..d5c1015 --- /dev/null +++ b/agent/jarvis-ha-poller.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +JARVIS HA Poller — pulls entity states from Home Assistant REST API +and pushes them to JARVIS as a homeassistant-type agent. +Runs on VM211 as a systemd service (jarvis-ha-poller). + +Config: /etc/jarvis-agent/ha-poller.json +""" + +import json +import os +import socket +import sys +import time +import urllib.request +import urllib.error +import ssl +from datetime import datetime, timezone +from pathlib import Path + +CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json" +STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json" +AGENT_VERSION = "1.0" +AGENT_ID = "homeassistant_ha" +HOSTNAME = "homeassistant" + +# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean) +SKIP_DOMAINS = { + 'sensor', 'binary_sensor', 'button', 'update', 'select', 'number', + 'device_tracker', 'event', 'image', 'person', 'zone', 'tts', + 'conversation', 'assist_satellite', 'input_button', 'media_player', + 'scene', 'water_heater', 'alarm_control_panel', 'automation', + 'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification', + 'tag', 'system_health', 'timer', 'counter', + 'camera', 'siren', 'remote', 'todo', 'lawn_mower', +} + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + +def load_config() -> dict: + if not os.path.exists(CONFIG_PATH): + print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True) + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if os.path.exists(STATE_PATH): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +def _ssl_ctx(verify: bool): + if not verify: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + return None + +def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None: + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + log(f"HA API error: {e}") + return None + +def register(cfg: dict, state: dict) -> str: + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", False)) + reg_key = cfg["registration_key"] + + log(f"Registering HA poller with JARVIS at {jarvis_url}...") + result = jarvis_post( + f"{jarvis_url}/api/agent/register", + { + "hostname": HOSTNAME, + "version": AGENT_VERSION, + "agent_type": "homeassistant", + "ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0], + "capabilities": ["ha_entities", "ha_state"], + "agent_id": AGENT_ID, + }, + {"X-Registration-Key": reg_key}, + ssl_verify, + ) + if "error" in result: + log(f"Registration failed: {result['error']}") + return "" + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = AGENT_ID + save_state(state) + log(f"Registered. agent_id={AGENT_ID}") + return api_key + +def push_entities(cfg: dict, api_key: str, entities: list) -> bool: + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", False)) + headers = {"X-Agent-Key": api_key} + + # Send in batches of 200 + batch_size = 200 + total = len(entities) + ok = True + for i in range(0, total, batch_size): + batch = entities[i:i+batch_size] + result = jarvis_post( + f"{jarvis_url}/api/agent/ha_state", + {"entities": batch}, + headers, + ssl_verify, + ) + if "error" in result: + log(f"Push batch {i//batch_size+1} failed: {result['error']}") + ok = False + return ok + +def heartbeat(cfg: dict, api_key: str) -> bool: + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", False)) + result = jarvis_post( + f"{jarvis_url}/api/agent/heartbeat", + {"version": AGENT_VERSION}, + {"X-Agent-Key": api_key}, + ssl_verify, + timeout=10, + ) + return "error" not in result + +def fetch_ha_states(cfg: dict) -> list: + ha_url = cfg["ha_url"].rstrip("/") + token = cfg["ha_token"] + states = ha_get(f"{ha_url}/api/states", token) + if not states or not isinstance(states, list): + return [] + + entities = [] + for s in states: + entity_id = s.get("entity_id", "") + domain = entity_id.split(".")[0] if "." in entity_id else "" + if domain in SKIP_DOMAINS: + continue + attrs = s.get("attributes", {}) + # Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime + lc = s.get("last_changed", "") + try: + dt = datetime.fromisoformat(lc.replace("Z", "+00:00")) + lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + lc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + entities.append({ + "entity_id": entity_id, + "name": attrs.get("friendly_name") or entity_id, + "state": s.get("state", ""), + "attributes": attrs, + "last_changed": lc, + }) + return entities + +def main(): + cfg = load_config() + state = load_state() + + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + + api_key = state.get("api_key", "") + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("Could not register. Retrying in 60s...") + time.sleep(60) + + headers = {"X-Agent-Key": api_key} + last_push = 0 + log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.") + + while True: + now = time.time() + + # Heartbeat + if not heartbeat(cfg, api_key): + log("Heartbeat failed (401?) — re-registering...") + state.clear() + save_state(state) + api_key = register(cfg, state) + if not api_key: + time.sleep(60) + continue + + # Push entity states every poll_interval + if now - last_push >= poll_interval: + entities = fetch_ha_states(cfg) + if entities: + ok = push_entities(cfg, api_key, entities) + if ok: + log(f"Pushed {len(entities)} HA entities to JARVIS.") + last_push = now + else: + log("No HA entities fetched (HA down or token invalid?)") + last_push = now + + time.sleep(heartbeat_every) + +if __name__ == "__main__": + main() diff --git a/agent/pve1-probes/jarvis-netscan.sh b/agent/pve1-probes/jarvis-netscan.sh new file mode 100644 index 0000000..57ecbab --- /dev/null +++ b/agent/pve1-probes/jarvis-netscan.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# JARVIS Network Scanner — runs on PVE1, pushes nmap results to JARVIS +# Cron: */3 * * * * /usr/local/bin/jarvis-netscan.sh >/dev/null 2>&1 + +JARVIS_URL="http://10.48.200.211" +JARVIS_HOST="jarvis.orbishosting.com" +REG_KEY=$(cat /etc/jarvis-agent/reg-key 2>/dev/null) +if [ -z "$REG_KEY" ]; then + echo "$(date): ERROR: /etc/jarvis-agent/reg-key not found" >&2 + exit 1 +fi +SUBNET="10.48.200.0/24" + +TMPFILE=$(mktemp) +nmap -sn --send-ip "$SUBNET" 2>/dev/null > "$TMPFILE" + +if [ ! -s "$TMPFILE" ]; then + echo "$(date): nmap produced no output" >&2 + rm -f "$TMPFILE" + exit 1 +fi + +JSON=$(python3 - "$TMPFILE" <<'PYEOF' +import sys, re, json + +with open(sys.argv[1]) as f: + data = f.read() + +devices = [] +cur = None + +for line in data.splitlines(): + line = line.strip() + m = re.match(r'Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)\)?', line) + if m: + if cur: + devices.append(cur) + hn = m.group(1) if m.group(1) and m.group(1) != m.group(2) else '' + cur = {'ip': m.group(2), 'hostname': hn, 'mac': '', 'vendor': ''} + elif cur: + m2 = re.match(r'MAC Address: ([0-9A-Fa-f:]{17}) \(([^)]+)\)', line) + if m2: + cur['mac'] = m2.group(1).lower() + cur['vendor'] = '' if m2.group(2) == 'Unknown' else m2.group(2) + +if cur: + devices.append(cur) + +print(json.dumps({'devices': devices})) +PYEOF +) + +rm -f "$TMPFILE" + +if [ -z "$JSON" ]; then + echo "$(date): JSON parse failed" >&2 + exit 1 +fi + +RESPONSE=$(curl -sk --max-time 15 \ + -X POST "$JARVIS_URL/api/netscan" \ + -H "Host: $JARVIS_HOST" \ + -H "Content-Type: application/json" \ + -H "X-Registration-Key: $REG_KEY" \ + -d "$JSON" 2>/dev/null) + +echo "$(date): $RESPONSE" diff --git a/agent/pve1-probes/jarvis-phone-probe.sh b/agent/pve1-probes/jarvis-phone-probe.sh new file mode 100644 index 0000000..936fe70 --- /dev/null +++ b/agent/pve1-probes/jarvis-phone-probe.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# JARVIS VoIP Phone Probe — runs every minute on PVE1 +# Pings all Yealink phones + checks FusionPBX SIP registration (read-only) +# 200.3 is on an external FusionPBX — ping only, no SIP check + +JARVIS_URL="http://10.48.200.211" +JARVIS_HOST="jarvis.orbishosting.com" +REG_KEY=$(cat /etc/jarvis-agent/reg-key 2>/dev/null) +if [ -z "$REG_KEY" ]; then + echo "$(date): ERROR: /etc/jarvis-agent/reg-key not found" >&2 + exit 1 +fi +FUSION_HOST="134.209.72.226" + +# IP|alias|extension(none=skip SIP check)|mac +PHONES=( + "10.48.200.2|Yealink — Myron Main (Ext 1000)|1000|80:5e:c0:35:04:77" + "10.48.200.3|Yealink — United Mirror & Glass (External SIP)|none|c4:fc:22:28:63:71" + "10.48.200.43|Yealink T48S — Tommy Main (Ext 1001)|1001|80:5e:0c:15:0c:4f" + "10.48.200.86|Yealink — Myron Vanguard WiFi (Offline During Work Hrs)|none|" + "10.48.200.65|Yealink — Myron Vanguard Work (Ext 1003)|1003|c4:fc:22:13:e1:89" +) + +# Get SIP registrations from FusionPBX (read-only) +REG_OUTPUT=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \ + root@$FUSION_HOST "fs_cli -x 'show registrations'" 2>/dev/null || echo "") + +# Collect results as TSV, delegate JSON building to python3 to avoid injection +RESULTS="" +for PHONE in "${PHONES[@]}"; do + IFS='|' read -r IP ALIAS EXT MAC <<< "$PHONE" + + if ping -c 1 -W 2 "$IP" > /dev/null 2>&1; then + STATUS="online" + else + STATUS="offline" + fi + + if [ "$EXT" = "none" ]; then + SIP="external" + elif [ -n "$REG_OUTPUT" ] && echo "$REG_OUTPUT" | grep -q "^${EXT},"; then + SIP="registered" + else + SIP="unregistered" + fi + + RESULTS="${RESULTS}${IP}\t${ALIAS}\t${MAC}\t${STATUS}\t${SIP}\t${EXT}\n" +done + +JSON=$(printf "%b" "$RESULTS" | python3 -c " +import sys, json +devices = [] +for line in sys.stdin: + line = line.rstrip('\n') + if not line: + continue + parts = line.split('\t') + if len(parts) < 6: + continue + ip, alias, mac, status, sip, ext = parts[:6] + devices.append({ + 'ip': ip, 'alias': alias, 'mac': mac, + 'vendor': 'Yealink', 'status': status, + 'sip_status': sip, 'extension': ext, + }) +print(json.dumps({'devices': devices})) +") + +curl -sk --max-time 10 \ + -X POST "$JARVIS_URL/api/netscan" \ + -H "Host: $JARVIS_HOST" \ + -H "Content-Type: application/json" \ + -H "X-Registration-Key: $REG_KEY" \ + -d "$JSON" > /dev/null 2>&1 diff --git a/agent/pve1-probes/jarvis-ping-probe.py b/agent/pve1-probes/jarvis-ping-probe.py new file mode 100644 index 0000000..72e68f8 --- /dev/null +++ b/agent/pve1-probes/jarvis-ping-probe.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +JARVIS Ping Probe — runs on PVE1 (10.48.200.90), which is on the LAN. +Pings devices that can't run the full agent, then calls JARVIS heartbeat +on their behalf so the dashboard shows live status. +""" + +import json +import subprocess +import urllib.request +import urllib.error +import ssl + +JARVIS_URL = "http://10.48.200.211" +HOST_HEADER = "jarvis.orbishosting.com" + +# Devices to probe: agent_id → api_key +DEVICES = { + "fortigate_gw": "00103aea6fcbf837bc55e11b445a3620", + "yealink_t48s": "2bf8bd7ca8dd31c28fd16aa956e15f88", + "homeassistant_ha": "6f8077dee7a7b4af202bc80886f1223d", +} + +# Map agent_id → IP (for ping) +IPS = { + "fortigate_gw": "10.48.200.1", + "yealink_t48s": "10.48.200.43", + "homeassistant_ha": "10.48.200.97", +} + +def ping(ip: str) -> bool: + result = subprocess.run( + ["ping", "-c", "1", "-W", "2", ip], + capture_output=True, timeout=5 + ) + return result.returncode == 0 + +def heartbeat(agent_id: str, api_key: str, alive: bool): + # If device is down we still send heartbeat so JARVIS updates last_seen + # and sets status based on the alive flag via the metric payload + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + payload = json.dumps({}).encode() + req = urllib.request.Request( + f"{JARVIS_URL}/api/agent/heartbeat", + data=payload, method="POST" + ) + req.add_header("Content-Type", "application/json") + req.add_header("X-Agent-Key", api_key) + req.add_header("Host", HOST_HEADER) + + try: + with urllib.request.urlopen(req, timeout=10, context=ctx): + pass + except Exception: + pass + +def update_status(agent_id: str, api_key: str, status: str): + """Push a minimal metric so JARVIS knows if device is up or down.""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + payload = json.dumps({ + "type": "system", + "data": { + "hostname": agent_id, + "cpu_percent": 0, + "ping_only": True, + "ping_status": status, + } + }).encode() + req = urllib.request.Request( + f"{JARVIS_URL}/api/agent/metrics", + data=payload, method="POST" + ) + req.add_header("Content-Type", "application/json") + req.add_header("X-Agent-Key", api_key) + req.add_header("Host", HOST_HEADER) + + try: + with urllib.request.urlopen(req, timeout=10, context=ctx): + pass + except Exception: + pass + +def main(): + for agent_id, api_key in DEVICES.items(): + ip = IPS.get(agent_id, "") + alive = ping(ip) if ip else False + status = "online" if alive else "offline" + print(f"{agent_id} ({ip}): {status}", flush=True) + heartbeat(agent_id, api_key, alive) + update_status(agent_id, api_key, status) + +if __name__ == "__main__": + main() diff --git a/api/config.example.php b/api/config.example.php new file mode 100644 index 0000000..2fed500 --- /dev/null +++ b/api/config.example.php @@ -0,0 +1,54 @@ + $msg]); + exit; +} + +function agent_ok(array $payload = []): void { + echo json_encode(array_merge(['ok' => true], $payload)); + exit; +} + +function generate_api_key(): string { + return bin2hex(random_bytes(24)); +} + +function get_agent_by_key(string $key): ?array { + $rows = JarvisDB::query( + 'SELECT * FROM registered_agents WHERE api_key = ? LIMIT 1', + [$key] + ); + return $rows[0] ?? null; +} + +function update_agent_seen(string $agentId, string $status = 'online', ?string $version = null): void { + if ($version !== null) { + JarvisDB::query( + 'UPDATE registered_agents SET last_seen = NOW(), status = ?, version = ? WHERE agent_id = ?', + [$status, $version, $agentId] + ); + } else { + JarvisDB::query( + 'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?', + [$status, $agentId] + ); + } +} + +// ── Auth (all actions except register) ─────────────────────────────────────── + +$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? ''; +$browserActions = ['list', 'status', 'myip']; + +if ($agentAction !== 'register') { + if (in_array($agentAction, $browserActions)) { + $token = $_SESSION['jarvis_token'] ?? ''; + $localIP = $_SERVER['REMOTE_ADDR'] ?? ''; + if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1'])) { + agent_error(401, 'Unauthorized'); + } + $agent = null; + } else { + if (empty($agentKey)) agent_error(401, 'X-Agent-Key header required'); + $agent = get_agent_by_key($agentKey); + if (!$agent) agent_error(401, 'Invalid agent key'); + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── + +switch ($agentAction) { + + // ── REGISTER ───────────────────────────────────────────────────────────── + case 'register': + if ($method !== 'POST') agent_error(405, 'POST only'); + $regKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ($data['registration_key'] ?? ''); + if (!hash_equals(AGENT_REGISTRATION_KEY, $regKey)) agent_error(403, 'Invalid registration key'); + + $hostname = trim($data['hostname'] ?? ''); + $agentType = $data['agent_type'] ?? 'linux'; + $ipAddress = $data['ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''); + $capabilities = $data['capabilities'] ?? []; + $agentId = $data['agent_id'] ?? ($hostname . '_' . substr(md5($hostname . $ipAddress), 0, 8)); + $version = trim($data['version'] ?? ''); + + if (!$hostname) agent_error(400, 'hostname required'); + if (!in_array($agentType, ['linux', 'homeassistant', 'proxmox', 'windows', 'macos'])) agent_error(400, 'Invalid agent_type'); + + // Upsert agent + $existing = JarvisDB::query('SELECT api_key FROM registered_agents WHERE agent_id = ?', [$agentId]); + if ($existing) { + $apiKey = $existing[0]['api_key']; + JarvisDB::query( + 'UPDATE registered_agents SET hostname=?, agent_type=?, ip_address=?, capabilities=?, version=?, last_seen=NOW(), status="online" WHERE agent_id=?', + [$hostname, $agentType, $ipAddress, json_encode($capabilities), $version ?: null, $agentId] + ); + } else { + $apiKey = generate_api_key(); + JarvisDB::query( + 'INSERT INTO registered_agents (agent_id, hostname, agent_type, ip_address, api_key, capabilities, version, last_seen, status) VALUES (?,?,?,?,?,?,?,NOW(),"online")', + [$agentId, $hostname, $agentType, $ipAddress, $apiKey, json_encode($capabilities), $version ?: null] + ); + } + + agent_ok(['agent_id' => $agentId, 'api_key' => $apiKey]); + + // ── HEARTBEAT ──────────────────────────────────────────────────────────── + case 'heartbeat': + $hbStatus = in_array($data['status'] ?? '', ['online','offline']) ? $data['status'] : 'online'; + update_agent_seen($agent['agent_id'], $hbStatus, trim($data['version'] ?? '') ?: null); + + // Return any pending commands for this agent + $commands = JarvisDB::query( + 'SELECT id, command_type, command_data FROM agent_commands WHERE agent_id = ? AND status = "pending" ORDER BY created_at ASC LIMIT 10', + [$agent['agent_id']] + ); + + // Mark as delivered + if ($commands) { + $cmdIds = array_column($commands, 'id'); + $placeholders = implode(',', array_fill(0, count($cmdIds), '?')); + JarvisDB::query("UPDATE agent_commands SET status='delivered', delivered_at=NOW() WHERE id IN ($placeholders)", $cmdIds); + foreach ($commands as &$cmd) { + $cmd['command_data'] = json_decode($cmd['command_data'] ?? '{}', true); + } + } + + agent_ok(['commands' => $commands ?: []]); + + // ── METRICS ────────────────────────────────────────────────────────────── + case 'metrics': + if ($method !== 'POST') agent_error(405, 'POST only'); + update_agent_seen($agent['agent_id']); + + $metricType = $data['type'] ?? 'system'; + $metricData = $data['data'] ?? []; + + JarvisDB::query( + 'INSERT INTO agent_metrics (agent_id, metric_type, metric_data) VALUES (?,?,?)', + [$agent['agent_id'], $metricType, json_encode($metricData)] + ); + + // Prune old metrics (keep 24h) + JarvisDB::query( + 'DELETE FROM agent_metrics WHERE agent_id = ? AND recorded_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)', + [$agent['agent_id']] + ); + + agent_ok(); + + // ── HA STATE PUSH ──────────────────────────────────────────────────────── + case 'ha_state': + if ($method !== 'POST') agent_error(405, 'POST only'); + update_agent_seen($agent['agent_id']); + + $entities = $data['entities'] ?? []; + if (empty($entities)) agent_error(400, 'entities array required'); + + foreach ($entities as $e) { + $entityId = $e['entity_id'] ?? ''; + $entityName = $e['name'] ?? $e['friendly_name'] ?? $entityId; + $domain = explode('.', $entityId)[0] ?? 'unknown'; + $state = $e['state'] ?? ''; + $attrs = $e['attributes'] ?? []; + $lastChanged = $e['last_changed'] ?? date('Y-m-d H:i:s'); + + if (!$entityId) continue; + + JarvisDB::query( + 'INSERT INTO ha_entities (agent_id, entity_id, entity_name, domain, state, attributes, last_changed, updated_at) + VALUES (?,?,?,?,?,?,?,NOW()) + ON DUPLICATE KEY UPDATE entity_name=VALUES(entity_name), state=VALUES(state), attributes=VALUES(attributes), last_changed=VALUES(last_changed), updated_at=NOW()', + [$agent['agent_id'], $entityId, $entityName, $domain, $state, json_encode($attrs), $lastChanged] + ); + } + + // Also update kb_facts for compatibility with existing KB engine + $entityMap = []; + $all = JarvisDB::query('SELECT entity_id, entity_name, domain, state, attributes FROM ha_entities WHERE agent_id = ?', [$agent['agent_id']]); + foreach ($all as $row) { + $entityMap[$row['entity_id']] = [ + 'entity_id' => $row['entity_id'], + 'name' => $row['entity_name'], + 'domain' => $row['domain'], + 'state' => $row['state'], + 'attributes'=> json_decode($row['attributes'] ?? '{}', true), + ]; + } + JarvisDB::query( + 'INSERT INTO kb_facts (category, fact_key, fact_value) VALUES ("ha_entities", "ha/entity_map", ?) + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()', + [json_encode($entityMap)] + ); + + agent_ok(['accepted' => count($entities)]); + + // ── COMMAND RESULT ─────────────────────────────────────────────────────── + case 'command_result': + if ($method !== 'POST') agent_error(405, 'POST only'); + $cmdId = (int)($data['command_id'] ?? 0); + $result = $data['result'] ?? []; + $status = ($data['success'] ?? true) ? 'executed' : 'failed'; + + JarvisDB::query( + 'UPDATE agent_commands SET status=?, executed_at=NOW(), result=? WHERE id=? AND agent_id=?', + [$status, json_encode($result), $cmdId, $agent['agent_id']] + ); + agent_ok(); + + // ── LIST (admin: get all agents status) ────────────────────────────────── + case 'list': + // Mark agents offline if last_seen > 2 minutes ago + JarvisDB::query( + 'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"' + ); + $agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, capabilities FROM registered_agents ORDER BY agent_type, hostname'); + foreach ($agents as &$a) { + $a['capabilities'] = json_decode($a['capabilities'] ?? '[]', true); + } + $realIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''; + $realIp = trim(explode(',', $realIp)[0]); + agent_ok(['agents' => $agents, 'my_ip' => $realIp]); + + // ── LATEST METRICS (for dashboard display) ─────────────────────────────── + case 'status': + $agentIdReq = $data['agent_id'] ?? ($parts[2] ?? ''); + $whereAgent = $agentIdReq ? 'AND agent_id = ?' : ''; + $params = $agentIdReq ? [$agentIdReq] : []; + + $latest = JarvisDB::query( + "SELECT agent_id, metric_type, metric_data, recorded_at + FROM agent_metrics + WHERE (agent_id, metric_type, recorded_at) IN ( + SELECT agent_id, metric_type, MAX(recorded_at) + FROM agent_metrics $whereAgent + GROUP BY agent_id, metric_type + ) + ORDER BY agent_id, metric_type", + $params + ); + + $grouped = []; + foreach ($latest as $row) { + $grouped[$row['agent_id']][$row['metric_type']] = json_decode($row['metric_data'], true); + $grouped[$row['agent_id']]['recorded_at'] = $row['recorded_at']; + } + + agent_ok(['metrics' => $grouped]); + + // ── MY IP (browser client IP detection) ────────────────────────────────── + case 'myip': + agent_ok(['ip' => $_SERVER['REMOTE_ADDR'] ?? '']); + + default: + agent_error(404, 'Unknown agent action: ' . $agentAction); +} diff --git a/api/endpoints/alerts.php b/api/endpoints/alerts.php new file mode 100644 index 0000000..828d0f0 --- /dev/null +++ b/api/endpoints/alerts.php @@ -0,0 +1,238 @@ + DATE_SUB(NOW(), INTERVAL 5 MINUTE)" + ); + + foreach ($latest as $row) { + $d = json_decode($row['metric_data'] ?? '{}', true); + $hn = $row['hostname']; + $id = $row['agent_id']; + + // CPU + $cpu = (float)($d['cpu_percent'] ?? 0); + if ($cpu >= $CPU_WARN) { + $key = 'agent:' . $id . ':cpu_high'; + $sev = $cpu >= 95 ? 'critical' : 'warning'; + upsert_alert($key, $sev, 'High CPU: ' . $hn, + round($cpu, 1) . '% CPU utilization on ' . $hn . '. Sustained high load detected.'); + $still_active[$key] = true; + } + + // Memory + $mem_pct = (float)($d['memory']['percent'] ?? 0); + if ($mem_pct >= $MEM_WARN) { + $key = 'agent:' . $id . ':mem_high'; + $sev = $mem_pct >= 95 ? 'critical' : 'warning'; + upsert_alert($key, $sev, 'High Memory: ' . $hn, + round($mem_pct, 1) . '% memory used on ' . $hn . + ' (' . round($d['memory']['used_mb'] ?? 0) . '/' . + round($d['memory']['total_mb'] ?? 0) . ' MB).'); + $still_active[$key] = true; + } + + // Disk + foreach (($d['disk'] ?? []) as $disk) { + $pct = (int)($disk['percent'] ?? 0); + if ($pct >= $DISK_WARN) { + $mount = $disk['mount'] ?? '/'; + $key = 'agent:' . $id . ':disk:' . str_replace('/', '_', $mount); + $sev = $pct >= $DISK_CRIT ? 'critical' : 'warning'; + upsert_alert($key, $sev, 'Disk Full: ' . $hn . ' ' . $mount, + $mount . ' is ' . $pct . '% full on ' . $hn . + ' (' . ($disk['used'] ?? '?') . ' of ' . ($disk['size'] ?? '?') . ' used).'); + $still_active[$key] = true; + } + } + + // Services down — alert AND dispatch auto-restart command + foreach (($d['services'] ?? []) as $svc) { + if (($svc['status'] ?? '') === 'active') continue; + if (($svc['status'] ?? '') === 'unknown') continue; + $svcName = $svc['service'] ?? ''; + $key = 'agent:' . $id . ':svc:' . $svcName; + upsert_alert($key, 'warning', 'Service Down: ' . $svcName . ' on ' . $hn, + $svcName . ' is ' . ($svc['status'] ?? 'inactive') . ' on ' . $hn . '.'); + $still_active[$key] = true; + // Auto-dispatch restart if no pending command already queued + $pending = JarvisDB::query( + "SELECT id FROM agent_commands WHERE agent_id=? AND command_type='restart_service' + AND status IN ('pending','delivered') AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE) + AND JSON_EXTRACT(command_data,'$.service')=?", + [$id, $svcName] + ); + if (empty($pending)) { + JarvisDB::query( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status) + VALUES (?,?,?,?)", + [$id, 'restart_service', json_encode(['service' => $svcName]), 'pending'] + ); + } + } + // NordVPN (nordlynx interface) + $nordvpn = $d['nordvpn'] ?? null; + if ($nordvpn !== null && !($nordvpn['active'] ?? true)) { + $key = 'agent:' . $id . ':nordvpn_down'; + upsert_alert($key, 'critical', 'VPN Down: ' . $hn, + 'nordlynx interface is down on ' . $hn . '. Downloads may be unprotected or blocked.'); + $still_active[$key] = true; + $pending = JarvisDB::query( + "SELECT id FROM agent_commands WHERE agent_id=? AND command_type='restart_service' + AND status IN ('pending','delivered') AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE) + AND JSON_EXTRACT(command_data,'$.service')=?", + [$id, 'nordvpnd'] + ); + if (empty($pending)) { + JarvisDB::query( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)", + [$id, 'restart_service', json_encode(['service' => 'nordvpnd']), 'pending'] + ); + } + } + } + + // ── Site health alerts from kb_facts ────────────────────────────────────── + $siteKeys = ['jarvis','tomsjavajive','epictravelexp','parkersling','orbishosting','orbisportal','tomtomgames']; + $siteNames = [ + 'jarvis' => 'jarvis.orbishosting.com', + 'tomsjavajive' => 'tomsjavajive.com', + 'epictravelexp'=> 'epictravelexpeditions.com', + 'parkersling' => 'parkerslingshotrentals.com', + 'orbishosting' => 'orbishosting.com', + 'orbisportal' => 'orbis.orbishosting.com', + 'tomtomgames' => 'tomtomgames.com', + ]; + $siteFacts = JarvisDB::query( + "SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' + AND updated_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)" + ); + foreach ($siteFacts as $sf) { + $skey = $sf['fact_key']; + $status = $sf['fact_value']; + $domain = $siteNames[$skey] ?? $skey; + if ($status !== 'up') { + $alertKey = 'site:' . $skey . ':down'; + upsert_alert($alertKey, 'critical', 'Site Down: ' . $domain, + $domain . ' returned status ' . $status . '. Site may be unreachable.'); + $still_active[$alertKey] = true; + } + } + + // ── Auto-resolve alerts whose condition has cleared ──────────────────────── + if (!empty($still_active)) { + $active_keys = array_keys($still_active); + // Get all auto-resolvable alerts that are unresolved + $open_auto = JarvisDB::query( + "SELECT id, source_key FROM alerts WHERE resolved=0 AND auto_resolve=1 AND source_key IS NOT NULL" + ); + foreach ($open_auto as $row) { + if (!isset($still_active[$row['source_key']])) { + JarvisDB::query( + 'UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', + [$row['id']] + ); + } + } + } else { + // Nothing active — resolve all auto alerts + JarvisDB::query( + "UPDATE alerts SET resolved=1, resolved_at=NOW() + WHERE resolved=0 AND auto_resolve=1" + ); + } +} + +function upsert_alert(string $key, string $sev, string $title, string $msg): void { + $existing = JarvisDB::query( + 'SELECT id, severity FROM alerts WHERE source_key=? AND resolved=0 LIMIT 1', + [$key] + ); + if ($existing) { + // Update severity/message if changed (e.g., warning → critical) + if ($existing[0]['severity'] !== $sev) { + JarvisDB::query( + 'UPDATE alerts SET severity=?, title=?, message=?, created_at=NOW() WHERE id=?', + [$sev, $title, $msg, $existing[0]['id']] + ); + } + } else { + JarvisDB::query( + 'INSERT INTO alerts (alert_type, title, message, severity, source_key, auto_resolve) VALUES (?,?,?,?,?,1)', + ['agent', $title, $msg, $sev, $key] + ); + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── + +if ($method === 'GET') { + // Rate-limit agent alert refresh to once per 60 seconds via kb_facts lock + $last_refresh = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='agent' AND fact_key='alert_refresh' LIMIT 1"); + $last_ts = !empty($last_refresh) ? (int)$last_refresh[0]['fact_value'] : 0; + if (time() - $last_ts >= 60) { + JarvisDB::query( + "INSERT INTO kb_facts (category, fact_key, fact_value, host) VALUES ('agent', 'alert_refresh', ?, 'local') + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()", + [time()] + ); + refresh_agent_alerts(); + } + $alerts = JarvisDB::query( + 'SELECT * FROM alerts WHERE resolved=0 ORDER BY severity DESC, created_at DESC LIMIT 30' + ); + echo json_encode(['alerts' => $alerts ?: [], 'count' => count($alerts ?: [])]); + +} elseif ($method === 'POST' && ($action === 'resolve' || ($data['action'] ?? '') === 'resolve')) { + $id = (int)($data['id'] ?? 0); + JarvisDB::query('UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', [$id]); + echo json_encode(['success' => true]); + +} elseif ($method === 'POST') { + JarvisDB::query( + 'INSERT INTO alerts (alert_type, title, message, severity) VALUES (?,?,?,?)', + [$data['type'] ?? 'system', $data['title'] ?? 'Alert', $data['message'] ?? '', $data['severity'] ?? 'info'] + ); + echo json_encode(['success' => true]); +} diff --git a/api/endpoints/arc.php b/api/endpoints/arc.php new file mode 100644 index 0000000..c0a375a --- /dev/null +++ b/api/endpoints/arc.php @@ -0,0 +1,344 @@ + true, + CURLOPT_TIMEOUT => ARC_TIMEOUT, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } elseif ($method === 'DELETE') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + } + $raw = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($err || $raw === false) { + return ['error' => 'Arc Reactor unreachable: ' . $err, 'online' => false]; + } + $decoded = json_decode($raw, true); + return $decoded ?? ['error' => 'Invalid response from Arc Reactor', 'raw' => substr($raw, 0, 200)]; +} + +// ── ROUTING ─────────────────────────────────────────────────────────────────── +// arc action comes from query string or POST body (not the URL path segment) +global $data; +$action = $_GET['action'] ?? $data['action'] ?? ''; + +switch ($action) { + + // GET /api/arc?action=status + case 'status': + $result = arc_request('GET', '/status'); + if (!isset($result['online'])) $result['online'] = false; + echo json_encode($result); + break; + + // POST /api/arc — create a job + case 'job_create': + $type = $data['type'] ?? ''; + $payload = $data['payload'] ?? []; + $priority = (int)($data['priority'] ?? 5); + if (!$type) { http_response_code(400); echo json_encode(['error' => 'Missing job type']); break; } + $result = arc_request('POST', '/job', [ + 'type' => $type, + 'payload' => $payload, + 'priority' => $priority, + 'created_by' => 'jarvis_ui', + ]); + echo json_encode($result); + break; + + // GET /api/arc?action=job_get&id=123 + case 'job_get': + $id = (int)($data['id'] ?? $_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing job id']); break; } + echo json_encode(arc_request('GET', "/job/{$id}")); + break; + + // GET /api/arc?action=jobs&status=done&limit=20 + case 'jobs': + $status = $_GET['status'] ?? $data['status'] ?? ''; + $limit = (int)($_GET['limit'] ?? $data['limit'] ?? 50); + $qs = http_build_query(array_filter(['status' => $status, 'limit' => $limit])); + echo json_encode(arc_request('GET', '/jobs' . ($qs ? "?{$qs}" : ''))); + break; + + // DELETE /api/arc?action=job_cancel&id=123 + case 'job_cancel': + $id = (int)($data['id'] ?? $_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing job id']); break; } + echo json_encode(arc_request('DELETE', "/job/{$id}")); + break; + + // DELETE /api/arc?action=purge + case 'purge': + echo json_encode(arc_request('DELETE', '/jobs/purge')); + break; + + // Quick ping test + case 'ping': + $result = arc_request('POST', '/job', [ + 'type' => 'ping', + 'payload' => [], + 'priority' => 9, + 'created_by' => 'jarvis_ping', + ]); + echo json_encode($result); + break; + + // GET /api/arc?action=triage&limit=50&filter=priority + // Returns email_triage rows for the COMMS tab + case 'triage': + $limit = min((int)($_GET['limit'] ?? 50), 100); + $filter = $_GET['filter'] ?? 'priority'; + + if ($filter === 'urgent') { + $sql = "SELECT id, account, from_name, from_email, subject, date_received, + category, priority, summary, draft_reply, action_taken, created_at + FROM email_triage + WHERE action_taken != 'dismissed' AND category = 'urgent' + ORDER BY priority DESC, created_at DESC LIMIT ?"; + } elseif ($filter === 'action') { + $sql = "SELECT id, account, from_name, from_email, subject, date_received, + category, priority, summary, draft_reply, action_taken, created_at + FROM email_triage + WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting') + ORDER BY priority DESC, created_at DESC LIMIT ?"; + } elseif ($filter === 'priority') { + $sql = "SELECT id, account, from_name, from_email, subject, date_received, + category, priority, summary, draft_reply, action_taken, created_at + FROM email_triage + WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting') + AND priority >= 5 + ORDER BY priority DESC, created_at DESC LIMIT ?"; + } else { + $sql = "SELECT id, account, from_name, from_email, subject, date_received, + category, priority, summary, draft_reply, action_taken, created_at + FROM email_triage + WHERE action_taken != 'dismissed' + ORDER BY priority DESC, created_at DESC LIMIT ?"; + } + $rows = JarvisDB::query($sql, [$limit]); + echo json_encode($rows ?: []); + break; + + // POST /api/arc?action=triage_action&id=123 body: { action: "dismissed"|"replied"|"done" } + case 'triage_action': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + $actionTaken = $data['action'] ?? 'dismissed'; + $allowed = ['dismissed', 'replied', 'done', 'snoozed']; + if (!$id || !in_array($actionTaken, $allowed)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid id or action']); + break; + } + JarvisDB::execute( + "UPDATE email_triage SET action_taken = ? WHERE id = ?", + [$actionTaken, $id] + ); + echo json_encode(['ok' => true, 'id' => $id, 'action_taken' => $actionTaken]); + break; + + // GET /api/arc?action=screenshots&limit=20&agent=hostname + case 'screenshots': + $limit = min((int)($_GET['limit'] ?? 20), 100); + $agent = $_GET['agent'] ?? ''; + $url = 'http://127.0.0.1:7474/screenshots?' . http_build_query(array_filter(['limit' => $limit, 'agent' => $agent])); + $ch = curl_init($url); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]); + $raw = curl_exec($ch); curl_close($ch); + echo $raw ?: '[]'; + break; + + // GET /api/arc?action=screenshot_get&id=123 + case 'screenshot_get': + $id = (int)($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('GET', "/screenshots/{$id}")); + break; + + // DELETE /api/arc?action=screenshot_delete&id=123 + case 'screenshot_delete': + $id = (int)($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('DELETE', "/screenshots/{$id}")); + break; + + // ── GUARDIAN MODE ───────────────────────────────────────────────────────── + case 'guardian_status': + echo json_encode(arc_request('GET', '/guardian/status')); + break; + + case 'guardian_events': + $limit = (int)($_GET['limit'] ?? 30); + $unread = !empty($_GET['unread']) ? 'true' : ''; + $severity = $_GET['severity'] ?? ''; + $since = $_GET['since'] ?? ''; + $qs = http_build_query(array_filter([ + 'limit' => $limit, + 'unread' => $unread, + 'severity' => $severity, + 'since' => $since, + ])); + echo json_encode(arc_request('GET', '/guardian/events' . ($qs ? "?{$qs}" : ''))); + break; + + case 'guardian_ack': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if ($id) { + echo json_encode(arc_request('POST', "/guardian/events/{$id}/ack")); + } else { + echo json_encode(arc_request('POST', '/guardian/events/ack_all')); + } + break; + + case 'guardian_chat': + $since = $_GET['since'] ?? ''; + $qs = $since ? '?since=' . urlencode($since) : ''; + echo json_encode(arc_request('GET', '/guardian/chat' . $qs)); + break; + + // ── COMMS v2 ────────────────────────────────────────────────────────────── + // GET /api/arc?action=comms_sent&limit=50&status=sent + case 'comms_sent': + $limit = min((int)($_GET['limit'] ?? 50), 200); + $status = $_GET['status'] ?? ''; + $qs = http_build_query(array_filter(['limit' => $limit, 'status' => $status])); + echo json_encode(arc_request('GET', '/comms/sent' . ($qs ? "?{$qs}" : ''))); + break; + + // GET /api/arc?action=comms_sent_get&id=123 + case 'comms_sent_get': + $id = (int)($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('GET', "/comms/sent/{$id}")); + break; + + // DELETE /api/arc?action=comms_sent_delete&id=123 + case 'comms_sent_delete': + $id = (int)($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('DELETE', "/comms/sent/{$id}")); + break; + + // ── MISSION OPS ─────────────────────────────────────────────────────────── + + // GET /api/arc?action=missions + case 'missions': + echo json_encode(arc_request('GET', '/missions')); + break; + + // GET /api/arc?action=mission_get&id=123 + case 'mission_get': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('GET', "/missions/{$id}")); + break; + + // GET /api/arc?action=mission_runs&id=123 + case 'mission_runs': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + $limit = (int)($_GET['limit'] ?? 20); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('GET', "/missions/{$id}/runs?limit={$limit}")); + break; + + // POST /api/arc?action=mission_create — body: { name, description, trigger_type, trigger_config, steps } + case 'mission_create': + echo json_encode(arc_request('POST', '/missions', $data)); + break; + + // POST /api/arc?action=mission_update — body: { id, name, ... steps } + case 'mission_update': + $id = (int)($data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('PUT', "/missions/{$id}", $data)); + break; + + // DELETE /api/arc?action=mission_delete&id=123 + case 'mission_delete': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('DELETE', "/missions/{$id}")); + break; + + // POST /api/arc?action=mission_run&id=123 + case 'mission_run': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('POST', "/missions/{$id}/run", ['trigger_source' => 'manual'])); + break; + + // POST /api/arc?action=mission_toggle&id=123 body: { enabled: 1|0 } + case 'mission_toggle': + $id = (int)($data['id'] ?? 0); + $enabled = (int)($data['enabled'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + echo json_encode(arc_request('PUT', "/missions/{$id}", ['enabled' => $enabled])); + break; + + // GET /api/arc?action=clearance_pending + case 'clearance_pending': + echo json_encode(arc_request('GET', '/clearance/pending')); + break; + + // GET /api/arc?action=clearance_history + case 'clearance_history': + $limit = (int)($_GET['limit'] ?? 50); + echo json_encode(arc_request('GET', "/clearance/history?limit={$limit}")); + break; + + // POST /api/arc?action=clearance_approve&id=123 body: { decided_by: "..." } + case 'clearance_approve': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + $decided_by = $data['decided_by'] ?? 'admin'; + echo json_encode(arc_request('POST', "/clearance/{$id}/approve", ['decided_by' => $decided_by])); + break; + + // POST /api/arc?action=clearance_deny&id=123 body: { decided_by: "...", note: "..." } + case 'clearance_deny': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + $decided_by = $data['decided_by'] ?? 'admin'; + $note = $data['note'] ?? ''; + echo json_encode(arc_request('POST', "/clearance/{$id}/deny", ['decided_by' => $decided_by, 'note' => $note])); + break; + + // GET /api/arc?action=clearance_rules + case 'clearance_rules': + echo json_encode(arc_request('GET', '/clearance/rules')); + break; + + // PUT /api/arc?action=clearance_rule_update&id=123 body: { require_approval: 0|1, auto_approve_after_min: N, ... } + case 'clearance_rule_update': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; } + unset($data['id']); + echo json_encode(arc_request('PUT', "/clearance/rules/{$id}", $data)); + break; + + // POST /api/arc?action=clearance_rule_create body: { job_type, risk_level, require_approval, ... } + case 'clearance_rule_create': + echo json_encode(arc_request('POST', '/clearance/rules', $data)); + break; + + default: + http_response_code(404); + echo json_encode(['error' => "Unknown arc action: {$action}"]); +} diff --git a/api/endpoints/auth.php b/api/endpoints/auth.php new file mode 100644 index 0000000..ac549cc --- /dev/null +++ b/api/endpoints/auth.php @@ -0,0 +1,53 @@ + 'Credentials required']); + exit; + } + + $user = JarvisDB::single( + 'SELECT * FROM users WHERE username = ?', [$username] + ); + + if ($user && password_verify($password, $user['password_hash'])) { + $token = bin2hex(random_bytes(32)); + $_SESSION['jarvis_token'] = $token; + $_SESSION['jarvis_user_id'] = $user['id']; + $_SESSION['jarvis_name'] = $user['display_name']; + + JarvisDB::execute( + 'UPDATE users SET last_seen = NOW() WHERE id = ?', [$user['id']] + ); + + // Use stored address preference for greeting + $addrRow = JarvisDB::query( + "SELECT pref_value FROM kb_preferences WHERE pref_key='user_title' LIMIT 1" + ); + $userAddr = $addrRow[0]['pref_value'] ?? $user['display_name']; + + echo json_encode([ + 'success' => true, + 'token' => $token, + 'display_name' => $userAddr, + 'greeting' => "Welcome back, {$userAddr}. All systems are online and awaiting your command.", + ]); + } else { + http_response_code(401); + echo json_encode(['error' => 'Invalid credentials']); + } +} elseif ($method === 'DELETE') { + session_destroy(); + echo json_encode(['success' => true, 'message' => 'Session terminated.']); +} else { + // Check session status + $loggedIn = !empty($_SESSION['jarvis_token']); + echo json_encode([ + 'authenticated' => $loggedIn, + 'name' => $_SESSION['jarvis_name'] ?? null, + ]); +} diff --git a/api/endpoints/calendar_sync.php b/api/endpoints/calendar_sync.php new file mode 100644 index 0000000..41eb3ed --- /dev/null +++ b/api/endpoints/calendar_sync.php @@ -0,0 +1,283 @@ + $val, 'tzid' => $tzid, 'raw_prop' => $prop]; + } + if (empty($e['SUMMARY']) || empty($e['UID'])) continue; + + $uid = $e['UID']['val']; + $summary = calDecode($e['SUMMARY']['val']); + $desc = isset($e['DESCRIPTION']) ? calDecode($e['DESCRIPTION']['val']) : ''; + $loc = isset($e['LOCATION']) ? calDecode($e['LOCATION']['val']) : ''; + + $startRaw = $e['DTSTART']['val'] ?? null; + $endRaw = $e['DTEND']['val'] ?? ($e['DURATION']['val'] ?? null); + if (!$startRaw) continue; + + $startDt = parseCalDate($startRaw, $e['DTSTART']['tzid'] ?? null); + $endDt = $endRaw ? parseCalDate($endRaw, $e['DTEND']['tzid'] ?? null) : null; + $allDay = (strlen($startRaw) === 8); // YYYYMMDD format = all-day + + if (!$startDt) continue; + + // Skip events more than 1 year in the past + if (strtotime($startDt) < time() - 365 * 86400) continue; + + $events[] = compact('uid','summary','desc','loc','startDt','endDt','allDay'); + } + return $events; +} + +function parseCalDate(string $raw, ?string $tzid): ?string { + $raw = preg_replace('/[^0-9T Z]/i', '', $raw); + // All-day: YYYYMMDD + if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $raw, $m)) + return "{$m[1]}-{$m[2]}-{$m[3]} 00:00:00"; + // Datetime: YYYYMMDDTHHMMSS[Z] + if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/', $raw, $m)) { + $ts = gmmktime((int)$m[4],(int)$m[5],(int)$m[6],(int)$m[2],(int)$m[3],(int)$m[1]); + // Convert to local (server) timezone + return date('Y-m-d H:i:s', $ts - date('Z')); + } + return null; +} + +function calDecode(string $s): string { + // Decode quoted-printable and backslash escapes + $s = str_replace(['\\n','\\N','\\,','\;'], ["\n"," ",",",";"], $s); + return trim($s); +} + +// ── CalDAV Client ───────────────────────────────────────────────────────────── +function caldavRequest(string $method, string $url, string $user, string $pass, + string $body = '', array $extraHeaders = []): array { + $ch = curl_init($url); + $headers = array_merge([ + 'Content-Type: text/xml; charset=utf-8', + 'Prefer: return-minimal', + ], $extraHeaders); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_USERPWD => "$user:$pass", + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $body, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + return ['code' => $code, 'body' => $resp ?: '', 'error' => $err]; +} + +function xmlVal(string $xml, string $tag): ?string { + if (preg_match('~<(?:[^:]+:)?' . preg_quote($tag, '~') . '[^>]*>(.*?)~s', $xml, $m)) + return trim(html_entity_decode(strip_tags($m[1]))); + return null; +} + +function fetchICloudCalendars(string $user, string $pass): array { + $results = []; + // Step 1: Discover principal + $propfind = ''; + $r = caldavRequest('PROPFIND', 'https://caldav.icloud.com/', $user, $pass, $propfind, ['Depth: 0']); + $principal = xmlVal($r['body'], 'current-user-principal'); + if (!$principal) $principal = xmlVal($r['body'], 'href'); + if (!$principal) return ['error' => 'Cannot discover principal: HTTP ' . $r['code']]; + + // Make absolute URL + if (!str_starts_with($principal, 'http')) $principal = 'https://caldav.icloud.com' . $principal; + + // Step 2: Get calendar home + $propfind2 = ''; + $r2 = caldavRequest('PROPFIND', $principal, $user, $pass, $propfind2, ['Depth: 0']); + $calHome = xmlVal($r2['body'], 'calendar-home-set'); + if (!$calHome) $calHome = xmlVal($r2['body'], 'href'); + if (!$calHome) return ['error' => 'Cannot find calendar home']; + + if (!str_starts_with($calHome, 'http')) $calHome = 'https://caldav.icloud.com' . $calHome; + + // Step 3: List calendars + $propfind3 = ''; + $r3 = caldavRequest('PROPFIND', $calHome, $user, $pass, $propfind3, ['Depth: 1']); + + // Parse calendar URLs from multistatus response + preg_match_all('~(.*?)~s', $r3['body'], $responses); + foreach ($responses[1] as $resp) { + $href = xmlVal($resp, 'href'); + $name = xmlVal($resp, 'displayname'); + // Only include actual calendars (not the home itself) + if ($href && $href !== parse_url($calHome, PHP_URL_PATH) && strpos($resp, 'VEVENT') !== false) { + $calUrl = str_starts_with($href, 'http') ? $href : 'https://caldav.icloud.com' . $href; + $results[] = ['url' => $calUrl, 'name' => $name ?: 'iCloud Calendar']; + } + } + return ['calendars' => $results, 'home' => $calHome]; +} + +function fetchCalDAVEvents(string $calUrl, string $user, string $pass): array { + // Fetch events from 1 year ago to 2 years ahead + $from = gmdate('Ymd\THis\Z', strtotime('-1 year')); + $to = gmdate('Ymd\THis\Z', strtotime('+2 years')); + $report = << + + + + + + + + + + +XML; + $r = caldavRequest('REPORT', $calUrl, $user, $pass, $report, ['Depth: 1', 'Content-Type: text/xml']); + if ($r['code'] !== 207) return ['error' => 'CalDAV REPORT failed: HTTP ' . $r['code']]; + + // Extract calendar-data from response + $allICS = ''; + preg_match_all('~<(?:[^:]+:)?calendar-data[^>]*>(.*?)~s', $r['body'], $m); + foreach ($m[1] as $icsBlock) { + $allICS .= html_entity_decode($icsBlock) . "\n"; + } + return ['events' => parseICS("BEGIN:VCALENDAR\n" . $allICS . "\nEND:VCALENDAR")]; +} + +// ── Sync events to DB ───────────────────────────────────────────────────────── +function syncEvents(array $events, string $source, string $feedName): array { + $inserted = $updated = $skipped = 0; + foreach ($events as $ev) { + $uid = md5($source . ':' . $ev['uid']); // deterministic per source + $existing = JarvisDB::single('SELECT id, start_at, title FROM appointments WHERE external_uid=? AND external_source=?', [$uid, $source]); + $cat = 'personal'; + if (preg_match('/\b(work|meeting|office|client|project|call|conference|interview|standup|sprint)\b/i', $ev['summary'])) $cat = 'work'; + if (preg_match('/\b(doctor|medical|dentist|pharmacy|hospital|clinic|appointment)\b/i', $ev['summary'])) $cat = 'medical'; + + if ($existing) { + // Update if start time changed + if ($existing['start_at'] !== $ev['startDt']) { + JarvisDB::execute( + 'UPDATE appointments SET title=?, description=?, location=?, start_at=?, end_at=?, all_day=?, category=?, last_synced=NOW() WHERE id=?', + [$ev['summary'], $ev['desc'], $ev['loc'], $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $cat, $existing['id']] + ); + $updated++; + } else { + $skipped++; + } + } else { + JarvisDB::execute( + 'INSERT INTO appointments (title, description, location, category, start_at, end_at, all_day, external_uid, external_source, last_synced) + VALUES (?,?,?,?,?,?,?,?,?,NOW())', + [$ev['summary'], $ev['desc'], $ev['loc'], $cat, $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $uid, $source] + ); + $inserted++; + } + } + // Remove events that no longer exist in the feed (deleted from calendar) + $syncedUids = array_map(fn($ev) => md5($source . ':' . $ev['uid']), $events); + if ($syncedUids) { + $placeholders = implode(',', array_fill(0, count($syncedUids), '?')); + JarvisDB::execute( + "DELETE FROM appointments WHERE external_source=? AND external_uid NOT IN ($placeholders) AND external_uid IS NOT NULL", + array_merge([$source], $syncedUids) + ); + } + return compact('inserted', 'updated', 'skipped'); +} + +// ── Main sync runner ────────────────────────────────────────────────────────── +function runSync(): array { + $results = []; + + // ── iCloud CalDAV ────────────────────────────────────────────────────── + if (defined('ICLOUD_USER') && ICLOUD_USER && defined('ICLOUD_PASS') && ICLOUD_PASS) { + $calInfo = fetchICloudCalendars(ICLOUD_USER, ICLOUD_PASS); + if (isset($calInfo['error'])) { + $results['icloud'] = 'Error: ' . $calInfo['error']; + } else { + $totalEvents = []; + foreach ($calInfo['calendars'] as $cal) { + $evData = fetchCalDAVEvents($cal['url'], ICLOUD_USER, ICLOUD_PASS); + if (!empty($evData['events'])) $totalEvents = array_merge($totalEvents, $evData['events']); + } + $r = syncEvents($totalEvents, 'icloud', 'iCloud'); + JarvisDB::execute("UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE source='icloud'", [count($totalEvents)]); + $results['icloud'] = "OK: {$r['inserted']} new, {$r['updated']} updated, {$r['skipped']} unchanged (" . count($totalEvents) . " events total)"; + } + } + + // ── Google Calendar ICS ──────────────────────────────────────────────── + $feeds = JarvisDB::query("SELECT * FROM calendar_feeds WHERE source IN ('google','ics') AND active=1") ?? []; + foreach ($feeds as $feed) { + if (!$feed['ics_url']) continue; + $ch = curl_init($feed['ics_url']); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>20, CURLOPT_FOLLOWLOCATION=>true, CURLOPT_SSL_VERIFYPEER=>true]); + if ($feed['username']) curl_setopt($ch, CURLOPT_USERPWD, $feed['username'] . ':' . $feed['password']); + $ics = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code !== 200 || !$ics) { + $results[$feed['name']] = "Error fetching ICS (HTTP $code)"; + continue; + } + $events = parseICS($ics); + $r = syncEvents($events, $feed['source'] . '_' . $feed['id'], $feed['name']); + JarvisDB::execute('UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE id=?', [count($events), $feed['id']]); + $results[$feed['name']] = "OK: {$r['inserted']} new, {$r['updated']} updated (" . count($events) . " events)"; + } + + return $results; +} + +// ── Entry point ─────────────────────────────────────────────────────────────── +if ($isCLI) { + echo date('Y-m-d H:i:s') . " Calendar sync started\n"; + $r = runSync(); + foreach ($r as $src => $msg) echo " $src: $msg\n"; + echo date('Y-m-d H:i:s') . " Done\n"; +} else { + // HTTP endpoint + $r = runSync(); + echo json_encode(['status' => 'ok', 'results' => $r, 'timestamp' => date('c')]); +} diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php new file mode 100644 index 0000000..8d2cf3a --- /dev/null +++ b/api/endpoints/chat.php @@ -0,0 +1,2425 @@ + 'POST only']); exit; +} + +$message = trim($data['message'] ?? ''); +$sessionId = $data['session_id'] ?? session_id(); +$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.) +$stream = !empty($data['stream']); + +if (!$message) { + echo json_encode(['error' => 'Message required']); exit; +} + +// Build context string from selected panel item +$ctxSnippet = ''; +if ($panelCtx && is_array($panelCtx)) { + $type = $panelCtx['type'] ?? ''; + switch ($type) { + case 'vm': + $ctxSnippet = sprintf( + '[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]', + $panelCtx['name'] ?? '?', + $panelCtx['vmid'] ?? '?', + $panelCtx['status'] ?? '?', + $panelCtx['cpu'] ?? '?', + $panelCtx['mem_mb'] ?? '?', + $panelCtx['maxmem_mb'] ?? '?', + $panelCtx['type_label'] ?? 'qemu' + ); + break; + case 'network': + $ctxSnippet = sprintf( + '[Selected Device: %s — IP: %s, Status: %s%s]', + $panelCtx['name'] ?? '?', + $panelCtx['ip'] ?? '?', + $panelCtx['status'] ?? '?', + $panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : '' + ); + break; + case 'alert': + $ctxSnippet = sprintf( + '[Selected Alert: %s — Severity: %s, Message: %s]', + $panelCtx['title'] ?? '?', + $panelCtx['severity'] ?? '?', + $panelCtx['message'] ?? '?' + ); + break; + case 'news': + $ctxSnippet = sprintf( + '[Selected News Story: "%s" — Source: %s, Published: %s]', + $panelCtx['title'] ?? '?', + $panelCtx['source'] ?? '?', + $panelCtx['pub'] ?? 'unknown' + ); + break; + case 'ha': + $ctxSnippet = sprintf( + '[Selected Home Device: %s (%s) — Current State: %s]', + $panelCtx['name'] ?? '?', + $panelCtx['entity_id'] ?? '?', + $panelCtx['state'] ?? '?' + ); + break; + } +} + +// Save user message +JarvisDB::insert( + 'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)', + [$sessionId, 'user', $message] +); + +// Conversation history — current session +$history = JarvisDB::query( + 'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10', + [$sessionId] +) ?? []; +$history = array_reverse($history); + +// Cross-session memory: last 6 turns from the most recent prior session +$priorSession = JarvisDB::query( + "SELECT session_id FROM conversations WHERE session_id != ? AND role='user' + ORDER BY created_at DESC LIMIT 1", + [$sessionId] +); +if ($priorSession && !empty($priorSession[0]['session_id'])) { + $priorHistory = JarvisDB::query( + 'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 6', + [$priorSession[0]['session_id']] + ) ?? []; + $history = array_merge(array_reverse($priorHistory), $history); +} + +$reply = null; +$source = 'unknown'; + +// ── Load user address preference ────────────────────────────────────────── +$prefRows = JarvisDB::query( + "SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')" +); +$prefs = []; +foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; } +$userName = $prefs['user_name'] ?? 'Myron'; +$userTitle = $prefs['user_title'] ?? 'Mr. Blair'; +// Address to use in responses +$userAddr = $userTitle; + +// ── Tier 0.1: Name preference change ───────────────────────────────────── +if (!$reply) { + $lc = strtolower($message); + // Patterns: "call me X", "refer to me as X", "address me as X", "my name is X", + // "don't call me X", "stop calling me X", "just call me X" + if (preg_match( + '/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i', + $message, $m + )) { + $newName = trim(preg_replace('/\s+/', ' ', $m[1])); + // Strip trailing punctuation + $newName = rtrim($newName, '.!?,;'); + if (strlen($newName) >= 2 && strlen($newName) <= 30) { + JarvisDB::execute( + "INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?) + ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)", + [$newName] + ); + $userTitle = $newName; + $userAddr = $newName; + $reply = "Understood. I'll address you as {$newName} from now on."; + $source = 'intent:name_pref'; + } + } elseif (preg_match( + '/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i', + $message, $m + )) { + // "don't call me {$userAddr}" — switch to first name + JarvisDB::execute( + "INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?) + ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)", + [$userName] + ); + $userTitle = $userName; + $userAddr = $userName; + $reply = "Of course. I'll call you {$userName} going forward."; + $source = 'intent:name_pref'; + } +} + +// ── Tier 0: Home Assistant Control ─────────────────────────────────────── +// Uses entity_map stored by facts_collector to resolve natural language → entity +$haEntityMapRow = JarvisDB::query( + 'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=? + AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY updated_at DESC LIMIT 1', + ['ha', 'entity_map'] +); +$haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value'])) + ? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? []) + : []; + +// Scene keywords for one-shot activations (no on/off needed) +$sceneKeywords = [ + 'good night' => 'scene.good_night', + 'goodnight' => 'scene.good_night', + 'good morning' => 'scene.good_morning', + 'goodmorning' => 'scene.good_morning', + 'goodbye' => 'scene.goodbye', + 'bye' => 'scene.goodbye', + 'kitchen lights on' => 'scene.kitchen_lights_on', + 'kitchen on' => 'scene.kitchen_lights_on', + 'kitchen lights off' => 'scene.kitchen_lights_off', + 'kitchen off' => 'scene.kitchen_lights_off', + 'front porch lights' => 'scene.outdoors_front_porch_lights', + 'porch scene' => 'scene.outdoors_front_porch_lights', + 'office dawn' => 'scene.office_ocean_dawn', + 'good morning work' => 'scene.good_morning_work', + 'morning work' => 'scene.good_morning_work', + 'work morning' => 'scene.good_morning_work', + 'master bedroom scene' => 'scene.master_bedroom_new_scene', + 'bedroom scene' => 'scene.master_bedroom_new_scene', + 'ceiling fan scene' => 'scene.master_bedroom_new_scene', + 'porch lights on' => 'scene.outdoors_front_porch_lights', + 'porch lights' => 'scene.outdoors_front_porch_lights', +]; + +$msgLower = strtolower(trim($message)); + +// Check for scene activation first +$sceneId = null; +foreach ($sceneKeywords as $kw => $sid) { + if (strpos($msgLower, $kw) !== false) { + $sceneId = $sid; + break; + } +} + +if ($sceneId) { + $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; + $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; + $ch = curl_init($haUrl . '/api/services/scene/turn_on'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]), + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'], + CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3, + ]); + $haResp = curl_exec($ch); + $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($haCode === 200) { + $sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId)); + $reply = "Activating {$sceneName}, {$userAddr}."; + $source = 'ha:scene'; + } +} + +// Check for device on/off control +if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch) + || (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) { + $turnOn = (bool) preg_match('/\bon\b/i', $message); + $turnOff = (bool) preg_match('/\boff\b/i', $message); + $haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null); + + if ($haService && !empty($haEntityMap)) { + // Find best matching entity + $bestEid = null; + $bestScore = 0; + $bestName = ''; + + // Special: "all lights" / "everything" + if (preg_match('/(all|everything|every)/i', $message)) { + $bestEid = '__all_lights__'; + $bestName = 'All lights'; + $bestScore = 1; + } + + if (!$bestEid) { + // Detect preferred domain from message + $preferLight = (bool) preg_match('/\blight(s)?\b/i', $message); + $preferSwitch = (bool) preg_match('/\b(switch|plug|outlet|strip)\b/i', $message); + + // Strip control words — keep device/room name + $searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?|plugs?|strips?)\b/i', ' ', $msgLower); + $searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg)); + + // Empty search means generic light command (e.g. "turn on the lights") — all lights + if ($searchMsg === '' && $preferLight) { + $bestEid = '__all_lights__'; + $bestName = 'All lights'; + $bestScore = 1; + } + + foreach ($haEntityMap as $eid => $info) { + if (($info['state'] ?? '') === 'unavailable') continue; + $nameLower = strtolower($info['name']); + $domain = $info['domain'] ?? ''; + $score = 0; + + // Exact name match = highest priority + if ($nameLower === $searchMsg) { + $score = 100; + // Search is substring of name + } elseif ($searchMsg && strpos($nameLower, $searchMsg) !== false) { + $score = 40; + // Name is substring of search (e.g. search="living room light", name="Living Room") + } elseif ($searchMsg && strpos($searchMsg, $nameLower) !== false) { + $score = 30; + } else { + // Word overlap scoring + $words = array_filter(explode(' ', $searchMsg)); + foreach ($words as $w) { + if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score += 8; + } + } + + if ($score <= 0) continue; + + // Domain preference bonus + if ($preferLight && $domain === 'light') $score += 20; + if ($preferSwitch && $domain === 'switch') $score += 20; + // Penalty for clearly wrong domain when user specified one + if ($preferLight && in_array($domain, ['media_player','sensor','climate'])) $score -= 15; + + if ($score > $bestScore) { + $bestScore = $score; + $bestEid = $eid; + $bestName = $info['name']; + } + } + } + + if ($bestEid && $bestScore > 0) { + $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; + $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; + + if ($bestEid === '__all_lights__') { + // Turn all lights on/off via domain targeting + $lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light')); + $payload = ['entity_id' => $lightIds]; + $svcDomain = 'light'; + } else { + $domain = $haEntityMap[$bestEid]['domain'] ?? 'switch'; + $payload = ['entity_id' => $bestEid]; + $svcDomain = $domain; + } + + $ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'], + CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3, + ]); + $haResp = curl_exec($ch); + $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($haCode === 200) { + $action = ($haService === 'turn_on') ? 'activated' : 'deactivated'; + $label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName; + $reply = "{$label} {$action}, {$userAddr}."; + $source = 'ha:' . $haService; + } + } + } +} + +// Status query for HA entities +if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message) + && !preg_match('/(turn|switch|put)/i', $message)) { + // Status query - find the entity and report its state + $searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower); + $searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg)); + $bestEid = null; $bestScore = 0; $bestName = ''; $bestState = ''; + foreach ($haEntityMap as $eid => $info) { + $nameLower = strtolower($info['name']); + $words = array_filter(explode(' ', $searchMsg)); + $score = 0; + foreach ($words as $w) { + if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++; + } + if ($score > $bestScore) { + $bestScore = $score; $bestEid = $eid; + $bestName = $info['name']; $bestState = $info['state']; + } + } + if ($bestEid && $bestScore > 0) { + $reply = "The {$bestName} is currently {$bestState}, {$userAddr}."; + $source = 'ha:status'; + } +} + + +// ── Tier 0a: Brightness control ────────────────────────────────────────── +if (!$reply && preg_match('/\b(dim|brighten|brightness|set.*to\s+\d+\s*%|percent)\b/i', $message) && !empty($haEntityMap)) { + $pctMatch = null; + if (preg_match('/(\d+)\s*%/', $message, $pm)) { + $pctMatch = max(1, min(100, (int)$pm[1])); + } elseif (preg_match('/\b(full|max|maximum|hundred)\b/i', $message)) { + $pctMatch = 100; + } elseif (preg_match('/\b(half|fifty)\b/i', $message)) { + $pctMatch = 50; + } elseif (preg_match('/\b(low|dim|minimum|min|quarter)\b/i', $message)) { + $pctMatch = 20; + } + + if ($pctMatch !== null) { + $searchMsg = preg_replace('/\b(dim|brighten|brightness|set|the|my|to|at|percent|%|\d+|jarvis|light|lights?)\b/i', ' ', $msgLower); + $searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg)); + $bestEid = null; $bestScore = 0; $bestName = ''; + foreach ($haEntityMap as $eid => $info) { + if ($info['domain'] !== 'light') continue; + if (($info['state'] ?? '') === 'unavailable') continue; + $nameLower = strtolower($info['name']); + $score = 0; + if ($searchMsg === '') { $bestEid = '__all_lights__'; $bestName = 'All lights'; $bestScore = 1; break; } + $words = array_filter(explode(' ', $searchMsg)); + foreach ($words as $w) { if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score += 10; } + if ($score > $bestScore) { $bestScore = $score; $bestEid = $eid; $bestName = $info['name']; } + } + if ($bestEid && $bestScore >= 0) { + $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; + $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; + $brightness = (int)round($pctMatch * 2.55); + if ($bestEid === '__all_lights__') { + $lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light')); + $payload = ['entity_id' => $lightIds, 'brightness' => $brightness]; + } else { + $payload = ['entity_id' => $bestEid, 'brightness' => $brightness]; + } + $ch = curl_init($haUrl . '/api/services/light/turn_on'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode($payload), + CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'], + CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]); + $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($haCode === 200) { + $label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName; + $reply = "{$label} set to {$pctMatch}%, {$userAddr}."; + $source = 'ha:brightness'; + } + } + } +} + +// ── Tier 0b: Media player control ──────────────────────────────────────── +if (!$reply && preg_match('/\b(apple tv|appletv|pioneer|receiver|tv|television)\b/i', $message) + && preg_match('/\b(turn|switch|power|on|off|pause|play|resume|stop|mute)\b/i', $message) && !empty($haEntityMap)) { + $mediaTurnOn = (bool) preg_match('/\b(on|play|resume|start)\b/i', $message); + $mediaTurnOff = (bool) preg_match('/\b(off|stop|shutdown|power off)\b/i', $message); + $mediaPause = (bool) preg_match('/\b(pause|mute)\b/i', $message); + $isAppleTV = (bool) preg_match('/\b(apple.?tv|appletv)\b/i', $message); + $isPioneer = (bool) preg_match('/\b(pioneer|receiver)\b/i', $message); + $isTv = (bool) preg_match('/\b(tv|television)\b/i', $message); + + $targetEid = null; $targetName = ''; + if ($isAppleTV) { + $targetEid = 'media_player.apple_tv_4k'; + $targetName = 'Apple TV 4K'; + } elseif ($isPioneer) { + $targetEid = 'media_player.vsx_822_2'; + $targetName = 'Pioneer Receiver'; + } elseif ($isTv) { + $targetEid = 'media_player.apple_tv_4k'; + $targetName = 'Apple TV'; + } + + if ($targetEid) { + $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; + $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; + if ($mediaPause) { + $svc = 'media_player/media_pause'; + } elseif ($mediaTurnOff) { + $svc = 'media_player/turn_off'; + } else { + $svc = 'media_player/turn_on'; + } + $ch = curl_init($haUrl . '/api/services/' . $svc); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$targetEid]), + CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'], + CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]); + curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($haCode === 200) { + if ($mediaPause) $verb = 'paused'; + elseif ($mediaTurnOff) $verb = 'powered off'; + else $verb = 'powered on'; + $reply = "{$targetName} {$verb}, {$userAddr}."; + $source = 'ha:media'; + } + } +} + +// ── Tier 0c: Camera siren toggle ───────────────────────────────────────── +if (!$reply && preg_match('/\b(siren|alarm|honk|sound (the )?alarm)\b/i', $message) + && preg_match('/\b(camera|back yard|backyard|carport|driveway|front yard)\b/i', $message)) { + $sirenOn = !preg_match('/\b(off|stop|disable|silence)\b/i', $message); + $haService = $sirenOn ? 'turn_on' : 'turn_off'; + $sirenMap = [ + 'back yard' => ['switch.camera1_siren_on_event_2', 'Back Yard'], + 'backyard' => ['switch.camera1_siren_on_event_2', 'Back Yard'], + 'carport' => ['switch.camera1_siren_on_event_3', 'Carport'], + 'front yard' => ['switch.front_yard_siren_on_event', 'Front Yard'], + 'driveway' => ['switch.down_hill_siren_on_event', 'Driveway'], + ]; + $targetSiren = null; $targetLabel = 'Camera'; + foreach ($sirenMap as $kw => [$eid, $lbl]) { + if (strpos($msgLower, $kw) !== false) { $targetSiren = $eid; $targetLabel = $lbl; break; } + } + if (!$targetSiren) { + $targetSiren = 'switch.camera1_siren_on_event_2'; + $targetLabel = 'Back Yard'; + } + $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; + $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; + $ch = curl_init($haUrl . '/api/services/switch/' . $haService); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$targetSiren]), + CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'], + CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]); + curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($haCode === 200) { + $verb = $sirenOn ? 'activated' : 'silenced'; + $reply = "{$targetLabel} camera siren {$verb}, {$userAddr}."; + $source = 'ha:siren'; + } +} + +// ── Email + Planner voice intents (action items, create task/appt from email) ─ +if (!$reply && preg_match('/\b(email|emails|inbox|gmail|outlook|mail|unread|messages)\b/i', $message)) { + $lc = strtolower($message); + + // ── Action items from email ─────────────────────────────────────────────── + if (preg_match('/\b(action item|action required|need.*attention|follow.?up|things to do.*email|email.*task)\b/i', $message)) { + $rows = JarvisDB::query( + "SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 5" + ) ?? []; + if (!$rows) { + $reply = "No email action items pending, {$userAddr}."; + } else { + $items = array_map(fn($r) => + ucfirst($r['action_type']) . ': "' . mb_substr($r['subject'], 0, 60) . '" from ' . ($r['from_name'] ?: $r['from_email']), + $rows + ); + $reply = count($rows) . " email action item" . (count($rows)>1?'s':'') . " need attention, {$userAddr}: " . implode('. ', $items) . '.'; + } + $source = 'email:action_items'; + + // ── Create task from most recent action email ───────────────────────────── + } elseif (preg_match('/\b(create task|add task|make.*task|task.*from.*email)\b/i', $message)) { + $fromMatch = null; + if (preg_match('/\bfrom\s+([a-z][\w\s\.]+)/i', $message, $fm)) $fromMatch = trim($fm[1]); + $where = "WHERE dismissed=0 AND task_id IS NULL AND action_type IN ('task','appointment')"; + $params = []; + if ($fromMatch) { $where .= ' AND (from_name LIKE ? OR from_email LIKE ?)'; $params[] = "%{$fromMatch}%"; $params[] = "%{$fromMatch}%"; } + $ea = JarvisDB::single("SELECT * FROM email_actions {$where} ORDER BY received_at DESC LIMIT 1", $params); + if ($ea) { + $title = mb_substr($ea['subject'], 0, 255); + $notes = "From: {$ea['from_name']} <{$ea['from_email']}>"; + $taskId = JarvisDB::insert('INSERT INTO tasks (title,notes,category,priority) VALUES (?,?,?,?)', + [$title, $notes, 'work', 'normal']); + JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $ea['id']]); + $reply = "Task created from email: \"{$title}\", {$userAddr}."; + $source = 'email:create_task'; + } else { + $reply = "No action-required emails found to create a task from, {$userAddr}."; + $source = 'email:create_task_none'; + } + + // ── Create appointment from meeting email ───────────────────────────────── + } elseif (preg_match('/\b(schedule|appointment|meeting|calendar).*\bemail\b|\bemail\b.*(schedule|appointment|meeting|calendar)\b/i', $message)) { + $ea = JarvisDB::single( + "SELECT * FROM email_actions WHERE dismissed=0 AND appointment_id IS NULL AND action_type='appointment' ORDER BY received_at DESC LIMIT 1" + ); + if ($ea) { + $start = $ea['suggested_date'] ? $ea['suggested_date'] . ' 09:00:00' : date('Y-m-d') . ' 09:00:00'; + $title = mb_substr($ea['subject'], 0, 255); + $apptId = JarvisDB::insert('INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)', + [$title, "From: {$ea['from_name']}", 'work', $start]); + JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $ea['id']]); + $reply = "Appointment scheduled from email: \"{$title}\" on " . date('l, M j', strtotime($start)) . ", {$userAddr}."; + $source = 'email:create_appt'; + } else { + $reply = "No meeting emails found to schedule, {$userAddr}."; + $source = 'email:create_appt_none'; + } + + // ── Unread count ────────────────────────────────────────────────────────── + } elseif (preg_match('/\b(how many|any|check|count|unread)\b/i', $message) && !preg_match('/\bread\b/i', $message)) { + $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; + $account = 'all'; + if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; + if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; + if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; + $ch = curl_init($emailUrl . '?account=' . $account); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]); + $emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($emailCode === 200 && $emailJson) { + $ed = json_decode($emailJson, true) ?? []; + $unread = (int)($ed['summary']['total_unread'] ?? 0); + $aiCount = (int)($ed['action_items_count'] ?? 0); + $parts = []; + foreach ($ed['accounts'] ?? [] as $a => $r) { + if (!empty($r['unread'])) $parts[] = $r['unread'] . ' on ' . ucfirst($a); + } + if ($unread === 0) { + $reply = "No unread emails, {$userAddr}."; + } else { + $bd = $parts ? ' (' . implode(', ', $parts) . ')' : ''; + $reply = "You have {$unread} unread email" . ($unread>1?'s':'') . "{$bd}, {$userAddr}."; + } + if ($aiCount > 0) $reply .= " {$aiCount} require action."; + $source = 'email:count'; + } + + // ── Read recent emails ──────────────────────────────────────────────────── + } else { + $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; + $account = 'all'; + if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; + if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; + if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; + $ch = curl_init($emailUrl . '?account=' . $account); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]); + $emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($emailCode === 200 && $emailJson) { + $ed = json_decode($emailJson, true) ?? []; + $recent = $ed['summary']['recent'] ?? []; + $unread = (int)($ed['summary']['total_unread'] ?? 0); + $aiCount = (int)($ed['action_items_count'] ?? 0); + // filter by sender if mentioned + if (preg_match('/\bfrom\s+(.+)/i', $message, $fm)) { + $sender = strtolower(trim($fm[1])); + $recent = array_values(array_filter($recent, fn($m) => + stripos($m['from_name']??'', $sender)!==false || stripos($m['from_email']??'', $sender)!==false)); + } + $toRead = array_filter($recent, fn($m) => $m['unread']) ?: $recent; + $toRead = array_slice(array_values($toRead), 0, 3); + if (empty($toRead)) { + $reply = "No emails to report, {$userAddr}."; + } else { + $lines = []; + foreach ($toRead as $m) { + $acct = isset($m['account']) ? ' [' . strtoupper($m['account']) . ']' : ''; + $lines[] = "From {$m['from_name']}: \"{$m['subject']}\", {$m['date']}{$acct}."; + } + $intro = $unread > 0 ? "You have {$unread} unread. " : ""; + $reply = $intro . implode(' ', $lines); + if ($aiCount > 0) $reply .= " {$aiCount} require action — say 'email action items' for details."; + } + $source = 'email:read'; + } + } +} + +// ── Tier 0.5: Network Device Management ────────────────────────────────── +if (!$reply) { + // Flow state stored in kb_facts (session_write_close() is called before this runs) + $flowKey = substr(session_id() ?: 'anon', 0, 32); + // Expire stale chat flows older than 10 minutes + JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)"); + $flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]); + $devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null; + + // Continue an active multi-step add-device flow + if ($devState && isset($devState['step'])) { + switch ($devState['step']) { + case 'waiting_ip': + if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) { + $newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']); + JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]); + $reply = "Got it — {$m[1]}. What should I call this device?"; + } else { + $reply = "Please give me a valid IP address such as 192.168.1.100."; + } + $source = 'device:flow'; + break; + + case 'waiting_name': + $cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message)); + $newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']); + JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]); + $reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip."; + $source = 'device:flow'; + break; + + case 'waiting_type': + $rawType = strtolower(trim($message)); + $allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown']; + $devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown'); + $devIp = $devState['ip'] ?? ''; + $devName = $devState['name'] ?? ''; + JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]); + if ($devIp && $devName) { + JarvisDB::execute( + "INSERT INTO network_devices (ip, alias, device_type, status, last_seen) + VALUES (?,?,?,'unknown',NOW()) + ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)", + [$devIp, $devName, $devType] + ); + $reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status."; + } else { + $reply = "Something went wrong with the device registration. Please try again."; + } + $source = 'device:added'; + break; + + default: + JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]); + } + } + + // Start a new add-device flow or handle inline add + if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) { + $hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM); + $hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM); + $hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM); + if ($hasIp && $hasName) { + $devIp = $ipM[1]; + $devName = trim($nameM[1]); + $devType = $hasType ? trim($typeM[1]) : 'unknown'; + JarvisDB::execute( + "INSERT INTO network_devices (ip, alias, device_type, status, last_seen) + VALUES (?,?,?,'unknown',NOW()) + ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)", + [$devIp, $devName, $devType] + ); + $reply = "Device '{$devName}' at {$devIp} has been added to network monitoring."; + $source = 'device:added'; + } elseif ($hasIp) { + JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]); + $reply = "I found the address {$ipM[1]}. What should I call this device?"; + $source = 'device:flow'; + } else { + JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]); + $reply = "I'll add a new device to network monitoring. What's its IP address?"; + $source = 'device:flow'; + } + } + + // Remove / delete device + if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) { + $target = trim($dm[2]); + $isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target); + $rows = $isIp + ? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target]) + : JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']); + if (count($rows)) { + $dev = $rows[0]; + JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]); + $label = $dev['alias'] ?? $dev['ip']; + $reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring."; + $source = 'device:removed'; + } + } + + // Update device name or type + if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) { + $target = trim($um[2]); + $newVal = trim($um[3]); + $rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']); + if (count($rows)) { + $dev = $rows[0]; + JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]); + $reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'."; + $source = 'device:updated'; + } + } + + // List devices + if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) { + $rows = JarvisDB::query( + "SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias" + ); + if (count($rows)) { + $items = array_map(fn($r) => + ($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'), + $rows + ); + $reply = "Monitored devices: " . implode('; ', $items) . '.'; + } else { + $reply = "No named devices in network monitoring yet. Say 'add a device' to get started."; + } + $source = 'device:list'; + } +} + + +// ── Tier 0.7: Planner — full voice intent coverage ──────────────────────── +if (!$reply) { + $lc = strtolower($message); + $today = date('Y-m-d'); + + // ── Daily briefing ──────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning|what do i have|what.?s on)\b/i', $message) + && !preg_match('/\b(weather|news|temperature|forecast)\b/i', $message)) { + $tasks_today = JarvisDB::query("SELECT title,priority FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')", [$today]) ?? []; + $tasks_overdue = JarvisDB::query("SELECT COUNT(*) cnt FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled')", [$today]); + $appts_today = JarvisDB::query("SELECT title,start_at FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC", [$today]) ?? []; + $email_actions = JarvisDB::single("SELECT COUNT(*) cnt FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL"); + $parts = []; + if ($appts_today) { + $ap = array_map(fn($a) => $a['title'] . ' at ' . date('g:i A', strtotime($a['start_at'])), $appts_today); + $parts[] = count($appts_today) . ' appointment' . (count($appts_today) > 1 ? 's' : '') . ': ' . implode(', ', $ap); + } + if ($tasks_today) { + $tl = array_map(fn($t) => $t['title'], $tasks_today); + $parts[] = count($tasks_today) . ' task' . (count($tasks_today) > 1 ? 's' : '') . ' due today: ' . implode(', ', $tl); + } + $ov = (int)($tasks_overdue['cnt'] ?? 0); + if ($ov > 0) $parts[] = $ov . ' overdue task' . ($ov > 1 ? 's' : '') . ' need attention'; + $ai = (int)($email_actions['cnt'] ?? 0); + if ($ai > 0) $parts[] = $ai . ' email' . ($ai > 1 ? 's' : '') . ' require action'; + // Include active directive progress summary + $active_dirs = JarvisDB::query( + "SELECT d.title, + COALESCE(SUM(kr.current_value),0) AS cur, + COALESCE(SUM(kr.target_value),1) AS tgt + FROM directives d + LEFT JOIN directive_key_results kr ON kr.directive_id=d.id + WHERE d.status='active' + GROUP BY d.id + ORDER BY d.priority DESC LIMIT 3" + ) ?? []; + if ($active_dirs) { + $dp = array_map(fn($d) => $d['title'] . ' (' . round($d['cur'] / max($d['tgt'],1) * 100) . '%)', $active_dirs); + $parts[] = count($active_dirs) . ' active directive' . (count($active_dirs) > 1 ? 's' : '') . ': ' . implode(', ', $dp); + } + // Time-of-day greeting + $hour = (int)date('G'); // 0-23, America/Chicago set in config + if ($hour >= 5 && $hour < 12) $greet = "Good morning"; + elseif ($hour >= 12 && $hour < 17) $greet = "Good afternoon"; + elseif ($hour >= 17 && $hour < 22) $greet = "Good evening"; + else $greet = "Good evening"; + + // Weather lead + $wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1"); + if ($wRow && !empty($wRow[0]['data'])) { + $wd = json_decode($wRow[0]['data'], true); + $c = $wd['current'] ?? []; + if (!empty($c['temp']) && !empty($c['desc'])) { + $parts = array_merge(["It's {$c['temp']}°F and {$c['desc']} outside"], $parts); + } + } + + // Offline agents + $offline_agents = JarvisDB::query( + "SELECT hostname FROM registered_agents WHERE status='offline' AND last_seen > DATE_SUB(NOW(), INTERVAL 7 DAY)" + ) ?? []; + if ($offline_agents) { + $names = implode(', ', array_column($offline_agents, 'hostname')); + $parts[] = count($offline_agents) . ' agent' . (count($offline_agents) > 1 ? 's' : '') . ' offline: ' . $names; + } + + $reply = $parts + ? "{$greet}, {$userAddr}. " . implode('. ', $parts) . '.' + : "{$greet}, {$userAddr}. Your schedule is clear — all systems nominal, no tasks or appointments pending."; + $source = 'planner:briefing'; + } + + // ── Overdue tasks ───────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(overdue|past due|late|missed.*task|task.*overdue)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, due_date FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC LIMIT 6", [$today]) ?? []; + if (!$rows) { + $reply = "No overdue tasks, {$userAddr}. You're all caught up."; + } else { + $items = array_map(fn($r) => $r['title'] . ' (was due ' . date('M j', strtotime($r['due_date'])) . ')', $rows); + $reply = count($rows) . " overdue task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:overdue'; + } + + // ── Urgent / high priority tasks ────────────────────────────────────── + if (!$reply && preg_match('/\b(urgent|high priority|important|critical|asap)\b.*\btask|\btask.*\b(urgent|high priority|important|critical)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE priority IN ('urgent','high') AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high'), due_date ASC LIMIT 6") ?? []; + if (!$rows) { + $reply = "No urgent or high priority tasks at the moment, {$userAddr}."; + } else { + $items = array_map(fn($r) => '[' . strtoupper($r['priority']) . '] ' . $r['title'], $rows); + $reply = count($rows) . " priority task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:priority_tasks'; + } + + // ── Tasks due this week ─────────────────────────────────────────────── + if (!$reply && preg_match('/\b(this week|week.?s tasks|due this week|tasks.*week)\b/i', $message)) { + $endOfWeek = date('Y-m-d', strtotime('sunday this week')); + $rows = JarvisDB::query("SELECT title, due_date, priority FROM tasks WHERE due_date BETWEEN ? AND ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC, FIELD(priority,'urgent','high','normal','low') LIMIT 8", [$today, $endOfWeek]) ?? []; + if (!$rows) { + $reply = "Nothing due this week, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ' (' . date('D', strtotime($r['due_date'])) . ')', $rows); + $reply = count($rows) . " task" . (count($rows) > 1 ? 's' : '') . " due this week, {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:week_tasks'; + } + + // ── Work tasks ──────────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(work tasks|work.*todo|office tasks|work related)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE category='work' AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 6") ?? []; + if (!$rows) { + $reply = "No work tasks pending, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows); + $reply = count($rows) . " work task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:work_tasks'; + } + + // ── Add task ────────────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put.*task|add.*to.*list)\b/i', $message)) { + // Strip trigger at START only (non-greedy anchor prevents stripping mid-phrase words) + $title = preg_replace('/^\s*(?:jarvis\s+)?(?:add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put\s+\w+\s+on\s+(?:my\s+)?(?:task\s+)?list|add\s+(?:this\s+)?to\s+(?:my\s+)?list)\s*/i', '', $message); + $title = trim($title, '. '); + $dueDate = null; + // Extract date — "by Friday", "on Monday", "due tomorrow", OR bare date at end of phrase + $datePattern = '((?:next\s+\w+|this\s+(?:monday|tuesday|wednesday|thursday|friday)|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)(?:\s+at\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?)'; + if (preg_match('/\s+(?:by|on|due|before|this|next)\s+' . $datePattern . '\s*$/i', $title, $dm)) { + $ts = strtotime(trim($dm[1])); + if ($ts !== false && $ts > time() - 86400) { + $dueDate = date('Y-m-d', $ts); + $title = trim(preg_replace('/\s+(?:by|on|due|before)\s+.*$/i', '', $title)); + } + } elseif (preg_match('/\s+' . $datePattern . '\s*$/i', $title, $dm)) { + // Bare date at end: "call dentist tomorrow", "pay bills Monday" + $ts = strtotime(trim($dm[1])); + if ($ts !== false && $ts > time() - 86400 && $ts < time() + 365*86400) { + $dueDate = date('Y-m-d', $ts); + $title = trim(substr($title, 0, strrpos($title, $dm[0]))); + } + } + $category = preg_match('/\b(work|meeting|project|client|office|report|email)\b/i', $title) ? 'work' : 'personal'; + $priority = 'normal'; + if (preg_match('/\b(urgent|asap|emergency|critical)\b/i', $title)) $priority = 'urgent'; + elseif (preg_match('/\b(important|high priority)\b/i', $title)) $priority = 'high'; + $title = trim($title); + if ($title) { + JarvisDB::execute('INSERT INTO tasks (title,category,priority,due_date) VALUES (?,?,?,?)', [$title, $category, $priority, $dueDate]); + $duePart = $dueDate ? ', due ' . date('l, M j', strtotime($dueDate)) : ''; + $reply = "Task added: \"{$title}\"{$duePart}, {$userAddr}."; + $source = 'planner:task_add'; + } + } + + // ── List tasks ──────────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(my tasks|todo list|to.?do|pending tasks|what.*tasks|show.*tasks|task list|how many tasks|task count)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title,priority,due_date FROM tasks WHERE status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 8") ?? []; + if (!$rows) { + $reply = "Your task list is clear, {$userAddr}. Nothing pending."; + } else { + $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows); + $reply = "You have " . count($rows) . " pending task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:task_list'; + } + + // ── Mark task done ──────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(mark|complete|finished|done with|completed|check off|close)\b.{1,40}\btask\b|\btask\b.{1,20}\b(done|complete|finished)\b/i', $message)) { + $search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the|check|off|close|with)\b/i', ' ', $message); + $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; + $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); + if ($row) { + JarvisDB::execute("UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$row['id']]); + $reply = "Marked \"{$row['title']}\" as complete, {$userAddr}."; + $source = 'planner:task_done'; + } + } + + // ── Delete / cancel task ────────────────────────────────────────────── + if (!$reply && preg_match('/\b(delete task|remove task|cancel task|drop task)\b/i', $message)) { + $search = preg_replace('/\b(delete|remove|cancel|drop|task)\b/i', ' ', $message); + $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; + $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); + if ($row) { + JarvisDB::execute("UPDATE tasks SET status='cancelled' WHERE id=?", [$row['id']]); + $reply = "Task \"{$row['title']}\" cancelled, {$userAddr}."; + $source = 'planner:task_cancel'; + } + } + + // ── Reschedule / move task ──────────────────────────────────────────── + if (!$reply && preg_match('/\b(reschedule|move|push|change due|update due)\b.*\btask\b/i', $message)) { + if (preg_match('/\bto\s+(.+)$/i', $message, $dm)) { + $ts = strtotime($dm[1]); + if ($ts !== false) { + $newDate = date('Y-m-d', $ts); + $search = preg_replace('/\b(reschedule|move|push|change|update|due|task|to\s+.+)$/i', ' ', $message); + $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; + $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); + if ($row) { + JarvisDB::execute("UPDATE tasks SET due_date=? WHERE id=?", [$newDate, $row['id']]); + $reply = "Moved \"{$row['title']}\" to " . date('l, M j', $ts) . ", {$userAddr}."; + $source = 'planner:task_reschedule'; + } + } + } + } + + // ── Next appointment ────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(next appointment|next meeting|next event|when.*next|upcoming appointment)\b/i', $message)) { + $row = JarvisDB::single("SELECT title, start_at, location FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 1"); + if ($row) { + $when = date('l, M j \a\t g:i A', strtotime($row['start_at'])); + $locPart = $row['location'] ? ' at ' . $row['location'] : ''; + $reply = "Your next appointment is \"{$row['title']}\"{$locPart} on {$when}, {$userAddr}."; + } else { + $reply = "No upcoming appointments on your calendar, {$userAddr}."; + } + $source = 'planner:next_appt'; + } + + // ── Week / date range calendar view ────────────────────────────────── + if (!$reply && preg_match('/\b(this week|week.*calendar|calendar.*week|appointments.*week|what.*week)\b/i', $message) + && !preg_match('/\btasks\b/i', $message)) { + $endOfWeek = date('Y-m-d', strtotime('sunday this week')); + $rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 8", [$today, $endOfWeek]) ?? []; + if (!$rows) { + $reply = "Nothing on your calendar this week, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows); + $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . " this week, {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:week_calendar'; + } + + // ── Add appointment ─────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(schedule|add\s+(?:an?\s+)?appointment|book\s+(?:an?\s+)?|set\s+up\s+(?:an?\s+)?meeting|add\s+(?:an?\s+)?meeting|add\s+to\s+(?:my\s+)?calendar)\b/i', $message) + && !preg_match('/\b(my calendar|upcoming|list|show|what|from email)\b/i', $message)) { + // Strip trigger at START only — prevents eating words like "dentist appointment" + $raw = preg_replace('/^\s*(?:jarvis\s+)?(?:schedule|add\s+(?:an?\s+)?appointment|book\s+(?:a?n?\s+)?|set\s+up\s+(?:an?\s+)?meeting|add\s+(?:an?\s+)?meeting|add\s+to\s+(?:my\s+)?calendar)\s*/i', '', $message); + $raw = trim($raw); + + // Normalize natural time words + $raw = preg_replace('/\bnoon\b/i', '12:00 pm', $raw); + $raw = preg_replace('/\bmidnight\b/i', '12:00 am', $raw); + $raw = preg_replace('/\bmidday\b/i', '12:00 pm', $raw); + + // Step 1: extract time ("at 2pm", "at 2:30pm", "2pm", "14:00") + $timeStr = null; + if (preg_match('/\bat\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm))\b/i', $raw, $tm)) { + $timeStr = $tm[1]; + $raw = trim(str_ireplace($tm[0], '', $raw)); + } elseif (preg_match('/\b(\d{1,2}:\d{2}\s*(?:am|pm)?)\b/i', $raw, $tm)) { + $timeStr = $tm[1]; + $raw = trim(str_ireplace($tm[0], '', $raw)); + } + + // Step 2: extract date (day names, "tomorrow", "next X", month+day) + $dateStr = null; + if (preg_match('/\b(tomorrow|today|next\s+\w+|this\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)|monday|tuesday|wednesday|thursday|friday|saturday|sunday|(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2})\b/i', $raw, $dm)) { + $dateStr = $dm[1]; + $raw = trim(str_ireplace($dm[0], '', $raw)); + } + + // Step 3: build datetime + $dtParsed = null; + if ($dateStr || $timeStr) { + $dtInput = trim(($dateStr ?: 'today') . ' ' . ($timeStr ?: '09:00 am')); + $ts = strtotime($dtInput); + if ($ts !== false && $ts > time() - 3600) { + $dtParsed = date('Y-m-d H:i:s', $ts); + } + } + + // Step 4: clean up title (remove leftover filler words) + $title = trim(preg_replace('/\s+/', ' ', preg_replace('/\b(on|at|the|a|an)\b\s*/i', ' ', $raw))); + $title = trim($title, ' .,'); + + if ($title && $dtParsed) { + $category = preg_match('/\b(work|meeting|office|client|project|call|conference|interview)\b/i', $title) ? 'work' : 'personal'; + JarvisDB::execute('INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', [$title, $category, $dtParsed]); + $reply = "Scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}."; + $source = 'planner:appt_add'; + } elseif ($title) { + $reply = "I can schedule that, {$userAddr} — what date and time?"; + $source = 'planner:appt_need_time'; + } + } + + // ── Cancel / delete appointment ─────────────────────────────────────── + if (!$reply && preg_match('/\b(cancel|delete|remove)\b.*\b(appointment|meeting|event)\b/i', $message)) { + $search = preg_replace('/\b(cancel|delete|remove|appointment|meeting|event|the|my)\b/i', ' ', $message); + $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; + $row = JarvisDB::single("SELECT id, title FROM appointments WHERE title LIKE ? AND start_at > NOW() LIMIT 1", [$search]); + if ($row) { + JarvisDB::execute("DELETE FROM appointments WHERE id=?", [$row['id']]); + $reply = "Appointment \"{$row['title']}\" removed from your calendar, {$userAddr}."; + $source = 'planner:appt_cancel'; + } + } + + // ── View appointments / calendar ────────────────────────────────────── + if (!$reply && preg_match('/\b(appointments|my calendar|my schedule|what.*scheduled|upcoming.*event|show.*calendar)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 6", [$today, date('Y-m-d', strtotime('+7 days'))]) ?? []; + if (!$rows) { + $reply = "No appointments in the next 7 days, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows); + $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:appt_list'; + } +} + +// ── Tier 0.8: Weather & News intents ───────────────────────────────────── +if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) { + $wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1"); + if ($wRow && !empty($wRow[0]['data'])) { + $wd = json_decode($wRow[0]['data'], true); + $c = $wd['current'] ?? []; + $fc = $wd['forecast'] ?? []; + $today = $fc[0] ?? []; + $tomorrow = $fc[1] ?? []; + $reply = sprintf( + 'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' . + 'Today\'s range: %d–%d°F. ' . + 'Tomorrow: %s, %d–%d°F, %d%% chance of rain.', + $c['icon'] ?? '', + $c['desc'] ?? '', + $c['temp'] ?? 0, + $c['feels'] ?? 0, + $c['humidity'] ?? 0, + $c['wind'] ?? 0, + $today['low'] ?? 0, + $today['high'] ?? 0, + $tomorrow['desc'] ?? '', + $tomorrow['low'] ?? 0, + $tomorrow['high'] ?? 0, + $tomorrow['rain_pct'] ?? 0 + ); + $source = 'intent:weather'; + } +} + +if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) { + $nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1"); + if ($nRow && !empty($nRow[0]['data'])) { + $nd = json_decode($nRow[0]['data'], true); + $cats = $nd['categories'] ?? []; + $lines = []; + foreach ($cats as $cat => $articles) { + if (!empty($articles)) { + $top3 = array_slice($articles, 0, 3); + foreach ($top3 as $a) { + $lines[] = '[' . $a['source'] . '] ' . $a['title']; + } + } + } + if ($lines) { + $reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.'; + } else { + $reply = 'News feed is still loading, Sir. Please try again in a moment.'; + } + $source = 'intent:news'; + } +} + +// ── Tier 0.9: Arc Protocols — research, triage, remote_exec, screenshot, sysinfo ─ +$arcJobId = null; + +// ── Memory Core helpers ─────────────────────────────────────────────────────── + +function getMemoryContext(string $message, int $limit = 12): string { + try { + $ch = curl_init('http://127.0.0.1:7474/memory/context?limit=' . $limit . + '&message=' . urlencode(substr($message, 0, 200))); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 2, + CURLOPT_CONNECTTIMEOUT => 1, + ]); + $raw = curl_exec($ch); + curl_close($ch); + if (!$raw) return ''; + $data = json_decode($raw, true); + return $data['context'] ?? ''; + } catch (Throwable $e) { + return ''; + } +} + +function memoryExtractAsync(string $userMsg, string $assistantMsg, string $sessionId): void { + // Fire-and-forget background memory extraction — does not block the response + $body = json_encode([ + 'type' => 'memory_extract', + 'payload' => [ + 'user_message' => substr($userMsg, 0, 600), + 'assistant_message' => substr($assistantMsg, 0, 600), + 'conversation_id' => null, + ], + 'priority' => 2, + 'created_by' => 'chat:' . $sessionId, + ]); + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 1, + CURLOPT_CONNECTTIMEOUT => 1, + ]); + @curl_exec($ch); + curl_close($ch); +} + +// Helper: submit job to Arc Reactor +function arcPost(string $path, array $body): ?array { + $ch = curl_init('http://127.0.0.1:7474' . $path); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 5, + CURLOPT_CONNECTTIMEOUT => 3, + ]); + $res = json_decode(curl_exec($ch), true); + curl_close($ch); + return $res; +} + +function arcGet(string $path): ?array { + $ch = curl_init('http://127.0.0.1:7474' . $path); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_CONNECTTIMEOUT => 3, + ]); + $res = json_decode(curl_exec($ch), true); + curl_close($ch); + return $res; +} + +function arcSubmitJob(string $type, array $payload, string $sessionId): ?array { + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode([ + 'type' => $type, + 'payload' => $payload, + 'priority' => 7, + 'created_by' => 'chat:' . $sessionId, + ]), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 5, + CURLOPT_CONNECTTIMEOUT => 3, + ]); + $res = json_decode(curl_exec($ch), true); + curl_close($ch); + return $res; +} + +// ── Tier 0.9a: Comms Protocol — gmail_triage detection ──────────────────── +if (!$reply) { + $triagePatterns = [ + '/^(?:jarvis[,\s]+)?(?:check|triage|sort)\s+(?:my\s+)?(?:email|inbox|gmail|mail)/i', + '/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:urgent|important)\s+(?:in\s+my\s+)?(?:email|inbox|mail)/i', + '/^(?:jarvis[,\s]+)?(?:any\s+)?(?:urgent|important)\s+(?:email|emails|messages)/i', + '/^(?:jarvis[,\s]+)?email\s+(?:briefing|brief|report|summary)/i', + ]; + foreach ($triagePatterns as $pat) { + if (preg_match($pat, $message)) { + $account = preg_match('/icloud/i', $message) ? 'icloud' : 'gmail'; + $maxEmails = 20; + if (preg_match('/\b(\d+)\s+emails?\b/i', $message, $em)) $maxEmails = min((int)$em[1], 40); + $arcRes = arcSubmitJob('gmail_triage', [ + 'account' => $account, + 'max_emails' => $maxEmails, + 'provider' => 'claude', + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $acctLabel = strtoupper($account); + $reply = "◈ COMMS PROTOCOL ACTIVATED — Triaging your {$acctLabel} inbox (Job #{$arcJobId}). I'm fetching emails and running priority analysis now, {$userAddr}. Switch to the COMMS tab to see results."; + $source = 'arc:gmail_triage'; + } else { + $reply = "Comms Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9b: Field Protocol — remote_exec detection ──────────────────── +if (!$reply) { + $remotePatterns = [ + '/^(?:jarvis[,\s]+)?restart\s+(.+?)\s+on\s+(.+)/i' => ['restart_service', 1, 2], + '/^(?:jarvis[,\s]+)?(?:run|execute|exec)\s+(.+?)\s+on\s+(.+)/i' => ['shell', 1, 2], + '/^(?:jarvis[,\s]+)?get\s+logs?\s+(?:from\s+|for\s+)?(.+?)\s+on\s+(.+)/i' => ['get_logs', 1, 2], + '/^(?:jarvis[,\s]+)?(?:ping|check)\s+agent\s+(.+)/i' => ['ping', 1, null], + '/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:running|the\s+status)\s+on\s+(.+)/i' => ['ping', 1, null], + ]; + foreach ($remotePatterns as $pat => $cfg) { + if (preg_match($pat, $message, $m)) { + [$cmdType, $cmdIdx, $agentIdx] = $cfg; + $cmdArg = $agentIdx ? trim($m[$cmdIdx]) : ''; + $agentArg = $agentIdx ? trim($m[$agentIdx]) : trim($m[$cmdIdx]); + $cmdData = match($cmdType) { + 'restart_service' => ['service' => $cmdArg], + 'shell' => ['command' => $cmdArg], + 'get_logs' => ['service' => $cmdArg, 'lines' => 50], + default => [], + }; + $arcRes = arcSubmitJob('remote_exec', [ + 'agent' => $agentArg, + 'command_type' => $cmdType, + 'command_data' => $cmdData, + 'timeout' => 35, + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ FIELD PROTOCOL ACTIVATED — Dispatching **{$cmdType}** to agent **{$agentArg}** (Job #{$arcJobId}). I'll relay the result when the field station responds, {$userAddr}."; + $source = 'arc:remote_exec'; + } else { + $reply = "Field Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9c: Vision Protocol — screenshot, sysinfo, vision detection ─────── +if (!$reply) { + // Screenshot patterns + $screenshotMatch = null; + if (preg_match('/^(?:jarvis[,\s]+)?(?:show\s+(?:me\s+)?(?:the\s+)?screen\s+(?:on|of|from)|screenshot\s+(?:of\s+)?|grab\s+(?:a\s+)?(?:screenshot|screen\s+cap)\s+(?:of\s+|from\s+)?|what(?:\'s|\s+is)\s+(?:on|showing\s+on)\s+(?:the\s+)?screen\s+(?:on|of))\s+(.+)/i', $message, $mm)) { + $screenshotMatch = trim($mm[1]); + $arcRes = arcSubmitJob('screenshot', ['agent' => $screenshotMatch, 'analyze' => true], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ VISION PROTOCOL ACTIVATED — Capturing screen on **{$screenshotMatch}** (Job #{$arcJobId}). I'll analyze what I see and report back, {$userAddr}."; + $source = 'arc:screenshot'; + } else { + $reply = "Vision Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + } + // Sysinfo patterns + elseif (preg_match('/^(?:jarvis[,\s]+)?(?:(?:what(?:\'s|\s+is)\s+(?:the\s+)?status\s+of|check\s+(?:the\s+)?(?:health|status)\s+of|how\s+is)\s+(.+)|system\s+(?:info|status|snapshot)\s+(?:on|from|for)\s+(.+))/i', $message, $mm)) { + $agentName = trim($mm[1] ?? $mm[2] ?? ''); + // Only fire for explicit agent references (has a name), not generic questions + if ($agentName && strlen($agentName) > 2 && + !preg_match('/\b(?:weather|today|things|everything|jarvis|yourself)\b/i', $agentName)) { + $arcRes = arcSubmitJob('sysinfo', ['agent' => $agentName, 'analyze' => true], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ FIELD PROTOCOL — Running system health check on **{$agentName}** (Job #{$arcJobId}). Snapshot incoming, {$userAddr}."; + $source = 'arc:sysinfo'; + } + } + } +} + +// ── Tier 0.9d: Guardian Mode — sitrep detection ─────────────────────────── +if (!$reply) { + $sitrepPatterns = [ + '/^(?:jarvis[,\s]+)?(?:sitrep|sit\s+rep|situation\s+report|status\s+report)/i', + '/^(?:jarvis[,\s]+)?(?:give\s+me\s+a|run\s+a)\s+(?:sitrep|situation|status)\s*(?:report)?/i', + '/^(?:jarvis[,\s]+)?(?:how\s+(?:are|is)\s+(?:everything|all\s+systems?|things?)(?:\s+looking)?)/i', + '/^(?:jarvis[,\s]+)?(?:system\s+health|overall\s+status|all\s+systems\s+(?:check|status|go))/i', + '/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:the\s+)?overall\s+(?:system\s+)?status/i', + ]; + $isSimple = (bool) preg_match('/\b(?:brief|quick|short|summary)\b/i', $message); + foreach ($sitrepPatterns as $pat) { + if (preg_match($pat, $message)) { + $arcRes = arcSubmitJob('sitrep', [ + 'detail' => $isSimple ? 'brief' : 'full', + 'provider' => 'claude', + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ GUARDIAN PROTOCOL — Generating situation report (Job #{$arcJobId}). Scanning all field stations and synthesizing a briefing now, {$userAddr}. Stand by."; + $source = 'arc:sitrep'; + } else { + $reply = "Guardian Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9e: Intel Protocol — research & tool_loop detection ──────────── +$intelPatterns = [ + '/^(?:jarvis[,\s]+)?(?:research|investigate|deep[- ]dive|deep dive)\s+(.+)/i' => 'research', + '/^(?:jarvis[,\s]+)?(?:look\s+(?:up|into)|find\s+out\s+(?:about)?)\s+(.+)/i' => 'research', + '/^(?:jarvis[,\s]+)?(?:step[- ]by[- ]step|figure\s+out|analyze\s+and\s+report|work\s+through)\s+(.+)/i' => 'tool_loop', + '/^(?:jarvis[,\s]+)?(?:run\s+a\s+research\s+(?:job|task)\s+on)\s+(.+)/i' => 'research', +]; + +if (!$reply) { + foreach ($intelPatterns as $pattern => $jobType) { + if (preg_match($pattern, $message, $m)) { + $queryOrTask = trim($m[1]); + if (strlen($queryOrTask) < 3) break; + + $depth = 'standard'; + if (preg_match('/\b(?:quick|brief)\b/i', $message)) $depth = 'quick'; + if (preg_match('/\b(?:deep|thorough|comprehensive|full)\b/i', $message)) $depth = 'deep'; + + $jobPayload = $jobType === 'research' + ? ['query' => $queryOrTask, 'depth' => $depth, 'provider' => 'claude'] + : ['task' => $queryOrTask, 'max_iterations' => 12, 'provider' => 'claude']; + + $arcRes = arcSubmitJob($jobType, $jobPayload, $sessionId); + + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + if ($jobType === 'research') { + $depthLabel = strtoupper($depth); + $reply = "◈ INTEL PROTOCOL ACTIVATED — Running {$depthLabel} research on **{$queryOrTask}** (Job #{$arcJobId}). I'm searching sources, extracting content, and synthesizing a briefing now, {$userAddr}. Switch to the INTEL tab to watch live progress."; + } else { + $reply = "◈ IRON PROTOCOL ACTIVATED — Multi-step analysis initiated for **{$queryOrTask}** (Job #{$arcJobId}). I'll work through this systematically using available tools, {$userAddr}. Results will appear in the INTEL tab."; + } + $source = "arc:{$jobType}"; + } else { + $reply = "Intel Protocol is offline, {$userAddr}. Arc Reactor may be unavailable — I'll try to answer directly."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9f: Comms v2 — send_email, compose_email ───────────────────────── +if (!$reply) { + // "reply to [name/id] saying..." or "send [name] a reply..." + if (preg_match('/^(?:jarvis[,\s]+)?(?:reply\s+to|send\s+(?:a\s+)?reply\s+to)\s+(.+?)\s+(?:saying|that|:)\s+(.+)/i', $message, $m)) { + $target = trim($m[1]); + $content = trim($m[2]); + $arcRes = arcSubmitJob('send_email', [ + 'target' => $target, + 'content' => $content, + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ COMMS PROTOCOL — Sending reply to **{$target}** (Job #{$arcJobId}). Drafting and transmitting now, {$userAddr}."; + $source = 'arc:send_email'; + } else { + $reply = "Comms Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + } + // "send email to [name] about [subject]" or "compose email to..." + elseif (preg_match('/^(?:jarvis[,\s]+)?(?:send\s+(?:an?\s+)?email\s+to|compose\s+(?:an?\s+)?email\s+to|write\s+(?:an?\s+)?email\s+to|draft\s+(?:an?\s+)?email\s+to)\s+(.+?)(?:\s+(?:about|regarding|re:?)\s+(.+))?$/i', $message, $m)) { + $recipient = trim($m[1]); + $instructions = isset($m[2]) ? trim($m[2]) : $message; + $arcRes = arcSubmitJob('compose_email', [ + 'recipient' => $recipient, + 'instructions' => $instructions, + 'auto_send' => false, + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ COMMS PROTOCOL — Composing email to **{$recipient}** (Job #{$arcJobId}). I'll draft it and show you before sending, {$userAddr}. Check the COMMS tab."; + $source = 'arc:compose_email'; + } else { + $reply = "Comms Protocol is offline, {$userAddr}."; + $source = 'arc:offline'; + } + } +} + +// ── Tier 0.9g: Comms v2 — schedule_event ───────────────────────────────────── +if (!$reply) { + $schedulePatterns = [ + '/^(?:jarvis[,\s]+)?(?:schedule|book|set\s+up|create)\s+(?:a\s+)?(?:meeting|call|appointment|event|session)\s+(?:with|for|about)?\s*(.+)/i', + '/^(?:jarvis[,\s]+)?(?:add\s+(?:a\s+)?(?:meeting|call|appointment|event)\s+(?:to\s+my\s+calendar)?\s*(?:with|for|about)?\s*(.+))/i', + '/^(?:jarvis[,\s]+)?(?:put\s+(?:a\s+)?(?:meeting|call|appointment)\s+(?:on\s+(?:my\s+)?calendar|in\s+my\s+schedule)(?:\s+(?:with|for|about)?\s+(.+))?)/i', + ]; + foreach ($schedulePatterns as $pat) { + if (preg_match($pat, $message, $m)) { + $details = trim($m[1] ?? $message); + $arcRes = arcSubmitJob('schedule_event', [ + 'request' => $message, + 'details' => $details, + 'provider' => 'claude', + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ SCHEDULING PROTOCOL — Processing your calendar request (Job #{$arcJobId}). I'm parsing the details and creating the appointment now, {$userAddr}."; + $source = 'arc:schedule_event'; + } else { + $reply = "Scheduling Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9h: Comms v2 — meeting_prep ──────────────────────────────────────── +if (!$reply) { + $meetingPrepPatterns = [ + '/^(?:jarvis[,\s]+)?(?:prep(?:are)?(?:\s+me)?\s+for|brief(?:ing)?\s+(?:me\s+)?(?:for|on|about)?)\s+(?:my\s+)?(?:next\s+)?(?:meeting|call|appointment)/i', + '/^(?:jarvis[,\s]+)?(?:what(?:\'s|\s+do\s+i\s+need\s+to\s+know)?\s+(?:is\s+)?(?:my\s+)?(?:next\s+)?meeting)/i', + '/^(?:jarvis[,\s]+)?(?:meeting\s+prep|pre[- ]meeting\s+(?:brief|notes|prep))/i', + '/^(?:jarvis[,\s]+)?(?:get\s+(?:me\s+)?ready\s+for\s+my\s+(?:next\s+)?(?:meeting|call))/i', + ]; + foreach ($meetingPrepPatterns as $pat) { + if (preg_match($pat, $message)) { + $arcRes = arcSubmitJob('meeting_prep', [ + 'provider' => 'claude', + 'research' => true, + ], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ MISSION PROTOCOL — Preparing your meeting briefing (Job #{$arcJobId}). I'm pulling your next appointment details and researching the participants, {$userAddr}. Stand by."; + $source = 'arc:meeting_prep'; + } else { + $reply = "Mission Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9i: Directives — review objectives, progress check ───────────────── +if (!$reply) { + $dirReviewPatterns = [ + '/^(?:jarvis[,\s]+)?(?:review\s+(?:my\s+)?(?:directives?|objectives?|goals?|OKRs?))/i', + '/^(?:jarvis[,\s]+)?(?:how\s+am\s+i\s+doing\s+on\s+(?:my\s+)?(?:directives?|objectives?|goals?))/i', + '/^(?:jarvis[,\s]+)?(?:directives?\s+(?:review|status|update|progress|briefing))/i', + '/^(?:jarvis[,\s]+)?(?:OKR\s+(?:review|update|status))/i', + '/^(?:jarvis[,\s]+)?(?:what(?:\'s|\s+is)\s+(?:my\s+)?(?:progress|status)\s+on\s+(?:my\s+)?(?:directives?|goals?|objectives?))/i', + ]; + foreach ($dirReviewPatterns as $pat) { + if (preg_match($pat, $message)) { + $arcRes = arcSubmitJob('directive_review', ['provider' => 'claude'], $sessionId); + if (isset($arcRes['job_id'])) { + $arcJobId = $arcRes['job_id']; + $reply = "◈ DIRECTIVE REVIEW INITIATED (Job #{$arcJobId}). I'm analyzing your active objectives and key results now, {$userAddr}. Stand by for your progress briefing."; + $source = 'arc:directive_review'; + } else { + $reply = "Directive review is offline, {$userAddr}. Arc Reactor may be unavailable."; + $source = 'arc:offline'; + } + break; + } + } +} + +// ── Tier 0.9j: Clearance Protocol — approve/deny voice commands ───────────── +if (!$reply) { + // "approve clearance 5", "authorize clearance", "approve all clearance" + if (preg_match('/^(?:jarvis[,\s]+)?(?:approve|authorize|grant)\s+(?:all\s+)?clearance(?:\s+(?:request\s+)?#?(\d+))?/i', $message, $m)) { + $crId = isset($m[1]) && $m[1] ? (int)$m[1] : null; + if ($crId) { + $resp = arcPost('/clearance/' . $crId . '/approve', ['decided_by' => 'voice']); + if (isset($resp['ok']) && $resp['ok']) { + $reply = "◈ Clearance request #{$crId} authorized, {$userAddr}. Job dispatched."; + } else { + $reply = "Clearance #{$crId} not found or already decided."; + } + } else { + // Approve all pending + $pending = arcGet('/clearance/pending') ?: []; + if (empty($pending)) { + $reply = "No pending clearance requests, {$userAddr}."; + } else { + $approved = 0; + foreach ($pending as $cr) { + $resp = arcPost('/clearance/' . $cr['id'] . '/approve', ['decided_by' => 'voice']); + if (isset($resp['ok']) && $resp['ok']) $approved++; + } + $reply = "◈ Authorized {$approved} clearance request" . ($approved !== 1 ? 's' : '') . ", {$userAddr}."; + } + } + $source = 'arc:clearance_approve'; + } +} +if (!$reply) { + // "deny clearance 5", "reject clearance 5" + if (preg_match('/^(?:jarvis[,\s]+)?(?:deny|reject|refuse)\s+clearance(?:\s+(?:request\s+)?#?(\d+))?/i', $message, $m)) { + $crId = isset($m[1]) && $m[1] ? (int)$m[1] : null; + if ($crId) { + $resp = arcPost('/clearance/' . $crId . '/deny', ['decided_by' => 'voice', 'note' => 'denied by voice command']); + $reply = isset($resp['ok']) && $resp['ok'] + ? "Clearance #{$crId} denied, {$userAddr}." + : "Clearance #{$crId} not found or already decided."; + } else { + $reply = "Which clearance request should I deny? Say: deny clearance [number]."; + } + $source = 'arc:clearance_deny'; + } +} +if (!$reply) { + // "any pending clearance", "clearance status", "show clearance" + if (preg_match('/^(?:jarvis[,\s]+)?(?:(?:any\s+|show\s+)?pending\s+clearance|clearance\s+(?:status|requests?|pending|queue))/i', $message)) { + $pending = arcGet('/clearance/pending') ?: []; + $count = count($pending); + if ($count === 0) { + $reply = "No pending clearance requests, {$userAddr}. All clear."; + } else { + $list = array_map(fn($cr) => "#{$cr['id']} {$cr['job_type']} ({$cr['risk_level']})", $pending); + $reply = "◈ {$count} pending clearance request" . ($count !== 1 ? 's' : '') . ": " . implode(', ', $list) . ". Say 'approve clearance [number]' to authorize."; + } + $source = 'arc:clearance_status'; + } +} + +// ── Tier 0.9k: Memory Core — remember/forget/recall voice commands ─────────── +if (!$reply) { + // "remember that I prefer X", "remember X", "note that X" + if (preg_match('/^(?:jarvis[,\s]+)?(?:remember|note down|make a note|remember that|note that)\s+(?:that\s+)?(.+)/i', $message, $m)) { + $fact = trim($m[1]); + // Use LLM to parse the fact into subject/predicate/object — but for speed, use heuristic + $arcRes = arcPost('/job', [ + 'type' => 'memory_store', + 'payload' => ['subject' => 'user', 'predicate' => 'note', 'object' => $fact, 'category' => 'instruction'], + 'priority' => 8, + 'created_by' => 'chat:' . $sessionId, + ]); + $reply = "Noted, {$userAddr}. I'll remember that: {$fact}"; + $source = 'memory:store'; + } +} +if (!$reply) { + // "forget X", "forget that X", "remove memory X" + if (preg_match('/^(?:jarvis[,\s]+)?(?:forget|discard|remove|delete)\s+(?:that\s+|the\s+memory\s+(?:about\s+)?)?(.+)/i', $message, $m)) { + $keyword = trim($m[1]); + // Mark matching facts inactive + $affected = JarvisDB::execute( + "UPDATE memory_facts SET active=0 WHERE active=1 AND (subject LIKE ? OR object LIKE ? OR predicate LIKE ?)", + ["%{$keyword}%", "%{$keyword}%", "%{$keyword}%"] + ); + $reply = $affected + ? "Memory cleared, {$userAddr}. Removed facts related to: {$keyword}." + : "Nothing in memory matched \"{$keyword}\", {$userAddr}."; + $source = 'memory:forget'; + } +} +if (!$reply) { + // "what do you know about X", "what do you remember about X" + if (preg_match('/^(?:jarvis[,\s]+)?(?:what do you know about|what do you remember about|recall|memory about)\s+(.+)/i', $message, $m)) { + $keyword = trim($m[1]); + $facts = JarvisDB::query( + "SELECT * FROM memory_facts WHERE active=1 AND (subject LIKE ? OR object LIKE ?) ORDER BY confirmed_count DESC LIMIT 8", + ["%{$keyword}%", "%{$keyword}%"] + ) ?: []; + if (empty($facts)) { + $reply = "I don't have any stored memories about \"{$keyword}\", {$userAddr}."; + } else { + $lines = array_map(fn($f) => "{$f['subject']} {$f['predicate']}: {$f['object']}", $facts); + $reply = "◈ I know the following about \"{$keyword}\", {$userAddr}:\n" . implode("\n", $lines); + } + $source = 'memory:recall'; + } +} +if (!$reply) { + // "how many memories", "memory status", "what do you know about me" + if (preg_match('/^(?:jarvis[,\s]+)?(?:(?:how many|show)\s+memories|memory\s+(?:status|count|summary)|what do you know about me)/i', $message)) { + $stats = JarvisDB::query( + "SELECT category, COUNT(*) cnt FROM memory_facts WHERE active=1 GROUP BY category ORDER BY cnt DESC" + ) ?: []; + $total = array_sum(array_column($stats, 'cnt')); + if (!$total) { + $reply = "My memory core is empty, {$userAddr}. I'll start learning from our conversations."; + } else { + $breakdown = implode(', ', array_map(fn($s) => "{$s['cnt']} {$s['category']}", $stats)); + $reply = "◈ Memory Core: {$total} facts stored — {$breakdown}. I use these to personalize responses."; + } + $source = 'memory:status'; + } +} + +// ── Tier 0.25: Quick-note capture ──────────────────────────────────────────── +if (!$reply && preg_match('/^note:\s*(.+)/iu', $message, $nm)) { + $noteText = trim($nm[1]); + $ts = date('Y-m-d H:i'); + JarvisDB::query( + "INSERT INTO kb_facts (category, fact, source, confidence) VALUES (?,?,?,?)", + ['notes', "[{$ts}] {$noteText}", 'user-note', 1.0] + ); + $reply = "Note saved to Memory Core, {$userAddr}: \"{$noteText}\""; + $source = 'intent:quick_note'; +} + +// ── Tier 0.5: Multi-step command detection ────────────────────────────────── +// Detect "do X and Y" or "X then Y" compound commands (only when no reply yet) +if (!$reply) { + $compoundParts = preg_split('/\s+(?:and|then|also)\s+/i', $message, 3); + if (count($compoundParts) >= 2) { + $multiReplies = []; + $multiActionIntents = []; + foreach ($compoundParts as $part) { + $part = trim($part); + if (!$part) continue; + $mMatch = KBEngine::match($part); + if ($mMatch && ($mMatch['action'] ?? '') === 'action') { + $multiActionIntents[] = ['match' => $mMatch, 'part' => $part]; + } + } + + if (count($multiActionIntents) >= 2) { + foreach ($multiActionIntents as $ma) { + $mIntent = $ma['match']['intent']; + $mPart = $ma['part']; + $mReply = null; + + if ($mIntent === 'ha_scene') { + $haStates = @json_decode(@file_get_contents(HA_URL.'/api/states', false, + stream_context_create(['http'=>['timeout'=>4,'header'=>'Authorization: Bearer '.HA_TOKEN]])), true) ?? []; + $haScenes = array_filter($haStates, fn($s) => str_starts_with($s['entity_id']??'','scene.')); + $mLow = strtolower($mPart); $best=null; $bestS=0; + foreach ($haScenes as $s) { + $name=strtolower($s['attributes']['friendly_name']??''); + $id=strtolower(str_replace(['scene.','_'],['',''],$s['entity_id'])); + $score=0; foreach(array_filter(explode(' ',"$name $id")) as $tok) + if(strlen($tok)>2&&str_contains($mLow,$tok))$score++; + if($name&&str_contains($mLow,$name))$score+=5; + if($score>$bestS){$bestS=$score;$best=$s;} + } + if ($best && $bestS >= 1) { + @file_get_contents(HA_URL.'/api/services/scene/turn_on', false, + stream_context_create(['http'=>['method'=>'POST','timeout'=>4, + 'header'=>"Authorization: Bearer ".HA_TOKEN." +Content-Type: application/json", + 'content'=>json_encode(['entity_id'=>$best['entity_id']])]])); + $mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated'; + } + } elseif (in_array($mIntent, ['jellyfin_pause','jellyfin_stop','jellyfin_next','jellyfin_previous'])) { + $jCmdMap = ['jellyfin_pause'=>'TogglePause','jellyfin_stop'=>'Stop','jellyfin_next'=>'NextTrack','jellyfin_previous'=>'PreviousTrack']; + $jCmd = $jCmdMap[$mIntent]; + $jSessions = @json_decode(@file_get_contents(JELLYFIN_URL.'/Sessions?api_key='.JELLYFIN_API_KEY,false, + stream_context_create(['http'=>['timeout'=>4]])),true)??[]; + foreach ($jSessions as $js) { if(isset($js['NowPlayingItem'])){ + @file_get_contents(JELLYFIN_URL.'/Sessions/'.rawurlencode($js['Id']).'/Command/'.$jCmd.'?api_key='.JELLYFIN_API_KEY, + false,stream_context_create(['http'=>['method'=>'POST','timeout'=>4,'content'=>'{}','header'=>'Content-Type: application/json']])); + $mReply = 'Jellyfin ' . strtolower(str_replace('Track','',$jCmd)); break; + }} + } elseif ($mIntent === 'focus_mode') { $uiAction = 'focus_mode'; $mReply = 'focus mode on'; } + elseif ($mIntent === 'show_panels') { $uiAction = 'show_panels'; $mReply = 'panels shown'; } + elseif ($mIntent === 'network_scan') { + $devCount = JarvisDB::query("SELECT COUNT(*) as c FROM network_devices WHERE last_seen > DATE_SUB(NOW(),INTERVAL 15 MINUTE)")[0]['c'] ?? 0; + $mReply = "network scan queued ({$devCount} devices online)"; + } + + if ($mReply) $multiReplies[] = $mReply; + } + + if (count($multiReplies) >= 2) { + $reply = "Done, {$userAddr}. " . implode(', and ', $multiReplies) . '.'; + $source = 'intent:multi_step'; + } + } + } +} + +// ── Tier 1: Intent Engine (instant, no LLM) ─────────────────────────────── +if (!$reply) { + $matched = KBEngine::match($message); + if ($matched && $matched['action'] === 'response') { + $reply = $matched['reply']; + $source = 'intent:' . $matched['intent']; + } +} + +// ── Tier 1b: Action Intents — handled directly, no LLM ────────────────── +if (!$reply) { + if (!isset($matched)) $matched = KBEngine::match($message); + if ($matched && $matched['action'] === 'action') { + switch ($matched['intent']) { + + case 'vm_suggestions': { + $rows = JarvisDB::query( + "SELECT a.hostname, a.agent_type, + ROUND(AVG(CAST(JSON_EXTRACT(m.metric_data,'$.cpu_percent') AS DECIMAL(5,1))),1) as avg_cpu, + ROUND(AVG(CAST(JSON_EXTRACT(m.metric_data,'$.memory.percent') AS DECIMAL(5,1))),1) as avg_mem, + ROUND(MAX(CAST(JSON_EXTRACT(m.metric_data,'$.memory.total_mb') AS UNSIGNED))/1024,1) as ram_gb, + COUNT(*) as samples + FROM agent_metrics m + JOIN registered_agents a ON a.agent_id = m.agent_id + WHERE m.recorded_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) + AND a.agent_type IN ('linux','proxmox') + GROUP BY a.hostname, a.agent_type + HAVING samples > 20 + ORDER BY avg_mem DESC" + ) ?? []; + + $suggestions = []; + foreach ($rows as $r) { + $cpu = (float)($r['avg_cpu'] ?? 0); + $mem = (float)($r['avg_mem'] ?? 0); + $ram = (float)($r['ram_gb'] ?? 0); + $host = $r['hostname']; + if (!$ram) continue; + + // High CPU + if ($cpu >= 80) + $suggestions[] = "{$host} is averaging {$cpu}% CPU — consider increasing vCPU allocation or investigating load."; + // Low CPU + reasonable RAM (suggest checking allocation) + elseif ($cpu < 5 && $ram >= 2) + $suggestions[] = "{$host} averages only {$cpu}% CPU — vCPU allocation may be generous."; + + // High MEM + if ($mem >= 85) + $suggestions[] = "{$host} is averaging {$mem}% memory use ({$ram}GB allocated) — consider increasing RAM."; + // Low MEM + elseif ($mem < 25 && $ram >= 2) + $suggestions[] = "{$host} uses only {$mem}% of its {$ram}GB RAM on average — allocation could be reduced."; + } + + if (!$suggestions) { + $reply = "All VM resources look well-balanced, {$userAddr}. No over or under-allocation detected across the past 24 hours."; + } else { + $reply = "Resource analysis for the past 24 hours, {$userAddr}: " . implode(' ', $suggestions); + } + $source = 'intent:vm_suggestions'; + break; + } + + case 'ha_scene': { + // Fetch all HA scenes and fuzzy-match against the message + $haScenes = @json_decode(@file_get_contents( + HA_URL . '/api/states', + false, + stream_context_create(['http' => ['timeout' => 5, 'header' => 'Authorization: Bearer ' . HA_TOKEN]]) + ), true) ?? []; + + $scenes = array_filter($haScenes, fn($s) => str_starts_with($s['entity_id'] ?? '', 'scene.')); + $msgLow = strtolower($message); + + // Score each scene against the message + $best = null; $bestScore = 0; + foreach ($scenes as $s) { + $name = strtolower($s['attributes']['friendly_name'] ?? ''); + $id = strtolower(str_replace(['scene.', '_'], ['', ' '], $s['entity_id'])); + // Token overlap score + $tokens = array_filter(explode(' ', "$name $id")); + $score = 0; + foreach ($tokens as $tok) { + if (strlen($tok) > 2 && str_contains($msgLow, $tok)) $score++; + } + // Bonus for exact phrase match + if ($name && str_contains($msgLow, $name)) $score += 5; + if ($score > $bestScore) { $bestScore = $score; $best = $s; } + } + + if ($best && $bestScore >= 1) { + $sceneName = $best['attributes']['friendly_name'] ?? $best['entity_id']; + @file_get_contents( + HA_URL . '/api/services/scene/turn_on', + false, + stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => "Authorization: Bearer " . HA_TOKEN . " +Content-Type: application/json", + 'content' => json_encode(['entity_id' => $best['entity_id']]), + 'timeout' => 5, + ]]) + ); + $reply = "Activating {$sceneName}, {$userAddr}."; + } else { + // List available scenes + $names = array_map(fn($s) => $s['attributes']['friendly_name'] ?? $s['entity_id'], $scenes); + $list = implode(', ', array_values($names)); + $reply = "I didn't catch which scene, {$userAddr}. Available scenes: {$list}."; + } + $source = 'intent:ha_scene'; + break; + } + + case 'restart_agent': + // Extract target hostname from message + $msgLow = strtolower($message); + $agentMap = [ + 'homebridge' => 'homebridge_b57cbaea', + 'jellyfin' => 'jellyfin_7e386833', + 'networkbackup' => 'networkbackup_NetworkB', + 'network backup'=> 'networkbackup_NetworkB', + 'novacpx' => 'novacpx_e3b07264', + 'nova' => 'novacpx_e3b07264', + 'mediastack' => 'MediaStack_2c00b1b8', + 'media stack' => 'MediaStack_2c00b1b8', + 'homeassistant' => 'homeassistant_ha', + 'home assistant'=> 'homeassistant_ha', + ]; + $targetAgentId = null; + $targetName = null; + foreach ($agentMap as $keyword => $agentId) { + if (str_contains($msgLow, $keyword)) { + $targetAgentId = $agentId; + $targetName = ucfirst($keyword); + break; + } + } + if ($targetAgentId) { + JarvisDB::insert( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status, created_at) + VALUES (?, 'restart_service', ?, 'pending', NOW())", + [$targetAgentId, json_encode(['service' => 'jarvis-agent'])] + ); + $reply = "Restart command sent to the {$targetName} agent, {$userAddr}. It should come back online within 15 seconds."; + } else { + // Fall back to listing restartable agents + $reply = "Which agent should I restart, {$userAddr}? I can restart: HomeAssistant, Homebridge, Jellyfin, MediaStack, NetworkBackup, or NovaCPX."; + } + $source = 'intent:restart_agent'; + break; + + case 'restart_agent': + // Extract target hostname from message + $msgLow = strtolower($message); + $agentMap = [ + 'homebridge' => 'homebridge_b57cbaea', + 'jellyfin' => 'jellyfin_7e386833', + 'networkbackup' => 'networkbackup_NetworkB', + 'network backup'=> 'networkbackup_NetworkB', + 'novacpx' => 'novacpx_e3b07264', + 'nova' => 'novacpx_e3b07264', + 'mediastack' => 'MediaStack_2c00b1b8', + 'media stack' => 'MediaStack_2c00b1b8', + 'homeassistant' => 'homeassistant_ha', + 'home assistant'=> 'homeassistant_ha', + ]; + $targetAgentId = null; + $targetName = null; + foreach ($agentMap as $keyword => $agentId) { + if (str_contains($msgLow, $keyword)) { + $targetAgentId = $agentId; + $targetName = ucfirst($keyword); + break; + } + } + if ($targetAgentId) { + JarvisDB::insert( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status, created_at) + VALUES (?, 'restart_service', ?, 'pending', NOW())", + [$targetAgentId, json_encode(['service' => 'jarvis-agent'])] + ); + $reply = "Restart command sent to the {$targetName} agent, {$userAddr}. It should come back online within 15 seconds."; + } else { + // Fall back to listing restartable agents + $reply = "Which agent should I restart, {$userAddr}? I can restart: HomeAssistant, Homebridge, Jellyfin, MediaStack, NetworkBackup, or NovaCPX."; + } + $source = 'intent:restart_agent'; + break; + + case 'focus_mode': + $uiAction = 'focus_mode'; + $reply = "Focus mode activated, {$userAddr}. Side panels hidden."; + $source = 'intent:focus_mode'; + break; + + case 'show_panels': + $uiAction = 'show_panels'; + $reply = "Full view restored, {$userAddr}. All panels visible."; + $source = 'intent:show_panels'; + break; + + case 'network_scan': + $online = JarvisDB::single( + "SELECT COUNT(*) cnt FROM network_devices WHERE status='online' AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)" + ); + $total = JarvisDB::single( + "SELECT COUNT(*) cnt FROM network_devices WHERE last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)" + ); + // Queue netscan to PVE1 agent for immediate refresh + $pve1 = JarvisDB::single( + "SELECT agent_id FROM registered_agents WHERE ip_address='10.48.200.90' AND status='online' LIMIT 1" + ); + if ($pve1) { + JarvisDB::execute( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)", + [$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending'] + ); + } + $o = (int)($online['cnt'] ?? 0); + $t = (int)($total['cnt'] ?? 0); + $reply = "Network status as of last scan: {$o} of {$t} devices online on 10.48.200.0/24, {$userAddr}. " . + ($pve1 ? 'Fresh scan dispatched to PVE1 — the network panel will update in approximately 40 seconds.' : + 'Automatic scan via PVE1 runs every 3 minutes.'); + $source = 'intent:network_scan'; + break; + + case 'jellyfin_pause': + case 'jellyfin_stop': + case 'jellyfin_next': + case 'jellyfin_previous': { + $intentCmd = [ + 'jellyfin_pause' => 'TogglePause', + 'jellyfin_stop' => 'Stop', + 'jellyfin_next' => 'NextTrack', + 'jellyfin_previous' => 'PreviousTrack', + ][$matched['intent']]; + // Get first active session + $jfSessions = @json_decode(@file_get_contents( + JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false, + stream_context_create(['http' => ['timeout' => 4]]) + ), true) ?? []; + $activeSession = null; + foreach ($jfSessions as $s) { + if (isset($s['NowPlayingItem'])) { $activeSession = $s; break; } + } + if (!$activeSession) { + $reply = "Nothing is currently playing on Jellyfin, {$userAddr}."; + } else { + $sid = $activeSession['Id']; + $url = JELLYFIN_URL . '/Sessions/' . rawurlencode($sid) . '/Command/' . $intentCmd + . '?api_key=' . JELLYFIN_API_KEY; + $ctx = stream_context_create(['http' => ['method' => 'POST', 'timeout' => 5, + 'header' => 'Content-Type: application/json', 'content' => '{}', 'ignore_errors' => true]]); + @file_get_contents($url, false, $ctx); + $titles = [ + 'TogglePause' => 'Playback toggled, {$userAddr}.', + 'Stop' => 'Playback stopped, {$userAddr}.', + 'NextTrack' => 'Skipping to next track, {$userAddr}.', + 'PreviousTrack' => 'Going back to previous, {$userAddr}.', + ]; + $reply = strtr($titles[$intentCmd], ['{$userAddr}' => $userAddr]); + } + $source = 'intent:jellyfin_control'; + break; + } + + case 'jellyfin_now_playing': + $jSessions = @json_decode(@file_get_contents( + JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false, + stream_context_create(['http' => ['timeout' => 4]]) + ), true) ?? []; + $active = array_filter($jSessions, fn($s) => isset($s['NowPlayingItem'])); + if (!$active) { + $reply = "Nothing is currently playing on Jellyfin, {$userAddr}."; + } else { + $lines = []; + foreach ($active as $s) { + $np = $s['NowPlayingItem']; + $title = $np['SeriesName'] ? $np['SeriesName'] . ' — ' . $np['Name'] : $np['Name']; + $paused = $s['PlayState']['IsPaused'] ? ' (paused)' : ''; + $lines[] = "{$s['UserName']} is watching {$title}{$paused} on {$s['DeviceName']}"; + } + $reply = implode('. ', $lines) . '.'; + } + $source = 'intent:jellyfin'; + break; + + case 'jellyfin_library': + $jMovies = @json_decode(@file_get_contents(JELLYFIN_URL . '/Items?IncludeItemTypes=Movie&Recursive=true&Limit=0&api_key=' . JELLYFIN_API_KEY, false, stream_context_create(['http' => ['timeout' => 4]])), true); + $jSeries = @json_decode(@file_get_contents(JELLYFIN_URL . '/Items?IncludeItemTypes=Series&Recursive=true&Limit=0&api_key=' . JELLYFIN_API_KEY, false, stream_context_create(['http' => ['timeout' => 4]])), true); + $movies = $jMovies['TotalRecordCount'] ?? 0; + $series = $jSeries['TotalRecordCount'] ?? 0; + $reply = "Jellyfin library: {$movies} movies and {$series} TV series, {$userAddr}."; + $source = 'intent:jellyfin'; + break; + + case 'alerts_show': + $activeAlerts = JarvisDB::query( + "SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10" + ); + if (!$activeAlerts) { + $reply = "No active alerts, {$userAddr}. All systems appear nominal."; + } else { + $lines = array_map(fn($a) => "[{$a['severity']}] {$a['title']}: {$a['message']}", $activeAlerts); + $reply = count($activeAlerts) . " active alert" . (count($activeAlerts)>1?'s':'') . ", {$userAddr}: " . implode('; ', $lines) . '.'; + } + $source = 'intent:alerts_show'; + break; + + case 'alerts_count': + $alertCount = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0"); + $cnt = (int)($alertCount['cnt'] ?? 0); + $reply = $cnt > 0 + ? "There are currently {$cnt} unresolved alert" . ($cnt>1?'s':'') . ", {$userAddr}. Say 'show alerts' for details." + : "No active alerts at this time, {$userAddr}. All systems nominal."; + $source = 'intent:alerts_count'; + break; + + case 'alerts_clear': + $cleared = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0"); + JarvisDB::execute("UPDATE alerts SET resolved=1 WHERE resolved=0"); + $cnt = (int)($cleared['cnt'] ?? 0); + $reply = "Resolved {$cnt} alert" . ($cnt!==1?'s':'') . ", {$userAddr}. Alert panel cleared."; + $source = 'intent:alerts_clear'; + break; + + case 'agents_offline': + $offline = JarvisDB::query( + "SELECT hostname, ip_address, agent_type FROM registered_agents WHERE status='offline' ORDER BY last_seen DESC LIMIT 10" + ); + if (!$offline) { + $reply = "All registered agents are currently online, {$userAddr}."; + } else { + $names = array_map(fn($a) => $a['hostname'] . ' (' . $a['ip_address'] . ')', $offline); + $reply = count($offline) . " agent" . (count($offline)>1?'s are':' is') . " offline, {$userAddr}: " . implode(', ', $names) . '.'; + } + $source = 'intent:agents_offline'; + break; + + case 'agents_all': + $allAgents = JarvisDB::query( + "SELECT hostname, ip_address, status, agent_type FROM registered_agents ORDER BY FIELD(status,'online','offline','unknown'), hostname ASC" + ); + if (!$allAgents) { + $reply = "No registered agents found, {$userAddr}."; + } else { + $onlineList = array_filter($allAgents, fn($a) => $a['status'] === 'online'); + $offlineList = array_filter($allAgents, fn($a) => $a['status'] !== 'online'); + $reply = count($allAgents) . " registered agents — " . count($onlineList) . " online, " . count($offlineList) . " offline, {$userAddr}."; + if ($onlineList) $reply .= ' Online: ' . implode(', ', array_map(fn($a) => $a['hostname'], $onlineList)) . '.'; + if ($offlineList) $reply .= ' Offline: ' . implode(', ', array_map(fn($a) => $a['hostname'], $offlineList)) . '.'; + } + $source = 'intent:agents_all'; + break; + + case 'agents_count': + $agentStats = JarvisDB::single( + "SELECT COUNT(*) total, SUM(status='online') online FROM registered_agents" + ); + $t = (int)($agentStats['total'] ?? 0); + $o = (int)($agentStats['online'] ?? 0); + $reply = "{$t} agents registered — {$o} online, " . ($t-$o) . " offline, {$userAddr}."; + $source = 'intent:agents_count'; + break; + + case 'deploy_status': + $deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log'; + if (file_exists($deployLog)) { + $lines = array_filter(array_map('trim', array_slice(file($deployLog), -20))); + $recent = array_slice(array_values($lines), -5); + $last = end($recent); + $reply = "Last deploy entry, {$userAddr}: " . htmlspecialchars_decode(strip_tags($last)) . '. Say "deploy log" to see the full recent history.'; + } else { + $reply = "Deploy log not found, {$userAddr}. Check /home/jarvis.orbishosting.com/logs/deploy.log on the server."; + } + $source = 'intent:deploy_status'; + break; + + case 'deploy_force': + $reply = "Manual deploy is triggered by pushing to the GitHub main branch, {$userAddr}. The webhook at jarvis.orbishosting.com/webhook.php handles it automatically within 60 seconds. To hot-fix without a push, SCP the file directly to the server."; + $source = 'intent:deploy_force'; + break; + } + } +} + + +// ── Memory injection — fetch relevant facts before LLM tiers ───────────── +$memoryContext = ''; +if (!$reply) { + $memoryContext = getMemoryContext($message, 12); +} + +// ── Tier 2: Ollama local LLM (fast local fallback) ─────────────────────── +if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) { + $ollamaHost = OLLAMA_HOST; + $ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b'; + $timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45; + + $ollamaMessages = []; + $ollamaMessages[] = ['role' => 'system', 'content' => + "You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " . + 'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.']; + $ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message]; + + $promptParts = []; + foreach ($ollamaMessages as $msg) { + $role = ucfirst($msg['role']); + $promptParts[] = "{$role}: {$msg['content']}"; + } + $promptParts[] = 'Assistant:'; + $promptStr = implode("\n\n", $promptParts); + + $payload = [ + 'model' => $ollamaModel, + 'prompt' => $promptStr, + 'stream' => false, + 'options' => ['temperature' => 0.7, 'num_predict' => 150], + ]; + + $ch = curl_init($ollamaHost . '/api/generate'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200 && $resp) { + $decoded = json_decode($resp, true); + $text = $decoded['response'] ?? null; + if ($text) { + $reply = trim($text); + $source = 'ollama:' . $ollamaModel; + } + } + // Silently fall through to Groq if Ollama fails or times out +} + +// ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ───────────── +if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) { + $needsSearch = (bool) preg_match( + '/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i', + $message + ); + $groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL; + + $memSuffix = $memoryContext ? "\n\n{$memoryContext}" : ''; + $groqMessages = [['role' => 'system', 'content' => + "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " . + "(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " . + 'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') . + $memSuffix . '.'], + ]; + foreach (array_slice($history, -6) as $h) { + $groqMessages[] = ['role' => $h['role'], 'content' => $h['content']]; + } + $userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message; + $groqMessages[] = ['role' => 'user', 'content' => $userMsg]; + + if ($stream) { + // ── Streaming SSE path ────────────────────────────────────────── + while (ob_get_level()) ob_end_clean(); + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('X-Accel-Buffering: no'); + header('Connection: keep-alive'); + ob_implicit_flush(true); + + $streamedReply = ''; + $ch = curl_init('https://api.groq.com/openai/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode([ + 'model' => $groqModel, + 'messages' => $groqMessages, + 'max_tokens' => 400, + 'temperature' => 0.7, + 'stream' => true, + ]), + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . GROQ_API_KEY, + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => GROQ_TIMEOUT, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_WRITEFUNCTION => function($ch, $rawData) use (&$streamedReply) { + $lines = explode("\n", $rawData); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line === 'data: [DONE]') continue; + if (!str_starts_with($line, 'data: ')) continue; + $ev = json_decode(substr($line, 6), true); + $tok = $ev['choices'][0]['delta']['content'] ?? null; + if ($tok !== null && $tok !== '') { + echo 'data: ' . json_encode(['type' => 'token', 'token' => $tok]) . "\n\n"; + flush(); + $streamedReply .= $tok; + } + } + return strlen($rawData); + }, + ]); + curl_exec($ch); + curl_close($ch); + + $reply = $streamedReply ? trim($streamedReply) : "Groq AI is temporarily unavailable, {$userAddr}."; + $source = $streamedReply ? 'groq:' . $groqModel : 'fallback'; + + JarvisDB::insert( + 'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)', + [$sessionId, 'assistant', $reply] + ); + KBEngine::learnFromConversation($message, $reply); + + echo 'data: ' . json_encode([ + 'type' => 'complete', + 'reply' => $reply, + 'source' => $source, + 'session_id' => $sessionId, + 'ui_action' => $uiAction ?? null, + 'arc_job' => null, + 'open_network_map' => false, + ]) . "\n\n"; + flush(); + exit; + } + + $ch = curl_init('https://api.groq.com/openai/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode([ + 'model' => $groqModel, + 'messages' => $groqMessages, + 'max_tokens' => 400, + 'temperature' => 0.7, + ]), + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . GROQ_API_KEY, + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => GROQ_TIMEOUT, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200 && $resp) { + $decoded = json_decode($resp, true); + $text = $decoded['choices'][0]['message']['content'] ?? null; + if ($text) { + $reply = trim($text); + $source = 'groq:' . $groqModel; + } + } + // Silently fall through to Claude if Groq fails +} + +// ── Tier 4: Claude API (final fallback) ────────────────────────────────── +if (!$reply) { + // Live context for Claude + $systemContext = ''; + try { + $memLines = file('/proc/meminfo'); + $mem = []; + foreach ($memLines as $l) { + if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; + } + $memPct = $mem['MemTotal'] > 0 + ? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100) + : '?'; + $sec = (int) file_get_contents('/proc/uptime'); + $uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h'; + $load = explode(' ', file_get_contents('/proc/loadavg')); + $systemContext .= "Jarvis server (10.48.200.211 PVE1): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n"; + } catch (Exception $e) {} + + $alerts = JarvisDB::query( + 'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3' + ); + if ($alerts) { + $systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n"; + } + + $kbContext = KBEngine::getContextSummary(); + + $systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision. + +Infrastructure: +- Jarvis Server: 10.48.200.211 (PVE1, nginx/PHP-FPM, Ubuntu 24.04) +- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b) +- Proxmox Host: 10.48.200.90 (manages all VMs) +- Home Assistant: 10.48.200.97:8123 +- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43 +- Digital Ocean: 165.22.1.228 (website hosting — tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com) +- Network: 10.48.200.0/24, FortiGate firewall + +Live data: +{$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') . +($memoryContext ? "\n\n{$memoryContext}" : '') . " +Today: " . date('l, F j Y, g:i A T') . " + +Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers."; + + $messages = []; + foreach ($history as $h) { + $messages[] = ['role' => $h['role'], 'content' => $h['content']]; + } + $messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message]; + + if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') { + $reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands."; + $source = 'fallback:no-key'; + } else { + $payload = [ + 'model' => CLAUDE_MODEL, + 'max_tokens' => CLAUDE_MAX_TOKENS, + 'system' => $systemPrompt, + 'messages' => $messages, + ]; + + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => [ + 'x-api-key: ' . CLAUDE_API_KEY, + 'anthropic-version: 2023-06-01', + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200) { + $decoded = json_decode($resp, true); + $reply = $decoded['content'][0]['text'] ?? null; + $source = 'claude'; + } else { + $err = json_decode($resp, true); + $errMsg = $err['error']['message'] ?? ''; + if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) { + $reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational."; + } else { + $reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg; + } + $source = 'claude:error'; + } + } +} + +// ── Final fallback ───────────────────────────────────────────────────────── +if (!$reply) { + $reply = "My systems are processing your request, {$userAddr}. Please try again momentarily."; + $source = 'fallback'; +} + +// Save reply and learn +JarvisDB::insert( + 'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)', + [$sessionId, 'assistant', $reply] +); +KBEngine::learnFromConversation($message, $reply); + +// Track usage pattern for action intents +if ($source && str_starts_with($source, 'intent:')) { + $intentKey = str_replace('intent:', '', $source); + JarvisDB::query( + "INSERT INTO usage_patterns (intent_name, hour, dow, hit_count) + VALUES (?, HOUR(NOW()), DAYOFWEEK(NOW())-1, 1) + ON DUPLICATE KEY UPDATE hit_count=hit_count+1, last_seen=NOW()", + [$intentKey] + ); +} + +// Memory Core — async extraction for LLM responses (don't extract from intent/KB/fallback) +if ($reply && !in_array(explode(':', $source)[0], ['intent', 'kb', 'fallback', 'memory', 'arc'])) { + memoryExtractAsync($message, $reply, $sessionId); +} + +$uiAction = $uiAction ?? null; +echo json_encode([ + 'reply' => $reply, + 'source' => $source, + 'session_id' => $sessionId, + 'timestamp' => date('c'), + 'arc_job' => $arcJobId, + 'open_network_map' => ($source === 'intent:network_scan'), + 'ui_action' => $uiAction, +]); diff --git a/api/endpoints/directives.php b/api/endpoints/directives.php new file mode 100644 index 0000000..9a75f05 --- /dev/null +++ b/api/endpoints/directives.php @@ -0,0 +1,171 @@ + 0) + ? (float)round($r['kr_current_sum'] / $r['kr_target_sum'] * 100, 1) + : 0; + } + unset($r); + echo json_encode(['directives' => $rows]); + break; + + // GET directives/get?id=X — full directive with key results and links + case 'get': + $id = (int)($_GET['id'] ?? 0); + if (!$id) { echo json_encode(['error' => 'Missing id']); break; } + $d = JarvisDB::single("SELECT * FROM directives WHERE id=?", [$id]); + if (!$d) { echo json_encode(['error' => 'Not found']); break; } + $krs = JarvisDB::query("SELECT * FROM directive_key_results WHERE directive_id=? ORDER BY id ASC", [$id]) ?: []; + $links = JarvisDB::query( + "SELECT dl.*, t.title AS task_title, a.title AS appt_title + FROM directive_links dl + LEFT JOIN tasks t ON dl.link_type='task' AND t.id=dl.link_id + LEFT JOIN appointments a ON dl.link_type='appointment' AND a.id=dl.link_id + WHERE dl.directive_id=? + ORDER BY dl.created_at DESC", + [$id] + ) ?: []; + $sum_cur = array_sum(array_column($krs, 'current_value')); + $sum_tgt = array_sum(array_column($krs, 'target_value')); + $d['progress'] = ($sum_tgt > 0) ? round($sum_cur / $sum_tgt * 100, 1) : 0; + $d['key_results'] = $krs; + $d['links'] = $links; + echo json_encode($d); + break; + + // POST directives/save — create or update directive + key results + case 'save': + $id = (int)($data['id'] ?? 0); + $title = trim($data['title'] ?? ''); + $description = trim($data['description'] ?? ''); + $category = $data['category'] ?? 'work'; + $status = $data['status'] ?? 'active'; + $priority = (int)($data['priority'] ?? 5); + $target_date = !empty($data['target_date']) ? $data['target_date'] : null; + if (!$title) { echo json_encode(['error' => 'Title required']); break; } + if ($id) { + JarvisDB::execute( + "UPDATE directives SET title=?,description=?,category=?,status=?,priority=?,target_date=?,updated_at=NOW() WHERE id=?", + [$title,$description,$category,$status,$priority,$target_date,$id] + ); + } else { + $id = JarvisDB::insert( + "INSERT INTO directives (title,description,category,status,priority,target_date) VALUES (?,?,?,?,?,?)", + [$title,$description,$category,$status,$priority,$target_date] + ); + } + // Replace key results if provided + if (isset($data['key_results']) && is_array($data['key_results'])) { + JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]); + foreach ($data['key_results'] as $kr) { + $krtitle = trim($kr['title'] ?? ''); if (!$krtitle) continue; + JarvisDB::execute( + "INSERT INTO directive_key_results (directive_id,title,current_value,target_value,unit) VALUES (?,?,?,?,?)", + [$id, $krtitle, (float)($kr['current_value']??0), (float)($kr['target_value']??100), $kr['unit']??'%'] + ); + } + } + echo json_encode(['ok' => true, 'id' => $id]); + break; + + // POST directives/delete?id=X + case 'delete': + $id = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$id) { echo json_encode(['error' => 'Missing id']); break; } + JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]); + JarvisDB::execute("DELETE FROM directive_links WHERE directive_id=?", [$id]); + JarvisDB::execute("DELETE FROM directives WHERE id=?", [$id]); + echo json_encode(['ok' => true]); + break; + + // POST directives/key_result_update — update a single KR's current value + case 'key_result_update': + $krid = (int)($data['id'] ?? 0); + $value = (float)($data['current_value'] ?? 0); + if (!$krid) { echo json_encode(['error' => 'Missing kr id']); break; } + JarvisDB::execute( + "UPDATE directive_key_results SET current_value=?, updated_at=NOW() WHERE id=?", + [$value, $krid] + ); + echo json_encode(['ok' => true]); + break; + + // POST directives/link — link a task or appointment to a directive + case 'link': + $did = (int)($data['directive_id'] ?? 0); + $link_type = $data['link_type'] ?? 'task'; + $link_id = (int)($data['link_id'] ?? 0); + $note = trim($data['note'] ?? ''); + if (!$did) { echo json_encode(['error' => 'Missing directive_id']); break; } + JarvisDB::execute( + "INSERT INTO directive_links (directive_id,link_type,link_id,note) VALUES (?,?,?,?)", + [$did, $link_type, $link_id ?: null, $note] + ); + echo json_encode(['ok' => true]); + break; + + // POST directives/unlink?id=X — remove a link + case 'unlink': + $lid = (int)($_GET['id'] ?? $data['id'] ?? 0); + if (!$lid) { echo json_encode(['error' => 'Missing id']); break; } + JarvisDB::execute("DELETE FROM directive_links WHERE id=?", [$lid]); + echo json_encode(['ok' => true]); + break; + + // GET directives/summary — compact progress snapshot for chat/briefing injection + case 'summary': + $rows = JarvisDB::query( + "SELECT d.id, d.title, d.category, d.target_date, + COALESCE(SUM(kr.current_value),0) AS kr_cur, + COALESCE(SUM(kr.target_value),1) AS kr_tgt + FROM directives d + LEFT JOIN directive_key_results kr ON kr.directive_id=d.id + WHERE d.status='active' + GROUP BY d.id + ORDER BY d.priority DESC, d.target_date ASC + LIMIT 10" + ) ?: []; + $summary = []; + foreach ($rows as $r) { + $pct = round($r['kr_cur'] / max($r['kr_tgt'], 1) * 100, 0); + $summary[] = [ + 'id' => $r['id'], + 'title' => $r['title'], + 'category' => $r['category'], + 'target_date' => $r['target_date'], + 'progress' => $pct, + ]; + } + echo json_encode(['directives' => $summary]); + break; + + default: + http_response_code(404); + echo json_encode(['error' => "Unknown directives action: {$action}"]); +} diff --git a/api/endpoints/do_server.php b/api/endpoints/do_server.php new file mode 100644 index 0000000..f5ce718 --- /dev/null +++ b/api/endpoints/do_server.php @@ -0,0 +1,94 @@ + 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0; +} + +$memLines = []; +foreach (file("/proc/meminfo") as $l) { + [$k, $v] = explode(":", $l, 2) + [null, null]; + if ($k) $memLines[trim($k)] = (int)trim($v); +} +$memTotal = $memLines["MemTotal"] ?? 0; +$memFree = $memLines["MemAvailable"] ?? 0; +$memUsed = $memTotal - $memFree; + +$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0]; +$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0]; + +$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? ""; +$diskPct = trim($dfOut); + +// Services +$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"]; +$svcMap = []; +foreach ($svcNames as $s) { + $status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? ""); + if ($status === "active") $svcMap[$s] = true; +} + +// Site health from kb_facts +$siteLabels = [ + "jarvis" => "jarvis.orbishosting.com", + "tomsjavajive" => "tomsjavajive.com", + "epictravelexp"=> "epictravelexpeditions.com", + "parkersling" => "parkerslingshotrentals.com", + "orbishosting" => "orbishosting.com", + "orbisportal" => "orbis.orbishosting.com", + "tomtomgames" => "tomtomgames.com", +]; +$sites = []; +$rows = JarvisDB::query( + "SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' AND updated_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE) ORDER BY fact_key" +); +foreach ($rows as $r) { + $label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"]; + $sites[$label] = $r["fact_value"]; +} + +$uptimeDays = intdiv($uptime, 86400); +$uptimeHrs = intdiv($uptime % 86400, 3600); + + +// DO server agent metrics (jarvis-do agent reporting via Tailscale) +$doAgent = JarvisDB::query( + "SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1" +); +$doMet = []; +if (!empty($doAgent[0]['metric_data'])) { + $dm = json_decode($doAgent[0]['metric_data'], true) ?? []; + $doMet = [ + "cpu" => $dm['cpu_percent'] ?? 0, + "mem" => $dm['memory']['percent'] ?? 0, + "disk" => (int)($dm['disk'][0]['percent'] ?? 0), + "uptime" => $dm['uptime']['human'] ?? "--", + "online" => true, + ]; +} +echo json_encode([ + "ip" => "10.48.200.211", // JARVIS VM (PVE1) + "reachable" => true, + "cpu_pct" => getCpuPct(), + "memory" => [ + "total_mb" => round($memTotal / 1024), + "used_mb" => round($memUsed / 1024), + "percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0, + ], + "disk_used_pct" => $diskPct, + "load_1m" => $load, + "uptime" => "{$uptimeDays}d {$uptimeHrs}h", + "services" => $svcMap, + "sites" => $sites, + "do_server" => $doMet, + "timestamp" => date("c"), +]); diff --git a/api/endpoints/email.php b/api/endpoints/email.php new file mode 100644 index 0000000..6bababc --- /dev/null +++ b/api/endpoints/email.php @@ -0,0 +1,238 @@ + 'not_configured']; + $mbox = @imap_open($host, $user, $pass, 0, 1, ['DISABLE_AUTHENTICATOR' => 'GSSAPI']); + if (!$mbox) return ['error' => imap_last_error() ?: 'connection_failed']; + $total = imap_num_msg($mbox); + $unseen = imap_search($mbox, 'UNSEEN') ?: []; + $unread = count($unseen); + $msgs = []; + for ($i = $total; $i >= max(1, $total - $maxMsgs + 1); $i--) { + $hdr = @imap_headerinfo($mbox, $i); + if (!$hdr) continue; + $from = $hdr->from[0] ?? null; + $fromName = $from && isset($from->personal) ? imap_utf8($from->personal) : ''; + $fromEmail = $from ? ($from->mailbox . '@' . ($from->host ?? '')) : ''; + $subject = isset($hdr->subject) ? imap_utf8($hdr->subject) : '(no subject)'; + $date = isset($hdr->date) ? date('M j g:ia', strtotime($hdr->date)) : ''; + $dateRaw = isset($hdr->date) ? date('Y-m-d H:i:s', strtotime($hdr->date)) : null; + $isUnread = in_array($i, $unseen); + $msgId = md5(($hdr->message_id ?? '') ?: ($fromEmail . $subject . $date)); + // Preview + $preview = ''; + $struct = @imap_fetchstructure($mbox, $i); + if ($struct) { + if ($struct->type === 0) { + $raw = @imap_fetchbody($mbox, $i, '1'); + $enc = $struct->encoding ?? 0; + if ($enc === 3) $raw = base64_decode($raw); + elseif ($enc === 4) $raw = quoted_printable_decode($raw); + $preview = mb_substr(strip_tags($raw), 0, 400); + } else { + foreach (($struct->parts ?? []) as $idx => $part) { + if ($part->type === 0) { + $raw = @imap_fetchbody($mbox, $i, (string)($idx + 1)); + $enc = $part->encoding ?? 0; + if ($enc === 3) $raw = base64_decode($raw); + elseif ($enc === 4) $raw = quoted_printable_decode($raw); + $preview = mb_substr(strip_tags($raw), 0, 400); + break; + } + } + } + } + $preview = trim(preg_replace('/\s+/', ' ', $preview)); + $msgs[] = [ + 'id' => $i, + 'msg_id' => $msgId, + 'from_name' => $fromName ?: $fromEmail, + 'from_email' => $fromEmail, + 'subject' => $subject, + 'date' => $date, + 'date_raw' => $dateRaw, + 'unread' => $isUnread, + 'preview' => $preview, + ]; + } + imap_close($mbox); + return ['total' => $total, 'unread' => $unread, 'messages' => $msgs]; +} + +// ── Action item detection ───────────────────────────────────────────────────── +function detectActionItem(string $subject, string $preview): array { + $text = strtolower($subject . ' ' . mb_substr($preview, 0, 500)); + $apptKw = ['meeting','zoom call','teams call','video call','conference call','join us','calendar invite', + 'appointment','interview','webinar','you are invited','let\'s meet','lets meet', + 'scheduled for','scheduled at','set up a call','book a call','at \d{1,2}(:\d{2})?\s*(am|pm)']; + $taskKw = ['action required','action needed','please review','please respond','please confirm', + 'please send','please provide','please complete','please sign','please approve', + 'follow up','follow-up','reminder:','deadline','due by','due date', + 'asap','urgent','your attention','needs your','waiting for you', + 'can you','could you','would you please','i need you to','kindly']; + $apptScore = 0; + $taskScore = 0; + foreach ($apptKw as $kw) { + if (@preg_match('/' . $kw . '/i', $text)) { $apptScore += 25; break; } + } + foreach ($taskKw as $kw) { + if (strpos($text, $kw) !== false) { $taskScore += 20; break; } + } + // Bonus: explicit deadline date in subject + if (preg_match('/\b(by\s+)?(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{1,2}\/\d{1,2})\b/i', $subject)) { + $taskScore += 15; + } + // Suggested date extraction + $sugDate = null; + if (preg_match('/\b(tomorrow|today|next\s+\w+|this\s+(monday|tuesday|wednesday|thursday|friday)|monday|tuesday|wednesday|thursday|friday|\d{1,2}[\/\-]\d{1,2}(?:[\/\-]\d{2,4})?)\b/i', $subject . ' ' . mb_substr($preview, 0, 200), $dm)) { + $ts = strtotime($dm[0]); + if ($ts !== false && $ts > time() - 86400) $sugDate = date('Y-m-d', $ts); + } + $type = null; + if ($apptScore >= 25 && $apptScore >= $taskScore) $type = 'appointment'; + elseif ($taskScore >= 20) $type = 'task'; + return ['type' => $type, 'suggested_date' => $sugDate]; +} + +// ── Upsert email actions ────────────────────────────────────────────────────── +function storeEmailActions(string $account, array $messages): void { + foreach ($messages as $msg) { + $ai = detectActionItem($msg['subject'], $msg['preview'] ?? ''); + if (!$ai['type']) continue; + JarvisDB::execute( + "INSERT INTO email_actions (account,message_uid,from_email,from_name,subject,preview,received_at,action_type,suggested_title,suggested_date) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE action_type=VALUES(action_type), suggested_title=VALUES(suggested_title), + suggested_date=VALUES(suggested_date), from_name=VALUES(from_name), + preview=VALUES(preview), received_at=VALUES(received_at)", + [$account, $msg['msg_id'], $msg['from_email'], $msg['from_name'], $msg['subject'], + mb_substr($msg['preview'] ?? '', 0, 500), $msg['date_raw'], + $ai['type'], mb_substr($msg['subject'], 0, 255), $ai['suggested_date']] + ); + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── +$actionType = $data['action'] ?? $_GET['action'] ?? ''; + +// ── Create task from email action ───────────────────────────────────────────── +if ($method === 'POST' && $actionType === 'create_task') { + $id = (int)($data['id'] ?? 0); + if (!$id) { echo json_encode(['error' => 'Missing id']); exit; } + $ea = JarvisDB::single('SELECT * FROM email_actions WHERE id=?', [$id]); + if (!$ea) { echo json_encode(['error' => 'Not found']); exit; } + $title = trim($data['title'] ?? $ea['suggested_title']); + $due_date = trim($data['due_date'] ?? $ea['suggested_date'] ?? ''); + $notes = "From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}"; + $taskId = JarvisDB::insert( + 'INSERT INTO tasks (title,notes,category,priority,due_date) VALUES (?,?,?,?,?)', + [$title, $notes, 'work', 'normal', $due_date ?: null] + ); + JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $id]); + echo json_encode(['success' => true, 'task_id' => $taskId]); + exit; +} + +// ── Create appointment from email action ────────────────────────────────────── +if ($method === 'POST' && $actionType === 'create_appt') { + $id = (int)($data['id'] ?? 0); + if (!$id) { echo json_encode(['error' => 'Missing id']); exit; } + $ea = JarvisDB::single('SELECT * FROM email_actions WHERE id=?', [$id]); + if (!$ea) { echo json_encode(['error' => 'Not found']); exit; } + $title = trim($data['title'] ?? $ea['suggested_title']); + $start_at = trim($data['start_at'] ?? ''); + if (!$start_at && $ea['suggested_date']) $start_at = $ea['suggested_date'] . ' 09:00:00'; + if (!$start_at) $start_at = date('Y-m-d') . ' 09:00:00'; + $desc = "From: {$ea['from_name']} <{$ea['from_email']}>"; + $apptId = JarvisDB::insert( + 'INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)', + [$title, $desc, 'work', $start_at] + ); + JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $id]); + echo json_encode(['success' => true, 'appointment_id' => $apptId]); + exit; +} + +// ── Dismiss email action ────────────────────────────────────────────────────── +if ($method === 'POST' && $actionType === 'dismiss') { + $id = (int)($data['id'] ?? 0); + if ($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?', [$id]); + echo json_encode(['success' => true]); + exit; +} + +// ── List detected action items ──────────────────────────────────────────────── +if ($actionType === 'action_items') { + $rows = JarvisDB::query( + "SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL + ORDER BY received_at DESC LIMIT 50" + ) ?? []; + echo json_encode(['action_items' => $rows, 'count' => count($rows)]); + exit; +} + +// ── Inbox fetch (main) ──────────────────────────────────────────────────────── +$account = $data['account'] ?? $_GET['account'] ?? 'all'; +$force = !empty($data['force']) || !empty($_GET['force']); +$cacheKey = 'email_' . $account; +$cacheTtl = 300; + +if (!$force) { + $cached = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key=?", [$cacheKey]); + if ($cached && (time() - (int)$cached['ts']) < $cacheTtl) { + $out = json_decode($cached['data'], true); + $out['cache_age_s'] = time() - (int)$cached['ts']; + // Merge unactioned email_actions count + $out['action_items_count'] = (int)(JarvisDB::single("SELECT COUNT(*) c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL")['c'] ?? 0); + echo json_encode($out); + exit; + } +} + +$result = ['accounts' => []]; + +$accounts_to_fetch = []; +if (in_array($account, ['all', 'gmail']) && defined('GMAIL_USER') && GMAIL_USER) $accounts_to_fetch['gmail'] = ['{imap.gmail.com:993/imap/ssl}INBOX', GMAIL_USER, GMAIL_PASS]; +if (in_array($account, ['all', 'outlook']) && defined('OUTLOOK_USER') && OUTLOOK_USER) $accounts_to_fetch['outlook'] = ['{outlook.office365.com:993/imap/ssl}INBOX', OUTLOOK_USER, OUTLOOK_PASS]; +if (in_array($account, ['all', 'icloud']) && defined('ICLOUD_USER') && ICLOUD_USER) $accounts_to_fetch['icloud'] = ['{imap.mail.me.com:993/imap/ssl}INBOX', ICLOUD_USER, ICLOUD_PASS]; + +foreach ($accounts_to_fetch as $acct => [$host, $user, $pass]) { + $r = imapFetch($host, $user, $pass, 15); + $result['accounts'][$acct] = $r; + if (!empty($r['messages'])) storeEmailActions($acct, $r['messages']); +} + +// Build summary +$totalUnread = 0; +$allMessages = []; +foreach ($result['accounts'] as $acct => $r) { + if (isset($r['unread'])) $totalUnread += (int)$r['unread']; + foreach ($r['messages'] ?? [] as $m) { + $m['account'] = $acct; + $allMessages[] = $m; + } +} +// Sort by date descending +usort($allMessages, fn($a,$b) => strtotime($b['date_raw']??'0') - strtotime($a['date_raw']??'0')); + +$result['summary'] = [ + 'total_unread' => $totalUnread, + 'recent' => array_slice($allMessages, 0, 15), + 'fetched_at' => date('c'), +]; +$result['action_items_count'] = (int)(JarvisDB::single("SELECT COUNT(*) c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL")['c'] ?? 0); + +JarvisDB::execute( + "INSERT INTO api_cache (cache_key,data,updated_at) VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE data=VALUES(data),updated_at=NOW()", + [$cacheKey, json_encode($result)] +); +$result['cache_age_s'] = 0; +echo json_encode($result); diff --git a/api/endpoints/facts_collector.php b/api/endpoints/facts_collector.php new file mode 100644 index 0000000..9542a03 --- /dev/null +++ b/api/endpoints/facts_collector.php @@ -0,0 +1,256 @@ + DATE_SUB(NOW(), INTERVAL ? SECOND)) AS is_fresh FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1', + [$secs, $cat] + ); + if (empty($row)) return false; + return (bool) $row[0]['is_fresh']; + }; + + + // ── System ──────────────────────────────────────────────────────────── + try { + $stat1 = file_get_contents('/proc/stat'); + usleep(200000); + $stat2 = file_get_contents('/proc/stat'); + + $cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d"); + $cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d"); + $dIdle = $cpu2[3] - $cpu1[3]; + $dTotal = array_sum($cpu2) - array_sum($cpu1); + $cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0; + KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl); + + $memLines = file('/proc/meminfo'); + $mem = []; + foreach ($memLines as $l) { + if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; + } + $total = round($mem['MemTotal'] / 1048576, 1); + $avail = round($mem['MemAvailable'] / 1048576, 1); + $used = round($total - $avail, 1); + $free = round($mem['MemFree'] / 1048576, 1); + $memPct = $total > 0 ? round($used / $total * 100) : 0; + KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl); + KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl); + KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl); + KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl); + + $la = explode(' ', file_get_contents('/proc/loadavg')); + KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl); + KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl); + KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl); + + $sec = (int) file_get_contents('/proc/uptime'); + KBEngine::storeFact('system', 'uptime', + intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours', + 'local', $ttl); + + $df = disk_free_space('/'); + $dt = disk_total_space('/'); + KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl); + KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl); + KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl); + + $results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)"; + } catch (Exception $e) { + $results['system'] = 'error: ' . $e->getMessage(); + } + + // ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ── + try { + $rows = JarvisDB::query( + "SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)" + ); + $online = count(array_filter($rows, fn($r) => $r['status'] === 'online')); + $total = count($rows); + KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl); + KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl); + KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl); + $results['network'] = "ok ({$online}/{$total} online)"; + } catch (Exception $e) { + $results['network'] = 'error: ' . $e->getMessage(); + } + + // ── Proxmox (TTL 10 min) ───────────────────────────────────────────── + if ($fresh('proxmox', 600)) { + $results['proxmox'] = 'skipped (fresh)'; + } else try { + if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) { + $base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json'; + $auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL; + + $nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth); + $vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth); + $cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth); + + if (isset($nd['data'])) { + $cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1); + $memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1); + $memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1); + $memPct = $memT > 0 ? round($memU / $memT * 100) : 0; + KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl); + } + $all = array_merge($vms['data'] ?? [], $cts['data'] ?? []); + $running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running')); + KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl); + $results['proxmox'] = "ok ({$running}/" . count($all) . " running)"; + } else { + $results['proxmox'] = 'skipped (no token)'; + } + } catch (Exception $e) { + $results['proxmox'] = 'error: ' . $e->getMessage(); + } + + // ── Home Assistant — skipped (HA agent pushes entities every 30s) ──── + $results['ha'] = 'skipped (agent push active)'; + + + // ── Digital Ocean ───────────────────────────────────────────────────── + try { + exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);; + $doStatus = ($doCode === 0) ? 'online' : 'unreachable'; + KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl); + $results['do_server'] = "ok ({$doStatus})"; + } catch (Exception $e) { + $results['do_server'] = 'error: ' . $e->getMessage(); + } + + // ── Ollama (TTL 15 min) ─────────────────────────────────────────────── + if ($fresh('ollama', 900)) { + $results['ollama'] = 'skipped (fresh)'; + } else try { + $ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434'; + $ch = curl_init($ollamaHost . '/api/tags'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200) { + $models = json_decode($resp, true)['models'] ?? []; + $names = array_column($models, 'name'); + KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null); + KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl); + KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl); + foreach ($models as $m) { + JarvisDB::execute( + 'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?) + ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()', + [$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)] + ); + } + $results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')'; + } else { + KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl); + $results['ollama'] = 'unreachable (VM may be booting)'; + } + } catch (Exception $e) { + $results['ollama'] = 'error: ' . $e->getMessage(); + } + + // ── Site Health (TTL 5 min) ─────────────────────────────────────────── + if ($fresh('sites', 300)) { + $results['sites'] = 'skipped (fresh)'; + } else try { + $sites = [ + "jarvis" => "http://127.0.0.1", + 'tomsjavajive' => 'https://tomsjavajive.com', + 'epictravelexp'=> 'https://epictravelexpeditions.com', + 'parkerslingshotrentals' => 'https://parkerslingshotrentals.com', + 'orbishosting' => 'https://orbishosting.com', + 'orbisportal' => 'https://orbis.orbishosting.com', + 'tomtomgames' => 'https://tomtomgames.com', + ]; + $down = []; + foreach ($sites as $key => $url) { + $parsed = parse_url($url); + $host = $parsed['host'] ?? $url; + // Check sites on the local server directly to avoid Cloudflare CDN timeouts. + // All JARVIS-hosted sites are served from this same OLS instance. + $localUrl = $url; // external check + $ch = curl_init($localUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_NOBODY => true, + ]); + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + $status = ($code >= 200 && $code < 400) ? 'up' : "down-$code"; + KBEngine::storeFact('sites', $key, $status, $url, 180); + if ($status !== 'up') $down[] = "$key($code)"; + } + $results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down); + } catch (Exception $e) { + $results['sites'] = 'error: ' . $e->getMessage(); + } + + + // Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh) + // which POSTs nmap results to /api/netscan every 3 minutes. + $results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)'; + + return $results; +} + +function pve_api_get(string $url, string $authHeader): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_HTTPHEADER => [$authHeader], + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_TIMEOUT => 5, + ]); + $resp = curl_exec($ch); + curl_close($ch); + return $resp ? (json_decode($resp, true) ?? []) : []; +} + +// ── Entry point ─────────────────────────────────────────────────────────── +$results = collect_all(); +if ($isCLI) { + echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n"; + foreach ($results as $k => $v) { + echo " {$k}: {$v}\n"; + } +} else { + echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]); +} diff --git a/api/endpoints/ha.php b/api/endpoints/ha.php new file mode 100644 index 0000000..89c7637 --- /dev/null +++ b/api/endpoints/ha.php @@ -0,0 +1,141 @@ + true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 8, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_SSL_VERIFYPEER => false, + ]); + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + } + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300 && $resp) { + return json_decode($resp, true); + } + return null; +} + +$configured = !(HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false); + +if (!$configured) { + echo json_encode(['configured' => false, 'message' => 'HA token not configured.', 'entities' => []]); + exit; +} + +// Scene list +if ($method === 'GET' && $action === 'scenes') { + $states = haRequest('/states') ?? []; + $scenes = []; + foreach ($states as $s) { + if (str_starts_with($s['entity_id'] ?? '', 'scene.')) { + $scenes[] = [ + 'entity_id' => $s['entity_id'], + 'name' => $s['attributes']['friendly_name'] ?? $s['entity_id'], + ]; + } + } + echo json_encode(['scenes' => $scenes]); + exit; +} + +// Scene activate +if ($method === 'POST' && $action === 'scene_activate') { + $entity_id = $data['entity_id'] ?? ''; + if ($entity_id && str_starts_with($entity_id, 'scene.')) { + $result = haRequest('/services/scene/turn_on', 'POST', ['entity_id' => $entity_id]); + echo json_encode(['success' => true, 'entity_id' => $entity_id]); + } else { + echo json_encode(['error' => 'Invalid scene entity_id']); + } + exit; +} + +// Live service call (toggle device) — always direct to HA, never cached +if ($method === 'POST' && $action === 'service') { + $domain = $data['domain'] ?? ''; + $service = $data['service'] ?? ''; + $entity_id = $data['entity_id'] ?? ''; + if ($domain && $service && $entity_id) { + $result = haRequest("/services/{$domain}/{$service}", 'POST', ['entity_id' => $entity_id]); + echo json_encode(['success' => true, 'result' => $result]); + } else { + echo json_encode(['error' => 'Missing domain/service/entity_id']); + } + exit; +} + +// Serve entities from ha_entities table (real-time agent push data) +$skipDomains = ['sensor','binary_sensor','button','update','select','number', + 'device_tracker','event','image','person','zone','tts','conversation', + 'assist_satellite','input_button','media_player','scene','water_heater', + 'alarm_control_panel','automation','script','calendar','notify','weather','camera','siren','remote','todo','lawn_mower']; +$skipKeywords = [ + // HACS / system toggles + 'pre_release','get_hacs','matter_server','zerotier','mariadb', + 'spotify_connect','file_editor','ssh_web','uptime_kuma','adguard_', + 'folding_home','music_assistant','mealie','mosquitto','social_to', + 'assist_microphone','cec_scanner','esphome_device_builder', + // Camera controls + '_record','_ftp_','_push_','_hub_ringtone','_siren_on', + '_email_on','_manual_record','_infrared_','motion_detection', + 'front_yard_record','down_hill_record','camera1_record', + 'back_yard_record','nvr_', + // Echo / smart display noise + 'do_not_disturb', + // Konnected security panel switches + 'floodlight', + 'konnected', + // Energy / power monitoring (sensors, not controls) + '_energy','_power','_voltage','_current','_consumption', + 'electricity_maps', +]; + +$rows = JarvisDB::query( + "SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts + FROM ha_entities + WHERE state NOT IN ('unavailable','unknown') + ORDER BY domain, entity_name" +) ?? []; + +$grouped = []; +$latestTs = 0; +foreach ($rows as $e) { + $dom = $e['domain']; + if (in_array($dom, $skipDomains)) continue; + $skip = false; + if ($dom === 'switch') { + foreach ($skipKeywords as $kw) { + if (strpos($e['entity_id'], $kw) !== false) { $skip = true; break; } + } + } + if ($skip) continue; + if ((int)$e['updated_ts'] > $latestTs) $latestTs = (int)$e['updated_ts']; + $grouped[$dom][] = [ + 'entity_id' => $e['entity_id'], + 'name' => $e['entity_name'], + 'state' => $e['state'], + ]; +} + +echo json_encode([ + 'configured' => true, + 'entities' => $grouped, + 'cache_age_s' => $latestTs > 0 ? (int)(time() - $latestTs) : -1, + 'cached_at' => $latestTs > 0 ? date('c', $latestTs) : null, +]); diff --git a/api/endpoints/history.php b/api/endpoints/history.php new file mode 100644 index 0000000..a710072 --- /dev/null +++ b/api/endpoints/history.php @@ -0,0 +1,21 @@ + [], 'error' => 'Query too short']); + exit; +} + +$rows = JarvisDB::query( + "SELECT role, content, created_at FROM conversations + WHERE content LIKE ? ORDER BY created_at DESC LIMIT 25", + ['%' . $q . '%'] +) ?? []; + +echo json_encode(['results' => $rows, 'total' => count($rows)]); diff --git a/api/endpoints/jellyfin.php b/api/endpoints/jellyfin.php new file mode 100644 index 0000000..b3002d7 --- /dev/null +++ b/api/endpoints/jellyfin.php @@ -0,0 +1,109 @@ + ['timeout' => 5, 'ignore_errors' => true]]); + $raw = @file_get_contents($url, false, $ctx); + return $raw ? (json_decode($raw, true) ?? []) : []; +} + +switch ($action) { + case 'sessions': + $sessions = jf_get('/Sessions'); + $active = array_filter($sessions, fn($s) => isset($s['NowPlayingItem'])); + $out = []; + foreach ($active as $s) { + $np = $s['NowPlayingItem']; + $pos = $s['PlayState']['PositionTicks'] ?? 0; + $dur = $np['RunTimeTicks'] ?? 0; + $out[] = [ + 'session_id' => $s['Id'], + 'user' => $s['UserName'] ?? 'Unknown', + 'device' => $s['DeviceName'] ?? '', + 'client' => $s['Client'] ?? '', + 'title' => $np['Name'] ?? '', + 'type' => $np['Type'] ?? '', + 'series' => $np['SeriesName'] ?? null, + 'year' => $np['ProductionYear'] ?? null, + 'paused' => $s['PlayState']['IsPaused'] ?? false, + 'position_pct'=> $dur > 0 ? round($pos / $dur * 100) : 0, + ]; + } + echo json_encode(['sessions' => array_values($out), 'total_active' => count($out)]); + break; + + case 'library': + $movies = jf_get('/Items?IncludeItemTypes=Movie&Recursive=true&Limit=0'); + $series = jf_get('/Items?IncludeItemTypes=Series&Recursive=true&Limit=0'); + $episodes= jf_get('/Items?IncludeItemTypes=Episode&Recursive=true&Limit=0'); + echo json_encode([ + 'movies' => $movies['TotalRecordCount'] ?? 0, + 'series' => $series['TotalRecordCount'] ?? 0, + 'episodes' => $episodes['TotalRecordCount'] ?? 0, + ]); + break; + + case 'search': + $q = trim($_GET['q'] ?? ''); + if (!$q) { echo json_encode(['results' => []]); break; } + $data = jf_get('/Search/Hints?SearchTerm=' . urlencode($q) . '&Limit=8&IncludeItemTypes=Movie,Series,Episode'); + $hints = $data['SearchHints'] ?? []; + $results = array_map(fn($h) => [ + 'id' => $h['ItemId'], + 'name' => $h['Name'], + 'type' => $h['Type'], + 'year' => $h['ProductionYear'] ?? null, + 'series'=> $h['Series'] ?? null, + ], $hints); + echo json_encode(['results' => $results]); + break; + + case 'recent': + $data = jf_get('/Items/Latest?Limit=8&IncludeItemTypes=Movie,Episode&Fields=Overview'); + $out = array_map(fn($i) => [ + 'name' => $i['Name'], + 'type' => $i['Type'], + 'series' => $i['SeriesName'] ?? null, + 'year' => $i['ProductionYear'] ?? null, + ], is_array($data) ? $data : []); + echo json_encode(['recent' => $out]); + break; + + case 'control': + $jfSessionId = $_GET['session_id'] ?? ''; + $jfCommand = $_GET['command'] ?? ''; + // General commands supported by Jellyfin session control + $allowed = ['TogglePause', 'Stop', 'NextTrack', 'PreviousTrack', 'VolumeUp', 'VolumeDown']; + if (!$jfSessionId || !in_array($jfCommand, $allowed, true)) { + // No session_id: get the first active session automatically + if (!$jfSessionId && in_array($jfCommand, $allowed, true)) { + $sessions = jf_get('/Sessions'); + foreach ($sessions as $s) { + if (isset($s['NowPlayingItem'])) { $jfSessionId = $s['Id']; break; } + } + } + if (!$jfSessionId) { echo json_encode(['error' => 'No active session or invalid command']); break; } + } + $url = JELLYFIN_URL . '/Sessions/' . rawurlencode($jfSessionId) . '/Command/' . $jfCommand + . '?api_key=' . JELLYFIN_API_KEY; + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'timeout' => 5, + 'header' => 'Content-Type: application/json', + 'content' => '{}', + 'ignore_errors' => true, + ]]); + @file_get_contents($url, false, $ctx); + echo json_encode(['success' => true, 'command' => $jfCommand, 'session_id' => $jfSessionId]); + break; + + default: + echo json_encode(['error' => 'Unknown action']); +} diff --git a/api/endpoints/kb_intent_generator.php b/api/endpoints/kb_intent_generator.php new file mode 100644 index 0000000..4012e76 --- /dev/null +++ b/api/endpoints/kb_intent_generator.php @@ -0,0 +1,329 @@ + 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 (2–5 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.'); diff --git a/api/endpoints/kb_intent_generator.php.bak2 b/api/endpoints/kb_intent_generator.php.bak2 new file mode 100644 index 0000000..b2f1aa6 --- /dev/null +++ b/api/endpoints/kb_intent_generator.php.bak2 @@ -0,0 +1,284 @@ + 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 (2–5 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.'); diff --git a/api/endpoints/kb_intent_generator.php.bak3-broken-2026-07-07 b/api/endpoints/kb_intent_generator.php.bak3-broken-2026-07-07 new file mode 100644 index 0000000..9fb18db --- /dev/null +++ b/api/endpoints/kb_intent_generator.php.bak3-broken-2026-07-07 @@ -0,0 +1,270 @@ + 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.'); diff --git a/api/endpoints/memory.php b/api/endpoints/memory.php new file mode 100644 index 0000000..614d6c9 --- /dev/null +++ b/api/endpoints/memory.php @@ -0,0 +1,81 @@ +true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + return json_decode($raw, true) ?: []; +} + +function memArcPost(string $path, array $body): array { + $ch = curl_init('http://127.0.0.1:7474' . $path); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>5, + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + return json_decode($raw, true) ?: []; +} + +function memArcDelete(string $path): array { + $ch = curl_init('http://127.0.0.1:7474' . $path); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_CUSTOMREQUEST=>'DELETE', CURLOPT_TIMEOUT=>5, + ]); + $raw = curl_exec($ch); curl_close($ch); + return json_decode($raw, true) ?: ['ok' => true]; +} + +switch ($action) { + case 'list': + $limit = min((int)($_GET['limit'] ?? 100), 500); + $category = $_GET['category'] ?? ''; + $search = $_GET['search'] ?? ''; + $qs = http_build_query(array_filter(['limit'=>$limit,'category'=>$category,'search'=>$search])); + echo json_encode(memArcGet('/memory/facts' . ($qs ? '?'.$qs : ''))); + break; + + case 'stats': + echo json_encode(memArcGet('/memory/stats')); + break; + + case 'context': + $msg = $_GET['message'] ?? ''; + $limit = (int)($_GET['limit'] ?? 12); + $qs = http_build_query(['message'=>$msg,'limit'=>$limit]); + echo json_encode(memArcGet('/memory/context?' . $qs)); + break; + + case 'store': + $subject = trim($data['subject'] ?? ''); + $predicate = trim($data['predicate'] ?? 'is'); + $object = trim($data['object'] ?? ''); + $category = $data['category'] ?? 'fact'; + if (!$subject || !$object) { http_response_code(400); echo json_encode(['error'=>'subject and object required']); break; } + echo json_encode(memArcPost('/memory/facts', ['subject'=>$subject,'predicate'=>$predicate,'object'=>$object,'category'=>$category])); + break; + + case 'delete': + $id = (int)($_GET['id'] ?? 0); + if (!$id) { http_response_code(400); echo json_encode(['error'=>'Missing id']); break; } + echo json_encode(memArcDelete('/memory/facts/' . $id)); + break; + + case 'clear': + $category = $_GET['category'] ?? ''; + $qs = $category ? '?category=' . urlencode($category) : ''; + echo json_encode(memArcDelete('/memory/facts' . $qs)); + break; + + default: + http_response_code(404); + echo json_encode(['error' => 'Unknown memory action: ' . $action]); +} diff --git a/api/endpoints/metrics.php b/api/endpoints/metrics.php new file mode 100644 index 0000000..fbbb97b --- /dev/null +++ b/api/endpoints/metrics.php @@ -0,0 +1,49 @@ + DATE_SUB(NOW(), INTERVAL ? HOUR)", + [$hours] + ) ?? []; + + $out = []; + foreach ($agents as $a) { + $rows = JarvisDB::query( + "SELECT CAST(JSON_EXTRACT(metric_data, '$.cpu_percent') AS DECIMAL(5,1)) as cpu, + CAST(JSON_EXTRACT(metric_data, '$.memory.percent') AS DECIMAL(5,1)) as mem, + recorded_at + FROM agent_metrics + WHERE agent_id = ? AND recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY recorded_at ASC", + [$a['agent_id'], $hours] + ) ?? []; + if ($rows) { + $out[$a['agent_id']] = array_map(fn($r) => [ + 'cpu' => (float)($r['cpu'] ?? 0), + 'mem' => (float)($r['mem'] ?? 0), + ], $rows); + } + } + echo json_encode($out); +} else { + $rows = JarvisDB::query( + "SELECT CAST(JSON_EXTRACT(metric_data, '$.cpu_percent') AS DECIMAL(5,1)) as cpu, + CAST(JSON_EXTRACT(metric_data, '$.memory.percent') AS DECIMAL(5,1)) as mem, + recorded_at + FROM agent_metrics + WHERE agent_id = ? AND recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY recorded_at ASC", + [$agentId, $hours] + ) ?? []; + echo json_encode(array_map(fn($r) => [ + 'cpu' => (float)($r['cpu'] ?? 0), + 'mem' => (float)($r['mem'] ?? 0), + ], $rows)); +} diff --git a/api/endpoints/netscan.php b/api/endpoints/netscan.php new file mode 100644 index 0000000..cc92221 --- /dev/null +++ b/api/endpoints/netscan.php @@ -0,0 +1,84 @@ + 'POST only']); exit; +} + +$reqKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ''; +if ($reqKey !== NETSCAN_KEY) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); exit; +} + +$body = file_get_contents('php://input'); +$payload = json_decode($body, true); +$devices = $payload['devices'] ?? []; + +if (empty($devices)) { + echo json_encode(['error' => 'No devices in payload']); exit; +} + +$discoveredIPs = []; +$upserted = 0; +foreach ($devices as $d) { + $ip = trim($d['ip'] ?? ''); + $mac = trim($d['mac'] ?? ''); + $hostname = trim($d['hostname'] ?? ''); + $vendor = trim($d['vendor'] ?? ''); + // Respect explicit status from probe (e.g. phone probe knows if device is offline) + // Fall back to "online" for nmap results which only report reachable hosts + $status = in_array($d['status'] ?? '', ['online','offline']) ? $d['status'] : 'online'; + if (!$ip) continue; + + $discoveredIPs[] = $ip; + JarvisDB::execute( + 'INSERT INTO network_devices (ip, mac, hostname, status, last_seen) + VALUES (?,?,?,?,NOW()) + ON DUPLICATE KEY UPDATE + mac = COALESCE(NULLIF(VALUES(mac),""), mac), + hostname = COALESCE(NULLIF(VALUES(hostname),""), hostname), + status = VALUES(status), + last_seen = NOW()', + [$ip, $mac ?: null, $hostname ?: $vendor ?: null, $status] + ); + if ($vendor) { + JarvisDB::execute( + 'UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', + [$vendor, $ip] + ); + } + // Store SIP registration status in kb_facts if provided (VoIP probe) + $sipStatus = trim($d['sip_status'] ?? ''); + $extension = trim($d['extension'] ?? ''); + if ($sipStatus && $extension && $extension !== 'none') { + JarvisDB::execute( + "INSERT INTO kb_facts (category, fact_key, fact_value, host) + VALUES ('voip', ?, ?, ?) + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()", + ["ext_{$extension}_sip", $sipStatus, $ip] + ); + JarvisDB::execute( + "INSERT INTO kb_facts (category, fact_key, fact_value, host) + VALUES ('voip', ?, ?, ?) + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()", + ["ext_{$extension}_ip", $ip, $ip] + ); + } + $upserted++; +} + +// Mark anything NOT in this scan as offline if stale > 10 min +if (!empty($discoveredIPs)) { + $ph = implode(',', array_fill(0, count($discoveredIPs), '?')); + JarvisDB::execute( + "UPDATE network_devices SET status='offline' + WHERE ip NOT IN ($ph) AND last_seen < DATE_SUB(NOW(), INTERVAL 10 MINUTE)", + $discoveredIPs + ); +} + +echo json_encode(['ok' => true, 'upserted' => $upserted, 'total_discovered' => count($discoveredIPs)]); diff --git a/api/endpoints/network.php b/api/endpoints/network.php new file mode 100644 index 0000000..63dc96a --- /dev/null +++ b/api/endpoints/network.php @@ -0,0 +1,188 @@ +/dev/null'; + $out = shell_exec($cmd); + $alive = $out && strpos($out, '1 received') !== false; + $latency = null; + if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) { + $latency = (float)$m[1]; + } + return ['alive' => $alive, 'latency_ms' => $latency]; +} + +function scanSubnet(string $prefix, int $timeout = 10): array { + $cmd = 'nmap -sn --host-timeout 1s ' . escapeshellarg($prefix . '.0/24') . + ' -oG - 2>/dev/null | grep "Up$" | awk \'{print $2}\''; + $out = shell_exec($cmd) ?? ''; + $hosts = array_filter(explode("\n", trim($out))); + return array_values($hosts); +} + +function getArpTable(): array { + $out = shell_exec('arp -n 2>/dev/null') ?? ''; + $devices = []; + foreach (explode("\n", trim($out)) as $line) { + if (preg_match('/^([\d.]+)\s+\w+\s+([\w:]+)/', $line, $m)) { + $devices[$m[1]] = strtolower($m[2]); + } + } + return $devices; +} + +$action = $action ?? 'status'; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +if ($action === 'scan') { + // JARVIS runs on DigitalOcean — cannot reach 10.48.200.x directly. + // Scan is delegated to the PVE1 agent (jarvis-netscan.sh runs nmap locally). + // This endpoint: queues the scan command to PVE1, returns current DB state immediately. + + // Queue netscan to PVE1 agent + $pve1 = JarvisDB::single( + "SELECT agent_id FROM registered_agents WHERE ip_address='10.48.200.90' AND status='online' LIMIT 1" + ); + $queued = false; + if ($pve1) { + JarvisDB::execute( + "INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)", + [$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending'] + ); + $queued = true; + } + + // Return current online devices from DB (populated by PVE1 netscan every 3 min) + $devices = JarvisDB::query( + "SELECT ip, mac, hostname, alias, device_type as type, status, last_seen + FROM network_devices WHERE status='online' AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE) + ORDER BY COALESCE(alias,hostname,ip)" + ); + + echo json_encode([ + 'devices' => $devices, + 'count' => count($devices), + 'queued' => $queued, + 'scanned_at' => date('c'), + 'note' => $queued + ? 'Scan dispatched to PVE1 — results update in ~40 seconds.' + : 'Returning cached scan data. PVE1 auto-scans every 3 minutes.', + ]); + +} elseif ($action === 'add' && $method === 'POST') { + $ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP); + $alias = substr(trim($data['alias'] ?? ''), 0, 100); + $type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($data['type'] ?? 'device')); + if (!$ip) { echo json_encode(['error' => 'Invalid IP address']); exit; } + if (!$alias) { echo json_encode(['error' => 'Name is required']); exit; } + JarvisDB::execute( + 'INSERT INTO network_devices (ip, alias, device_type, status) VALUES (?,?,?,\'unknown\') + ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)', + [$ip, $alias, $type] + ); + echo json_encode(['success' => true]); + +} elseif ($action === 'delete' && $method === 'POST') { + $ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP); + if (!$ip) { echo json_encode(['error' => 'Invalid IP']); exit; } + // Don't allow deleting agent-managed entries + $isAgent = JarvisDB::query('SELECT id FROM registered_agents WHERE ip_address=? LIMIT 1', [$ip]); + if (!empty($isAgent)) { echo json_encode(['error' => 'Cannot delete agent-managed device']); exit; } + JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$ip]); + echo json_encode(['success' => true]); + +} else { + // Status: unified device list from agents + user-managed DB entries + external services + $devices = []; + + // Mark agents offline if not heard from in 2 minutes + JarvisDB::execute( + 'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"' + ); + + // 1. Agent-based devices — status from heartbeat, no ping from DO needed + $agents = JarvisDB::query( + 'SELECT agent_id, hostname, ip_address, status, last_seen, agent_type FROM registered_agents ORDER BY hostname' + ); + $agentIPs = []; + foreach ($agents as $ag) { + $agentIPs[] = $ag['ip_address']; + $devices[] = [ + 'ip' => $ag['ip_address'], + 'name' => $ag['hostname'], + 'type' => 'agent', + 'agent_id' => $ag['agent_id'], + 'agent_type' => $ag['agent_type'], + 'alive' => $ag['status'] === 'online', + 'status' => $ag['status'], + 'last_seen' => $ag['last_seen'], + 'source' => 'agent', + 'deletable' => false, + ]; + } + + // 2. User-managed devices from DB (named/aliased entries not covered by agents) + $pinned = JarvisDB::query( + 'SELECT ip, alias, device_type, status, last_seen FROM network_devices + WHERE alias IS NOT NULL AND alias != "" ORDER BY alias' + ); + foreach ($pinned as $dev) { + if (in_array($dev['ip'], $agentIPs)) continue; // agent already covers this IP + $ping = pingHost($dev['ip']); + $newStatus = $ping['alive'] ? 'online' : 'offline'; + JarvisDB::execute( + 'UPDATE network_devices SET status=?, last_seen=NOW() WHERE ip=?', + [$newStatus, $dev['ip']] + ); + $devices[] = [ + 'ip' => $dev['ip'], + 'name' => $dev['alias'], + 'type' => $dev['device_type'] ?: 'device', + 'alive' => $ping['alive'], + 'latency_ms' => $ping['latency_ms'], + 'status' => $newStatus, + 'last_seen' => $dev['last_seen'], + 'source' => 'db', + 'deletable' => true, + ]; + } + + // 3. Netscan-discovered devices (PVE1 nmap push — status from last scan) + $discovered = JarvisDB::query( + 'SELECT ip, mac, hostname, device_type, status, last_seen FROM network_devices + WHERE (alias IS NULL OR alias = "") AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE) + ORDER BY ip' + ); + foreach ($discovered as $dev) { + if (in_array($dev['ip'], $agentIPs)) continue; + $devices[] = [ + 'ip' => $dev['ip'], + 'name' => $dev['hostname'] ?: ($dev['device_type'] ?: $dev['ip']), + 'mac' => $dev['mac'], + 'type' => $dev['device_type'] ?: 'device', + 'alive' => $dev['status'] === 'online', + 'status' => $dev['status'], + 'last_seen' => $dev['last_seen'], + 'source' => 'netscan', + 'deletable' => false, + ]; + } + + // 4. External services we can actually ping from DO + $external = [ + ['ip' => '134.209.72.226', 'name' => 'FusionPBX DO', 'type' => 'server'], + ]; + foreach ($external as $host) { + if (in_array($host['ip'], $agentIPs)) continue; + $ping = pingHost($host['ip']); + $devices[] = array_merge($host, [ + 'alive' => $ping['alive'], + 'latency_ms' => $ping['latency_ms'], + 'status' => $ping['alive'] ? 'online' : 'offline', + 'source' => 'static', + 'deletable' => false, + ]); + } + + echo json_encode(['devices' => $devices, 'timestamp' => date('c')]); +} diff --git a/api/endpoints/news.php b/api/endpoints/news.php new file mode 100644 index 0000000..b2fb3bd --- /dev/null +++ b/api/endpoints/news.php @@ -0,0 +1,37 @@ + [], + 'total' => 0, + 'cache_age_s' => -1, + 'message' => 'News feed warming up — available within 5 minutes.', + ]; +} + +// Prepend custom/pinned news items added via admin portal +$custom = JarvisDB::query( + "SELECT fact_key as title, fact_value as url, updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC" +); +if (!empty($custom)) { + $pinned = array_map(fn($r) => [ + 'title' => $r['title'], + 'url' => $r['url'] ?: null, + 'source' => 'JARVIS', + 'published' => $r['updated_at'], + 'pinned' => true, + ], $custom); + // Insert pinned as first category + $out['categories'] = array_merge(['pinned' => $pinned], $out['categories'] ?? []); +} + +echo json_encode($out); diff --git a/api/endpoints/planner.php b/api/endpoints/planner.php new file mode 100644 index 0000000..ab3aea6 --- /dev/null +++ b/api/endpoints/planner.php @@ -0,0 +1,159 @@ + 0) ? date('Y-m-d', $ts) : null; +} +function parseNaturalDatetime(string $text): ?string { + $text = trim($text); + if (!$text) return null; + $ts = strtotime($text); + return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : null; +} + +// ── Route ───────────────────────────────────────────────────────────────────── +// $action from api.php: tasks | appointments | today | done | summary + +switch ($action) { + + // ── Tasks ───────────────────────────────────────────────────────────────── + case 'tasks': + if ($method === 'GET') { + $status = $_GET['status'] ?? ''; + $category = $_GET['category'] ?? ''; + $where = '1=1'; + $params = []; + if ($status) { $where .= ' AND status=?'; $params[] = $status; } + if ($category) { $where .= ' AND category=?'; $params[] = $category; } + else { $where .= " AND status NOT IN ('done','cancelled')"; } + $rows = JarvisDB::query( + "SELECT * FROM tasks WHERE {$where} ORDER BY + FIELD(priority,'urgent','high','normal','low'), due_date ASC, created_at DESC", + $params + ) ?? []; + echo json_encode(['tasks' => $rows]); + } elseif ($method === 'POST') { + $id = (int)($data['id'] ?? 0); + $title = trim($data['title'] ?? ''); + $notes = trim($data['notes'] ?? ''); + $category = $data['category'] ?? 'personal'; + $priority = $data['priority'] ?? 'normal'; + $status = $data['status'] ?? 'pending'; + $due_date = parseNaturalDate($data['due_date'] ?? ''); + $due_time = !empty($data['due_time']) ? $data['due_time'] : null; + if (!$title) { echo json_encode(['error' => 'Title required']); exit; } + if ($id) { + JarvisDB::execute( + 'UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?', + [$title,$notes,$category,$priority,$status,$due_date,$due_time,$id] + ); + echo json_encode(['success' => true, 'id' => $id]); + } else { + $newId = JarvisDB::insert( + 'INSERT INTO tasks (title,notes,category,priority,due_date,due_time) VALUES (?,?,?,?,?,?)', + [$title,$notes,$category,$priority,$due_date,$due_time] + ); + echo json_encode(['success' => true, 'id' => $newId]); + } + } elseif ($method === 'DELETE') { + $id = (int)($_GET['id'] ?? 0); + if ($id) { JarvisDB::execute('DELETE FROM tasks WHERE id=?', [$id]); } + echo json_encode(['success' => true]); + } + break; + + // ── Mark task done ──────────────────────────────────────────────────────── + case 'done': + $id = (int)($data['id'] ?? $_GET['id'] ?? 0); + if ($id) { + JarvisDB::execute( + "UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$id] + ); + } + echo json_encode(['success' => true]); + break; + + // ── Appointments ────────────────────────────────────────────────────────── + case 'appointments': + if ($method === 'GET') { + $from = $_GET['from'] ?? date('Y-m-d'); + $to = $_GET['to'] ?? date('Y-m-d', strtotime('+90 days')); + $rows = JarvisDB::query( + "SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC", + [$from, $to] + ) ?? []; + echo json_encode(['appointments' => $rows]); + } elseif ($method === 'POST') { + $id = (int)($data['id'] ?? 0); + $title = trim($data['title'] ?? ''); + $description = trim($data['description'] ?? ''); + $category = $data['category'] ?? 'personal'; + $location = trim($data['location'] ?? ''); + $all_day = (int)($data['all_day'] ?? 0); + $reminder = (int)($data['reminder_min'] ?? 30); + $start_raw = trim($data['start_at'] ?? ''); + $end_raw = trim($data['end_at'] ?? ''); + if (!$title || !$start_raw) { echo json_encode(['error' => 'Title and start time required']); exit; } + $start_at = parseNaturalDatetime($start_raw) ?? date('Y-m-d H:i:s', strtotime($start_raw)); + $end_at = $end_raw ? (parseNaturalDatetime($end_raw) ?? null) : null; + if ($id) { + JarvisDB::execute( + 'UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?', + [$title,$description,$category,$start_at,$end_at,$location,$all_day,$reminder,$id] + ); + echo json_encode(['success' => true, 'id' => $id]); + } else { + $newId = JarvisDB::insert( + 'INSERT INTO appointments (title,description,category,start_at,end_at,location,all_day,reminder_min) VALUES (?,?,?,?,?,?,?,?)', + [$title,$description,$category,$start_at,$end_at,$location,$all_day,$reminder] + ); + echo json_encode(['success' => true, 'id' => $newId]); + } + } elseif ($method === 'DELETE') { + $id = (int)($_GET['id'] ?? 0); + if ($id) { JarvisDB::execute('DELETE FROM appointments WHERE id=?', [$id]); } + echo json_encode(['success' => true]); + } + break; + + // ── Today / briefing summary ────────────────────────────────────────────── + case 'today': + default: + $today = date('Y-m-d'); + $tomorrow = date('Y-m-d', strtotime('+1 day')); + + $tasks_today = JarvisDB::query( + "SELECT * FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')", + [$today] + ) ?? []; + $tasks_overdue = JarvisDB::query( + "SELECT * FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC", + [$today] + ) ?? []; + $tasks_pending = JarvisDB::query( + "SELECT COUNT(*) cnt FROM tasks WHERE status='pending' AND (due_date IS NULL OR due_date >= ?)", + [$today] + ); + $appts_today = JarvisDB::query( + "SELECT * FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC", + [$today] + ) ?? []; + $appts_upcoming = JarvisDB::query( + "SELECT * FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 5", + [] + ) ?? []; + + echo json_encode([ + 'date' => $today, + 'tasks_today' => $tasks_today, + 'tasks_overdue' => $tasks_overdue, + 'pending_count' => (int)($tasks_pending[0]['cnt'] ?? 0), + 'appts_today' => $appts_today, + 'appts_upcoming' => $appts_upcoming, + ]); + break; +} diff --git a/api/endpoints/proxmox.php b/api/endpoints/proxmox.php new file mode 100644 index 0000000..f95e289 --- /dev/null +++ b/api/endpoints/proxmox.php @@ -0,0 +1,41 @@ + false, + 'message' => 'Proxmox API token not yet configured.', + 'vms' => [], 'nodes' => [], + ]); + exit; +} + +// Serve from cache (refreshed by stats_cache.php cron every 5 min) +$cached = JarvisDB::query( + 'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1', + ['proxmox'] +); + +if ($cached && !empty($cached[0]['data'])) { + $row = $cached[0]; + $data = json_decode($row['data'], true); + // Add cache age to response + $data['cache_age_s'] = (int)(time() - (int)$row['updated_ts']); + echo json_encode($data); +} else { + // Cache empty — return placeholder so UI shows something useful + echo json_encode([ + 'configured' => true, + 'node' => PROXMOX_NODE, + 'node_status' => null, + 'vms' => [], + 'containers' => [], + 'vm_count' => 0, + 'ct_count' => 0, + 'cached_at' => null, + 'cache_age_s' => -1, + 'message' => 'Cache warming up — first update in under 5 minutes.', + ]); +} diff --git a/api/endpoints/sites.php b/api/endpoints/sites.php new file mode 100644 index 0000000..62fc249 --- /dev/null +++ b/api/endpoints/sites.php @@ -0,0 +1,176 @@ + [ + 'name' => "Tom's Java Jive", + 'url' => 'https://tomsjavajive.com', + // Also sync to TJJ's own settings table + 'sync_db' => ['host'=>'localhost','name'=>'toms_tjj_db','user'=>'toms_tjj_user','pass'=>'+60wlPc+55e@gFq4'], + 'sync_keys' => ['api_key'=>'cybermail_api_key','from_email'=>'cybermail_from_email','from_name'=>'cybermail_from_name','admin_email'=>'smtp_admin_email'], + ], + 'tomtomgames' => [ + 'name' => 'TomTomGames', + 'url' => 'https://tomtomgames.com', + // Also try to push to file + 'file' => '/home/tomtomgames.com/includes/config.php', + 'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'SMTP_FROM','from_name'=>'SMTP_FROM_NAME','admin_email'=>'ADMIN_EMAIL'], + ], + 'epictravelexpeditions' => [ + 'name' => 'Epic Travel Expeditions', + 'url' => 'https://epictravelexpeditions.com', + 'file' => '/home/epictravelexpeditions.com/public_html/api/config.php', + 'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'MAIL_FROM','from_name'=>'MAIL_FROM_NAME','admin_email'=>'ADMIN_EMAIL'], + ], + 'parkerslingshot' => [ + 'name' => 'Parker Slingshot', + 'url' => 'https://parkerslingshot.epictravelexpeditions.com', + 'file' => '/home/epictravelexpeditions.com/parkerslingshot/db.php', + 'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'MAIL_FROM','from_name'=>'MAIL_FROM_NAME','admin_email'=>'ADMIN_EMAIL'], + ], + 'parkerslingshotrentals' => [ + 'name' => 'Parker Slingshot Rentals', + 'url' => 'https://parkerslingshotrentals.com', + 'file' => '/home/parkerslingshotrentals.com/public_html/db.php', + 'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'MAIL_FROM','from_name'=>'MAIL_FROM_NAME','admin_email'=>'ADMIN_EMAIL'], + ], +]; + +// ── Primary storage: jarvis_db site_settings table ────────────────── +function ssGet(string $siteId, string $field): string { + $row = JarvisDB::single( + 'SELECT setting_value FROM site_settings WHERE site_id=? AND setting_key=?', + [$siteId, $field] + ); + return $row['setting_value'] ?? ''; +} + +function ssSet(string $siteId, string $field, string $value): bool { + JarvisDB::execute( + 'INSERT INTO site_settings (site_id, setting_key, setting_value) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value), updated_at=NOW()', + [$siteId, $field, $value] + ); + return true; +} + +// ── Secondary sync: TJJ own DB ─────────────────────────────────────── +function tjjSync(array $syncDb, array $syncKeys, string $field, string $value): void { + $key = $syncKeys[$field] ?? null; + if (!$key) return; + try { + $pdo = new PDO("mysql:host={$syncDb['host']};dbname={$syncDb['name']};charset=utf8mb4", + $syncDb['user'], $syncDb['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $exists = $pdo->prepare('SELECT id FROM settings WHERE setting_key=?'); + $exists->execute([$key]); + if ($exists->fetch()) { + $pdo->prepare('UPDATE settings SET setting_value=? WHERE setting_key=?') + ->execute([json_encode($value), $key]); + } else { + $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)') + ->execute([$key, json_encode($value)]); + } + } catch (Exception $e) { /* best-effort */ } +} + +// ── File push: best-effort write to config file ────────────────────── +function filePush(string $file, string $constant, string $value): void { + if (!file_exists($file) || !is_writable($file)) return; + $content = file_get_contents($file); + $safe = str_replace("'", "\\'", $value); + $new = preg_replace( + "/define\s*\(\s*['\"]" . preg_quote($constant, '/') . "['\"],\s*['\"][^'\"]*['\"](\s*)\)/", + "define('" . $constant . "', '" . $safe . "'$1)", + $content + ); + if ($new && $new !== $content) { + file_put_contents($file, $new); + } +} + +// ── Initial population: seed jarvis_db from TJJ DB on first access ── +function seedFromTjjDb(string $siteId, array $syncDb, array $syncKeys): void { + foreach ($syncKeys as $field => $key) { + if (ssGet($siteId, $field) !== '') continue; // already have it + try { + $pdo = new PDO("mysql:host={$syncDb['host']};dbname={$syncDb['name']};charset=utf8mb4", + $syncDb['user'], $syncDb['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $s = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key=?'); + $s->execute([$key]); + $row = $s->fetch(PDO::FETCH_ASSOC); + if ($row) { + $v = json_decode($row['setting_value'], true) ?? $row['setting_value']; + ssSet($siteId, $field, $v); + } + } catch (Exception $e) { /* best-effort */ } + } +} + +// ── Router ─────────────────────────────────────────────────────────── +if ($method === 'GET') { + // Seed TJJ from its own DB if not yet in jarvis_db + if (isset($SITES['tomsjavajive']['sync_db'])) { + seedFromTjjDb('tomsjavajive', $SITES['tomsjavajive']['sync_db'], $SITES['tomsjavajive']['sync_keys']); + } + + $result = []; + foreach ($SITES as $id => $site) { + $result[$id] = [ + 'name' => $site['name'], + 'url' => $site['url'], + 'api_key' => ssGet($id, 'api_key'), + 'from_email' => ssGet($id, 'from_email'), + 'from_name' => ssGet($id, 'from_name'), + 'admin_email' => ssGet($id, 'admin_email'), + ]; + } + echo json_encode(['success' => true, 'sites' => $result]); + exit; +} + +if ($method === 'POST') { + $action = $data['action'] ?? ''; + $siteId = $data['site'] ?? ''; + + if ($action === 'push_key') { + $apiKey = trim($data['api_key'] ?? ''); + if (!$apiKey) { echo json_encode(['success'=>false,'error'=>'API key required']); exit; } + $results = []; + foreach ($SITES as $id => $site) { + ssSet($id, 'api_key', $apiKey); + if (isset($site['sync_db'])) tjjSync($site['sync_db'], $site['sync_keys'], 'api_key', $apiKey); + if (isset($site['file'])) filePush($site['file'], $site['file_keys']['api_key'] ?? '', $apiKey); + $results[$id] = true; + } + echo json_encode(['success'=>true,'results'=>$results]); + exit; + } + + if ($action === 'save' && $siteId && isset($SITES[$siteId])) { + $site = $SITES[$siteId]; + $fields = ['api_key','from_email','from_name','admin_email']; + foreach ($fields as $field) { + if (!isset($data[$field])) continue; + $value = trim($data[$field]); + // 1. Always save to jarvis_db + ssSet($siteId, $field, $value); + // 2. Sync to TJJ own DB if applicable + if (isset($site['sync_db'])) tjjSync($site['sync_db'], $site['sync_keys'], $field, $value); + // 3. Push to file if accessible + if (isset($site['file']) && isset($site['file_keys'][$field])) + filePush($site['file'], $site['file_keys'][$field], $value); + } + echo json_encode(['success'=>true]); + exit; + } + + echo json_encode(['success'=>false,'error'=>'Unknown action']); + exit; +} + +echo json_encode(['success'=>false,'error'=>'Method not allowed']); diff --git a/api/endpoints/stats_cache.php b/api/endpoints/stats_cache.php new file mode 100644 index 0000000..416aee4 --- /dev/null +++ b/api/endpoints/stats_cache.php @@ -0,0 +1,347 @@ + true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + ]); + $out = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + if ($err) { echo "[cache] curl error: $err\n"; return null; } + return ($code >= 200 && $code < 300) ? $out : null; +} + +function cacheStore(string $key, $data): void { + $json = is_string($data) ? $data : json_encode($data); + JarvisDB::execute( + 'INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW()) + ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()', + [$key, $json] + ); +} + +// ── Proxmox ────────────────────────────────────────────────────────────── +if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') { + $pveBase = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json'; + $pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL]; + + // Cluster resources API — returns all VMs/CTs from ALL nodes (pve + pve2) + $clusterRaw = curlGet("$pveBase/cluster/resources?type=vm", $pveAuth); + $nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth); + $nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null; + + $allResources = $clusterRaw ? (json_decode($clusterRaw, true)['data'] ?? []) : []; + + function fmtUptime(int $sec): string { + $d = intdiv($sec, 86400); $h = intdiv($sec % 86400, 3600); $m = intdiv($sec % 3600, 60); + return ($d > 0 ? "{$d}d " : '') . "{$h}h {$m}m"; + } + + function fmtBytes(int $b): string { + if ($b >= 1073741824) return round($b/1073741824, 1) . ' GB'; + if ($b >= 1048576) return round($b/1048576, 1) . ' MB'; + return $b . ' B'; + } + + $vmDetails = []; $lxcDetails = []; + foreach ($allResources as $r) { + $memPct = ($r['maxmem'] ?? 0) > 0 ? round($r['mem'] / $r['maxmem'] * 100, 1) : 0; + $diskGb = round(($r['maxdisk'] ?? 0) / 1073741824, 1); + $cpuPct = round(($r['cpu'] ?? 0) * 100, 1); + $upSec = (int)($r['uptime'] ?? 0); + $entry = [ + 'vmid' => $r['vmid'], + 'name' => $r['name'] ?? ($r['type'] === 'lxc' ? 'CT-' : 'VM-') . $r['vmid'], + 'node' => $r['node'] ?? 'pve', + 'type' => $r['type'] ?? 'qemu', + 'status' => $r['status'] ?? 'unknown', + 'cpu_pct' => $cpuPct, + 'cpus' => $r['maxcpu'] ?? 1, + 'mem_pct' => $memPct, + 'mem_used_mb' => round(($r['mem'] ?? 0) / 1048576), + 'mem_total_mb' => round(($r['maxmem'] ?? 0) / 1048576), + 'disk_gb' => $diskGb, + 'uptime_s' => $upSec, + 'uptime_human' => $upSec > 0 ? fmtUptime($upSec) : '—', + 'netin' => $r['netin'] ?? 0, + 'netout' => $r['netout'] ?? 0, + 'netin_fmt' => fmtBytes((int)($r['netin'] ?? 0)), + 'netout_fmt' => fmtBytes((int)($r['netout'] ?? 0)), + ]; + if ($r['type'] === 'lxc') $lxcDetails[] = $entry; + else $vmDetails[] = $entry; + } + + // Sort by node then vmid + usort($vmDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']); + usort($lxcDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']); + + // Node summary for both nodes + $nodeInfo = []; + if ($nodeStatus) { + $ns = $nodeStatus; + $nodeInfo['pve'] = [ + 'cpu_pct' => round(($ns['cpu'] ?? 0) * 100, 1), + 'mem_pct' => ($ns['memory']['total'] ?? 0) > 0 + ? round($ns['memory']['used'] / $ns['memory']['total'] * 100, 1) : 0, + 'mem_used_gb' => round(($ns['memory']['used'] ?? 0) / 1073741824, 1), + 'mem_total_gb' => round(($ns['memory']['total'] ?? 0) / 1073741824, 1), + 'uptime' => fmtUptime((int)($ns['uptime'] ?? 0)), + 'disk_used_gb' => round(($ns['rootfs']['used'] ?? 0) / 1073741824, 1), + 'disk_total_gb'=> round(($ns['rootfs']['total'] ?? 0) / 1073741824, 1), + ]; + } + + cacheStore('proxmox', [ + 'configured' => true, + 'node' => PROXMOX_NODE, + 'node_status' => $nodeStatus, + 'node_info' => $nodeInfo, + 'vms' => $vmDetails, + 'containers' => $lxcDetails, + 'vm_count' => count($vmDetails), + 'ct_count' => count($lxcDetails), + 'cached_at' => date('c'), + ]); + echo '[cache] Proxmox: ' . count($vmDetails) . ' VMs, ' . count($lxcDetails) . " CTs cached (both nodes)\n"; +} + +// ── Home Assistant ──────────────────────────────────────────────────────── +if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false) { + $haHeaders = [ + 'Authorization: Bearer ' . HA_TOKEN, + 'Content-Type: application/json', + ]; + + $statesRaw = curlGet(HA_URL . '/api/states', $haHeaders, 15); + $configRaw = curlGet(HA_URL . '/api/config', $haHeaders, 8); + + $states = $statesRaw ? json_decode($statesRaw, true) : []; + $config = $configRaw ? json_decode($configRaw, true) : []; + + // Controllable domains only — skip read-only sensors to keep list manageable + $interesting = ['light','switch','alarm_control_panel', + 'lawn_mower','fan','lock','cover','climate','input_boolean']; + // Switches that are HA internals / camera settings, not physical devices + $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', + '_siren_on','_email_on','_manual_record','_infrared_', + 'do_not_disturb','matter_server','zerotier','mariadb', + 'spotify_connect','file_editor','ssh_web','uptime_kuma', + 'folding_home','music_assistant','get_hacs','mealie', + 'mosquitto','social_to','esphome_device','motion_detection', + 'front_yard_record','down_hill_record','camera1_record', + 'back_yard_record','nvr_','assist_microphone','cec_scanner', + 'kiosker','hacs_pre','adguard']; + $grouped = []; + foreach (($states ?? []) as $entity) { + $domain = explode('.', $entity['entity_id'])[0]; + if (!in_array($domain, $interesting)) continue; + if ($domain === 'switch') { + $skip = false; + foreach ($skipKeywords as $kw) { + if (strpos($entity['entity_id'], $kw) !== false) { $skip = true; break; } + } + if ($skip) continue; + } + if (!isset($grouped[$domain])) $grouped[$domain] = []; + $grouped[$domain][] = [ + 'entity_id' => $entity['entity_id'], + 'name' => $entity['attributes']['friendly_name'] ?? $entity['entity_id'], + 'state' => $entity['state'], + 'last_changed' => $entity['last_changed'] ?? null, + ]; + } + + cacheStore('ha_entities', [ + 'configured' => true, + 'ha_version' => $config['version'] ?? 'unknown', + 'location' => $config['location_name'] ?? 'Home', + 'entity_count' => count($states ?? []), + 'entities' => $grouped, + 'cached_at' => date('c'), + ]); + $total = array_sum(array_map('count', $grouped)); + echo "[cache] HA: $total entities across " . count($grouped) . " domains cached\n"; +} + +// ── Weather (wttr.in — refresh every 30 min) ────────────────────────────── +$weatherRow = JarvisDB::query( + "SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='weather' LIMIT 1" +); +$weatherAge = $weatherRow ? (time() - (int)$weatherRow[0]['ts']) : PHP_INT_MAX; + +if ($weatherAge > 1800) { + // wttr.in code → icon mapping + $wttrIcon = function(int $code): string { + if ($code === 113) return 'SUNNY'; + if ($code === 116) return 'PARTLY CLOUDY'; + if (in_array($code, [119,122])) return 'CLOUDY'; + if (in_array($code, [143,248,260])) return 'FOGGY'; + if (in_array($code, [176,263,266,293,296])) return 'LIGHT RAIN'; + if (in_array($code, [299,302,305,308,353,356,359])) return 'RAIN'; + if (in_array($code, [317,320,362,365])) return 'SLEET'; + if (in_array($code, [323,326,329,332,335,338,368,371])) return 'SNOW'; + if (in_array($code, [386,389,392,395,200])) return 'STORMS'; + if (in_array($code, [281,284,311,314])) return 'FREEZING RAIN'; + return 'MIXED'; + }; + $wttrEmoji = function(int $code): string { + if ($code === 113) return 'Sunny'; + if ($code === 116) return 'Partly Cloudy'; + if (in_array($code, [119,122])) return 'Cloudy'; + if (in_array($code, [143,248,260])) return 'Foggy'; + if (in_array($code, [176,263,266,293,296])) return 'Light Rain'; + if (in_array($code, [299,302,305,308,353,356,359])) return 'Rain'; + if (in_array($code, [317,320,362,365])) return 'Sleet'; + if (in_array($code, [323,326,329,332,335,338,368,371])) return 'Snow'; + if (in_array($code, [386,389,392,395,200])) return 'Thunderstorm'; + return 'Mixed'; + }; + + $weatherRaw = curlGet( + 'https://wttr.in/FortWorth,TX?format=j1', + ['User-Agent: curl/7.88 Jarvis/1.0'], + 15 + ); + + if ($weatherRaw) { + $w = json_decode($weatherRaw, true); + $cu = $w['current_condition'][0] ?? []; + $days = $w['weather'] ?? []; + + $curCode = (int)($cu['weatherCode'] ?? 113); + $forecast = []; + foreach (array_slice($days, 0, 4) as $day) { + // max rain chance across 8 hourly slots + $rainPct = 0; + foreach ($day['hourly'] ?? [] as $h) { + $rainPct = max($rainPct, (int)($h['chanceofrain'] ?? 0)); + } + $dayCode = (int)($day['hourly'][4]['weatherCode'] ?? 113); + $forecast[] = [ + 'date' => $day['date'] ?? '', + 'day' => date('D', strtotime($day['date'] ?? 'now')), + 'high' => (int)($day['maxtempF'] ?? 0), + 'low' => (int)($day['mintempF'] ?? 0), + 'rain_pct' => $rainPct, + 'desc' => $wttrEmoji($dayCode), + 'icon' => $wttrIcon($dayCode), + ]; + } + + cacheStore('weather', [ + 'source' => 'wttr.in', + 'location' => 'Fort Worth, TX', + 'current' => [ + 'temp' => (int)($cu['temp_F'] ?? 0), + 'feels' => (int)($cu['FeelsLikeF'] ?? 0), + 'humidity' => (int)($cu['humidity'] ?? 0), + 'wind' => (int)($cu['windspeedMiles'] ?? 0), + 'desc' => $wttrEmoji($curCode), + 'icon' => $wttrIcon($curCode), + 'cloud' => (int)($cu['cloudcover'] ?? 0), + 'vis' => (int)($cu['visibility'] ?? 0), + ], + 'forecast' => $forecast, + 'cached_at' => date('c'), + ]); + echo '[cache] Weather: ' . ($cu['temp_F'] ?? '?') . "°F, " . $wttrEmoji($curCode) . " (wttr.in) cached\n"; + } else { + echo "[cache] Weather: wttr.in fetch failed\n"; + } +} else { + echo '[cache] Weather: fresh (' . round($weatherAge/60) . " min old)\n"; +} + +// ── News (RSS feeds — refresh every 30 min) ─────────────────────────────── +$newsRow = JarvisDB::query( + "SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='news' LIMIT 1" +); +$newsAge = $newsRow ? (time() - (int)$newsRow[0]['ts']) : PHP_INT_MAX; + +if ($newsAge > 1800) { + $feeds = [ + 'headlines' => [ + 'https://feeds.bbci.co.uk/news/rss.xml', + 'https://feeds.npr.org/1001/rss.xml', + 'https://feeds.abcnews.com/abcnews/topstories', + ], + 'technology' => [ + 'http://feeds.arstechnica.com/arstechnica/index', + 'https://www.theverge.com/rss/index.xml', + ], + ]; + + function parseRss(string $xml, int $max = 5): array { + $items = []; + if (!$xml) return $items; + libxml_use_internal_errors(true); + $dom = new DOMDocument(); + $dom->loadXML($xml); + $nodes = $dom->getElementsByTagName('item'); + $count = 0; + foreach ($nodes as $node) { + if ($count >= $max) break; + $title = $node->getElementsByTagName('title')->item(0)?->textContent ?? ''; + $link = $node->getElementsByTagName('link')->item(0)?->textContent ?? ''; + $pub = $node->getElementsByTagName('pubDate')->item(0)?->textContent ?? ''; + $title = html_entity_decode(trim($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if ($title && strlen($title) > 5) { + $items[] = [ + 'title' => $title, + 'link' => trim($link), + 'pub' => $pub ? date('M j g:ia', strtotime($pub)) : '', + 'source' => '', + ]; + $count++; + } + } + return $items; + } + + $allNews = []; + foreach ($feeds as $category => $urls) { + $allNews[$category] = []; + foreach ($urls as $url) { + $xml = curlGet($url, ['User-Agent: Mozilla/5.0 Jarvis/1.0'], 12); + $items = parseRss($xml, 4); + // Tag source from URL + $src = preg_match('/bbc/i', $url) ? 'BBC' : + (preg_match('/npr/i', $url) ? 'NPR' : + (preg_match('/abcnews/i', $url) ? 'ABC' : + (preg_match('/arstechnica/i', $url) ? 'Ars Technica' : + (preg_match('/theverge/i', $url) ? 'The Verge' : 'News')))); + foreach ($items as &$it) { $it['source'] = $src; } + $allNews[$category] = array_merge($allNews[$category], $items); + if (count($allNews[$category]) >= 8) break; + } + // Trim to 8 per category + $allNews[$category] = array_slice($allNews[$category], 0, 8); + } + + $totalItems = array_sum(array_map('count', $allNews)); + cacheStore('news', [ + 'categories' => $allNews, + 'cached_at' => date('c'), + 'total' => $totalItems, + ]); + echo "[cache] News: $totalItems articles cached\n"; +} else { + echo '[cache] News: fresh (' . round($newsAge/60) . " min old)\n"; +} + +echo '[cache] Done at ' . date('Y-m-d H:i:s') . "\n"; diff --git a/api/endpoints/suggestions.php b/api/endpoints/suggestions.php new file mode 100644 index 0000000..bec7ed7 --- /dev/null +++ b/api/endpoints/suggestions.php @@ -0,0 +1,42 @@ += 3 + ORDER BY hit_count DESC LIMIT 3", + [$hour, $dow] +) ?? []; + +// Map intents to friendly suggestion prompts +$intentPrompts = [ + 'network_scan' => 'Run a network scan?', + 'jellyfin_now_playing' => 'Check what\'s playing on Jellyfin?', + 'jellyfin_library' => 'Check the Jellyfin library?', + 'ha_scene' => 'Activate a home scene?', + 'planner:briefing' => 'Get your daily briefing?', + 'vm_suggestions' => 'Check VM resource usage?', + 'jellyfin_pause' => 'Pause Jellyfin?', + 'focus_mode' => 'Switch to focus mode?', + 'show_panels' => 'Show all panels?', +]; + +$suggestions = []; +foreach ($rows as $r) { + $intent = $r['intent_name']; + if (isset($intentPrompts[$intent])) { + $suggestions[] = [ + 'intent' => $intent, + 'prompt' => $intentPrompts[$intent], + 'hit_count' => (int)$r['hit_count'], + ]; + } +} + +echo json_encode(['suggestions' => $suggestions, 'hour' => $hour, 'dow' => $dow]); diff --git a/api/endpoints/system.php b/api/endpoints/system.php new file mode 100644 index 0000000..5dca765 --- /dev/null +++ b/api/endpoints/system.php @@ -0,0 +1,136 @@ + 0 ? round((($dTotal - $dIdle) / $dTotal) * 100, 1) : 0.0; +} + +function getMemory(): array { + $lines = file('/proc/meminfo'); + $mem = []; + foreach ($lines as $l) { + if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; + } + $total = $mem['MemTotal'] ?? 0; + $available = $mem['MemAvailable'] ?? 0; + $used = $total - $available; + return [ + 'total_mb' => round($total / 1024), + 'used_mb' => round($used / 1024), + 'free_mb' => round($available / 1024), + 'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + ]; +} + +function getDisk(): array { + $disks = []; + foreach (disk_total_space('/') as $dummy) break; // warm up + $total = disk_total_space('/'); + $free = disk_free_space('/'); + $used = $total - $free; + return [ + 'total_gb' => round($total / 1073741824, 1), + 'used_gb' => round($used / 1073741824, 1), + 'free_gb' => round($free / 1073741824, 1), + 'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + ]; +} + +function getDisk2(): array { + $total = disk_total_space('/'); + $free = disk_free_space('/'); + $used = $total - $free; + return [ + 'total_gb' => round($total / 1073741824, 1), + 'used_gb' => round($used / 1073741824, 1), + 'free_gb' => round($free / 1073741824, 1), + 'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + ]; +} + +function getUptime(): string { + $secs = (int)file_get_contents('/proc/uptime'); + $d = intdiv($secs, 86400); $h = intdiv($secs % 86400, 3600); + $m = intdiv($secs % 3600, 60); + return "{$d}d {$h}h {$m}m"; +} + +function getLoadAvg(): array { + $l = explode(' ', file_get_contents('/proc/loadavg')); + return ['1m' => (float)$l[0], '5m' => (float)$l[1], '15m' => (float)$l[2]]; +} + +function getNetworkIO(): array { + $lines = file('/proc/net/dev'); + $ifaces = []; + foreach ($lines as $line) { + if (strpos($line, ':') === false) continue; + [$name, $stats] = explode(':', $line, 2); + $name = trim($name); + if ($name === 'lo') continue; + $vals = preg_split('/\s+/', trim($stats)); + $ifaces[$name] = [ + 'rx_mb' => round($vals[0] / 1048576, 2), + 'tx_mb' => round($vals[8] / 1048576, 2), + ]; + } + return $ifaces; +} + +function getServices(): array { + $services = ["nginx","php8.3-fpm","mariadb","redis-server","jarvis-arc","jarvis-agent"]; + $result = []; + foreach ($services as $svc) { + $out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null'); + $result[$svc] = trim($out ?? '') === 'active'; + } + return $result; +} + +function getTopProcesses(): array { + $out = shell_exec("ps aux --sort=-%cpu | awk 'NR>1 && NR<=6 {print $11\",\"$3\",\"$4}' 2>/dev/null"); + $procs = []; + foreach (explode("\n", trim($out ?? '')) as $line) { + if (!$line) continue; + [$cmd, $cpu, $mem] = explode(',', $line, 3); + $procs[] = ['cmd' => basename($cmd), 'cpu' => (float)$cpu, 'mem' => (float)$mem]; + } + return $procs; +} + +$cpu = getCpuUsage(); +$memory = getMemory(); +$disk = getDisk2(); + +$stats = [ + 'cpu' => $cpu, + 'memory' => $memory, + 'disk' => $disk, + 'uptime' => getUptime(), + 'load' => getLoadAvg(), + 'network_io' => getNetworkIO(), + 'services' => getServices(), + 'processes' => getTopProcesses(), + 'hostname' => gethostname(), + 'ip' => JARVIS_IP, + 'timestamp' => date('c'), +]; + +// Log to history +JarvisDB::execute( + 'INSERT INTO metrics_history (metric_name, metric_value, host) VALUES (?,?,?),(?,?,?),(?,?,?)', + ['cpu', $cpu, 'jarvis', 'memory', $memory['percent'], 'jarvis', 'disk', $disk['percent'], 'jarvis'] +); + +echo json_encode($stats); diff --git a/api/endpoints/tts.php b/api/endpoints/tts.php new file mode 100644 index 0000000..335cd2e --- /dev/null +++ b/api/endpoints/tts.php @@ -0,0 +1,53 @@ + 'No text']); + exit; +} + +// Cap at 400 chars to protect free-tier quota +$text = mb_substr($text, 0, 400); + +if (!defined('ELEVENLABS_API_KEY') || !ELEVENLABS_API_KEY) { + http_response_code(503); + echo json_encode(['error' => 'ElevenLabs not configured']); + exit; +} + +$payload = json_encode([ + 'text' => $text, + 'model_id' => ELEVENLABS_MODEL, + 'voice_settings' => ['stability' => 0.45, 'similarity_boost' => 0.80, 'style' => 0.10], +]); + +$ch = curl_init('https://api.elevenlabs.io/v1/text-to-speech/' . ELEVENLABS_VOICE_ID); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'xi-api-key: ' . ELEVENLABS_API_KEY, + 'Content-Type: application/json', + 'Accept: audio/mpeg', + ], + CURLOPT_POSTFIELDS => $payload, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 5, +]); + +$audio = curl_exec($ch); +$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($code === 200 && $audio) { + header('Content-Type: audio/mpeg'); + header('Cache-Control: no-store'); + echo $audio; +} else { + http_response_code(502); + echo json_encode(['error' => 'ElevenLabs error', 'code' => $code]); +} diff --git a/api/endpoints/weather.php b/api/endpoints/weather.php new file mode 100644 index 0000000..e7eb39d --- /dev/null +++ b/api/endpoints/weather.php @@ -0,0 +1,20 @@ + null, + 'forecast' => [], + 'cache_age_s' => -1, + 'message' => 'Weather data warming up — available within 5 minutes.', + ]); +} diff --git a/api/lib/db.php b/api/lib/db.php new file mode 100644 index 0000000..d4997d8 --- /dev/null +++ b/api/lib/db.php @@ -0,0 +1,40 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false] + ); + } + return self::$pdo; + } + + public static function query(string $sql, array $params = []): array { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + public static function execute(string $sql, array $params = []): int { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return $stmt->rowCount(); + } + + public static function single(string $sql, array $params = []): ?array { + $rows = self::query($sql, $params); + return $rows[0] ?? null; + } + + public static function insert(string $sql, array $params = []): int { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return (int)self::get()->lastInsertId(); + } +} diff --git a/api/lib/kb_engine.php b/api/lib/kb_engine.php new file mode 100644 index 0000000..ca8efac --- /dev/null +++ b/api/lib/kb_engine.php @@ -0,0 +1,154 @@ + string, 'intent' => string] or null if no match. + */ + public static function match(string $input): ?array { + $intents = JarvisDB::query( + 'SELECT * FROM kb_intents WHERE active=1 ORDER BY priority DESC, id ASC' + ); + if (!$intents) return null; + + foreach ($intents as $intent) { + $pat = '~' . $intent['pattern'] . '~'; + if (@preg_match($pat, $input)) { + $reply = self::fillTemplate( + $intent['response_template'], + $intent['fact_category'] + ); + return [ + 'reply' => $reply, + 'intent' => $intent['intent_name'], + 'action' => $intent['action_type'], + ]; + } + } + return null; + } + + /** + * Replace {placeholder} tokens in a template with values from kb_facts, + * plus built-in dynamic tokens like {current_time}. + */ + private static function fillTemplate(string $template, ?string $category): string { + // Built-in tokens + $builtins = [ + 'current_time' => date('g:i A'), + 'current_date' => date('l, F j Y'), + ]; + + // Load user address preference + $prefRows = JarvisDB::query( + "SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')" + ); + $prefs = []; + foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; } + $builtins['user_title'] = $prefs['user_title'] ?? $prefs['user_name'] ?? 'Sir'; + $builtins['user_name'] = $prefs['user_name'] ?? 'Myron'; + + // Computed builtins + $pendingRow = JarvisDB::single("SELECT COUNT(*) cnt FROM tasks WHERE status NOT IN ('done','cancelled')"); + $builtins['pending_count'] = (string)($pendingRow['cnt'] ?? 0); + $overdueRow = JarvisDB::single("SELECT COUNT(*) cnt FROM tasks WHERE due_date < CURDATE() AND status NOT IN ('done','cancelled')"); + $builtins['overdue_count'] = (string)($overdueRow['cnt'] ?? 0); + + // Fetch all facts for this category + $facts = []; + if ($category) { + $rows = JarvisDB::query( + 'SELECT fact_key, fact_value FROM kb_facts + WHERE category = ? AND (expires_at IS NULL OR expires_at > NOW())', + [$category] + ); + foreach ($rows ?? [] as $r) { + $facts[$r['fact_key']] = $r['fact_value']; + } + } + // Pull network facts if template uses them + if (strpos($template, '{online_count}') !== false || strpos($template, '{total_count}') !== false) { + $netRows = JarvisDB::query("SELECT fact_key, fact_value FROM kb_facts WHERE category='network' AND (expires_at IS NULL OR expires_at > NOW())"); + foreach ($netRows ?? [] as $r) { $facts[$r['fact_key']] = $r['fact_value']; } + } + // Pull system facts if template uses them + if (preg_match('/\{(cpu_usage|mem_|disk_|uptime|load_)\w*\}/', $template)) { + $sysRows = JarvisDB::query("SELECT fact_key, fact_value FROM kb_facts WHERE category='system' AND (expires_at IS NULL OR expires_at > NOW())"); + foreach ($sysRows ?? [] as $r) { $facts[$r['fact_key']] = $r['fact_value']; } + } + + $allTokens = array_merge($builtins, $facts); + + // Replace placeholders + return preg_replace_callback('/\{([a-z0-9_]+)\}/', function ($m) use ($allTokens) { + return $allTokens[$m[1]] ?? ''; + }, $template); + } + + /** + * Store a fact in kb_facts (upsert). + */ + public static function storeFact( + string $category, + string $key, + string $value, + string $host = 'local', + ?int $ttlSeconds = null + ): void { + $expires = $ttlSeconds ? gmdate('Y-m-d H:i:s', time() + $ttlSeconds) : null; + JarvisDB::execute( + 'INSERT INTO kb_facts (category, fact_key, fact_value, host, expires_at) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), expires_at=VALUES(expires_at), updated_at=NOW()', + [$category, $key, $value, $host, $expires] + ); + } + + /** + * Learn from conversation — store interesting facts the user mentions. + */ + public static function learnFromConversation(string $input, string $reply): void { + // Preference learning: user states a preference + if (preg_match('/(?i)i (prefer|like|want|always)\s+(.+?)(?:\.|$)/', $input, $m)) { + $pref = trim($m[2]); + if (strlen($pref) < 120) { + JarvisDB::execute( + 'INSERT INTO kb_preferences (pref_key, pref_value) + VALUES (?,?) + ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)', + ['learned_' . md5($pref), $pref] + ); + } + } + } + + /** + * Return a summary of what the KB knows (for system prompt injection). + */ + public static function getContextSummary(): string { + // Exclude entity_map — too large for Ollama 1B tokenizer + $facts = JarvisDB::query( + "SELECT category, fact_key, fact_value FROM kb_facts + WHERE fact_key != 'entity_map' + ORDER BY category, updated_at DESC" + ); + if (!$facts) return ''; + + $byCategory = []; + foreach ($facts as $f) { + $byCategory[$f['category']][] = "{$f['fact_key']}={$f['fact_value']}"; + } + + $lines = []; + foreach ($byCategory as $cat => $items) { + $lines[] = strtoupper($cat) . ': ' . implode(', ', array_slice($items, 0, 8)); + } + return implode("\n", $lines); + } +} diff --git a/config/vhost/vhost.conf b/config/vhost/vhost.conf new file mode 100755 index 0000000..fe2cee1 --- /dev/null +++ b/config/vhost/vhost.conf @@ -0,0 +1,94 @@ +docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@orbishosting.com +enableGzip 1 +enableIpGeo 1 + +index { + useServer 0 + indexFiles index.php, index.html +} + +errorlog $VH_ROOT/logs/$VH_NAME.error_log { + useServer 0 + logLevel WARN + rollingSize 10M +} + +accesslog $VH_ROOT/logs/$VH_NAME.access_log { + useServer 0 + logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:jarvi5150 php +} + +extprocessor jarvi5150 { + type lsapi + address UDS://tmp/lshttpd/jarvi5150.sock + maxConns 10 + env LSAPI_CHILDREN=10 + initTimeout 600 + retryTimeout 0 + persistConn 1 + pcKeepAliveTimeout 1 + respBuffer 0 + autoStart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + extUser jarvi5150 + extGroup jarvi5150 + memSoftLimit 1024M + memHardLimit 1024M + procSoftLimit 400 + procHardLimit 500 +} + +phpIniOverride { + +} + +module cache { + storagePath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoLoadHtaccess 1 + rules <<s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:jarvi5150 php +} + +extprocessor jarvi5150 { + type lsapi + address UDS://tmp/lshttpd/jarvi5150.sock + maxConns 10 + env LSAPI_CHILDREN=10 + initTimeout 600 + retryTimeout 0 + persistConn 1 + pcKeepAliveTimeout 1 + respBuffer 0 + autoStart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + extUser jarvi5150 + extGroup jarvi5150 + memSoftLimit 1024M + memHardLimit 1024M + procSoftLimit 400 + procHardLimit 500 +} + +phpIniOverride { + +} + +module cache { + storagePath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoLoadHtaccess 1 + rules <<s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:jarvi5150 php +} + +extprocessor jarvi5150 { + type lsapi + address UDS://tmp/lshttpd/jarvi5150.sock + maxConns 10 + env LSAPI_CHILDREN=10 + initTimeout 600 + retryTimeout 0 + persistConn 1 + pcKeepAliveTimeout 1 + respBuffer 0 + autoStart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + extUser jarvi5150 + extGroup jarvi5150 + memSoftLimit 1024M + memHardLimit 1024M + procSoftLimit 400 + procHardLimit 500 +} + +phpIniOverride { + +} + +module cache { + storagePath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoLoadHtaccess 1 + rules <<> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 +*/5 * * * * /usr/local/lsws/lsphp85/bin/lsphp /home/jarvis.orbishosting.com/api/endpoints/stats_cache.php >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 diff --git a/deploy/jarvis-agent.service b/deploy/jarvis-agent.service new file mode 100644 index 0000000..abde978 --- /dev/null +++ b/deploy/jarvis-agent.service @@ -0,0 +1,14 @@ +[Unit] +Description=JARVIS Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/local/bin/jarvis-agent.py +Restart=always +RestartSec=30 +User=root + +[Install] +WantedBy=multi-user.target diff --git a/deploy/jarvis-arc.service b/deploy/jarvis-arc.service new file mode 100644 index 0000000..dc8054b --- /dev/null +++ b/deploy/jarvis-arc.service @@ -0,0 +1,17 @@ +[Unit] +Description=JARVIS Arc Reactor +After=network-online.target mysql.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/jarvis-arc +ExecStart=/opt/jarvis-arc/venv/bin/python3 /opt/jarvis-arc/reactor.py +Restart=always +RestartSec=10 +User=root +StandardOutput=append:/home/jarvis.orbishosting.com/logs/arc_reactor.log +StandardError=append:/home/jarvis.orbishosting.com/logs/arc_reactor.log + +[Install] +WantedBy=multi-user.target diff --git a/deploy/jarvis-backup.sh b/deploy/jarvis-backup.sh new file mode 100755 index 0000000..7fd61a7 --- /dev/null +++ b/deploy/jarvis-backup.sh @@ -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" \ No newline at end of file diff --git a/deploy/jarvis-deploy.sh b/deploy/jarvis-deploy.sh new file mode 100755 index 0000000..71ba1fc --- /dev/null +++ b/deploy/jarvis-deploy.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# JARVIS Auto-Deploy Runner — processes GitHub webhook queue every minute. +# Validates PHP syntax before deploying; auto-reverts on bad code. +# Restarts OLS after JARVIS deploys to pick up PHP changes. + +QUEUE=/tmp/jarvis-deploy-queue.txt +LOG=/home/jarvis.orbishosting.com/logs/deploy.log +PHP=/usr/bin/php8.3 + +TS() { date '+%Y-%m-%d %H:%M:%S'; } +log() { echo "[$(TS)] $1" >> "$LOG"; } + +[ ! -f "$QUEUE" ] && exit 0 +[ ! -s "$QUEUE" ] && exit 0 + +# Atomically take ownership of the queue via rename — prevents TOCTOU loss of +# entries written between a cat and truncate +PROCESSING="${QUEUE}.processing" +mv "$QUEUE" "$PROCESSING" 2>/dev/null || exit 0 +SNAPSHOT=$(cat "$PROCESSING") +rm -f "$PROCESSING" + +while IFS= read -r path; do + [ -z "$path" ] && continue + [ ! -d "$path/.git" ] && log "SKIP $path — not a git repo" && continue + + log "Deploying $path" + cd "$path" || continue + + BEFORE=$(git rev-parse HEAD 2>/dev/null) + git fetch origin main >> "$LOG" 2>&1 + REMOTE=$(git rev-parse origin/main 2>/dev/null) + + if [ "$BEFORE" = "$REMOTE" ]; then + log "Already up to date: $path" + continue + fi + + git pull origin main >> "$LOG" 2>&1 + AFTER=$(git rev-parse HEAD 2>/dev/null) + CHANGED=$(git diff --name-only "$BEFORE" "$AFTER" 2>/dev/null) + + # PHP syntax validation — check every changed .php file + SYNTAX_OK=true + BAD_FILE="" + while IFS= read -r f; do + [[ "$f" != *.php ]] && continue + [ ! -f "$f" ] && continue + if ! $PHP -l "$f" > /dev/null 2>&1; then + SYNTAX_OK=false + BAD_FILE="$f" + break + fi + done <<< "$CHANGED" + + if [ "$SYNTAX_OK" = false ]; then + log "SYNTAX ERROR in $BAD_FILE — reverting locally and pushing revert to GitHub" + git reset --hard "$BEFORE" >> "$LOG" 2>&1 + # Push the revert so GitHub matches the live server — prevents infinite re-deploy loop + git push --force origin HEAD:main >> "$LOG" 2>&1 + PUSH_EXIT=$? + if [ $PUSH_EXIT -ne 0 ]; then + log "WARNING: Force-push of revert failed (exit $PUSH_EXIT) — bad commit still on GitHub" + fi + # Insert alert into JARVIS DB + BAD_ESCAPED=$(printf '%s' "$BAD_FILE" | sed "s/'/\\\\\\'/g") + mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se \ + "INSERT INTO alerts (alert_type,title,message,severity) + VALUES ('deploy_fail','Deploy reverted: syntax error', + 'PHP syntax error in $BAD_ESCAPED. Commit $AFTER was reverted and force-pushed to GitHub.','critical');" 2>/dev/null + log "Reverted. Bad commit: $AFTER" + continue + fi + + log "Deploy OK ($BEFORE -> $AFTER): $path" + log "Changed: $(echo "$CHANGED" | tr '\n' ' ')" + + # Restart OLS after any JARVIS deploy to pick up PHP changes + if [[ "$path" == *"jarvis"* ]]; then + systemctl reload lsws 2>/dev/null || systemctl restart lsws 2>/dev/null + log "OLS reloaded for JARVIS deploy" + + # Patch live config.php with correct Ollama host/model and Groq search model + CONFIG="$path/api/config.php" + if [ -f "$CONFIG" ]; then + sed -i "s|http://10\.48\.200\.95:11434|http://10.48.200.210:11434|g" "$CONFIG" + sed -i "s|'llama3\.2:1b'|'llama3.1:8b'|g" "$CONFIG" + sed -i "s|\"llama3\.2:1b\"|\"llama3.1:8b\"|g" "$CONFIG" + sed -i "s|'llama3\.1:70b'|'llama3.1:8b'|g" "$CONFIG" + sed -i "s|\"llama3\.1:70b\"|\"llama3.1:8b\"|g" "$CONFIG" + sed -i "s|'groq/compound-mini'|'compound-beta-mini'|g;s|\"groq/compound-mini\"|\"compound-beta-mini\"|g" "$CONFIG" + log "Patched config.php: Ollama IP→.210, model→llama3.1:8b, Groq search→compound-beta-mini" + fi + + # Self-install the deploy script on every run + cp "$path/deploy/jarvis-deploy.sh" /usr/local/bin/jarvis-deploy.sh 2>/dev/null && chmod +x /usr/local/bin/jarvis-deploy.sh + + # Sync reactor.py + service file to runtime location if they changed + if echo "$CHANGED" | grep -q 'deploy/reactor.py\|deploy/jarvis-arc.service\|deploy/requirements.txt'; then + mkdir -p /opt/jarvis-arc /home/jarvis.orbishosting.com/logs + cp "$path/deploy/reactor.py" /opt/jarvis-arc/reactor.py + cp "$path/deploy/requirements.txt" /opt/jarvis-arc/requirements.txt 2>/dev/null + # Bootstrap venv if it doesn't exist + if [ ! -f /opt/jarvis-arc/venv/bin/activate ]; then + log "Arc Reactor venv missing — creating and installing packages" + python3 -m venv /opt/jarvis-arc/venv + /opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt + log "Arc Reactor venv ready" + fi + if echo "$CHANGED" | grep -q 'deploy/jarvis-arc.service'; then + cp "$path/deploy/jarvis-arc.service" /etc/systemd/system/jarvis-arc.service + systemctl daemon-reload + systemctl enable jarvis-arc + log "Arc Reactor service file installed and enabled" + fi + systemctl restart jarvis-arc 2>/dev/null || \ + bash -c "pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &" + log "Arc Reactor updated and restarted" + fi + fi + +done <<< "$SNAPSHOT" diff --git a/deploy/jarvis-watchdog.sh b/deploy/jarvis-watchdog.sh new file mode 100755 index 0000000..c227af9 --- /dev/null +++ b/deploy/jarvis-watchdog.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# JARVIS Self-Healing Watchdog — runs every 5 min via root cron +# Checks: lsws, mysql, redis, JARVIS HTTP, disk, memory +# Auto-heals: restarts failed services, restarts offline Proxmox VM agents +# Logs to: /home/jarvis.orbishosting.com/logs/watchdog.log + +LOG=/home/jarvis.orbishosting.com/logs/watchdog.log +MYSQL="mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se" +TS() { date '+%Y-%m-%d %H:%M:%S'; } + +log() { echo "[$(TS)] $1" >> "$LOG"; } + +# Escape single quotes for MySQL string interpolation in bash +sql_esc() { printf '%s' "$1" | sed "s/'/\\\\''/g"; } + +alert() { + local type="$1" title="$2" msg="$3" sev="${4:-warning}" + local e_type e_title e_msg e_sev + e_type=$(sql_esc "$type"); e_title=$(sql_esc "$title") + e_msg=$(sql_esc "$msg"); e_sev=$(sql_esc "$sev") + $MYSQL "INSERT IGNORE INTO alerts (alert_type,title,message,severity,source_key,auto_resolve) + VALUES ('$e_type','$e_title','$e_msg','$e_sev','watchdog:$e_type',1);" 2>/dev/null +} + +resolve() { + local e_key + e_key=$(sql_esc "$1") + $MYSQL "UPDATE alerts SET resolved=1,resolved_at=NOW() + WHERE source_key='watchdog:$e_key' AND resolved=0;" 2>/dev/null +} + +# ── Service health ───────────────────────────────────────────────────────────── +for SVC in lsws mysql redis; do + if ! systemctl is-active --quiet "$SVC"; then + log "HEAL: $SVC is down — restarting" + systemctl restart "$SVC" + if systemctl is-active --quiet "$SVC"; then + log "HEAL: $SVC restarted successfully" + alert "service_down" "$SVC restarted" "JARVIS watchdog restarted $SVC which was stopped." "warning" + else + log "ERROR: $SVC failed to restart" + alert "service_down" "$SVC failed to restart" "$SVC is down and could not be restarted automatically." "critical" + fi + else + resolve "service_down_$SVC" + fi +done + +# ── JARVIS HTTP self-check ───────────────────────────────────────────────────── +HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 10 https://jarvis.orbishosting.com/api.php 2>/dev/null) +if [[ "$HTTP_CODE" == "5"* ]] || [[ -z "$HTTP_CODE" ]]; then + log "HEAL: JARVIS HTTP returned $HTTP_CODE — restarting lsws" + systemctl restart lsws + alert "jarvis_http" "JARVIS HTTP error — restarted OLS" "JARVIS returned HTTP $HTTP_CODE. OpenLiteSpeed was restarted." "critical" +else + resolve "jarvis_http" +fi + +# ── Disk usage ───────────────────────────────────────────────────────────────── +DISK_PCT=$(df / | awk 'NR==2{print $5}' | tr -d '%') +if [ "$DISK_PCT" -ge 90 ]; then + log "ALERT: Disk at ${DISK_PCT}% (critical)" + alert "disk_critical" "Disk ${DISK_PCT}% full on DO server" "Root filesystem is ${DISK_PCT}% full. Immediate cleanup required." "critical" +elif [ "$DISK_PCT" -ge 80 ]; then + log "WARN: Disk at ${DISK_PCT}%" + alert "disk_warning" "Disk ${DISK_PCT}% full on DO server" "Root filesystem is ${DISK_PCT}% full." "warning" +else + $MYSQL "UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE source_key IN ('watchdog:disk_critical','watchdog:disk_warning') AND resolved=0;" 2>/dev/null +fi + +# ── Memory usage ────────────────────────────────────────────────────────────── +MEM_TOTAL=$(grep MemTotal /proc/meminfo | awk '{print $2}') +MEM_AVAIL=$(grep MemAvailable /proc/meminfo | awk '{print $2}') +MEM_PCT=$(( (MEM_TOTAL - MEM_AVAIL) * 100 / MEM_TOTAL )) +if [ "$MEM_PCT" -ge 90 ]; then + log "ALERT: Memory at ${MEM_PCT}%" + alert "mem_critical" "Memory ${MEM_PCT}% used on DO server" "DO server memory is ${MEM_PCT}% used." "critical" +fi + +# ── Offline agent auto-restart (Proxmox VMs only) ───────────────────────────── +# Map: agent_id → [proxmox_ip, vmid] +declare -A AGENT_PVE=( + ["ollama_vm"]="10.48.200.90 210" + ["ha_vm"]="10.48.200.90 101" + ["networkbackup_vm"]="10.48.200.91 302" +) + +OFFLINE=$($MYSQL "SELECT agent_id FROM registered_agents + WHERE status='offline' AND last_seen < DATE_SUB(NOW(), INTERVAL 5 MINUTE) + AND agent_type='linux';" 2>/dev/null) + +for AID in $OFFLINE; do + # Check if we have a Proxmox mapping for this agent + for KEY in "${!AGENT_PVE[@]}"; do + if [[ "$AID" == *"$KEY"* ]] || [[ "$KEY" == *"$AID"* ]]; then + PVE_INFO=(${AGENT_PVE[$KEY]}) + PVE_IP="${PVE_INFO[0]}" + VMID="${PVE_INFO[1]}" + log "HEAL: Attempting to restart jarvis-agent on $AID (VM $VMID @ $PVE_IP)" + sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + root@"$PVE_IP" \ + "qm guest exec $VMID -- systemctl restart jarvis-agent" 2>/dev/null + log "HEAL: Restart command sent to $AID (exit: $?)" + alert "agent_offline" "Auto-restarted agent: $AID" \ + "Agent $AID was offline. JARVIS watchdog sent restart command via Proxmox." "warning" + break + fi + done +done + +# ── Deploy log rotation (keep last 1000 lines) ──────────────────────────────── +for LOGFILE in "$LOG" /home/jarvis.orbishosting.com/logs/deploy.log /home/jarvis.orbishosting.com/logs/cron.log; do + [ -f "$LOGFILE" ] || continue + LINES=$(wc -l < "$LOGFILE") + if [ "$LINES" -gt 1000 ]; then + tail -500 "$LOGFILE" > "${LOGFILE}.tmp" && mv "${LOGFILE}.tmp" "$LOGFILE" + fi +done diff --git a/deploy/reactor.py b/deploy/reactor.py new file mode 100644 index 0000000..6939e19 --- /dev/null +++ b/deploy/reactor.py @@ -0,0 +1,2789 @@ +#!/usr/bin/env python3 +""" +JARVIS Arc Reactor — Core Daemon v9.0 +Phase 1: Job queue, ping/echo/shell +Phase 2: Intel Protocol (research), Iron Protocol (tool loop), LLM router +Phase 3: Gmail Triage (Comms Protocol), Remote Exec (Field Protocol) +Phase 4: Vision Protocol (screenshot, vision, sysinfo) +Phase 5: Guardian Mode (continuous awareness, proactive alerts, SITREP) +Phase 6: Comms v2 — send email, compose, schedule event, meeting prep +Phase 7: Mission Ops — multi-step automated workflows with trigger engine +Phase 8: Mission Directives — OKR/goal tracking with AI progress review +Phase 9: Clearance Protocol — approval gating for high-risk operations +""" + +import asyncio +import email as _email_lib +import imaplib +import json +import logging +import os +import re +import sys +import traceback +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from email.header import decode_header as _decode_header +from email.utils import parsedate_to_datetime +from typing import Any, Optional + +import aiomysql +import aiohttp +import uvicorn +from fastapi import FastAPI, HTTPException, Request + +# ── CONFIG ──────────────────────────────────────────────────────────────────── +HOST = "127.0.0.1" +PORT = 7474 +VERSION = "9.0.0" +DB_HOST = "localhost" +DB_PORT = 3306 +DB_USER = "jarvis_user" +DB_PASS = "J4rv1s_Pr0t0c0l_2026!" +DB_NAME = "jarvis_db" +LOG_FILE = "/var/log/jarvis/arc_reactor.log" +POLL_INTERVAL = 3 +HEARTBEAT_INTERVAL = 30 + +CLAUDE_API_KEY = "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" +CLAUDE_MODEL = "claude-sonnet-4-6" +GROQ_API_KEY = "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" +GROQ_MODEL = "llama-3.3-70b-versatile" +OLLAMA_HOST = "http://10.48.200.210:11434" +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_PASS = "demsvdylwweacbcx" +ICLOUD_USER = "myronblair@icloud.com" +ICLOUD_PASS = "yxfi-yvzu-geqk-japr" + +# ── LOGGING ─────────────────────────────────────────────────────────────────── +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.FileHandler(LOG_FILE), + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger("arc_reactor") + +# ── DB POOL ─────────────────────────────────────────────────────────────────── +_pool: Optional[aiomysql.Pool] = None + +async def get_pool() -> aiomysql.Pool: + global _pool + if _pool is None or _pool.closed: + _pool = await aiomysql.create_pool( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS, + db=DB_NAME, autocommit=True, minsize=2, maxsize=10, charset="utf8mb4", + ) + return _pool + +async def db_execute(sql: str, args: tuple = ()) -> int: + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(sql, args) + return cur.lastrowid + +async def db_fetchone(sql: str, args: tuple = ()) -> Optional[dict]: + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(sql, args) + return await cur.fetchone() + +async def db_fetchall(sql: str, args: tuple = ()) -> list: + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(sql, args) + return await cur.fetchall() + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 1 HANDLERS +# ═══════════════════════════════════════════════════════════════════════════════ + +async def handle_ping(payload: dict) -> dict: + return {"pong": True, "timestamp": datetime.utcnow().isoformat(), "version": VERSION} + +async def handle_echo(payload: dict) -> dict: + return {"echo": payload.get("message", ""), "timestamp": datetime.utcnow().isoformat()} + +async def handle_shell(payload: dict) -> dict: + cmd = payload.get("command", "").strip() + allowed = ("df ", "free ", "uptime", "hostname", "uname", "ps ", "cat /proc/") + if not any(cmd.startswith(p) for p in allowed): + raise ValueError(f"Command not in whitelist: {cmd[:40]}") + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15) + return {"stdout": stdout.decode().strip(), "stderr": stderr.decode().strip(), "exit_code": proc.returncode} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 2: LLM ROUTER +# ═══════════════════════════════════════════════════════════════════════════════ + +async def llm_call(messages: list, provider: str = "claude", system: str = "") -> str: + if provider == "claude" and CLAUDE_API_KEY: + return await _claude_call(messages, system) + elif provider == "groq" and GROQ_API_KEY: + return await _groq_call(messages, system) + elif provider == "ollama": + return await _ollama_call(messages, system) + for p in ["groq", "ollama"]: + try: + return await llm_call(messages, p, system) + except Exception: + continue + raise RuntimeError("All LLM providers failed") + +async def _claude_call(messages: list, system: str = "") -> str: + payload = {"model": CLAUDE_MODEL, "max_tokens": 4096, "messages": messages} + if system: + payload["system"] = system + headers = {"x-api-key": CLAUDE_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"} + async with aiohttp.ClientSession() as session: + async with session.post("https://api.anthropic.com/v1/messages", json=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=60)) as resp: + data = await resp.json() + if resp.status != 200: + raise RuntimeError(f"Claude API error {resp.status}: {data.get('error',{}).get('message','')}") + return data["content"][0]["text"] + +async def _groq_call(messages: list, system: str = "") -> str: + all_msgs = ([{"role": "system", "content": system}] if system else []) + messages + payload = {"model": GROQ_MODEL, "messages": all_msgs, "max_tokens": 4096} + headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"} + async with aiohttp.ClientSession() as session: + async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload, + headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp: + data = await resp.json() + if resp.status != 200: + raise RuntimeError(f"Groq error {resp.status}") + return data["choices"][0]["message"]["content"] + +async def _ollama_call(messages: list, system: str = "") -> str: + prompt = (system + "\n\n" if system else "") + "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages) + async with aiohttp.ClientSession() as session: + async with session.post(f"{OLLAMA_HOST}/api/generate", json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False}, + timeout=aiohttp.ClientTimeout(total=30)) as resp: + data = await resp.json() + return data.get("response", "") + + +# -- VISION CALL ---------------------------------------------------------- +async def _vision_call(image_b64: str, prompt: str) -> tuple: + """ + Vision provider cascade: Claude -> Ollama vision model -> graceful fallback. + Returns (analysis: str, provider: str). + To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava"). + """ + # 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: + message = payload.get("message", "") + system = payload.get("system", "You are JARVIS, an Iron Man-style AI assistant.") + provider = payload.get("provider", "claude") + if not message: + raise ValueError("Missing message") + result = await llm_call([{"role": "user", "content": message}], provider, system) + return {"response": result, "provider": provider} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 2: INTEL PROTOCOL — RESEARCH ENGINE +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _web_search(query: str, max_results: int = 6) -> list: + try: + from duckduckgo_search import DDGS + results = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + results.append({"url": r.get("href",""), "title": r.get("title",""), "snippet": r.get("body","")}) + return results + except Exception as e: + log.warning(f"DDG search failed: {e}") + return [] + +async def _fetch_url_content(url: str, timeout: int = 12) -> str: + try: + import trafilatura + headers = {"User-Agent": "Mozilla/5.0 (compatible; JARVIS-Research/3.0)"} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout), + allow_redirects=True, ssl=False) as resp: + if resp.status != 200: + return "" + html = await resp.text(errors="replace") + text = trafilatura.extract(html, include_links=False, include_images=False, no_fallback=False, favor_precision=True) + return (text or "")[:4000] + except Exception as e: + log.debug(f"Fetch failed {url[:60]}: {e}") + return "" + +async def handle_research(payload: dict) -> dict: + query = payload.get("query", "").strip() + depth = payload.get("depth", "standard") + provider = payload.get("provider", "claude") + if not query: + raise ValueError("Missing research query") + + max_sources = {"quick": 3, "standard": 5, "deep": 8}.get(depth, 5) + log.info(f"[INTEL] Research: '{query}' depth={depth} sources={max_sources}") + + search_results = await _web_search(query, max_results=max_sources + 2) + if not search_results: + search_results = [{"url": "", "title": query, "snippet": f"No search results for: {query}"}] + + fetch_tasks = [_fetch_url_content(r["url"]) for r in search_results if r.get("url")] + page_texts = await asyncio.gather(*fetch_tasks, return_exceptions=True) + + sources = [] + for i, r in enumerate(search_results[:max_sources]): + text = page_texts[i] if i < len(page_texts) and isinstance(page_texts[i], str) else "" + sources.append({"url": r["url"], "title": r["title"], "snippet": r["snippet"], "content": text}) + + context_parts = [] + for i, s in enumerate(sources, 1): + body = s["content"] or s["snippet"] + if body: + context_parts.append(f"SOURCE {i}: {s['title']}\nURL: {s['url']}\n{body[:3000]}\n") + + context_text = "\n---\n".join(context_parts) if context_parts else "No page content available." + synthesis_prompt = ( + f'You are JARVIS, an advanced AI research assistant. The user asked: "{query}"\n\n' + f"You have retrieved the following source material:\n\n{context_text}\n\n" + f"Provide a comprehensive research briefing with:\n" + f"1. **EXECUTIVE SUMMARY** (2-3 sentences)\n" + f"2. **KEY FINDINGS** (5-8 bullet points)\n" + f"3. **DETAILS** (thorough synthesis, 2-4 paragraphs)\n" + f"4. **CONFIDENCE** (High/Medium/Low based on source quality)\n\n" + f"Be precise, factual, and cite source numbers where relevant." + ) + + try: + synthesis = await llm_call([{"role": "user", "content": synthesis_prompt}], provider=provider) + except Exception as e: + log.warning(f"[INTEL] Synthesis failed, falling back: {e}") + synthesis = f"**RESEARCH BRIEFING: {query}**\n\n" + "\n\n".join(f"**{s['title']}**\n{s['snippet']}" for s in sources) + + key_points = [] + for line in synthesis.split("\n"): + line = line.strip() + if line.startswith(("- ", "• ", "* ")) and len(line) > 10: + key_points.append(re.sub(r'^[-•*]\s*', '', line)) + elif re.match(r'^\d+\.\s+', line) and len(line) > 10: + key_points.append(re.sub(r'^\d+\.\s+', '', line)) + + clean_sources = [{"url": s["url"], "title": s["title"] or s["url"]} for s in sources if s.get("url")] + log.info(f"[INTEL] Research complete: '{query}' — {len(sources)} sources") + return {"query": query, "depth": depth, "synthesis": synthesis, "key_points": key_points[:10], + "sources": clean_sources, "source_count": len(clean_sources), "provider": provider} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 2: IRON PROTOCOL — MULTI-STEP TOOL LOOP +# ═══════════════════════════════════════════════════════════════════════════════ + +AGENT_TOOLS = [ + {"name": "web_search", "description": "Search the web for current information.", + "input_schema": {"type": "object", "properties": {"query": {"type": "string"}, "max_results": {"type": "integer", "default": 5}}, "required": ["query"]}}, + {"name": "fetch_url", "description": "Fetch a web page and extract its main text content.", + "input_schema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}, + {"name": "jarvis_agents", "description": "Get status of all JARVIS agents.", + "input_schema": {"type": "object", "properties": {}}}, + {"name": "jarvis_alerts", "description": "Get current active JARVIS alerts.", + "input_schema": {"type": "object", "properties": {}}}, + {"name": "current_time", "description": "Get current date and time.", + "input_schema": {"type": "object", "properties": {}}}, +] + +async def execute_agent_tool(tool_name: str, tool_input: dict) -> str: + try: + if tool_name == "web_search": + results = await _web_search(tool_input.get("query", ""), tool_input.get("max_results", 5)) + if not results: + return "No search results found." + return "\n".join(f"{i+1}. {r['title']}\n URL: {r['url']}\n {r['snippet']}" for i, r in enumerate(results)) + elif tool_name == "fetch_url": + url = tool_input.get("url", "") + if not url: + return "No URL provided." + return await _fetch_url_content(url) or "Could not extract content." + elif tool_name == "jarvis_agents": + agents = await db_fetchall("SELECT hostname, agent_type, ip_address, status, last_seen FROM registered_agents ORDER BY status='online' DESC, hostname") + if not agents: + return "No agents registered." + return "\n".join(f"- {a['hostname']} ({a['agent_type']}) @ {a['ip_address']} — {a['status'].upper()}" for a in agents) + elif tool_name == "jarvis_alerts": + alerts = await db_fetchall("SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10") + return "\n".join(f"- [{a['severity'].upper()}] {a['title']}: {a['message']}" for a in alerts) or "No active alerts." + elif tool_name == "current_time": + return datetime.utcnow().strftime("UTC: %Y-%m-%d %H:%M:%S") + else: + return f"Unknown tool: {tool_name}" + except Exception as e: + return f"Tool error ({tool_name}): {str(e)[:200]}" + +async def handle_tool_loop(payload: dict) -> dict: + task = payload.get("task", "").strip() + max_iterations = min(int(payload.get("max_iterations", 15)), 200) + system_prompt = payload.get("system", ( + "You are JARVIS, an advanced autonomous AI assistant with access to tools. " + "Use tools methodically to complete the task. Be thorough but concise. " + "When you have enough information, provide a clear final answer." + )) + if not task: + raise ValueError("Missing task") + + log.info(f"[IRON] Tool loop: '{task[:80]}' max_iter={max_iterations}") + + import anthropic + client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY) + messages = [{"role": "user", "content": task}] + iteration = 0 + final_text = "" + + while iteration < max_iterations: + iteration += 1 + response = await client.messages.create( + model=CLAUDE_MODEL, max_tokens=4096, system=system_prompt, + tools=AGENT_TOOLS, messages=messages, + ) + assistant_content = [] + text_parts = [] + tool_uses = [] + for block in response.content: + if block.type == "text": + assistant_content.append({"type": "text", "text": block.text}) + text_parts.append(block.text) + elif block.type == "tool_use": + assistant_content.append({"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}) + tool_uses.append(block) + messages.append({"role": "assistant", "content": assistant_content}) + if text_parts: + final_text = "\n".join(text_parts) + if response.stop_reason == "end_turn" or not tool_uses: + log.info(f"[IRON] Complete after {iteration} iterations") + break + tool_results = [] + for tu in tool_uses: + log.info(f"[IRON] Tool: {tu.name}") + result = await execute_agent_tool(tu.name, tu.input) + tool_results.append({"type": "tool_result", "tool_use_id": tu.id, "content": result[:3000]}) + messages.append({"role": "user", "content": tool_results}) + + tools_used = list({b["name"] for m in messages if isinstance(m["content"], list) + for b in m["content"] if isinstance(b, dict) and b.get("type") == "tool_use"}) + return {"task": task, "result": final_text, "iterations": iteration, "tools_used": tools_used} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 3: COMMS PROTOCOL — GMAIL TRIAGE +# ═══════════════════════════════════════════════════════════════════════════════ + +def _imap_fetch_sync(account: str, max_msgs: int) -> list: + """Synchronous IMAP fetch — called via run_in_executor.""" + if account == "icloud": + host, port, user, passwd = "imap.mail.me.com", 993, ICLOUD_USER, ICLOUD_PASS + else: + host, port, user, passwd = "imap.gmail.com", 993, GMAIL_USER, GMAIL_PASS + if not user or not passwd: + return [] + try: + mbox = imaplib.IMAP4_SSL(host, port) + mbox.login(user, passwd) + mbox.select("INBOX") + _, data = mbox.search(None, "ALL") + ids = data[0].split()[-max_msgs:] + msgs = [] + for mid in reversed(ids): + _, raw = mbox.fetch(mid, "(RFC822)") + if not raw or not raw[0]: + continue + msg = _email_lib.message_from_bytes(raw[0][1]) + # Subject + subj_raw = msg.get("Subject", "") + subject = "" + for part, enc in _decode_header(subj_raw): + if isinstance(part, bytes): + subject += part.decode(enc or "utf-8", errors="replace") + else: + subject += str(part) + # From + from_raw = msg.get("From", "") + from_name = "" + for part, enc in _decode_header(from_raw): + if isinstance(part, bytes): + from_name += part.decode(enc or "utf-8", errors="replace") + else: + from_name += str(part) + from_name = from_name.strip().strip('"') + em = re.search(r"<([^>]+)>", from_raw) + from_email = em.group(1) if em else from_raw + # Date + date_str = msg.get("Date", "") + # Body preview + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + body = payload.decode(part.get_content_charset() or "utf-8", errors="replace") + break + else: + payload = msg.get_payload(decode=True) + if payload: + body = payload.decode(msg.get_content_charset() or "utf-8", errors="replace") + body = " ".join(body.split())[:500] + msg_uid = (msg.get("Message-ID", "") or f"{from_email}:{subject}:{date_str}").strip("<>") + msgs.append({ + "msg_id": msg_uid[:255], + "from_name": from_name[:100], + "from_email": from_email[:100], + "subject": subject[:255], + "date_raw": date_str, + "body": body, + }) + mbox.logout() + return msgs + except Exception as e: + log.warning(f"IMAP fetch failed ({account}): {e}") + return [] + +async def handle_gmail_triage(payload: dict) -> dict: + account = payload.get("account", "gmail") + max_emails = min(int(payload.get("max_emails", 20)), 40) + provider = payload.get("provider", "claude") + + log.info(f"[COMMS] Gmail triage: account={account} max={max_emails}") + + loop = asyncio.get_event_loop() + emails = await loop.run_in_executor(None, lambda: _imap_fetch_sync(account, max_emails)) + + if not emails: + return {"account": account, "triaged": 0, "urgent": 0, "action": 0, + "meeting": 0, "items": [], "error": "No emails fetched or IMAP unavailable"} + + email_list = "" + for i, e in enumerate(emails, 1): + email_list += ( + f"EMAIL {i}:\n" + f"From: {e['from_name']} <{e['from_email']}>\n" + f"Subject: {e['subject']}\n" + f"Date: {e['date_raw']}\n" + f"Body: {e['body'][:400]}\n\n" + ) + + triage_prompt = ( + f"You are JARVIS, triaging {len(emails)} emails for Myron Blair.\n\n" + "For each email assign:\n" + "- category: urgent | action | reply | meeting | info | promo | spam\n" + "- priority: 1-10 (10 = drop everything)\n" + "- summary: one sentence\n" + "- draft_reply: for urgent/action/reply/meeting ONLY — brief professional reply. Empty string otherwise.\n\n" + "Return ONLY a valid JSON array. Example:\n" + '[{"index":1,"category":"urgent","priority":9,"summary":"Contract needs signing today","draft_reply":"Hi, I will review and sign today."}]\n\n' + f"EMAILS TO TRIAGE:\n{email_list}" + ) + + try: + raw = await llm_call([{"role": "user", "content": triage_prompt}], provider) + raw = raw.strip() + if raw.startswith("```"): + raw = "\n".join(raw.split("\n")[1:]) + if raw.endswith("```"): + raw = raw[:-3] + triage_items = json.loads(raw.strip()) + except Exception as e: + log.warning(f"[COMMS] Triage parse error: {e}") + triage_items = [{"index": i+1, "category": "info", "priority": 3, + "summary": f"Subject: {e2['subject']}", "draft_reply": ""} + for i, e2 in enumerate(emails)] + + # Save to email_triage table + saved = 0 + for item in triage_items: + idx = int(item.get("index", 0)) - 1 + if idx < 0 or idx >= len(emails): + continue + e = emails[idx] + try: + date_parsed = None + if e.get("date_raw"): + try: + date_parsed = parsedate_to_datetime(e["date_raw"]).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + pass + await db_execute( + """INSERT INTO email_triage + (msg_id, account, from_name, from_email, subject, date_received, + category, priority, summary, draft_reply, action_taken) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'none') + ON DUPLICATE KEY UPDATE + category=VALUES(category), priority=VALUES(priority), + summary=VALUES(summary), draft_reply=VALUES(draft_reply)""", + (e["msg_id"], account, e["from_name"], e["from_email"], e["subject"], + date_parsed, item.get("category", "info"), int(item.get("priority", 3)), + item.get("summary", "")[:500], item.get("draft_reply", "")[:3000]) + ) + saved += 1 + except Exception as ex: + log.debug(f"[COMMS] Save triage error: {ex}") + + counts = {} + for item in triage_items: + c = item.get("category", "info") + counts[c] = counts.get(c, 0) + 1 + + urgent_items = [] + for it in triage_items: + idx = int(it.get("index", 0)) - 1 + if 0 <= idx < len(emails) and it.get("category") in ("urgent", "action", "reply", "meeting"): + urgent_items.append({ + "from": emails[idx]["from_name"], + "from_email": emails[idx]["from_email"], + "subject": emails[idx]["subject"][:80], + "summary": it.get("summary", ""), + "priority": it.get("priority", 0), + "draft_reply": it.get("draft_reply", ""), + "category": it.get("category", ""), + }) + urgent_items.sort(key=lambda x: -x["priority"]) + + log.info(f"[COMMS] Triage complete: {len(emails)} emails, {saved} saved, counts={counts}") + return { + "account": account, + "triaged": len(emails), + "saved": saved, + "counts": counts, + "urgent": counts.get("urgent", 0), + "action": counts.get("action", 0), + "meeting": counts.get("meeting", 0), + "reply": counts.get("reply", 0), + "items": urgent_items[:10], + } + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 3: FIELD PROTOCOL — REMOTE EXEC +# ═══════════════════════════════════════════════════════════════════════════════ + +async def handle_remote_exec(payload: dict) -> dict: + """ + Queue a command to a JARVIS Field Station agent and wait for the result. + payload: { agent, command_type, command_data, timeout } + """ + agent_name = payload.get("agent", "").strip() + cmd_type = payload.get("command_type", "ping") + cmd_data = payload.get("command_data", {}) + timeout_secs = min(int(payload.get("timeout", 35)), 120) + + if not agent_name: + raise ValueError("Missing agent name") + + log.info(f"[FIELD] remote_exec {cmd_type} on {agent_name}") + + # NOTE: _dispatch_agent_command is defined in Phase 4 block below. + # Forward declaration — called at runtime, not at import. + dispatch = await _dispatch_agent_command(agent_name, cmd_type, cmd_data, timeout_secs) + return { + "agent": dispatch["agent"], + "agent_id": dispatch["agent_id"], + "command_type": cmd_type, + "command_data": cmd_data, + "cmd_id": dispatch["cmd_id"], + "status": dispatch["status"], + "result": dispatch["result"], + } + +# ── PHASE 4: VISION PROTOCOL ────────────────────────────────────────────────── + +async def _dispatch_agent_command(agent_name: str, cmd_type: str, cmd_data: dict, + timeout_secs: int = 45) -> dict: + """Shared helper: find agent, queue command, poll for result.""" + agent = await db_fetchone( + """SELECT agent_id, hostname, status FROM registered_agents + WHERE (hostname LIKE %s OR agent_id LIKE %s) AND status='online' LIMIT 1""", + (f"%{agent_name}%", f"%{agent_name}%") + ) + if not agent: + all_agents = await db_fetchall("SELECT hostname FROM registered_agents WHERE status='online'") + names = [a["hostname"] for a in all_agents] + raise ValueError(f"No online agent matching '{agent_name}'. Online: {', '.join(names) or 'none'}") + + cmd_id = await db_execute( + "INSERT INTO agent_commands (agent_id, command_type, command_data) VALUES (%s, %s, %s)", + (agent["agent_id"], cmd_type, json.dumps(cmd_data)) + ) + elapsed = 0 + while elapsed < timeout_secs: + await asyncio.sleep(2) + elapsed += 2 + row = await db_fetchone("SELECT status, result FROM agent_commands WHERE id=%s", (cmd_id,)) + if row and row["status"] in ("executed", "failed"): + result = row.get("result") + if result and isinstance(result, str): + try: + result = json.loads(result) + except Exception: + pass + return {"agent": agent["hostname"], "agent_id": agent["agent_id"], + "cmd_id": cmd_id, "status": row["status"], "result": result or {}} + await db_execute("UPDATE agent_commands SET status='failed' WHERE id=%s AND status='pending'", (cmd_id,)) + raise TimeoutError(f"No response from {agent['hostname']} within {timeout_secs}s") + + +async def handle_screenshot(payload: dict) -> dict: + """ + Capture a screenshot (or system snapshot) from a field agent, then optionally + run vision analysis via Claude. + payload: { agent, analyze: true|false, analyze_prompt: "...", timeout: 45 } + """ + agent_name = payload.get("agent", "").strip() + do_analyze = bool(payload.get("analyze", True)) + analyze_prompt = payload.get("analyze_prompt", "Describe what you see on this screen in detail. Note any important status indicators, errors, running processes, or interesting information.") + timeout_secs = min(int(payload.get("timeout", 45)), 120) + + if not agent_name: + raise ValueError("Missing agent name") + + log.info(f"[VISION] Screenshot request: agent={agent_name} analyze={do_analyze}") + + # Dispatch screenshot command to field agent + dispatch = await _dispatch_agent_command(agent_name, "screenshot", {}, timeout_secs) + result = dispatch.get("result", {}) + hostname = dispatch.get("agent", agent_name) + + if dispatch.get("status") == "failed" or not result.get("success"): + err = result.get("error", "Agent returned failure") if isinstance(result, dict) else str(result) + return {"agent": hostname, "success": False, "error": err} + + image_b64 = result.get("image_b64", "") + method = result.get("method", "unknown") + width = result.get("width", 0) + height = result.get("height", 0) + file_size = result.get("file_size", 0) + + # Run Claude vision analysis if we have an image + analysis = "" + provider_used = "" + if do_analyze and image_b64: + analysis, provider_used = await _vision_call(image_b64, analyze_prompt) + log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)") + elif do_analyze and not image_b64 and result.get("snapshot_type") == "text": + # Text-only sysinfo snapshot — summarize with LLM + try: + snap_text = json.dumps(result, indent=2)[:3000] + prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}" + analysis = await llm_call([{"role": "user", "content": prompt}], "groq") + provider_used = "groq" + except Exception as e: + analysis = f"Analysis unavailable: {e}" + + # Store screenshot + screenshot_id = await db_execute( + """INSERT INTO agent_screenshots + (agent_id, hostname, method, image_b64, width, height, file_size, + vision_analysis, vision_provider) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (dispatch.get("agent_id", ""), hostname, method, + image_b64, width, height, file_size, analysis, provider_used) + ) + + log.info(f"[VISION] Screenshot saved: id={screenshot_id} agent={hostname} method={method}") + return { + "agent": hostname, + "screenshot_id": screenshot_id, + "method": method, + "width": width, + "height": height, + "file_size": file_size, + "has_image": bool(image_b64), + "analysis": analysis, + "provider": provider_used, + } + + +async def handle_vision(payload: dict) -> dict: + """ + Run Claude vision on an already-stored screenshot or a provided base64 image. + payload: { screenshot_id OR image_b64, prompt, provider } + """ + screenshot_id = payload.get("screenshot_id") + image_b64 = payload.get("image_b64", "") + prompt = payload.get("prompt", "Describe this image in detail.") + provider = payload.get("provider", "claude") + + if screenshot_id: + row = await db_fetchone( + "SELECT image_b64, hostname, method FROM agent_screenshots WHERE id=%s", + (int(screenshot_id),) + ) + if not row or not row["image_b64"]: + raise ValueError(f"Screenshot {screenshot_id} not found or has no image data") + image_b64 = row["image_b64"] + hostname = row.get("hostname", "unknown") + else: + hostname = "direct" + + if not image_b64: + raise ValueError("No image data provided") + + log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}") + + analysis, provider_used = await _vision_call(image_b64, prompt) + + # Update stored screenshot if we have an ID + if screenshot_id: + await db_execute( + "UPDATE agent_screenshots SET vision_analysis=%s, vision_provider=%s WHERE id=%s", + (analysis, provider_used, int(screenshot_id)) + ) + + return { + "agent": hostname, + "screenshot_id": screenshot_id, + "prompt": prompt, + "analysis": analysis, + "provider": provider_used, + } + + +async def handle_sysinfo(payload: dict) -> dict: + """ + Get a structured system snapshot from a field agent (no image). + Optionally ask Claude to summarize/assess the health of the machine. + payload: { agent, analyze: true|false, timeout: 30 } + """ + agent_name = payload.get("agent", "").strip() + do_analyze = bool(payload.get("analyze", True)) + timeout_secs = min(int(payload.get("timeout", 30)), 60) + + if not agent_name: + raise ValueError("Missing agent name") + + dispatch = await _dispatch_agent_command(agent_name, "sysinfo", {}, timeout_secs) + result = dispatch.get("result", {}) + hostname = dispatch.get("agent", agent_name) + + if dispatch.get("status") == "failed": + return {"agent": hostname, "success": False, "error": result.get("error", "Command failed")} + + analysis = "" + if do_analyze: + try: + snap = json.dumps(result, indent=2)[:3000] + summary_prompt = ( + f"You are JARVIS analyzing a field station sysinfo snapshot from '{hostname}'.\n\n" + f"Snapshot:\n{snap}\n\n" + "Provide a concise health assessment: status (healthy/warning/critical), " + "key metrics, any concerns, and recommended actions if needed. " + "Keep it under 150 words, JARVIS style." + ) + analysis = await llm_call([{"role": "user", "content": summary_prompt}], "claude") + except Exception as e: + analysis = f"Analysis unavailable: {e}" + + return { + "agent": hostname, + "success": True, + "snapshot": result, + "analysis": analysis, + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# JOB HANDLER REGISTRY +# ═══════════════════════════════════════════════════════════════════════════════ + +JOB_HANDLERS = { + "ping": handle_ping, + "echo": handle_echo, + "shell": handle_shell, + "llm": handle_llm, + "research": handle_research, + "tool_loop": handle_tool_loop, + "gmail_triage": handle_gmail_triage, + "remote_exec": handle_remote_exec, + # Phase 4 + "screenshot": handle_screenshot, + "vision": handle_vision, + "sysinfo": handle_sysinfo, + # Phase 5 + "sitrep": None, # registered after guardian functions are defined + "guardian_config": None, +} + +# ── PHASE 5: GUARDIAN MODE ──────────────────────────────────────────────────── + +# In-memory state: tracks last alert time per (agent_id, metric) to debounce +_guardian_state: dict = { + "enabled": True, + "last_scan": None, + "last_sitrep": None, + "alert_cooldown": {}, # key: "agent_id:metric" → last_alert_epoch + "online_snapshot": {}, # agent_id → was_online bool +} +GUARDIAN_COOLDOWN = 600 # seconds between repeat alerts for same metric + + +async def _guardian_get_config() -> dict: + rows = await db_fetchall("SELECT key_name, value FROM guardian_config") + return {r["key_name"]: r["value"] for r in rows} if rows else {} + + +async def _guardian_emit(event_type: str, severity: str, agent_id: str, + hostname: str, metric: str, value: float, + threshold: float, message: str, ai_analysis: str = "") -> int: + return await db_execute( + """INSERT INTO guardian_events + (event_type, severity, agent_id, hostname, metric, value, threshold, + message, ai_analysis) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (event_type, severity, agent_id, hostname, metric, value, threshold, + message, ai_analysis) + ) + + +async def _guardian_cooldown_ok(agent_id: str, metric: str) -> bool: + """Return True if enough time has passed since last alert for this agent+metric.""" + import time + key = f"{agent_id}:{metric}" + last = _guardian_state["alert_cooldown"].get(key, 0) + if (time.time() - last) >= GUARDIAN_COOLDOWN: + _guardian_state["alert_cooldown"][key] = time.time() + return True + return False + + +async def guardian_loop() -> None: + """ + Background task — runs every SCAN_INTERVAL seconds. + Checks all agents for anomalies, emits guardian_events, optionally + generates AI analysis for critical findings. + """ + import time + log.info("[GUARDIAN] Guardian Mode activated") + + while True: + try: + cfg = await _guardian_get_config() + if cfg.get("enabled", "1") == "0": + await asyncio.sleep(60) + continue + + scan_interval = int(cfg.get("scan_interval", 120)) + cpu_thresh = float(cfg.get("cpu_threshold", 85)) + mem_thresh = float(cfg.get("mem_threshold", 88)) + disk_thresh = float(cfg.get("disk_threshold", 88)) + offline_mins = int(cfg.get("offline_minutes", 3)) + ai_on = cfg.get("ai_analysis", "1") == "1" + proactive_chat = cfg.get("proactive_chat", "1") == "1" + + _guardian_state["last_scan"] = datetime.utcnow().isoformat() + critical_findings = [] + + # ── 1. Agent online/offline transitions ─────────────────────────── + agents = await db_fetchall( + "SELECT agent_id, hostname, status, last_seen FROM registered_agents" + ) + current_snapshot = {} + for ag in agents: + aid = ag["agent_id"] + hostname = ag["hostname"] + is_online = ag["status"] == "online" + current_snapshot[aid] = is_online + was_online = _guardian_state["online_snapshot"].get(aid) + + if was_online is True and not is_online: + # Agent went offline + if await _guardian_cooldown_ok(aid, "offline"): + eid = await _guardian_emit("agent_offline", "critical", aid, hostname, + "status", 0, 1, f"Field station '{hostname}' went OFFLINE") + critical_findings.append(f"⚠ {hostname} OFFLINE") + log.warning(f"[GUARDIAN] {hostname} went offline → event #{eid}") + + elif was_online is False and is_online: + # Agent came back + if await _guardian_cooldown_ok(aid, "online"): + await _guardian_emit("agent_online", "info", aid, hostname, + "status", 1, 0, f"Field station '{hostname}' back ONLINE") + log.info(f"[GUARDIAN] {hostname} came back online") + + _guardian_state["online_snapshot"] = current_snapshot + + # ── 2. Metrics analysis ─────────────────────────────────────────── + # Get latest metric per online agent + metrics_rows = await db_fetchall( + """SELECT m.agent_id, r.hostname, m.metric_data + FROM agent_metrics m + JOIN registered_agents r ON r.agent_id = m.agent_id + WHERE r.status = 'online' + AND m.recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE) + ORDER BY m.recorded_at DESC""" + ) + seen_agents: set = set() + for row in metrics_rows: + aid = row["agent_id"] + if aid in seen_agents: + continue + seen_agents.add(aid) + hostname = row["hostname"] + + try: + m = json.loads(row["metric_data"]) if isinstance(row["metric_data"], str) else row["metric_data"] + sys_data = m + + cpu = sys_data.get("cpu_percent") + if cpu is not None and float(cpu) >= cpu_thresh: + sev = "critical" if float(cpu) >= 95 else "warning" + if await _guardian_cooldown_ok(aid, "cpu"): + msg = f"{hostname}: CPU at {cpu:.1f}% (threshold: {cpu_thresh}%)" + await _guardian_emit("cpu_high", sev, aid, hostname, + "cpu_percent", float(cpu), cpu_thresh, msg) + critical_findings.append(f"CPU {hostname} {cpu:.0f}%") + + mem = sys_data.get("memory", {}) + mem_pct = mem.get("percent") if isinstance(mem, dict) else None + if mem_pct is not None and float(mem_pct) >= mem_thresh: + sev = "critical" if float(mem_pct) >= 95 else "warning" + if await _guardian_cooldown_ok(aid, "memory"): + msg = f"{hostname}: Memory at {mem_pct:.1f}% (threshold: {mem_thresh}%)" + await _guardian_emit("mem_high", sev, aid, hostname, + "mem_percent", float(mem_pct), mem_thresh, msg) + critical_findings.append(f"MEM {hostname} {mem_pct:.0f}%") + + disks = sys_data.get("disk", []) + if isinstance(disks, list): + for disk in disks: + dpct = disk.get("percent", 0) + if dpct and float(str(dpct).rstrip("%")) >= disk_thresh: + mount = disk.get("mountpoint", "/") + key = f"disk_{mount}" + if await _guardian_cooldown_ok(aid, key): + msg = f"{hostname}: Disk {mount} at {dpct}% (threshold: {disk_thresh}%)" + sev = "critical" if float(str(dpct).rstrip("%")) >= 95 else "warning" + await _guardian_emit("disk_high", sev, aid, hostname, + key, float(str(dpct).rstrip("%")), disk_thresh, msg) + critical_findings.append(f"DISK {hostname}{mount} {dpct}%") + + # Service anomalies + services = sys_data.get("services", []) + for svc in services: + if svc.get("status") == "failed": + svc_name = svc.get("service", "unknown") + if await _guardian_cooldown_ok(aid, f"svc_{svc_name}"): + msg = f"{hostname}: Service '{svc_name}' is FAILED" + await _guardian_emit("service_down", "critical", aid, hostname, + f"service:{svc_name}", 0, 1, msg) + critical_findings.append(f"SVC {hostname}/{svc_name} FAILED") + + except Exception as e: + log.debug(f"[GUARDIAN] Metrics parse error for {row['hostname']}: {e}") + + # ── 3. AI analysis for critical findings ────────────────────────── + if critical_findings and ai_on: + try: + findings_text = "\n".join(f"- {f}" for f in critical_findings) + ai_prompt = ( + f"You are JARVIS. The following anomalies were just detected:\n\n" + f"{findings_text}\n\n" + "Provide a brief (2-3 sentences), Iron Man-style alert message " + "for Myron. Be direct about severity and what action to take. " + "No markdown, no headers." + ) + ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq") + # Update the most recent guardian event with AI analysis + await db_execute( + """UPDATE guardian_events SET ai_analysis=%s + WHERE ai_analysis='' AND created_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE) + ORDER BY id DESC LIMIT 1""", + (ai_msg,) + ) + # Inject proactive chat message if configured + if proactive_chat: + await _guardian_inject_chat(f"◈ GUARDIAN ALERT — {ai_msg}") + except Exception as e: + log.warning(f"[GUARDIAN] AI analysis error: {e}") + + if critical_findings: + log.warning(f"[GUARDIAN] Scan complete — {len(critical_findings)} findings: {', '.join(critical_findings)}") + else: + log.debug(f"[GUARDIAN] Scan complete — all clear") + + except Exception as exc: + log.error(f"[GUARDIAN] Loop error: {exc}") + + await asyncio.sleep(scan_interval) + + +async def _guardian_inject_chat(message: str) -> None: + """Write a proactive JARVIS message into the conversations table so the HUD picks it up.""" + try: + await db_execute( + """INSERT INTO conversations (session_id, role, message, created_at) + VALUES ('guardian', 'assistant', %s, NOW())""", + (message,) + ) + except Exception as e: + log.debug(f"[GUARDIAN] Chat inject error: {e}") + + +async def handle_sitrep(payload: dict) -> dict: + """ + Situation Report — comprehensive health briefing across all field stations. + payload: { detail: brief|full, provider: groq } + """ + detail = payload.get("detail", "full") + provider = payload.get("provider", "groq") + + log.info(f"[GUARDIAN] SITREP requested (detail={detail})") + + # 1. Gather agent status + agents = await db_fetchall( + """SELECT agent_id, hostname, status, ip_address, agent_type, + capabilities, last_seen + FROM registered_agents ORDER BY status DESC, hostname ASC""" + ) + + # 2. Get latest metrics per agent + metrics_map = {} + metrics_rows = await db_fetchall( + """SELECT m.agent_id, m.metric_data, m.recorded_at + FROM agent_metrics m + WHERE m.recorded_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE) + ORDER BY m.recorded_at DESC""" + ) + for row in metrics_rows: + if row["agent_id"] not in metrics_map: + try: + m = json.loads(row["metric_data"]) if isinstance(row["metric_data"], str) else row["metric_data"] + metrics_map[row["agent_id"]] = m + except Exception: + pass + + # 3. Recent guardian events (last 24h) + recent_events = await db_fetchall( + """SELECT event_type, severity, hostname, metric, value, threshold, message, created_at + FROM guardian_events + WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) + ORDER BY created_at DESC LIMIT 20""" + ) + + # 4. Arc Reactor job stats + arc_stats = await db_fetchone( + """SELECT + SUM(status='queued') AS queued, + SUM(status='running') AS running, + SUM(status='done') AS done, + SUM(status='failed') AS failed + FROM arc_jobs WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)""" + ) + + # 5. Build context for Claude + agent_lines = [] + for ag in agents: + m = metrics_map.get(ag["agent_id"], {}) + sys = m if m else {} + cpu = sys.get("cpu_percent", "?") + mem = sys.get("memory", {}).get("percent", "?") if isinstance(sys.get("memory"), dict) else "?" + disk = next((d.get("percent","?") for d in (sys.get("disk") or []) if d.get("mount",d.get("mountpoint","")) == "/"), "?") + line = (f" {ag['hostname']} ({ag['status'].upper()}) — " + f"CPU:{cpu}% MEM:{mem}% DISK:{disk}%") + agent_lines.append(line) + + event_lines = [ + f" [{e['severity'].upper()}] {e['hostname']}: {e['message']} ({e['created_at']})" + for e in recent_events + ] or [" No events in last 24h"] + + sitrep_context = ( + f"JARVIS SITREP — {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}\n\n" + f"FIELD STATIONS ({len(agents)} total):\n" + "\n".join(agent_lines) + "\n\n" + f"ARC REACTOR (last 24h): queued={arc_stats.get('queued',0)} " + f"running={arc_stats.get('running',0)} done={arc_stats.get('done',0)} " + f"failed={arc_stats.get('failed',0)}\n\n" + f"GUARDIAN EVENTS (last 24h):\n" + "\n".join(event_lines) + ) + + prompt = ( + f"{sitrep_context}\n\n" + f"You are JARVIS. Deliver a {'brief 3-sentence' if detail == 'brief' else 'comprehensive'} " + f"situation report for Myron Blair. Iron Man style — direct, confident, actionable. " + f"Highlight: overall health status, any critical issues requiring immediate attention, " + f"and key metrics. " + + ("Keep it under 80 words." if detail == "brief" else "Structure: Overall Status → Issues → Metrics → Recommendation.") + ) + + analysis = await llm_call([{"role": "user", "content": prompt}], provider) + _guardian_state["last_sitrep"] = datetime.utcnow().isoformat() + + # Log SITREP as guardian event + await _guardian_emit("sitrep", "info", "system", "system", "sitrep", 0, 0, + f"SITREP generated ({detail})", analysis) + + online_count = sum(1 for a in agents if a["status"] == "online") + offline_count = len(agents) - online_count + critical_events = sum(1 for e in recent_events if e["severity"] == "critical") + + return { + "sitrep": analysis, + "agents_online": online_count, + "agents_offline": offline_count, + "agents_total": len(agents), + "events_24h": len(recent_events), + "critical_24h": critical_events, + "arc_stats": dict(arc_stats) if arc_stats else {}, + "timestamp": datetime.utcnow().isoformat(), + "detail": detail, + } + + +async def handle_guardian_config(payload: dict) -> dict: + """ + Get or set Guardian Mode configuration. + payload: { action: get|set, key: ..., value: ... } or { action: set, config: {...} } + """ + action = payload.get("action", "get") + + if action == "get": + cfg = await _guardian_get_config() + return {"config": cfg, "guardian_state": { + "enabled": _guardian_state.get("enabled"), + "last_scan": _guardian_state.get("last_scan"), + "last_sitrep": _guardian_state.get("last_sitrep"), + }} + + elif action == "set": + updates = payload.get("config", {}) + if payload.get("key") and payload.get("value") is not None: + updates[payload["key"]] = str(payload["value"]) + for k, v in updates.items(): + await db_execute( + "INSERT INTO guardian_config (key_name, value) VALUES (%s, %s) " + "ON DUPLICATE KEY UPDATE value=%s, updated_at=NOW()", + (k, str(v), str(v)) + ) + if k == "enabled": + _guardian_state["enabled"] = v not in ("0", "false", "off") + return {"ok": True, "updated": list(updates.keys())} + + raise ValueError(f"Unknown guardian_config action: {action}") + + +# Register Phase 5 handlers +JOB_HANDLERS["sitrep"] = handle_sitrep +JOB_HANDLERS["guardian_config"] = handle_guardian_config + +# ── PHASE 6: COMMS v2 — SEND EMAIL + SCHEDULE + MEETING PREP ───────────────── + +import smtplib +import ssl as _ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr, make_msgid + +SMTP_SETTINGS = { + "gmail": ("smtp.gmail.com", 587, GMAIL_USER, GMAIL_PASS), + "icloud": ("smtp.mail.me.com", 587, ICLOUD_USER, ICLOUD_PASS), +} + +def _smtp_send_sync(account: str, to_email: str, to_name: str, + subject: str, body: str, + reply_to_msg_id: str = "") -> dict: + """Synchronous SMTP send — run in executor.""" + host, port, user, passwd = SMTP_SETTINGS.get(account, SMTP_SETTINGS["gmail"]) + if not user or not passwd: + return {"success": False, "error": "No credentials configured for account"} + + try: + msg = MIMEMultipart("alternative") + from_addr = formataddr(("JARVIS / Myron Blair", user)) + msg["From"] = from_addr + msg["To"] = formataddr((to_name, to_email)) if to_name else to_email + msg["Subject"] = subject + msg_id = make_msgid(domain=user.split("@")[1]) + msg["Message-ID"] = msg_id + if reply_to_msg_id: + msg["In-Reply-To"] = f"<{reply_to_msg_id.strip('<>')}>" + msg["References"] = f"<{reply_to_msg_id.strip('<>')}>" + + msg.attach(MIMEText(body, "plain", "utf-8")) + + ctx = _ssl.create_default_context() + with smtplib.SMTP(host, port, timeout=20) as smtp: + smtp.ehlo() + smtp.starttls(context=ctx) + smtp.login(user, passwd) + smtp.sendmail(user, [to_email], msg.as_bytes()) + + return {"success": True, "message_id": msg_id} + except Exception as e: + return {"success": False, "error": str(e)} + + +async def handle_send_email(payload: dict) -> dict: + """ + Send an email from Gmail or iCloud. + payload: { + account: gmail|icloud, + to_email: recipient address, + to_name: recipient display name (optional), + subject: subject line, + body: plain-text body, + triage_id: int (optional — if replying to a triage item), + reply_to_msg_id: original Message-ID for threading (optional), + compose: true — ask Claude to draft the body from a prompt first + } + """ + account = payload.get("account", "gmail") + to_email = payload.get("to_email", "").strip() + to_name = payload.get("to_name", "").strip() + subject = payload.get("subject", "").strip() + body = payload.get("body", "").strip() + triage_id = payload.get("triage_id") + reply_msg_id = payload.get("reply_to_msg_id", "") + compose_prompt = payload.get("compose_prompt", "") + + # If triage_id is given, pull recipient + subject + draft from email_triage + if triage_id and not to_email: + row = await db_fetchone( + "SELECT from_email, from_name, subject, draft_reply, msg_id FROM email_triage WHERE id=%s", + (int(triage_id),) + ) + if row: + to_email = to_email or row["from_email"] + to_name = to_name or row["from_name"] + subject = subject or ("Re: " + (row["subject"] or "")) + body = body or row.get("draft_reply", "") + reply_msg_id = reply_msg_id or row.get("msg_id", "") + + if not to_email: + raise ValueError("Missing to_email") + + # If compose requested, use Claude to write the body + if compose_prompt and not body: + draft_prompt = ( + f"You are drafting an email on behalf of Myron Blair.\n" + f"To: {to_name or to_email}\n" + f"Subject: {subject}\n\n" + f"Instructions: {compose_prompt}\n\n" + "Write a professional, concise email body. No subject line, no salutation header. " + "Start with 'Hi [name],' or similar. Sign off as 'Myron Blair'." + ) + body = await llm_call([{"role": "user", "content": draft_prompt}], "claude") + + if not subject: + raise ValueError("Missing subject") + if not body: + raise ValueError("Missing email body — provide body or compose_prompt") + + log.info(f"[COMMS] Sending email: {account} → {to_email} | {subject[:60]}") + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, lambda: _smtp_send_sync(account, to_email, to_name, subject, body, reply_msg_id) + ) + + # Record in email_sent + status = "sent" if result["success"] else "failed" + await db_execute( + """INSERT INTO email_sent + (account, to_email, to_name, subject, body, triage_id, status, error, message_id) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (account, to_email, to_name, subject, body, + triage_id or None, status, + result.get("error", ""), result.get("message_id", "")) + ) + + # Update triage item if this was a reply + if triage_id and result["success"]: + await db_execute( + "UPDATE email_triage SET action_taken='replied' WHERE id=%s", (int(triage_id),) + ) + + log.info(f"[COMMS] Send result: {status} → {to_email}") + return { + "success": result["success"], + "account": account, + "to_email": to_email, + "subject": subject, + "status": status, + "message_id": result.get("message_id", ""), + "error": result.get("error", ""), + "body": body, + } + + +async def handle_compose_email(payload: dict) -> dict: + """ + Compose an email from natural-language instructions — draft only (no auto-send). + payload: { to_email, to_name, subject_hint, instructions, account, send: false } + """ + to_email = payload.get("to_email", "").strip() + to_name = payload.get("to_name", "").strip() + subject_hint = payload.get("subject_hint", "").strip() + instructions = payload.get("instructions", "").strip() + account = payload.get("account", "gmail") + auto_send = bool(payload.get("send", False)) + + if not instructions: + raise ValueError("Missing instructions for compose") + + # Generate subject if not given + if not subject_hint: + subj_prompt = f"Write a concise email subject line (max 8 words) for an email about: {instructions}" + subject_hint = (await llm_call([{"role": "user", "content": subj_prompt}], "claude")).strip().strip('"') + + # Draft full body + draft_prompt = ( + f"You are drafting an email for Myron Blair.\n" + f"To: {to_name or to_email or 'the recipient'}\n" + f"Subject: {subject_hint}\n\n" + f"Instructions: {instructions}\n\n" + "Write a professional, concise email. Start with 'Hi [name],' or 'Hello,' " + "and sign off as 'Myron Blair'. Return only the email body text." + ) + body = await llm_call([{"role": "user", "content": draft_prompt}], "claude") + + if auto_send and to_email: + return await handle_send_email({ + "account": account, + "to_email": to_email, + "to_name": to_name, + "subject": subject_hint, + "body": body, + }) + + # Draft-only — save to email_sent with status='queued' for review + draft_id = await db_execute( + """INSERT INTO email_sent + (account, to_email, to_name, subject, body, status) + VALUES (%s,%s,%s,%s,%s,'queued')""", + (account, to_email, to_name, subject_hint, body) + ) + + log.info(f"[COMMS] Composed draft #{draft_id}: {to_email} | {subject_hint[:60]}") + return { + "draft_id": draft_id, + "to_email": to_email, + "to_name": to_name, + "subject": subject_hint, + "body": body, + "account": account, + "sent": False, + "status": "queued", + } + + +async def handle_schedule_event(payload: dict) -> dict: + """ + Create or find an appointment in the JARVIS planner from natural language. + payload: { title, description, start_at, end_at, location, category, + natural_language, all_day, reminder_min, generate_ics } + """ + nl = payload.get("natural_language", "").strip() + title = payload.get("title", "").strip() + start_at = payload.get("start_at", "") + end_at = payload.get("end_at", "") + location = payload.get("location", "") + category = payload.get("category", "work") + all_day = bool(payload.get("all_day", False)) + reminder = int(payload.get("reminder_min", 30)) + description = payload.get("description", "") + gen_ics = bool(payload.get("generate_ics", True)) + + # If natural language given, parse with Claude + if nl and (not title or not start_at): + now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + parse_prompt = ( + f"Current datetime: {now_str}\n\n" + f"Parse this scheduling request into JSON:\n\"{nl}\"\n\n" + "Return ONLY valid JSON with these fields (no markdown):\n" + '{"title":"...","start_at":"YYYY-MM-DD HH:MM:SS","end_at":"YYYY-MM-DD HH:MM:SS or null",' + '"location":"...","category":"personal|work|medical|other","all_day":false,' + '"description":"..."}\n' + "Use 24h time. Default duration 1 hour if not specified. " + "If only a date (no time) is given, set all_day=true." + ) + raw = await llm_call([{"role": "user", "content": parse_prompt}], "claude") + raw = raw.strip().strip("```json").strip("```").strip() + try: + parsed = json.loads(raw) + title = title or parsed.get("title", nl[:100]) + start_at = start_at or parsed.get("start_at", "") + end_at = end_at or parsed.get("end_at") or "" + location = location or parsed.get("location", "") + category = parsed.get("category", category) + all_day = parsed.get("all_day", all_day) + description = description or parsed.get("description", "") + except Exception as e: + log.warning(f"[COMMS] Schedule parse failed: {e} — raw: {raw[:100]}") + if not title: + title = nl[:100] + + if not title: + raise ValueError("Could not determine event title from input") + if not start_at: + raise ValueError("Could not determine start time from input") + + # Insert into appointments table + appt_id = await db_execute( + """INSERT INTO appointments + (title, description, category, start_at, end_at, location, + all_day, reminder_min, external_source) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'manual')""", + (title, description, category, start_at, + end_at or None, location, int(all_day), reminder) + ) + + # Generate ICS if requested + ics_content = "" + if gen_ics and start_at: + try: + from datetime import datetime as dt + uid = f"jarvis-{appt_id}@orbishosting.com" + dtstart = start_at.replace(" ", "T").replace("-", "").replace(":", "") + dtend = (end_at or start_at).replace(" ", "T").replace("-", "").replace(":", "") + dtstamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + ics_content = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//JARVIS//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\n" + f"DTSTAMP:{dtstamp}\r\n" + f"DTSTART:{dtstart}\r\n" + f"DTEND:{dtend}\r\n" + f"SUMMARY:{title}\r\n" + f"DESCRIPTION:{description}\r\n" + f"LOCATION:{location}\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + except Exception: + pass + + log.info(f"[COMMS] Event scheduled: #{appt_id} '{title}' @ {start_at}") + return { + "appointment_id": appt_id, + "title": title, + "start_at": start_at, + "end_at": end_at, + "location": location, + "category": category, + "all_day": all_day, + "ics": ics_content, + "description": description, + } + + +async def handle_meeting_prep(payload: dict) -> dict: + """ + Prepare a briefing for an upcoming meeting. + payload: { appointment_id OR title OR timeframe, research: true } + """ + appt_id = payload.get("appointment_id") + title = payload.get("title", "").strip() + timeframe = payload.get("timeframe", "today") + do_research = bool(payload.get("research", True)) + + # Find the appointment + appt = None + if appt_id: + appt = await db_fetchone("SELECT * FROM appointments WHERE id=%s", (int(appt_id),)) + elif title: + appt = await db_fetchone( + "SELECT * FROM appointments WHERE title LIKE %s AND start_at >= NOW() ORDER BY start_at LIMIT 1", + (f"%{title}%",) + ) + else: + appt = await db_fetchone( + """SELECT * FROM appointments + WHERE start_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 24 HOUR) + ORDER BY start_at LIMIT 1""" + ) + + if not appt: + # Check tasks too + task = await db_fetchone( + "SELECT * FROM tasks WHERE title LIKE %s AND status != 'done' ORDER BY due_date LIMIT 1", + (f"%{title}%",) + ) + if task: + appt = {"title": task["title"], "description": task.get("notes",""), + "start_at": str(task.get("due_date") or ""), "location": ""} + + if not appt: + return {"success": False, "error": f"No upcoming appointment found matching '{title or timeframe}'"} + + appt_title = appt.get("title", "") + appt_start = str(appt.get("start_at", "")) + appt_desc = appt.get("description", "") + appt_loc = appt.get("location", "") + + # Optional: kick off a research job on the meeting topic/attendees + research_summary = "" + if do_research and appt_title: + try: + research_result = await handle_research({ + "query": f"background information on: {appt_title}", + "depth": "quick", + "provider": "claude", + }) + research_summary = research_result.get("synthesis", "")[:1500] + except Exception: + pass + + # Build meeting briefing + context = ( + f"Meeting: {appt_title}\n" + f"Time: {appt_start}\n" + f"Location: {appt_loc or 'Not specified'}\n" + f"Notes: {appt_desc or 'None'}\n" + ) + if research_summary: + context += f"\nBackground Research:\n{research_summary}" + + prompt = ( + f"You are JARVIS. Prepare a concise meeting briefing for Myron Blair.\n\n" + f"{context}\n\n" + "Format: Meeting summary → Key context/background → Suggested talking points → " + "Action items to prepare. Keep it sharp and actionable. Iron Man style." + ) + briefing = await llm_call([{"role": "user", "content": prompt}], "claude") + + log.info(f"[COMMS] Meeting prep complete: '{appt_title}'") + return { + "appointment_id": appt.get("id"), + "title": appt_title, + "start_at": appt_start, + "location": appt_loc, + "briefing": briefing, + "has_research": bool(research_summary), + } + + +# Register Phase 6 handlers +JOB_HANDLERS["send_email"] = handle_send_email +JOB_HANDLERS["compose_email"] = handle_compose_email +JOB_HANDLERS["schedule_event"] = handle_schedule_event +JOB_HANDLERS["meeting_prep"] = handle_meeting_prep + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 7: MISSION OPS — multi-step automated workflows +# ═══════════════════════════════════════════════════════════════════════════════ + +import re as _re + +def _render_template(text: str, context: dict) -> str: + """Replace {{key.subkey}} tokens in a string/JSON with values from context.""" + if not isinstance(text, str): + return text + def replacer(m): + path = m.group(1).strip().split(".") + val = context + for p in path: + if isinstance(val, dict): + val = val.get(p, m.group(0)) + else: + return m.group(0) + return str(val) if not isinstance(val, (dict, list)) else json.dumps(val) + return _re.sub(r'\{\{([^}]+)\}\}', replacer, text) + +def _apply_templates(payload: Any, context: dict) -> Any: + """Recursively apply template substitution to a payload dict/list/str.""" + if isinstance(payload, str): + return _render_template(payload, context) + if isinstance(payload, dict): + return {k: _apply_templates(v, context) for k, v in payload.items()} + if isinstance(payload, list): + return [_apply_templates(v, context) for v in payload] + return payload + +async def _execute_mission(mission_id: int, trigger_source: str = "manual") -> dict: + """Run all steps of a mission sequentially, log results, return summary.""" + mission = await db_fetchone("SELECT * FROM missions WHERE id=%s", (mission_id,)) + if not mission: + return {"error": f"Mission {mission_id} not found"} + + steps = await db_fetchall( + "SELECT * FROM mission_steps WHERE mission_id=%s ORDER BY step_order ASC", + (mission_id,) + ) + if not steps: + return {"error": "Mission has no steps"} + + # Create run record + run_id = await db_execute( + "INSERT INTO mission_runs (mission_id, status, trigger_source, steps_log) VALUES (%s,'running',%s,'[]')", + (mission_id, trigger_source) + ) + await db_execute( + "UPDATE missions SET last_run_at=NOW(), run_count=run_count+1 WHERE id=%s", + (mission_id,) + ) + + context = {"trigger": {"source": trigger_source, "mission_id": mission_id}} + steps_log = [] + overall_status = "done" + + for i, step in enumerate(steps): + step_label = step.get("label") or step["job_type"] + raw_payload = step.get("job_payload") or {} + if isinstance(raw_payload, str): + try: + raw_payload = json.loads(raw_payload) + except Exception: + raw_payload = {} + + payload = _apply_templates(raw_payload, context) + step_entry = { + "step": i, + "label": step_label, + "job_type": step["job_type"], + "status": "running", + "result": None, + "error": None, + } + + try: + handler = JOB_HANDLERS.get(step["job_type"]) + if not handler: + raise ValueError(f"Unknown job type: {step['job_type']}") + result = await handler(payload) + step_entry["status"] = "done" + step_entry["result"] = result + context[f"step_{i}"] = result + log.info(f"[MISSION {mission_id} RUN {run_id}] Step {i} ({step_label}) done") + except Exception as exc: + step_entry["status"] = "failed" + step_entry["error"] = str(exc)[:500] + log.warning(f"[MISSION {mission_id} RUN {run_id}] Step {i} ({step_label}) failed: {exc}") + if not step.get("continue_on_failure"): + overall_status = "failed" + steps_log.append(step_entry) + break + + steps_log.append(step_entry) + + await db_execute( + "UPDATE mission_runs SET status=%s, steps_log=%s, completed_at=NOW() WHERE id=%s", + (overall_status, json.dumps(steps_log, default=str), run_id) + ) + return { + "run_id": run_id, + "status": overall_status, + "steps": len(steps_log), + "steps_log": steps_log, + } + +async def handle_run_mission(payload: dict) -> dict: + mission_id = int(payload.get("mission_id") or 0) + if not mission_id: + return {"error": "Missing mission_id"} + source = payload.get("trigger_source", "manual") + return await _execute_mission(mission_id, source) + +JOB_HANDLERS["run_mission"] = handle_run_mission + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 8: MISSION DIRECTIVES — OKR / goal tracking with AI review +# ═══════════════════════════════════════════════════════════════════════════════ + +async def handle_directive_review(payload: dict) -> dict: + """AI-powered review of active directives: progress analysis + recommendations.""" + directive_id = payload.get("directive_id") + provider = payload.get("provider", "claude") + + # Fetch directives + if directive_id: + dirs = await db_fetchall( + "SELECT * FROM directives WHERE id=%s", (directive_id,) + ) + else: + dirs = await db_fetchall( + "SELECT * FROM directives WHERE status='active' ORDER BY priority DESC LIMIT 10" + ) + + if not dirs: + return {"error": "No active directives found"} + + dir_ids = [d["id"] for d in dirs] + placeholders = ",".join(["%s"] * len(dir_ids)) + + key_results = await db_fetchall( + f"SELECT * FROM directive_key_results WHERE directive_id IN ({placeholders}) ORDER BY directive_id,id", + dir_ids + ) or [] + + links = await db_fetchall( + f"""SELECT dl.directive_id, dl.link_type, dl.note, + COALESCE(t.title, a.title) AS linked_title, + COALESCE(t.status, 'scheduled') AS linked_status + FROM directive_links dl + LEFT JOIN tasks t ON dl.link_type='task' AND t.id=dl.link_id + LEFT JOIN appointments a ON dl.link_type='appointment' AND a.id=dl.link_id + WHERE dl.directive_id IN ({placeholders})""", + dir_ids + ) or [] + + # Build context block + context_lines = [] + for d in dirs: + d_krs = [kr for kr in key_results if kr["directive_id"] == d["id"]] + d_links = [lk for lk in links if lk["directive_id"] == d["id"]] + kr_sum_cur = sum(float(kr["current_value"]) for kr in d_krs) + kr_sum_tgt = sum(float(kr["target_value"]) for kr in d_krs) or 1 + pct = round(kr_sum_cur / kr_sum_tgt * 100, 1) + + context_lines.append(f"\n## DIRECTIVE: {d['title']} ({d['category'].upper()}) — {pct}% complete") + context_lines.append(f" Status: {d['status']} | Priority: {d['priority']}/10 | Target: {d.get('target_date') or 'no date'}") + if d.get("description"): + context_lines.append(f" Description: {d['description']}") + for kr in d_krs: + context_lines.append(f" KR: {kr['title']} — {kr['current_value']}/{kr['target_value']} {kr['unit']}") + for lk in d_links: + status = lk.get("linked_status") or "" + context_lines.append(f" Linked {lk['link_type']}: {lk.get('linked_title') or lk.get('note') or '—'} [{status}]") + + context = "\n".join(context_lines) + today = datetime.utcnow().strftime("%Y-%m-%d") + + prompt = f"""You are JARVIS, an AI assistant conducting a Mission Directives review for your principal. +Today is {today}. + +{context} + +Provide a concise executive briefing covering: +1. Overall progress summary (2-3 sentences) +2. For each directive: current status, what's on track, what needs attention +3. Top 3 recommended next actions to move the highest-priority directives forward +4. Any directives at risk of missing their target date + +Keep the tone confident and action-oriented. Format with clear sections. Under 400 words.""" + + review_text = "" + try: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.anthropic.com/v1/messages", + headers={"x-api-key": CLAUDE_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}, + json={"model": CLAUDE_MODEL, "max_tokens": 600, "messages": [{"role": "user", "content": prompt}]}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + rdata = await resp.json() + review_text = rdata.get("content", [{}])[0].get("text", "") + except Exception as e: + review_text = f"[Review generation failed: {e}]" + + # Store in conversations so HUD can surface it + await db_execute( + "INSERT INTO conversations (session_id, role, message, created_at) VALUES ('guardian','assistant',%s,NOW())", + (review_text,) + ) + + return { + "review": review_text, + "directives": len(dirs), + "provider": provider, + } + +JOB_HANDLERS["directive_review"] = handle_directive_review + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 10: MEMORY CORE — auto-extraction knowledge graph +# ═══════════════════════════════════════════════════════════════════════════════ + +_MEMORY_EXTRACT_PROMPT = """Extract factual information about the user from this conversation exchange. Focus on: +- Preferences (likes/dislikes, preferred ways of working) +- People they mention (names, relationships, roles) +- Places (home, work, locations) +- Routines (schedules, habits) +- Goals (things they want to achieve) +- Instructions to JARVIS (always/never rules) +- Key facts about their life/work/projects + +Return a JSON array. Each element: {{"category": "", "subject": "", "predicate": "", "object": "", "confidence": <0.0-1.0>}} + +Rules: +- Only extract clearly stated facts, not speculation +- subject/predicate should be concise (under 50 chars each) +- confidence: 0.95 for explicit statements, 0.75-0.90 for clear implications +- Skip ephemeral info (what they asked just now, current queries) +- Max 8 facts per exchange +- Return [] if nothing durable worth remembering + +Exchange: +USER: {user_msg} +JARVIS: {asst_msg} + +Return ONLY valid JSON array, nothing else.""" + +async def handle_memory_extract(payload: dict) -> dict: + user_msg = str(payload.get("user_message", "")).strip() + asst_msg = str(payload.get("assistant_message", "")).strip() + conv_id = payload.get("conversation_id") + + if not user_msg and not asst_msg: + return {"ok": False, "reason": "empty exchange"} + + prompt = _MEMORY_EXTRACT_PROMPT.format( + user_msg=user_msg[:800], + asst_msg=asst_msg[:800] + ) + + raw = "" + try: + # Use Haiku for speed/cost; fall back to Groq + headers = {"x-api-key": CLAUDE_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"} + body = { + "model": "claude-haiku-4-5-20251001", + "max_tokens": 800, + "messages": [{"role": "user", "content": prompt}] + } + async with aiohttp.ClientSession() as session: + async with session.post("https://api.anthropic.com/v1/messages", json=body, + headers=headers, timeout=aiohttp.ClientTimeout(total=20)) as resp: + if resp.status == 200: + data = await resp.json() + raw = data["content"][0]["text"].strip() + else: + raise RuntimeError(f"Claude {resp.status}") + except Exception as e: + log.warning(f"[MEMORY] Claude extract failed ({e}), trying Groq") + try: + raw = await llm_call([{"role": "user", "content": prompt}], provider="groq") + except Exception as e2: + log.error(f"[MEMORY] All providers failed: {e2}") + return {"ok": False, "reason": str(e2)} + + # Parse JSON — strip code fences if present + if "```" in raw: + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + raw = raw.strip() + + try: + facts = json.loads(raw) + if not isinstance(facts, list): + facts = [] + except Exception: + log.warning(f"[MEMORY] JSON parse failed. Raw: {raw[:200]}") + return {"ok": False, "reason": "json_parse_failed"} + + valid_cats = {"preference", "person", "place", "routine", "goal", "fact", "instruction"} + inserted = 0 + for f in facts: + if not isinstance(f, dict): + continue + subj = str(f.get("subject", "")).strip()[:255] + pred = str(f.get("predicate", "is")).strip()[:255] + obj = str(f.get("object", "")).strip() + cat = f.get("category", "fact") + conf = min(1.0, max(0.0, float(f.get("confidence", 0.85)))) + if not subj or not obj or cat not in valid_cats: + continue + try: + await db_execute( + """INSERT INTO memory_facts (category, subject, predicate, object, confidence, source, conversation_id) + VALUES (%s, %s, %s, %s, %s, 'conversation', %s) + ON DUPLICATE KEY UPDATE + object=VALUES(object), + confidence=GREATEST(confidence, VALUES(confidence)), + confirmed_count=confirmed_count+1, + last_confirmed_at=NOW(), + active=1""", + (cat, subj, pred, obj, conf, conv_id) + ) + inserted += 1 + except Exception as e: + log.warning(f"[MEMORY] Insert ({subj},{pred}) failed: {e}") + + log.info(f"[MEMORY] Stored {inserted}/{len(facts)} facts from conv {conv_id}") + return {"ok": True, "extracted": inserted, "candidates": len(facts)} + + +async def handle_memory_store(payload: dict) -> dict: + """Explicit memory insertion — from 'remember that X' voice commands.""" + subject = str(payload.get("subject", "user")).strip()[:255] + predicate = str(payload.get("predicate", "note")).strip()[:255] + obj = str(payload.get("object", "")).strip() + category = payload.get("category", "fact") + if not obj: + return {"ok": False, "reason": "empty object"} + valid_cats = {"preference", "person", "place", "routine", "goal", "fact", "instruction"} + if category not in valid_cats: + category = "fact" + fid = await db_execute( + """INSERT INTO memory_facts (category, subject, predicate, object, confidence, source) + VALUES (%s, %s, %s, %s, 1.0, 'explicit') + ON DUPLICATE KEY UPDATE + object=VALUES(object), confidence=1.0, + confirmed_count=confirmed_count+1, last_confirmed_at=NOW(), active=1""", + (category, subject, predicate, obj) + ) + log.info(f"[MEMORY] Explicit store: [{category}] {subject} {predicate}: {obj}") + return {"ok": True, "id": fid} + + +JOB_HANDLERS["memory_extract"] = handle_memory_extract +JOB_HANDLERS["memory_store"] = handle_memory_store + +# ═══════════════════════════════════════════════════════════════════════════════ +# PHASE 9: CLEARANCE PROTOCOL — approval gating for high-risk operations +# ═══════════════════════════════════════════════════════════════════════════════ + +def _clearance_describe(job_type: str, payload: dict) -> str: + """Human-readable description of what the job would do.""" + if job_type == "shell": + cmd = payload.get("command", payload.get("cmd", "?")) + agent = payload.get("agent_id", payload.get("agent", "unknown")) + return f"Execute shell command on agent '{agent}': {str(cmd)[:120]}" + if job_type == "send_email": + to = payload.get("to_email") or payload.get("target", "?") + subj = payload.get("subject", "") + tid = payload.get("triage_id") + if tid: + return f"Send SMTP reply to triage item #{tid} (to: {to})" + return f"Send email to {to}" + (f" — {subj}" if subj else "") + if job_type == "remote_exec": + cmd_type = payload.get("command_type", payload.get("command", "?")) + agent = payload.get("agent_id", payload.get("agent", "?")) + return f"Remote execute '{cmd_type}' on agent '{agent}'" + if job_type == "purge": + return "Purge all completed/failed jobs from the Arc Reactor queue" + return f"Execute {job_type} job" + +async def _check_clearance(job_type: str, payload: dict, created_by: str) -> Optional[dict]: + """ + Returns a clearance-pending response dict if approval is required, + or None if the job can proceed immediately. + """ + rule = await db_fetchone( + "SELECT * FROM clearance_rules WHERE job_type=%s AND enabled=1 AND require_approval=1", + (job_type,) + ) + if not rule: + return None + desc = _clearance_describe(job_type, payload) + expires = None + if rule.get("auto_approve_after_min") is not None: + expires = datetime.utcnow() + timedelta(minutes=int(rule["auto_approve_after_min"])) + cr_id = await db_execute( + "INSERT INTO clearance_requests (job_type,job_payload,risk_level,description,requested_by,status,expires_at) " + "VALUES (%s,%s,%s,%s,%s,'pending',%s)", + (job_type, json.dumps(payload), rule["risk_level"], desc, created_by, expires) + ) + log.warning(f"[CLEARANCE] Request #{cr_id} — {rule['risk_level'].upper()} — {desc}") + return { + "status": "pending_clearance", + "clearance_id": cr_id, + "risk_level": rule["risk_level"], + "description": desc, + "message": f"◈ CLEARANCE REQUIRED — {rule['risk_level'].upper()} risk operation intercepted. Approval needed before execution.", + } + +async def _dispatch_cleared_job(cr_id: int, job_type: str, payload: dict, priority: int = 7, decided_by: str = "admin") -> dict: + """Approve a clearance request and dispatch the job into the queue.""" + jid = await db_execute( + "INSERT INTO arc_jobs (job_type, payload, priority, created_by) VALUES (%s, %s, %s, %s)", + (job_type, json.dumps(payload), priority, f"clearance:{cr_id}") + ) + await db_execute( + "UPDATE clearance_requests SET status='approved', decided_by=%s, arc_job_id=%s, decided_at=NOW() WHERE id=%s", + (decided_by, jid, cr_id) + ) + log.info(f"[CLEARANCE] Request #{cr_id} approved by {decided_by} → Job #{jid}") + return {"job_id": jid, "clearance_id": cr_id, "status": "approved"} + +# ── Clearance watchdog ───────────────────────────────────────────────────────── + +async def clearance_watchdog() -> None: + """Expire timed-out pending requests; auto-approve those with auto_approve_after_min set.""" + log.info("Clearance watchdog started") + await asyncio.sleep(20) + while True: + try: + now = datetime.utcnow() + # Expire requests past their expiry with no auto-approve (expires_at IS NULL means never expires) + expired = await db_fetchall( + "SELECT cr.*, cru.auto_approve_after_min FROM clearance_requests cr " + "JOIN clearance_rules cru ON cru.job_type=cr.job_type " + "WHERE cr.status='pending' AND cr.expires_at IS NOT NULL AND cr.expires_at <= %s", + (now,) + ) or [] + for req in expired: + if req.get("auto_approve_after_min") is not None: + # Auto-approve: dispatch the job + payload = req.get("job_payload") or {} + if isinstance(payload, str): + try: payload = json.loads(payload) + except Exception: payload = {} + await _dispatch_cleared_job(req["id"], req["job_type"], payload, decided_by="auto_approve") + log.info(f"[CLEARANCE] Auto-approved request #{req['id']} ({req['job_type']})") + else: + await db_execute( + "UPDATE clearance_requests SET status='expired', decided_at=NOW() WHERE id=%s", + (req["id"],) + ) + log.info(f"[CLEARANCE] Expired request #{req['id']} ({req['job_type']})") + except Exception as exc: + log.error(f"Clearance watchdog error: {exc}") + await asyncio.sleep(60) + +# ── Mission trigger loop ─────────────────────────────────────────────────────── + +_mission_trigger_state: dict = {} # mission_id → last_triggered ISO + +async def mission_trigger_loop() -> None: + """Check scheduled and event-based mission triggers every 30 seconds.""" + log.info("Mission trigger loop started") + await asyncio.sleep(15) # stagger startup + while True: + try: + missions = await db_fetchall( + "SELECT * FROM missions WHERE enabled=1 AND trigger_type != 'manual'" + ) + for m in missions: + mid = m["id"] + ttype = m["trigger_type"] + cfg = m.get("trigger_config") or {} + if isinstance(cfg, str): + try: cfg = json.loads(cfg) + except Exception: cfg = {} + + should_run = False + source = ttype + + if ttype == "schedule": + interval_min = int(cfg.get("interval_minutes", 60)) + last_run = m.get("last_run_at") + if last_run is None: + should_run = True + else: + last_dt = last_run if isinstance(last_run, datetime) else datetime.fromisoformat(str(last_run)) + elapsed = (datetime.utcnow() - last_dt).total_seconds() / 60 + should_run = elapsed >= interval_min + + elif ttype == "guardian_event": + severity = cfg.get("severity", "") + etype = cfg.get("event_type", "") + last_key = f"ge_{mid}" + last_seen = _mission_trigger_state.get(last_key, "1970-01-01") + wheres = ["acknowledged=0", "created_at > %s"] + params: list = [last_seen] + if severity: wheres.append("severity=%s"); params.append(severity) + if etype: wheres.append("event_type=%s"); params.append(etype) + row = await db_fetchone( + f"SELECT id, created_at FROM guardian_events WHERE {' AND '.join(wheres)} ORDER BY created_at DESC LIMIT 1", + params + ) + if row: + should_run = True + _mission_trigger_state[last_key] = str(row["created_at"]) + source = f"guardian_event:{row['id']}" + + elif ttype == "email_keyword": + keywords = cfg.get("keywords", []) + category = cfg.get("category", "") + last_key = f"ek_{mid}" + last_seen = _mission_trigger_state.get(last_key, "1970-01-01") + if keywords: + kw_conds = " OR ".join(["subject LIKE %s OR summary LIKE %s"] * len(keywords)) + kw_params = [] + for kw in keywords: + kw_params += [f"%{kw}%", f"%{kw}%"] + where = f"created_at > %s AND ({kw_conds})" + params = [last_seen] + kw_params + if category: + where += " AND category=%s" + params.append(category) + row = await db_fetchone( + f"SELECT id, created_at FROM email_triage WHERE {where} ORDER BY created_at DESC LIMIT 1", + params + ) + if row: + should_run = True + _mission_trigger_state[last_key] = str(row["created_at"]) + source = f"email_triage:{row['id']}" + + if should_run: + log.info(f"[MISSION TRIGGER] Mission {mid} ({m['name']}) triggered by {source}") + asyncio.create_task(_execute_mission(mid, source)) + + except Exception as exc: + log.error(f"Mission trigger loop error: {exc}") + await asyncio.sleep(30) + +# ═══════════════════════════════════════════════════════════════════════════════ +# JOB RUNNER + BACKGROUND TASKS +# ═══════════════════════════════════════════════════════════════════════════════ + +_running_jobs: set = set() +_stats = {"done": 0, "failed": 0} + +async def run_job(job: dict) -> None: + jid = job["id"] + jtype = job["job_type"] + _running_jobs.add(jid) + try: + payload = job.get("payload") or {} + if isinstance(payload, str): + payload = json.loads(payload) + await db_execute("UPDATE arc_jobs SET status='running', started_at=NOW() WHERE id=%s", (jid,)) + log.info(f"[JOB {jid}] Starting {jtype}") + handler = JOB_HANDLERS.get(jtype) + if not handler: + raise ValueError(f"Unknown job type: {jtype}") + result = await handler(payload) + result_str = json.dumps(result, default=str) + await db_execute("UPDATE arc_jobs SET status='done', result=%s, completed_at=NOW() WHERE id=%s", (result_str, jid)) + _stats["done"] += 1 + log.info(f"[JOB {jid}] Done: {jtype}") + except Exception as exc: + err = str(exc) + await db_execute("UPDATE arc_jobs SET status='failed', error=%s, completed_at=NOW() WHERE id=%s", (err[:2000], jid)) + _stats["failed"] += 1 + log.warning(f"[JOB {jid}] Failed: {err[:120]}") + finally: + _running_jobs.discard(jid) + +async def job_poller() -> None: + log.info("Arc Reactor job poller started") + while True: + try: + jobs = await db_fetchall("SELECT * FROM arc_jobs WHERE status='queued' ORDER BY priority DESC, id ASC LIMIT 5") + for job in jobs: + if job["id"] not in _running_jobs: + asyncio.create_task(run_job(job)) + except Exception as exc: + log.error(f"Poller error: {exc}") + await asyncio.sleep(POLL_INTERVAL) + +async def heartbeat_loop() -> None: + while True: + try: + await db_execute( + "UPDATE arc_status SET last_heartbeat=NOW(), active_jobs=%s, jobs_done=%s, jobs_failed=%s WHERE id=1", + (len(_running_jobs), _stats["done"], _stats["failed"]) + ) + except Exception as exc: + log.error(f"Heartbeat error: {exc}") + await asyncio.sleep(HEARTBEAT_INTERVAL) + +# ═══════════════════════════════════════════════════════════════════════════════ +# FASTAPI APP +# ═══════════════════════════════════════════════════════════════════════════════ + +@asynccontextmanager +async def lifespan(app: FastAPI): + log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}") + await get_pool() + await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,)) + asyncio.create_task(job_poller()) + asyncio.create_task(heartbeat_loop()) + asyncio.create_task(guardian_loop()) + asyncio.create_task(mission_trigger_loop()) + asyncio.create_task(clearance_watchdog()) + log.info(f"◈ Arc Reactor online — {len(JOB_HANDLERS)} handlers: {', '.join(JOB_HANDLERS)}") + yield + log.info("◈ Arc Reactor shutting down") + if _pool and not _pool.closed: + _pool.close() + await _pool.wait_closed() + +app = FastAPI(title="JARVIS Arc Reactor", version=VERSION, lifespan=lifespan) + +# ── Mission Ops endpoints ────────────────────────────────────────────────────── + +@app.get("/missions") +async def missions_list(enabled: Optional[int] = None): + if enabled is not None: + rows = await db_fetchall("SELECT * FROM missions WHERE enabled=%s ORDER BY id DESC", (enabled,)) + else: + rows = await db_fetchall("SELECT * FROM missions ORDER BY id DESC") + return rows or [] + +@app.get("/missions/{mission_id}") +async def mission_get(mission_id: int): + m = await db_fetchone("SELECT * FROM missions WHERE id=%s", (mission_id,)) + if not m: + raise HTTPException(status_code=404, detail="Not found") + steps = await db_fetchall( + "SELECT * FROM mission_steps WHERE mission_id=%s ORDER BY step_order ASC", + (mission_id,) + ) + runs = await db_fetchall( + "SELECT id, status, trigger_source, started_at, completed_at FROM mission_runs WHERE mission_id=%s ORDER BY id DESC LIMIT 10", + (mission_id,) + ) + return {**m, "steps": steps or [], "recent_runs": runs or []} + +@app.post("/missions") +async def mission_create(req: Request): + body = await req.json() + name = body.get("name", "").strip() + if not name: + raise HTTPException(status_code=400, detail="name required") + desc = body.get("description", "") + ttype = body.get("trigger_type", "manual") + tcfg = json.dumps(body.get("trigger_config") or {}) + enabled = int(body.get("enabled", 1)) + mid = await db_execute( + "INSERT INTO missions (name,description,trigger_type,trigger_config,enabled) VALUES (%s,%s,%s,%s,%s)", + (name, desc, ttype, tcfg, enabled) + ) + steps = body.get("steps") or [] + for i, s in enumerate(steps): + await db_execute( + "INSERT INTO mission_steps (mission_id,step_order,label,job_type,job_payload,continue_on_failure) VALUES (%s,%s,%s,%s,%s,%s)", + (mid, i, s.get("label",""), s["job_type"], json.dumps(s.get("payload",{})), int(s.get("continue_on_failure",0))) + ) + return {"id": mid, "ok": True} + +@app.put("/missions/{mission_id}") +async def mission_update(mission_id: int, req: Request): + body = await req.json() + fields: list = [] + params: list = [] + for col in ("name", "description", "trigger_type", "enabled"): + if col in body: + fields.append(f"{col}=%s") + params.append(body[col]) + if "trigger_config" in body: + fields.append("trigger_config=%s") + params.append(json.dumps(body["trigger_config"])) + if fields: + params.append(mission_id) + await db_execute(f"UPDATE missions SET {','.join(fields)} WHERE id=%s", params) + if "steps" in body: + await db_execute("DELETE FROM mission_steps WHERE mission_id=%s", (mission_id,)) + for i, s in enumerate(body["steps"]): + await db_execute( + "INSERT INTO mission_steps (mission_id,step_order,label,job_type,job_payload,continue_on_failure) VALUES (%s,%s,%s,%s,%s,%s)", + (mission_id, i, s.get("label",""), s["job_type"], json.dumps(s.get("payload",{})), int(s.get("continue_on_failure",0))) + ) + return {"ok": True} + +@app.delete("/missions/{mission_id}") +async def mission_delete(mission_id: int): + await db_execute("DELETE FROM mission_steps WHERE mission_id=%s", (mission_id,)) + await db_execute("DELETE FROM mission_runs WHERE mission_id=%s", (mission_id,)) + await db_execute("DELETE FROM missions WHERE id=%s", (mission_id,)) + return {"ok": True} + +@app.post("/missions/{mission_id}/run") +async def mission_run_endpoint(mission_id: int, req: Request): + try: + body = await req.json() + except Exception: + body = {} + source = body.get("trigger_source", "manual") if isinstance(body, dict) else "manual" + return await _execute_mission(mission_id, source) + +@app.get("/missions/{mission_id}/runs") +async def mission_runs_list(mission_id: int, limit: int = 20): + rows = await db_fetchall( + "SELECT * FROM mission_runs WHERE mission_id=%s ORDER BY id DESC LIMIT %s", + (mission_id, limit) + ) + return rows or [] + +# ── Clearance FastAPI endpoints ──────────────────────────────────────────────── + +@app.get("/clearance/pending") +async def clearance_pending(): + rows = await db_fetchall( + "SELECT * FROM clearance_requests WHERE status='pending' ORDER BY created_at DESC" + ) + return rows or [] + +@app.get("/clearance/history") +async def clearance_history(limit: int = 50): + rows = await db_fetchall( + "SELECT * FROM clearance_requests ORDER BY created_at DESC LIMIT %s", (limit,) + ) + return rows or [] + +@app.post("/clearance/{cr_id}/approve") +async def clearance_approve(cr_id: int, req: Request): + try: body = await req.json() + except Exception: body = {} + decided_by = body.get("decided_by", "admin") if isinstance(body, dict) else "admin" + cr = await db_fetchone("SELECT * FROM clearance_requests WHERE id=%s AND status='pending'", (cr_id,)) + if not cr: + raise HTTPException(status_code=404, detail="Clearance request not found or not pending") + payload = cr.get("job_payload") or {} + if isinstance(payload, str): + try: payload = json.loads(payload) + except Exception: payload = {} + return await _dispatch_cleared_job(cr_id, cr["job_type"], payload, decided_by=decided_by) + +@app.post("/clearance/{cr_id}/deny") +async def clearance_deny(cr_id: int, req: Request): + try: body = await req.json() + except Exception: body = {} + decided_by = body.get("decided_by", "admin") if isinstance(body, dict) else "admin" + note = body.get("note", "") if isinstance(body, dict) else "" + await db_execute( + "UPDATE clearance_requests SET status='denied', decided_by=%s, decision_note=%s, decided_at=NOW() WHERE id=%s", + (decided_by, note, cr_id) + ) + log.info(f"[CLEARANCE] Request #{cr_id} denied by {decided_by}") + return {"ok": True, "clearance_id": cr_id, "status": "denied"} + +@app.get("/clearance/rules") +async def clearance_rules_list(): + rows = await db_fetchall("SELECT * FROM clearance_rules ORDER BY risk_level DESC, job_type ASC") + return rows or [] + +@app.put("/clearance/rules/{rule_id}") +async def clearance_rule_update(rule_id: int, req: Request): + body = await req.json() + fields: list = [] + params: list = [] + for col in ("risk_level", "require_approval", "auto_approve_after_min", "enabled", "description"): + if col in body: + fields.append(f"{col}=%s") + params.append(body[col]) + if not fields: + raise HTTPException(status_code=400, detail="Nothing to update") + params.append(rule_id) + await db_execute(f"UPDATE clearance_rules SET {','.join(fields)} WHERE id=%s", params) + return {"ok": True} + +@app.post("/clearance/rules") +async def clearance_rule_create(req: Request): + body = await req.json() + job_type = body.get("job_type", "").strip() + if not job_type: + raise HTTPException(status_code=400, detail="job_type required") + rid = await db_execute( + "INSERT INTO clearance_rules (job_type,risk_level,require_approval,auto_approve_after_min,description,enabled) " + "VALUES (%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE " + "risk_level=%s,require_approval=%s,auto_approve_after_min=%s,description=%s,enabled=%s", + (job_type, + body.get("risk_level","high"), int(body.get("require_approval",1)), + body.get("auto_approve_after_min"), body.get("description",""), int(body.get("enabled",1)), + body.get("risk_level","high"), int(body.get("require_approval",1)), + body.get("auto_approve_after_min"), body.get("description",""), int(body.get("enabled",1))) + ) + return {"ok": True, "id": rid} + +# ── Memory Core FastAPI endpoints ────────────────────────────────────────────── + +@app.get("/memory/facts") +async def memory_facts_list(limit: int = 100, category: str = "", search: str = ""): + where = "WHERE active=1" + params: list = [] + if category: + where += " AND category=%s" + params.append(category) + if search: + where += " AND (subject LIKE %s OR predicate LIKE %s OR object LIKE %s)" + params += [f"%{search}%"] * 3 + rows = await db_fetchall( + f"SELECT * FROM memory_facts {where} ORDER BY confirmed_count DESC, last_confirmed_at DESC LIMIT %s", + params + [limit] + ) + return rows or [] + +@app.post("/memory/facts") +async def memory_fact_create(req: Request): + body = await req.json() + subj = str(body.get("subject", "")).strip()[:255] + pred = str(body.get("predicate", "is")).strip()[:255] + obj = str(body.get("object", "")).strip() + cat = body.get("category", "fact") + if not subj or not obj: + raise HTTPException(status_code=400, detail="subject and object required") + valid = {"preference","person","place","routine","goal","fact","instruction"} + if cat not in valid: cat = "fact" + fid = await db_execute( + """INSERT INTO memory_facts (category, subject, predicate, object, confidence, source) + VALUES (%s,%s,%s,%s,1.0,'explicit') + ON DUPLICATE KEY UPDATE + object=VALUES(object), confidence=1.0, + confirmed_count=confirmed_count+1, last_confirmed_at=NOW(), active=1""", + (cat, subj, pred, obj) + ) + return {"ok": True, "id": fid} + +@app.delete("/memory/facts/{fact_id}") +async def memory_fact_delete(fact_id: int): + await db_execute("UPDATE memory_facts SET active=0 WHERE id=%s", (fact_id,)) + return {"ok": True} + +@app.delete("/memory/facts") +async def memory_facts_clear(category: str = ""): + if category: + await db_execute("UPDATE memory_facts SET active=0 WHERE category=%s", (category,)) + else: + await db_execute("UPDATE memory_facts SET active=0 WHERE active=1", ()) + return {"ok": True} + +@app.get("/memory/context") +async def memory_context(message: str = "", limit: int = 15): + """Return relevant memory facts for a given message (for prompt injection).""" + params: list = [] + if message.strip(): + words = [w for w in message.lower().split() if len(w) > 3][:10] + if words: + like_clauses = " OR ".join(["subject LIKE %s OR object LIKE %s"] * len(words)) + for w in words: + params += [f"%{w}%", f"%{w}%"] + rows = await db_fetchall( + f"SELECT * FROM memory_facts WHERE active=1 AND ({like_clauses}) " + f"ORDER BY confirmed_count DESC, confidence DESC LIMIT %s", + params + [limit] + ) or [] + # Pad with top general facts if sparse + if len(rows) < 5: + existing = [r["id"] for r in rows] + excl = ("AND id NOT IN (" + ",".join(["%s"]*len(existing)) + ")") if existing else "" + extra = await db_fetchall( + f"SELECT * FROM memory_facts WHERE active=1 {excl} " + f"ORDER BY confirmed_count DESC LIMIT %s", + existing + [max(1, limit - len(rows))] + ) or [] + rows = rows + extra + else: + rows = await db_fetchall( + "SELECT * FROM memory_facts WHERE active=1 ORDER BY confirmed_count DESC LIMIT %s", (limit,) + ) or [] + else: + rows = await db_fetchall( + "SELECT * FROM memory_facts WHERE active=1 ORDER BY confirmed_count DESC LIMIT %s", (limit,) + ) or [] + lines = [f"[{r['category']}] {r['subject']} {r['predicate']}: {r['object']}" for r in rows] + return {"facts": rows, "context": "\n".join(lines) if lines else ""} + +@app.get("/memory/stats") +async def memory_stats(): + total = await db_fetchone("SELECT COUNT(*) cnt FROM memory_facts WHERE active=1") + by_cat = await db_fetchall( + "SELECT category, COUNT(*) cnt FROM memory_facts WHERE active=1 GROUP BY category ORDER BY cnt DESC" + ) + recent = await db_fetchall( + "SELECT * FROM memory_facts WHERE active=1 ORDER BY last_confirmed_at DESC LIMIT 5" + ) + return { + "total": total["cnt"] if total else 0, + "by_category": by_cat or [], + "recent": recent or [], + } + +@app.get("/status") +async def status(): + row = await db_fetchone("SELECT * FROM arc_status WHERE id=1") + queued = await db_fetchone("SELECT COUNT(*) cnt FROM arc_jobs WHERE status='queued'") + running = await db_fetchone("SELECT COUNT(*) cnt FROM arc_jobs WHERE status='running'") + return { + "online": True, "version": VERSION, + "last_heartbeat": row["last_heartbeat"].isoformat() if row and row["last_heartbeat"] else None, + "started_at": row["started_at"].isoformat() if row and row["started_at"] else None, + "jobs_done": row["jobs_done"] if row else 0, + "jobs_failed": row["jobs_failed"] if row else 0, + "active_jobs": len(_running_jobs), + "queued_jobs": queued["cnt"] if queued else 0, + "running_jobs": running["cnt"] if running else 0, + "handlers": list(JOB_HANDLERS.keys()), + } + +@app.post("/job") +async def create_job(request: Request): + body = await request.json() + jtype = body.get("type", "") + payload = body.get("payload", {}) + priority = int(body.get("priority", 5)) + created_by = body.get("created_by", "jarvis") + if not jtype: + raise HTTPException(status_code=400, detail="Missing job type") + clearance = await _check_clearance(jtype, payload, created_by) + if clearance: + return clearance + jid = await db_execute( + "INSERT INTO arc_jobs (job_type, payload, priority, created_by) VALUES (%s, %s, %s, %s)", + (jtype, json.dumps(payload), priority, created_by) + ) + return {"job_id": jid, "status": "queued"} + +@app.get("/job/{job_id}") +async def get_job(job_id: int): + job = await db_fetchone("SELECT * FROM arc_jobs WHERE id=%s", (job_id,)) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + result = job.get("result") + if result and isinstance(result, str): + try: + result = json.loads(result) + except Exception: + pass + return { + "id": job["id"], "job_type": job["job_type"], "status": job["status"], + "result": result, "error": job.get("error"), + "created_at": job["created_at"].isoformat() if job["created_at"] else None, + "started_at": job["started_at"].isoformat() if job["started_at"] else None, + "completed_at": job["completed_at"].isoformat() if job["completed_at"] else None, + } + +@app.delete("/job/{job_id}") +async def cancel_job(job_id: int): + await db_execute("UPDATE arc_jobs SET status='cancelled' WHERE id=%s AND status='queued'", (job_id,)) + return {"ok": True} + +@app.get("/jobs") +async def list_jobs(limit: int = 50, status: str = ""): + if status: + rows = await db_fetchall( + "SELECT id,job_type,status,priority,created_by,created_at,completed_at FROM arc_jobs WHERE status=%s ORDER BY id DESC LIMIT %s", + (status, limit) + ) + else: + rows = await db_fetchall( + "SELECT id,job_type,status,priority,created_by,created_at,completed_at FROM arc_jobs ORDER BY id DESC LIMIT %s", + (limit,) + ) + for r in rows: + if r.get("created_at"): r["created_at"] = r["created_at"].isoformat() + if r.get("completed_at"): r["completed_at"] = r["completed_at"].isoformat() + return rows + +@app.get("/jobs/recent") +async def recent_jobs(limit: int = 10, job_type: str = ""): + if job_type: + rows = await db_fetchall( + "SELECT id,job_type,status,result,created_at,completed_at FROM arc_jobs WHERE job_type=%s ORDER BY id DESC LIMIT %s", + (job_type, limit) + ) + else: + rows = await db_fetchall( + "SELECT id,job_type,status,result,created_at,completed_at FROM arc_jobs ORDER BY id DESC LIMIT %s", + (limit,) + ) + for r in rows: + if r.get("created_at"): r["created_at"] = r["created_at"].isoformat() + if r.get("completed_at"): r["completed_at"] = r["completed_at"].isoformat() + if r.get("result") and isinstance(r["result"], str): + try: + r["result"] = json.loads(r["result"]) + except Exception: + pass + return rows + +@app.get("/triage") +async def get_triage(limit: int = 30, account: str = "gmail"): + rows = await db_fetchall( + """SELECT id, from_name, from_email, subject, date_received, category, priority, + summary, draft_reply, action_taken, created_at + FROM email_triage WHERE account=%s + ORDER BY priority DESC, created_at DESC LIMIT %s""", + (account, limit) + ) + for r in rows: + if r.get("date_received"): r["date_received"] = str(r["date_received"]) + if r.get("created_at"): r["created_at"] = str(r["created_at"]) + return rows + +@app.post("/triage/{triage_id}/action") +async def triage_action(triage_id: int, request: Request): + body = await request.json() + action = body.get("action", "dismissed") + await db_execute("UPDATE email_triage SET action_taken=%s WHERE id=%s", (action, triage_id)) + return {"ok": True} + +@app.delete("/jobs/purge") +async def purge_jobs(): + await db_execute("DELETE FROM arc_jobs WHERE status IN ('done','failed','cancelled') AND completed_at < DATE_SUB(NOW(), INTERVAL 7 DAY)") + return {"ok": True} + +# ── VISION PROTOCOL endpoints ───────────────────────────────────────────────── + +@app.get("/screenshots") +async def list_screenshots(limit: int = 20, agent: str = ""): + if agent: + rows = await db_fetchall( + "SELECT id, agent_id, hostname, method, width, height, file_size, " + " vision_analysis, vision_provider, created_at " + "FROM agent_screenshots WHERE hostname LIKE %s " + "ORDER BY created_at DESC LIMIT %s", + (f"%{agent}%", limit) + ) + else: + rows = await db_fetchall( + "SELECT id, agent_id, hostname, method, width, height, file_size, " + " vision_analysis, vision_provider, created_at " + "FROM agent_screenshots ORDER BY created_at DESC LIMIT %s", + (limit,) + ) + return rows or [] + +@app.get("/screenshots/{screenshot_id}") +async def get_screenshot(screenshot_id: int): + row = await db_fetchone( + "SELECT * FROM agent_screenshots WHERE id=%s", (screenshot_id,) + ) + if not row: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Screenshot not found") + return row + +@app.delete("/screenshots/{screenshot_id}") +async def delete_screenshot(screenshot_id: int): + await db_execute("DELETE FROM agent_screenshots WHERE id=%s", (screenshot_id,)) + return {"ok": True} + +@app.delete("/screenshots/purge") +async def purge_screenshots(): + await db_execute("DELETE FROM agent_screenshots WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)") + return {"ok": True} + +# ── GUARDIAN MODE endpoints ─────────────────────────────────────────────────── + +@app.get("/guardian/status") +async def guardian_status(): + cfg = await _guardian_get_config() + counts = await db_fetchone( + """SELECT + SUM(acknowledged=0) AS unread, + SUM(severity='critical' AND acknowledged=0) AS critical_unread, + SUM(severity='warning' AND acknowledged=0) AS warning_unread, + SUM(created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS events_24h + FROM guardian_events""" + ) + return { + "enabled": cfg.get("enabled", "1") == "1", + "scan_interval": int(cfg.get("scan_interval", 120)), + "last_scan": _guardian_state.get("last_scan"), + "last_sitrep": _guardian_state.get("last_sitrep"), + "thresholds": { + "cpu": float(cfg.get("cpu_threshold", 85)), + "memory": float(cfg.get("mem_threshold", 88)), + "disk": float(cfg.get("disk_threshold", 88)), + "offline_minutes": int(cfg.get("offline_minutes", 3)), + }, + "counts": dict(counts) if counts else {}, + } + +@app.get("/guardian/events") +async def guardian_events_list(limit: int = 30, unread: bool = False, + severity: str = "", since: str = ""): + wheres = [] + params: list = [] + if unread: + wheres.append("acknowledged = 0") + if severity: + wheres.append("severity = %s"); params.append(severity) + if since: + wheres.append("created_at > %s"); params.append(since) + where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else "" + params.append(limit) + rows = await db_fetchall( + f"SELECT * FROM guardian_events {where_sql} ORDER BY created_at DESC LIMIT %s", + params + ) + return rows or [] + +@app.post("/guardian/events/{event_id}/ack") +async def guardian_ack(event_id: int): + await db_execute("UPDATE guardian_events SET acknowledged=1 WHERE id=%s", (event_id,)) + return {"ok": True} + +@app.post("/guardian/events/ack_all") +async def guardian_ack_all(): + await db_execute("UPDATE guardian_events SET acknowledged=1 WHERE acknowledged=0") + return {"ok": True} + +@app.get("/guardian/chat") +async def guardian_chat_events(since: str = ""): + """Return proactive guardian messages injected into conversations.""" + if since: + rows = await db_fetchall( + "SELECT id, message, created_at FROM conversations " + "WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC", + (since,) + ) + else: + rows = await db_fetchall( + "SELECT id, message, created_at FROM conversations " + "WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10" + ) + return rows or [] + +# ── COMMS v2 endpoints ─────────────────────────────────────────────────────── + +@app.get("/comms/sent") +async def comms_sent(limit: int = 50, status: str = ""): + if status: + rows = await db_fetchall( + "SELECT id,account,to_email,to_name,subject,status,sent_at,triage_id " + "FROM email_sent WHERE status=%s ORDER BY sent_at DESC LIMIT %s", + (status, limit) + ) + else: + rows = await db_fetchall( + "SELECT id,account,to_email,to_name,subject,status,sent_at,triage_id " + "FROM email_sent ORDER BY sent_at DESC LIMIT %s", + (limit,) + ) + return rows or [] + +@app.get("/comms/sent/{sent_id}") +async def comms_sent_get(sent_id: int): + row = await db_fetchone("SELECT * FROM email_sent WHERE id=%s", (sent_id,)) + if not row: + raise HTTPException(status_code=404, detail="Not found") + return row + +@app.delete("/comms/sent/{sent_id}") +async def comms_sent_delete(sent_id: int): + await db_execute("DELETE FROM email_sent WHERE id=%s", (sent_id,)) + return {"ok": True} + +if __name__ == "__main__": + uvicorn.run("reactor:app", host=HOST, port=PORT, log_level="info", access_log=False) diff --git a/deploy/requirements.txt b/deploy/requirements.txt new file mode 100644 index 0000000..bb38b04 --- /dev/null +++ b/deploy/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +aiomysql +aiohttp +anthropic +trafilatura diff --git a/public_html/.gitignore b/public_html/.gitignore new file mode 100644 index 0000000..fee9217 --- /dev/null +++ b/public_html/.gitignore @@ -0,0 +1 @@ +*.conf diff --git a/public_html/.htaccess b/public_html/.htaccess new file mode 100644 index 0000000..02b8ec1 --- /dev/null +++ b/public_html/.htaccess @@ -0,0 +1,11 @@ +Options -Indexes +RewriteEngine On + +# Route all /api/* requests to api.php +RewriteCond %{REQUEST_URI} ^/api(/|$) +RewriteRule ^api(/.*)?$ api.php [QSA,L] + +# Everything else serves static files or index.html +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.html [L] diff --git a/public_html/admin/index.php b/public_html/admin/index.php new file mode 100644 index 0000000..44b4842 --- /dev/null +++ b/public_html/admin/index.php @@ -0,0 +1,5303 @@ + $msg]); } + +function self_upsert_device(array $d): void { + JarvisDB::execute( + 'INSERT INTO network_devices (ip,mac,hostname,status,last_seen) VALUES (?,?,?,"online",NOW()) + ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname), status="online", last_seen=NOW()', + [$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']] + ); + if (!empty($d['vendor'])) { + JarvisDB::execute('UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']]); + } +} + +// ── BACKEND API ─────────────────────────────────────────────────────────────── +$action = $_GET['action'] ?? $_POST['action'] ?? ''; +if ($action) { + // Login doesn't require session + if ($action === 'login') { + $u = trim($_POST['username'] ?? ''); + $p = $_POST['password'] ?? ''; + $row = JarvisDB::single('SELECT * FROM users WHERE username = ?', [$u]); + if ($row && password_verify($p, $row['password_hash'])) { + $_SESSION['admin_user'] = $row['username']; + $_SESSION['admin_name'] = $row['display_name']; + j(['ok' => true, 'name' => $row['display_name']]); + } + bad('Invalid credentials', 401); + } + if ($action === 'logout') { session_destroy(); j(['ok' => true]); } + if (!loggedIn()) bad('Not authenticated', 401); + + switch ($action) { + + // ── DASHBOARD ───────────────────────────────────────────────────────── + case 'dashboard': + $mi = []; + foreach (file('/proc/meminfo') as $l) { + [$k,$v] = explode(':', $l, 2) + [null,null]; + if ($k) $mi[trim($k)] = (int)trim($v); + } + $mt = $mi['MemTotal'] ?? 0; $mf = $mi['MemAvailable'] ?? 0; + $up = (int)explode(' ', file_get_contents('/proc/uptime'))[0]; + $la = explode(' ', file_get_contents('/proc/loadavg')); + $disk = trim(shell_exec("df / | tail -1 | awk '{print $5}'") ?? ''); + j([ + 'sys' => [ + 'mem_pct' => $mt > 0 ? round(($mt-$mf)/$mt*100,1) : 0, + 'mem_used_mb' => round(($mt-$mf)/1024), + 'mem_total_mb' => round($mt/1024), + 'uptime_s' => $up, + 'load_1m' => (float)$la[0], + 'disk_pct' => $disk, + ], + 'agents' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM registered_agents'), + 'alerts' => JarvisDB::single('SELECT COUNT(*) total, SUM(resolved=0) active FROM alerts'), + 'devices' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM network_devices WHERE alias IS NOT NULL'), + 'facts' => JarvisDB::single('SELECT COUNT(*) total FROM kb_facts'), + 'intents' => JarvisDB::single('SELECT COUNT(*) total, SUM(active=1) active FROM kb_intents'), + ]); + + // ── AGENTS ─────────────────────────────────────────────────────────── + case 'agents_list': + $agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, created_at FROM registered_agents ORDER BY status="online" DESC, hostname'); + $metrics = JarvisDB::query( + "SELECT agent_id, + ROUND(JSON_EXTRACT(metric_data,'$.cpu_percent'),1) AS cpu_pct, + ROUND(JSON_EXTRACT(metric_data,'$.memory.percent'),1) AS mem_pct, + ROUND(JSON_EXTRACT(metric_data,'$.disk[0].percent'),1) AS disk_pct + FROM agent_metrics + WHERE metric_type='system' AND recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE) + GROUP BY agent_id ORDER BY recorded_at DESC" + ); + $mm = array_column($metrics, null, 'agent_id'); + foreach ($agents as &$a) $a['metrics'] = $mm[$a['agent_id']] ?? null; + j($agents); + + case 'agents_delete': + $id = $_POST['agent_id'] ?? ''; if (!$id) bad('Missing agent_id'); + JarvisDB::execute('DELETE FROM registered_agents WHERE agent_id=?', [$id]); + JarvisDB::execute('DELETE FROM agent_metrics WHERE agent_id=?', [$id]); + JarvisDB::execute('DELETE FROM agent_commands WHERE agent_id=?', [$id]); + j(['ok' => true]); + + // ── NETWORK ────────────────────────────────────────────────────────── + case 'network_list': + j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices ORDER BY status="online" DESC, COALESCE(alias,hostname,ip)')); + + case 'network_scan': + // Queue shell command to PVE1 agent — it runs jarvis-netscan.sh and pushes results back + $pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE ip_address="10.48.200.90" AND status="online" LIMIT 1'); + if (!$pve1) { + $pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE hostname LIKE "%pve%" AND status="online" LIMIT 1'); + } + if ($pve1) { + JarvisDB::execute( + 'INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)', + [$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending'] + ); + j(['ok' => true, 'queued' => true, 'note' => 'Scan command sent to PVE1 agent — results in ~40 seconds']); + } else { + j(['ok' => false, 'note' => 'PVE1 agent offline — scan will run automatically via cron in < 3 minutes']); + } + + case 'network_save': + $id = (int)($_POST['id'] ?? 0); + $ip = trim($_POST['ip'] ?? ''); $alias = trim($_POST['alias'] ?? ''); + $type = trim($_POST['device_type'] ?? 'device'); + if (!$ip || !$alias) bad('IP and alias required'); + if ($id) { + JarvisDB::execute('UPDATE network_devices SET ip=?,alias=?,device_type=? WHERE id=?', [$ip,$alias,$type,$id]); + } else { + JarvisDB::execute('INSERT INTO network_devices (ip,alias,device_type,status) VALUES (?,?,?,"unknown") ON DUPLICATE KEY UPDATE alias=?,device_type=?', [$ip,$alias,$type,$alias,$type]); + } + j(['ok' => true]); + + case 'network_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM network_devices WHERE id=?', [$id]); + j(['ok' => true]); + + case 'network_ping': + $ip = trim($_POST['ip'] ?? ''); if (!$ip) bad('Missing IP'); + $out = shell_exec('ping -c 2 -W 2 '.escapeshellarg($ip).' 2>/dev/null'); + $alive = $out && (strpos($out,'2 received')!==false || strpos($out,'1 received')!==false); + $lat = null; + if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) $lat = (float)$m[1]; + j(['alive'=>$alive,'latency_ms'=>$lat]); + + // ── ALERTS ─────────────────────────────────────────────────────────── + case 'alerts_list': + $f = $_GET['filter'] ?? 'all'; + $w = $f === 'active' ? 'WHERE resolved=0' : ($f === 'resolved' ? 'WHERE resolved=1' : ''); + j(JarvisDB::query("SELECT id,alert_type,title,message,severity,resolved,created_at,resolved_at,source_key FROM alerts $w ORDER BY created_at DESC LIMIT 300")); + + case 'alerts_resolve': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE id=?', [$id]); + j(['ok' => true]); + + case 'alerts_resolve_all': + JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE resolved=0'); + j(['ok' => true]); + + case 'alerts_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM alerts WHERE id=?', [$id]); + j(['ok' => true]); + + case 'alerts_purge_resolved': + JarvisDB::execute('DELETE FROM alerts WHERE resolved=1'); + j(['ok' => true]); + + case 'alerts_save': + $id = (int)($_POST['id'] ?? 0); + $t = trim($_POST['title'] ?? ''); if (!$t) bad('Title required'); + $typ = trim($_POST['alert_type'] ?? 'manual'); + $msg = trim($_POST['message'] ?? ''); + $sev = trim($_POST['severity'] ?? 'info'); + if ($id) { + JarvisDB::execute('UPDATE alerts SET alert_type=?,title=?,message=?,severity=? WHERE id=?', [$typ,$t,$msg,$sev,$id]); + } else { + JarvisDB::execute('INSERT INTO alerts (alert_type,title,message,severity,resolved) VALUES (?,?,?,?,0)', [$typ,$t,$msg,$sev]); + } + j(['ok' => true]); + + // ── KB FACTS ───────────────────────────────────────────────────────── + case 'facts_categories': + j(JarvisDB::query('SELECT category, COUNT(*) cnt FROM kb_facts GROUP BY category ORDER BY cnt DESC')); + + case 'facts_list': + $cat = $_GET['category'] ?? ''; + if ($cat === '__all__') { + j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts ORDER BY category,fact_key LIMIT 1000')); + } + j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts WHERE category=? ORDER BY fact_key', [$cat])); + + case 'facts_save': + $id = (int)($_POST['id'] ?? 0); + $cat = trim($_POST['category'] ?? ''); $key = trim($_POST['fact_key'] ?? ''); $val = trim($_POST['fact_value'] ?? ''); + if (!$cat||!$key) bad('Category and key required'); + if ($id) { + JarvisDB::execute('UPDATE kb_facts SET category=?,fact_key=?,fact_value=? WHERE id=?', [$cat,$key,$val,$id]); + } else { + JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES (?,?,?)', [$cat,$key,$val]); + } + j(['ok' => true]); + + case 'facts_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM kb_facts WHERE id=?', [$id]); + j(['ok' => true]); + + // ── KB INTENTS ─────────────────────────────────────────────────────── + case 'intents_list': + j(JarvisDB::query('SELECT id,intent_name,pattern,response_template,action_type,priority,active FROM kb_intents ORDER BY priority DESC,intent_name')); + + case 'intents_save': + $id = (int)($_POST['id'] ?? 0); + $name = trim($_POST['intent_name'] ?? ''); $pat = trim($_POST['pattern'] ?? ''); + $resp = trim($_POST['response_template'] ?? ''); + $typ = trim($_POST['action_type'] ?? 'response'); + $pri = (int)($_POST['priority'] ?? 5); $act = (int)($_POST['active'] ?? 1); + if (!$name||!$pat) bad('Name and pattern required'); + if ($id) { + JarvisDB::execute('UPDATE kb_intents SET intent_name=?,pattern=?,response_template=?,action_type=?,priority=?,active=? WHERE id=?', [$name,$pat,$resp,$typ,$pri,$act,$id]); + } else { + JarvisDB::execute('INSERT INTO kb_intents (intent_name,pattern,response_template,action_type,priority,active) VALUES (?,?,?,?,?,?)', [$name,$pat,$resp,$typ,$pri,$act]); + } + j(['ok' => true]); + + case 'intents_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$id]); + j(['ok' => true]); + + case 'intents_toggle': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('UPDATE kb_intents SET active=NOT active WHERE id=?', [$id]); + j(['ok' => true]); + + // ── SITES ──────────────────────────────────────────────────────────── + case 'sites_list': + j(JarvisDB::query("SELECT fact_key,fact_value,updated_at FROM kb_facts WHERE category='sites' ORDER BY fact_key")); + + // ── HOME ASSISTANT ENTITIES ─────────────────────────────────────────── + case 'ha_list': + // Read from ha_entities table (real-time pushes from jarvis_agent custom component) + $domain = $_GET['domain'] ?? ''; + $search = strtolower(trim($_GET['search'] ?? '')); + $skipDomains = ['sensor','binary_sensor','button','update','select','number', + 'device_tracker','event','image','person','zone','tts','conversation', + 'assist_satellite','input_button']; + $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', + '_siren_on','_email_on','_manual_record','_infrared_', + 'do_not_disturb','matter_server','zerotier','mariadb', + 'spotify_connect','file_editor','ssh_web','uptime_kuma', + 'folding_home','music_assistant','get_hacs','mealie', + 'mosquitto','social_to','esphome_device','motion_detection', + 'front_yard_record','down_hill_record','camera1_record', + 'back_yard_record','nvr_','assist_microphone','cec_scanner']; + $where = "state NOT IN ('unavailable','unknown')"; + $params = []; + if ($domain) { $where .= " AND domain=?"; $params[] = $domain; } + $rows = JarvisDB::query( + "SELECT entity_id, entity_name name, domain, state, updated_at + FROM ha_entities WHERE $where ORDER BY domain, entity_name LIMIT 500", + $params + ) ?? []; + $all = []; $domains = []; + foreach ($rows as $e) { + $dom = $e['domain']; + if (in_array($dom, $skipDomains)) continue; + $skip = false; + if ($dom === 'switch') { + foreach ($skipKeywords as $kw) { + if (strpos($e['entity_id'], $kw) !== false) { $skip = true; break; } + } + } + if ($skip) continue; + if ($search && strpos(strtolower($e['name']??''), $search) === false) continue; + $all[] = $e; + $domains[$dom] = true; + } + j(['entities'=>$all,'domains'=>array_keys($domains),'total'=>count($all),'ts'=>time()]); + + case 'ha_toggle': + $eid = trim($_POST['entity_id'] ?? ''); if (!$eid) bad('Missing entity_id'); + $state = trim($_POST['state'] ?? ''); + if (!defined('HA_URL')||!defined('HA_TOKEN')) bad('HA not configured'); + $domain = explode('.',$eid)[0]; + $svc = match($domain) { + 'light','switch','input_boolean','fan' => ($state==='on'?'turn_off':'turn_on'), + default => ($state==='on'?'turn_off':'turn_on') + }; + $ch = curl_init(HA_URL.'/api/services/'.$domain.'/'.$svc); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true, + CURLOPT_HTTPHEADER=>['Authorization: Bearer '.HA_TOKEN,'Content-Type: application/json'], + CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$eid]),CURLOPT_TIMEOUT=>8]); + $res = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); + j(['ok'=>$code<300,'code'=>$code]); + + // ── NEWS ───────────────────────────────────────────────────────────── + case 'news_list': + $cached = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='news'"); + $news = $cached ? (json_decode($cached['data'],true)??[]) : []; + $custom = JarvisDB::query("SELECT id,fact_key title,fact_value url,updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC"); + j(['news'=>$news,'custom'=>$custom,'cache_age'=>$cached?time()-(int)$cached['ts']:null]); + + case 'news_custom_save': + $id = (int)($_POST['id']??0); + $t = trim($_POST['title']??''); if(!$t) bad('Title required'); + $url = trim($_POST['url']??''); + if($id) { + JarvisDB::execute('UPDATE kb_facts SET fact_key=?,fact_value=? WHERE id=? AND category="custom_news"',[$t,$url,$id]); + } else { + JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES ("custom_news",?,?)',[$t,$url]); + } + j(['ok'=>true]); + + case 'news_custom_delete': + $id=(int)($_POST['id']??0); if(!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM kb_facts WHERE id=? AND category="custom_news"',[$id]); + j(['ok'=>true]); + + // ── PROXMOX VMs ─────────────────────────────────────────────────────── + case 'vms_list': + $raw = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='proxmox'"); + if (!$raw) j(['vms'=>[],'containers'=>[],'node_info'=>[],'ts'=>null]); + $pve = json_decode($raw['data'],true) ?? []; + j(['vms'=>$pve['vms']??[],'containers'=>$pve['containers']??[],'node_info'=>$pve['node_info']??[],'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]); + + // ── USERS ──────────────────────────────────────────────────────────── + case 'email_inbox': + // Call via server's own IP — REMOTE_ADDR matches JARVIS_IP so auth bypass applies + $acct = $_GET['account'] ?? 'all'; + $force = !empty($_GET['force']) ? '&force=1' : ''; + $ch = curl_init('https://165.22.1.228/api/email?account=' . $acct . $force); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>25, + CURLOPT_SSL_VERIFYPEER=>false,CURLOPT_SSL_VERIFYHOST=>false, + CURLOPT_HTTPHEADER=>['Host: jarvis.orbishosting.com']]); + $r = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); + if($code===200 && $r) j(json_decode($r,true)); + else j(['error'=>'Email fetch failed (HTTP '.$code.')']); + + case 'email_action_items': + $rows = JarvisDB::query("SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 100") ?? []; + j(['action_items'=>$rows]); + + case 'email_create_task': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + $ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found'); + $title=trim($_POST['title']??$ea['suggested_title']); + $due=trim($_POST['due_date']??$ea['suggested_date']??''); + $notes="From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}"; + $tid=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,due_date)VALUES(?,?,?,?,?)', + [$title,$notes,'work','normal',$due?:null]); + JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?',[$tid,$id]); + j(['ok'=>true,'task_id'=>$tid]); + + case 'email_create_appt': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + $ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found'); + $title=trim($_POST['title']??$ea['suggested_title']); + $start=trim($_POST['start_at']??''); + if(!$start) $start=($ea['suggested_date']??date('Y-m-d')).' 09:00:00'; + $aid=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at)VALUES(?,?,?,?)', + [$title,"From: {$ea['from_name']} <{$ea['from_email']}>",'work',$start]); + JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?',[$aid,$id]); + j(['ok'=>true,'appointment_id'=>$aid]); + + case 'email_dismiss': + $id=(int)($_POST['id']??0); + if($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?',[$id]); + j(['ok'=>true]); + + case 'task_list': + $status = trim($_GET['status'] ?? ''); + $category = trim($_GET['category'] ?? ''); + $where = '1=1'; $params = []; + if ($status) { $where .= ' AND status=?'; $params[] = $status; } + if ($category) { $where .= ' AND category=?'; $params[] = $category; } + else if (!$status) { $where .= " AND status NOT IN ('done','cancelled')"; } + $rows = JarvisDB::query("SELECT * FROM tasks WHERE {$where} ORDER BY FIELD(priority,'urgent','high','normal','low'),due_date ASC,created_at DESC LIMIT 200",$params) ?? []; + j(['tasks'=>$rows]); + + case 'task_save': + $id=$_POST['id']??0; $title=trim($_POST['title']??''); + $notes=trim($_POST['notes']??''); $cat=$_POST['category']??'personal'; + $pri=$_POST['priority']??'normal'; $stat=$_POST['status']??'pending'; + $due=!empty($_POST['due_date'])?$_POST['due_date']:null; + $dtime=!empty($_POST['due_time'])?$_POST['due_time']:null; + if(!$title) bad('Title required'); + if($id){ + JarvisDB::execute('UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?',[$title,$notes,$cat,$pri,$stat,$due,$dtime,$id]); + j(['ok'=>true,'id'=>(int)$id]); + } else { + $newId=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,status,due_date,due_time)VALUES(?,?,?,?,?,?,?)',[$title,$notes,$cat,$pri,$stat,$due,$dtime]); + j(['ok'=>true,'id'=>$newId]); + } + + case 'task_done': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + JarvisDB::execute("UPDATE tasks SET status='done',completed_at=NOW() WHERE id=?",[$id]); + j(['ok'=>true]); + + case 'task_delete': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + JarvisDB::execute('DELETE FROM tasks WHERE id=?',[$id]); + j(['ok'=>true]); + + case 'appt_list': + $from=$_GET['from']??date('Y-m-d'); $to=$_GET['to']??date('Y-m-d',strtotime('+90 days')); + $rows=JarvisDB::query("SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 200",[$from,$to]) ?? []; + j(['appointments'=>$rows]); + + case 'appt_save': + $id=$_POST['id']??0; $title=trim($_POST['title']??''); $desc=trim($_POST['description']??''); + $cat=$_POST['category']??'personal'; $loc=trim($_POST['location']??''); + $all_day=(int)($_POST['all_day']??0); $rem=(int)($_POST['reminder_min']??30); + $start=trim($_POST['start_at']??''); $end=trim($_POST['end_at']??''); + if(!$title||!$start) bad('Title and start required'); + $ts=strtotime($start); if(!$ts) bad('Invalid start datetime'); + $startDt=date('Y-m-d H:i:s',$ts); + $endDt=($end&&strtotime($end))?date('Y-m-d H:i:s',strtotime($end)):null; + if($id){ + JarvisDB::execute('UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem,$id]); + j(['ok'=>true,'id'=>(int)$id]); + } else { + $newId=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at,end_at,location,all_day,reminder_min)VALUES(?,?,?,?,?,?,?,?)',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem]); + j(['ok'=>true,'id'=>$newId]); + } + + case 'appt_delete': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + JarvisDB::execute('DELETE FROM appointments WHERE id=?',[$id]); + j(['ok'=>true]); + + + case 'cal_feeds_list': + j(JarvisDB::query("SELECT id,name,source,ics_url,username,(password != '' AND password IS NOT NULL) AS has_password,active,created_at FROM calendar_feeds ORDER BY source,name") ?? []); + + case 'cal_feed_save': + $id = (int)($_POST['id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $source = $_POST['source'] ?? 'ics'; + $ics = trim($_POST['ics_url'] ?? ''); + $user = trim($_POST['username'] ?? ''); + $pass = trim($_POST['password'] ?? ''); + $active = (int)($_POST['active'] ?? 1); + if (!$name) bad('Name required'); + if ($id) { + JarvisDB::execute("UPDATE calendar_feeds SET name=?,source=?,ics_url=?,username=?,password=?,active=? WHERE id=?", + [$name,$source,$ics,$user,$pass,$active,$id]); + j(['ok'=>true,'id'=>$id]); + } else { + $nid = JarvisDB::insert("INSERT INTO calendar_feeds(name,source,ics_url,username,password,active) VALUES(?,?,?,?,?,?)", + [$name,$source,$ics,$user,$pass,$active]); + j(['ok'=>true,'id'=>$nid]); + } + + case 'cal_feed_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('No id'); + JarvisDB::execute("DELETE FROM calendar_feeds WHERE id=?", [$id]); + j(['ok'=>true]); + + case 'cal_sync_now': + if (!class_exists('JarvisDB')) require_once __DIR__ . '/../../api/lib/db.php'; + require_once __DIR__ . '/../../api/endpoints/calendar_sync.php'; + $r = runSync(); + j(['ok'=>true,'results'=>$r]); + + + // ── ARC REACTOR ────────────────────────────────────────────────────── + + case 'workers_list': + $agents = JarvisDB::query( + 'SELECT agent_id, hostname, agent_type, ip_address, status, capabilities, version, last_seen + FROM registered_agents ORDER BY status DESC, hostname ASC' + ); + // Latest available versions per platform + $latestVersions = [ + 'linux' => '3.1', + 'proxmox' => '3.1', + 'windows' => '3.0', + 'macos' => '3.0', + 'homeassistant' => null, + ]; + $reactorRaw = @file_get_contents('http://127.0.0.1:7474/status'); + $reactor = $reactorRaw ? json_decode($reactorRaw, true) : null; + $arcStats = JarvisDB::query( + 'SELECT status, COUNT(*) as cnt FROM arc_jobs + WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) GROUP BY status' + ); + $arcCounts = []; + foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt']; + $cronLast = []; + $cronLog = '/var/log/jarvis/cron.log'; + if (file_exists($cronLog)) { + $lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar\\|intent' " . escapeshellarg($cronLog) . " | tail -60"))); + foreach ($lines as $line) { + if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1]; + if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1]; + if (preg_match('/^\\[(\\d{2}{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1]; + if (preg_match('/^\\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\\].*intent/i', $line, $m)) $cronLast['kb_intent_generator'] = $m[1]; + } + } + if (empty($cronLast['stats_cache'])) { + $row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")'); + if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t']; + } + $deployLog = '/var/log/jarvis/deploy.log'; + if (file_exists($deployLog)) { + $last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1"); + if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1]; + } + $wdLog = '/var/log/jarvis/watchdog.log'; + if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog)); + $bkLog = '/var/backups/jarvis/backup.log'; + if (file_exists($bkLog)) { + $last = shell_exec("grep -a '\\[' " . escapeshellarg($bkLog) . " | tail -1"); + if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_backup'] = $m[1]; + } + $doLog = '/var/log/do-server-backup.log'; + if (file_exists($doLog)) $cronLast['do_server_backup'] = date('Y-m-d H:i:s', filemtime($doLog)); + j(['agents'=>$agents,'reactor'=>$reactor,'arc_counts'=>$arcCounts,'cron_last'=>$cronLast,'latest_versions'=>$latestVersions]); + break; + + case 'worker_action': + $wType = $_REQUEST['worker_type'] ?? ''; + $wId = $_REQUEST['worker_id'] ?? ''; + $wAction = $_REQUEST['waction'] ?? $_REQUEST['action'] ?? ''; + if ($wType === 'agent' && $wAction === 'update') { + JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)', + [$wId,'update','{}','pending']); + j(['ok'=>true,'msg'=>'Update dispatched to '.$wId]); + } elseif ($wType === 'agent' && $wAction === 'screenshot') { + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode(['type'=>'screenshot','payload'=>['agent'=>$wId,'analyze'=>false],'priority'=>8,'created_by'=>'admin:workers']), + CURLOPT_HTTPHEADER=>['Content-Type: application/json'],CURLOPT_TIMEOUT=>5]); + j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']); + } elseif ($wType === 'cron' && $wAction === 'run') { + $scripts = [ + 'facts_collector' =>[true, '/var/www/jarvis/api/endpoints/facts_collector.php'], + 'stats_cache' =>[true, '/var/www/jarvis/api/endpoints/stats_cache.php'], + 'calendar_sync' =>[true, '/var/www/jarvis/api/endpoints/calendar_sync.php'], + 'kb_intent_generator' =>[true, '/var/www/jarvis/api/endpoints/kb_intent_generator.php'], + 'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'], + 'jarvis_watchdog' =>[false,'/usr/local/bin/jarvis-watchdog.sh'], + ]; + if (isset($scripts[$wId])) { + [$isPhp,$path] = $scripts[$wId]; + $env = ($wId === 'kb_intent_generator') ? 'JARVIS_FORCE_RUN=1 ' : ''; + $cmd = $isPhp + ? $env.'/usr/bin/php8.3 '.escapeshellarg($path).' >> /var/log/jarvis/cron.log 2>&1 &' + : escapeshellcmd($path).' >> /var/log/jarvis/deploy.log 2>&1 &'; + shell_exec($cmd); + j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']); + } else { bad('Unknown cron worker'); } + } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') { + shell_exec('systemctl restart jarvis-arc 2>&1'); + j(['ok'=>true,'msg'=>'Arc Reactor restarting via systemd']); + } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') { + $log = '/var/log/jarvis/arc-setup.log'; + $cmd = implode(' && ', [ + 'mkdir -p /opt/jarvis-arc /var/log/jarvis', + 'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py', + 'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt', + '[ ! -f /opt/jarvis-arc/venv/bin/activate ] && python3 -m venv /opt/jarvis-arc/venv || true', + '/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt', + 'cp /var/www/jarvis/deploy/jarvis-arc.service /etc/systemd/system/jarvis-arc.service', + 'systemctl daemon-reload', + 'systemctl enable jarvis-arc', + 'systemctl restart jarvis-arc', + ]); + shell_exec("($cmd) >> " . escapeshellarg($log) . " 2>&1 &"); + j(['ok'=>true,'msg'=>'Arc Reactor setup started — check ' . $log]); + } elseif ($wType === 'agent' && $wAction === 'update_status') { + $ag = JarvisDB::single('SELECT version, status FROM registered_agents WHERE agent_id=?', [$wId]); + j(['ok'=>true,'version'=>$ag['version']??null,'status'=>$ag['status']??'unknown']); + } else { bad('Invalid worker action'); } + break; + case 'intent_gen_history': + $logFile = '/var/log/jarvis/cron.log'; + $result = ['last_success'=>null,'last_success_count'=>null,'failures'=>[]]; + if (file_exists($logFile)) { + $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $cutoff = time() - 7 * 86400; + foreach (array_reverse($lines) as $line) { + if (stripos($line, 'KB Intent Generator') === false) continue; + // parse timestamp + preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/', $line, $tm); + $ts = isset($tm[1]) ? strtotime($tm[1]) : 0; + // last success (done/complete) + if (!$result['last_success'] && preg_match('/done|complete/i', $line)) { + $result['last_success'] = $tm[1] ?? null; + preg_match('/inserted[:\s]+(\d+)/i', $line, $cnt); + $result['last_success_count'] = isset($cnt[1]) ? (int)$cnt[1] : null; + } + // failures in last 7 days + if ($ts >= $cutoff && preg_match('/error|fail/i', $line)) { + $result['failures'][] = $line; + if (count($result['failures']) >= 20) break; + } + } + $result['failures'] = array_reverse($result['failures']); + } + j($result); + case 'intent_gen_log': + $logFile = '/var/log/jarvis/cron.log'; + $since = max(0, (int)($_GET['since'] ?? 0)); + if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); } + $allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $total = count($allLines); + // snapshot=1 just returns the current line count (call BEFORE triggering run) + if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); } + // Return only KB Intent Generator lines from position $since onward + $out = []; + for ($i = $since; $i < $total; $i++) { + if (stripos($allLines[$i], 'KB Intent Generator') !== false) { + $out[] = $allLines[$i]; + } + } + j(['lines'=>$out, 'next_line'=>$total]); + + // ── KB GENERATOR TOPICS ───────────────────────────────────────────── + case 'kb_topics': + $where = ['1=1']; $params = []; + if (!empty($_GET['q'])) { + $q2 = '%'.$_GET['q'].'%'; + $where[] = '(topic_name LIKE ? OR topic_id LIKE ? OR category LIKE ? OR description LIKE ?)'; + $params = array_merge($params, [$q2,$q2,$q2,$q2]); + } + if (isset($_GET['cat']) && $_GET['cat'] !== '') { $where[] = 'category=?'; $params[] = $_GET['cat']; } + if (isset($_GET['active']) && $_GET['active'] !== '') { $where[] = 'active=?'; $params[] = (int)$_GET['active']; } + $topics = JarvisDB::query( + 'SELECT id,topic_id,category,topic_name,description,active,run_count,last_run_at + FROM kb_generator_topics WHERE '.implode(' AND ',$where).' ORDER BY category,topic_name', + $params + ); + $catRows = JarvisDB::query('SELECT DISTINCT category FROM kb_generator_topics ORDER BY category'); + j(['topics'=>$topics, 'categories'=>array_column($catRows,'category')]); + + case 'kb_topic_save': + $id = (int)($_POST['id'] ?? 0); + $tid = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($_POST['topic_id'] ?? ''))); + $cat = trim($_POST['category'] ?? ''); + $name = trim($_POST['topic_name'] ?? ''); + $desc = trim($_POST['description'] ?? ''); + $act = (int)!empty($_POST['active']); + if (!$tid || !preg_match('/[a-z0-9]/', $tid) || !$cat || !$name || !$desc) + bad('All fields required — topic_id must contain at least one letter or digit'); + try { + if ($id) { + JarvisDB::execute( + 'UPDATE kb_generator_topics SET topic_id=?,category=?,topic_name=?,description=?,active=?,updated_at=NOW() WHERE id=?', + [$tid,$cat,$name,$desc,$act,$id] + ); + } else { + JarvisDB::execute( + 'INSERT INTO kb_generator_topics (topic_id,category,topic_name,description,active) VALUES (?,?,?,?,?)', + [$tid,$cat,$name,$desc,$act] + ); + } + } catch (\PDOException $e) { + bad(strpos($e->getMessage(), 'Duplicate entry') !== false + ? 'topic_id already in use — choose a different slug' + : 'Database error'); + } + j(['ok'=>true,'msg'=> $id ? 'Topic updated' : 'Topic created']); + + case 'kb_topic_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('DELETE FROM kb_generator_topics WHERE id=?', [$id]); + j(['ok'=>true]); + + case 'kb_topic_toggle': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute('UPDATE kb_generator_topics SET active=NOT active, updated_at=NOW() WHERE id=?', [$id]); + $row = JarvisDB::single('SELECT active FROM kb_generator_topics WHERE id=?', [$id]); + j(['ok'=>true,'active'=>(bool)$row['active']]); + + case 'arc_setup_log': + $logFile = '/var/log/jarvis/arc-setup.log'; + $since = max(0, (int)($_GET['since'] ?? 0)); + if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); } + $allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $total = count($allLines); + if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); } + j(['lines'=>array_values(array_slice($allLines, $since)), 'next_line'=>$total]); + case 'arc_status': + $ch = curl_init('http://127.0.0.1:7474/status'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]); + $raw = curl_exec($ch); $err = curl_error($ch); curl_close($ch); + if ($err || !$raw) j(['online'=>false, 'error'=>$err ?: 'unreachable']); + j(json_decode($raw, true) ?: ['online'=>false, 'error'=>'bad response']); + + case 'arc_jobs': + $status = $_GET['status'] ?? ''; + $limit = (int)($_GET['limit'] ?? 100); + $url = 'http://127.0.0.1:7474/jobs?' . http_build_query(array_filter(['status'=>$status,'limit'=>$limit])); + $ch = curl_init($url); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'arc_job_get': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/job/' . $id); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'not found']); + + case 'arc_ping': + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode(['type'=>'ping','payload'=>[],'priority'=>9,'created_by'=>'admin']), + CURLOPT_HTTPHEADER=>['Content-Type: application/json']]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'failed']); + + case 'arc_purge': + $ch = curl_init('http://127.0.0.1:7474/jobs/purge'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + // ── GMAIL TRIAGE ────────────────────────────────────────────────────── + case 'triage_list': + $limit = min((int)($_GET['limit'] ?? 100), 200); + $filter = $_GET['filter'] ?? 'priority'; + if ($filter === 'urgent') { + $rows = JarvisDB::query( + "SELECT * FROM email_triage WHERE action_taken != 'dismissed' AND category = 'urgent' ORDER BY priority DESC, created_at DESC LIMIT ?", + [$limit] + ); + } elseif ($filter === 'action') { + $rows = JarvisDB::query( + "SELECT * FROM email_triage WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting') ORDER BY priority DESC, created_at DESC LIMIT ?", + [$limit] + ); + } elseif ($filter === 'priority') { + $rows = JarvisDB::query( + "SELECT * FROM email_triage WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting') AND priority >= 5 ORDER BY priority DESC, created_at DESC LIMIT ?", + [$limit] + ); + } else { + $rows = JarvisDB::query( + "SELECT * FROM email_triage ORDER BY priority DESC, created_at DESC LIMIT ?", + [$limit] + ); + } + $counts = JarvisDB::single("SELECT COUNT(*) AS total, + SUM(category='urgent') AS urgent, SUM(category='action') AS action, + SUM(category='reply') AS reply, SUM(category='meeting') AS meeting, + SUM(action_taken='none') AS pending + FROM email_triage WHERE action_taken != 'dismissed'"); + j(['items' => $rows ?: [], 'counts' => $counts]); + + case 'triage_action': + $id = (int)($_GET['id'] ?? $_POST['id'] ?? 0); if (!$id) bad('Missing id'); + $act = $_POST['action'] ?? $_GET['action_val'] ?? 'dismissed'; + $allowed = ['dismissed','replied','done','snoozed']; + if (!in_array($act, $allowed)) bad('Invalid action'); + JarvisDB::execute("UPDATE email_triage SET action_taken = ? WHERE id = ?", [$act, $id]); + j(['ok' => true]); + + case 'triage_run': + $account = $_GET['account'] ?? 'gmail'; + $maxEmails = (int)($_GET['max'] ?? 25); + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['type'=>'gmail_triage','payload'=>['account'=>$account,'max_emails'=>$maxEmails,'provider'=>'claude'],'priority'=>7,'created_by'=>'admin']), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']); + + // ── OUTBOX ──────────────────────────────────────────────────────────── + case 'outbox_list': + $limit = min((int)($_GET['limit'] ?? 50), 200); + $status = $_GET['status'] ?? ''; + $qs = http_build_query(array_filter(['limit' => $limit, 'status' => $status])); + $ch = curl_init('http://127.0.0.1:7474/comms/sent' . ($qs ? "?{$qs}" : '')); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'outbox_delete': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/comms/sent/' . $id); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'failed']); + + case 'send_reply': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing triage id'); + $content = $_GET['content'] ?? ''; + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['type'=>'send_email','payload'=>['triage_id'=>$id,'content'=>$content],'priority'=>8,'created_by'=>'admin']), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']); + + case 'compose_email': + $to = $_GET['to'] ?? ''; if (!$to) bad('Missing recipient'); + $subject = $_GET['subject'] ?? ''; + $body = $_GET['body'] ?? ''; + $account = $_GET['account'] ?? 'gmail'; + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['type'=>'compose_email','payload'=>['recipient'=>$to,'subject'=>$subject,'instructions'=>$body,'account'=>$account,'auto_send'=>false],'priority'=>7,'created_by'=>'admin']), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']); + + // ── DIRECTIVES ─────────────────────────────────────────────────────── + case 'directive_list': + $status = $_GET['status'] ?? 'active'; + $category = $_GET['category'] ?? ''; + $where = '1=1'; $params = []; + if ($status && $status !== 'all') { $where .= ' AND d.status=?'; $params[] = $status; } + if ($category) { $where .= ' AND d.category=?'; $params[] = $category; } + $rows = JarvisDB::query( + "SELECT d.*, + COUNT(kr.id) AS kr_count, + COALESCE(SUM(kr.current_value),0) AS kr_current_sum, + COALESCE(SUM(kr.target_value),0) AS kr_target_sum, + (SELECT COUNT(*) FROM directive_links dl WHERE dl.directive_id=d.id) AS link_count + FROM directives d + LEFT JOIN directive_key_results kr ON kr.directive_id=d.id + WHERE {$where} + GROUP BY d.id + ORDER BY d.priority DESC, d.target_date ASC, d.created_at DESC", + $params + ) ?: []; + foreach ($rows as &$r) { + $r['progress'] = ($r['kr_target_sum'] > 0) + ? (float)round($r['kr_current_sum'] / $r['kr_target_sum'] * 100, 1) + : 0; + } + j(['directives' => $rows]); + + case 'directive_get': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $d = JarvisDB::single("SELECT * FROM directives WHERE id=?", [$id]); + if (!$d) bad('Not found', 404); + $krs = JarvisDB::query("SELECT * FROM directive_key_results WHERE directive_id=? ORDER BY id", [$id]) ?: []; + $links = JarvisDB::query( + "SELECT dl.*, COALESCE(t.title,a.title) AS linked_title + FROM directive_links dl + LEFT JOIN tasks t ON dl.link_type='task' AND t.id=dl.link_id + LEFT JOIN appointments a ON dl.link_type='appointment' AND a.id=dl.link_id + WHERE dl.directive_id=? ORDER BY dl.created_at DESC", + [$id] + ) ?: []; + $cur = array_sum(array_column($krs,'current_value')); + $tgt = array_sum(array_column($krs,'target_value')); + $d['progress'] = $tgt > 0 ? round($cur/$tgt*100,1) : 0; + $d['key_results'] = $krs; + $d['links'] = $links; + j($d); + + case 'directive_save': + $id = (int)($_GET['id'] ?? 0); + $body = file_get_contents('php://input'); + $data_in = json_decode($body, true) ?: []; + $title = trim($data_in['title'] ?? ''); + $description = trim($data_in['description'] ?? ''); + $category = $data_in['category'] ?? 'work'; + $status = $data_in['status'] ?? 'active'; + $priority = (int)($data_in['priority'] ?? 5); + $target_date = $data_in['target_date'] ?? null; + $krs = $data_in['key_results'] ?? []; + if (!$title) bad('Title required'); + if ($id) { + JarvisDB::execute( + "UPDATE directives SET title=?,description=?,category=?,status=?,priority=?,target_date=?,updated_at=NOW() WHERE id=?", + [$title,$description,$category,$status,$priority,$target_date?:null,$id] + ); + } else { + $id = JarvisDB::insert( + "INSERT INTO directives (title,description,category,status,priority,target_date) VALUES (?,?,?,?,?,?)", + [$title,$description,$category,$status,$priority,$target_date?:null] + ); + } + if (is_array($krs)) { + JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]); + foreach ($krs as $kr) { + $krt = trim($kr['title'] ?? ''); if (!$krt) continue; + JarvisDB::execute( + "INSERT INTO directive_key_results (directive_id,title,current_value,target_value,unit) VALUES (?,?,?,?,?)", + [$id,$krt,(float)($kr['current_value']??0),(float)($kr['target_value']??100),$kr['unit']??'%'] + ); + } + } + j(['ok' => true, 'id' => $id]); + + case 'directive_delete': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]); + JarvisDB::execute("DELETE FROM directive_links WHERE directive_id=?", [$id]); + JarvisDB::execute("DELETE FROM directives WHERE id=?", [$id]); + j(['ok' => true]); + + case 'arc_action': + $body = file_get_contents('php://input'); + $d = json_decode($body, true) ?: []; + $type = $d['action'] === 'job_create' ? ($d['type'] ?? '') : ''; + $payload = $d['payload'] ?? []; + $pri = (int)($d['priority'] ?? 5); + if (!$type) bad('Missing type'); + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS => json_encode(['type'=>$type,'payload'=>$payload,'priority'=>$pri,'created_by'=>'admin']), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']); + + // ── MISSION OPS ────────────────────────────────────────────────────── + case 'mission_list': + $ch = curl_init('http://127.0.0.1:7474/missions'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'mission_get': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/missions/' . $id); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'not found']); + + case 'mission_runs': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $limit = (int)($_GET['limit'] ?? 20); + $ch = curl_init("http://127.0.0.1:7474/missions/{$id}/runs?limit={$limit}"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'mission_save': // create or update + $id = (int)($_GET['id'] ?? 0); + $url = $id ? "http://127.0.0.1:7474/missions/{$id}" : 'http://127.0.0.1:7474/missions'; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, + CURLOPT_CUSTOMREQUEST => $id ? 'PUT' : 'POST', + CURLOPT_POSTFIELDS => file_get_contents('php://input'), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']); + + case 'mission_delete': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/missions/' . $id); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + case 'mission_run': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init("http://127.0.0.1:7474/missions/{$id}/run"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>120, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS => json_encode(['trigger_source'=>'admin']), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable or timeout']); + + case 'mission_toggle': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $enabled = (int)($_GET['enabled'] ?? 0); + $ch = curl_init('http://127.0.0.1:7474/missions/' . $id); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT', + CURLOPT_POSTFIELDS => json_encode(['enabled'=>$enabled]), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + // ── CLEARANCE PROTOCOL ─────────────────────────────────────────────── + case 'clearance_pending': + $ch = curl_init('http://127.0.0.1:7474/clearance/pending'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'clearance_history': + $limit = min((int)($_GET['limit'] ?? 50), 200); + $ch = curl_init('http://127.0.0.1:7474/clearance/history?limit=' . $limit); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'clearance_approve': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $body = json_decode(file_get_contents('php://input'), true) ?: []; + $decidedBy = $body['decided_by'] ?? 'admin'; + $ch = curl_init('http://127.0.0.1:7474/clearance/' . $id . '/approve'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS => json_encode(['decided_by'=>$decidedBy]), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + case 'clearance_deny': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $body = json_decode(file_get_contents('php://input'), true) ?: []; + $decidedBy = $body['decided_by'] ?? 'admin'; + $note = $body['note'] ?? ''; + $ch = curl_init('http://127.0.0.1:7474/clearance/' . $id . '/deny'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS => json_encode(['decided_by'=>$decidedBy,'note'=>$note]), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + case 'clearance_rules': + $ch = curl_init('http://127.0.0.1:7474/clearance/rules'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'clearance_rule_update': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $body = json_decode(file_get_contents('php://input'), true) ?: []; + $ch = curl_init('http://127.0.0.1:7474/clearance/rules/' . $id); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT', + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + case 'clearance_rule_create': + $body = json_decode(file_get_contents('php://input'), true) ?: []; + $ch = curl_init('http://127.0.0.1:7474/clearance/rules'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true, + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + // ── MEMORY CORE ────────────────────────────────────────────────────── + case 'memory_list': + $limit = min((int)($_GET['limit'] ?? 200), 500); + $category = $_GET['category'] ?? ''; + $search = $_GET['search'] ?? ''; + $qs = http_build_query(array_filter(['limit'=>$limit,'category'=>$category,'search'=>$search])); + $ch = curl_init('http://127.0.0.1:7474/memory/facts' . ($qs ? '?'.$qs : '')); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: []); + + case 'memory_stats': + $ch = curl_init('http://127.0.0.1:7474/memory/stats'); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: ['total'=>0,'by_category'=>[]]); + + case 'memory_store': + $body = json_decode(file_get_contents('php://input'),true) ?: []; + $ch = curl_init('http://127.0.0.1:7474/memory/facts'); + curl_setopt_array($ch,[ + CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>5, + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: ['ok'=>true]); + + case 'memory_delete': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/memory/facts/' . $id); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_CUSTOMREQUEST=>'DELETE',CURLOPT_TIMEOUT=>5]); + curl_exec($ch); curl_close($ch); + j(['ok'=>true]); + + case 'memory_clear': + $category = $_GET['category'] ?? ''; + $url = 'http://127.0.0.1:7474/memory/facts' . ($category ? '?category=' . urlencode($category) : ''); + $ch = curl_init($url); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_CUSTOMREQUEST=>'DELETE',CURLOPT_TIMEOUT=>5]); + curl_exec($ch); curl_close($ch); + j(['ok'=>true]); + + // ── VISION PROTOCOL ────────────────────────────────────────────────── + case 'vision_list': + $limit = min((int)($_GET['limit'] ?? 30), 100); + $agent = $_GET['agent'] ?? ''; + $url = 'http://127.0.0.1:7474/screenshots?' . http_build_query(array_filter(['limit'=>$limit,'agent'=>$agent])); + $ch = curl_init($url); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: []); + + case 'vision_get': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/screenshots/' . $id); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'not found']); + + case 'vision_delete': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/screenshots/' . $id); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + case 'vision_screenshot': + $agent = trim($_GET['agent'] ?? ''); if (!$agent) bad('Missing agent'); + $analyze = ($_GET['analyze'] ?? '1') !== '0'; + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['type'=>'screenshot','payload'=>['agent'=>$agent,'analyze'=>$analyze],'priority'=>8,'created_by'=>'admin']), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']); + + + case 'vision_analyze': + $id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id'); + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>5, + CURLOPT_POSTFIELDS=>json_encode(['type'=>'vision','payload'=>['screenshot_id'=>$id,'provider'=>'claude'],'priority'=>8,'created_by'=>'admin']), + CURLOPT_HTTPHEADER=>['Content-Type: application/json']]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']); + break; + + case 'vision_purge': + $ch = curl_init('http://127.0.0.1:7474/screenshots/purge'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw, true) ?: ['ok'=>true]); + + // ── GUARDIAN MODE ───────────────────────────────────────────────── + case 'guardian_status': + $ch = curl_init('http://127.0.0.1:7474/guardian/status'); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: ['error'=>'unreachable']); + + case 'guardian_events': + $limit = (int)($_GET['limit'] ?? 50); + $severity = $_GET['severity'] ?? ''; + $url = 'http://127.0.0.1:7474/guardian/events?' . http_build_query(array_filter(['limit'=>$limit,'severity'=>$severity])); + $ch = curl_init($url); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: []); + + case 'guardian_ack': + $id = (int)($_GET['id'] ?? $_POST['id'] ?? 0); + if ($id) { + $ch = curl_init('http://127.0.0.1:7474/guardian/events/'.$id.'/ack'); + } else { + $ch = curl_init('http://127.0.0.1:7474/guardian/events/ack_all'); + } + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5,CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>'']); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: ['ok'=>true]); + + case 'guardian_sitrep': + $detail = $_GET['detail'] ?? 'full'; + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch,[ + CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5,CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode(['type'=>'sitrep','payload'=>['detail'=>$detail,'provider'=>'claude'],'priority'=>9,'created_by'=>'admin']), + CURLOPT_HTTPHEADER=>['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: ['error'=>'Arc Reactor unreachable']); + + case 'guardian_config_set': + $key = $_POST['key'] ?? $_GET['key'] ?? ''; + $val = $_POST['value'] ?? $_GET['value'] ?? ''; + if (!$key) bad('Missing key'); + $ch = curl_init('http://127.0.0.1:7474/job'); + curl_setopt_array($ch,[ + CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5,CURLOPT_POST=>true, + CURLOPT_POSTFIELDS=>json_encode(['type'=>'guardian_config','payload'=>['action'=>'set','key'=>$key,'value'=>$val],'priority'=>9,'created_by'=>'admin']), + CURLOPT_HTTPHEADER=>['Content-Type: application/json'], + ]); + $raw = curl_exec($ch); curl_close($ch); + j(json_decode($raw,true) ?: ['ok'=>true]); + + case 'users_list': + j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username')); + + case 'users_save': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); + $dn = trim($_POST['display_name'] ?? ''); + $pw = trim($_POST['password'] ?? ''); + if ($pw) { + JarvisDB::execute('UPDATE users SET display_name=?,password_hash=? WHERE id=?', [$dn, password_hash($pw, PASSWORD_BCRYPT), $id]); + } else { + JarvisDB::execute('UPDATE users SET display_name=? WHERE id=?', [$dn, $id]); + } + j(['ok' => true]); + + // ── BACKUPS ─────────────────────────────────────────────────────────── + case 'backups_list': + $dir = '/var/backups/jarvis'; + $lock = "$dir/backup.lock"; + $log = "$dir/backup.log"; + $running = file_exists($lock) && (time() - filemtime($lock)) < 3600; + $files = []; + foreach (glob("$dir/jarvis_backup_*.tar.gz") ?: [] as $f) { + $files[] = [ + 'file' => basename($f), + 'size' => filesize($f), + 'size_mb' => round(filesize($f)/1048576, 1), + 'date' => date('Y-m-d H:i:s', filemtime($f)), + ]; + } + usort($files, fn($a,$b) => strcmp($b['date'], $a['date'])); + $lastLog = $log && file_exists($log) ? trim(shell_exec("tail -3 " . escapeshellarg($log))) : ''; + j(['running' => $running, 'files' => $files, 'last_log' => $lastLog]); + + case 'backup_trigger': + $lock = '/var/backups/jarvis/backup.lock'; + if (file_exists($lock) && (time() - filemtime($lock)) < 3600) { + j(['ok' => false, 'message' => 'Backup already running']); + } + shell_exec('nohup /usr/local/bin/jarvis-backup.sh > /dev/null 2>&1 &'); + sleep(1); + j(['ok' => true, 'message' => 'Backup started']); + + case 'backup_download': + $file = basename($_GET['file'] ?? ''); + if (!preg_match('/^jarvis_backup_[\d_-]+\.tar\.gz$/', $file)) bad('Invalid filename'); + $path = '/var/backups/jarvis/' . $file; + if (!file_exists($path)) bad('File not found', 404); + header('Content-Type: application/gzip'); + header('Content-Disposition: attachment; filename="' . $file . '"'); + header('Content-Length: ' . filesize($path)); + header('X-Accel-Buffering: no'); + ob_end_clean(); + readfile($path); + exit; + + case 'docs_download': + $path = '/var/www/jarvis-private/INFRASTRUCTURE-REFERENCE.md'; + if (!file_exists($path)) bad('File not found', 404); + header('Content-Type: text/markdown'); + header('Content-Disposition: attachment; filename="INFRASTRUCTURE-REFERENCE.md"'); + header('Content-Length: ' . filesize($path)); + header('X-Accel-Buffering: no'); + ob_end_clean(); + readfile($path); + exit; + + default: bad('Unknown action'); + } +} +?> + + + + + +JARVIS ADMIN + + + + + + + + +
+
+

JARVIS

+

ADMIN PORTAL

+
+
+
+ +
+
+ + +
+
+
+ + ADMIN PORTAL +
+
+ + +
+
+
+ +
+ + +
+
DASHBOARD
+
SCANNING...
+
+
+ + +
+
AGENTS +
+
+
+
+ + + +
+
⚙ JARVIS AGENT WORKERS + +
+
FIELD AGENTS
+ + +
HOSTNAMETYPEIPSTATUSVERSIONCAPABILITIESLAST SEENACTIONS
LOADING...
+
CRON WORKERS
+ + +
WORKERSCHEDULEHOSTLAST RUNACTIONS
+
DAEMONS
+ + +
DAEMONHOSTSTATUSINFOACTIONS
+
+
+
NETWORK DEVICES +
+ + + +
+
+
+ FILTER: + + + + +   +
+
SCANNING...
+
+ + +
+
ALERTS +
+ + + + +
+
+
+ FILTER: + + + +
+
SCANNING...
+
+ + +
+
KB FACTS +
+ + +
+
+
+ CATEGORY: + +   +
+
SCANNING...
+
+ + +
+
KB INTENTS +
+ + + + + +
+
+
+ + + +
+
SCANNING...
+
+ + +
+
HOME ASSISTANT ENTITIES +
+
+
+ DOMAIN: + +   + +   + +
+
SCANNING...
+
+ + +
+
NEWS MANAGEMENT +
+ + +
+
+
+
+
PINNED / CUSTOM NEWS
+
SCANNING...
+
+
+
LIVE FEED (auto-refreshed)
+
SCANNING...
+
+
+
+ + +
+
PROXMOX VMs +
+
+
SCANNING...
+
+ + +
+
BACKUPS +
+ + +
+
+ +
+ Daily automatic backup runs at 2:00 AM. Files + all databases. Last 7 days retained. Stored on server — download anytime. +
+
SCANNING...
+
+ + +
+
DOCUMENTATION
+
+
INFRASTRUCTURE REFERENCE
+
Complete server map, credentials, deployment workflow, service configs, and phone system reference.
+ + ↓ DOWNLOAD INFRASTRUCTURE-REFERENCE.MD + +
+
+ + +
+
SITE HEALTH
+
SCANNING...
+
+ + +
+
EMAIL INTELLIGENCE +
+ + + + +
+
+
+
+
+ +
+ + +
+
TASKS +
+ + + + +
+
+
+
+ + +
+
APPOINTMENTS +
+ + +
+
+
+
+ + +
+
CALENDAR SYNC +
+ + + +
+
+
+ iCloud CalDAV syncs automatically every 15 min. Add Google Calendar or ICS feeds below. + +
+
+
+ + +
+
USERS
+
SCANNING...
+
+ + + +
+
⚡ ARC REACTOR — CORE DAEMON
+
+
+
STATUS
+
CHECKING...
+
+
+
VERSION
+
+
+
+
JOBS DONE
+
+
+
+
FAILED
+
+
+
+
HEARTBEAT
+
+
+
+
CAPABILITIES
+
+
+
+ +
+ + + + +
+ +
INITIALIZING...
+
+ + +
+
◈ VISION PROTOCOL — FIELD SCREENSHOTS
+ +
+ + + + +
+
+ + +
+ + +
+
◈ GUARDIAN MODE
+ +
+
+
STATUS
+
CHECKING...
+
+
+
LAST SCAN
+
+
+
+
UNREAD
+
+
+
+
24H EVENTS
+
+
+
+
THRESHOLDS
+
+
+
+ +
+ + + + + +
+ +
LOADING...
+
+ + +
+
◈ COMMS PROTOCOL — GMAIL TRIAGE
+ +
+ + + + +
+
+ + + +
LOADING TRIAGE DATA...
+
+ + +
+
◈ COMMS OUTBOX — SENT & QUEUED
+
+ + + +
+
+
LOADING OUTBOX...
+
+ + +
+
◈ MISSION OPS — AUTOMATED WORKFLOWS
+
+ + +
+
+ + +
LOADING MISSIONS...
+ + + + + + +
+ + +
+
◈ MISSION DIRECTIVES — OBJECTIVES & KEY RESULTS
+
+ + + + + +
+
+ +
LOADING DIRECTIVES...
+ + + +
+ + +
+
🔒 CLEARANCE PROTOCOL
+
+ +
+
+ +
+ +
+
PENDING AUTHORIZATION
+
LOADING...
+
+ +
+
CLEARANCE RULES
+
LOADING...
+
+
ADD CUSTOM RULE
+
+
JOB TYPE
+
RISK LEVEL
+ +
+
REQUIRE APPROVAL
+ +
+
AUTO-APPROVE AFTER (MIN)
+
+ + +
+
+
+ + +
+
DECISION HISTORY
+
LOADING...
+
+
+ + +
+
◈ MEMORY CORE — KNOWLEDGE GRAPH
+
+ + + + + +
+
+
LOADING MEMORY CORE...
+ + + +
+ +
+
+
+ + +
+ +
+ +
+ + + + diff --git a/public_html/agent/install-mac.sh b/public_html/agent/install-mac.sh new file mode 100644 index 0000000..9f5fda0 --- /dev/null +++ b/public_html/agent/install-mac.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# JARVIS Agent Installer — macOS +# Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY +# Or one-liner: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY + +set -e + +JARVIS_URL="https://jarvis.orbishosting.com" +REG_KEY="" +INSTALL_DIR="$HOME/.jarvis-agent" +PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist" +SERVICE_LABEL="com.jarvis.agent" + +while [[ $# -gt 0 ]]; do + case "$1" in + --jarvis-url) JARVIS_URL="$2"; shift 2 ;; + --key) REG_KEY="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +if [[ -z "$REG_KEY" ]]; then + read -rp "Registration key: " REG_KEY +fi + +JARVIS_URL="${JARVIS_URL%/}" + +echo "" +echo " ====================================" +echo " JARVIS Agent Installer v3.0 " +echo " ====================================" +echo "" + +# Check for Python3 +PYTHON3=$(command -v python3 2>/dev/null || "") +if [[ -z "$PYTHON3" ]]; then + echo "Python 3 is required. Install it with:" + echo " brew install python3" + echo " or download from https://www.python.org/downloads/" + exit 1 +fi +echo "Using Python: $PYTHON3 ($($PYTHON3 --version 2>&1))" + +mkdir -p "$INSTALL_DIR" + +# Download macOS-native agent script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" +if [[ -f "$SCRIPT_DIR/jarvis-agent-mac.py" ]]; then + cp "$SCRIPT_DIR/jarvis-agent-mac.py" "$INSTALL_DIR/jarvis-agent.py" + echo "Copied agent from local directory." +else + echo "Downloading macOS agent..." + curl -sSL "$JARVIS_URL/agent/jarvis-agent-mac.py" -o "$INSTALL_DIR/jarvis-agent.py" + echo "Downloaded." +fi +chmod +x "$INSTALL_DIR/jarvis-agent.py" + +HOSTNAME=$(hostname -f 2>/dev/null || hostname) +AGENT_ID="${HOSTNAME}_mac" + +# Write config (preserve existing) +if [[ ! -f "$INSTALL_DIR/config.json" ]]; then +cat > "$INSTALL_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME", + "agent_id": "$AGENT_ID", + "agent_type": "macos", + "poll_interval": 30, + "heartbeat_every": 10, + "update_check_hours": 24, + "watch_services": [], + "allow_shell_commands": false +} +JSONEOF + chmod 600 "$INSTALL_DIR/config.json" + echo "Config written to $INSTALL_DIR/config.json" +else + echo "Config already exists — preserving $INSTALL_DIR/config.json" +fi + +# Write launchd plist +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$PLIST_PATH" << PLISTEOF + + + + + Label + $SERVICE_LABEL + ProgramArguments + + $PYTHON3 + $INSTALL_DIR/jarvis-agent.py + + EnvironmentVariables + + JARVIS_CONFIG + $INSTALL_DIR/config.json + JARVIS_STATE + $INSTALL_DIR/state.json + + RunAtLoad + + KeepAlive + + StandardOutPath + $INSTALL_DIR/jarvis-agent.log + StandardErrorPath + $INSTALL_DIR/jarvis-agent.log + + +PLISTEOF + +# Load/reload the service +launchctl unload "$PLIST_PATH" 2>/dev/null || true +launchctl load "$PLIST_PATH" + +sleep 2 +if launchctl list 2>/dev/null | grep -q "$SERVICE_LABEL"; then + echo "" + echo " JARVIS Agent installed and running." + echo " Machine : $HOSTNAME ($AGENT_ID)" + echo " JARVIS : $JARVIS_URL" + echo " Logs : tail -f $INSTALL_DIR/jarvis-agent.log" + echo " Config : $INSTALL_DIR/config.json" + echo " Stop : launchctl unload $PLIST_PATH" + echo " Update : curl -sSL $JARVIS_URL/agent/install-mac.sh | bash -s -- --key $REG_KEY" +else + echo "" + echo " Agent installed but not detected as running. Check logs:" + echo " tail -f $INSTALL_DIR/jarvis-agent.log" +fi +echo "" diff --git a/public_html/agent/install-windows.ps1 b/public_html/agent/install-windows.ps1 new file mode 100644 index 0000000..f37b1ed --- /dev/null +++ b/public_html/agent/install-windows.ps1 @@ -0,0 +1,151 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + JARVIS Agent installer for Windows. + +.DESCRIPTION + Installs JARVIS Agent as a Windows Service that auto-starts at boot. + Requires: PowerShell 5.1+, internet access, and Administrator rights. + +.EXAMPLE + # Interactive install (prompts for registration key): + irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + + # Silent install with key: + $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +#> + +$ErrorActionPreference = 'Stop' +$JARVIS_URL = 'https://jarvis.orbishosting.com' +$INSTALL_DIR = 'C:\ProgramData\jarvis-agent' +$SERVICE_NAME = 'JARVISAgent' +$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py" +$CONFIG_FILE = "$INSTALL_DIR\config.json" + +function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan } +function Write-OK { param($msg) Write-Host " OK: $msg" -ForegroundColor Green } +function Write-Fail { param($msg) Write-Host " ERROR: $msg" -ForegroundColor Red; exit 1 } + +Write-Host "`n========================================" -ForegroundColor Yellow +Write-Host " JARVIS Agent Installer for Windows" -ForegroundColor Yellow +Write-Host "========================================`n" -ForegroundColor Yellow + +# ── Stop existing service if running ───────────────────────────────────────── +$existing = Get-Service -Name $SERVICE_NAME -ErrorAction SilentlyContinue +if ($existing) { + Write-Step "Stopping existing JARVIS Agent service..." + if ($existing.Status -eq 'Running') { + Stop-Service -Name $SERVICE_NAME -Force + Start-Sleep 2 + } + try { + & python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null + } catch {} + Write-OK "Existing service removed." +} + +# ── Check / install Python ──────────────────────────────────────────────────── +Write-Step "Checking Python..." +$py = Get-Command python -ErrorAction SilentlyContinue +if (-not $py) { + Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run." + } + winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements + $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User") + $py = Get-Command python -ErrorAction SilentlyContinue + if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" } +} +$pyVersion = & python --version 2>&1 +Write-OK $pyVersion + +# ── Install pywin32 ─────────────────────────────────────────────────────────── +Write-Step "Checking pywin32..." +$checkWin32 = & python -c "import win32service; print('ok')" 2>&1 +if ($checkWin32 -ne 'ok') { + Write-Host " Installing pywin32..." -ForegroundColor Yellow + & python -m pip install --quiet pywin32 + & python -m pywin32_postinstall -install 2>$null + Write-OK "pywin32 installed." +} else { + Write-OK "pywin32 already installed." +} + +# ── Create install dir ──────────────────────────────────────────────────────── +Write-Step "Creating install directory..." +New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null +Write-OK $INSTALL_DIR + +# ── Download agent script ───────────────────────────────────────────────────── +Write-Step "Downloading JARVIS agent..." +try { + Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing + Write-OK "Agent downloaded to $AGENT_SCRIPT" +} catch { + Write-Fail "Failed to download agent: $_" +} + +# ── Get registration key ────────────────────────────────────────────────────── +$regKey = $env:JARVIS_REG_KEY +if (-not $regKey -and (Test-Path $CONFIG_FILE)) { + $existingCfg = Get-Content $CONFIG_FILE | ConvertFrom-Json + $regKey = $existingCfg.registration_key + if ($regKey) { Write-OK "Using existing registration key from config." } +} +if (-not $regKey) { + $regKey = Read-Host "`n Enter JARVIS registration key" + if (-not $regKey) { Write-Fail "Registration key required." } +} + +# ── Get hostname ────────────────────────────────────────────────────────────── +$hostname = $env:COMPUTERNAME +$customHostname = $env:JARVIS_HOSTNAME +if ($customHostname) { $hostname = $customHostname } + +# ── Write config ────────────────────────────────────────────────────────────── +Write-Step "Writing config..." +$cfg = @{ + jarvis_url = $JARVIS_URL + registration_key = $regKey + hostname = $hostname + agent_type = 'windows' + ssl_verify = $true + poll_interval = 30 + heartbeat_every = 10 + update_check_hours = 24 + watch_services = @('WinDefend', 'Spooler', 'wuauserv') +} | ConvertTo-Json -Depth 5 +$cfg | Out-File -FilePath $CONFIG_FILE -Encoding utf8 +Write-OK "Config written to $CONFIG_FILE" + +# ── Install Windows Service ─────────────────────────────────────────────────── +Write-Step "Installing Windows service..." +$pyPath = (Get-Command python).Source +& $pyPath "$AGENT_SCRIPT" --startup auto install +if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." } +Write-OK "Service '$SERVICE_NAME' installed." + +# ── Start service ───────────────────────────────────────────────────────────── +Write-Step "Starting service..." +Start-Service -Name $SERVICE_NAME +Start-Sleep 3 +$svc = Get-Service -Name $SERVICE_NAME +if ($svc.Status -ne 'Running') { Write-Fail "Service failed to start. Check C:\ProgramData\jarvis-agent\jarvis-agent.log" } +Write-OK "Service is running." + +# ── Test connectivity ───────────────────────────────────────────────────────── +Write-Step "Testing JARVIS connection..." +try { + $ping = Invoke-RestMethod -Uri "$JARVIS_URL/api/ping" -TimeoutSec 10 + Write-OK "JARVIS is online: $($ping.codename)" +} catch { + Write-Host " WARNING: Could not reach JARVIS at $JARVIS_URL - check connectivity." -ForegroundColor Yellow +} + +Write-Host "`n========================================" -ForegroundColor Green +Write-Host " JARVIS Agent installed successfully!" -ForegroundColor Green +Write-Host " Hostname: $hostname" -ForegroundColor Green +Write-Host " Service: $SERVICE_NAME (auto-start at boot)" -ForegroundColor Green +Write-Host " Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Green +Write-Host "========================================`n" -ForegroundColor Green diff --git a/public_html/agent/install.sh b/public_html/agent/install.sh new file mode 100644 index 0000000..5d6c268 --- /dev/null +++ b/public_html/agent/install.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# JARVIS Agent Installer — one-liner for any Linux host: +# curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s +# +# agent_type: linux | proxmox | homeassistant +# Example: curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s myserver linux + +set -e + +HOSTNAME_ARG="${1:-$(hostname -s)}" +AGENT_TYPE="${2:-linux}" +JARVIS_URL="${JARVIS_URL:-https://jarvis.orbishosting.com}" +JARVIS_HOST="" +INSTALL_DIR="/opt/jarvis-agent" +CONFIG_DIR="/etc/jarvis-agent" +STATE_DIR="/var/lib/jarvis-agent" +REG_KEY="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" +SERVICE_FILE="/etc/systemd/system/jarvis-agent.service" + +echo "=== JARVIS Agent Installer v3.0 ===" +echo "Host: $HOSTNAME_ARG | Type: $AGENT_TYPE | Server: $JARVIS_URL" + +# ── Dependencies ────────────────────────────────────────────────────────────── +if command -v apt-get &>/dev/null; then + apt-get install -yq python3 curl imagemagick 2>/dev/null || true + apt-get install -yq python3-psutil python3-requests 2>/dev/null || true +elif command -v yum &>/dev/null; then + yum install -yq python3 curl ImageMagick 2>/dev/null || true +elif command -v apk &>/dev/null; then + apk add --no-cache python3 curl imagemagick 2>/dev/null || true +fi + +# pip fallback if psutil/requests not available via package manager +python3 -c "import psutil, requests" 2>/dev/null || pip3 install -q --break-system-packages requests psutil 2>/dev/null || pip3 install -q requests psutil 2>/dev/null || true + +# ── Create directories ───────────────────────────────────────────────────────── +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR" + +# ── Download agent ───────────────────────────────────────────────────────────── +echo "Downloading agent..." +curl -sk "$JARVIS_URL/agent/jarvis-agent.py" -o "$INSTALL_DIR/jarvis-agent.py" +cp "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py +chmod +x "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py + +# ── Write config (skip if already exists) ──────────────────────────────────── +if [[ -f "$CONFIG_DIR/config.json" ]]; then + echo "Config already exists at $CONFIG_DIR/config.json — keeping existing settings." +else + cat > "$CONFIG_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "host_header": "$JARVIS_HOST", + "ssl_verify": true, + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME_ARG", + "agent_type": "$AGENT_TYPE", + "poll_interval": 30, + "heartbeat_every": 10, + "update_check_hours": 24, + "watch_services": [] +} +JSONEOF + chmod 600 "$CONFIG_DIR/config.json" + echo "Config written to $CONFIG_DIR/config.json" +fi + +# ── Systemd service ──────────────────────────────────────────────────────────── +cat > "$SERVICE_FILE" << SVCEOF +[Unit] +Description=JARVIS Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 $INSTALL_DIR/jarvis-agent.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +SVCEOF + +systemctl daemon-reload +systemctl enable jarvis-agent +systemctl restart jarvis-agent + +sleep 2 +if systemctl is-active --quiet jarvis-agent; then + echo "" + echo "=== JARVIS Agent v3.0 installed and running ===" + echo "Config: $CONFIG_DIR/config.json" + echo "State: $STATE_DIR/state.json (created on first run)" + echo "Logs: journalctl -u jarvis-agent -f" +else + echo "" + echo "WARNING: Agent installed but not running. Check: journalctl -u jarvis-agent -n 30" +fi diff --git a/public_html/agent/jarvis-agent-mac.py b/public_html/agent/jarvis-agent-mac.py new file mode 100644 index 0000000..69f9247 --- /dev/null +++ b/public_html/agent/jarvis-agent-mac.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for macOS — system monitor that reports metrics to JARVIS HUD. +Install: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY +Config: ~/.jarvis-agent/config.json (or $JARVIS_CONFIG) +Logs: ~/.jarvis-agent/jarvis-agent.log (or journalctl via launchd) +""" + +import json +import os +import platform +import re +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +AGENT_VERSION = "3.0" + +# Config/state paths — env vars let the launchd plist override them +_default_dir = Path.home() / ".jarvis-agent" +CONFIG_PATH = Path(os.environ.get("JARVIS_CONFIG", str(_default_dir / "config.json"))) +STATE_PATH = Path(os.environ.get("JARVIS_STATE", str(_default_dir / "state.json"))) +LOG_PATH = _default_dir / "jarvis-agent.log" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +_last_cpu_times = None + +def get_cpu_percent() -> float: + """Delta-based CPU % using top (two samples).""" + global _last_cpu_times + try: + # top -l 2 gives two snapshots; second line has the real delta + r = subprocess.run( + ["top", "-l", "2", "-s", "0", "-n", "0"], + capture_output=True, text=True, timeout=12 + ) + idle = None + for line in r.stdout.splitlines(): + if "CPU usage:" in line: + m = re.search(r"([\d.]+)%\s+idle", line) + if m: + idle = float(m.group(1)) + if idle is not None: + return round(100.0 - idle, 1) + except Exception: + pass + return 0.0 + +def get_memory() -> dict: + try: + # Total physical memory + r = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=3) + total_bytes = int(r.stdout.strip()) + + # vm_stat for page counts; default page size on Apple Silicon and Intel = 4096 + page_size = 4096 + try: + ps_r = subprocess.run(["sysctl", "-n", "hw.pagesize"], capture_output=True, text=True, timeout=3) + page_size = int(ps_r.stdout.strip()) + except Exception: + pass + + vm_r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5) + pages = {} + for line in vm_r.stdout.splitlines(): + m = re.match(r"Pages\s+(.+?):\s+([\d]+)", line) + if m: + pages[m.group(1).strip().lower()] = int(m.group(2)) + + free_pages = pages.get("free", 0) + pages.get("speculative", 0) + # "available" = free + inactive (can be reclaimed) + avail_pages = free_pages + pages.get("inactive", 0) + used_pages = (total_bytes // page_size) - avail_pages + + total_mb = round(total_bytes / (1024 * 1024), 1) + used_mb = round(used_pages * page_size / (1024 * 1024), 1) + avail_mb = round(avail_pages * page_size / (1024 * 1024), 1) + used_mb = max(0, min(used_mb, total_mb)) + + return { + "total_mb": total_mb, + "used_mb": used_mb, + "free_mb": avail_mb, + "percent": round(used_mb / total_mb * 100, 1) if total_mb else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + r = subprocess.run(["df", "-H", "-l"], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines()[1:]: + parts = line.split() + if len(parts) >= 6: + mount = parts[5] + if not any(mount.startswith(x) for x in ["/dev", "/private/var/vm", "/System/Volumes/VM"]): + disks.append({ + "mount": mount, + "size": parts[1], + "used": parts[2], + "avail": parts[3], + "percent": parts[4].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + r = subprocess.run(["sysctl", "-n", "kern.boottime"], capture_output=True, text=True, timeout=3) + m = re.search(r"sec\s*=\s*(\d+)", r.stdout) + if m: + boot_ts = int(m.group(1)) + secs = time.time() - boot_ts + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + pass + return {} + +def get_load() -> list: + try: + r = subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True, timeout=3) + # format: "{ 0.12 0.34 0.56 }" + nums = re.findall(r"[\d.]+", r.stdout) + if len(nums) >= 3: + return [float(nums[0]), float(nums[1]), float(nums[2])] + except Exception: + pass + return [0, 0, 0] + +def get_services(cfg: dict) -> list: + """Check macOS LaunchDaemon/LaunchAgent services via launchctl.""" + watch = cfg.get("watch_services", []) + if not watch: + return [] + statuses = [] + try: + r = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=5) + running = set() + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 3: + running.add(parts[2]) + except Exception: + running = set() + for svc in watch: + status = "active" if any(svc in s for s in running) else "inactive" + statuses.append({"service": svc, "status": status}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + import shutil + caps = ["metrics", "commands"] + if shutil.which("docker"): + caps.append("docker") + if shutil.which("ollama"): + caps.append("ollama") + # screencapture is always available on macOS + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": "macOS", + "os_version": platform.mac_ver()[0], + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "macos") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_mac") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "version": AGENT_VERSION, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile, shutil + tmp = tempfile.mktemp(suffix=".png") + method = "unknown" + + # screencapture -x = no sound, always available on macOS + try: + r = subprocess.run(["screencapture", "-x", "-t", "png", tmp], + capture_output=True, timeout=15) + if r.returncode == 0 and os.path.exists(tmp): + method = "screencapture" + except Exception: + pass + + # Fallback: PIL + if method == "unknown": + try: + from PIL import ImageGrab + img = ImageGrab.grab() + img.save(tmp, "PNG") + method = "pil" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + snap = _sysinfo_snapshot() + snap["screenshot_available"] = False + snap["method"] = "text_only" + return snap + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "macOS"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + load = get_load() + data["load_1m"], data["load_5m"], data["load_15m"] = load[0], load[1], load[2] + except Exception: + pass + try: + r = subprocess.run(["df", "-H", "/"], capture_output=True, text=True, timeout=5) + data["disk"] = r.stdout.splitlines()[1] if r.stdout else "" + except Exception: + pass + try: + r = subprocess.run(["ps", "aux", "-r"], capture_output=True, text=True, timeout=5) + data["top_procs"] = r.stdout.splitlines()[1:8] + except Exception: + pass + try: + r = subprocess.run(["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, timeout=5) + listening = [l for l in r.stdout.splitlines() if "LISTEN" in l] + data["listening_ports"] = "\n".join(listening[:20]) + except Exception: + pass + return data + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2000", host], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + _cfg = load_config() + updated = self_update(_cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["launchctl", "kickstart", "-k", f"system/{svc}"], + capture_output=True, text=True, timeout=15) + if r.returncode != 0: + r = subprocess.run(["brew", "services", "restart", svc], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + r = subprocess.run(["log", "show", "--predicate", f'process == "{svc}"', + "--last", "1h", "--style", "compact"], + capture_output=True, text=True, timeout=15) + output = "\n".join(r.stdout.splitlines()[-lines:]) + return {"success": True, "output": output} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-mac.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (macOS) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while True: + tick_start = time.time() + now = tick_start + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + time.sleep(heartbeat_every) + + +if __name__ == "__main__": + main() diff --git a/public_html/agent/jarvis-agent-mac.py.sha256 b/public_html/agent/jarvis-agent-mac.py.sha256 new file mode 100644 index 0000000..85dd380 --- /dev/null +++ b/public_html/agent/jarvis-agent-mac.py.sha256 @@ -0,0 +1 @@ +6a0cb7a876f0d4ba36c5f7aaf6a80d6224b52d6e23ac32d6cd9f8c03ca9d8062 jarvis-agent-mac.py diff --git a/public_html/agent/jarvis-agent-windows.py b/public_html/agent/jarvis-agent-windows.py new file mode 100644 index 0000000..23d6440 --- /dev/null +++ b/public_html/agent/jarvis-agent-windows.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD. +Runs as a Windows Service (Win 8.1+) via pywin32. + +Install (run PowerShell as Admin): + irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + +Service management: + python jarvis-agent-windows.py --startup auto install # register service + python jarvis-agent-windows.py start # start + python jarvis-agent-windows.py stop # stop + python jarvis-agent-windows.py remove # uninstall + python jarvis-agent-windows.py debug # run in console (for testing) + +Config: C:\ProgramData\jarvis-agent\config.json +Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import threading +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") +CONFIG_PATH = INSTALL_DIR / "config.json" +STATE_PATH = INSTALL_DIR / "state.json" +LOG_PATH = INSTALL_DIR / "jarvis-agent.log" +AGENT_VERSION = "3.1" + +# Set by the service wrapper so self_update knows to stop instead of exec +_is_service = False +_stop_event = threading.Event() +_update_restart = False # True when stopping for self-update; triggers SCM restart + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + with open(LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH, encoding="utf-8") as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH, encoding="utf-8") as f: + return json.load(f) + return {} + +def save_state(state: dict): + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── PowerShell helper ────────────────────────────────────────────────────────── + +def _ps(script: str, timeout: int = 8) -> str: + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=timeout + ) + return r.stdout.strip() + except Exception: + return "" + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def get_cpu_percent() -> float: + try: + out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average") + return round(float(out), 1) + except Exception: + return 0.0 + +def get_memory() -> dict: + try: + out = _ps("$o=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$o.TotalVisibleMemorySize;free=$o.FreePhysicalMemory}|ConvertTo-Json") + d = json.loads(out) + total_kb = int(d.get("total", 0)) + free_kb = int(d.get("free", 0)) + used_kb = total_kb - free_kb + if total_kb == 0: + return {} + return { + "total_mb": round(total_kb / 1024, 1), + "used_mb": round(used_kb / 1024, 1), + "free_mb": round(free_kb / 1024, 1), + "percent": round(used_kb / total_kb * 100, 1), + } + except Exception: + return {} + +def get_disk() -> list: + try: + out = _ps("Get-PSDrive -PSProvider FileSystem | Where-Object{$_.Used -ne $null} | Select-Object Name,@{n='used';e={[math]::Round($_.Used/1GB,2)}},@{n='free';e={[math]::Round($_.Free/1GB,2)}} | ConvertTo-Json") + if not out: + return [] + items = json.loads(out) + if isinstance(items, dict): + items = [items] + disks = [] + for d in items: + used = float(d.get("used", 0)) + free = float(d.get("free", 0)) + total = used + free + pct = round(used / total * 100, 1) if total else 0 + disks.append({ + "mount": d.get("Name", "?") + ":\\", + "size": f"{round(total, 1)}G", + "used": f"{used}G", + "avail": f"{free}G", + "percent": str(int(pct)), + }) + return disks + except Exception: + return [] + +def get_uptime() -> dict: + try: + out = _ps("(Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalSeconds") + secs = float(out) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", ["WinDefend", "Spooler"]) + statuses = [] + for svc in watch: + try: + out = _ps(f"(Get-Service -Name '{svc}' -ErrorAction SilentlyContinue).Status") + status = "active" if out.lower() == "running" else (out.lower() or "unknown") + statuses.append({"service": svc, "status": status}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + import shutil + if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists(): + caps.append("docker") + # Screenshot via PIL or PowerShell .NET (always available on Windows) + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": [0, 0, 0], + "services": get_services(cfg), + "platform": "Windows", + "os_version": platform.version(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname().lower()) + agent_type = cfg.get("agent_type", "windows") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_windows") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "version": AGENT_VERSION, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile + tmp = str(INSTALL_DIR / "screenshot_tmp.png") + method = "unknown" + + # Try PIL/Pillow first + try: + from PIL import ImageGrab + img = ImageGrab.grab(all_screens=True) + img.save(tmp, "PNG") + method = "pil" + except ImportError: + pass + except Exception: + pass + + # Fallback: PowerShell .NET screenshot (no extra packages needed) + if method == "unknown": + ps_script = ( + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; " + "$s=[System.Windows.Forms.SystemInformation]::VirtualScreen; " + "$bmp=New-Object System.Drawing.Bitmap($s.Width,$s.Height); " + "$g=[System.Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($s.Location,[System.Drawing.Point]::Empty,$s.Size); " + f"$bmp.Save('{tmp}'); $g.Dispose(); $bmp.Dispose()" + ) + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script], + capture_output=True, timeout=15 + ) + if r.returncode == 0 and os.path.exists(tmp): + method = "powershell" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + return _sysinfo_snapshot() + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "Windows"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + data["cpu_percent"] = get_cpu_percent() + except Exception: + pass + try: + data["disk"] = get_disk() + except Exception: + pass + try: + ut = get_uptime() + data["uptime"] = ut.get("human", "") + except Exception: + pass + try: + out = _ps("Get-Process | Sort-Object CPU -Descending | Select-Object -First 8 Name,CPU,WorkingSet | ConvertTo-Json") + data["top_procs"] = json.loads(out) if out else [] + except Exception: + pass + try: + out = _ps("netstat -an | findstr LISTENING | head -20") + data["listening_ports"] = out[:800] + except Exception: + pass + return data + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + # Verify hash + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + if _is_service: + global _update_restart + _update_restart = True + log("Running as service — stopping for SCM-managed restart after update.") + _stop_event.set() + else: + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict, cfg: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-n", "3", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + log("[CMD] Self-update requested") + updated = self_update(cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + # Guard reads LOCAL config — never trust server-supplied flags + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd_str], + capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Restart-Service -Name '{svc}' -Force -ErrorAction Stop; 'ok'") + return {"success": "ok" in out.lower(), "output": out} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Get-EventLog -LogName Application -Source '{svc}' -Newest {lines} -ErrorAction SilentlyContinue | Format-List TimeGenerated,EntryType,Message | Out-String") + return {"success": True, "output": out[:3000]} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + if _stop_event.wait(60): + return + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (Windows) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while not _stop_event.is_set(): + now = time.time() + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd, cfg) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + if _stop_event.wait(heartbeat_every): + break # service stop was requested + + log("Agent stopped.") + + +# ── Windows Service wrapper ──────────────────────────────────────────────────── + +try: + import win32serviceutil + import win32service + import win32event + import servicemanager + _HAS_WIN32 = True +except ImportError: + _HAS_WIN32 = False + +if _HAS_WIN32: + class JarvisAgentService(win32serviceutil.ServiceFramework): + _svc_name_ = "JARVISAgent" + _svc_display_name_ = "JARVIS AI Agent" + _svc_description_ = "JARVIS system monitoring and AI agent — reports metrics to JARVIS HUD" + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self._svc_stop_event = win32event.CreateEvent(None, 0, 0, None) + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + _stop_event.set() + win32event.SetEvent(self._svc_stop_event) + + def SvcDoRun(self): + global _is_service + _is_service = True + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + main() + if _update_restart: + # Non-zero exit triggers SCM failure recovery → automatic restart + sys.exit(1) + + +if __name__ == "__main__": + if _HAS_WIN32: + if len(sys.argv) == 1: + # No args — SCM is starting us as a service + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(JarvisAgentService) + servicemanager.StartServiceCtrlDispatcher() + else: + # install / start / stop / remove / debug + win32serviceutil.HandleCommandLine(JarvisAgentService) + else: + # pywin32 not available — run directly (useful for testing) + main() diff --git a/public_html/agent/jarvis-agent-windows.py.sha256 b/public_html/agent/jarvis-agent-windows.py.sha256 new file mode 100644 index 0000000..2750c62 --- /dev/null +++ b/public_html/agent/jarvis-agent-windows.py.sha256 @@ -0,0 +1 @@ +224a634375b5d49ccc0a012e0e122ade5f8a1302615450dffbf9a03eac6b7a19 diff --git a/public_html/agent/jarvis-agent.py b/public_html/agent/jarvis-agent.py new file mode 100644 index 0000000..d237438 --- /dev/null +++ b/public_html/agent/jarvis-agent.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent — lightweight system monitor for Linux machines. +Registers with JARVIS, reports metrics, and executes commands. + +Install: sudo bash /opt/jarvis-agent/install.sh +Config: /etc/jarvis-agent/config.json +Logs: journalctl -u jarvis-agent -f +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import uuid +from datetime import datetime +from pathlib import Path + +CONFIG_PATH = "/etc/jarvis-agent/config.json" +STATE_PATH = "/var/lib/jarvis-agent/state.json" +AGENT_VERSION = "3.1" + +# ── Config helpers ──────────────────────────────────────────────────────────── + +def load_config() -> dict: + legacy_path = "/opt/jarvis-agent/config.json" + + if not os.path.exists(CONFIG_PATH): + if os.path.exists(legacy_path): + print(f"[JARVIS] Config found at legacy path {legacy_path} - migrating...", flush=True) + Path(CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(legacy_path) as f: + cfg = json.load(f) + else: + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True) + sys.exit(1) + else: + with open(CONFIG_PATH) as f: + cfg = json.load(f) + + # Migrate old key names so the agent self-heals instead of crash-looping + import re as _re + changed = False + if "server_url" in cfg and "jarvis_url" not in cfg: + cfg["jarvis_url"] = cfg.pop("server_url") + print("[JARVIS] Config migrated: server_url -> jarvis_url", flush=True) + changed = True + if "api_key" in cfg and "registration_key" not in cfg: + cfg["registration_key"] = cfg.pop("api_key") + print("[JARVIS] Config migrated: api_key -> registration_key", flush=True) + changed = True + if "hostname" not in cfg: + cfg["hostname"] = socket.gethostname() + changed = True + if "ssl_verify" not in cfg: + cfg["ssl_verify"] = not bool(_re.match(r"https?://\d+\.\d+\.\d+\.\d+", cfg.get("jarvis_url", ""))) + changed = True + + if changed: + with open(CONFIG_PATH, "w") as f: + json.dump(cfg, f, indent=2) + print("[JARVIS] Config saved after migration.", flush=True) + + return cfg + +def load_state() -> dict: + if os.path.exists(STATE_PATH): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool) -> _ssl.SSLContext | None: + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" # set from config at startup + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Registration ────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + # Check for Proxmox + if os.path.exists("/usr/bin/pvesh") or os.path.exists("/usr/sbin/pveversion"): + caps.append("proxmox") + # Check for Docker + if os.path.exists("/usr/bin/docker") or os.path.exists("/usr/local/bin/docker"): + caps.append("docker") + # Check for Ollama + if os.path.exists("/usr/local/bin/ollama") or os.path.exists("/usr/bin/ollama"): + caps.append("ollama") + # Check for Home Assistant + if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"): + caps.append("homeassistant") + return caps + +def register(cfg: dict, state: dict) -> str: + """Register with JARVIS. Returns api_key.""" + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "linux") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_{socket.gethostname()[:8]}") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + print(f"[JARVIS] Registering as '{agent_id}' ({agent_type}) from {ip}...", flush=True) + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + { + "hostname": hostname, + "version": AGENT_VERSION, + "agent_type": agent_type, + "ip_address": ip, + "capabilities": capabilities, + "agent_id": agent_id, + }, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + print(f"[ERROR] Registration failed: {result['error']}", flush=True) + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + print(f"[JARVIS] Registered. agent_id={state['agent_id']}", flush=True) + return api_key + +# ── Metrics collection ──────────────────────────────────────────────────────── + +def read_cpu_percent() -> float: + try: + with open("/proc/stat") as f: + line = f.readline() + fields = list(map(int, line.split()[1:])) + idle = fields[3] + total = sum(fields) + return round((1 - idle / total) * 100, 1) if total else 0.0 + except Exception: + return 0.0 + +_last_cpu = None + +def get_cpu_percent() -> float: + global _last_cpu + try: + with open("/proc/stat") as f: + line = f.readline() + fields = list(map(int, line.split()[1:])) + idle = fields[3] + fields[4] # idle + iowait + total = sum(fields) + if _last_cpu: + d_idle = idle - _last_cpu[0] + d_total = total - _last_cpu[1] + result = round((1 - d_idle / d_total) * 100, 1) if d_total else 0.0 + else: + result = 0.0 + _last_cpu = (idle, total) + return result + except Exception: + return 0.0 + +def get_memory() -> dict: + mem = {} + try: + with open("/proc/meminfo") as f: + for line in f: + parts = line.split() + if parts[0] in ("MemTotal:", "MemAvailable:", "MemFree:", "Buffers:", "Cached:"): + mem[parts[0].rstrip(":")] = int(parts[1]) + total = mem.get("MemTotal", 0) + available = mem.get("MemAvailable", 0) + used = total - available + return { + "total_mb": round(total / 1024, 1), + "used_mb": round(used / 1024, 1), + "free_mb": round(available / 1024, 1), + "percent": round(used / total * 100, 1) if total else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + result = subprocess.run(["df", "-h", "--output=source,fstype,size,used,avail,pcent,target"], + capture_output=True, text=True, timeout=5) + lines = result.stdout.strip().split("\n")[1:] + for line in lines: + parts = line.split() + if len(parts) >= 7: + mount = parts[6] + if not any(mount.startswith(x) for x in ["/sys", "/proc", "/dev/pts", "/run", "/snap"]): + disks.append({ + "mount": mount, + "size": parts[2], + "used": parts[3], + "avail": parts[4], + "percent": parts[5].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + with open("/proc/uptime") as f: + secs = float(f.read().split()[0]) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", []) + statuses = [] + for svc in watch: + try: + r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3) + statuses.append({"service": svc, "status": r.stdout.strip()}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def get_load() -> list: + try: + with open("/proc/loadavg") as f: + parts = f.read().split() + return [float(parts[0]), float(parts[1]), float(parts[2])] + except Exception: + return [0, 0, 0] + +def get_nordvpn_status() -> dict | None: + """Check nordlynx WireGuard interface. Returns None if nordlynx not present on this host.""" + try: + r = subprocess.run(["ip", "link", "show", "nordlynx"], + capture_output=True, text=True, timeout=3) + if r.returncode != 0: + return None + active = "UP,LOWER_UP" in r.stdout or "state UP" in r.stdout + return {"active": active, "interface": "nordlynx"} + except Exception: + return None + +def collect_metrics(cfg: dict) -> dict: + # First reading for CPU delta + get_cpu_percent() + time.sleep(1) + metrics = { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": platform.system(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + nordvpn = get_nordvpn_status() + if nordvpn is not None: + metrics["nordvpn"] = nordvpn + return metrics + +# ── Proxmox metrics ─────────────────────────────────────────────────────────── + +def collect_proxmox_metrics(cfg: dict) -> dict | None: + try: + result = subprocess.run( + ["pvesh", "get", "/nodes/pve/status", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + node_status = json.loads(result.stdout) + vms_result = subprocess.run( + ["pvesh", "get", "/nodes/pve/qemu", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + vms = json.loads(vms_result.stdout) + return {"node": node_status, "vms": vms} + except Exception as e: + return {"error": str(e)} + +# ── Command execution ───────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + + try: + if cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["systemctl", "restart", svc], capture_output=True, text=True, timeout=30) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["journalctl", "-u", svc, "-n", str(lines), "--no-pager"], + capture_output=True, text=True, timeout=15) + return {"success": True, "output": r.stdout} + + elif cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + updated = self_update(cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + # Only allow if explicitly enabled in config + if not cmd_data.get("allowed", False): + return {"success": False, "error": "Shell commands not enabled"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ───────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + + # Register if no API key yet + api_key = state.get("api_key", "") + if not api_key: + api_key = register(cfg, state) + if not api_key: + print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) + time.sleep(60) + main() + return + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + tick = 0 + + print(f"[JARVIS] Agent v{AGENT_VERSION} running. Polling {jarvis_url} every {heartbeat_every}s.", flush=True) + + while True: + tick += 1 + now = time.time() + + try: + # Heartbeat + get commands + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {"version": AGENT_VERSION}, headers, ssl_verify=ssl_verify) + if "error" in hb: + print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True) + else: + commands = hb.get("commands", []) + for cmd in commands: + print(f"[CMD] Executing: {cmd['command_type']}", flush=True) + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check (every update_interval seconds, default 24h) + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) # restarts process if update found + + # Push metrics every poll_interval seconds + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + + # Proxmox metrics if available + if "proxmox" in detect_capabilities(cfg): + px = collect_proxmox_metrics(cfg) + if px: + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "proxmox", "data": px}, headers, ssl_verify=ssl_verify) + + last_metrics = now + + except Exception as e: + print(f"[ERROR] Loop error: {e}", flush=True) + + time.sleep(heartbeat_every) + + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + """Check JARVIS server for a newer version of this script. If different, replace and restart.""" + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + try: + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent/1.0") + with urllib.request.urlopen(req, timeout=30) as resp: + new_content = resp.read() + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + print(f"[JARVIS] Self-update check failed: {e}", flush=True) + return False + + +if __name__ == "__main__": + main() diff --git a/public_html/agent/jarvis-agent.py.sha256 b/public_html/agent/jarvis-agent.py.sha256 new file mode 100644 index 0000000..1038a4d --- /dev/null +++ b/public_html/agent/jarvis-agent.py.sha256 @@ -0,0 +1 @@ +6ba92a1ad4f91a218cbc4ce6834c55e8f56a0e22fca04278d77260958e429d5b diff --git a/public_html/agent/jarvis-ha-poller.py b/public_html/agent/jarvis-ha-poller.py new file mode 100644 index 0000000..372859b --- /dev/null +++ b/public_html/agent/jarvis-ha-poller.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +JARVIS HA Poller — pulls entity states from Home Assistant REST API +and pushes them to JARVIS as a homeassistant-type agent. +Runs on VM211 as a systemd service (jarvis-ha-poller). + +Config: /etc/jarvis-agent/ha-poller.json +""" + +import json +import os +import socket +import sys +import time +import urllib.request +import urllib.error +import ssl +from datetime import datetime, timezone +from pathlib import Path + +CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json" +STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json" +AGENT_VERSION = "1.0" +AGENT_ID = "homeassistant_ha" +HOSTNAME = "homeassistant" + +# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean) +SKIP_DOMAINS = { + 'sensor', 'binary_sensor', 'button', 'update', 'select', 'number', + 'device_tracker', 'event', 'image', 'person', 'zone', 'tts', + 'conversation', 'assist_satellite', 'input_button', 'media_player', + 'scene', 'water_heater', 'alarm_control_panel', 'automation', + 'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification', + 'tag', 'system_health', 'timer', 'counter', + 'camera', 'siren', 'remote', 'todo', 'lawn_mower', +} + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + +def load_config() -> dict: + if not os.path.exists(CONFIG_PATH): + print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True) + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if os.path.exists(STATE_PATH): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +def _ssl_ctx(verify: bool): + if not verify: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + return None + +def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None: + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + log(f"HA API error: {e}") + return None + +def register(cfg: dict, state: dict) -> str: + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", False)) + reg_key = cfg["registration_key"] + + log(f"Registering HA poller with JARVIS at {jarvis_url}...") + result = jarvis_post( + f"{jarvis_url}/api/agent/register", + { + "hostname": HOSTNAME, + "version": AGENT_VERSION, + "agent_type": "homeassistant", + "ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0], + "capabilities": ["ha_entities", "ha_state"], + "agent_id": AGENT_ID, + }, + {"X-Registration-Key": reg_key}, + ssl_verify, + ) + if "error" in result: + log(f"Registration failed: {result['error']}") + return "" + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = AGENT_ID + save_state(state) + log(f"Registered. agent_id={AGENT_ID}") + return api_key + +def push_entities(cfg: dict, api_key: str, entities: list) -> bool: + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", False)) + headers = {"X-Agent-Key": api_key} + + # Send in batches of 200 + batch_size = 200 + total = len(entities) + ok = True + for i in range(0, total, batch_size): + batch = entities[i:i+batch_size] + result = jarvis_post( + f"{jarvis_url}/api/agent/ha_state", + {"entities": batch}, + headers, + ssl_verify, + ) + if "error" in result: + log(f"Push batch {i//batch_size+1} failed: {result['error']}") + ok = False + return ok + +def heartbeat(cfg: dict, api_key: str) -> bool: + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", False)) + result = jarvis_post( + f"{jarvis_url}/api/agent/heartbeat", + {"version": AGENT_VERSION}, + {"X-Agent-Key": api_key}, + ssl_verify, + timeout=10, + ) + return "error" not in result + +def fetch_ha_states(cfg: dict) -> list: + ha_url = cfg["ha_url"].rstrip("/") + token = cfg["ha_token"] + states = ha_get(f"{ha_url}/api/states", token) + if not states or not isinstance(states, list): + return [] + + entities = [] + for s in states: + entity_id = s.get("entity_id", "") + domain = entity_id.split(".")[0] if "." in entity_id else "" + if domain in SKIP_DOMAINS: + continue + attrs = s.get("attributes", {}) + # Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime + lc = s.get("last_changed", "") + try: + dt = datetime.fromisoformat(lc.replace("Z", "+00:00")) + lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + lc = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + entities.append({ + "entity_id": entity_id, + "name": attrs.get("friendly_name") or entity_id, + "state": s.get("state", ""), + "attributes": attrs, + "last_changed": lc, + }) + return entities + +def main(): + cfg = load_config() + state = load_state() + + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + + api_key = state.get("api_key", "") + if not api_key: + api_key = register(cfg, state) + if not api_key: + log("Could not register. Retrying in 60s...") + time.sleep(60) + main() + return + + headers = {"X-Agent-Key": api_key} + last_push = 0 + log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.") + + while True: + now = time.time() + + # Heartbeat + if not heartbeat(cfg, api_key): + log("Heartbeat failed (401?) — re-registering...") + state.clear() + save_state(state) + api_key = register(cfg, state) + if not api_key: + time.sleep(60) + continue + + # Push entity states every poll_interval + if now - last_push >= poll_interval: + entities = fetch_ha_states(cfg) + if entities: + ok = push_entities(cfg, api_key, entities) + if ok: + log(f"Pushed {len(entities)} HA entities to JARVIS.") + last_push = now + else: + log("No HA entities fetched (HA down or token invalid?)") + + time.sleep(heartbeat_every) + +if __name__ == "__main__": + main() diff --git a/public_html/agent/setup-task.ps1 b/public_html/agent/setup-task.ps1 new file mode 100644 index 0000000..5518817 --- /dev/null +++ b/public_html/agent/setup-task.ps1 @@ -0,0 +1,36 @@ +# Kill any stale Task Scheduler approach +Unregister-ScheduledTask -TaskName 'JARVIS-Agent' -Confirm:$false -ErrorAction SilentlyContinue + +# Create a VBScript launcher (runs Python silently, no console window) +$vbs = 'Set WShell = CreateObject("WScript.Shell")' + "`r`n" + + 'WShell.Run """C:\Users\myron\AppData\Local\Programs\Python\Python312\pythonw.exe"" ""C:\ProgramData\jarvis-agent\jarvis-agent.py""", 0, False' + +[System.IO.File]::WriteAllText('C:\ProgramData\jarvis-agent\start-agent.vbs', $vbs, [System.Text.ASCIIEncoding]::new()) + +# Add to user startup folder so it runs at every login +$startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup" +Copy-Item 'C:\ProgramData\jarvis-agent\start-agent.vbs' "$startupDir\JARVIS-Agent.vbs" -Force +Write-Host "Startup entry created: $startupDir\JARVIS-Agent.vbs" -ForegroundColor Green + +# Kill any existing python process running the agent +Get-Process python*, pythonw* -ErrorAction SilentlyContinue | Where-Object {$_.CommandLine -like '*jarvis-agent*'} | Stop-Process -Force -ErrorAction SilentlyContinue + +# Launch now +Write-Host "Starting agent..." -ForegroundColor Cyan +Start-Process 'C:\Users\myron\AppData\Local\Programs\Python\Python312\pythonw.exe' -ArgumentList 'C:\ProgramData\jarvis-agent\jarvis-agent.py' -WorkingDirectory 'C:\ProgramData\jarvis-agent' +Start-Sleep -Seconds 4 + +# Confirm running +$proc = Get-Process pythonw -ErrorAction SilentlyContinue +if ($proc) { + Write-Host "Agent running — PID $($proc.Id)" -ForegroundColor Green +} else { + Write-Host "pythonw not found — check C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Yellow +} + +# Show log tail +Start-Sleep -Seconds 2 +if (Test-Path 'C:\ProgramData\jarvis-agent\jarvis-agent.log') { + Write-Host "`nLog:" -ForegroundColor Cyan + Get-Content 'C:\ProgramData\jarvis-agent\jarvis-agent.log' -Tail 10 +} diff --git a/public_html/api.php b/public_html/api.php new file mode 100644 index 0000000..24ad1b0 --- /dev/null +++ b/public_html/api.php @@ -0,0 +1,128 @@ + true, + $_e0 === 'netscan' => true, + $_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true, + default => false, +}; +if (!$_skipSession) { + session_start(); +} + +header('Content-Type: application/json'); +$_allowedOrigins = ['https://jarvis.orbishosting.com', 'http://jarvis.orbishosting.com']; +$_origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +if (in_array($_origin, $_allowedOrigins, true)) { + header('Access-Control-Allow-Origin: ' . $_origin); + header('Access-Control-Allow-Credentials: true'); +} +header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type, X-Session-Token'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } + +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$method = $_SERVER['REQUEST_METHOD']; +$path = trim(parse_url($uri, PHP_URL_PATH), '/'); +$parts = explode('/', $path); + +if (($parts[0] ?? '') === 'api') array_shift($parts); +$endpoint = $parts[0] ?? ''; +$action = $parts[1] ?? ''; + +// ── Auth check (skip for auth / agent / netscan) ────────────────────── +if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) { + $token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? ''); + $isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? ''); + if (!$isValid) { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + $isLocal = \in_array($ip, ['127.0.0.1', '::1', JARVIS_IP], true); + if (!$isLocal && $endpoint !== 'ping') { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized', 'code' => 401]); + exit; + } + } +} + +if ($endpoint !== 'auth') session_write_close(); + +$body = file_get_contents('php://input'); +$data = json_decode($body, true) ?? []; + +// ── Fast ping (no file dispatch needed) ────────────────────────────── +if ($endpoint === 'ping') { + echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]); + exit; +} + +// ── Endpoint → file map ─────────────────────────────────────────────── +$endpoints = [ + 'auth' => 'auth.php', + 'chat' => 'chat.php', + 'system' => 'system.php', + 'netscan' => 'netscan.php', + 'network' => 'network.php', + 'proxmox' => 'proxmox.php', + 'ha' => 'ha.php', + 'tts' => 'tts.php', + 'email' => 'email.php', + 'do' => 'do_server.php', + 'alerts' => 'alerts.php', + 'facts' => 'facts_collector.php', + 'weather' => 'weather.php', + 'news' => 'news.php', + 'sites' => 'sites.php', + 'agent' => 'agent.php', + 'planner' => 'planner.php', + 'jellyfin' => 'jellyfin.php', + 'history' => 'history.php', + 'metrics' => 'metrics.php', + 'suggestions' => 'suggestions.php', + 'arc' => 'arc.php', + 'directives' => 'directives.php', + 'memory' => 'memory.php', + 'calendar' => 'calendar_sync.php', +]; + +if (!isset($endpoints[$endpoint])) { + http_response_code(404); + echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]); + exit; +} + +$file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint]; + +// ── Fault-isolated dispatch ─────────────────────────────────────────── +// ob_start() buffers any partial output so a mid-execution fatal doesn't +// send a broken response. catch(Throwable) catches ParseError, TypeError, +// and all other Errors + Exceptions in PHP 7+. +ob_start(); +try { + require $file; + ob_end_flush(); +} catch (\Throwable $e) { + ob_end_clean(); + http_response_code(500); + echo json_encode(['error' => 'Endpoint unavailable', 'endpoint' => $endpoint, 'code' => 500]); + error_log(sprintf('JARVIS API [%s] %s: %s in %s:%d', + $endpoint, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine() + )); +} diff --git a/public_html/assets/css/jarvis.css b/public_html/assets/css/jarvis.css new file mode 100644 index 0000000..13c7a47 --- /dev/null +++ b/public_html/assets/css/jarvis.css @@ -0,0 +1,1381 @@ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg:#000810; + --bg2:#000d1a; + --cyan:#00d4ff; + --cyan2:#00a8cc; + --cyan3:rgba(0,212,255,0.15); + --orange:#ff6600; + --orange2:#ff4400; + --green:#00ff88; + --red:#ff2244; + --yellow:#ffd700; + --dim:rgba(0,212,255,0.4); + --dimmer:rgba(0,212,255,0.12); + --grid:rgba(0,180,255,0.07); + --text:#c8e6ff; + --text-dim:rgba(200,230,255,0.5); + --panel-bg:rgba(0,15,35,0.85); + --panel-border:rgba(0,212,255,0.2); + --font-display:'Orbitron',monospace; + --font-body:'Rajdhani',sans-serif; + --font-mono:'Share Tech Mono',monospace; + --r:8px; +} +html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-body)} + +/* ── GRID BACKGROUND — animated drift ─────────────────────────────── */ +@keyframes gridDrift{from{background-position:0 0,0 0}to{background-position:40px 40px,40px 40px}} +body::before{ + content:''; + position:fixed;inset:0; + background-image: + linear-gradient(var(--grid) 1px,transparent 1px), + linear-gradient(90deg,var(--grid) 1px,transparent 1px); + background-size:40px 40px; + z-index:0; + pointer-events:none; + animation:gridDrift 18s linear infinite; +} +body::after{ + content:''; + position:fixed;inset:0; + background:radial-gradient(ellipse at 50% 50%,rgba(0,80,160,0.08) 0%,transparent 70%); + z-index:0; + pointer-events:none; +} + +/* ── SCAN LINES ───────────────────────────────────────────────────── */ +.scanlines{ + position:fixed;inset:0;z-index:1;pointer-events:none; + background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px); + animation:scanMove 8s linear infinite; +} +@keyframes scanMove{0%{background-position:0 0}100%{background-position:0 100%}} + +/* ── SCANLINE SWEEP ───────────────────────────────────────────────── */ +.scanline-sweep{ + position:fixed;top:0;left:0;right:0;height:120px; + background:linear-gradient(180deg,transparent 0%,rgba(0,212,255,0.04) 40%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 60%,transparent 100%); + pointer-events:none;z-index:2; + animation:sweepDown 7s linear infinite; + box-shadow:0 0 12px rgba(0,212,255,0.15); +} +@keyframes sweepDown{ + 0%{transform:translateY(-120px);opacity:0} + 3%{opacity:1} + 97%{opacity:0.7} + 100%{transform:translateY(100vh);opacity:0} +} + +/* ── PARTICLE CANVAS ──────────────────────────────────────────────── */ +#particleCanvas{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.7} + +/* ── PANEL FLOAT + GLOW ───────────────────────────────────────────── */ +@keyframes panelFloat{ + 0%,100%{transform:translateY(var(--pty,0px)) rotateX(var(--prx,0deg)) rotateY(var(--pry,0deg));box-shadow:0 4px 20px rgba(0,0,0,0.4),0 0 0px rgba(0,212,255,0)} + 50%{transform:translateY(calc(var(--pty,0px) - 7px)) rotateX(var(--prx,0deg)) rotateY(var(--pry,0deg));box-shadow:0 16px 40px rgba(0,0,0,0.5),0 0 30px rgba(0,212,255,0.06),0 0 60px rgba(0,212,255,0.02)} +} +/* Panel flash when data updates */ +@keyframes panelFlash{0%{box-shadow:0 0 0 1px rgba(0,212,255,0.8),0 0 20px rgba(0,212,255,0.3)}100%{box-shadow:none}} +.panel.data-flash{animation:panelFlash 0.5s ease-out,panelFloat 7s ease-in-out infinite} + +/* Alert pulse — red ambient glow on body when alerts active */ +@keyframes alertPulse{0%,100%{opacity:0}50%{opacity:1}} +#alertOverlay{position:fixed;inset:0;pointer-events:none;z-index:1;background:radial-gradient(ellipse at 50% 50%,rgba(255,30,60,0.07) 0%,transparent 70%);animation:alertPulse 3s ease-in-out infinite;display:none} + +/* Glitch keyframes */ +@keyframes glitch1{ + 0%,100%{clip-path:inset(0 0 100% 0);transform:translate(0)} + 10%{clip-path:inset(10% 0 60% 0);transform:translate(-3px,1px)} + 20%{clip-path:inset(40% 0 30% 0);transform:translate(3px,-1px)} + 30%{clip-path:inset(70% 0 10% 0);transform:translate(-2px,2px)} + 40%{clip-path:inset(0 0 100% 0);transform:translate(0)} +} +@keyframes glitch2{ + 0%,100%{clip-path:inset(0 0 100% 0);transform:translate(0)} + 10%{clip-path:inset(60% 0 10% 0);transform:translate(3px,-1px)} + 25%{clip-path:inset(20% 0 50% 0);transform:translate(-3px,1px)} + 35%{clip-path:inset(80% 0 5% 0);transform:translate(2px,2px)} + 45%{clip-path:inset(0 0 100% 0);transform:translate(0)} +} +.tb-logo-text{position:relative;display:inline-block} +.tb-logo-text::before,.tb-logo-text::after{ + content:attr(data-text);position:absolute;top:0;left:0; + color:var(--cyan);font-family:var(--font-display);font-size:inherit;font-weight:inherit;letter-spacing:inherit; + pointer-events:none;opacity:0; +} +.tb-logo-text::before{color:rgba(255,0,80,0.8);text-shadow:2px 0 rgba(255,0,80,0.5)} +.tb-logo-text::after{color:rgba(0,255,255,0.8);text-shadow:-2px 0 rgba(0,255,255,0.5)} +.tb-logo-text.glitching::before{animation:glitch1 0.25s steps(1) forwards;opacity:1} +.tb-logo-text.glitching::after{animation:glitch2 0.25s steps(1) forwards;opacity:1} + +/* Metric bar shimmer */ +@keyframes barShimmer{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}} +.metric-bar-fill::after{ + content:'';position:absolute;top:0;left:0;width:30%;height:100%; + background:linear-gradient(90deg,transparent,rgba(255,255,255,0.25),transparent); + animation:barShimmer 2.5s ease-in-out infinite; + border-radius:inherit; +} +.metric-bar-fill{position:relative;overflow:hidden} + +/* Panel hover rise */ +.panel:hover{ + transform:translateY(calc(var(--pty,0px) - 11px)) !important; + border-color:rgba(0,212,255,0.45) !important; + box-shadow:0 20px 50px rgba(0,0,0,0.55),0 0 40px rgba(0,212,255,0.1) !important; + transition:transform 0.3s ease,border-color 0.3s ease,box-shadow 0.3s ease; +} + +/* Sparkline canvas */ +.sparkline-wrap{margin:4px 0 8px;height:32px;position:relative} +.sparkline-wrap canvas{display:block;width:100%;height:32px} + +/* ── HUD CORNER BRACKETS ──────────────────────────────────────────── */ +/* ── MINI ARC REACTOR ─────────────────────────────────────────────── */ +.tb-reactor{width:30px;height:30px;position:relative;flex-shrink:0} +.tbr-ring{position:absolute;border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)} +.tbr-r1{width:30px;height:30px;border:1px solid rgba(0,212,255,0.35);animation:spinRing 9s linear infinite} +.tbr-r2{width:20px;height:20px;border:1px solid var(--orange);box-shadow:0 0 6px var(--orange);animation:spinRing 4s linear infinite reverse} +.tbr-core{position:absolute;width:8px;height:8px;border-radius:50%;background:radial-gradient(circle,#fff 0%,var(--cyan) 50%,var(--cyan2) 100%);box-shadow:0 0 10px var(--cyan),0 0 20px rgba(0,212,255,0.5);top:50%;left:50%;transform:translate(-50%,-50%);animation:corePulse 2s ease-in-out infinite} + +/* ── LOGIN SCREEN ─────────────────────────────────────────────────── */ +#loginScreen{ + position:fixed;inset:0;z-index:1000; + display:flex;align-items:center;justify-content:center;flex-direction:column; + background:var(--bg); +} +.login-reactor{ + width:160px;height:160px;position:relative;margin-bottom:40px;cursor:pointer; +} +.login-reactor .ring{ + position:absolute;border-radius:50%;border:2px solid var(--cyan); + top:50%;left:50%;transform:translate(-50%,-50%); + box-shadow:0 0 8px var(--cyan),inset 0 0 8px rgba(0,212,255,0.1); + animation:spinRing var(--spd,4s) linear infinite; +} +.login-reactor .r1{width:160px;height:160px;--spd:8s;border-color:rgba(0,212,255,0.3)} +.login-reactor .r2{width:130px;height:130px;--spd:6s;animation-direction:reverse} +.login-reactor .r3{width:100px;height:100px;--spd:4s;border-color:var(--orange);box-shadow:0 0 12px var(--orange)} +.login-reactor .r4{width:70px;height:70px;--spd:3s;animation-direction:reverse} +.login-reactor .core{ + position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); + width:40px;height:40px;border-radius:50%; + background:radial-gradient(circle,#ffffff 0%,var(--cyan) 40%,var(--cyan2) 70%,transparent 100%); + box-shadow:0 0 20px var(--cyan),0 0 40px var(--cyan),0 0 80px rgba(0,212,255,0.3); + animation:corePulse 2s ease-in-out infinite; +} +@keyframes spinRing{from{transform:translate(-50%,-50%) rotate(0deg)}to{transform:translate(-50%,-50%) rotate(360deg)}} +@keyframes corePulse{0%,100%{opacity:0.8;transform:translate(-50%,-50%) scale(1)}50%{opacity:1;transform:translate(-50%,-50%) scale(1.1)}} +#loginScreen h1{ + font-family:var(--font-display);font-size:2.5rem;font-weight:900;letter-spacing:8px; + color:var(--cyan);text-shadow:0 0 20px var(--cyan),0 0 40px rgba(0,212,255,0.4); + margin-bottom:8px; +} +#loginScreen p{color:var(--text-dim);font-size:0.85rem;letter-spacing:4px;text-transform:uppercase;margin-bottom:40px} +.login-form{display:flex;flex-direction:column;gap:14px;width:320px} +.login-form input{ + background:rgba(0,212,255,0.05); + border:1px solid var(--panel-border); + border-radius:var(--r); + padding:12px 16px; + color:var(--cyan); + font-family:var(--font-mono);font-size:0.95rem; + outline:none; + transition:border-color 0.2s,box-shadow 0.2s; + letter-spacing:1px; +} +.login-form input:focus{border-color:var(--cyan);box-shadow:0 0 12px rgba(0,212,255,0.2)} +.login-form input::placeholder{color:var(--dim);letter-spacing:1px} +.login-form button{ + background:linear-gradient(135deg,rgba(0,212,255,0.15),rgba(0,212,255,0.05)); + border:1px solid var(--cyan); + border-radius:var(--r); + padding:14px; + color:var(--cyan); + font-family:var(--font-display);font-size:0.8rem;font-weight:700; + letter-spacing:3px;text-transform:uppercase; + cursor:pointer; + transition:all 0.2s; + box-shadow:0 0 12px rgba(0,212,255,0.1); +} +.login-form button:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 20px rgba(0,212,255,0.3)} +#loginError{color:var(--red);font-size:0.85rem;text-align:center;letter-spacing:1px;min-height:20px} + +/* ── MAIN APP (hidden until login) ───────────────────────────────── */ +#app{position:fixed;inset:0;z-index:2;display:none;flex-direction:column} + +/* ── TOP NAV BAR ─────────────────────────────────────────────────── */ +#topBar{ + display:flex;align-items:center;justify-content:space-between; + padding:0 20px;height:48px; + background:rgba(0,8,22,0.9); + border-bottom:1px solid var(--panel-border); + flex-shrink:0; +} +.tb-logo{ + font-family:var(--font-display);font-size:1rem;font-weight:900;letter-spacing:4px; + color:var(--cyan);text-shadow:0 0 10px var(--cyan);display:flex;align-items:center;gap:10px; + transition:filter 0.4s ease; + will-change:transform; +} +.tb-logo.face-tracking{ + filter:drop-shadow(0 0 12px rgba(0,212,255,0.9)) drop-shadow(0 0 24px rgba(0,212,255,0.4)); +} +/* Face scan crosshair overlay */ +#faceScanOverlay{ + position:fixed;pointer-events:none;z-index:9; + width:60px;height:60px; + display:none; +} +#faceScanOverlay::before,#faceScanOverlay::after{ + content:'';position:absolute; + border-color:rgba(0,212,255,0.7);border-style:solid; +} +#faceScanOverlay::before{ + top:0;left:0;width:16px;height:16px; + border-width:2px 0 0 2px; +} +#faceScanOverlay::after{ + bottom:0;right:0;width:16px;height:16px; + border-width:0 2px 2px 0; +} +#faceScanOverlay .fso-tr{position:absolute;top:0;right:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-left:0;border-bottom:0} +#faceScanOverlay .fso-bl{position:absolute;bottom:0;left:0;width:16px;height:16px;border:2px solid rgba(0,212,255,0.7);border-right:0;border-top:0} +#faceScanOverlay .fso-dot{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:4px;border-radius:50%;background:rgba(0,212,255,0.6);box-shadow:0 0 6px var(--cyan)} +#faceScanOverlay .fso-label{position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);font-family:var(--font-mono);font-size:0.45rem;color:rgba(0,212,255,0.7);letter-spacing:1px;white-space:nowrap} +@keyframes fsoSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} +#faceScanOverlay .fso-ring{position:absolute;top:50%;left:50%;width:40px;height:40px;margin:-20px;border-radius:50%;border:1px solid rgba(0,212,255,0.3);border-top-color:rgba(0,212,255,0.7);animation:fsoSpin 1.2s linear infinite} +.tb-logo-dot{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite} +.tb-center{ + display:flex;gap:24px;align-items:center; +} +.tb-stat{font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim)} +.tb-stat span{color:var(--cyan)} +.tb-right{display:flex;align-items:center;gap:16px} +#clock{font-family:var(--font-mono);font-size:1rem;color:var(--cyan);letter-spacing:2px} +#date-display{font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim)} +.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);animation:blink 2s infinite} +@keyframes blink{0%,100%{opacity:1}50%{opacity:0.4}} +.btn-logout{ + background:none;border:1px solid rgba(255,34,68,0.3);border-radius:4px; + color:rgba(255,34,68,0.7);font-family:var(--font-mono);font-size:0.7rem; + padding:4px 10px;cursor:pointer;letter-spacing:1px;transition:all 0.2s; +} +.btn-logout:hover{border-color:var(--red);color:var(--red);box-shadow:0 0 8px rgba(255,34,68,0.2)} + +/* ── MAIN LAYOUT ─────────────────────────────────────────────────── */ +#mainLayout{ + flex:1;display:grid; + grid-template-columns:280px 1fr 280px; + grid-template-rows:1fr; + gap:10px;padding:10px; + overflow:hidden; + perspective:1200px; + transition:grid-template-columns 0.45s cubic-bezier(0.4,0,0.2,1); +} +#mainLayout.focus-mode{grid-template-columns:0px 1fr 0px} +#leftPanel,#rightPanel{ + transition:opacity 0.35s ease,transform 0.45s cubic-bezier(0.4,0,0.2,1); + overflow:hidden; +} +#mainLayout.focus-mode #leftPanel{opacity:0;transform:translateX(-20px);pointer-events:none} +#mainLayout.focus-mode #rightPanel{opacity:0;transform:translateX(20px);pointer-events:none} + +#leftPanel{grid-area:left} +#centerPanel{grid-area:center} +#rightPanel{grid-area:right} +#mainLayout{grid-template-areas:"left center right"} +#mainLayout.swapped{grid-template-areas:"right center left"} +#mainLayout.swapped.focus-mode #leftPanel{transform:translateX(20px)} +#mainLayout.swapped.focus-mode #rightPanel{transform:translateX(-20px)} +#btn-swap-panels{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 8px;cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;letter-spacing:1px;transition:all 0.2s} +#btn-swap-panels:hover,#btn-swap-panels.active{color:var(--cyan);border-color:var(--cyan)} +/* ── MOBILE RESPONSIVE ──────────────────────────────────────────────── */ +@media(max-width:900px){ + #mainLayout{grid-template-columns:1fr!important;grid-template-rows:1fr!important;padding:6px;gap:6px} + #leftPanel,#rightPanel{display:none} + #leftPanel.mob-active,#rightPanel.mob-active{display:flex!important;flex-direction:column} + #centerPanel{display:none} + #centerPanel.mob-active{display:flex!important;flex-direction:column} + #topBar{padding:0 10px;height:42px} + .tb-center{display:none} + .tb-logo{font-size:0.85rem;letter-spacing:2px} + #clock{font-size:0.85rem} + #date-display{display:none} + #btn-swap-panels,.btn-panels{display:none} + #inputArea{padding:8px 6px} + #textInput{font-size:0.85rem;padding:8px 10px;min-height:40px} + #sendBtn{min-height:40px;padding:0 14px;font-size:0.65rem} + #micBtn{min-height:40px;min-width:40px;font-size:1.1rem} + #app{padding-bottom:48px} + #mobileNav{display:flex!important} +} +@media(min-width:901px){#mobileNav{display:none!important}} +#mobileNav{ + display:none;position:fixed;bottom:0;left:0;right:0;z-index:100; + background:rgba(0,8,22,0.96);border-top:1px solid var(--panel-border);height:48px; +} +.mob-nav-btn{ + flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center; + font-family:var(--font-display);font-size:0.5rem;letter-spacing:1.5px; + color:var(--text-dim);cursor:pointer;gap:2px;border:none;background:none;transition:color 0.2s; +} +.mob-nav-btn .mob-icon{font-size:1rem;line-height:1} +.mob-nav-btn.active{color:var(--cyan)} +.mob-nav-btn.active .mob-icon{filter:drop-shadow(0 0 4px var(--cyan))} +/* Panel toggle button */ +.btn-panels{ + background:rgba(0,212,255,0.06); + border:1px solid rgba(0,212,255,0.25); + border-radius:4px;color:var(--cyan); + font-family:var(--font-display);font-size:0.55rem; + letter-spacing:1px;padding:4px 10px;cursor:pointer; + transition:all 0.2s;margin-right:6px; +} +.btn-panels:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)} +.btn-panels.focus-active{background:rgba(0,212,255,0.15);border-color:var(--cyan)} +/* Camera auto-mic button */ +.btn-camera{ + background:rgba(0,212,255,0.06); + border:1px solid rgba(0,212,255,0.25); + border-radius:4px;color:var(--cyan); + font-family:var(--font-display);font-size:0.55rem; + letter-spacing:1px;padding:4px 10px;cursor:pointer; + transition:all 0.2s;margin-right:6px; +} +.btn-camera:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)} +.btn-camera.cam-active{background:rgba(0,255,100,0.1);border-color:var(--green);color:var(--green)} +.btn-camera.cam-sensing{animation:camPulse 1.5s ease-in-out infinite} +.btn-agent{background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);font-family:var(--font-mono);font-size:0.62rem;letter-spacing:2px;padding:6px 10px;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all 0.2s} +.btn-agent:hover{border-color:var(--cyan);box-shadow:0 0 8px rgba(0,212,255,0.2)} +.btn-agent .agent-dot{width:7px;height:7px;border-radius:50%;background:var(--red);flex-shrink:0;transition:all 0.3s} +.btn-agent.agent-online .agent-dot{background:var(--green);box-shadow:0 0 6px var(--green)} +#agentModal{position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;display:none;align-items:center;justify-content:center} +#agentModal.open{display:flex} +.agent-modal-box{background:var(--panel-bg);border:1px solid var(--panel-border);padding:24px 32px;max-width:500px;width:90%;font-family:var(--font-mono)} +.agent-modal-box h3{color:var(--cyan);font-size:0.75rem;letter-spacing:4px;margin:0 0 16px} +.agent-modal-box pre{background:rgba(0,0,0,0.4);padding:12px;font-size:0.65rem;color:var(--green);overflow-x:auto;margin:8px 0;white-space:pre-wrap;word-break:break-all} +.agent-modal-close{float:right;background:transparent;border:1px solid var(--panel-border);color:var(--text-dim);cursor:pointer;font-family:var(--font-mono);font-size:0.6rem;padding:4px 10px;letter-spacing:2px} +.agent-modal-close:hover{border-color:var(--red);color:var(--red)} +.agent-dl-btn{display:inline-block;margin-top:12px;background:rgba(0,212,255,0.1);border:1px solid var(--cyan);color:var(--cyan);font-family:var(--font-mono);font-size:0.65rem;letter-spacing:2px;padding:8px 16px;cursor:pointer;text-decoration:none;transition:all 0.2s} +.agent-dl-btn:hover{background:rgba(0,212,255,0.2)} +@keyframes camPulse{0%,100%{box-shadow:0 0 4px rgba(0,255,100,0.3)}50%{box-shadow:0 0 12px rgba(0,255,100,0.6)}} + +/* ── PANELS ───────────────────────────────────────────────────────── */ +.panel{ + background:var(--panel-bg); + border:1px solid var(--panel-border); + border-radius:var(--r); + padding:14px; + overflow:hidden; + position:relative; + backdrop-filter:blur(4px); + animation:panelFloat 7s ease-in-out infinite; + will-change:transform; +} +.panel::before{ + content:'';position:absolute;top:0;left:0;right:0;height:1px; + background:linear-gradient(90deg,transparent,var(--cyan),transparent); + opacity:0.4; + z-index:2; +} +/* HUD corner brackets */ +.panel::after{ + content:'';position:absolute;inset:0;pointer-events:none;z-index:2; + background: + linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top left / 14px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top left / 1px 14px no-repeat, + linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top right / 14px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) top right / 1px 14px no-repeat, + linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom left / 14px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom left / 1px 14px no-repeat, + linear-gradient(to right,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom right / 14px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.8),rgba(0,212,255,0.8)) bottom right / 1px 14px no-repeat; + opacity:0.55; +} +.panel-title{ + font-family:var(--font-display);font-size:0.6rem;font-weight:700; + letter-spacing:3px;color:var(--cyan);text-transform:uppercase; + margin-bottom:12px;display:flex;align-items:center;justify-content:space-between; +} +.panel-title .indicator{ + width:6px;height:6px;border-radius:50%;background:var(--cyan); + box-shadow:0 0 6px var(--cyan);animation:blink 3s infinite; +} +.panel-title{cursor:pointer;user-select:none} +.panel-collapse-btn{ + background:none;border:none;color:rgba(0,212,255,0.35);cursor:pointer; + font-size:0.65rem;padding:0;margin-left:6px;flex-shrink:0;line-height:1; + transition:transform 0.25s ease,color 0.2s; +} +.panel-collapse-btn:hover{color:var(--cyan)!important} +.panel.collapsed .panel-collapse-btn{transform:rotate(-90deg);color:rgba(0,212,255,0.5)} +.panel.collapsed > *:not(.panel-title){ + display:none!important; +} +.panel.collapsed{ + flex:0 0 auto!important;min-height:0!important;overflow:visible!important; +} + +/* ── LEFT PANEL — SYSTEM ─────────────────────────────────────────── */ +#leftPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto} + +.metric-row{margin-bottom:10px} +.metric-label{ + font-family:var(--font-mono);font-size:0.7rem;color:var(--text-dim); + display:flex;justify-content:space-between;margin-bottom:4px; +} +.metric-label span{color:var(--cyan)} +.metric-bar{ + height:4px;border-radius:2px; + background:rgba(0,212,255,0.1); + overflow:hidden;position:relative; +} +.metric-bar-fill{ + height:100%;border-radius:2px; + background:linear-gradient(90deg,var(--cyan2),var(--cyan)); + box-shadow:0 0 6px var(--cyan); + transition:width 1s ease; +} +.metric-bar-fill.warn{background:linear-gradient(90deg,#cc6600,var(--orange));box-shadow:0 0 6px var(--orange)} +.metric-bar-fill.danger{background:linear-gradient(90deg,#cc0022,var(--red));box-shadow:0 0 6px var(--red)} + +.service-row{ + display:flex;align-items:center;justify-content:space-between; + padding:5px 0;border-bottom:1px solid rgba(0,212,255,0.06); + font-family:var(--font-mono);font-size:0.72rem; +} +.service-row:last-child{border-bottom:none} +.svc-name{color:var(--text-dim)} +.svc-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0} +.svc-dot.on{background:var(--green);box-shadow:0 0 4px var(--green)} +.svc-dot.off{background:var(--red);box-shadow:0 0 4px var(--red)} + +.do-section{margin-top:8px} +.val-row{ + display:flex;justify-content:space-between; + font-family:var(--font-mono);font-size:0.72rem;padding:3px 0; +} +.val-row .lbl{color:var(--text-dim)} +.val-row .val{color:var(--cyan)} +.val-row .val.ok{color:var(--green)} +.val-row .val.warn{color:var(--orange)} +.val-row .val.danger{color:var(--red)} + +/* ── CENTER PANEL ────────────────────────────────────────────────── */ +#centerPanel{ + display:flex;flex-direction:column;align-items:center;gap:10px;overflow:hidden; +} + +/* ARC REACTOR ─────────────────────────────────────────────────────── */ +#arcReactor{position:relative;width:220px;height:220px;flex-shrink:0} +.arc-ring{ + position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); + border-radius:50%;border-style:solid;border-color:var(--cyan); + animation:spinRing var(--spd,6s) linear infinite var(--dir,normal); +} +.arc-ring.r1{width:220px;height:220px;border-width:1px;border-color:rgba(0,212,255,0.15);--spd:20s} +.arc-ring.r2{width:195px;height:195px;border-width:1px;border-color:rgba(0,212,255,0.25);--spd:15s;--dir:reverse} +.arc-ring.r3{ + width:170px;height:170px;border-width:2px;--spd:10s; + border-top-color:transparent;border-right-color:transparent; + box-shadow:0 0 6px var(--cyan); +} +.arc-ring.r4{ + width:145px;height:145px;border-width:1px;--spd:8s;--dir:reverse; + border-bottom-color:transparent;border-left-color:transparent; +} +.arc-ring.r5{ + width:115px;height:115px;border-width:2px;--spd:5s; + border-color:var(--orange);border-right-color:transparent; + box-shadow:0 0 8px var(--orange); +} +.arc-ring.r6{width:88px;height:88px;border-width:1px;--spd:4s;--dir:reverse;border-color:rgba(0,212,255,0.6)} +.arc-ring.r7{ + width:62px;height:62px;border-width:2px;--spd:3s; + border-left-color:transparent;border-bottom-color:transparent; +} +.arc-core{ + position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); + width:36px;height:36px;border-radius:50%; + background:radial-gradient(circle,#fff 0%,var(--cyan) 35%,var(--cyan2) 65%,transparent 100%); + box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 60px rgba(0,212,255,0.4),0 0 100px rgba(0,212,255,0.15); + animation:corePulse 2s ease-in-out infinite; +} + +/* HUD TICKS ───────────────────────────────────────────────────────── */ +.hud-ticks{ + position:absolute;inset:0;border-radius:50%; +} +.hud-ticks::before,.hud-ticks::after{ + content:'';position:absolute; + background:var(--cyan);opacity:0.5; +} +.hud-ticks::before{left:50%;top:0;width:1px;height:12px;transform:translateX(-50%)} +.hud-ticks::after{left:0;top:50%;height:1px;width:12px;transform:translateY(-50%)} + +/* ── CHAT AREA ───────────────────────────────────────────────────── */ +#chatArea{ + flex:1;width:100%;display:flex;flex-direction:column;gap:8px;overflow:hidden; +} +#chatLog{ + flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px; + padding:4px 0; + scrollbar-width:thin;scrollbar-color:var(--dim) transparent; +} +.msg{ + padding:10px 14px;border-radius:var(--r);max-width:100%; + font-family:var(--font-body);font-size:0.9rem;line-height:1.5; + animation:msgIn 0.3s ease; +} +@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} +.msg.user{ + background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.15); + border-left:3px solid var(--cyan); + font-family:var(--font-mono);font-size:0.8rem;color:var(--text); +} +.msg.user::before{content:'';display:none} +.msg.jarvis{ + background:rgba(0,40,80,0.4);border:1px solid rgba(0,212,255,0.1); + border-left:3px solid var(--orange); +} +.msg.jarvis::before{content:'◆ JARVIS ';color:var(--orange);font-weight:700;font-size:0.7rem;letter-spacing:2px} +.msg.system{ + border:1px solid rgba(255,215,0,0.2);background:rgba(255,215,0,0.03); + font-family:var(--font-mono);font-size:0.75rem;color:var(--text-dim);text-align:center; + border-radius:4px; +} + +/* ── CONTEXT CHIP ───────────────────────────────────────────────── */ +#contextChip{ + display:none;flex-shrink:0; + padding:5px 10px;margin-bottom:6px; + background:rgba(0,212,255,0.07); + border:1px solid rgba(0,212,255,0.35); + border-radius:var(--r); + display:flex;align-items:center;gap:8px; + font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px; +} +#contextChip.visible{display:flex} +#contextLabel{color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#contextType{color:var(--text-dim)} +#contextClear{ + background:none;border:none;color:var(--dim);cursor:pointer; + font-size:1rem;line-height:1;padding:0 2px;flex-shrink:0; +} +#contextClear:hover{color:var(--red)} + +/* Planner mini panel */ +/* Clickable panel items */ +.vm-card{cursor:pointer;transition:background 0.15s,border-color 0.15s} +.vm-card:hover{background:rgba(0,212,255,0.07);border-color:rgba(0,212,255,0.4)} +.vm-card.ctx-active{border-color:var(--cyan);background:rgba(0,212,255,0.1)} +.device-item{cursor:pointer;transition:background 0.15s} +.device-item:hover{background:rgba(0,212,255,0.05)} +.device-item.ctx-active{background:rgba(0,212,255,0.1);border-color:rgba(0,212,255,0.4)} +.alert-item{cursor:pointer} +.alert-item.ctx-active{border-color:var(--cyan) !important;box-shadow:0 0 8px rgba(0,212,255,0.15)} +.tier-badge{ + display:inline-block;font-family:var(--font-display);font-size:0.42rem; + letter-spacing:1.5px;padding:1px 5px;border-radius:2px;margin-top:4px; + opacity:0.7;border:1px solid;vertical-align:middle; +} +.tier-badge.kb {color:#00d4ff;border-color:rgba(0,212,255,0.4);background:rgba(0,212,255,0.06)} +.tier-badge.groq {color:#f5a623;border-color:rgba(245,166,35,0.4);background:rgba(245,166,35,0.06)} +.tier-badge.claude{color:#b57cf5;border-color:rgba(181,124,245,0.4);background:rgba(181,124,245,0.06)} +.tier-badge.ollama{color:#7ef55a;border-color:rgba(126,245,90,0.4);background:rgba(126,245,90,0.06)} +.news-item{cursor:pointer;transition:background 0.15s} +.news-item:hover{background:rgba(0,212,255,0.04)} +.news-item.ctx-active{background:rgba(0,212,255,0.08);border-color:rgba(0,212,255,0.4)} +.ha-ask-btn{ + background:none;border:1px solid rgba(0,212,255,0.2);border-radius:3px; + color:var(--text-dim);font-size:0.55rem;padding:1px 5px;cursor:pointer; + font-family:var(--font-display);letter-spacing:1px;flex-shrink:0; + transition:all 0.15s; +} +.ha-ask-btn:hover{border-color:var(--cyan);color:var(--cyan);background:rgba(0,212,255,0.1)} + +/* ── VOICE INPUT ─────────────────────────────────────────────────── */ +#inputArea{ + display:flex;gap:10px;align-items:center;flex-shrink:0; +} +#textInput{ + flex:1;background:rgba(0,212,255,0.04); + border:1px solid var(--panel-border);border-radius:var(--r); + padding:10px 14px;color:var(--text); + font-family:var(--font-mono);font-size:0.85rem;outline:none; + transition:border-color 0.2s; +} +#textInput:focus{border-color:var(--cyan);box-shadow:0 0 10px rgba(0,212,255,0.1)} +#textInput::placeholder{color:var(--dim)} +#sendBtn{ + background:rgba(0,212,255,0.1);border:1px solid var(--dim); + border-radius:var(--r);padding:10px 16px; + color:var(--cyan);font-family:var(--font-display);font-size:0.65rem;font-weight:700; + letter-spacing:2px;cursor:pointer;transition:all 0.2s;white-space:nowrap; +} +#sendBtn:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 12px rgba(0,212,255,0.2)} + +/* MIC BUTTON ──────────────────────────────────────────────────────── */ +#micBtn{ + width:48px;height:48px;border-radius:50%; + background:radial-gradient(circle,rgba(255,102,0,0.15),rgba(0,8,22,0.9)); + border:2px solid var(--orange); + display:flex;align-items:center;justify-content:center; + cursor:pointer;transition:all 0.2s;flex-shrink:0; + box-shadow:0 0 10px rgba(255,102,0,0.2); +} +#micBtn:hover{box-shadow:0 0 20px rgba(255,102,0,0.4);transform:scale(1.05)} +#micBtn.listening{ + border-color:var(--red);background:radial-gradient(circle,rgba(255,34,68,0.2),rgba(0,8,22,0.9)); + box-shadow:0 0 25px rgba(255,34,68,0.5); + animation:micPulse 0.8s ease-in-out infinite; +} +@keyframes micPulse{0%,100%{box-shadow:0 0 25px rgba(255,34,68,0.5)}50%{box-shadow:0 0 40px rgba(255,34,68,0.8),0 0 60px rgba(255,34,68,0.3)}} +#micBtn.muted{border-color:var(--text-dim);background:radial-gradient(circle,rgba(200,230,255,0.05),rgba(0,8,22,0.9));box-shadow:0 0 8px rgba(200,230,255,0.1);} +#micIcon{font-size:20px} + +/* WAVEFORM ─────────────────────────────────────────────────────────── */ +#waveform{ + display:none;align-items:center;justify-content:center;gap:3px;height:30px; +} +#waveform.active{display:flex} +.wave-bar{ + width:3px;border-radius:2px;background:var(--red); + animation:waveBounce var(--d,0.6s) ease-in-out infinite alternate; + box-shadow:0 0 4px var(--red); +} +@keyframes waveBounce{from{height:4px}to{height:24px}} +.wave-bar.live{animation:none!important;transition:height 0.06s} + +/* ── AMBIENT DIM MODE ─────────────────────────────────────────────────── */ +.ambient-dim-active .panel,.ambient-dim-active #bottomBar{ + opacity:0.12;transition:opacity 2s ease;pointer-events:none} +.ambient-dim-active .panel:hover,.ambient-dim-active #bottomBar:hover{ + opacity:1;pointer-events:auto;transition:opacity 0.3s ease} + +/* ── THEME BUTTONS ────────────────────────────────────────────────────── */ +.theme-btn{ + background:none;border:1px solid rgba(0,212,255,0.25);border-radius:50%; + width:14px;height:14px;cursor:pointer;padding:0;font-size:0.6rem;line-height:1; + display:flex;align-items:center;justify-content:center;color:var(--cyan); + transition:all 0.2s;flex-shrink:0} +.theme-btn.active{border-color:currentColor;box-shadow:0 0 6px currentColor} +.theme-btn:hover{opacity:0.8;transform:scale(1.2)} + +/* ── CANCEL BUTTON (in thinking bubble) ──────────────────────────────── */ +.thinking-cancel{ + background:none;border:1px solid rgba(255,34,68,0.4);color:rgba(255,34,68,0.8); + font-family:var(--font-mono);font-size:0.55rem;letter-spacing:1px; + padding:2px 8px;border-radius:2px;cursor:pointer;margin-top:6px;display:block} +.thinking-cancel:hover{background:rgba(255,34,68,0.1)} + +/* ── QUICK NOTE BAR ──────────────────────────────────────────────────── */ +#quickNoteBar{ + position:fixed;bottom:90px;left:50%;transform:translateX(-50%); + width:500px;max-width:90vw;background:rgba(0,8,16,0.95); + border:1px solid var(--cyan);border-radius:3px;padding:8px 14px; + display:none;z-index:1100;align-items:center;gap:8px} +#quickNoteBar.open{display:flex} +#quickNoteInput{ + flex:1;background:none;border:none;color:var(--cyan); + font-family:var(--font-mono);font-size:0.75rem;outline:none;letter-spacing:0.5px} +#quickNoteInput::placeholder{color:rgba(0,212,255,0.4)} + +/* ── STREAMING MESSAGE ───────────────────────────────────────────────── */ +.msg.jarvis.streaming::after{ + content:'▋';animation:blink 0.7s step-end infinite;color:var(--cyan);margin-left:2px} +@keyframes blink{0%,100%{opacity:1}50%{opacity:0}} + +/* ── RIGHT PANEL ─────────────────────────────────────────────────── */ +#rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto} + +/* NETWORK DEVICE LIST ─────────────────────────────────────────────── */ +.device-item{ + display:flex;align-items:center;gap:8px;padding:6px 0; + border-bottom:1px solid rgba(0,212,255,0.06); + font-family:var(--font-mono);font-size:0.72rem; +} +.device-item:last-child{border-bottom:none} +.device-status{width:6px;height:6px;border-radius:50%;flex-shrink:0} +.device-status.on{background:var(--green);box-shadow:0 0 4px var(--green)} +.device-status.off{background:var(--red);box-shadow:0 0 4px var(--red)} +.device-status.unk{background:var(--yellow);box-shadow:0 0 4px var(--yellow);opacity:0.6} +.device-info{flex:1;min-width:0} +.device-name{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.device-ip{color:var(--text-dim);font-size:0.65rem} + +/* VM CARDS ─────────────────────────────────────────────────────────── */ +.vm-card{ + background:rgba(0,212,255,0.04); + border:1px solid rgba(0,212,255,0.12);border-radius:6px; + padding:8px 10px;margin-bottom:6px; + font-family:var(--font-mono);font-size:0.72rem; +} +.vm-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px} +.vm-name{color:var(--cyan);font-weight:600} +.vm-id{color:var(--text-dim);font-size:0.65rem} +.vm-metrics{display:grid;grid-template-columns:1fr 1fr;gap:4px} +.vm-metric{color:var(--text-dim)} +.vm-metric span{color:var(--text)} + +/* HA DEVICES ────────────────────────────────────────────────────────── */ +.ha-table{width:100%;border-collapse:collapse} +.ha-thead th{ + font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px; + color:var(--text-dim);padding:4px 2px 6px;border-bottom:1px solid rgba(0,212,255,0.15); + text-align:left; +} +.ha-thead th:nth-child(3){text-align:center} +.ha-thead th:nth-child(4){text-align:center} +.ha-row{transition:background 0.12s} +.ha-row:hover{background:rgba(0,212,255,0.05)} +.ha-row td{ + padding:4px 2px;border-bottom:1px solid rgba(0,212,255,0.05); + font-family:var(--font-mono);font-size:0.70rem;vertical-align:middle; +} +.ha-col-domain{font-size:0.85rem;text-align:center;width:20px;padding-right:4px!important} +.ha-col-name{color:var(--text);max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ha-col-state{text-align:center;width:30px;font-size:0.62rem;font-weight:700;white-space:nowrap} +.ha-col-state.on{color:var(--green)} +.ha-col-state.off{color:var(--text-dim)} +.ha-col-ctrl{text-align:center;width:36px} +/* toggle switch */ +.ha-toggle{ + position:relative;display:inline-block;width:30px;height:15px;cursor:pointer; +} +.ha-toggle input{opacity:0;width:0;height:0;position:absolute} +.ha-slider{ + position:absolute;inset:0;border-radius:8px; + background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.14); + transition:background 0.18s,border-color 0.18s; +} +.ha-slider::before{ + content:'';position:absolute;left:2px;top:2px; + width:9px;height:9px;border-radius:50%; + background:var(--text-dim);transition:transform 0.18s,background 0.18s; +} +.ha-toggle input:checked + .ha-slider{background:rgba(0,255,100,0.22);border-color:var(--green)} +.ha-toggle input:checked + .ha-slider::before{transform:translateX(15px);background:var(--green)} +/* scene activate button */ +.ha-scene-btn{ + background:transparent;border:1px solid var(--cyan);border-radius:3px; + color:var(--cyan);font-size:0.58rem;padding:1px 4px;cursor:pointer; + font-family:var(--font-mono);transition:background 0.15s; +} +.ha-scene-btn:hover{background:rgba(0,212,255,0.15)} + +/* ALERTS BADGE ─────────────────────────────────────────────────────── */ +.alert-item{ + padding:7px 10px;border-radius:6px; + font-family:var(--font-mono);font-size:0.72rem; + margin-bottom:6px;border-left:3px solid var(--yellow); + background:rgba(255,215,0,0.05); + display:flex;justify-content:space-between;align-items:center; +} +.alert-item.critical{border-color:var(--red);background:rgba(255,34,68,0.05)} +.alert-item.info{border-color:var(--cyan);background:rgba(0,212,255,0.04)} + +/* ── BOTTOM STATUS BAR ──────────────────────────────────────────── */ +#bottomBar{ + height:32px;flex-shrink:0; + background:rgba(0,8,22,0.9); + border-top:1px solid var(--panel-border); + display:flex;align-items:center;padding:0 20px;gap:24px; + font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim); +} +#bottomBar span{color:var(--cyan)} +.bb-item{display:flex;align-items:center;gap:6px} +.bb-dot{width:5px;height:5px;border-radius:50%} +.bb-dot.online{background:var(--green);box-shadow:0 0 4px var(--green)} +.bb-dot.offline{background:var(--red)} + +/* ── THINKING INDICATOR ──────────────────────────────────────────── */ +.thinking{display:flex;gap:4px;align-items:center;padding:8px 14px} +.thinking-dot{ + width:6px;height:6px;border-radius:50%;background:var(--orange); + animation:thinkBounce 0.6s ease-in-out infinite; +} +.thinking-dot:nth-child(2){animation-delay:0.15s} +.thinking-dot:nth-child(3){animation-delay:0.3s} +@keyframes thinkBounce{0%,100%{transform:translateY(0);opacity:0.5}50%{transform:translateY(-6px);opacity:1}} + +/* ── ARC REACTOR HEALTH STATES ──────────────────────────────────── */ +#arcReactor.health-warning .arc-ring.r3{border-color:rgba(245,166,35,0.8);box-shadow:0 0 8px #f5a623} +#arcReactor.health-warning .arc-ring.r5{border-color:rgba(245,166,35,0.6)} +#arcReactor.health-warning .arc-ring.r7{border-color:rgba(245,166,35,0.5)} +#arcReactor.health-warning .arc-core{ + background:radial-gradient(circle,#fff 0%,#f5a623 35%,#c97b00 65%,transparent 100%); + box-shadow:0 0 15px #f5a623,0 0 30px #f5a623,0 0 60px rgba(245,166,35,0.4); + animation:corePulse 1.2s ease-in-out infinite; +} +#arcReactor.health-critical .arc-ring.r3{border-color:rgba(255,34,68,0.9);box-shadow:0 0 10px var(--red)} +#arcReactor.health-critical .arc-ring.r5{border-color:rgba(255,34,68,0.7)} +#arcReactor.health-critical .arc-ring.r7{border-color:rgba(255,34,68,0.6)} +#arcReactor.health-critical .arc-core{ + background:radial-gradient(circle,#fff 0%,var(--red) 35%,#8b0000 65%,transparent 100%); + box-shadow:0 0 15px var(--red),0 0 30px var(--red),0 0 60px rgba(255,34,68,0.5); + animation:corePulse 0.6s ease-in-out infinite; +} +/* ── SPEAKING ANIMATION ──────────────────────────────────────────── */ +@keyframes speakPulse{ + 0%,100%{opacity:0.85;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 15px var(--cyan),0 0 30px var(--cyan),0 0 50px rgba(0,212,255,0.3)} + 50%{opacity:1;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 25px var(--cyan),0 0 55px var(--cyan),0 0 100px rgba(0,212,255,0.6),0 0 150px rgba(0,212,255,0.25)} +} +#arcReactor.speaking .arc-core{ + animation:speakPulse 0.45s ease-in-out infinite; +} +#arcReactor.speaking .arc-ring.r3{border-color:rgba(0,212,255,0.7)} +#arcReactor.speaking .arc-ring.r5{border-color:rgba(255,165,0,0.6)} + +/* ── SCROLLBAR ───────────────────────────────────────────────────── */ +::-webkit-scrollbar{width:4px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--dim);border-radius:2px} + +/* ── TABS (for right panel) ────────────────────────────────────── */ +.tab-bar{display:flex;gap:0;margin-bottom:10px;border-bottom:1px solid var(--panel-border);flex-wrap:wrap} +.forecast-card{background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:4px;padding:5px 3px;text-align:center;min-width:0} +.forecast-card .fc-day{font-family:var(--font-display);font-size:0.55rem;color:var(--text-dim);letter-spacing:1px;font-weight:700} +.forecast-card .fc-temps{font-size:0.6rem;color:var(--cyan);font-family:var(--font-display);margin-top:2px} +.forecast-card .fc-rain{font-size:0.5rem;color:#4fc3f7;min-height:0.7rem} +.news-item{padding:6px 0;border-bottom:1px solid rgba(0,212,255,0.08)} +.news-item:last-child{border-bottom:none} +.news-source{font-size:0.5rem;color:var(--cyan);font-family:var(--font-display);letter-spacing:1px} +.news-title{font-size:0.62rem;color:var(--text-primary);line-height:1.3;margin-top:2px} +.news-time{font-size:0.5rem;color:var(--text-dim);margin-top:2px} +.news-cat-header{font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;color:var(--cyan);margin:8px 0 4px;border-bottom:1px solid rgba(0,212,255,0.2);padding-bottom:3px} +.tab{ + font-family:var(--font-display);font-size:0.55rem;font-weight:700;letter-spacing:2px; + padding:6px 10px;cursor:pointer;color:var(--text-dim); + border-bottom:2px solid transparent;margin-bottom:-1px;transition:all 0.2s; +} +.tab.active{color:var(--cyan);border-bottom-color:var(--cyan)} +.tab-pane{display:none} +.tab-pane.active{display:block} + +/* ── UTILITY ─────────────────────────────────────────────────────── */ +.loading-shimmer{ + background:linear-gradient(90deg,rgba(0,212,255,0.04) 0%,rgba(0,212,255,0.12) 50%,rgba(0,212,255,0.04) 100%); + background-size:200% 100%; + animation:shimmer 1.5s infinite;border-radius:4px;height:12px; +} +@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}} +.text-cyan{color:var(--cyan)} +.text-green{color:var(--green)} +.text-orange{color:var(--orange)} +.text-red{color:var(--red)} +.text-dim{color:var(--text-dim)} + +/* ① HUD CORNER RINGS */ +#hudCornersCanvas{position:fixed;inset:0;z-index:3;pointer-events:none} + +/* ② DATA STREAM COLUMNS */ +#dataStreamCanvas{position:fixed;inset:0;z-index:0;pointer-events:none} + +/* ③ NETWORK TOPOLOGY */ +#topoCanvas{display:block;width:100%;flex-shrink:0;cursor:default;border-bottom:1px solid var(--panel-border);margin-bottom:6px} + +/* ④ BOOT SEQUENCE */ +@keyframes bootLeft{0%{opacity:0;transform:translateX(-70px)}100%{opacity:1;transform:none}} +@keyframes bootRight{0%{opacity:0;transform:translateX(70px)}100%{opacity:1;transform:none}} +@keyframes bootDown{0%{opacity:0;transform:translateY(-18px)}100%{opacity:1;transform:none}} +@keyframes bootCenter{0%{opacity:0;transform:scale(0.94) translateY(14px)}100%{opacity:1;transform:none}} +.boot-left{animation:bootLeft 0.55s cubic-bezier(0.4,0,0.2,1) both} +.boot-right{animation:bootRight 0.55s cubic-bezier(0.4,0,0.2,1) both} +.boot-top{animation:bootDown 0.4s ease both} +.boot-center{animation:bootCenter 0.65s cubic-bezier(0.4,0,0.2,1) both} + +/* ⑤ BREATHING EDGE VIGNETTE */ +#vignetteOverlay{position:fixed;inset:0;pointer-events:none;z-index:1; + background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(0,2,18,0.6) 100%); + animation:vignettePulse 5s ease-in-out infinite} +#vignetteOverlay.alert-vignette{background:radial-gradient(ellipse at 50% 50%,transparent 32%,rgba(20,0,8,0.65) 100%)} +@keyframes vignettePulse{0%,100%{opacity:0.75}50%{opacity:1}} + +/* ⑥ EKG HEARTBEAT */ +#ekgWrap{flex:1;max-width:180px;display:flex;align-items:center;overflow:hidden} +#ekgCanvas{display:block;width:100%;height:22px;opacity:0.8} + +/* ⑦ AUDIO RING */ +.tb-reactor{position:relative} +#audioRingCanvas{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); + width:60px;height:60px;pointer-events:none;z-index:4} + +/* ⑧ TYPEWRITER CURSOR */ +@keyframes cursorBlink{0%,100%{opacity:1}49%{opacity:1}50%,99%{opacity:0}} +.type-cursor{display:inline-block;width:6px;height:0.82em;background:var(--cyan);margin-left:1px; + vertical-align:text-bottom;animation:cursorBlink 0.7s step-end infinite} + +/* ⑨ STATIC NOISE BURST */ +@keyframes staticBurst{0%{opacity:0}10%{opacity:1}90%{opacity:1}100%{opacity:0}} +.panel-noise-layer{position:absolute;inset:0;pointer-events:none;z-index:20;border-radius:var(--r); + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.1'/%3E%3C/svg%3E"); + background-size:100% 100%;mix-blend-mode:screen;animation:staticBurst 0.28s ease forwards} + +/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */ +#netMapOverlay{position:fixed;top:0;left:0;width:100vw;height:100vh; + z-index:200;display:none;flex-direction:column; + background:rgba(0,4,18,0.96);border:1px solid rgba(0,212,255,0.28); + border-top:none;border-left:none;transform-origin:0 0;backdrop-filter:blur(14px); + overflow:hidden;box-shadow:6px 6px 40px rgba(0,0,0,0.75),0 0 50px rgba(0,212,255,0.05)} +#netMapOverlay::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:1; + background: + linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 18px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 1px 18px no-repeat, + linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 18px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 1px 18px no-repeat, + linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 18px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 1px 18px no-repeat, + linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 18px 1px no-repeat, + linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 1px 18px no-repeat} +#netMapOverlay.nm-open{display:flex;animation:nmExplode 0.45s cubic-bezier(0.4,0,0.2,1) forwards} +#netMapOverlay.nm-closing{animation:nmCollapse 0.3s cubic-bezier(0.4,0,0.2,1) forwards} +@keyframes nmExplode{0%{transform:scale(0.04,0.06);opacity:0}60%{opacity:1}100%{transform:scale(1);opacity:1}} +@keyframes nmCollapse{0%{transform:scale(1);opacity:1}100%{transform:scale(0.04,0.06);opacity:0}} +#nmHeader{display:flex;align-items:center;justify-content:space-between;padding:7px 16px; + flex-shrink:0;border-bottom:1px solid rgba(0,212,255,0.16);background:rgba(0,8,28,0.6);z-index:2;position:relative} +#nmTitle{font-family:var(--font-display);font-size:0.62rem;letter-spacing:4px;color:var(--cyan);display:flex;align-items:center;gap:10px} +#nmTitle .nm-pulse{width:6px;height:6px;border-radius:50%;background:var(--cyan);box-shadow:0 0 7px var(--cyan);animation:corePulse 1.5s infinite} +#nmStats{font-family:var(--font-mono);font-size:0.58rem;color:var(--text-dim);display:flex;gap:16px} +#nmStats span{color:var(--cyan)} +#nmClose{background:none;border:1px solid rgba(0,212,255,0.3);color:var(--text-dim);font-family:var(--font-mono);font-size:0.56rem;padding:3px 10px;cursor:pointer;letter-spacing:2px;transition:all 0.2s} +#nmClose:hover{border-color:var(--red);color:var(--red)} +#nmCanvas{flex:1;display:block;z-index:2;position:relative} +#nmLegend{display:flex;gap:16px;align-items:center;padding:5px 16px;flex-shrink:0; + border-top:1px solid rgba(0,212,255,0.1);font-family:var(--font-mono);font-size:0.54rem; + color:var(--text-dim);background:rgba(0,8,28,0.6);z-index:2;position:relative} +.nm-leg-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:4px;flex-shrink:0} +#nmNodeInfo{position:absolute;pointer-events:none;z-index:10;background:rgba(0,8,30,0.95); + border:1px solid rgba(0,212,255,0.4);padding:7px 11px;font-family:var(--font-mono); + font-size:0.6rem;display:none;min-width:150px;box-shadow:0 0 18px rgba(0,212,255,0.12)} +#nmNodeInfo .ni-title{color:var(--cyan);font-size:0.62rem;letter-spacing:2px;margin-bottom:3px} +#nmNodeInfo .ni-row{color:var(--text-dim);margin:2px 0} + +/* ── SLEEP MODE ──────────────────────────────────────────────────────── */ +#sleepOverlay{ + position:fixed;inset:0;z-index:500;display:none; + flex-direction:column;align-items:center;justify-content:center; + background:rgba(0,2,10,0.94); + backdrop-filter:blur(6px); +} +#sleepOverlay.active{display:flex} +@keyframes sleepPulse{0%,100%{opacity:0.25;transform:scale(1)}50%{opacity:0.6;transform:scale(1.06)}} +@keyframes sleepCoreGlow{0%,100%{box-shadow:0 0 30px rgba(0,212,255,0.15),0 0 60px rgba(0,212,255,0.05)}50%{box-shadow:0 0 50px rgba(0,212,255,0.3),0 0 100px rgba(0,212,255,0.1)}} +.sleep-reactor{position:relative;width:120px;height:120px;margin-bottom:40px} +.sleep-ring{position:absolute;border-radius:50%;border:1px solid rgba(0,212,255,0.2);top:50%;left:50%;transform:translate(-50%,-50%)} +.sleep-ring.sr1{width:120px;height:120px;animation:spinRing 18s linear infinite;border-color:rgba(0,212,255,0.12)} +.sleep-ring.sr2{width:85px;height:85px;animation:spinRing 12s linear infinite reverse;border-color:rgba(0,212,255,0.18)} +.sleep-ring.sr3{width:52px;height:52px;animation:spinRing 8s linear infinite;border-color:rgba(0,80,160,0.3)} +.sleep-core{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); + width:24px;height:24px;border-radius:50%; + background:radial-gradient(circle,rgba(0,150,220,0.6) 0%,rgba(0,80,160,0.3) 60%,transparent 100%); + animation:sleepCoreGlow 4s ease-in-out infinite,sleepPulse 4s ease-in-out infinite} +.sleep-label{font-family:var(--font-display);font-size:0.6rem;letter-spacing:6px; + color:rgba(0,212,255,0.35);animation:sleepPulse 4s ease-in-out infinite;margin-bottom:12px} +.sleep-sub{font-family:var(--font-mono);font-size:0.55rem;letter-spacing:3px; + color:rgba(0,212,255,0.2);animation:sleepPulse 4s ease-in-out infinite;animation-delay:0.5s} +/* App dims on sleep */ +#app.sleeping #mainLayout,#app.sleeping #topBar,#app.sleeping #bottomBar{ + pointer-events:none; + filter:brightness(0.08) saturate(0.3); + transition:filter 1.2s ease; +} +#app.sleeping #sleepOverlay{display:flex} +/* ── GUARDIAN MODE ────────────────────────────────────────────────── */ +.guardian-event{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-bottom:1px solid var(--panel-border);cursor:pointer} +.guardian-event:hover{background:rgba(0,212,255,0.04)} +.guardian-event.critical{border-left:3px solid var(--red)} +.guardian-event.warning{border-left:3px solid #f5a623} +.guardian-event.info{border-left:3px solid rgba(0,212,255,0.3)} +.guardian-event.acked{opacity:0.45} +.guardian-sev{font-family:var(--font-mono);font-size:0.5rem;padding:2px 4px;border-radius:2px;flex-shrink:0;letter-spacing:1px;margin-top:1px} +.guardian-sev.critical{background:rgba(255,34,68,0.15);color:var(--red);border:1px solid rgba(255,34,68,0.3)} +.guardian-sev.warning{background:rgba(245,166,35,0.12);color:#f5a623;border:1px solid rgba(245,166,35,0.3)} +.guardian-sev.info{background:rgba(0,212,255,0.08);color:var(--cyan);border:1px solid rgba(0,212,255,0.2)} +.guardian-msg{flex:1;font-size:0.62rem;line-height:1.4;color:var(--text)} +.guardian-time{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0} +.guardian-ai{font-size:0.6rem;color:rgba(0,212,255,0.6);margin-top:3px;font-style:italic} +#bb-guardian-dot.all-clear{background:var(--green);box-shadow:0 0 5px var(--green)} +#bb-guardian-dot.warning{background:#f5a623;box-shadow:0 0 5px #f5a623} +#bb-guardian-dot.critical{background:var(--red);box-shadow:0 0 5px var(--red);animation:pulse 1.2s ease-in-out infinite} +.guardian-ack-btn{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:1px 5px;border-radius:2px;font-size:0.5rem;cursor:pointer;font-family:var(--font-mono);letter-spacing:1px;flex-shrink:0} +.guardian-ack-btn:hover{color:var(--cyan);border-color:var(--cyan)} +/* ── VISION PROTOCOL — screenshot lightbox ───────────────────────── */ +#vision-lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:9999;flex-direction:column;align-items:center;justify-content:flex-start;padding:20px;overflow-y:auto} +#vision-lightbox.open{display:flex} +#vision-lb-header{width:100%;max-width:960px;display:flex;align-items:center;gap:10px;margin-bottom:12px} +#vision-lb-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);flex:1} +#vision-lb-close{background:none;border:1px solid var(--panel-border);color:var(--text-dim);padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--font-display);font-size:0.6rem} +#vision-lb-img{max-width:960px;width:100%;border:1px solid var(--panel-border);border-radius:4px;margin-bottom:12px} +#vision-lb-analysis{max-width:960px;width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:14px 16px;font-size:0.65rem;line-height:1.7;color:var(--text);white-space:pre-wrap} +#vision-lb-spinner{color:var(--cyan);font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;animation:pulse 1.5s ease-in-out infinite;margin:30px auto} +/* ── COMMS PROTOCOL — email triage cards ─────────────────────────── */ +.comms-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden} +.comms-card-head{display:flex;align-items:center;gap:7px;padding:7px 10px;cursor:pointer;user-select:none} +.comms-card-head:hover{background:rgba(0,212,255,0.06)} +.comms-card-subject{font-family:var(--font-display);font-size:0.58rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.comms-card-cat{font-family:var(--font-mono);font-size:0.52rem;padding:2px 5px;border-radius:2px;flex-shrink:0;text-transform:uppercase;letter-spacing:1px} +.comms-card-cat.urgent{color:#ff2244;border:1px solid rgba(255,34,68,0.4);animation:pulse 1.5s ease-in-out infinite} +.comms-card-cat.action{color:#ffd700;border:1px solid rgba(255,215,0,0.4)} +.comms-card-cat.reply{color:var(--cyan);border:1px solid rgba(0,212,255,0.3)} +.comms-card-cat.meeting{color:#a78bfa;border:1px solid rgba(167,139,250,0.4)} +.comms-card-cat.info{color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)} +.comms-card-cat.promo,.comms-card-cat.spam{color:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.08)} +.comms-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)} +.comms-card.open .comms-card-body{display:block} +.comms-card-from{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin:7px 0 3px} +.comms-card-summary{font-size:0.62rem;line-height:1.5;color:var(--text);margin:5px 0} +.comms-draft-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);margin:8px 0 4px} +.comms-draft{font-size:0.6rem;line-height:1.5;color:rgba(0,212,255,0.7);background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.15);border-radius:3px;padding:7px 9px;white-space:pre-wrap;max-height:160px;overflow-y:auto} +.comms-empty{text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px} +.comms-header-bar{display:flex;gap:5px;margin-bottom:7px;flex-wrap:wrap} +.comms-filter-btn{flex:1;min-width:50px;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:4px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer;text-align:center} +.comms-filter-btn.active,.comms-filter-btn:hover{background:rgba(0,212,255,0.12);color:var(--cyan);border-color:var(--cyan)} +.comms-triage-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px} +.comms-triage-btn:hover{background:rgba(0,212,255,0.12)} +.comms-prio{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);flex-shrink:0} +.comms-section-label{font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--text-dim);border-bottom:1px solid var(--panel-border);padding:6px 0 4px;margin:8px 0 6px} +.comms-compose-btn{width:100%;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:5px} +.comms-compose-btn:hover{background:rgba(0,212,255,0.15)} +.comms-send-btn{flex:1;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.4);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer} +.comms-send-btn:hover{background:rgba(0,212,255,0.2)} +.comms-send-btn:disabled{opacity:0.4;cursor:not-allowed} +.comms-outbox-card{background:rgba(0,212,255,0.03);border:1px solid rgba(0,212,255,0.1);border-radius:3px;padding:6px 9px;margin-bottom:5px} +.comms-outbox-to{font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)} +.comms-outbox-subj{font-family:var(--font-display);font-size:0.55rem;color:var(--cyan);margin:2px 0} +.comms-outbox-status{font-family:var(--font-mono);font-size:0.48rem;padding:1px 4px;border-radius:2px} +.comms-outbox-status.sent{color:#00ff88;border:1px solid rgba(0,255,136,0.3)} +.comms-outbox-status.failed{color:#ff2244;border:1px solid rgba(255,34,68,0.3)} +.comms-outbox-status.queued{color:#ffd700;border:1px solid rgba(255,215,0,0.3)} +/* compose modal */ +.comms-compose-modal{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9000} +.comms-compose-inner{background:#0a0e14;border:1px solid var(--cyan);border-radius:6px;padding:16px;width:min(90vw,480px);max-height:80vh;overflow-y:auto} +.comms-compose-title{font-family:var(--font-display);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:12px} +.comms-compose-field{width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:3px;padding:6px 8px;color:var(--text);font-family:var(--font-mono);font-size:0.6rem;box-sizing:border-box;margin-bottom:7px} +.comms-compose-field:focus{outline:none;border-color:var(--cyan)} +.comms-compose-actions{display:flex;gap:6px;margin-top:8px} +/* ── DIRECTIVES HUD ──────────────────────────────────────────────── */ +.dir-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden} +.dir-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none} +.dir-card-head:hover{background:rgba(0,212,255,0.06)} +.dir-card-title{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dir-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)} +.dir-card.open .dir-card-body{display:block} +.dir-progress-bar{height:5px;background:rgba(255,255,255,0.08);border-radius:3px;margin:6px 0} +.dir-progress-fill{height:100%;border-radius:3px;transition:width 0.4s ease} +.dir-kr-row{display:flex;align-items:center;gap:6px;margin:4px 0;font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)} +.dir-kr-bar{flex:1;height:3px;background:rgba(255,255,255,0.06);border-radius:2px} +.dir-kr-fill{height:100%;border-radius:2px;background:rgba(0,212,255,0.5)} +.dir-admin-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px} +.dir-admin-btn:hover{background:rgba(0,212,255,0.12)} +/* ── MISSION OPS HUD ─────────────────────────────────────────────── */ +.mission-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden} +.mission-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none} +.mission-card-head:hover{background:rgba(0,212,255,0.06)} +.mission-card-name{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.mission-card-trigger{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)} +.mission-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)} +.mission-card.open .mission-card-body{display:block} +.mission-run-bar{display:flex;gap:5px;margin-top:8px} +.mission-run-btn{flex:1;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer} +.mission-run-btn:hover{background:rgba(0,212,255,0.15)} +.mission-run-btn:disabled{opacity:0.4;cursor:not-allowed} +.mission-run-item{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem} +.mission-run-status.done{color:#00ff88} +.mission-run-status.failed{color:#ff2244} +.mission-run-status.running{color:#ffd700;animation:pulse 1.5s ease-in-out infinite} +.mission-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px} +.mission-new-btn:hover{background:rgba(0,212,255,0.12)} +/* ── CLEARANCE PROTOCOL ──────────────────────────────────────────── */ +#clearance-banner{display:none;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:var(--r);padding:6px 10px;margin:0 0 8px;font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:#ff6680;animation:borderPulse 2s ease-in-out infinite} +@keyframes borderPulse{0%,100%{border-color:rgba(255,34,68,0.4)}50%{border-color:rgba(255,34,68,0.9)}} +#clearance-banner.active{display:flex;align-items:center;gap:8px} +#clearance-banner .clr-count{background:rgba(255,34,68,0.3);border-radius:3px;padding:1px 5px;font-size:0.6rem;color:#ff2244} +#clearance-banner .clr-view{margin-left:auto;cursor:pointer;color:#ff6680;text-decoration:underline} +.clr-card{background:rgba(255,34,68,0.04);border:1px solid rgba(255,34,68,0.3);border-radius:var(--r);margin-bottom:7px;overflow:hidden} +.clr-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none} +.clr-card-head:hover{background:rgba(255,34,68,0.06)} +.clr-card-type{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;flex:1;color:#ff8899} +.clr-card-risk{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;border:1px solid} +.clr-card-risk.critical{color:#ff2244;border-color:rgba(255,34,68,0.5)} +.clr-card-risk.high{color:#ffd700;border-color:rgba(255,215,0,0.4)} +.clr-card-risk.medium{color:#ff9900;border-color:rgba(255,153,0,0.4)} +.clr-card-body{display:none;padding:8px 10px 10px;border-top:1px solid rgba(255,34,68,0.2)} +.clr-card.open .clr-card-body{display:block} +.clr-card-desc{font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);margin-bottom:8px;line-height:1.5;white-space:pre-wrap} +.clr-action-bar{display:flex;gap:6px;margin-top:8px} +.clr-approve-btn{flex:1;background:rgba(0,255,136,0.08);border:1px solid rgba(0,255,136,0.4);border-radius:3px;padding:4px 8px;color:#00ff88;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer} +.clr-approve-btn:hover{background:rgba(0,255,136,0.18)} +.clr-deny-btn{flex:1;background:rgba(255,34,68,0.08);border:1px solid rgba(255,34,68,0.4);border-radius:3px;padding:4px 8px;color:#ff2244;font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;cursor:pointer} +.clr-deny-btn:hover{background:rgba(255,34,68,0.18)} +.clr-history-row{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim)} +.clr-status-approved{color:#00ff88}.clr-status-denied{color:#ff2244}.clr-status-pending{color:#ffd700}.clr-status-expired{color:rgba(255,255,255,0.3)} +.clr-rule-row{display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem} +.clr-rule-type{flex:1;color:var(--cyan)} +.clr-rule-toggle{cursor:pointer;padding:2px 6px;border-radius:2px;font-size:0.48rem;border:1px solid} +.clr-rule-enabled{color:#00ff88;border-color:rgba(0,255,136,0.4)} +.clr-rule-disabled{color:rgba(255,255,255,0.3);border-color:rgba(255,255,255,0.15)} +.clr-admin-btn{width:100%;background:rgba(255,34,68,0.06);border:1px solid rgba(255,34,68,0.3);border-radius:4px;padding:5px;color:#ff6680;font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px} +.clr-admin-btn:hover{background:rgba(255,34,68,0.12)} +/* ── INTEL PROTOCOL — research result cards ──────────────────────── */ +.intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden} +.intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none} +.intel-card-head:hover{background:rgba(0,212,255,0.06)} +.intel-card-query{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.intel-card-status{font-family:var(--font-mono);font-size:0.55rem;padding:2px 6px;border-radius:2px;flex-shrink:0} +.intel-card-status.running{color:#ffd700;border:1px solid rgba(255,215,0,0.4);animation:pulse 1.5s ease-in-out infinite} +.intel-card-status.done{color:var(--green);border:1px solid rgba(0,255,136,0.3)} +.intel-card-status.failed{color:var(--red);border:1px solid rgba(255,34,68,0.3)} +.intel-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)} +.intel-card.open .intel-card-body{display:block} +.intel-card-body .synthesis{font-size:0.65rem;line-height:1.6;color:var(--text);margin:8px 0;white-space:pre-wrap} +.intel-sources{margin-top:8px} +.intel-source{font-size:0.58rem;color:var(--text-dim);padding:2px 0;border-bottom:1px solid rgba(0,212,255,0.06)} +.intel-source a{color:var(--cyan2);text-decoration:none} +.intel-source a:hover{text-decoration:underline} +.intel-empty{text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px} +.intel-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.55rem;letter-spacing:2px;cursor:pointer;margin-bottom:8px} +.intel-new-btn:hover{background:rgba(0,212,255,0.12)} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}} + + +/* ── ARC REACTOR BOOT SPIN ───────────────────────────────────────────── */ +@keyframes arcBootSpin{ + 0%{transform:rotate(0deg) scale(0.6);opacity:0.2} + 40%{opacity:1} + 100%{transform:rotate(360deg) scale(1);opacity:1} +} +#arcReactor.boot-spin .arc-ring{animation:arcBootSpin 1.4s cubic-bezier(0.4,0,0.2,1) both!important} +#arcReactor.boot-spin .arc-ring.r3{animation-delay:0s!important} +#arcReactor.boot-spin .arc-ring.r5{animation-delay:0.08s!important} +#arcReactor.boot-spin .arc-ring.r7{animation-delay:0.16s!important} +#arcReactor.boot-spin .arc-core{animation:arcBootSpin 1.0s 0.3s cubic-bezier(0.4,0,0.2,1) both!important} + +/* ── COMMAND PALETTE ─────────────────────────────────────────────────── */ +#cmdPalette{ + position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000; + display:none;align-items:flex-start;justify-content:center; + padding-top:12vh;backdrop-filter:blur(4px); + opacity:0;transition:opacity 0.18s ease; +} +#cmdPalette.open{opacity:1} +#cmdPaletteBox{ + width:min(640px,92vw);background:rgba(5,15,25,0.97); + border:1px solid rgba(0,212,255,0.35);border-radius:4px; + box-shadow:0 0 40px rgba(0,212,255,0.15),0 20px 60px rgba(0,0,0,0.8); + overflow:hidden;transform:translateY(-8px);transition:transform 0.18s ease; +} +#cmdPalette.open #cmdPaletteBox{transform:translateY(0)} +#cmdPaletteInput{ + width:100%;background:transparent;border:none;border-bottom:1px solid rgba(0,212,255,0.2); + color:var(--cyan);font-family:var(--font-display);font-size:0.85rem;letter-spacing:1px; + padding:16px 20px;outline:none;caret-color:var(--cyan); +} +#cmdPaletteInput::placeholder{color:rgba(0,212,255,0.3);letter-spacing:2px} +#cmdPaletteList{max-height:360px;overflow-y:auto;padding:8px 0} +#cmdPaletteList .cp-group{ + font-family:var(--font-display);font-size:0.4rem;letter-spacing:3px; + color:rgba(0,212,255,0.4);padding:8px 20px 4px;text-transform:uppercase +} +#cmdPaletteList .cp-item{ + display:flex;align-items:center;gap:10px;padding:8px 20px;cursor:pointer; + font-family:var(--font-mono);font-size:0.68rem;color:rgba(200,230,255,0.7); + transition:background 0.1s,color 0.1s; +} +#cmdPaletteList .cp-item:hover,#cmdPaletteList .cp-item.cp-active{ + background:rgba(0,212,255,0.08);color:var(--cyan) +} +#cmdPaletteList .cp-item .cp-icon{color:rgba(0,212,255,0.4);flex-shrink:0;font-size:0.6rem} +#cmdPaletteList .cp-item .cp-label{flex:1} +#cmdPaletteList .cp-item .cp-label mark{background:none;color:var(--cyan);font-weight:700} +#cmdPaletteList .cp-item .cp-kbd{ + font-family:var(--font-mono);font-size:0.45rem;color:rgba(0,212,255,0.3); + border:1px solid rgba(0,212,255,0.2);border-radius:2px;padding:1px 5px; + opacity:0;transition:opacity 0.1s; +} +#cmdPaletteList .cp-item.cp-active .cp-kbd,#cmdPaletteList .cp-item:hover .cp-kbd{opacity:1} +#cmdPaletteFooter{ + font-family:var(--font-display);font-size:0.4rem;letter-spacing:2px; + color:rgba(0,212,255,0.25);padding:8px 20px;border-top:1px solid rgba(0,212,255,0.1); + display:flex;gap:16px +} + +/* ── VOICE TRANSCRIPT BAR ────────────────────────────────────────── */ +#voiceTranscriptBar.vt-active{opacity:1} +/* ── AGENT TOPOLOGY ────────────────────────────────────────────────── */ +#agentTopoCanvas{background:transparent;border-top:1px solid rgba(0,212,255,0.08);display:block} +#agent-topo-btn.active{background:rgba(0,212,255,0.15);border-color:rgba(0,212,255,0.5)} + +/* ════════════════════════════════════════════════════════════════════════ + FIRE HD 8 (12th Gen) TABLET MODE + Applied via body.tablet-mode — set automatically on Silk UA detection + Target: 1280×800 landscape, 189 PPI, touch-only input + ════════════════════════════════════════════════════════════════════════ */ + +/* Prevent accidental text selection on touch; restore for inputs */ +body.tablet-mode { -webkit-user-select:none; user-select:none; } +body.tablet-mode input, +body.tablet-mode textarea { -webkit-user-select:auto; user-select:auto; } + +/* ── TOPBAR — taller row, bigger tap zones ─────────────────────── */ +body.tablet-mode #topBar { + height:54px; + padding:0 12px; +} +body.tablet-mode #clock { font-size:1.1rem; letter-spacing:3px; } +body.tablet-mode .tb-logo { font-size:0.95rem; } + +/* Toolbar buttons — min 40px touch target */ +body.tablet-mode .btn-panels, +body.tablet-mode .btn-camera { + font-size:0.62rem; + letter-spacing:1.5px; + padding:9px 13px; + min-height:40px; + margin-right:4px; +} + +/* Theme color dots — bigger tap area */ +body.tablet-mode #themeBar { gap:6px; } +body.tablet-mode .theme-btn { + width:20px; height:20px; + font-size:0.75rem; +} + +/* Swap + logout */ +body.tablet-mode #btn-swap-panels { + font-size:0.65rem; + padding:7px 11px; +} +body.tablet-mode .btn-logout { + font-size:0.72rem; + padding:7px 12px; +} + +/* ── MAIN LAYOUT — narrower side panels → wider center ──────────── */ +/* 220+220 side cols → center gets ~808px instead of ~660px */ +body.tablet-mode #mainLayout { + grid-template-columns:220px 1fr 220px; + padding:8px; + gap:8px; +} + +/* ── PANELS — tighter padding, larger text ──────────────────────── */ +body.tablet-mode .panel { padding:11px; } +body.tablet-mode .panel-title { + font-size:0.67rem; + letter-spacing:2.5px; + margin-bottom:9px; +} + +/* Metric rows */ +body.tablet-mode .metric-label { font-size:0.75rem; } +body.tablet-mode .service-row { font-size:0.75rem; padding:6px 0; } +body.tablet-mode .val-row { font-size:0.75rem; padding:4px 0; } +body.tablet-mode .device-item { font-size:0.75rem; padding:7px 0; } +body.tablet-mode .device-name { font-size:0.75rem; } +body.tablet-mode .device-ip { font-size:0.68rem; } +body.tablet-mode .vm-card { font-size:0.75rem; padding:9px 10px; } + +/* Scrollable side panels — smooth touch inertia */ +body.tablet-mode #leftPanel, +body.tablet-mode #rightPanel { + -webkit-overflow-scrolling:touch; + overscroll-behavior:contain; +} + +/* ── CENTER — arc reactor + chat ────────────────────────────────── */ +/* Scale reactor down so chat gets more vertical room */ +body.tablet-mode #arcReactor { width:180px; height:180px; } +body.tablet-mode .arc-ring.r1 { width:180px; height:180px; } +body.tablet-mode .arc-ring.r2 { width:159px; height:159px; } +body.tablet-mode .arc-ring.r3 { width:139px; height:139px; } +body.tablet-mode .arc-ring.r4 { width:118px; height:118px; } +body.tablet-mode .arc-ring.r5 { width:94px; height:94px; } +body.tablet-mode .arc-ring.r6 { width:72px; height:72px; } +body.tablet-mode .arc-ring.r7 { width:51px; height:51px; } +body.tablet-mode .arc-core { width:30px; height:30px; } + +/* Chat messages — comfortable reading size */ +body.tablet-mode .msg { + font-size:0.95rem; + line-height:1.55; + padding:11px 14px; +} +body.tablet-mode .msg.user { font-size:0.88rem; } +body.tablet-mode .msg.system { font-size:0.78rem; } + +/* Touch-scroll chat log */ +body.tablet-mode #chatLog { + -webkit-overflow-scrolling:touch; + overscroll-behavior:contain; +} + +/* Input row — 16px prevents Silk from zooming on focus */ +body.tablet-mode #textInput { + font-size:1rem; + min-height:46px; + padding:12px 14px; +} +body.tablet-mode #sendBtn { + font-size:0.68rem; + min-height:46px; + padding:0 18px; +} +body.tablet-mode #micBtn { + width:52px; height:52px; + flex-shrink:0; +} +body.tablet-mode #searchBtn { + min-height:46px !important; + padding:0 13px !important; + font-size:1.1rem !important; +} + +/* ── TABS — bigger tap targets ──────────────────────────────────── */ +body.tablet-mode .tab { + font-size:0.58rem; + letter-spacing:1.5px; + padding:9px 12px; +} + +/* ── HA TABLE — more readable on 8" ────────────────────────────── */ +body.tablet-mode .ha-thead th { font-size:0.55rem; padding:6px 3px 8px; } +body.tablet-mode .ha-row td { font-size:0.74rem; padding:6px 3px; } + +/* Toggle slider — bigger for fat fingers */ +body.tablet-mode .ha-toggle { width:36px; height:18px; } +body.tablet-mode .ha-slider::before { width:12px; height:12px; left:2px; top:2px; } +body.tablet-mode .ha-toggle input:checked + .ha-slider::before { transform:translateX(18px); } + +/* ── DISABLE HOVER-RISE — not meaningful on touch ───────────────── */ +body.tablet-mode .panel:hover { + transform:translateY(var(--pty,0px)) !important; + border-color:var(--panel-border) !important; + box-shadow:none !important; + transition:none !important; +} + +/* ── ALERTS ──────────────────────────────────────────────────────── */ +body.tablet-mode .alert-item { font-size:0.75rem; padding:9px 11px; } + +/* ── BOTTOM BAR ─────────────────────────────────────────────────── */ +body.tablet-mode #bottomBar { font-size:0.7rem; height:34px; } + +/* ════════════════════════════════════════════════════════════════════════ + KIOSK MODE — hide noisy panels, keep it clean on Fire tablet + Only active when body.kiosk-mode (fullscreen) + ════════════════════════════════════════════════════════════════════════ */ +body.kiosk-mode #server-panel { display:none !important; } +body.kiosk-mode #network-status-panel { display:none !important; } +body.kiosk-mode #tab-btn-agents { display:none !important; } +body.kiosk-mode #tab-btn-guardian { display:none !important; } +body.kiosk-mode #tab-agents { display:none !important; } +body.kiosk-mode #tab-guardian { display:none !important; } +body.kiosk-mode #bb-ha-item { display:none !important; } +body.kiosk-mode #bb-agents-item { display:none !important; } +body.kiosk-mode #bb-memory-item { display:none !important; } +body.kiosk-mode #bb-pve-item { display:none !important; } diff --git a/public_html/assets/js/jarvis-app.js b/public_html/assets/js/jarvis-app.js new file mode 100644 index 0000000..1c075e5 --- /dev/null +++ b/public_html/assets/js/jarvis-app.js @@ -0,0 +1,1832 @@ +// ── GLOBALS ────────────────────────────────────────────────────────── +function escHtml(s) { + return String(s == null ? '' : s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} +// For values embedded inside a single-quoted JS string literal within an HTML attribute +// (e.g. onclick="fn('${escJs(x)}')"). escHtml() alone isn't enough there: the browser +// HTML-decodes the attribute before parsing it as JS, so an encoded quote would just +// turn back into a literal ' before the JS parser ever sees it. +function escJs(s) { + return escHtml(String(s == null ? '' : s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,'\\r')); +} +let sessionToken = ''; +let sessionUser = ''; +let sessionId = 'session_' + Date.now(); +let isListening = false; +let recognition = null; +let synth = window.speechSynthesis; +let selectedVoice = null; +let refreshTimer = null; +let isSpeaking = false; +let panelsVisible = true; +let cameraActive = false; +let faceLoopId = null; +let lastFaceSeen = 0; +let autoMicCooldown = 0; +let faceApiReady = false; +let lastActivity = Date.now(); +const IDLE_RELOAD_MS = 5 * 60 * 1000; // 5 min inactivity → full reload +let voiceMode = false; // true = JARVIS awake (listening for commands) +let voiceMuted = false; // true = awake but mic muted +let voiceLastCmd = 0; +const VOICE_SLEEP_MS = 30 * 60 * 1000; // 30 min voice inactivity → sleep +const VOICE_ACTIVE_MS = 17000; // 17s active window after each command +let voiceActive = 0; // timestamp of last issued command +// Phase 1: full phrase required to wake from sleep +const WAKE_PHRASES = ["wake up jarvis", "daddy's home", "wake up, jarvis", "daddys home"]; +// Phase 2: command prefix — "jarvis "; then 17s free-listen window +const CMD_PREFIX = 'jarvis'; +const FACE_MODEL_URL = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights'; + +// ── INIT ───────────────────────────────────────────────────────────── +function initCollapsiblePanels() { + document.querySelectorAll('.panel').forEach(panel => { + const title = panel.querySelector('.panel-title'); + if (!title) return; + const btn = document.createElement('button'); + btn.className = 'panel-collapse-btn'; + btn.textContent = '▾'; + btn.title = 'Collapse / expand'; + title.appendChild(btn); + const key = 'pnl_' + (title.textContent||'').trim().substring(0,24).replace(/\s+/g,'_').toLowerCase().replace(/[^a-z0-9_]/g,''); + if (localStorage.getItem(key) === '1') panel.classList.add('collapsed'); + title.addEventListener('click', e => { + if (e.target.closest('button:not(.panel-collapse-btn),a,input,select')) return; + const col = panel.classList.toggle('collapsed'); + localStorage.setItem(key, col ? '1' : '0'); + }); + }); +} + +window.addEventListener("load", () => { + ["mousemove","keydown","touchstart","click"].forEach(e => + window.addEventListener(e, () => { lastActivity = Date.now(); }, {passive:true}) + ); + updateClock(); + setInterval(updateClock, 1000); + initVoice(); + loadVoices(); + + // Check if already logged in — prefer PHP-injected global, fall back to sessionStorage + const saved = (typeof __jarvisToken !== 'undefined' ? __jarvisToken : null) + || sessionStorage.getItem('jarvis_token'); + const savedUser = (typeof __jarvisUser !== 'undefined' ? __jarvisUser : null) + || sessionStorage.getItem('jarvis_user') || ''; + const autoReload = sessionStorage.getItem('jarvis_autoreload') === '1'; + sessionStorage.removeItem('jarvis_autoreload'); + if (saved) { + sessionToken = saved; + sessionUser = savedUser; + try { sessionStorage.setItem('jarvis_token', saved); sessionStorage.setItem('jarvis_user', savedUser); } catch(e) {} + if (localStorage.getItem('jarvis_panels_swapped') === '1') swapPanels(); + showApp(savedUser, null, autoReload); + } +}); + +function updateClock() { + const now = new Date(); + document.getElementById('clock').textContent = + now.toLocaleTimeString('en-US',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'}); + document.getElementById('date-display').textContent = + now.toLocaleDateString('en-US',{weekday:'short',year:'numeric',month:'short',day:'numeric'}).toUpperCase(); +} + +// ── LOGIN ───────────────────────────────────────────────────────────── +document.getElementById('loginForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const user = document.getElementById('loginUser').value; + const pass = document.getElementById('loginPass').value; + const errEl = document.getElementById('loginError'); + errEl.textContent = ''; + + try { + const res = await api('auth', 'POST', {username:user, password:pass}); + if (res.success) { + sessionToken = res.token; + sessionUser = res.display_name; + sessionStorage.setItem('jarvis_token', sessionToken); + sessionStorage.setItem('jarvis_user', sessionUser); + showApp(sessionUser, res.greeting); + } else { + errEl.textContent = 'ACCESS DENIED'; + } + } catch(err) { + errEl.textContent = 'CONNECTION FAILED'; + } +}); + +function showApp(name, greeting, silent = false) { + document.getElementById('loginScreen').style.display = 'none'; + const app = document.getElementById('app'); + app.style.display = 'flex'; + + // HUD boot sequence — staggered slide-in + const topBar = document.getElementById('topBar'); + const leftPanel = document.getElementById('leftPanel'); + const rightPanel = document.getElementById('rightPanel'); + const centerPanel= document.getElementById('centerPanel'); + [topBar, leftPanel, rightPanel, centerPanel].forEach(el => el && (el.style.opacity = '0')); + requestAnimationFrame(() => { + if (topBar) { topBar.style.opacity=''; topBar.style.animationDelay='0s'; topBar.classList.add('boot-top'); } + setTimeout(()=>{ if(leftPanel) { leftPanel.style.opacity=''; leftPanel.style.animationDelay='0s'; leftPanel.classList.add('boot-left'); }}, 120); + setTimeout(()=>{ if(rightPanel) { rightPanel.style.opacity=''; rightPanel.style.animationDelay='0s'; rightPanel.classList.add('boot-right'); }}, 180); + setTimeout(()=>{ if(centerPanel){ centerPanel.style.opacity='';centerPanel.style.animationDelay='0s';centerPanel.classList.add('boot-center');}}, 240); + setTimeout(()=>{ [topBar,leftPanel,rightPanel,centerPanel].forEach(el=>el?.classList.remove('boot-top','boot-left','boot-right','boot-center')); }, 1200); + }); + + if (!silent) { + if (greeting) { + addMessage('jarvis', greeting); + speak(greeting); + } else { + const g = `Welcome back, ${name}. All systems online and standing by.`; + addMessage('jarvis', g); + speak(g); + } + } + + // Smart morning briefing: auto-speak once per day before noon + const _briefKey = 'jarvis_brief_' + new Date().toISOString().slice(0, 10); + const _briefHour = new Date().getHours(); + if (!silent && _briefHour < 12 && !localStorage.getItem(_briefKey)) { + localStorage.setItem(_briefKey, '1'); + setTimeout(triggerMorningBriefing, 3500); + } + + // Arc Reactor boot spin-up + const _ar = document.getElementById('arcReactor'); + if (_ar) { + _ar.classList.add('boot-spin'); + setTimeout(() => _ar.classList.remove('boot-spin'), 1600); + } + + // Start data refresh + initCollapsiblePanels(); + refreshAll(); + refreshTimer = setInterval(refreshAll, 10000); // every 10s + setInterval(() => { + if (!isAsleep && Date.now() - lastActivity > IDLE_RELOAD_MS) { + sessionStorage.setItem('jarvis_autoreload', '1'); + location.reload(); + } + }, 30000); + setInterval(() => { + if (voiceMode && voiceLastCmd > 0 && Date.now() - voiceLastCmd > VOICE_SLEEP_MS) { + exitVoiceMode(); + } + }, 60000); + // Watchdog: reset isSpeaking if stuck; heartbeat keeps mic alive + setInterval(() => { + if (isSpeaking && !_ttsAudio && !window.speechSynthesis?.speaking) { + isSpeaking = false; + if (isListening) _scheduleRecStart(200); + } + }, 4000); + // Heartbeat: if mic should be on but recognition has gone quiet, nudge it + setInterval(() => { + if (isListening && !isSpeaking) { + try { + recognition.start(); // throws if already running — that's fine + } catch(_) {} + } + }, 12000); + startListening(); + loadNetwork(); + loadHA(); + checkAgentStatus(); + checkArcStatus().catch(() => {}); + loadAgents(); + loadAlerts(); + loadWeather(); + loadNews(); + initMobile(); + setTimeout(checkPlannerReminder, 3000); + setInterval(checkUpcomingAppts, 300000); + setTimeout(pollAlertsProactive, 8000); + setTimeout(checkSuggestions, 15000); + setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load + setInterval(pollAlertsProactive, 60000); // poll every 60s + setInterval(() => { + const layout = document.getElementById('mainLayout'); + if (!layout) return; + if (Date.now() - lastActivity > 90000) layout.classList.add('ambient-dim-active'); + else layout.classList.remove('ambient-dim-active'); + }, 5000); + setTimeout(() => { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } + }, 9000); + // Guardian Mode — badge refresh + proactive chat + setTimeout(() => { + _refreshGuardianBadge(); + _pollProactiveChat(); + startGuardianPolling(); + setInterval(_pollProactiveChat, 30000); + }, 5000); + // Clearance banner — poll every 30s + setTimeout(() => { + updateClearanceBanner(); + setInterval(updateClearanceBanner, 30000); + }, 6000); + // Memory Core — poll count every 60s + setTimeout(() => { + updateMemoryCount(); + setInterval(updateMemoryCount, 60000); + }, 8000); +} + +async function logout() { + clearInterval(refreshTimer); + await api('auth', 'DELETE', {}); + sessionStorage.clear(); + location.reload(); +} + +// ── API HELPER ──────────────────────────────────────────────────────── +async function api(endpoint, method='GET', body=null) { + const opts = { + method, + headers: {'Content-Type':'application/json','X-Session-Token':sessionToken}, + credentials:'include', + }; + if (body && method !== 'GET') opts.body = JSON.stringify(body); + const res = await fetch('/api/' + endpoint, opts); + if (res.status === 401) { logout(); return {}; } + return res.json(); +} + +// ── PANEL TOGGLE ───────────────────────────────────────────────────── +function swapPanels() { + const layout = document.getElementById('mainLayout'); + const btn = document.getElementById('btn-swap-panels'); + const isSwapped = layout.classList.toggle('swapped'); + btn.classList.toggle('active', isSwapped); + localStorage.setItem('jarvis_panels_swapped', isSwapped ? '1' : '0'); +} + +function togglePanels(silent) { + panelsVisible = !panelsVisible; + const layout = document.getElementById('mainLayout'); + const btn = document.getElementById('panelToggleBtn'); + if (panelsVisible) { + layout.classList.remove('focus-mode'); + btn.classList.remove('focus-active'); + btn.textContent = '◧ PANELS'; + if (!silent) speak('Full view restored.'); + } else { + layout.classList.add('focus-mode'); + btn.classList.add('focus-active'); + btn.textContent = '◫ FOCUS'; + if (!silent) speak('Focus mode activated. Side panels hidden.'); + } +} + +// Keyboard shortcut: backslash to toggle panels +document.addEventListener('keydown', (e) => { + if (e.key === '\\' && document.activeElement.id !== 'textInput') { + togglePanels(); + } +}); + +// ── CAMERA FACE DETECTION / AUTO-MIC ───────────────────────────────── +async function loadFaceApi() { + if (faceApiReady) return true; + try { + if (typeof faceapi === 'undefined') { + addMessage('system', 'Face detection library not available.'); + return false; + } + await faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL); + faceApiReady = true; + return true; + } catch(e) { + addMessage('system', 'Could not load face detection model: ' + e.message); + return false; + } +} + +async function startCamera() { + if (cameraActive) return; + const btn = document.getElementById('cameraBtn'); + btn.textContent = '◉ LOADING…'; + try { + const stream = await navigator.mediaDevices.getUserMedia( + {video:{facingMode:'user', width:{ideal:320}, height:{ideal:240}}, audio:false} + ); + const video = document.getElementById('faceVideo'); + video.srcObject = stream; + await video.play(); + + const ok = await loadFaceApi(); + if (!ok) { stopCamera(); return; } + + cameraActive = true; + btn.classList.add('cam-active'); + btn.textContent = '◉ SENSING'; + startFaceTracking(); + addMessage('system', 'Face detection active — reactor tracking engaged.'); + + faceLoopId = setInterval(async () => { + if (!cameraActive) return; + // Run detection even while speaking — needed for tracking + prevents lastFaceSeen staling out + try { + const detection = await faceapi.detectSingleFace( + document.getElementById('faceVideo'), + new faceapi.TinyFaceDetectorOptions({inputSize:160, scoreThreshold:0.45}) + ); + const now = Date.now(); + if (detection) { + lastFaceSeen = now; + const ratio = (detection.box.width * detection.box.height) / (320 * 240); + + // Always drive the reactor + updateFaceTarget(detection.box, 320, 240); + + // Only auto-trigger voice when not already speaking/active, cooldown passed + if (ratio > 0.03 && !voiceMode && !isSpeaking && now > autoMicCooldown) { + autoMicCooldown = now + 9000; + document.getElementById('cameraBtn').classList.add('cam-sensing'); + enterVoiceMode(); + } + } else { + // While JARVIS is speaking, keep lastFaceSeen fresh so the exit timer doesn't tick down + if (isSpeaking) { lastFaceSeen = now; } + else { clearFaceTarget(); } + + document.getElementById('cameraBtn').classList.remove('cam-sensing'); + + // Exit voice mode only if: face gone >12s AND no command in that same window AND not speaking + const noFaceMs = now - lastFaceSeen; + const noCommandMs = now - (voiceLastCmd || 0); + if (voiceMode && !isSpeaking && noFaceMs > 12000 && noCommandMs > 12000) { + exitVoiceMode(); + } + } + } catch(_) {} + }, 600); + + } catch(e) { + btn.textContent = '◉ CAMERA'; + if (e.name === 'NotAllowedError') { + addMessage('system', 'Camera permission denied. Grant camera access in browser settings to enable hands-free mode.'); + } else { + addMessage('system', 'Camera unavailable: ' + e.message); + } + } +} + +function stopCamera() { + cameraActive = false; + clearInterval(faceLoopId); + faceLoopId = null; + const video = document.getElementById('faceVideo'); + if (video && video.srcObject) { + video.srcObject.getTracks().forEach(t => t.stop()); + video.srcObject = null; + } + const btn = document.getElementById('cameraBtn'); + if (btn) { + btn.classList.remove('cam-active', 'cam-sensing'); + btn.textContent = '◉ CAMERA'; + } + stopFaceTracking(); +} + +function toggleCamera() { + if (cameraActive) { + stopCamera(); + addMessage('system', 'Face detection disabled.'); + } else { + startCamera(); + } +} + +// ── REFRESH ALL ─────────────────────────────────────────────────────── +let _refreshTick = 0; +let selectedContext = null; +const _panelCtx = {}; +let _haEntities = {}; +const _svcLabels = {nginx:'WEB','php8.3-fpm':'PHP',mariadb:'DB','redis-server':'REDIS','jarvis-arc':'ARC','jarvis-agent':'AGENT'}; + +async function refreshAll() { + _refreshTick++; + const el = document.getElementById('last-refresh'); + if (el) el.textContent = new Date().toLocaleTimeString('en-US',{hour12:false}); + + // Fire core calls in parallel — cuts refresh latency from ~3s to ~600ms + const [s, n, d] = await Promise.all([ + api('system').catch(() => null), + api('network').catch(() => null), + api('do').catch(() => null), + ]); + if (s) renderSystem(s); + if (n) renderNetworkStatus(n); + if (d) renderDO(d); + + // Agent status every tick (fire and forget — doesn't block) + checkAgentStatus().catch(() => {}); + + // Refresh right-panel tabs every 3rd tick (~30s) — all parallel + if (_refreshTick % 3 === 0) { + Promise.all([ + loadHA().catch(() => {}), + loadAlerts().catch(() => {}), + loadAgents().catch(() => {}), + loadProxmox().catch(() => {}), + loadPlannerSummary().catch(() => {}), + ]); + } + // Refresh Arc Reactor status every 6th tick (~60s) + if (_refreshTick % 6 === 0) { + checkArcStatus().catch(() => {}); + } + // Refresh weather + news every 18th tick (~3 min) + if (_refreshTick % 18 === 0) { + Promise.all([ + loadWeather().catch(() => {}), + loadNews().catch(() => {}), + ]); + } +} + +// ── ANIMATED NUMBER COUNTER ─────────────────────────────────────────── +const _prevVals = {}; +function tickTo(id, newVal, unit='%', decimals=0) { + const el = document.getElementById(id); + if (!el) return; + const prev = _prevVals[id] !== undefined ? _prevVals[id] : 0; + _prevVals[id] = newVal; + if (Math.abs(newVal - prev) < 0.5) { el.textContent = newVal.toFixed(decimals) + unit; return; } + const start = performance.now(), dur = 700; + (function frame(now) { + const p = Math.min((now - start) / dur, 1); + const ease = 1 - Math.pow(1 - p, 3); + el.textContent = (prev + (newVal - prev) * ease).toFixed(decimals) + unit; + if (p < 1) requestAnimationFrame(frame); + })(performance.now()); +} + +// ── RENDER: SYSTEM ──────────────────────────────────────────────────── +function renderSystem(s) { + if (!s || s.error) return; + const cpu = s.cpu || 0; + const mem = s.memory?.percent || 0; + const disk = s.disk?.percent || 0; + + // Top bar (animated) + tickTo('tb-cpu', cpu, ''); + tickTo('tb-mem', mem, ''); + + // Metric bars + setBar('cpu', cpu); + setBar('mem', mem); + setBar('disk', disk); + + tickTo('cpu-val', cpu); + tickTo('mem-val', mem); + tickTo('disk-val', disk); + + // Sparklines + pushSparkData('cpu', cpu); + pushSparkData('mem', mem); + pushSparkData('disk', disk); + drawSparkline('spark-cpu', _sparkData.cpu, 'rgb(0,212,255)'); + drawSparkline('spark-mem', _sparkData.mem, 'rgb(0,255,136)'); + drawSparkline('spark-disk', _sparkData.disk, 'rgb(255,166,0)'); + + // Flash the system panel on data arrival + flashPanel(document.querySelector('#leftPanel .panel')); + document.getElementById('uptime-val').textContent = s.uptime || '--'; + document.getElementById('load-val').textContent = s.load?.['1m'] || '--'; + document.getElementById('host-val').textContent = s.hostname || 'jarvis'; + + // Services + if (s.services) { + const svcEl = document.getElementById('services-list'); + svcEl.innerHTML = Object.entries(s.services).map(([k,v]) => + `
+ ${_svcLabels[k]||k.toUpperCase()} +
+
` + ).join(''); + } + + // Processes + if (s.processes?.length) { + document.getElementById('procs-list').innerHTML = s.processes.map(p => + `
+
${p.cmd}
+
${p.cpu}%
+
` + ).join(''); + } +} + +function setBar(id, pct) { + const el = document.getElementById(id+'-bar'); + if (!el) return; + el.style.width = Math.min(pct,100) + '%'; + el.className = 'metric-bar-fill' + (pct>90?' danger':pct>75?' warn':''); +} + +// ── RENDER: DO SERVER (site health only — metrics merged into system panel) ─── +function renderDO(d) { + const dot = document.getElementById('bb-do-dot'); + const status = document.getElementById('bb-do-status'); + const sitesEl = document.getElementById('sites-list'); + + if (!d || d.error || !d.reachable) { + if (dot) dot.className = 'bb-dot offline'; + if (status) status.textContent = 'OFFLINE'; + document.getElementById('tb-do').className = 'text-red'; + document.getElementById('tb-do').textContent = 'OFFLINE'; + if (sitesEl) sitesEl.innerHTML = '
Unavailable
'; + return; + } + + dot.className = 'bb-dot online'; + status.textContent = 'ONLINE'; + document.getElementById('tb-do').className = 'text-green'; + document.getElementById('tb-do').textContent = 'ONLINE'; + + if (sitesEl && d.sites && Object.keys(d.sites).length) { + sitesEl.innerHTML = Object.entries(d.sites).map(([k, v]) => { + const cls = v === 'up' ? 'ok' : v === 'down' ? 'danger' : 'warn'; + const lbl = k.replace(/^https?:\/\//, '').replace(/\.orbishosting\.com$/, '').replace(/\.com$/, ''); + return `
+
${lbl}
+
${v.toUpperCase()}
+
`; + }).join(''); + } + + // WEB HOST (DO server agent metrics) + const ds = d.do_server || {}; + const doStatus = document.getElementById('do-host-status'); + const doCpu = document.getElementById('do-cpu'); + const doMem = document.getElementById('do-mem'); + const doDisk = document.getElementById('do-disk'); + if (ds.online) { + if (doStatus) { doStatus.textContent = '●'; doStatus.style.color = 'var(--green)'; } + if (doCpu) doCpu.textContent = (ds.cpu || 0) + '%'; + if (doMem) doMem.textContent = (ds.mem || 0) + '%'; + if (doDisk) doDisk.textContent = (ds.disk || 0) + '%'; + } else { + if (doStatus) { doStatus.textContent = '○'; doStatus.style.color = 'var(--red)'; } + } +} + +async function loadNetwork() { + try { + const n = await api('network'); + renderNetworkStatus(n); + } catch(e) {} +} + +// ── RENDER: NETWORK ─────────────────────────────────────────────────── +function renderNetworkStatus(n) { + if (!n) return; + renderTopology(n.devices || []); + const el = document.getElementById('network-list'); + if (!el) return; + const devices = n.devices || []; + const online = devices.filter(d => d.alive || d.status === 'online').length; + const countEl = document.getElementById('net-agent-count'); + if (countEl) countEl.textContent = online + '/' + devices.length + ' ONLINE'; + + const agents = devices.filter(d => d.source === 'agent'); + const others = devices.filter(d => d.source !== 'agent'); + + function renderDev(d) { + const alive = d.alive || d.status === 'online'; + const ctxKey = d.source === 'agent' ? 'agent_' + d.agent_id : 'net_' + (d.ip||'').replace(/\./g,'_'); + _panelCtx[ctxKey] = {type: d.source === 'agent' ? 'agent' : 'network', + label: d.name || d.ip, ip: d.ip, status: d.status || (alive ? 'online' : 'offline'), + agent_id: d.agent_id, hostname: d.name}; + const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : ''; + const badge = d.source === 'agent' + ? `${escHtml((d.agent_type||'AGENT').toUpperCase())}` : ''; + const del = d.deletable + ? `` : ''; + const bl = d.source === 'agent' ? 'border-left:2px solid ' + (alive ? 'var(--green)' : 'var(--red)') + ';' : ''; + return `
+
+
+
${escHtml(d.name||d.ip)}${badge}
+
${escHtml(d.ip||'')}${lat}
+
${del} +
`; + } + + let out = ''; + if (agents.length) { + const agOn = agents.filter(d => d.alive || d.status === 'online').length; + out += `
AGENTS (${agOn}/${agents.length})
`; + out += agents.map(renderDev).join(''); + } + if (others.length) { + if (agents.length) out += '
'; + out += `
DEVICES
`; + out += others.map(renderDev).join(''); + } + if (!out) out = '
No devices
'; + el.innerHTML = out; +} + +// ── NETWORK SCAN ────────────────────────────────────────────────────── +async function scanNetwork() { + const btn = document.getElementById('scanBtn'); + btn.textContent = 'QUEUING...'; + btn.disabled = true; + + try { + const data = await api('network/scan'); + const count = data.count ?? 0; + const msg = data.queued + ? `Network scan dispatched to PVE1 probe, Sir. Currently showing ${count} active device${count!==1?'s':''} — panel will refresh with live results in approximately 40 seconds.` + : `Showing last known network data: ${count} active device${count!==1?'s':''} on 10.48.200.0/24. PVE1 probe scans automatically every 3 minutes.`; + addMessage('jarvis', msg); + speak(count + ' devices online.'); + // Refresh the network panel with current data + loadNetwork(); + // Auto-refresh again after 45s to catch PVE1 scan results + if (data.queued) setTimeout(loadNetwork, 45000); + } catch(e) { + addMessage('jarvis', 'Network scan request failed, Sir.'); + } + + btn.textContent = 'RUN NETWORK SCAN'; + btn.disabled = false; +} + +// ── PROXMOX ─────────────────────────────────────────────────────────── +async function loadProxmox() { + const data = await api('proxmox'); + const el = document.getElementById('vm-list'); + const dot = document.getElementById('bb-pve-dot'); + const status = document.getElementById('bb-pve-status'); + + if (!data.configured) { + el.innerHTML = `
+
⚠ NOT CONFIGURED
+ Set PROXMOX_HOST and PROXMOX_TOKEN_VAL in config.php to enable VM monitoring. +
`; + dot.className='bb-dot offline'; status.textContent='NOT CONFIGURED'; + return; + } + + dot.className='bb-dot online'; status.textContent='ONLINE'; + + const vms = [...(data.vms||[]), ...(data.containers||[])]; + if (!vms.length) { + el.innerHTML = '
No VMs found.
'; + return; + } + + el.innerHTML = vms.map(vm => { + const statusColor = vm.status==='running'?'var(--green)':vm.status==='stopped'?'var(--red)':'var(--yellow)'; + const cpuClass = vm.cpu>80?'text-red':vm.cpu>60?'text-orange':'text-cyan'; + const ctxKey = 'vm_' + vm.vmid; + _panelCtx[ctxKey] = {type:'vm', label:vm.name, + vmid:vm.vmid, name:vm.name, status:vm.status, + cpu:vm.cpu, mem_mb:vm.mem_mb, maxmem_mb:vm.maxmem_mb, + type_label:vm.type||'qemu', uptime:vm.uptime||0}; + return `
+
+ ${escHtml(vm.name)} + ● ${(vm.status||'').toUpperCase()} +
+
+
CPU ${vm.cpu}%
+
RAM ${vm.mem_mb||0}/${vm.maxmem_mb||0}MB
+
ID ${vm.vmid}
+
TYPE ${vm.type||'qemu'}
+
+
`; + }).join(''); +} + +// ── HOME ASSISTANT ──────────────────────────────────────────────────── +async function loadHA() { + const data = await api('ha'); + const el = document.getElementById('ha-list'); + const dot = document.getElementById('bb-ha-dot'); + const sta = document.getElementById('bb-ha-status'); + + if (!data.configured) { + el.innerHTML = `
+
⚠ NOT CONFIGURED
+ Set HA_URL and HA_TOKEN in config.php to enable smart home control. +
`; + dot.className='bb-dot offline'; sta.textContent='NOT CONFIGURED'; + return; + } + + dot.className='bb-dot online'; sta.textContent='ONLINE'; + + const entities = data.entities || {}; + _haEntities = entities; + if (!Object.keys(entities).length) { + el.innerHTML = '
No entities found.
'; + return; + } + + renderHATable(entities); +} + +const _domainIcon = { + light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}', + media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}', + lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}', + lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙' + }; + +function renderHATable(entities) { + const el = document.getElementById('ha-list'); + if (!el) return; + let rows = ''; + let totalShown = 0; + for (const [domain, items] of Object.entries(entities)) { + const icon = _domainIcon[domain] || '•'; + const available = items.filter(e => e.state !== 'unavailable' && e.state !== 'unknown'); + if (!available.length) continue; + available.forEach(e => { + totalShown++; + const isOn = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night'].includes(e.state); + const isScene = domain === 'scene'; + const ctxKey = 'ha_' + e.entity_id.replace(/[^a-z0-9]/gi,'_'); + _panelCtx[ctxKey] = {type:'ha', label:e.name, + entity_id:e.entity_id, name:e.name, state:e.state, domain:domain}; + const stateLabel = isScene ? '—' : (isOn ? 'ON' : 'OFF'); + const stateClass = isOn ? 'on' : 'off'; + const eid = e.entity_id.replace(/'/g,"\\'"); + const ctrl = isScene + ? `` + : ``; + rows += ` + ${icon} + ${e.name} + ${stateLabel} + ${ctrl} + `; + }); + } + if (!totalShown) { + el.innerHTML = '
No available entities.
'; + return; + } + el.innerHTML = ` + + ${rows}
DEVICESTATECTRL
`; +} + +async function toggleHA(entityId, domain, currentState) { + let service; + const ON_STATES = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night','active']; + const wasOn = ON_STATES.includes(currentState); + if (domain === 'scene') { + service = 'turn_on'; + } else if (domain === 'alarm_control_panel') { + service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm'; + } else { + service = wasOn ? 'turn_off' : 'turn_on'; + } + try { + await api('ha/service', 'POST', {domain, service, entity_id: entityId}); + // Optimistic update — flip state immediately so toggle doesn't snap back + if (_haEntities[domain]) { + const ent = _haEntities[domain].find(e => e.entity_id === entityId); + if (ent && domain !== 'scene') ent.state = wasOn ? 'off' : 'on'; + } + renderHATable(_haEntities); + // Full sync after 4s — HA executes + agent pushes new state + setTimeout(loadHA, 4000); + } catch(e) {} +} + +// ── PROACTIVE REMINDERS ────────────────────────────────────────────────────── +let _reminderShown = false; +async function checkPlannerReminder() { + if (_reminderShown || sessionStorage.getItem('reminderShown')) return; + _reminderShown = true; + sessionStorage.setItem('reminderShown', '1'); + const d = await api('planner/today').catch(() => null); + if (!d) return; + const tasks = [...(d.tasks_overdue||[]), ...(d.tasks_today||[])]; + const appts = d.appts_today || []; + const overdue = d.tasks_overdue?.length || 0; + if (!tasks.length && !appts.length) return; + + const parts = []; + if (overdue) parts.push(overdue + ' overdue task' + (overdue > 1 ? 's' : '')); + if (tasks.length - overdue > 0) parts.push((tasks.length - overdue) + ' task' + (tasks.length - overdue > 1 ? 's' : '') + ' due today'); + if (appts.length) { + const nextAppt = appts[0]; + const t = nextAppt.start_at ? new Date(nextAppt.start_at).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}) : ''; + parts.push((t ? 'appointment at ' + t : appts.length + ' appointment' + (appts.length > 1 ? 's' : '') + ' today')); + } + + const msg = 'Heads up, ' + (sessionUser||'Sir') + '. You have ' + parts.join(' and ') + '.'; + addMessage('jarvis', msg); + if (typeof speak === 'function' && isVoiceActive) speak(msg); +} + +// Check for upcoming appointments (fires every 5 min after load) +let _apptAlerted = new Set(); +async function checkUpcomingAppts() { + const d = await api('planner/today').catch(() => null); + if (!d) return; + const now = Date.now(); + for (const a of (d.appts_today||[])) { + if (!a.start_at || _apptAlerted.has(a.id)) continue; + const start = new Date(a.start_at).getTime(); + const minsUntil = (start - now) / 60000; + if (minsUntil > 0 && minsUntil <= 15) { + _apptAlerted.add(a.id); + const msg = 'Reminder: ' + a.title + ' starts in ' + Math.round(minsUntil) + ' minutes' + (a.location ? ' at ' + a.location : '') + '.'; + addMessage('jarvis', msg); + if (typeof speak === 'function' && isVoiceActive) speak(msg); + } + } +} + +// ── PLANNER SUMMARY (top bar badge only) ───────────────────────────────── +async function loadPlannerSummary() { + const d = await api('planner/today'); + const el = document.getElementById('tb-planner'); + const tx = document.getElementById('tb-planner-text'); + if (el && tx) { + const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length; + const appts = (d.appts_today || []).length; + if (!tasksDue && !appts) { el.style.display = 'none'; } + else { + const parts = []; + if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : '')); + if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : '')); + tx.textContent = parts.join(' · '); + el.style.display = ''; + } + } + + // Render planner mini panel + const pEl = document.getElementById('planner-tasks'); + const badge = document.getElementById('planner-badge'); + if (!pEl) return; + + const priClass = {urgent:'pri-urgent',high:'pri-high',normal:'pri-normal',low:'pri-low'}; + const fmtTime = s => { if(!s) return ''; const d=new Date(s); return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); }; + const fmtDate = s => { if(!s) return ''; const d=new Date(s+'T00:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); }; + + const tasks = [...(d.tasks_overdue||[]).map(t=>({...t,_overdue:true})), ...(d.tasks_today||[])]; + const appts = d.appts_today || []; + + let html = ''; + if (!tasks.length && !appts.length) { + html = '
No tasks or appointments today.
'; + } else { + if (appts.length) { + html += '
TODAY\'S SCHEDULE
'; + html += appts.map(a => `
${fmtTime(a.start_at)}${a.title}${a.location?' · '+a.location+'':''}
`).join(''); + } + if (tasks.length) { + html += '
TASKS DUE
'; + html += tasks.map(t => `
${t.title}${t._overdue?'OVERDUE':''}
`).join(''); + } + if (d.pending_count > tasks.length) { + html += `
${d.pending_count} pending total
`; + } + } + pEl.innerHTML = html; + const total = tasks.length + appts.length; + if (badge) badge.textContent = total ? total + ' TODAY' : ''; +} + +// ── ALERTS ──────────────────────────────────────────────────────────── +async function loadAlerts() { + const data = await api('alerts'); + const el = document.getElementById('alerts-list'); + const tb = document.getElementById('tb-alerts'); + + const alerts = data.alerts || []; + if (!alerts.length) { + el.innerHTML = '
✓ NO ACTIVE ALERTS
'; + tb.textContent='NO ALERTS'; tb.className='text-green'; + setAlertState(false); + setSystemHealth('ok'); + return; + } + + tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':''); + tb.className='text-red'; + setAlertState(true); + const hasCritical = alerts.some(a => a.severity === 'critical'); + setSystemHealth(hasCritical ? 'critical' : 'warning'); + + el.innerHTML = alerts.map(a => { + const ctxKey = 'alert_' + a.id; + _panelCtx[ctxKey] = {type:'alert', label:a.title, + id:a.id, title:a.title, message:a.message, severity:a.severity}; + return `
+
+
${a.title}
+
${a.message}
+
+ +
`; + }).join(''); +} + +async function resolveAlert(id) { + await api('alerts/resolve', 'POST', {id}); + loadAlerts(); +} + +// ── PROACTIVE ALERT POLLING ─────────────────────────────────────────────────── +let _knownAlertIds = null; +let _spokenAlertIds = new Set(); + +async function pollAlertsProactive() { + const data = await api('alerts').catch(() => null); + if (!data) return; + const alerts = (data.alerts || []); + + if (_knownAlertIds === null) { + // First run: baseline existing alerts — do not speak them + _knownAlertIds = new Set(alerts.map(a => a.id)); + return; + } + + for (const a of alerts) { + if (_knownAlertIds.has(a.id) || _spokenAlertIds.has(a.id)) continue; + _knownAlertIds.add(a.id); + _spokenAlertIds.add(a.id); + + if (a.severity === 'critical' || a.severity === 'warning') { + const prefix = a.severity === 'critical' ? '🚨' : '⚠'; + addMessage('jarvis', `${prefix} ${a.title}: ${a.message}`); + const tts = (a.severity === 'critical' ? 'Critical alert. ' : 'Warning. ') + a.title + '. ' + a.message; + if (typeof speak === 'function' && isVoiceActive) speak(tts); + } + } + + // Remove resolved alerts from known set so they can re-trigger if they come back + const liveIds = new Set(alerts.map(a => a.id)); + for (const id of _knownAlertIds) { + if (!liveIds.has(id)) _knownAlertIds.delete(id); + } +} + +// ── WEATHER ─────────────────────────────────────────────────────────── +async function loadWeather() { + const d = await api('weather'); + if (!d || !d.current) return; + const c = d.current; + document.getElementById('weather-temp').textContent = c.temp; + document.getElementById('weather-desc').textContent = (c.desc || '').toUpperCase(); + document.getElementById('weather-feels').textContent = c.feels + '°F'; + document.getElementById('weather-humidity').textContent = c.humidity + '%'; + document.getElementById('weather-details').textContent = + 'Wind ' + c.wind + ' mph · Cloud ' + c.cloud + '% · Vis ' + c.vis + ' mi'; + + const fc = d.forecast || []; + document.getElementById('weather-forecast').innerHTML = fc.slice(0, 4).map(day => ` +
+
${day.day}
+
${day.icon}
+
${day.high}°${day.low}°
+
${day.rain_pct > 0 ? day.rain_pct+'%' : ''}
+
`).join(''); +} + +// ── NEWS ────────────────────────────────────────────────────────────── +function getNewsHidden() { + try { return JSON.parse(localStorage.getItem('news_hidden_cats') || '[]'); } catch(e) { return []; } +} +function setNewsHidden(arr) { localStorage.setItem('news_hidden_cats', JSON.stringify(arr)); } + +function toggleNewsFilter() { + const panel = document.getElementById('news-filter-panel'); + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; +} + +let _newsCats = []; +async function loadNews() { + const d = await api('news'); + const el = document.getElementById('news-list'); + if (!d || !d.categories || Object.keys(d.categories).length === 0) { + el.innerHTML = '
News loading...
'; + return; + } + const catLabels = { headlines: '📰 TOP HEADLINES', technology: '💻 TECHNOLOGY', pinned: '📌 JARVIS PINNED' }; + const hidden = getNewsHidden(); + _newsCats = Object.keys(d.categories); + + // Build filter checkboxes + const cbContainer = document.getElementById('news-filter-checkboxes'); + if (cbContainer) { + cbContainer.innerHTML = _newsCats.map(cat => ` + `).join(''); + } + + let html = ''; + for (const [cat, articles] of Object.entries(d.categories)) { + if (!articles.length || hidden.includes(cat)) continue; + html += `
${catLabels[cat] || cat.toUpperCase()}
`; + for (const a of articles.slice(0, 5)) { + const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30); + _panelCtx[ctxKey] = {type:'news', label:a.title, + title:a.title, source:a.source, pub:a.pub||'', category:cat}; + const titleDisplay = a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title; + html += `
+
${escHtml(a.source)}
+
${escHtml(titleDisplay)}
+ ${a.pub ? '
' + escHtml(a.pub) + '
' : ''} +
`; + } + } + if (!html) html = '
All categories hidden — use ⚙ to show sources
'; + const ageMin = d.cache_age_s > 0 ? Math.round(d.cache_age_s/60) : 0; + html += `
Updated ${ageMin}m ago
`; + el.innerHTML = html; +} + +function toggleNewsCat(cat, show) { + const hidden = getNewsHidden(); + if (show) { + const idx = hidden.indexOf(cat); + if (idx > -1) hidden.splice(idx, 1); + } else { + if (!hidden.includes(cat)) hidden.push(cat); + } + setNewsHidden(hidden); + loadNews(); +} + +// ── TABS ────────────────────────────────────────────────────────────── +function switchTab(name) { + if (name === 'sites') { openSitesModal(); return; } + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + event.target.classList.add('active'); + const pane = document.getElementById('tab-'+name); + if (pane) pane.classList.add('active'); + if (name === 'news') loadNews(); + if (name === 'agents') loadAgents(); + if (name === 'intel') loadIntel(); + if (name === 'comms') { loadComms(); loadCommsOutbox(); } + if (name === 'guardian') loadGuardian(); + if (name === 'missions') loadMissionsHud(); + if (name === 'directives') loadDirectivesHud(); + if (name === 'clearance') loadClearanceHud(); + if (name === 'alerts') loadAlerts(); +} + +// ── CHAT ────────────────────────────────────────────────────────────── +function sourceBadge(source) { + if (!source) return ''; + let cls, label; + if (/^intent:|^planner:|^kb:/.test(source)) { cls = 'kb'; label = 'KB'; } + else if (/^groq:/.test(source)) { cls = 'groq'; label = 'GROQ'; } + else if (source === 'claude' || /^claude/.test(source)) { cls = 'claude'; label = 'CLAUDE'; } + else if (/^ollama/.test(source)) { cls = 'ollama'; label = 'LOCAL AI'; } + else return ''; + const s = document.createElement('div'); + s.style.cssText = 'margin-top:4px;text-align:right'; + s.innerHTML = `${label}`; + return s; +} + +function addMessage(role, text, source=null) { + const log = document.getElementById('chatLog'); + const div = document.createElement('div'); + div.className = 'msg ' + role; + log.appendChild(div); + + if (role === 'jarvis' && text && text.length > 0) { + // Adaptive speed: fast for short, slower for long (feels intentional either way) + const msPerChar = Math.max(9, Math.min(25, 1600 / text.length)); + const cursor = document.createElement('span'); + cursor.className = 'type-cursor'; + div.appendChild(cursor); + let i = 0; + const type = () => { + if (i < text.length) { + cursor.insertAdjacentText('beforebegin', text[i++]); + log.scrollTop = log.scrollHeight; + setTimeout(type, msPerChar + (text[i-1] === '.' || text[i-1] === ',' ? msPerChar * 4 : 0)); + } else { + cursor.remove(); + const badge = sourceBadge(source); + if (badge) div.appendChild(badge); + } + }; + setTimeout(type, 0); + } else { + div.textContent = text; + } + + log.scrollTop = log.scrollHeight; + return div; +} + +function showThinking() { + const log = document.getElementById('chatLog'); + const div = document.createElement('div'); + div.className = 'msg jarvis'; + div.innerHTML = '
'; + div.id = 'thinking-bubble'; + log.appendChild(div); + log.scrollTop = log.scrollHeight; +} + +// ── PANEL CONTEXT SELECTION ─────────────────────────────────────────── +function selectContext(key) { + const ctx = _panelCtx[key]; + if (!ctx) return; + + // Clear previous active highlight + document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active')); + + selectedContext = ctx; + + // Highlight clicked element + const el = document.querySelector('[data-ctx-key="' + key + '"]'); + if (el) el.classList.add('ctx-active'); + + // Show chip + const chip = document.getElementById('contextChip'); + const typeLabels = {vm:'VM', network:'DEVICE', alert:'ALERT', news:'NEWS', ha:'HOME'}; + document.getElementById('contextType').textContent = typeLabels[ctx.type] || ctx.type.toUpperCase(); + document.getElementById('contextLabel').textContent = ctx.label; + chip.classList.add('visible'); + + // Focus input for immediate question + document.getElementById('textInput').focus(); +} + +function clearContext() { + selectedContext = null; + document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active')); + const chip = document.getElementById('contextChip'); + chip.classList.remove('visible'); +} + +async function sendMessage() { + const input = document.getElementById('textInput'); + const text = input.value.trim(); + if (!text) return; + + var t2 = text.toLowerCase(); + + if (SLEEP_CMDS.test(t2)) { input.value=''; addMessage('user',text); enterSleepMode(); return; } + + if (NM_OPEN_RE.test(t2)) { + input.value=''; addMessage('user',text); + addMessage('jarvis','Launching network topology display.'); + speak('Launching network topology display.'); openNetMap(); return; + } + if (NM_CLOSE_RE.test(t2)) { + input.value=''; addMessage('user',text); + var isOpen=document.getElementById('netMapOverlay')?.classList.contains('nm-open'); + if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');} + else addMessage('jarvis','Network map is not currently active.'); + return; + } + input.value = ''; + addMessage('user', text); + showThinking(); + _abortController = new AbortController(); + + try { + const payload = {message:text, session_id:sessionId, stream:true}; + if (selectedContext) { payload.context = selectedContext; clearContext(); } + + const resp = await fetch('/api/chat', { + method: 'POST', + headers: {'Content-Type':'application/json','X-Session-Token':sessionToken}, + body: JSON.stringify(payload), + signal: _abortController.signal, + credentials: 'include', + }); + _abortController = null; + + if (resp.status === 401) { logout(); return; } + + const ct = resp.headers.get('Content-Type') || ''; + + if (ct.includes('text/event-stream')) { + // ── Streaming path (Groq LLM with token-by-token delivery) ────── + const bubble = document.getElementById('thinking-bubble'); + if (bubble) bubble.remove(); + let msgEl = null, accum = ''; + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let lineBuf = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) break; + lineBuf += dec.decode(value, {stream:true}); + const lines = lineBuf.split('\n'); + lineBuf = lines.pop(); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + let ev; try { ev = JSON.parse(line.slice(6)); } catch { continue; } + if (ev.type === 'token') { + accum += ev.token; + if (!msgEl) msgEl = _addStreamingMsg(accum); + else _updateStreamingMsg(msgEl, accum); + } else if (ev.type === 'complete') { + const finalText = ev.reply || accum; + if (msgEl) _finalizeStreamingMsg(msgEl, finalText, ev.source); + else addMessage('jarvis', finalText, ev.source); + speak(finalText); + if (ev.open_network_map) openNetMap(); + if (ev.ui_action === 'focus_mode' && panelsVisible) togglePanels(true); + if (ev.ui_action === 'show_panels' && !panelsVisible) togglePanels(true); + if (ev.arc_job) onArcJobStarted(ev.arc_job, ev.source||''); + } + } + } + } else { + // ── Regular JSON path (intent/KB — near-instant) ──────────────── + const data = await resp.json(); + const bubble = document.getElementById('thinking-bubble'); + if (bubble) bubble.remove(); + if (data.reply) { addMessage('jarvis', data.reply, data.source||null); speak(data.reply); } + if (data.open_network_map) openNetMap(); + if (data.ui_action === 'focus_mode' && panelsVisible) togglePanels(true); + if (data.ui_action === 'show_panels' && !panelsVisible) togglePanels(true); + if (data.arc_job) onArcJobStarted(data.arc_job, data.source||''); + } + } catch(e) { + _abortController = null; + const bubble = document.getElementById('thinking-bubble'); + if (bubble) bubble.remove(); + if (e.name === 'AbortError') addMessage('jarvis', 'Request cancelled, Sir.'); + else addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.'); + } +} + +function _addStreamingMsg(text) { + const log = document.getElementById('chatLog'); + const div = document.createElement('div'); + div.className = 'msg jarvis streaming'; + div.id = 'streaming-bubble'; + div.textContent = text; + log.appendChild(div); + log.scrollTop = log.scrollHeight; + return div; +} +function _updateStreamingMsg(el, text) { + if (!el) return; + el.textContent = text; + const log = document.getElementById('chatLog'); + if (log) log.scrollTop = log.scrollHeight; +} +function _finalizeStreamingMsg(el, text, source) { + if (!el) return; + el.id = ''; el.classList.remove('streaming'); + el.textContent = text; + if (source) { + const s = document.createElement('div'); + s.className = 'msg-source'; s.textContent = source; + el.appendChild(s); + } +} +function cancelRequest() { + if (_abortController) { _abortController.abort(); _abortController = null; } +} + +// ── VOICE RECOGNITION ───────────────────────────────────────────────── +function initVoice() { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SR) { + if (window.isSecureContext === false) { + console.warn('Speech Recognition blocked: not a secure context'); + } else { + console.warn('Speech Recognition not supported in this browser'); + } + return; + } + recognition = new SR(); + recognition.continuous = false; // restart-per-utterance — most reliable in Chrome + recognition.interimResults = false; + recognition.lang = 'en-US'; + recognition.maxAlternatives = 1; + + recognition.onresult = (e) => { + if (isSpeaking) return; + let interimText = ''; + for (let ri = e.resultIndex; ri < e.results.length; ri++) { + if (!e.results[ri].isFinal) interimText += e.results[ri][0].transcript; + } + if (interimText && voiceMode && !voiceMuted) _showInterimTranscript(interimText); + const transcript = (e.results[0][0].transcript || '').trim(); + if (!transcript) return; + const lc = transcript.toLowerCase(); + + // Sleeping: ONLY respond to master wake phrases + if (isAsleep) { + if (WAKE_PHRASES.some(p => lc.includes(p))) wakeFromSleep(); + return; + } + + if (!voiceMode) { + if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode(); + } else if (!voiceMuted) { + voiceLastCmd = Date.now(); + voiceActive = Date.now(); + const cmd = lc.startsWith(CMD_PREFIX) + ? transcript.substring(CMD_PREFIX.length).trim() + : transcript; + if (cmd) { + // Check for sleep command by voice + if (SLEEP_CMDS.test(cmd)) { + addMessage('user', transcript); + enterSleepMode(); + return; + } + _showTranscript(cmd); + document.getElementById('textInput').value = cmd; + sendMessage(); + } + } + }; + + recognition.onend = () => { + // Restart immediately unless TTS is playing or mic is off + if (isListening && !isSpeaking) { + _scheduleRecStart(100); + } + }; + + recognition.onerror = (e) => { + if (e.error === 'not-allowed') { + isListening = false; + updateMicBtn(); + addMessage('system', 'Microphone access denied. Please allow microphone permission in your browser, then reload.'); + } else if (e.error === 'audio-capture') { + isListening = false; + updateMicBtn(); + addMessage('system', 'No microphone detected. Please connect a microphone and try again.'); + } + // no-speech / aborted / network: onend will fire and restart + }; +} + +let _transcriptTimer = null; +function _showTranscript(text) { + const el = document.getElementById('textInput'); + if (el) { el.placeholder = '▶ ' + text.substring(0, 60); setTimeout(() => { el.placeholder = 'Enter command or speak to JARVIS...'; }, 3000); } + const bar = document.getElementById('voiceTranscriptBar'); + if (!bar) return; + bar.textContent = text; + bar.classList.add('vt-active'); + if (_transcriptTimer) clearTimeout(_transcriptTimer); + _transcriptTimer = setTimeout(() => { bar.classList.remove('vt-active'); bar.textContent = ''; }, 3200); +} +function _showInterimTranscript(text) { + const bar = document.getElementById('voiceTranscriptBar'); + if (!bar || !text) return; + bar.textContent = text + '…'; + bar.classList.add('vt-active'); + if (_transcriptTimer) clearTimeout(_transcriptTimer); +} + +function enterVoiceMode(source) { + voiceMode = true; + voiceMuted = false; + voiceLastCmd = Date.now(); + voiceActive = Date.now(); + updateMicBtn(); + // Focus/notify when woken from minimized or sleep + _focusWindow(); + if (source === 'wake') { + const g = 'All systems back online, ' + (sessionUser || 'Sir') + '. Good to have you back.'; + addMessage('jarvis', g); + speak(g); + } else { + speak('Yes, ' + (sessionUser || 'Sir') + '?'); + } +} + +function exitVoiceMode() { + if (document.body.classList.contains('kiosk-mode')) return; + voiceMode = false; + voiceMuted = false; + updateMicBtn(); +} + +function updateMicBtn() { + const btn = document.getElementById('micBtn'); + const icon = document.getElementById('micIcon'); + const wave = document.getElementById('waveform'); + if (!btn) return; + if (!voiceMode) { + btn.classList.remove('listening', 'muted'); + btn.title = 'Click to activate, or say: wake up JARVIS / daddy\'s home'; + icon.textContent = '🎤'; + wave.classList.remove('active'); + } else if (voiceMuted) { + btn.classList.remove('listening'); + btn.classList.add('muted'); + btn.title = 'Muted — click to unmute'; + icon.textContent = '🔇'; + wave.classList.remove('active'); + } else { + btn.classList.add('listening'); + btn.classList.remove('muted'); + btn.title = 'Listening — click to mute'; + icon.textContent = '🟢'; + wave.classList.add('active'); + } +} + +function toggleVoice() { + if (!voiceMode) { + enterVoiceMode(); + } else { + voiceMuted = !voiceMuted; + if (!voiceMuted) voiceLastCmd = Date.now(); + updateMicBtn(); + } +} + +let _recTimer = null; +function _scheduleRecStart(ms = 100) { + clearTimeout(_recTimer); + _recTimer = setTimeout(() => { + if (isListening && !isSpeaking) { + try { recognition.start(); } catch(_) {} + } + }, ms); +} + +function startListening() { + if (!recognition) { + if (!window.isSecureContext) { + addMessage('system', 'Voice recognition requires a trusted HTTPS connection. Please access JARVIS via https://jarvis.orbishosting.com for voice support.'); + } else { + addMessage('system', 'Voice recognition requires Chrome or Edge browser.'); + } + return; + } + isListening = true; + _startWaveform(); + _scheduleRecStart(50); +} + +function stopListening() { + isListening = false; + voiceMode = false; + voiceMuted = false; + updateMicBtn(); + clearTimeout(_recTimer); + _stopWaveform(); + try { recognition.abort(); } catch(_) {} +} + +// ── VOICE WAVEFORM (Web Audio API) ────────────────────────────────────────── +async function _startWaveform() { + if (_waveAudioCtx) return; + try { + _waveStream = await navigator.mediaDevices.getUserMedia({audio:true, video:false}); + _waveAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); + _waveAnalyser = _waveAudioCtx.createAnalyser(); + _waveAnalyser.fftSize = 32; + _waveAudioCtx.createMediaStreamSource(_waveStream).connect(_waveAnalyser); + const bars = document.querySelectorAll('#waveform .wave-bar'); + bars.forEach(b => b.classList.add('live')); + const buf = new Uint8Array(_waveAnalyser.frequencyBinCount); + (function drawWave() { + _waveRafId = requestAnimationFrame(drawWave); + _waveAnalyser.getByteFrequencyData(buf); + bars.forEach((bar, i) => { + const v = (buf[i % buf.length] || 0) / 255; + bar.style.height = (4 + Math.round(v * 20)) + 'px'; + }); + })(); + } catch(_) { /* mic permission denied — CSS animation continues */ } +} +function _stopWaveform() { + if (_waveRafId) { cancelAnimationFrame(_waveRafId); _waveRafId = null; } + if (_waveStream) { _waveStream.getTracks().forEach(t => t.stop()); _waveStream = null; } + if (_waveAudioCtx) { _waveAudioCtx.close().catch(()=>{}); _waveAudioCtx = null; } + _waveAnalyser = null; + document.querySelectorAll('#waveform .wave-bar').forEach(b => { + b.classList.remove('live'); b.style.height = ''; + }); +} + +// ── SPEECH SYNTHESIS ────────────────────────────────────────────────── +function loadVoices() { + const set = () => { + const voices = synth.getVoices(); + // Priority: Australian male → Australian → British male → British → any English + selectedVoice = + voices.find(v => v.name === 'Nathan') // macOS Australian male + || voices.find(v => v.name === 'Google Australian English') // Chrome Australian + || voices.find(v => v.name === 'Karen') // macOS Australian female + || voices.find(v => v.lang === 'en-AU') // any Australian + || voices.find(v => v.name === 'Daniel') // macOS British male + || voices.find(v => v.name === 'Google UK English Male') // Chrome British male + || voices.find(v => v.lang === 'en-GB') // any British + || voices.find(v => v.lang.startsWith('en')) // any English + || voices[0] + || null; + }; + set(); + synth.onvoiceschanged = set; +} + +let _ttsAudio = null; +let _abortController = null; +let _waveAudioCtx = null; +let _waveAnalyser = null; +let _waveStream = null; +let _waveRafId = null; + +async function speak(text) { + if (!text) return; + if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; } + synth?.cancel(); + isSpeaking = true; + // Pause recognition while JARVIS speaks to avoid mic feedback + try { recognition?.abort(); } catch(_) {} + const reactor = document.getElementById('arcReactor'); + reactor?.classList.add('speaking'); + const _resumeMic = () => { + isSpeaking = false; + reactor?.classList.remove('speaking'); + // onend will fire from the abort we did before TTS, and restart cleanly + if (isListening) _scheduleRecStart(900); + }; + try { + const res = await fetch('/api/tts', { + method: 'POST', + headers: {'Content-Type':'application/json','X-Session-Token': sessionToken}, + body: JSON.stringify({text: text.substring(0, 400)}), + }); + if (!res.ok) throw new Error('tts'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + _ttsAudio = new Audio(url); + _ttsAudio.onended = () => { URL.revokeObjectURL(url); _ttsAudio = null; _resumeMic(); }; + _ttsAudio.onerror = () => { _ttsAudio = null; _resumeMic(); }; + await _ttsAudio.play(); + } catch(e) { + _resumeMic(); + _speakFallback(text); + } +} + +function _speakFallback(text) { + if (!synth || !text) return; + synth.cancel(); + isSpeaking = true; + const utter = new SpeechSynthesisUtterance(text); + if (selectedVoice) utter.voice = selectedVoice; + utter.rate = 0.92; utter.pitch = 0.85; utter.volume = 1; + const reactor = document.getElementById('arcReactor'); + utter.onstart = () => reactor?.classList.add('speaking'); + utter.onend = () => { + reactor?.classList.remove('speaking'); + isSpeaking = false; + if (isListening) _scheduleRecStart(900); + }; + synth.speak(utter); +} + +// ── AGENT DETECTION & BROWSER INSTALL ───────────────────────────────── +let _agentOnline = false; +let _myAgent = null; + +function detectOS() { + const ua = navigator.userAgent; + const p = (navigator.platform || '').toLowerCase(); + // Tablets — check before desktop OS (iPads spoof MacIntel) + if (/iPad|Android/.test(ua) || (p.includes('mac') && navigator.maxTouchPoints > 1)) return 'tablet'; + if (/iPhone/.test(ua)) return 'tablet'; + if (p.includes('win') || ua.includes('Windows')) return 'windows'; + if (p.includes('mac') || ua.includes('Macintosh')) return 'mac'; + if (p.includes('linux') || ua.includes('Linux')) return 'linux'; + return 'unknown'; +} + +async function checkAgentStatus() { + const dot = document.getElementById('bb-agent-dot'); + const sta = document.getElementById('bb-agent-status'); + const btn = document.getElementById('agentBtn'); + if (!dot || !sta) return; + try { + const data = await api('agent/list'); + const agents = data.agents || []; + const online = agents.filter(a => a.status === 'online'); + dot.className = 'bb-dot ' + (online.length > 0 ? 'online' : 'offline'); + sta.textContent = online.length > 0 ? online.length + ' ONLINE' : 'NONE'; + const cnt = document.getElementById('net-agent-count'); + if (cnt) cnt.textContent = online.length + ' AGENT' + (online.length !== 1 ? 'S' : '') + ' ONLINE'; + const myIp = data.my_ip || ''; + // Match by exact IP first, then by same /24 subnet (handles NAT behind same router) + const mySubnet = myIp.split('.').slice(0,3).join('.'); + _myAgent = online.find(a => a.ip_address === myIp) + || online.find(a => a.ip_address && a.ip_address.startsWith(mySubnet + '.')); + _agentOnline = !!_myAgent; + if (btn) { + const isTablet = detectOS() === 'tablet'; + if (isTablet) { + btn.title = 'JARVIS Agent — not available for tablets'; + btn.style.opacity = '0.5'; + } else if (_agentOnline) { + btn.classList.add('agent-online'); + btn.title = 'Agent active: ' + _myAgent.hostname; + } else { + btn.classList.remove('agent-online'); + btn.title = 'Click to install JARVIS Agent on this machine'; + } + } + // Also refresh the AGENTS tab if it's visible + if (document.getElementById('tab-agents').classList.contains('active')) { + renderAgentsTab(agents, data.metrics || {}); + } + } catch(e) { + if (dot) dot.className = 'bb-dot offline'; + if (sta) sta.textContent = 'ERROR'; + } +} + +// ── SMART MORNING BRIEFING ───────────────────────────────────────────────── +async function triggerMorningBriefing() { + try { + const [planner, alerts, weather] = await Promise.all([ + api('planner/today').catch(() => null), + api('alerts').catch(() => null), + api('weather').catch(() => null), + ]); + + const tasks = (planner?.tasks || []).filter(t => t.status !== 'done'); + const appts = planner?.appointments || []; + const active = (alerts?.alerts || alerts || []).filter(a => + a.severity === 'critical' || a.severity === 'warning'); + const temp = weather?.current?.temp_f ?? weather?.current?.temp ?? null; + const cond = weather?.current?.condition?.text ?? weather?.current?.description ?? null; + + const parts = []; + if (tasks.length > 0) parts.push(`${tasks.length} task${tasks.length > 1 ? 's' : ''} due today`); + if (appts.length > 0) parts.push(`${appts.length} appointment${appts.length > 1 ? 's' : ''} on the calendar`); + if (active.length > 0) parts.push(`${active.length} active alert${active.length > 1 ? 's' : ''} requiring attention`); + if (temp !== null) parts.push(`currently ${Math.round(temp)}°${cond ? ' and ' + cond.toLowerCase() : ''}`); + + const name = sessionUser || 'sir'; + const msg = parts.length > 0 + ? `Good morning, ${name}. ${parts.join(', ')}. Systems nominal — ready when you are.` + : `Good morning, ${name}. No tasks or alerts today — clear skies ahead. All systems nominal.`; + + addMessage('jarvis', msg); + speak(msg); + } catch(e) {} +} + +// ── ACCENT COLOR THEMES ─────────────────────────────────────────────────────── +const _THEMES = { + 'stark-blue': {'--cyan':'#00d4ff','--cyan2':'#00a8cc','--cyan3':'rgba(0,212,255,0.15)'}, + 'widow-red': {'--cyan':'#ff3366','--cyan2':'#cc1a44','--cyan3':'rgba(255,51,102,0.15)'}, + 'hulk-green': {'--cyan':'#39ff14','--cyan2':'#27b30d','--cyan3':'rgba(57,255,20,0.15)'}, +}; +function applyTheme(name) { + const t = _THEMES[name]; if (!t) return; + const root = document.documentElement; + Object.entries(t).forEach(([k,v]) => root.style.setProperty(k, v)); + localStorage.setItem('jarvis_theme', name); + document.querySelectorAll('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === name)); +} +// Apply saved theme on load +(function() { + const saved = localStorage.getItem('jarvis_theme'); + if (saved && saved !== 'stark-blue') setTimeout(() => applyTheme(saved), 50); +})(); + +// ── QUICK-NOTE CAPTURE ──────────────────────────────────────────────────────── +function openQuickNote() { + const bar = document.getElementById('quickNoteBar'); + if (!bar) return; + bar.classList.add('open'); + setTimeout(() => document.getElementById('quickNoteInput')?.focus(), 50); +} +function closeQuickNote() { + const bar = document.getElementById('quickNoteBar'); + if (bar) bar.classList.remove('open'); + const inp = document.getElementById('quickNoteInput'); + if (inp) inp.value = ''; +} +async function saveQuickNote() { + const inp = document.getElementById('quickNoteInput'); + if (!inp || !inp.value.trim()) { closeQuickNote(); return; } + const note = inp.value.trim(); + closeQuickNote(); + try { + await api('chat', 'POST', {message: 'note: ' + note, session_id: sessionId}); + addMessage('jarvis', 'Note saved to Memory Core, Sir: "' + note + '"'); + } catch(_) {} +} +function handleNoteKey(e) { + if (e.key === 'Enter') { e.preventDefault(); saveQuickNote(); } + else if (e.key === 'Escape') { e.stopPropagation(); closeQuickNote(); } +} + +// ── KEYBOARD SHORTCUTS ─────────────────────────────────────────────────────────────── +document.addEventListener('keydown', function(e) { + const tag = (document.activeElement?.tagName || '').toLowerCase(); + const inInput = tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable; + if ((e.ctrlKey || e.metaKey) && e.key === 'k') return; // handled by palette + if (e.key === 'Escape') { + ['sitesModal','agentModal','searchModal'].forEach(id => { + const el = document.getElementById(id); + if (el && (el.style.display === 'flex' || el.style.display === 'block')) el.style.display = 'none'; + }); + if (document.getElementById('netMapOverlay')?.classList.contains('nm-open')) closeNetMap(); + if (document.getElementById('quickNoteBar')?.classList.contains('open')) closeQuickNote(); + return; + } + if (inInput) return; + if (e.key === 'F5') { e.preventDefault(); refreshAll(); return; } + if (e.key === 'm' || e.key === 'M') { toggleVoice(); return; } + if (e.key === 'n' || e.key === 'N') { openQuickNote(); return; } + if (e.key === ' ') { e.preventDefault(); document.getElementById('textInput')?.focus(); return; } + const tabMap = {'1':'ha','2':'alerts','3':'news','4':'agents'}; + if (tabMap[e.key]) { + document.querySelectorAll('.tab').forEach(t => { + const oc = t.getAttribute('onclick') || ''; + if (oc.includes("'" + tabMap[e.key] + "'")) t.click(); + }); + } +}); + + +// ── FIRE HD 8 TABLET DETECTION ──────────────────────────────────────────────────────── +const IS_SILK = /Silk\//i.test(navigator.userAgent); +const IS_FIRE = /KFTT|KFOT|KFJWI|KFSOWI|KFTHWI|KFTHWA|KFAPWI|KFAPWA|KFARWI|KFASWI|KFMEWI|KFFOWI|KFSAWA|KFMAWI|KFGIWI|KFDOWI|KFTBWI|KFTRWI|KFKAWI/i.test(navigator.userAgent); +function isTablet() { return IS_SILK || IS_FIRE; } + +function applyTabletMode() { + document.body.classList.add("tablet-mode"); + const kb = document.getElementById("kioskBtn"); + if (kb) kb.title = "Full-screen kiosk (Fire HD 8 layout active)"; +} +if (isTablet()) applyTabletMode(); + +// ── KIOSK MODE ──────────────────────────────────────────────────────────────────────── +let _wakeLock = null; + +async function toggleKiosk() { + const btn = document.getElementById("kioskBtn"); + const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement); + + if (!isFs) { + applyTabletMode(); + const el = document.documentElement; + const req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen; + if (req) req.call(el).catch(() => {}); + if ("wakeLock" in navigator) { + try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {} + } + document.body.classList.add("kiosk-mode"); + // Kiosk: silently activate mic + voice mode (no TTS greeting) + if (typeof wakeFromSleep === "function" && isAsleep) wakeFromSleep(); + voiceMode = true; voiceMuted = false; voiceLastCmd = Date.now(); updateMicBtn(); + if (typeof startListening === "function" && !isListening) startListening(); + if (btn) { btn.textContent = "⧞ EXIT"; btn.style.color = "var(--cyan)"; } + } else { + const ex = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen; + if (ex) ex.call(document).catch(() => {}); + if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; } + document.body.classList.remove("kiosk-mode"); + if (typeof stopListening === "function") stopListening(); + if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; } + if (!isTablet()) document.body.classList.remove("tablet-mode"); + } +} + +document.addEventListener("visibilitychange", async () => { + if (_wakeLock && document.visibilityState === "visible") { + try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {} + } +}); + +function _onFsChange() { + const btn = document.getElementById("kioskBtn"); + if (!document.fullscreenElement && !document.webkitFullscreenElement) { + if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; } + document.body.classList.remove("kiosk-mode"); + if (typeof stopListening === "function") stopListening(); + if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; } + if (!isTablet()) document.body.classList.remove("tablet-mode"); + } +} +document.addEventListener("fullscreenchange", _onFsChange); +document.addEventListener("webkitfullscreenchange", _onFsChange); diff --git a/public_html/assets/js/jarvis-effects.js b/public_html/assets/js/jarvis-effects.js new file mode 100644 index 0000000..67bea88 --- /dev/null +++ b/public_html/assets/js/jarvis-effects.js @@ -0,0 +1,590 @@ +// ── PARTICLE CANVAS ─────────────────────────────────────────────────── +(function initParticles() { + const canvas = document.getElementById('particleCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const N = 65; + const CONNECT_DIST = 130; + let W, H, particles = []; + + function resize() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; + } + + function spawn() { + particles = []; + for (let i = 0; i < N; i++) { + particles.push({ + x: Math.random() * W, + y: Math.random() * H, + vx: (Math.random() - 0.5) * 0.25, + vy: (Math.random() - 0.5) * 0.25, + r: Math.random() * 1.2 + 0.4, + a: Math.random() * 0.35 + 0.08, + }); + } + } + + function draw() { + ctx.clearRect(0, 0, W, H); + for (let i = 0; i < N; i++) { + const p = particles[i]; + for (let j = i + 1; j < N; j++) { + const q = particles[j]; + const dx = p.x - q.x, dy = p.y - q.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d < CONNECT_DIST) { + ctx.strokeStyle = `rgba(0,180,255,${0.09 * (1 - d / CONNECT_DIST)})`; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); + } + } + ctx.fillStyle = `rgba(0,200,255,${p.a})`; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); + p.x += p.vx; p.y += p.vy; + if (p.x < 0) p.x = W; if (p.x > W) p.x = 0; + if (p.y < 0) p.y = H; if (p.y > H) p.y = 0; + } + requestAnimationFrame(draw); + } + + resize(); spawn(); draw(); + window.addEventListener('resize', () => { resize(); spawn(); }); +})(); + +// ── PANEL FLOAT STAGGER — different phase per panel ─────────────────── +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.panel').forEach((p, i) => { + p.style.animationDelay = `-${(i * 1.37).toFixed(2)}s`; + }); +}); + +// ── MOUSE PARALLAX — panels tilt toward cursor ──────────────────────── +(function initParallax() { + const MAX_TILT = 3.5; // degrees + let mouseX = 0, mouseY = 0, raf = null; + + window.addEventListener('mousemove', e => { + mouseX = e.clientX / window.innerWidth - 0.5; // -0.5 to 0.5 + mouseY = e.clientY / window.innerHeight - 0.5; + if (!raf) raf = requestAnimationFrame(applyTilt); + }); + + function applyTilt() { + raf = null; + const rx = mouseY * MAX_TILT; + const ry = -mouseX * MAX_TILT; + document.querySelectorAll('.panel').forEach(p => { + p.style.setProperty('--prx', rx.toFixed(2) + 'deg'); + p.style.setProperty('--pry', ry.toFixed(2) + 'deg'); + }); + // Column-level parallax (skip in focus mode) + if (!document.getElementById('mainLayout')?.classList.contains('focus-mode')) { + const lp = document.getElementById('leftPanel'); + const rp = document.getElementById('rightPanel'); + const cp = document.getElementById('centerPanel'); + if (lp) lp.style.transform = `translateX(${mouseX * 5}px) translateY(${mouseY * 3}px)`; + if (rp) rp.style.transform = `translateX(${-mouseX * 5}px) translateY(${mouseY * 3}px)`; + if (cp) cp.style.transform = `translateX(${mouseX * 2}px)`; + } + } +})(); + +// ── SPARKLINES ──────────────────────────────────────────────────────── +const _sparkData = {cpu: [], mem: [], disk: []}; +const SPARK_MAX = 25; + +function pushSparkData(key, val) { + _sparkData[key].push(val); + if (_sparkData[key].length > SPARK_MAX) _sparkData[key].shift(); +} + +function drawSparkline(canvasId, data, color) { + const canvas = document.getElementById(canvasId); + if (!canvas || !data.length) return; + const wrap = canvas.parentElement; + canvas.width = wrap.clientWidth || 240; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const W = canvas.width, H = canvas.height; + const min = 0, max = 100; + const step = W / (SPARK_MAX - 1); + + // Fill area under line + const grad = ctx.createLinearGradient(0, 0, 0, H); + grad.addColorStop(0, color.replace(')', ',0.35)').replace('rgb','rgba')); + grad.addColorStop(1, color.replace(')', ',0)').replace('rgb','rgba')); + ctx.beginPath(); + data.forEach((v, i) => { + const x = i * step; + const y = H - ((v - min) / (max - min)) * H; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.lineTo((data.length - 1) * step, H); + ctx.lineTo(0, H); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + + // Line + ctx.beginPath(); + data.forEach((v, i) => { + const x = i * step; + const y = H - ((v - min) / (max - min)) * H; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Current value dot + if (data.length > 1) { + const last = data[data.length - 1]; + const x = (data.length - 1) * step; + const y = H - ((last - min) / (max - min)) * H; + ctx.beginPath(); + ctx.arc(x, y, 2.5, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 1; + ctx.stroke(); + } +} + +// ── PANEL DATA FLASH ────────────────────────────────────────────────── +function flashPanel(panelEl) { + if (!panelEl) return; + panelEl.classList.remove('data-flash'); + void panelEl.offsetWidth; // reflow to restart animation + panelEl.classList.add('data-flash'); + setTimeout(() => panelEl.classList.remove('data-flash'), 600); +} + +// ── ALERT PULSE ─────────────────────────────────────────────────────── +function setAlertState(hasAlerts) { + const ov = document.getElementById('alertOverlay'); + if (ov) ov.style.display = hasAlerts ? 'block' : 'none'; + const vg = document.getElementById('vignetteOverlay'); + if (vg) vg.classList.toggle('alert-vignette', hasAlerts); +} + +function setSystemHealth(level) { + // level: 'ok' | 'warning' | 'critical' + const reactor = document.getElementById('arcReactor'); + if (!reactor) return; + reactor.classList.remove('health-warning', 'health-critical'); + if (level === 'warning') reactor.classList.add('health-warning'); + if (level === 'critical') reactor.classList.add('health-critical'); + // Also update topbar logo dot + const dot = document.querySelector('.tb-logo-dot'); + if (dot) { + dot.style.background = level === 'critical' ? 'var(--red)' : level === 'warning' ? '#f5a623' : 'var(--cyan)'; + dot.style.boxShadow = level === 'critical' ? '0 0 8px var(--red)' : level === 'warning' ? '0 0 8px #f5a623' : '0 0 8px var(--cyan)'; + } +} + +// ── FACE TRACKING — reactor follows face position ───────────────────── +let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5 +let _faceCurrX = 0, _faceCurrY = 0; +let _faceVisible = false; +let _faceTrackRaf = null; +const FACE_MAX_X = 48; // max px left/right travel +const FACE_MAX_Y = 10; // max px up/down travel +const FACE_LERP = 0.07; // smoothing (lower = slower/smoother) + +function _faceTrackLoop() { + _faceTrackRaf = requestAnimationFrame(_faceTrackLoop); + + // Lerp toward target (or back to 0 when no face) + const targetX = _faceVisible ? _faceTargetX : 0; + const targetY = _faceVisible ? _faceTargetY : 0; + _faceCurrX += (targetX - _faceCurrX) * FACE_LERP; + _faceCurrY += (targetY - _faceCurrY) * FACE_LERP; + + const tx = _faceCurrX * FACE_MAX_X; + const ty = _faceCurrY * FACE_MAX_Y; + + const logo = document.querySelector('.tb-logo'); + if (logo) logo.style.transform = `translateX(${tx.toFixed(2)}px) translateY(${ty.toFixed(2)}px)`; +} + +function updateFaceTarget(box, videoW, videoH) { + // Face center, normalized — flip X because front camera is mirrored + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + _faceTargetX = 0.5 - (cx / videoW); // flipped: move right when face is left + _faceTargetY = (cy / videoH) - 0.5; // positive = face low = logo drops slightly + _faceVisible = true; + + // Position the scan overlay over the detected face in the viewport. + // The video feed is 320×240 but hidden; map to viewport coords. + const scaleX = window.innerWidth / videoW; + const scaleY = window.innerHeight / videoH; + const faceVx = box.x * scaleX; + const faceVy = box.y * scaleY; + const faceVw = box.width * scaleX; + const faceVh = box.height * scaleY; + const ov = document.getElementById('faceScanOverlay'); + if (ov) { + ov.style.display = 'block'; + // Center overlay on face, flipped X to match mirror + const ovX = window.innerWidth - faceVx - faceVw / 2 - 30; + const ovY = faceVy + faceVh / 2 - 30; + ov.style.left = Math.max(0, Math.min(window.innerWidth - 60, ovX)) + 'px'; + ov.style.top = Math.max(0, Math.min(window.innerHeight - 80, ovY)) + 'px'; + } +} + +function clearFaceTarget() { + _faceVisible = false; + const ov = document.getElementById('faceScanOverlay'); + if (ov) ov.style.display = 'none'; +} + +function startFaceTracking() { + const logo = document.querySelector('.tb-logo'); + if (logo) logo.classList.add('face-tracking'); + if (!_faceTrackRaf) _faceTrackLoop(); +} + +function stopFaceTracking() { + clearFaceTarget(); + const logo = document.querySelector('.tb-logo'); + if (logo) { logo.classList.remove('face-tracking'); logo.style.transform = ''; } + // Let the loop coast to zero naturally rather than snapping — cancel after settling + setTimeout(() => { + if (!_faceVisible && _faceTrackRaf) { + cancelAnimationFrame(_faceTrackRaf); + _faceTrackRaf = null; + } + }, 1500); +} + +// ── GLITCH EFFECT ───────────────────────────────────────────────────── +(function initGlitch() { + function triggerGlitch() { + const el = document.querySelector('.tb-logo-text'); + if (!el) return; + el.classList.add('glitching'); + setTimeout(() => el.classList.remove('glitching'), 280); + setTimeout(triggerGlitch, 35000 + Math.random() * 25000); + } + setTimeout(triggerGlitch, 20000); +})(); + +// ① HUD CORNER RINGS ────────────────────────────────────────────────── +(function initHudCorners() { + const canvas = document.getElementById('hudCornersCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let W, H, t = 0; + function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } + + function drawCorner(cx, cy, a0, a1) { + const R = 62, R2 = R + 16; + // Edge lines + ctx.strokeStyle = 'rgba(0,212,255,0.35)'; ctx.lineWidth = 1; + const edgeLen = 28; + // two short lines along the screen edges from the corner point + const midA = (a0 + a1) / 2; + const ax = Math.cos(a0), ay = Math.sin(a0); + const bx = Math.cos(a1), by = Math.sin(a1); + ctx.beginPath(); ctx.moveTo(cx + ax*(R+6), cy + ay*(R+6)); ctx.lineTo(cx + ax*(R+6+edgeLen), cy + ay*(R+6+edgeLen)); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx + bx*(R+6), cy + by*(R+6)); ctx.lineTo(cx + bx*(R+6+edgeLen), cy + by*(R+6+edgeLen)); ctx.stroke(); + + // Primary arc + ctx.beginPath(); ctx.arc(cx, cy, R, a0, a1); + ctx.strokeStyle = 'rgba(0,212,255,0.5)'; ctx.lineWidth = 1.2; ctx.stroke(); + // Outer arc + ctx.beginPath(); ctx.arc(cx, cy, R2, a0 + 0.12, a1 - 0.12); + ctx.strokeStyle = 'rgba(0,212,255,0.18)'; ctx.lineWidth = 0.6; ctx.stroke(); + + // Tick marks + const ticks = 14; + for (let i = 0; i <= ticks; i++) { + const a = a0 + (a1 - a0) * (i / ticks); + const big = i % 7 === 0; + const len = big ? 9 : (i % 2 === 0 ? 5 : 3); + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(a) * (R - 2), cy + Math.sin(a) * (R - 2)); + ctx.lineTo(cx + Math.cos(a) * (R - 2 - len), cy + Math.sin(a) * (R - 2 - len)); + ctx.strokeStyle = big ? 'rgba(0,212,255,0.8)' : 'rgba(0,212,255,0.3)'; + ctx.lineWidth = big ? 1 : 0.5; ctx.stroke(); + } + + // Animated scanning dot + const dotA = a0 + ((a1 - a0) * ((t * 0.35) % 1)); + ctx.beginPath(); ctx.arc(cx + Math.cos(dotA) * R, cy + Math.sin(dotA) * R, 2.5, 0, Math.PI*2); + ctx.fillStyle = 'rgba(0,212,255,1)'; + ctx.shadowColor = 'rgba(0,212,255,0.9)'; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowBlur = 0; + + // Small numeric labels + ctx.font = '7px Share Tech Mono,monospace'; ctx.fillStyle = 'rgba(0,212,255,0.55)'; + ctx.fillText(Math.round((Math.abs(a0) / (Math.PI*2)) * 360) + '°', + cx + Math.cos(a0) * (R + 20), cy + Math.sin(a0) * (R + 20)); + } + + function draw() { + ctx.clearRect(0, 0, W, H); t += 0.01; + drawCorner(0, 0, 0, Math.PI*0.5); + drawCorner(W, 0, Math.PI*0.5, Math.PI); + drawCorner(0, H, Math.PI*1.5, Math.PI*2); + drawCorner(W, H, Math.PI, Math.PI*1.5); + requestAnimationFrame(draw); + } + resize(); draw(); + window.addEventListener('resize', resize); +})(); + +// ② DATA STREAM COLUMNS ─────────────────────────────────────────────── +(function initDataStream() { + const canvas = document.getElementById('dataStreamCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let W, H; + const CHARS = '0123456789ABCDEF◈◉▸▹⟩⟨⬡░▒'; + const COL_COUNT = 22; + let cols = []; + + function resize() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; + cols = []; + for (let i = 0; i < COL_COUNT; i++) { + const x = (i / COL_COUNT) * W + Math.random() * (W / COL_COUNT) * 0.6; + cols.push({ x, y: Math.random() * H, speed: Math.random() * 0.7 + 0.25, + len: Math.floor(Math.random() * 14 + 5), chars: [], + alpha: Math.random() * 0.035 + 0.015, tick: 0 }); + } + } + + function draw() { + ctx.clearRect(0, 0, W, H); + ctx.font = '11px Share Tech Mono,monospace'; + for (const c of cols) { + c.y += c.speed; + if (c.y - c.len * 14 > H) { c.y = -c.len * 14; c.alpha = Math.random() * 0.035 + 0.015; } + if (++c.tick > 7) { c.tick = 0; c.chars[0] = CHARS[Math.floor(Math.random() * CHARS.length)]; } + for (let i = 0; i < c.len; i++) { + if (!c.chars[i]) c.chars[i] = CHARS[Math.floor(Math.random() * CHARS.length)]; + const cy = c.y - i * 14; + if (cy < -14 || cy > H + 14) continue; + const a = c.alpha * (1 - i / c.len); + ctx.fillStyle = i === 0 ? `rgba(180,240,255,${Math.min(a*4,0.15)})` : `rgba(0,185,225,${a})`; + ctx.fillText(c.chars[i], c.x, cy); + } + } + requestAnimationFrame(draw); + } + resize(); draw(); + window.addEventListener('resize', resize); +})(); + +// ③ NETWORK TOPOLOGY ────────────────────────────────────────────────── +let _topoNodes = [], _topoT = 0, _topoRunning = false; + +function renderTopology(devices) { + const canvas = document.getElementById('topoCanvas'); + if (!canvas) return; + const W = canvas.parentElement?.clientWidth || 260; + canvas.width = W; canvas.height = 118; + + _topoNodes = devices.slice(0, 18).map((d, i, arr) => { + const angle = (i / arr.length) * Math.PI * 2 - Math.PI / 2; + const rx = W * 0.36, ry = 36; + return { + x: W/2 + Math.cos(angle) * rx * (0.6 + (i%3)*0.18), + y: 52 + Math.sin(angle) * ry * (0.65 + (i%2)*0.25), + label: (d.name || d.ip || '?').split('.')[0].substring(0, 9), + on: !!(d.alive || d.status === 'online'), + agent: d.source === 'agent', + phase: Math.random() * Math.PI * 2, + }; + }); + + if (!_topoRunning) { _topoRunning = true; _drawTopo(); } +} + +function _drawTopo() { + requestAnimationFrame(_drawTopo); + const canvas = document.getElementById('topoCanvas'); + if (!canvas || !_topoNodes.length) return; + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + _topoT += 0.018; + ctx.clearRect(0, 0, W, H); + + const floatY = n => Math.sin(_topoT * 0.9 + n.phase) * 2.5; + + // Connections + for (let i = 0; i < _topoNodes.length; i++) { + for (let j = i+1; j < _topoNodes.length; j++) { + const a = _topoNodes[i], b = _topoNodes[j]; + if (!a.on || !b.on) continue; + const ax = a.x, ay = a.y + floatY(a), bx = b.x, by = b.y + floatY(b); + const dist = Math.hypot(bx-ax, by-ay); + if (dist > W * 0.55) continue; + const lg = ctx.createLinearGradient(ax, ay, bx, by); + const col = a.agent ? '0,255,136' : '0,212,255'; + lg.addColorStop(0, `rgba(${col},0.25)`); lg.addColorStop(0.5, `rgba(${col},0.1)`); lg.addColorStop(1, `rgba(${col},0.25)`); + ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); + ctx.strokeStyle = lg; ctx.lineWidth = 0.8; ctx.stroke(); + // travelling pulse + const p = (_topoT * 0.4 + a.phase) % 1; + ctx.beginPath(); ctx.arc(ax+(bx-ax)*p, ay+(by-ay)*p, 1.8, 0, Math.PI*2); + ctx.fillStyle = 'rgba(0,212,255,0.85)'; ctx.fill(); + } + } + + // Bubble nodes + for (const n of _topoNodes) { + const pulse = 0.5 + Math.sin(_topoT * 1.4 + n.phase) * 0.3; + const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80'; + const r = n.agent ? 10 : 7; + const nx = n.x, ny = n.y + floatY(n); + + if (n.on) { + // Ambient bloom + const bloom = ctx.createRadialGradient(nx, ny, r*0.4, nx, ny, r*3); + bloom.addColorStop(0, `rgba(${col},${(pulse*0.18).toFixed(3)})`); + bloom.addColorStop(1, `rgba(${col},0)`); + ctx.beginPath(); ctx.arc(nx, ny, r*3, 0, Math.PI*2); ctx.fillStyle = bloom; ctx.fill(); + } + + // Frosted glass fill + const fg = ctx.createRadialGradient(nx, ny - r*0.3, 0, nx, ny, r); + const fa = n.on ? 0.18 + pulse*0.1 : 0.07; + fg.addColorStop(0, `rgba(${col},${(fa*1.8).toFixed(3)})`); + fg.addColorStop(0.65, `rgba(${col},${fa.toFixed(3)})`); + fg.addColorStop(1, `rgba(${col},${(fa*0.2).toFixed(3)})`); + ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.fillStyle = fg; ctx.fill(); + + // Border + ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); + ctx.strokeStyle = `rgba(${col},${n.on ? (0.5 + pulse*0.32).toFixed(3) : '0.2'})`; + ctx.lineWidth = 1; ctx.stroke(); + + // Label below bubble + ctx.font = '6px Share Tech Mono,monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = n.on ? `rgba(${col},0.82)` : 'rgba(255,100,100,0.5)'; + ctx.fillText(n.label, nx, ny + r + 7); + ctx.textAlign = 'left'; + } +} + +// ④ EKG HEARTBEAT ──────────────────────────────────────────────────── +(function initEKG() { + const canvas = document.getElementById('ekgCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let W, H, data = [], phase = 0; + + function resize() { + const wrap = document.getElementById('ekgWrap'); + W = canvas.width = wrap ? Math.max(100, wrap.clientWidth) : 180; + H = canvas.height = 22; + data = new Array(W).fill(0); + } + + function ekgVal(p) { + const c = p % 1; + if (c < 0.28) return 0; + if (c < 0.38) return Math.sin((c-0.28)/0.10*Math.PI) * 0.22; // P bump + if (c < 0.43) return 0; // PR flat + if (c < 0.45) return -(c-0.43)/0.02 * 0.18; // Q dip + if (c < 0.465){ const f=(c-0.45)/0.015; return f<0.5?f*2*0.95:(2-f*2)*0.95; } // R spike + if (c < 0.49) return -(c-0.465)/0.025 * 0.22; // S dip + if (c < 0.52) return -(0.22-(c-0.49)/0.03*0.22); // back to baseline + if (c < 0.70) return Math.sin((c-0.52)/0.18*Math.PI) * 0.28; // T wave + return 0; + } + + function draw() { + phase += 0.0038; + data.shift(); data.push(ekgVal(phase)); + ctx.clearRect(0, 0, W, H); + // Glow layer + ctx.beginPath(); + data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); }); + ctx.strokeStyle='rgba(0,220,100,0.2)'; ctx.lineWidth=4; ctx.stroke(); + // Line + ctx.beginPath(); + data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); }); + ctx.strokeStyle='rgba(0,255,120,0.85)'; ctx.lineWidth=1.2; ctx.stroke(); + requestAnimationFrame(draw); + } + resize(); draw(); + window.addEventListener('resize', resize); +})(); + +// ⑤ AUDIO WAVEFORM RING ─────────────────────────────────────────────── +(function initAudioRing() { + const canvas = document.getElementById('audioRingCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const CX = 30, CY = 30, BARS = 28, INNER = 20; + + let t = 0; + function draw() { + t += 0.055; + ctx.clearRect(0, 0, 60, 60); + const listening = window.isListening; + const speaking = window.isSpeaking; + if (!listening && !speaking) { requestAnimationFrame(draw); return; } + + for (let i = 0; i < BARS; i++) { + const angle = (i / BARS) * Math.PI * 2; + let amp; + if (speaking) { + amp = 0.35 + Math.abs(Math.sin(t*4.1+i*0.9))*0.5 + Math.abs(Math.sin(t*9+i*1.7))*0.15; + } else { + amp = 0.08 + Math.abs(Math.sin(t*1.1+i*0.6))*0.18; + } + const barLen = 3 + amp * 11; + ctx.beginPath(); + ctx.moveTo(CX+Math.cos(angle)*INNER, CY+Math.sin(angle)*INNER); + ctx.lineTo(CX+Math.cos(angle)*(INNER+barLen), CY+Math.sin(angle)*(INNER+barLen)); + const alpha = speaking ? 0.55+amp*0.45 : 0.25+amp*0.35; + ctx.strokeStyle = speaking ? `rgba(0,255,175,${alpha})` : `rgba(0,212,255,${alpha})`; + ctx.lineWidth = 1.8; ctx.stroke(); + } + requestAnimationFrame(draw); + } + draw(); +})(); + +// ⑥ STATIC NOISE BURSTS ─────────────────────────────────────────────── +(function initStaticBursts() { + function burst() { + const panels = document.querySelectorAll('#app .panel'); + if (panels.length) { + const p = panels[Math.floor(Math.random() * panels.length)]; + const n = document.createElement('div'); + n.className = 'panel-noise-layer'; + p.appendChild(n); + setTimeout(() => n.remove(), 320); + } + setTimeout(burst, 75000 + Math.random() * 55000); + } + setTimeout(burst, 40000 + Math.random() * 30000); +})(); + +// ⑦ AMBIENT COLOR CYCLE ─────────────────────────────────────────────── +(function initAmbientColor() { + let t = 0; + const root = document.documentElement; + function tick() { + t += 0.00025; + const g = Math.round(210 + Math.sin(t) * 22); + const b = Math.round(248 + Math.cos(t * 1.15) * 28); + root.style.setProperty('--cyan', `rgb(0,${g},${b})`); + root.style.setProperty('--cyan2', `rgb(0,${Math.round(g*.82)},${Math.round(b*.87)})`); + requestAnimationFrame(tick); + } + tick(); +})(); diff --git a/public_html/assets/js/jarvis-overlays.js b/public_html/assets/js/jarvis-overlays.js new file mode 100644 index 0000000..70f0e0d --- /dev/null +++ b/public_html/assets/js/jarvis-overlays.js @@ -0,0 +1,384 @@ +// ── SLEEP MODE ──────────────────────────────────────────────────────────────── +var isAsleep = false; +var _sleepRefreshTimer = null; + +var SLEEP_CMDS = /\b(good\s*night(\s*jarvis)?|go\s*to\s*sleep|sleep\s*mode|shut\s*(down|off)\s*(jarvis|for\s*the\s*night)|go\s*offline|going\s*offline|jarvis\s*(go\s*)?(offline|sleep|shutdown)|stand\s*by\s*mode|power\s*down(\s*jarvis)?|signing\s*off)\b/i; + +function enterSleepMode() { + if (document.body.classList.contains("kiosk-mode")) return; + if (isAsleep) return; + isAsleep = true; + + // Pause voice mode + voiceMode = false; + voiceMuted = false; + updateMicBtn(); + + // Slow or pause the refresh loop — keep mic alive for wake word + clearInterval(refreshTimer); + refreshTimer = null; + // Light polling every 2 min just to stay alive + _sleepRefreshTimer = setInterval(function() { + // heartbeat only — keep session alive without hammering APIs + try { fetch('/api/auth', {method:'GET', headers:{'Authorization':'Bearer '+sessionToken}}); } catch(e) {} + }, 120000); + + // Dim the UI + var app = document.getElementById('app'); + if (app) app.classList.add('sleeping'); + + // Flash title to confirm + document.title = 'JARVIS — STANDBY'; + + addMessage('jarvis', 'Understood. Going offline. Say "wake up JARVIS" when you need me.'); +} + +function wakeFromSleep() { + if (!isAsleep) return; + isAsleep = false; + + // Restore full polling + clearInterval(_sleepRefreshTimer); + _sleepRefreshTimer = null; + refreshAll(); + refreshTimer = setInterval(refreshAll, 10000); + + // Remove dim overlay + var app = document.getElementById('app'); + if (app) app.classList.remove('sleeping'); + document.title = 'JARVIS — Integrated Defense and Logistics System'; + + // Boot sequence + var topBar=document.getElementById('topBar'), lp=document.getElementById('leftPanel'); + var rp=document.getElementById('rightPanel'), cp=document.getElementById('centerPanel'); + [topBar,lp,rp,cp].forEach(function(el){if(el){el.style.opacity='0';}}); + requestAnimationFrame(function(){ + setTimeout(function(){if(topBar){topBar.style.opacity='';topBar.classList.add('boot-top');}},0); + setTimeout(function(){if(lp){lp.style.opacity='';lp.classList.add('boot-left');}},140); + setTimeout(function(){if(rp){rp.style.opacity='';rp.classList.add('boot-right');}},200); + setTimeout(function(){if(cp){cp.style.opacity='';cp.classList.add('boot-center');}},260); + setTimeout(function(){[topBar,lp,rp,cp].forEach(function(el){if(el)el.classList.remove('boot-top','boot-left','boot-right','boot-center');});},1400); + }); + + // Enter voice mode and greet + enterVoiceMode('wake'); +} + +function _focusWindow() { + // Attempt to bring browser window to front + try { window.focus(); } catch(e) {} + + // Flash title to grab attention if tab is backgrounded + var _origTitle = 'JARVIS — Integrated Defense and Logistics System'; + var _flashCount = 0; + var _titleFlash = setInterval(function() { + document.title = _flashCount % 2 === 0 ? '⚡ JARVIS — ONLINE' : _origTitle; + if (++_flashCount >= 8) { clearInterval(_titleFlash); document.title = _origTitle; } + }, 400); + + // Notify if already have permission — never request during voice activity + if ('Notification' in window && Notification.permission === 'granted') { + try { new Notification('JARVIS', { body: 'Wake word detected.', tag: 'jarvis-wake', requireInteraction: false }); } catch(e) {} + } +} + +// ── NETWORK MAP ────────────────────────────────────────────────────────────── +var _nmNodes=[], _nmEdges=[], _nmParticles=[], _nmRaf=null, _nmT=0, _nmHoverNode=null; +var _nmRot=[0,0,0,0,0,0]; +var NM_RINGS=[ + {name:'hub', label:'', rFrac:0, speed:0, rgb:'0,212,255', nodeR:30, cap:1 }, + {name:'proxmox', label:'PROXMOX', rFrac:0.16, speed:0.006, rgb:'0,255,136', nodeR:22, cap:4 }, + {name:'services',label:'SERVICES', rFrac:0.30, speed:-0.004, rgb:'255,215,0', nodeR:20, cap:7 }, + {name:'agents', label:'AGENTS', rFrac:0.45, speed:0.0025, rgb:'0,190,255', nodeR:17, cap:12 }, + {name:'devices', label:'DEVICES', rFrac:0.62, speed:-0.002, rgb:'0,160,200', nodeR:14, cap:14 }, + {name:'network', label:'NETWORK', rFrac:0.82, speed:0.0015, rgb:'0,110,170', nodeR:11, cap:28 }, +]; +var NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network(\s*(map|topology|viz|visual|graph|panel|status|scan|view|overlay))?|topology|node\s*map)\b|\bnetwork\s*(map|topology|viz|visual|graph)\b|\b(show|open|display|launch|pull\s*up|bring\s*up)\s+(?:me\s+)?(?:the\s+)?network\b/i; +var NM_CLOSE_RE = /\b(close|hide|dismiss|exit|collapse)\b.*\b(network|map|topology|overlay)\b|\b(close|hide|dismiss)\s*map\b/i; + +function _nmClassify(d){ + var h=(d.name||'').toLowerCase(); + if(d.source==='agent'){ + if(d.agent_type==='proxmox'||h.indexOf('pve')>=0||h.indexOf('proxmox')>=0) return 'proxmox'; + if(d.agent_type==='homeassistant'||h.indexOf('homeassist')>=0||h.indexOf('_ha')>=0|| + h.indexOf('ollama')>=0||h.indexOf('ai')>=0||h.indexOf('fusion')>=0||h.indexOf('pbx')>=0|| + h.indexOf('jellyfin')>=0||h.indexOf('homebridge')>=0) return 'services'; + return 'agents'; + } + // Named/pinned DB devices and static hosts get the inner device ring + if(d.source==='db'||d.source==='static') return 'devices'; + // Netscan-discovered devices go to the outer network ring + return 'network'; +} +function _nmRgb(n){ + if(!n.online) return '255,50,80'; + if(n.ringIdx===1) return '0,255,136'; + if(n.ringIdx===2) return '255,215,0'; + if(n.ringIdx===3) return '0,190,255'; + if(n.ringIdx===4) return '0,160,200'; + if(n.ringIdx===5) return '0,110,170'; + return '0,212,255'; +} +function _nmNodePos(n,W,H){ + if(n.ringIdx===0) return {x:W/2, y:H/2}; + var rd=NM_RINGS[n.ringIdx], rot=_nmRot[n.ringIdx]||0; + var r=Math.min(W/2,H/2)*rd.rFrac; + return {x:W/2+Math.cos(n.angle+rot)*r, y:H/2+Math.sin(n.angle+rot)*r}; +} + +async function openNetMap(){ + var ov=document.getElementById('netMapOverlay'); if(!ov) return; + ov.classList.remove('nm-closing'); ov.classList.add('nm-open'); + var devices=[]; + try{ var n=await api('network'); devices=n.devices||[]; }catch(e){} + _nmBuild(devices); _nmDraw(); +} +function closeNetMap(){ + var ov=document.getElementById('netMapOverlay'); if(!ov) return; + ov.classList.add('nm-closing'); + setTimeout(function(){ ov.classList.remove('nm-open','nm-closing'); }, 350); + if(_nmRaf){ cancelAnimationFrame(_nmRaf); _nmRaf=null; } +} +function _nmBuild(devices){ + _nmNodes=[]; _nmEdges=[]; _nmParticles=[]; + // Hub + _nmNodes.push({id:'jarvis',label:'JARVIS',sub:'10.48.200.211',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0}); + // Bucket + var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]}; + // Deduplicate agent devices by hostname (same logical host registered twice) + var _seenNames={}; + var _dedupedDevices=[]; + for(var i=0;i(prev.last_seen||'')){_dedupedDevices[_seenNames[nk].idx]=d;_seenNames[nk].d=d;} + } + } + // Bucket: offline agents are excluded from inner rings (keep DB/netscan as-is) + for(var i=0;i<_dedupedDevices.length;i++){ + var d=_dedupedDevices[i]; + var isOffline=!(d.alive||d.status==='online'); + if(d.source==='agent'&&isOffline) continue; // hide offline agents from map + buckets[_nmClassify(d)].push(d); + } + // Sort netscan devices: online first, then those with meaningful hostnames + buckets.network.sort(function(a,b){ + var sa=a.alive?1:0, sb=b.alive?1:0; + if(sb!==sa) return sb-sa; + var ha=(a.name&&a.name!==a.ip&&a.name.indexOf('10.48')!==0)?1:0; + var hb=(b.name&&b.name!==b.ip&&b.name.indexOf('10.48')!==0)?1:0; + return hb-ha; + }); + var rings=['proxmox','services','agents','devices','network']; + for(var ri=0;ri0.45) _nmParticles.push({edge:_nmEdges.length-1,t:Math.random(),dir:'out',speed:0.0013+Math.random()*0.0017,r:1.5+Math.random()*0.7}); + } + } + // Stats + var online=_nmNodes.filter(function(n){return n.online;}).length; + var agt=_nmNodes.filter(function(n){return n.agent;}).length; + function sg(id){return document.getElementById(id);} + if(sg('nm-node-count')) sg('nm-node-count').textContent=_nmNodes.length; + if(sg('nm-online-count')) sg('nm-online-count').textContent=online; + if(sg('nm-agent-count')) sg('nm-agent-count').textContent=agt; +} +function _nmDraw(){ + if(_nmRaf) cancelAnimationFrame(_nmRaf); + var canvas=document.getElementById('nmCanvas'); if(!canvas) return; + var ov=document.getElementById('netMapOverlay'); + canvas.width = ov ? ov.clientWidth : 820; + canvas.height= ov ? ov.clientHeight-48 : 490; + var W=canvas.width, H=canvas.height, cx=W/2, cy=H/2, minR=Math.min(cx,cy); + var ctx=canvas.getContext('2d'); + + function frame(){ + if(!document.getElementById('netMapOverlay').classList.contains('nm-open')) return; + _nmRaf=requestAnimationFrame(frame); + _nmT+=0.016; + for(var i=0;i0){ctx.font='6.5px Share Tech Mono,monospace';ctx.fillStyle='rgba('+rd.rgb+',0.28)';ctx.fillText(zOn+'/'+zTot,cx+r+5,cy+11);} + ctx.textAlign='left'; + } + + // Compute node positions + var pos=[]; + for(var i=0;i<_nmNodes.length;i++) pos.push(_nmNodePos(_nmNodes[i],W,H)); + + // Spokes + for(var ei=0;ei<_nmEdges.length;ei++){ + var e=_nmEdges[ei], pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) continue; + var n=_nmNodes[e.from], rgb=_nmRgb(n); + // Apply float offset to spoke endpoints + var fya=Math.sin(_nmT*0.85+_nmNodes[e.from].pulse)*4; + var fyb=Math.sin(_nmT*0.85+_nmNodes[e.to].pulse)*4; + var lg=ctx.createLinearGradient(pa.x,pa.y+fya,pb.x,pb.y+fyb); + if(n.online){lg.addColorStop(0,'rgba('+rgb+',0.22)');lg.addColorStop(0.5,'rgba('+rgb+',0.08)');lg.addColorStop(1,'rgba(0,212,255,0.15)');} + else{lg.addColorStop(0,'rgba(80,20,30,0.07)');lg.addColorStop(1,'rgba(80,20,30,0.07)');} + ctx.beginPath();ctx.moveTo(pa.x,pa.y+fya);ctx.lineTo(pb.x,pb.y+fyb); + ctx.strokeStyle=lg;ctx.lineWidth=e.strength*1.1;ctx.stroke(); + } + + // Particles + for(var pi=0;pi<_nmParticles.length;pi++){ + var p=_nmParticles[pi]; p.t=(p.t+p.speed)%1; + var e=_nmEdges[p.edge]; if(!e) continue; + var pa=pos[e.from],pb=pos[e.to]; if(!pa||!pb) continue; + if(!_nmNodes[e.from].online) continue; + var t=p.dir==='in'?p.t:1-p.t; + var px=pa.x+(pb.x-pa.x)*t, py=pa.y+(pb.y-pa.y)*t; + var fade=Math.min(t*8,(1-t)*8,1); + ctx.beginPath();ctx.arc(px,py,p.r,0,Math.PI*2); + if(p.dir==='in'){ctx.fillStyle='rgba(0,210,255,'+(((0.6+Math.sin(_nmT*3+p.t*10)*0.3)*fade).toFixed(3))+')';ctx.shadowColor='rgba(0,200,255,0.7)';} + else{ctx.fillStyle='rgba(255,130,0,'+(((0.5+Math.sin(_nmT*4+p.t*8)*0.25)*fade).toFixed(3))+')';ctx.shadowColor='rgba(255,110,0,0.6)';} + ctx.shadowBlur=6;ctx.fill();ctx.shadowBlur=0; + } + + // Bubble nodes + for(var ni=0;ni<_nmNodes.length;ni++){ + var n=_nmNodes[ni], p=pos[ni], rgb=_nmRgb(n); + var pulse=0.5+Math.sin(_nmT*1.4+n.pulse)*0.3; + var isHub=n.ringIdx===0, isHov=_nmHoverNode===ni; + // Float offset — each node drifts on Y with unique phase + var fy=Math.sin(_nmT*0.85+n.pulse)*4; + var px=p.x, py=p.y+fy; + var baseR=n.r*(isHov?1.28:1.0); + + // Ambient glow bloom + if(n.online){ + var bloomR=baseR*2.6+Math.sin(_nmT*1.1+n.pulse)*3; + var bloom=ctx.createRadialGradient(px,py,baseR*0.4,px,py,bloomR); + bloom.addColorStop(0,'rgba('+rgb+','+(pulse*0.2).toFixed(3)+')'); + bloom.addColorStop(1,'rgba('+rgb+',0)'); + ctx.beginPath();ctx.arc(px,py,bloomR,0,Math.PI*2);ctx.fillStyle=bloom;ctx.fill(); + + // Sonar ping — expanding ring that fades out + var pingR=baseR+(((_nmT*0.6+n.pulse)%1)*baseR*2.5); + var pingA=(1-(pingR-baseR)/(baseR*2.5))*0.3; + ctx.beginPath();ctx.arc(px,py,pingR,0,Math.PI*2); + ctx.strokeStyle='rgba('+rgb+','+pingA.toFixed(3)+')'; + ctx.lineWidth=0.7;ctx.stroke(); + } + + // Frosted glass fill + var fg=ctx.createRadialGradient(px,py-baseR*0.28,0,px,py,baseR); + var fa=n.online?0.17+pulse*0.1:0.06; + fg.addColorStop(0,'rgba('+rgb+','+(fa*2.0).toFixed(3)+')'); + fg.addColorStop(0.55,'rgba('+rgb+','+fa.toFixed(3)+')'); + fg.addColorStop(1,'rgba('+rgb+','+(fa*0.15).toFixed(3)+')'); + ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=fg;ctx.fill(); + + // Glassy highlight sheen (top-left arc) + if(n.online){ + var sh=ctx.createRadialGradient(px-baseR*0.3,py-baseR*0.35,0,px,py,baseR); + sh.addColorStop(0,'rgba(255,255,255,0.12)');sh.addColorStop(0.45,'rgba(255,255,255,0.03)');sh.addColorStop(1,'rgba(255,255,255,0)'); + ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=sh;ctx.fill(); + } + + // Border + ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2); + ctx.strokeStyle='rgba('+rgb+','+(n.online?(0.45+pulse*0.35).toFixed(3):'0.18')+')'; + ctx.lineWidth=isHub?1.8:1.1;ctx.stroke(); + + // Hub crosshairs (softer) + if(isHub){ + ctx.strokeStyle='rgba('+rgb+',0.15)';ctx.lineWidth=0.6; + var ext=50; + var hlines=[[px-ext,py,px-baseR-3,py],[px+baseR+3,py,px+ext,py],[px,py-ext,px,py-baseR-3],[px,py+baseR+3,px,py+ext]]; + for(var li=0;li=0?found:null; + var info=document.getElementById('nmNodeInfo'); if(!info) return; + if(found>=0){ + var n=_nmNodes[found],rgb=_nmRgb(n); + document.getElementById('ni-name').textContent=n.label; document.getElementById('ni-name').style.color='rgb('+rgb+')'; + document.getElementById('ni-ip').textContent='IP: '+(n.sub||'—'); + document.getElementById('ni-status').textContent='STATUS: '+(n.online?'ONLINE':'OFFLINE'); + document.getElementById('ni-type').textContent='RING: '+(NM_RINGS[n.ringIdx]?NM_RINGS[n.ringIdx].name.toUpperCase():'HUB'); + info.style.display='block'; info.style.left=(mx+14)+'px'; info.style.top=(my-6)+'px'; + } else { info.style.display='none'; } + }; + canvas.onmouseleave=function(){_nmHoverNode=null;var i=document.getElementById('nmNodeInfo');if(i)i.style.display='none';}; +} diff --git a/public_html/assets/js/panels/jarvis-agents.js b/public_html/assets/js/panels/jarvis-agents.js new file mode 100644 index 0000000..0d1c97e --- /dev/null +++ b/public_html/assets/js/panels/jarvis-agents.js @@ -0,0 +1,715 @@ +// ── MISSION OPS HUD ─────────────────────────────────────────────────────────── +let _missionsOpenCards = new Set(); + +async function loadMissionsHud() { + const el = document.getElementById('missions-hud'); + if (!el) return; + try { + const missions = await api('arc?action=missions'); + const list = Array.isArray(missions) ? missions : []; + + let html = ''; + + if (!list.length) { + html += '
◈ NO MISSIONS
Create workflows in Admin → Mission Ops
'; + el.innerHTML = html; + return; + } + + const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'}; + for (const m of list) { + const isOpen = _missionsOpenCards.has(m.id); + const icon = trigIcons[m.trigger_type] || '◈'; + const enabled = m.enabled; + const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never'; + html += `
+
+ ${icon} + ${escHtml(m.name)} + ${m.trigger_type.replace('_',' ').toUpperCase()} + ${m.run_count||0} runs +
+
+ ${m.description ? `
${escHtml(m.description)}
` : ''} +
Last run: ${lastRun} · ${m.run_count||0} total runs
+
+ +
+
+
+
`; + } + el.innerHTML = html; + } catch(e) { + if (el) el.innerHTML = '
MISSIONS OFFLINE
'; + } +} + +function toggleMissionCard(id) { + const card = document.getElementById('mission-card-' + id); + if (!card) return; + if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id); + else _missionsOpenCards.add(id); + card.classList.toggle('open'); +} + +async function hudRunMission(id) { + const btn = document.getElementById('mission-run-btn-' + id); + const res = document.getElementById('mission-run-result-' + id); + if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; } + if (res) res.textContent = ''; + try { + const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'}); + const s = data.status || 'done'; + const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700'; + if (res) res.style.color = color; + if (res) res.textContent = `◈ ${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`; + if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; } + setTimeout(loadMissionsHud, 2000); + } catch(e) { + if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; } + if (res) res.textContent = '✗ Run failed'; + } +} + +// ── DIRECTIVES HUD ──────────────────────────────────────────────────────────── +let _dirOpenCards = new Set(); + +async function loadDirectivesHud() { + const el = document.getElementById('directives-hud'); + if (!el) return; + try { + const d = await api('directives/list?status=active'); + const list = (d.directives || []); + + let html = ''; + + if (!list.length) { + html += '
◈ NO ACTIVE DIRECTIVES
Create objectives in Admin → Directives
'; + el.innerHTML = html; + return; + } + + const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'}; + for (const dir of list) { + const pct = Math.min(100, Math.round(dir.progress || 0)); + const isOpen = _dirOpenCards.has(dir.id); + const color = catColors[dir.category] || 'var(--cyan)'; + const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644'; + const daysLeft = dir.target_date + ? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null; + const dueTxt = daysLeft !== null + ? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`) + : ''; + const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)'; + + html += `
+
+ ${dir.category.toUpperCase()} + ${escHtml(dir.title)} + ${pct}% + ${dueTxt ? `${dueTxt}` : ''} +
+
+
+
${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS
+ +
+
`; + } + el.innerHTML = html; + } catch(e) { + if (el) el.innerHTML = '
DIRECTIVES OFFLINE
'; + } +} + +function toggleDirCard(id) { + const card = document.getElementById('dir-card-' + id); + if (!card) return; + if (_dirOpenCards.has(id)) _dirOpenCards.delete(id); + else _dirOpenCards.add(id); + card.classList.toggle('open'); +} + +async function hudDirectiveReview(id) { + const res = await api('arc?action=job_create', 'POST', { + type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6, + }); + if (res.job_id) { + addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`); + speak(`Directive review underway. I'll brief you on your progress in a moment.`); + } +} + +// ── MEMORY CORE — bottom bar count ──────────────────────────────────────────── +async function updateMemoryCount() { + try { + const stats = await api('memory?action=stats'); + const el = document.getElementById('bb-memory-count'); + const dot = document.getElementById('bb-memory-dot'); + if (el && stats) { + const total = stats.total || 0; + el.textContent = total + ' FACTS'; + if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)'; + } + } catch(e) {} +} + +// ── CLEARANCE PROTOCOL HUD ───────────────────────────────────────────────────── +const _clrOpenCards = new Set(); + +async function updateClearanceBanner() { + try { + const pending = await api('arc?action=clearance_pending'); + const list = Array.isArray(pending) ? pending : []; + const count = list.length; + const banner = document.getElementById('clearance-banner'); + const badge = document.getElementById('clr-tab-badge'); + const bcount = document.getElementById('clr-banner-count'); + if (banner) { + if (count > 0) { + banner.classList.add('active'); + if (bcount) bcount.textContent = count; + } else { + banner.classList.remove('active'); + } + } + if (badge) { + if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; } + else badge.style.display = 'none'; + } + } catch(e) {} +} + +async function loadClearanceHud() { + const el = document.getElementById('clearance-hud'); + if (!el) return; + try { + const [pendingRes, rulesRes, historyRes] = await Promise.all([ + api('arc?action=clearance_pending'), + api('arc?action=clearance_rules'), + api('arc?action=clearance_history&limit=20') + ]); + const pending = Array.isArray(pendingRes) ? pendingRes : []; + const rules = Array.isArray(rulesRes) ? rulesRes : []; + const history = Array.isArray(historyRes) ? historyRes : []; + + let html = ''; + + // Pending requests + html += `
PENDING AUTHORIZATION (${pending.length})
`; + if (!pending.length) { + html += '
◈ NO PENDING CLEARANCE REQUESTS
'; + } else { + for (const cr of pending) { + const isOpen = _clrOpenCards.has(cr.id); + const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {}); + const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : ''; + const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : ''; + html += `
+
+ ${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))} + ${cr.risk_level.toUpperCase()} + #${cr.id} +
+
+
${escHtml(cr.description || 'No description')}
+
+ Requested: ${created}${expires ? ' · Expires: ' + expires : ''} +
+
+ Payload: ${escHtml(JSON.stringify(pl))} +
+
+ + +
+
+
`; + } + } + + // Rules + html += `
CLEARANCE RULES
`; + if (!rules.length) { + html += '
No rules configured
'; + } else { + html += '
'; + for (const r of rules) { + const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled'; + const enLabel = r.enabled ? 'ON' : 'OFF'; + const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW'; + const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : ''; + html += `
+ ${r.job_type.replace(/_/g,' ').toUpperCase()} + ${r.risk_level.toUpperCase()} + ${reqLabel}${autoTxt} + +
`; + } + html += '
'; + } + + // Recent history + html += `
RECENT HISTORY
`; + const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10); + if (!recentDecided.length) { + html += '
No history yet
'; + } else { + html += '
'; + for (const h of recentDecided) { + const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : ''; + html += `
+ + ${h.job_type.replace(/_/g,' ').toUpperCase()} + ${h.status.toUpperCase()} + ${ts} +
`; + } + html += '
'; + } + + el.innerHTML = html; + await updateClearanceBanner(); + } catch(e) { + if (el) el.innerHTML = '
CLEARANCE SYSTEM OFFLINE
'; + } +} + +function toggleClrCard(id) { + const card = document.getElementById('clr-card-' + id); + if (!card) return; + if (_clrOpenCards.has(id)) _clrOpenCards.delete(id); + else _clrOpenCards.add(id); + card.classList.toggle('open'); +} + +async function hudClearanceDecide(id, action) { + const label = action === 'approve' ? 'AUTHORIZE' : 'DENY'; + if (!confirm(`${label} clearance request #${id}?`)) return; + const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : ''; + try { + const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note }); + const msg = action === 'approve' + ? `◈ Clearance #${id} authorized. Job dispatched.` + : `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`; + addMessage('jarvis', msg); + speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.'); + await loadClearanceHud(); + } catch(e) { + addMessage('system', 'Clearance action failed.'); + } +} + +async function hudClearanceRuleToggle(id, newEnabled) { + try { + await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled }); + await loadClearanceHud(); + } catch(e) {} +} + +async function loadAgents() { + const [listData, metricsData] = await Promise.all([ + api('agent/list'), + api('agent/status') + ]); + const agents = listData.agents || []; + const metrics = metricsData.metrics || {}; + // Fetch sparkline data (non-blocking) + api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {}); + renderAgentsTab(agents, metrics); +} + +async function addNetworkDevice() { + const ip = prompt('IP address (e.g. 10.48.200.43):'); + if (!ip) return; + const name = prompt('Device name (e.g. Yealink Phone):'); + if (!name) return; + const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device'; + const r = await api('network/add', 'POST', {ip, alias: name, type}); + if (r.error) { alert('Error: ' + r.error); return; } + loadNetwork(); +} + +async function deleteNetworkDevice(ip, evt) { + evt.stopPropagation(); + if (!confirm('Remove ' + ip + ' from the network list?')) return; + const r = await api('network/delete', 'POST', {ip}); + if (r.error) { alert('Error: ' + r.error); return; } + loadNetwork(); +} + +let _agentSparkData = {}; +function sparkline(points, width=80, height=20, color='var(--cyan)') { + if (!points || points.length < 2) return ''; + const max = Math.max(...points, 1); + const min = Math.min(...points); + const range = max - min || 1; + const step = width / (points.length - 1); + const pts = points.map((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 2) - 1; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ` + + + `; +} + +function renderAgentsTab(agents, metrics) { + const el = document.getElementById('agents-list'); + if (!el) return; + if (!agents.length) { + el.innerHTML = '
NO AGENTS REGISTERED
'; + return; + } + el.innerHTML = agents.map(ag => { + const m = metrics[ag.agent_id] || {}; + const sys = m.system || {}; + const alive = ag.status === 'online'; + const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--'; + const mem = sys.memory ? Math.round(sys.memory.percent) : '--'; + const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--'; + const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--'; + const disks = sys.disk || []; + const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null; + const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE'); + const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--'; + + const gauge = (val, unit='%', warn=80, crit=90) => { + const v = typeof val === 'number' ? val : parseInt(val); + if (isNaN(v)) return `--`; + const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)'; + return `
+
+
+
+ ${v}${unit} +
`; + }; + + const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true) + .map(s => `${s.service}: ${s.status}`) + .join(''); + + const ctxKey = 'agent_' + ag.agent_id; + _panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id, + hostname: ag.hostname, status: ag.status, cpu, mem}; + + return `
+
+
+ ${escHtml(ag.hostname)} + ${escHtml(ag.agent_type.toUpperCase())} · ${escHtml(ag.ip_address)} + ${alive ? 'ONLINE' : 'OFFLINE'} +
+ ${alive ? `
+
CPU
${gauge(cpu)}
+
MEM ${memUsed}/${memTot}
${gauge(mem)}
+
DISK
${maxDisk != null ? gauge(maxDisk) : '--'}
+
+
+
+
CPU 2H
+ ${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')} +
+
+
MEM 2H
+ ${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')} +
+
` : ''} +
+
UP: ${uptime} · SEEN: ${since}
+ ${svcs ? `
${svcs}
` : ''} +
+ ${alive ? `
+ + +
` : ''} +
`; + }).join(''); +} + +function openAgentModal() { + const os = detectOS(); + const title = document.getElementById('agentModalTitle'); + const content = document.getElementById('agentModalContent'); + const modal = document.getElementById('agentModal'); + const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518'; + const baseUrl = 'https://jarvis.orbishosting.com/agent'; + const jUrl = window.location.origin; + + if (os === 'tablet') { + title.textContent = '● JARVIS — TABLET / MOBILE'; + content.innerHTML = + '
✓ You\'re viewing JARVIS on a tablet or mobile device.
' + + '
The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).

' + + 'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.
'; + } else if (_agentOnline) { + title.textContent = '● AGENT CONNECTED'; + content.innerHTML = + '
✓ JARVIS Agent is active on this machine.
' + + '
' + + 'Host: ' + (_myAgent?.hostname||'—') + '
' + + 'IP: ' + (_myAgent?.ip_address||'—') + '
' + + 'Type: ' + (_myAgent?.agent_type||'—').toUpperCase() + '
' + + 'Reporting: CPU · Memory · Disk · Services · Uptime
'; + } else { + const inst = { + windows: { + label:'Windows', + cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey, + dl: baseUrl+'/install-windows.ps1', + note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.' + }, + mac: { + label:'macOS', + cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey, + dl: baseUrl+'/install-mac.sh', + note:'Run in Terminal. Installs as a launchd background service.' + }, + linux: { + label:'Linux', + cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey, + dl: baseUrl+'/install.sh', + note:'Run in terminal. Installs as a systemd service.' + }, + unknown: { + label:'Your System', + cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/', + dl: 'https://jarvis.orbishosting.com/agent/', + note:'Choose your platform installer from the JARVIS agent directory.' + } + }; + const i = inst[os] || inst.unknown; + const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase(); + title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM'); + content.innerHTML = + '
DETECTED: ' + osBadge + '
' + + '
'+i.note+'
' + + '
'+i.cmd+'
' + + '↓ DOWNLOAD INSTALLER' + + '
After install, the AGENT indicator turns green within 30 seconds.
'; + } + modal.classList.add('open'); +} + +document.addEventListener('click', function(e) { + if (e.target === document.getElementById('agentModal')) + document.getElementById('agentModal').classList.remove('open'); +}); + + + + +// ── SITES MANAGER ──────────────────────────────────────────────────── +let sitesData = {}; + +function openSitesModal() { + document.getElementById('sitesModal').style.display = 'flex'; + loadSites(); +} +function closeSitesModal() { + document.getElementById('sitesModal').style.display = 'none'; +} +// Close on backdrop click +document.getElementById('sitesModal').addEventListener('click', function(e) { + if (e.target === this) closeSitesModal(); +}); + +async function loadSites() { + document.getElementById('sites-grid').innerHTML = '
LOADING SITE SETTINGS...
'; + const res = await api('sites'); + if (!res.success) { + document.getElementById('sites-grid').innerHTML = '
FAILED TO LOAD SETTINGS
'; + return; + } + sitesData = res.sites; + // Pre-fill global key from first site + const firstKey = Object.values(res.sites)[0]?.api_key || ''; + document.getElementById('global-api-key').value = firstKey; + renderSiteCards(); +} + +function renderSiteCards() { + const grid = document.getElementById('sites-grid'); + let html = ''; + for (const [id, s] of Object.entries(sitesData)) { + html += ` +
+
+
${s.name.toUpperCase()}
+
${s.url}
+
+
+
FROM EMAIL
+ +
+
+
FROM NAME
+ +
+
+
ADMIN NOTIFICATION EMAIL
+ +
+
+ + +
+
`; + } + grid.innerHTML = html; +} + +async function pushApiKey() { + const key = document.getElementById('global-api-key').value.trim(); + const status = document.getElementById('push-status'); + if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; } + status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...'; + const res = await api('sites', 'POST', {action:'push_key', api_key:key}); + if (res.success) { + const ok = Object.values(res.results).filter(Boolean).length; + const total = Object.keys(res.results).length; + status.style.color = ok === total ? 'var(--cyan)' : '#fa0'; + status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`; + for (const id of Object.keys(sitesData)) sitesData[id].api_key = key; + } else { + status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED'); + } +} + +async function saveSite(id) { + const status = document.getElementById(id + '-status'); + status.style.color='var(--text-dim)'; status.textContent='SAVING...'; + const res = await api('sites', 'POST', { + action: 'save', + site: id, + from_email: document.getElementById(id+'-from_email').value.trim(), + from_name: document.getElementById(id+'-from_name').value.trim(), + admin_email: document.getElementById(id+'-admin_email').value.trim(), + }); + if (res.success) { + status.style.color='var(--cyan)'; status.textContent='✓ SAVED'; + setTimeout(() => { status.textContent=''; }, 3000); + } else { + status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED'); + } +} + +// ── VISION PROTOCOL — screenshot lightbox ──────────────────────────────────── +function openVisionLightbox(title) { + const lb = document.getElementById('vision-lightbox'); + document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL'; + document.getElementById('vision-lb-img').style.display = 'none'; + document.getElementById('vision-lb-img').src = ''; + document.getElementById('vision-lb-analysis').textContent = ''; + document.getElementById('vision-lb-spinner').style.display = 'block'; + lb.classList.add('open'); +} + +function closeVisionLightbox() { + document.getElementById('vision-lightbox').classList.remove('open'); +} + +async function agentScreenshot(hostname) { + openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase()); + const arcRes = await api('arc?action=job_create', 'POST', { + type: 'screenshot', + payload: {agent: hostname, analyze: true}, + priority: 8, + }).catch(() => null); + + if (!arcRes || !arcRes.job_id) { + document.getElementById('vision-lb-spinner').style.display = 'none'; + document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.'; + return; + } + + // Poll for result + const jobId = arcRes.job_id; + let tries = 0; + const poll = async () => { + tries++; + const job = await api('arc?action=job_get&id=' + jobId).catch(() => null); + if (job && job.status === 'done') { + const r = job.result || {}; + document.getElementById('vision-lb-spinner').style.display = 'none'; + if (r.has_image && r.screenshot_id) { + // Fetch full screenshot with image + const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null); + if (full && full.image_b64) { + const img = document.getElementById('vision-lb-img'); + img.src = 'data:image/png;base64,' + full.image_b64; + img.style.display = 'block'; + } + } + document.getElementById('vision-lb-analysis').textContent = + r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2)); + } else if (job && job.status === 'failed') { + document.getElementById('vision-lb-spinner').style.display = 'none'; + document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error'); + } else if (tries < 30) { + setTimeout(poll, 2000); + } else { + document.getElementById('vision-lb-spinner').style.display = 'none'; + document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.'; + } + }; + setTimeout(poll, 2000); +} + +async function agentSysinfo(hostname) { + openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase()); + const arcRes = await api('arc?action=job_create', 'POST', { + type: 'sysinfo', + payload: {agent: hostname, analyze: true}, + priority: 7, + }).catch(() => null); + + if (!arcRes || !arcRes.job_id) { + document.getElementById('vision-lb-spinner').style.display = 'none'; + document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.'; + return; + } + + const jobId = arcRes.job_id; + let tries = 0; + const poll = async () => { + tries++; + const job = await api('arc?action=job_get&id=' + jobId).catch(() => null); + if (job && job.status === 'done') { + const r = job.result || {}; + document.getElementById('vision-lb-spinner').style.display = 'none'; + const snap = r.snapshot || {}; + const snapText = Object.entries(snap) + .filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k)) + .map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`) + .join('\n'); + document.getElementById('vision-lb-analysis').textContent = + (r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2)); + } else if (job && job.status === 'failed') { + document.getElementById('vision-lb-spinner').style.display = 'none'; + document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error'); + } else if (tries < 20) { + setTimeout(poll, 2000); + } else { + document.getElementById('vision-lb-spinner').style.display = 'none'; + document.getElementById('vision-lb-analysis').textContent = 'Timed out.'; + } + }; + setTimeout(poll, 2000); +} + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeVisionLightbox(); +}); + diff --git a/public_html/assets/js/panels/jarvis-arc.js b/public_html/assets/js/panels/jarvis-arc.js new file mode 100644 index 0000000..8d9e428 --- /dev/null +++ b/public_html/assets/js/panels/jarvis-arc.js @@ -0,0 +1,608 @@ +// ── ARC REACTOR STATUS ──────────────────────────────────────────────── +let _arcOnline = false; +let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 }; + +async function checkArcStatus() { + const dot = document.getElementById('bb-arc-dot'); + const sta = document.getElementById('bb-arc-status'); + if (!dot || !sta) return; + try { + const d = await api('arc?action=status'); + if (d && d.online) { + _arcOnline = true; + dot.className = 'bb-dot online'; + const active = (d.active_jobs || 0) + (d.queued_jobs || 0); + sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE'; + _arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0, + done: d.jobs_done||0, failed: d.jobs_failed||0 }; + } else { + _arcOnline = false; + dot.className = 'bb-dot offline'; + sta.textContent = 'OFFLINE'; + } + } catch(e) { + _arcOnline = false; + dot.className = 'bb-dot offline'; + sta.textContent = 'OFFLINE'; + } +} + +// Submit a job to the Arc Reactor and return job_id +async function arcSubmitJob(type, payload, priority) { + payload = payload || {}; + priority = priority || 5; + const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority }); + return d.job_id || null; +} + +// Poll a job until done or failed (max 120s), calling onProgress each tick +async function arcWaitJob(jobId, onProgress) { + var start = Date.now(); + while (Date.now() - start < 120000) { + const d = await api('arc?action=job_get&id=' + jobId); + if (onProgress) onProgress(d); + if (d.status === 'done') return d; + if (d.status === 'failed') throw new Error(d.error || 'Job failed'); + await new Promise(function(r){ setTimeout(r, 1500); }); + } + throw new Error('Arc Reactor job timed out'); +} + + +// ── INTEL PROTOCOL — HUD panel ──────────────────────────────────────── +let _intelPollTimer = null; +let _intelActiveJobs = new Set(); +let _intelLastLoad = 0; + +async function loadIntel() { + const el = document.getElementById('intel-list'); + if (!el) return; + _intelLastLoad = Date.now(); + + try { + // Fetch recent research + tool_loop jobs + const [resJobs, toolJobs] = await Promise.all([ + api('arc?action=jobs&status=&limit=20').catch(() => []), + Promise.resolve([]), + ]); + const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : []; + + if (!jobs.length) { + el.innerHTML = '
◈ NO INTEL JOBS
Say "research [topic]" to activate
'; + stopIntelPolling(); + return; + } + + // Check for active jobs + const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running'); + if (hasActive) startIntelPolling(); else stopIntelPolling(); + + let html = ''; + for (const job of jobs) { + const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running'; + const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running'; + const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase(); + const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM'; + + // Get result details if done + let bodyHtml = ''; + if (job.status === 'done' && job.result) { + let r = job.result; + if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} } + if (typeof r === 'object') { + const synthesis = (r.synthesis || r.result || r.response || '').trim(); + const sources = r.sources || []; + const query = r.query || r.task || ''; + const provider = r.provider || ''; + + bodyHtml = `
`; + if (provider) bodyHtml += `
PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}
`; + if (synthesis) bodyHtml += `
${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}
`; + if (sources.length) { + bodyHtml += '
SOURCES
'; + sources.slice(0,5).forEach((s,i) => { + const title = escHtml((s.title||s.url||'').substring(0,60)); + const url = escHtml(s.url||''); + bodyHtml += ``; + }); + bodyHtml += '
'; + } + bodyHtml += '
'; + } + } else if (job.status === 'running' || job.status === 'queued') { + const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...'; + bodyHtml = `
${typeMsg}
`; + } else if (job.status === 'failed' && job.error) { + bodyHtml = `
${escHtml(job.error.substring(0,200))}
`; + } + + const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : ''; + const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : ''; + + html += `
+
+ ${typeLabel} + #${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))} + ${ts} + ${statusLabel} +
+ ${bodyHtml} +
`; + } + el.innerHTML = html; + + } catch(e) { + if (el) el.innerHTML = '
INTEL OFFLINE
'; + } +} + +function toggleIntelCard(id) { + const card = document.getElementById('intel-card-' + id); + if (!card) return; + if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id); + else _intelActiveJobs.add(id); + card.classList.toggle('open'); +} + +function startIntelPolling() { + if (_intelPollTimer) return; + _intelPollTimer = setInterval(() => { + if (document.getElementById('tab-intel')?.classList.contains('active')) { + loadIntel(); + } + }, 4000); +} + +function stopIntelPolling() { + if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; } +} + +function escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function intelPrompt() { + const input = document.getElementById('textInput'); + if (input) { input.value = 'research '; input.focus(); } +} + +// Called when arc_job is returned from chat response +function onArcJobStarted(jobId, jobType) { + const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep']; + if (commsTypes.includes(jobType)) { + const commsBtn = document.getElementById('tab-btn-comms'); + if (commsBtn) commsBtn.click(); + startCommsPolling(); + } else { + _intelActiveJobs.add(jobId); + const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]'); + if (intelTab) intelTab.click(); + startIntelPolling(); + } +} + +// ── COMMS PROTOCOL — email triage HUD ──────────────────────────────────── +let _commsPollTimer = null; +let _commsFilter = 'priority'; +let _commsOpenCards = new Set(); + +async function loadComms() { + const el = document.getElementById('comms-list'); + if (!el) return; + + try { + const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter); + const items = Array.isArray(res) ? res : (res.items || []); + + if (!items.length) { + el.innerHTML = '' + + '
◈ NO TRIAGE DATA
Say "check my email" to activate
'; + stopCommsPolling(); + return; + } + + const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6}; + const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'ℹ', promo:'📢', spam:'🗑'}; + + let html = '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) { + html += `
${label}
`; + } + html += '
'; + + for (const item of items) { + const cat = item.category || 'info'; + const icon = catIcons[cat] || '◈'; + const prio = item.priority || 0; + const isOpen = _commsOpenCards.has(item.id); + const hasReply = item.draft_reply && item.draft_reply.trim().length > 5; + + html += `
+
+ ${icon} ${cat.toUpperCase()} + ${escHtml((item.subject||'(no subject)').substring(0,60))} + ${prio}/10 +
+
+
FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}
+
${escHtml(item.summary||'')}
+ ${hasReply ? `
DRAFT REPLY
${escHtml(item.draft_reply)}
` : ''} +
+ ${hasReply ? `` : ''} + ${hasReply ? `` : ''} + +
+
+
`; + } + + el.innerHTML = html; + + } catch(e) { + if (el) el.innerHTML = '
COMMS OFFLINE
'; + } +} + +function toggleCommsCard(id) { + const card = document.getElementById('comms-card-' + id); + if (!card) return; + if (_commsOpenCards.has(id)) _commsOpenCards.delete(id); + else _commsOpenCards.add(id); + card.classList.toggle('open'); +} + +function commsSetFilter(f) { + _commsFilter = f; + loadComms(); +} + +async function commsDismiss(id) { + await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {}); + loadComms(); +} + +async function commsCopyReply(id) { + const draft = document.querySelector(`#comms-draft-${id}`); + if (draft) { + navigator.clipboard.writeText(draft.innerText).catch(() => {}); + const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`); + if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); } + } +} + +async function commsSendReply(id) { + const btn = document.getElementById('comms-send-' + id); + const draft = document.getElementById('comms-draft-' + id); + if (!btn || !draft) return; + btn.disabled = true; + btn.textContent = '◈ SENDING…'; + try { + const res = await api('arc', 'POST', { + action: 'job_create', + type: 'send_email', + payload: { triage_id: id, content: draft.innerText }, + priority: 8, + }); + if (res.job_id) { + btn.textContent = '◈ SENT ✓'; + btn.style.color = '#00ff88'; + setTimeout(() => loadComms(), 3000); + loadCommsOutbox(); + } else { + btn.disabled = false; + btn.textContent = '◈ SEND REPLY'; + alert('Send failed: ' + (res.error || 'unknown error')); + } + } catch(e) { + btn.disabled = false; + btn.textContent = '◈ SEND REPLY'; + } +} + +function commsShowCompose() { + const existing = document.getElementById('comms-compose-modal'); + if (existing) existing.remove(); + const modal = document.createElement('div'); + modal.className = 'comms-compose-modal'; + modal.id = 'comms-compose-modal'; + modal.innerHTML = ` +
+
◈ COMPOSE MESSAGE
+ + + + + +
+ + + +
+
+
`; + document.body.appendChild(modal); + modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); }); +} + +let _ccDraftedBody = ''; + +async function commsComposeDraft() { + const to = document.getElementById('cc-to')?.value.trim(); + const subject = document.getElementById('cc-subject')?.value.trim(); + const instructions = document.getElementById('cc-instructions')?.value.trim(); + const account = document.getElementById('cc-account')?.value; + const status = document.getElementById('cc-status'); + if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; } + if (status) status.textContent = '◈ DRAFTING…'; + try { + const res = await api('arc', 'POST', { + action: 'job_create', type: 'compose_email', + payload: { recipient: to, subject, instructions, account, auto_send: false }, + priority: 7, + }); + if (!res.job_id) throw new Error(res.error || 'No job'); + // poll for result + let attempts = 0; + const poll = async () => { + const job = await api('arc?action=job_get&id=' + res.job_id); + if (job.status === 'done' && job.result?.drafted_body) { + _ccDraftedBody = job.result.drafted_body; + document.getElementById('cc-preview-body').textContent = _ccDraftedBody; + document.getElementById('cc-preview').style.display = 'block'; + document.getElementById('cc-send-btn').style.display = ''; + if (status) status.textContent = '◈ DRAFT READY — Review and send'; + } else if (job.status === 'failed') { + if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown'); + } else if (attempts++ < 20) { + setTimeout(poll, 1500); + } else { + if (status) status.textContent = '◈ Job still running — check INTEL tab'; + } + }; + setTimeout(poll, 1500); + } catch(e) { + if (status) status.textContent = '✗ Error: ' + e.message; + } +} + +async function commsComposeAndSend() { + const to = document.getElementById('cc-to')?.value.trim(); + const subject = document.getElementById('cc-subject')?.value.trim(); + const account = document.getElementById('cc-account')?.value; + const status = document.getElementById('cc-status'); + const btn = document.getElementById('cc-send-btn'); + if (!to || !_ccDraftedBody) return; + if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; } + if (status) status.textContent = '◈ TRANSMITTING…'; + try { + const res = await api('arc', 'POST', { + action: 'job_create', type: 'send_email', + payload: { to_email: to, subject, body: _ccDraftedBody, account }, + priority: 9, + }); + if (res.job_id) { + if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')'; + setTimeout(() => { + document.getElementById('comms-compose-modal')?.remove(); + loadCommsOutbox(); + }, 1500); + } else { + if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; } + if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown'); + } + } catch(e) { + if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; } + if (status) status.textContent = '✗ Error: ' + e.message; + } +} + +async function loadCommsOutbox() { + const el = document.getElementById('comms-outbox'); + if (!el) return; + try { + const data = await api('arc?action=comms_sent&limit=20'); + const sent = Array.isArray(data) ? data : (data.sent || []); + if (!sent.length) { + el.innerHTML = '
No sent messages yet
'; + return; + } + const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'}; + let html = ''; + for (const m of sent) { + const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—'; + const sc = m.status || 'sent'; + html += `
+
+
TO: ${escHtml((m.to_email||'').substring(0,40))}
+ ${sc.toUpperCase()} +
+
${escHtml((m.subject||'(no subject)').substring(0,60))}
+
${ts} · ${m.account||'gmail'}
+
`; + } + el.innerHTML = html; + } catch(e) { + el.innerHTML = '
OUTBOX OFFLINE
'; + } +} + +function commsTriageNow() { + const input = document.getElementById('textInput'); + if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); } +} + +function startCommsPolling() { + if (_commsPollTimer) return; + _commsPollTimer = setInterval(() => { + if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); } + }, 8000); +} + +function stopCommsPolling() { + if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; } +} + +// ── GUARDIAN MODE ───────────────────────────────────────────────────────────── +let _guardianPollTimer = null; +let _guardianChatTimer = null; +let _guardianLastChat = ''; +let _guardianUnread = 0; + +async function loadGuardian() { + const el = document.getElementById('guardian-list'); + if (!el) return; + + try { + const [statusData, eventsData] = await Promise.all([ + api('arc?action=guardian_status').catch(() => ({})), + api('arc?action=guardian_events&limit=40').catch(() => []), + ]); + + const events = Array.isArray(eventsData) ? eventsData : []; + const status = statusData || {}; + const counts = status.counts || {}; + const unread = parseInt(counts.unread || 0); + const critU = parseInt(counts.critical_unread || 0); + + _guardianUnread = unread; + _updateGuardianBadge(unread, critU); + if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') { + new Notification('JARVIS ALERT', { + body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.', + icon: '/favicon.ico', + }); + } + + const lastScan = status.last_scan + ? new Date(status.last_scan + 'Z').toLocaleTimeString() + : '—'; + + let html = `
+ ◈ GUARDIAN MODE + + ${status.enabled ? '● ACTIVE' : '○ INACTIVE'} + + SCAN: ${lastScan} + ${unread ? `` : ''} + +
`; + + if (!events.length) { + html += '
◈ ALL CLEAR
Guardian is watching...
'; + } else { + for (const ev of events) { + const sev = ev.severity || 'info'; + const acked = ev.acknowledged; + const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : ''; + const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡', + mem_high:'⚡',disk_high:'💾',service_down:'✗', + service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈'; + html += `
+ ${sev.toUpperCase()} +
+
${typeIco} ${escHtml(ev.message||'')}
+ ${ev.ai_analysis ? `
${escHtml(ev.ai_analysis.substring(0,200))}
` : ''} +
+
+ ${ts} + ${!acked ? `` : ''} +
+
`; + } + } + el.innerHTML = html; + startGuardianPolling(); + + } catch(e) { + if (el) el.innerHTML = '
GUARDIAN OFFLINE
'; + } +} + +function _updateGuardianBadge(unread, critical) { + const dot = document.getElementById('bb-guardian-dot'); + const badge = document.getElementById('bb-guardian-badge'); + const status = document.getElementById('bb-guardian-status'); + if (!dot) return; + dot.className = 'bb-dot'; + if (critical > 0) { + dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)'; + } else if (unread > 0) { + dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623'; + } else { + dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)'; + } + if (unread > 0) { + badge.textContent = unread; badge.style.display = 'inline'; + } else { + badge.style.display = 'none'; + } +} + +async function guardianAck(id) { + await api('arc?action=guardian_ack&id=' + id).catch(() => {}); + const ev = document.getElementById('gev-' + id); + if (ev) ev.classList.add('acked'); + _guardianUnread = Math.max(0, _guardianUnread - 1); + _updateGuardianBadge(_guardianUnread, 0); +} + +async function guardianAckAll() { + await api('arc?action=guardian_ack').catch(() => {}); + loadGuardian(); +} + +function guardianSitrep() { + const input = document.getElementById('textInput'); + if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); } +} + +function switchGuardianTab() { + const btn = document.getElementById('tab-btn-guardian'); + if (btn) btn.click(); +} + +function startGuardianPolling() { + if (_guardianPollTimer) return; + _guardianPollTimer = setInterval(() => { + if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian(); + else _refreshGuardianBadge(); + }, 30000); +} + +async function _refreshGuardianBadge() { + const s = await api('arc?action=guardian_status').catch(() => null); + if (!s) return; + const counts = s.counts || {}; + _updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0)); +} + +// Proactive chat polling — checks for guardian-injected messages every 30s +let _proactiveChatLastId = 0; +async function _pollProactiveChat() { + try { + const rows = await api('arc?action=guardian_chat').catch(() => []); + if (!Array.isArray(rows)) return; + for (const row of rows) { + if (row.id > _proactiveChatLastId) { + _proactiveChatLastId = row.id; + // Don't spam on first load — only show messages from last 5 min + const age = Date.now() - new Date(row.created_at + 'Z').getTime(); + if (age < 300000) { + addMessage('jarvis', row.message); + speak(row.message); + } + } + } + } catch(e) {} +} + diff --git a/public_html/assets/js/panels/jarvis-assistant.js b/public_html/assets/js/panels/jarvis-assistant.js new file mode 100644 index 0000000..79cf66e --- /dev/null +++ b/public_html/assets/js/panels/jarvis-assistant.js @@ -0,0 +1,345 @@ +// ── CHAT HISTORY SEARCH ─────────────────────────────────────────────────────── +function openSearchModal() { + document.getElementById('searchModal').style.display = 'flex'; + document.getElementById('searchInput').focus(); +} +function closeSearchModal() { + document.getElementById('searchModal').style.display = 'none'; + document.getElementById('searchResults').innerHTML = '
Type to search your JARVIS conversations
'; + document.getElementById('searchInput').value = ''; +} +async function runSearch() { + const q = document.getElementById('searchInput').value.trim(); + if (!q) return; + const el = document.getElementById('searchResults'); + el.innerHTML = '
Searching...
'; + try { + const d = await api('history?q=' + encodeURIComponent(q)); + if (!d.results || !d.results.length) { + el.innerHTML = '
No results for "' + q + '"
'; + return; + } + el.innerHTML = d.results.map(r => { + const role = r.role === 'user' ? '👤' : '🤖'; + const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); + const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content; + return `
+
+ ${role} ${r.role.toUpperCase()} + ${ts} +
+
${snippet.replace(/ +
`; + }).join(''); + } catch(e) { + el.innerHTML = '
Search failed
'; + } +} +document.getElementById('searchModal')?.addEventListener('click', e => { + if (e.target === document.getElementById('searchModal')) closeSearchModal(); +}); + +// ── PROACTIVE SUGGESTIONS ──────────────────────────────────────────────────── +const _shownSuggestions = new Set(); +async function checkSuggestions() { + const d = await api('suggestions').catch(() => null); + if (!d || !d.suggestions || !d.suggestions.length) return; + for (const s of d.suggestions) { + const key = s.intent + ':' + d.hour + ':' + d.dow; + if (_shownSuggestions.has(key)) continue; + _shownSuggestions.add(key); + // Show as a soft suggestion chip in chat + const log = document.getElementById('chatLog'); + const chip = document.createElement('div'); + chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0'; + chip.innerHTML = ``; + log.appendChild(chip); + log.scrollTop = log.scrollHeight; + break; // show max one suggestion at a time + } +} + +function sendSuggestion(intent, btn) { + btn.closest('div').remove(); + const prompts = { + 'network_scan': 'run a network scan', + 'jellyfin_now_playing': 'what is playing on Jellyfin', + 'ha_scene': 'what scenes are available', + 'planner:briefing': 'daily briefing', + 'vm_suggestions': 'VM resource suggestions', + 'focus_mode': 'focus mode', + }; + const msg = prompts[intent] || intent.replace(/_/g,' '); + document.getElementById('textInput').value = msg; + sendMessage(); +} + +// ── MOBILE PANEL SWITCHER ───────────────────────────────────────────────────── +function mobSwitch(which) { + if (window.innerWidth > 900) return; + const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'}; + Object.entries(panels).forEach(([k, id]) => { + document.getElementById(id)?.classList.toggle('mob-active', k === which); + }); + document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active')); + document.getElementById('mob-btn-' + which)?.classList.add('active'); + if (which === 'right') loadNews(); +} +function initMobile() { + if (window.innerWidth > 900) return; + ['leftPanel','centerPanel','rightPanel'].forEach(id => + document.getElementById(id)?.classList.remove('mob-active')); + document.getElementById('leftPanel')?.classList.add('mob-active'); + document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active')); + document.getElementById('mob-btn-left')?.classList.add('active'); +} +window.addEventListener('resize', initMobile); + +// ── COMMAND PALETTE (Ctrl+K) ────────────────────────────────────────────── +const _PALETTE_COMMANDS = [ + { label: 'Run a network scan', q: 'run a network scan', group: 'Network' }, + { label: 'Show online devices', q: 'who is online on the network', group: 'Network' }, + { label: 'Proxmox status', q: 'proxmox status', group: 'Network' }, + { label: 'Check agent status', q: 'check all agents', group: 'Agents' }, + { label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' }, + { label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' }, + { label: 'Daily briefing', q: 'daily briefing', group: 'Planner' }, + { label: 'My tasks today', q: 'my tasks today', group: 'Planner' }, + { label: 'My calendar', q: 'my calendar', group: 'Planner' }, + { label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' }, + { label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' }, + { label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' }, + { label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' }, + { label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'}, + { label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'}, + { label: 'Focus mode', q: 'focus mode', group: 'UI' }, + { label: 'Show all panels', q: 'show all panels', group: 'UI' }, + { label: 'Check alerts', q: 'check alerts', group: 'System' }, + { label: 'Site health', q: 'site health', group: 'System' }, + { label: 'System status', q: 'system status', group: 'System' }, + { label: 'Check inbox', q: 'check inbox', group: 'Comms' }, + { label: 'Search history…', q: '', group: 'Chat', search: true }, +]; + +let _paletteOpen = false; + +function openPalette() { + if (_paletteOpen) return; + _paletteOpen = true; + const ov = document.getElementById('cmdPalette'); + if (!ov) return; + ov.style.display = 'flex'; + const inp = document.getElementById('cmdPaletteInput'); + inp.value = ''; + renderPaletteItems(''); + requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); }); +} + +function closePalette() { + if (!_paletteOpen) return; + _paletteOpen = false; + const ov = document.getElementById('cmdPalette'); + if (!ov) return; + ov.classList.remove('open'); + setTimeout(() => { ov.style.display = 'none'; }, 180); +} + +function renderPaletteItems(q) { + const list = document.getElementById('cmdPaletteList'); + if (!list) return; + const low = q.toLowerCase().trim(); + const filtered = low + ? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low)) + : _PALETTE_COMMANDS; + + let currentGroup = null; + list.innerHTML = ''; + filtered.forEach((cmd, i) => { + if (cmd.group !== currentGroup) { + currentGroup = cmd.group; + const g = document.createElement('div'); + g.className = 'cp-group'; + g.textContent = cmd.group; + list.appendChild(g); + } + const row = document.createElement('div'); + row.className = 'cp-item' + (i === 0 ? ' cp-active' : ''); + row.dataset.q = cmd.q; + row.dataset.search = cmd.search ? '1' : ''; + const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'), + m => `${m}`); + row.innerHTML = `${lbl}`; + row.addEventListener('click', () => firePaletteItem(row)); + list.appendChild(row); + }); +} + +function movePaletteSelection(dir) { + const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item')); + if (!items.length) return; + const cur = items.findIndex(el => el.classList.contains('cp-active')); + const next = (cur + dir + items.length) % items.length; + items.forEach(el => el.classList.remove('cp-active')); + items[next].classList.add('cp-active'); + items[next].scrollIntoView({ block: 'nearest' }); +} + +function firePaletteItem(el) { + if (!el) { + const active = document.querySelector('#cmdPaletteList .cp-active'); + if (!active) return; + el = active; + } + const q = el.dataset.q; + const isSearch = el.dataset.search === '1'; + closePalette(); + if (isSearch) { + if (typeof openSearchModal === 'function') openSearchModal(); + return; + } + if (q) { + document.getElementById('textInput').value = q; + sendMessage(); + } +} + +// Keyboard events +document.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + _paletteOpen ? closePalette() : openPalette(); + return; + } + if (!_paletteOpen) return; + if (e.key === 'Escape') { e.preventDefault(); closePalette(); } + if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); } + if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); } +}); + +// Filter on type +document.getElementById('cmdPaletteInput')?.addEventListener('input', e => { + renderPaletteItems(e.target.value); +}); + +// Close on backdrop click +document.getElementById('cmdPalette')?.addEventListener('click', e => { + if (e.target.id === 'cmdPalette') closePalette(); +}); + +// ── AGENT TOPOLOGY MAP ───────────────────────────────────────────────────────────── +let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = []; + +function toggleAgentTopo() { + _agentTopoMode = !_agentTopoMode; + const btn = document.getElementById('agent-topo-btn'); + const list = document.getElementById('agents-list'); + const cvs = document.getElementById('agentTopoCanvas'); + if (!btn || !list || !cvs) return; + btn.classList.toggle('active', _agentTopoMode); + if (_agentTopoMode) { + list.style.display = 'none'; cvs.style.display = 'block'; + _buildAgentTopoData(); _drawAgentTopo(); + } else { + list.style.display = 'block'; cvs.style.display = 'none'; + if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; } + } +} + +function _buildAgentTopoData() { + // Build node list from rendered agent cards + _agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}]; + document.querySelectorAll('.agent-card').forEach(el => { + const nameEl = el.querySelector('.agent-name, [class*="name"]'); + if (!nameEl) return; + const name = nameEl.textContent.trim(); + const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online'); + const lname = name.toLowerCase(); + let type = 'linux'; + if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox'; + else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant'; + else if (lname.includes('windows') || lname.includes('mini')) type = 'windows'; + _agentTopoData.push({id:name, label:name.substring(0,12), online, type}); + }); + // Fallback: use last known registered agent list if cards not rendered + if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') { + (_lastAgents || []).forEach(a => { + _agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'}); + }); + } +} + +function _drawAgentTopo() { + const cvs = document.getElementById('agentTopoCanvas'); + if (!cvs || !_agentTopoMode) return; + const ctx = cvs.getContext('2d'); + const rect = cvs.getBoundingClientRect(); + const W = rect.width || 280, H = rect.height || 260; + const dpr = window.devicePixelRatio || 1; + cvs.width = W * dpr; cvs.height = H * dpr; + ctx.scale(dpr, dpr); + const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68}; + const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'}; + // Assign positions + const byType = {}; + _agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); }); + _agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2; + Object.entries(byType).forEach(([tp, nodes]) => { + const rf = typeRing[tp] || 0.68; + const r = Math.min(W, H) / 2 * rf; + nodes.forEach((n, i) => { + const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2; + n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r; + }); + }); + let t = 0; + function frame() { + if (!_agentTopoMode) return; + t += 0.007; ctx.clearRect(0, 0, W, H); + // Orbit rings + [0.28, 0.48, 0.68].forEach(rf => { + ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2); + ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke(); + }); + // Edges + _agentTopoData.slice(1).forEach(n => { + if (!n.x) return; + const col = typeColor[n.type] || '0,190,255'; + ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y); + ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)'; + ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke(); + }); + // Particles + _agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => { + const p = ((t*0.35+i*0.41)%1); + const col = typeColor[n.type]||'0,190,255'; + const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p; + ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2); + ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill(); + }); + // Nodes + _agentTopoData.forEach((n,i) => { + if (!n.x) return; + const col = typeColor[n.type]||'0,190,255'; + const nr = n.type==='hub' ? 13 : 7; + const pulse = Math.sin(t+i*0.9)*0.25+0.75; + if (n.online||n.type==='hub') { + const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5); + g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')'); + g.addColorStop(1,'transparent'); + ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2); + ctx.fillStyle=g; ctx.fill(); + } + ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2); + ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)'; + ctx.fill(); + ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke(); + ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)'; + ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace'; + ctx.textAlign='center'; + ctx.fillText(n.label, n.x, n.y+nr+9); + }); + _agentTopoRaf = requestAnimationFrame(frame); + } + frame(); +} diff --git a/public_html/bridge.php b/public_html/bridge.php new file mode 100644 index 0000000..b7e6ee1 --- /dev/null +++ b/public_html/bridge.php @@ -0,0 +1,10 @@ + + + diff --git a/public_html/index.html b/public_html/index.html new file mode 100644 index 0000000..3d8b1a3 --- /dev/null +++ b/public_html/index.html @@ -0,0 +1,478 @@ + + + + + +JARVIS — Integrated Defense and Logistics System + + + + + + + + +
+
+
+
+
+
+
+
TRACKING
+
+
+
+ + +
+ +

JARVIS

+

Just A Rather Very Intelligent System

+ +
+ + +
+ +
+ +
+
LOCAL --% CPU
+
MEM --%
+
JARVIS VM --
+
NO ALERTS
+ +
+
+
+
--:--:--
+
LOADING...
+
+
+ + + + + +
+ + + +
+ + +
+
+ + +
+ +
+ +
+
WEATHER FORT WORTH, TX
+
+
+
+ -- + °F +
+
LOADING...
+
+
+
+
FEELS LIKE
+
--°F
+
HUMIDITY
+
--%
+
+
+
+
+
+
JARVIS SERVER 10.48.200.211
+ + +
+
CPU --%
+
+
+
+
+
MEMORY --%
+
+
+
+
+
DISK --%
+
+
+
+
UPTIME
--
+
LOAD
--
+
HOST
--
+ + +
SERVICES
+
+
+
+ + +
WEBSITES
+
+
+
+ + +
PROCESSES
+
+
+
+
+ +
WEB HOST
+
+
CPU
--%
+
RAM
--%
+
DISK
--%
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
◈ JARVIS ONLINE — AWAITING INSTRUCTIONS ◈
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ CONTEXT + + +
+
+ + + + +
+
+
+ + +
+ + +
+
NETWORK STATUS
+ +
+
+
+
+
+ +
+ + +
+ +
+ ◈ CLEARANCE REQUIRED — + 0 + PENDING AUTHORIZATION + VIEW → +
+
+ +
HOME
+
ALERTS
+
NEWS
+
AGENTS
+
SITES
+
INTEL
+
COMMS
+
GUARDIAN
+
MISSIONS
+
DIRECTIVES
+
CLEARANCE
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
✎ NOTE
+
+ + +
+
+
+ JARVIS CORE ONLINE +
+
+
+ JARVIS VM CHECKING +
+
+
+ PROXMOX CHECKING +
+
+
+ HOME ASSISTANT CHECKING +
+
+
+ AGENTS -- +
+ +
+
+ ARC REACTOR OFFLINE +
+ +
+
+ GUARDIAN INIT + +
+ +
+
+ MEMORY -- +
+ +
+
+ JARVIS v2.0 · SECURITY LEVEL ALPHA · UPDATED --:--:-- +
+
+
+ + +
+
+
◈ NETWORK TOPOLOGY — LIVE
+
NODES  ·  ONLINE  ·  AGENTS
+
+ SAY "CLOSE MAP" + +
+
+ +
+ PROXMOX + SERVICES + AGENTS + DEVICES + NETWORK + OFFLINE + CYAN = DATA IN · ORANGE = CMD OUT +
+
+
+ +
+
+ +
+
+ ↑↓ navigate↵ executeESC close + 1-4 tabs · M mute · F5 refresh · Space→input +
+
+
+ + +
+
+
+
+
+
+
+
JARVIS — STANDBY
+
SAY "WAKE UP JARVIS" TO RESUME
+
+ + + +
+
+ +

● JARVIS AGENT

+
+
+
+ + + + + + + + + + + + + +
+
+ ◈ VISION PROTOCOL + +
+
● SCANNING...
+ +

+
+ + + + + + + diff --git a/public_html/index.php b/public_html/index.php new file mode 100644 index 0000000..cf1adcc --- /dev/null +++ b/public_html/index.php @@ -0,0 +1,38 @@ +' + . 'var __jarvisToken=' . json_encode($token) . ';' + . 'var __jarvisUser=' . json_encode($name) . ';' + . 'try{sessionStorage.setItem("jarvis_token",__jarvisToken);' + . 'sessionStorage.setItem("jarvis_user",__jarvisUser);}catch(e){}' + . ''; +$html = str_replace('', '' . $inject, $html); + +// Force login screen hidden and app visible at HTML level so the dashboard +// shows immediately regardless of JS execution order +$html = str_replace( + '
', + '
', + $html +); +$html = str_replace( + '
', + '
', + $html +); + +echo $html; diff --git a/public_html/install-agent.sh b/public_html/install-agent.sh new file mode 100755 index 0000000..ff7374e --- /dev/null +++ b/public_html/install-agent.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# JARVIS Agent Installer — one-liner for any Linux host: +# curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s +# +# agent_type: linux | proxmox | homeassistant +# Example: curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s myserver linux + +set -e + +HOSTNAME_ARG="${1:-$(hostname -s)}" +AGENT_TYPE="${2:-linux}" +JARVIS_URL="https://165.22.1.228" +JARVIS_HOST="jarvis.orbishosting.com" +INSTALL_DIR="/opt/jarvis-agent" +CONFIG_DIR="/etc/jarvis-agent" +STATE_DIR="/var/lib/jarvis-agent" +REG_KEY="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" +SERVICE_FILE="/etc/systemd/system/jarvis-agent.service" + +echo "=== JARVIS Agent Installer v3.0 ===" +echo "Host: $HOSTNAME_ARG | Type: $AGENT_TYPE | Server: $JARVIS_URL" + +# ── Dependencies ────────────────────────────────────────────────────────────── +if command -v apt-get &>/dev/null; then + apt-get install -yq python3 curl imagemagick 2>/dev/null || true + apt-get install -yq python3-psutil python3-requests 2>/dev/null || true +elif command -v yum &>/dev/null; then + yum install -yq python3 curl ImageMagick 2>/dev/null || true +elif command -v apk &>/dev/null; then + apk add --no-cache python3 curl imagemagick 2>/dev/null || true +fi + +# pip fallback if psutil/requests not available via package manager +python3 -c "import psutil, requests" 2>/dev/null || pip3 install -q --break-system-packages requests psutil 2>/dev/null || pip3 install -q requests psutil 2>/dev/null || true + +# ── Create directories ───────────────────────────────────────────────────────── +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR" + +# ── Download agent ───────────────────────────────────────────────────────────── +echo "Downloading agent..." +curl -sk -H "Host: $JARVIS_HOST" "$JARVIS_URL/agent/jarvis-agent.py" -o "$INSTALL_DIR/jarvis-agent.py" +cp "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py +chmod +x "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py + +# ── Write config (skip if already exists) ──────────────────────────────────── +if [[ -f "$CONFIG_DIR/config.json" ]]; then + echo "Config already exists at $CONFIG_DIR/config.json — keeping existing settings." +else + cat > "$CONFIG_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "host_header": "$JARVIS_HOST", + "ssl_verify": false, + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME_ARG", + "agent_type": "$AGENT_TYPE", + "poll_interval": 30, + "heartbeat_every": 10, + "update_check_hours": 24, + "watch_services": [] +} +JSONEOF + chmod 600 "$CONFIG_DIR/config.json" + echo "Config written to $CONFIG_DIR/config.json" +fi + +# ── Systemd service ──────────────────────────────────────────────────────────── +cat > "$SERVICE_FILE" << SVCEOF +[Unit] +Description=JARVIS Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 $INSTALL_DIR/jarvis-agent.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +SVCEOF + +systemctl daemon-reload +systemctl enable jarvis-agent +systemctl restart jarvis-agent + +sleep 2 +if systemctl is-active --quiet jarvis-agent; then + echo "" + echo "=== JARVIS Agent v3.0 installed and running ===" + echo "Config: $CONFIG_DIR/config.json" + echo "State: $STATE_DIR/state.json (created on first run)" + echo "Logs: journalctl -u jarvis-agent -f" +else + echo "" + echo "WARNING: Agent installed but not running. Check: journalctl -u jarvis-agent -n 30" +fi diff --git a/public_html/login.php b/public_html/login.php new file mode 100644 index 0000000..3f4873f --- /dev/null +++ b/public_html/login.php @@ -0,0 +1,79 @@ + PDO::ERRMODE_EXCEPTION]); + $row = $pdo->prepare('SELECT * FROM users WHERE username=? LIMIT 1'); + $row->execute([$u]); + $user = $row->fetch(PDO::FETCH_ASSOC); + if ($user && password_verify($p, $user['password_hash'])) { + $token = bin2hex(random_bytes(32)); + $_SESSION['jarvis_token'] = $token; + $_SESSION['jarvis_user_id'] = $user['id']; + $_SESSION['jarvis_name'] = $user['display_name']; + $pdo->prepare('UPDATE users SET last_seen=NOW() WHERE id=?')->execute([$user['id']]); + header('Location: /'); + exit; + } + $error = 'ACCESS DENIED'; + } else { $error = 'ENTER CREDENTIALS'; } +} +?> + + +JARVIS + + + + +
+
+
+
+
+
+
+

JARVIS

+

Just A Rather Very Intelligent System

+
+ + + +
+
+
+ diff --git a/public_html/webhook.php b/public_html/webhook.php new file mode 100644 index 0000000..258dc6e --- /dev/null +++ b/public_html/webhook.php @@ -0,0 +1,58 @@ + 'Webhook not configured']); + exit; +} +define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt'); +define('DEPLOY_LOG', '/var/www/jarvis/logs/deploy.log'); + +header('Content-Type: application/json'); + +$payload = file_get_contents('php://input'); +$sig = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; +$expected = 'sha256=' . hash_hmac('sha256', $payload, WEBHOOK_SECRET); + +if (!hash_equals($expected, $sig)) { + http_response_code(403); + echo json_encode(['error' => 'Invalid signature']); + exit; +} + +$data = json_decode($payload, true); +$repo = $data['repository']['name'] ?? ''; +$ref = $data['ref'] ?? ''; +$pusher = $data['pusher']['name'] ?? 'unknown'; + +// Only deploy on pushes to main +if ($ref !== 'refs/heads/main') { + echo json_encode(['ok' => true, 'skipped' => "ref $ref is not main"]); + exit; +} + +$repoMap = [ + 'jarvis' => '/var/www/jarvis', +]; + +if (!isset($repoMap[$repo])) { + http_response_code(404); + echo json_encode(['error' => "Unknown repo: $repo"]); + exit; +} + +$path = $repoMap[$repo]; +$ts = date('Y-m-d H:i:s'); + +file_put_contents(DEPLOY_QUEUE, $path . "\n", FILE_APPEND | LOCK_EX); +file_put_contents(DEPLOY_LOG, "[$ts] Queued deploy: $repo by $pusher -> $path\n", FILE_APPEND | LOCK_EX); + +echo json_encode(['ok' => true, 'queued' => $repo, 'path' => $path]);