From 89a82a157363c560d3d5b965d59df4b703d5cc82 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 13:56:37 -0500 Subject: [PATCH 01/10] Add Windows agent installer, fix Linux install URL - install-windows.ps1: one-liner PowerShell installs Python, pywin32, downloads agent, creates config, installs Windows Service (auto-start) - install.sh: fix JARVIS_URL from hardcoded LAN IP to https://jarvis.orbishosting.com - install.sh: fix ssl_verify default to true for external agents Co-Authored-By: Claude Sonnet 4.6 --- agent/install-windows.ps1 | 151 ++++++++++ public_html/agent/install-windows.ps1 | 397 +++++++++----------------- public_html/agent/install.sh | 4 +- 3 files changed, 288 insertions(+), 264 deletions(-) create mode 100644 agent/install-windows.ps1 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/public_html/agent/install-windows.ps1 b/public_html/agent/install-windows.ps1 index bae0543..f37b1ed 100644 --- a/public_html/agent/install-windows.ps1 +++ b/public_html/agent/install-windows.ps1 @@ -1,278 +1,151 @@ -# JARVIS Agent Installer — Windows (PowerShell) -# Registers the agent as a proper Windows Service (Win 8.1+, no open window required). -# Requires pywin32. Runs the service as LocalSystem. -# -# Run as Administrator: -# Set-ExecutionPolicy Bypass -Scope Process -# .\install-windows.ps1 -JarvisUrl https://jarvis.orbishosting.com -Key YOUR_KEY -# -# One-liner (PowerShell as Admin): -# irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +#Requires -RunAsAdministrator +<# +.SYNOPSIS + JARVIS Agent installer for Windows. -param( - [string]$JarvisUrl = "", - [string]$Key = "", - [string]$AgentName = "" -) +.DESCRIPTION + Installs JARVIS Agent as a Windows Service that auto-starts at boot. + Requires: PowerShell 5.1+, internet access, and Administrator rights. -# param() defaults don't apply when piped through iex — set here as fallback -if (-not $JarvisUrl) { $JarvisUrl = "https://jarvis.orbishosting.com" } -if (-not $Key) { $Key = "f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" } -if (-not $AgentName) { $AgentName = $env:COMPUTERNAME.ToLower() } +.EXAMPLE + # Interactive install (prompts for registration key): + irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex -$ErrorActionPreference = "Stop" -$InstallDir = "C:\ProgramData\jarvis-agent" -$AgentScript = "$InstallDir\jarvis-agent-windows.py" -$ConfigFile = "$InstallDir\config.json" -$ServiceName = "JARVISAgent" -$OldTaskName = "JARVIS-Agent" # legacy scheduled-task name + # Silent install with key: + $env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +#> -Write-Host "" -Write-Host " ====================================" -ForegroundColor Cyan -Write-Host " JARVIS Agent Installer v3.1 " -ForegroundColor Cyan -Write-Host " Windows Service Edition " -ForegroundColor Cyan -Write-Host " ====================================" -ForegroundColor Cyan -Write-Host "" +$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" -# ── Require admin ────────────────────────────────────────────────────────────── -if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Error "Run PowerShell as Administrator and try again." -} +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 } -# ── Prompt if not provided ───────────────────────────────────────────────────── -$JarvisUrl = $JarvisUrl.TrimEnd("/") +Write-Host "`n========================================" -ForegroundColor Yellow +Write-Host " JARVIS Agent Installer for Windows" -ForegroundColor Yellow +Write-Host "========================================`n" -ForegroundColor Yellow -# ── Find or install Python 3 (system-wide so LocalSystem service can reach it) ─ -Write-Host "[1/6] Checking for Python 3..." -ForegroundColor Cyan - -$pythonPath = $null - -# System-wide paths — accessible by LocalSystem service account -$systemPaths = @( - "C:\Program Files\Python313\python.exe", - "C:\Program Files\Python312\python.exe", - "C:\Program Files\Python311\python.exe", - "C:\Program Files\Python310\python.exe", - "C:\Program Files\Python39\python.exe", - "C:\Python313\python.exe", - "C:\Python312\python.exe", - "C:\Python311\python.exe", - "C:\Python310\python.exe" -) - -function Install-PythonSystemWide { - # Try winget first (Win 10 1709+ / Win 11) - $wingetOk = $false - try { - $null = Get-Command winget -ErrorAction Stop - Write-Host " Using winget (system-wide)..." -NoNewline - winget install Python.Python.3.12 --silent --scope machine ` - --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { $wingetOk = $true; Write-Host " done." -ForegroundColor Green } - } catch {} - - if (-not $wingetOk) { - # Direct download — works on Win 8.1 without winget - # Python 3.11 explicitly supports Win 8.1+ - Write-Host " Downloading Python 3.11 (Win 8.1+ compatible)..." -NoNewline - $pyInstaller = "$env:TEMP\python-installer.exe" - $pyUrl = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe" - try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 - $wc = New-Object System.Net.WebClient - $wc.DownloadFile($pyUrl, $pyInstaller) - Write-Host " downloaded." -ForegroundColor Green - } catch { - Write-Error "Could not download Python. Install from https://python.org choosing 'Install for all users', then re-run." - } - Write-Host " Installing system-wide (silent)..." -NoNewline - $proc = Start-Process -FilePath $pyInstaller ` - -ArgumentList "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0" ` - -Wait -PassThru - if ($proc.ExitCode -ne 0) { - Write-Error "Python installer exited $($proc.ExitCode). Install manually from https://python.org then re-run." - } - Write-Host " done." -ForegroundColor Green - Remove-Item $pyInstaller -ErrorAction SilentlyContinue - } - - # Refresh PATH after install - $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + - [System.Environment]::GetEnvironmentVariable("PATH","User") -} - -# ── Search for system-wide Python first ─────────────────────────────────────── -foreach ($p in $systemPaths) { - if (Test-Path $p) { - try { - $ver = & $p --version 2>&1 - if ("$ver" -match "Python 3") { $pythonPath = $p; break } - } catch {} - } -} - -# ── Fall back to PATH — but flag if it's per-user ───────────────────────────── -if (-not $pythonPath) { - foreach ($cmd in @("python", "python3", "py")) { - try { - $ver = & $cmd --version 2>&1 - if ("$ver" -match "Python 3") { - $resolved = (Get-Command $cmd -ErrorAction SilentlyContinue) - if ($resolved) { $pythonPath = $resolved.Source; break } - } - } catch {} - } -} - -# ── If Python is per-user (AppData), install system-wide so LocalSystem can use it ── -$needsSystemPython = $false -if ($pythonPath -and ($pythonPath -match "AppData")) { - Write-Host " Found per-user Python: $pythonPath" -ForegroundColor Yellow - Write-Host " LocalSystem service needs system-wide Python. Installing..." -ForegroundColor Yellow - $needsSystemPython = $true -} elseif (-not $pythonPath) { - Write-Host " Python 3 not found. Installing system-wide..." -ForegroundColor Yellow - $needsSystemPython = $true -} - -if ($needsSystemPython) { - Install-PythonSystemWide - # Locate the newly installed system-wide Python - $pythonPath = $null - foreach ($p in $systemPaths) { - if (Test-Path $p) { - try { - $ver = & $p --version 2>&1 - if ("$ver" -match "Python 3") { $pythonPath = $p; break } - } catch {} - } - } - if (-not $pythonPath) { - Write-Error "System-wide Python not found after install. Open a new Admin PowerShell and re-run." - } -} - -Write-Host " Python: $pythonPath" -ForegroundColor Green - -# ── Install pywin32 (required for Windows service support) ──────────────────── -Write-Host "[2/6] Installing pywin32..." -ForegroundColor Cyan - -# pip install -$pipResult = & $pythonPath -m pip install --upgrade pywin32 2>&1 -if ($LASTEXITCODE -ne 0) { - Write-Error "pip install pywin32 failed (exit $LASTEXITCODE).`n$pipResult`nTry manually: $pythonPath -m pip install pywin32" -} - -# postinstall registers service runner DLLs — non-fatal if it fails -try { - $postResult = & $pythonPath -c "import pywin32_postinstall; pywin32_postinstall.install()" 2>&1 - Write-Host " pywin32 installed." -ForegroundColor Green -} catch { - Write-Host " pywin32 installed (postinstall skipped — service should still work)." -ForegroundColor Yellow -} - -# ── Create install directory and download agent ──────────────────────────────── -Write-Host "[3/6] Downloading agent..." -ForegroundColor Cyan -New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - -try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 - [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } - $wc = New-Object System.Net.WebClient - $wc.Headers.Add("User-Agent", "JARVIS-Installer/3.1") - $wc.DownloadFile("$JarvisUrl/agent/jarvis-agent-windows.py", $AgentScript) - Write-Host " Downloaded to $AgentScript" -ForegroundColor Green -} catch { - Write-Error "Download failed: $_" -} - -# ── Write config ─────────────────────────────────────────────────────────────── -Write-Host "[4/6] Writing config..." -ForegroundColor Cyan -$agentId = "${AgentName}_windows" -$config = [ordered]@{ - jarvis_url = $JarvisUrl - host_header = "" - ssl_verify = $true - registration_key = $Key - agent_type = "windows" - hostname = $AgentName - agent_id = $agentId - poll_interval = 30 - heartbeat_every = 10 - update_check_hours = 24 - watch_services = @("WinDefend", "Spooler") -} | ConvertTo-Json -Depth 3 - -[System.IO.File]::WriteAllText($ConfigFile, $config, [System.Text.UTF8Encoding]::new($false)) -Write-Host " Config: $ConfigFile" -ForegroundColor Green - -# ── Remove legacy scheduled task if present ──────────────────────────────────── -try { - $oldTask = Get-ScheduledTask -TaskName $OldTaskName -ErrorAction SilentlyContinue - if ($oldTask) { - Stop-ScheduledTask -TaskName $OldTaskName -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName $OldTaskName -Confirm:$false -ErrorAction SilentlyContinue - Write-Host " Removed legacy scheduled task '$OldTaskName'." -ForegroundColor Yellow - } -} catch {} - -# ── Register Windows service ─────────────────────────────────────────────────── -Write-Host "[5/6] Registering Windows service '$ServiceName'..." -ForegroundColor Cyan - -# Stop + remove any existing service first -$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +# ── Stop existing service if running ───────────────────────────────────────── +$existing = Get-Service -Name $SERVICE_NAME -ErrorAction SilentlyContinue if ($existing) { - if ($existing.Status -eq "Running") { - Write-Host " Stopping existing service..." -NoNewline - & $pythonPath $AgentScript stop 2>&1 | Out-Null - Start-Sleep -Seconds 3 - Write-Host " stopped." -ForegroundColor Yellow + Write-Step "Stopping existing JARVIS Agent service..." + if ($existing.Status -eq 'Running') { + Stop-Service -Name $SERVICE_NAME -Force + Start-Sleep 2 } - Write-Host " Removing existing service..." -NoNewline - & $pythonPath $AgentScript remove 2>&1 | Out-Null - Start-Sleep -Seconds 2 - Write-Host " removed." -ForegroundColor Yellow + try { + & python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null + } catch {} + Write-OK "Existing service removed." } -# Install the service (--startup auto = start at boot) -& $pythonPath $AgentScript --startup auto install -if ($LASTEXITCODE -ne 0) { - Write-Error "Service registration failed (exit $LASTEXITCODE). Check that pywin32 postinstall completed." +# ── 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." } -# Configure failure recovery: restart after 5s, 10s, 30s -sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null -Write-Host " Service registered with auto-restart on failure." -ForegroundColor Green +# ── Create install dir ──────────────────────────────────────────────────────── +Write-Step "Creating install directory..." +New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null +Write-OK $INSTALL_DIR -# ── Start the service ────────────────────────────────────────────────────────── -Write-Host "[6/6] Starting service..." -ForegroundColor Cyan -& $pythonPath $AgentScript start -Start-Sleep -Seconds 4 +# ── 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: $_" +} -$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue -$status = if ($svc) { $svc.Status } else { "NotFound" } -$color = if ($status -eq "Running") { "Green" } else { "Yellow" } -Write-Host " Service status: $status" -ForegroundColor $color +# ── 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." } +} -Write-Host "" -Write-Host " ====================================" -ForegroundColor Green -Write-Host " Installation complete! " -ForegroundColor Green -Write-Host " ====================================" -ForegroundColor Green -Write-Host "" -Write-Host " Machine : $AgentName ($agentId)" -ForegroundColor White -Write-Host " JARVIS : $JarvisUrl" -ForegroundColor White -Write-Host " Python : $pythonPath" -ForegroundColor White -Write-Host " Logs : $InstallDir\jarvis-agent.log" -ForegroundColor White -Write-Host "" -Write-Host " Manage the service:" -ForegroundColor Gray -Write-Host " Get-Service JARVISAgent" -ForegroundColor Gray -Write-Host " Start-Service JARVISAgent" -ForegroundColor Gray -Write-Host " Stop-Service JARVISAgent" -ForegroundColor Gray -Write-Host " Restart-Service JARVISAgent" -ForegroundColor Gray -Write-Host " Get-Content '$InstallDir\jarvis-agent.log' -Tail 30 -Wait" -ForegroundColor Gray -Write-Host "" -Write-Host " To uninstall:" -ForegroundColor Gray -Write-Host " Stop-Service JARVISAgent" -ForegroundColor Gray -Write-Host " & '$pythonPath' '$AgentScript' remove" -ForegroundColor Gray -Write-Host "" +# ── 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 index edb6161..a23c469 100644 --- a/public_html/agent/install.sh +++ b/public_html/agent/install.sh @@ -9,7 +9,7 @@ set -e HOSTNAME_ARG="${1:-$(hostname -s)}" AGENT_TYPE="${2:-linux}" -JARVIS_URL="http://10.48.200.211" +JARVIS_URL="${JARVIS_URL:-https://jarvis.orbishosting.com}" JARVIS_HOST="" INSTALL_DIR="/opt/jarvis-agent" CONFIG_DIR="/etc/jarvis-agent" @@ -50,7 +50,7 @@ else { "jarvis_url": "$JARVIS_URL", "host_header": "$JARVIS_HOST", - "ssl_verify": false, + "ssl_verify": true, "registration_key": "$REG_KEY", "hostname": "$HOSTNAME_ARG", "agent_type": "$AGENT_TYPE", From 84cd2ded5066c71c5c260145ba30d8e8d425097e Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 15:44:28 -0500 Subject: [PATCH 02/10] Add HA poller, fix domain filters, create missing DB tables, update schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jarvis-ha-poller.py: new service polling HA entities → JARVIS (running on VM211) - ha.php: add camera/siren/remote/todo/lawn_mower to skipDomains - db/schema.sql: add tasks, appointments, usage_patterns tables; fix registered_agents enum (windows/macos) + version column Co-Authored-By: Claude Sonnet 4.6 --- agent/jarvis-ha-poller.py | 236 ++++++++++++++++++++++++++ api/endpoints/ha.php | 3 +- db/schema.sql | 210 ++++++++++++++++++++++- public_html/agent/jarvis-ha-poller.py | 236 ++++++++++++++++++++++++++ 4 files changed, 676 insertions(+), 9 deletions(-) create mode 100644 agent/jarvis-ha-poller.py create mode 100644 public_html/agent/jarvis-ha-poller.py diff --git a/agent/jarvis-ha-poller.py b/agent/jarvis-ha-poller.py new file mode 100644 index 0000000..372859b --- /dev/null +++ b/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/api/endpoints/ha.php b/api/endpoints/ha.php index 648ad3a..89c7637 100644 --- a/api/endpoints/ha.php +++ b/api/endpoints/ha.php @@ -83,7 +83,8 @@ if ($method === 'POST' && $action === 'service') { // 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']; + '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', diff --git a/db/schema.sql b/db/schema.sql index 8cf7fd6..5742f63 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,9 @@ /*M!999999\- enable the sandbox mode */ +-- MariaDB dump 10.19 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64) +-- +-- Host: localhost Database: jarvis_db +-- ------------------------------------------------------ +-- Server version 10.11.14-MariaDB-0ubuntu0.24.04.1 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; @@ -10,6 +15,11 @@ /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `agent_commands` +-- + DROP TABLE IF EXISTS `agent_commands`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -28,6 +38,11 @@ CREATE TABLE `agent_commands` ( KEY `idx_created` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `agent_metrics` +-- + DROP TABLE IF EXISTS `agent_metrics`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -40,8 +55,13 @@ CREATE TABLE `agent_metrics` ( PRIMARY KEY (`id`), KEY `idx_agent_time` (`agent_id`,`recorded_at`), KEY `idx_recorded` (`recorded_at`) -) ENGINE=InnoDB AUTO_INCREMENT=28329 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=29445 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `alerts` +-- + DROP TABLE IF EXISTS `alerts`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -58,8 +78,13 @@ CREATE TABLE `alerts` ( `auto_resolve` tinyint(1) DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_source_key` (`source_key`) -) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `api_cache` +-- + DROP TABLE IF EXISTS `api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -70,6 +95,80 @@ CREATE TABLE `api_cache` ( PRIMARY KEY (`cache_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `appointments` +-- + +DROP TABLE IF EXISTS `appointments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `appointments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `description` text DEFAULT NULL, + `category` varchar(64) DEFAULT 'personal', + `start_at` datetime NOT NULL, + `end_at` datetime DEFAULT NULL, + `location` varchar(255) DEFAULT NULL, + `all_day` tinyint(1) DEFAULT 0, + `reminder_min` int(11) DEFAULT 30, + `alerted` tinyint(1) DEFAULT 0, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_start` (`start_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `arc_jobs` +-- + +DROP TABLE IF EXISTS `arc_jobs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `arc_jobs` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `job_type` varchar(64) NOT NULL, + `payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, + `priority` int(11) DEFAULT 0, + `status` enum('queued','running','done','failed','cancelled') DEFAULT 'queued', + `result` longtext DEFAULT NULL, + `error` varchar(2000) DEFAULT NULL, + `created_by` varchar(128) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `started_at` datetime DEFAULT NULL, + `completed_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `arc_status` +-- + +DROP TABLE IF EXISTS `arc_status`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `arc_status` ( + `id` int(11) NOT NULL DEFAULT 1, + `version` varchar(20) DEFAULT NULL, + `started_at` datetime DEFAULT NULL, + `last_heartbeat` datetime DEFAULT NULL, + `active_jobs` int(11) DEFAULT 0, + `jobs_done` int(11) DEFAULT 0, + `jobs_failed` int(11) DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `conversations` +-- + DROP TABLE IF EXISTS `conversations`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -85,6 +184,11 @@ CREATE TABLE `conversations` ( KEY `idx_created` (`created_at`) ) ENGINE=InnoDB AUTO_INCREMENT=325 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `ha_entities` +-- + DROP TABLE IF EXISTS `ha_entities`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -102,8 +206,13 @@ CREATE TABLE `ha_entities` ( UNIQUE KEY `uk_agent_entity` (`agent_id`,`entity_id`), KEY `idx_domain` (`domain`), KEY `idx_updated` (`updated_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=8436 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb_facts` +-- + DROP TABLE IF EXISTS `kb_facts`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -117,8 +226,13 @@ CREATE TABLE `kb_facts` ( `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `unique_fact` (`category`,`fact_key`,`host`) -) ENGINE=InnoDB AUTO_INCREMENT=26088 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=39129 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb_intents` +-- + DROP TABLE IF EXISTS `kb_intents`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -135,6 +249,11 @@ CREATE TABLE `kb_intents` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb_ollama_models` +-- + DROP TABLE IF EXISTS `kb_ollama_models`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -149,6 +268,11 @@ CREATE TABLE `kb_ollama_models` ( UNIQUE KEY `model_name` (`model_name`) ) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `kb_preferences` +-- + DROP TABLE IF EXISTS `kb_preferences`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -161,6 +285,11 @@ CREATE TABLE `kb_preferences` ( UNIQUE KEY `pref_key` (`pref_key`) ) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `known_commands` +-- + DROP TABLE IF EXISTS `known_commands`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -173,6 +302,11 @@ CREATE TABLE `known_commands` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `metrics_history` +-- + DROP TABLE IF EXISTS `metrics_history`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -184,8 +318,13 @@ CREATE TABLE `metrics_history` ( `recorded_at` timestamp NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `idx_metric_time` (`metric_name`,`recorded_at`) -) ENGINE=InnoDB AUTO_INCREMENT=33415 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=34771 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `network_devices` +-- + DROP TABLE IF EXISTS `network_devices`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -203,6 +342,11 @@ CREATE TABLE `network_devices` ( UNIQUE KEY `uk_ip` (`ip`) ) ENGINE=InnoDB AUTO_INCREMENT=409 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `registered_agents` +-- + DROP TABLE IF EXISTS `registered_agents`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -210,10 +354,11 @@ CREATE TABLE `registered_agents` ( `id` int(11) NOT NULL AUTO_INCREMENT, `agent_id` varchar(128) NOT NULL, `hostname` varchar(255) NOT NULL, - `agent_type` enum('linux','homeassistant','proxmox') NOT NULL DEFAULT 'linux', + `agent_type` enum('linux','homeassistant','proxmox','windows','macos') NOT NULL DEFAULT 'linux', `ip_address` varchar(45) DEFAULT NULL, `api_key` varchar(64) NOT NULL, `capabilities` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capabilities`)), + `version` varchar(32) DEFAULT NULL, `last_seen` datetime DEFAULT NULL, `status` enum('online','offline','unknown') NOT NULL DEFAULT 'unknown', `created_at` datetime DEFAULT current_timestamp(), @@ -221,8 +366,56 @@ CREATE TABLE `registered_agents` ( PRIMARY KEY (`id`), UNIQUE KEY `uk_agent_id` (`agent_id`), KEY `idx_status` (`status`) -) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tasks` +-- + +DROP TABLE IF EXISTS `tasks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `tasks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `notes` text DEFAULT NULL, + `category` varchar(64) DEFAULT 'personal', + `priority` enum('urgent','high','normal','low') DEFAULT 'normal', + `status` enum('pending','in_progress','done','cancelled') DEFAULT 'pending', + `due_date` date DEFAULT NULL, + `due_time` time DEFAULT NULL, + `completed_at` datetime DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_status_due` (`status`,`due_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `usage_patterns` +-- + +DROP TABLE IF EXISTS `usage_patterns`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `usage_patterns` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `intent_name` varchar(64) NOT NULL, + `hour` tinyint(2) NOT NULL, + `dow` tinyint(1) NOT NULL, + `hit_count` int(11) DEFAULT 1, + `last_used` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_intent_time` (`intent_name`,`hour`,`dow`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `users` +-- + DROP TABLE IF EXISTS `users`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8mb4 */; @@ -236,7 +429,7 @@ CREATE TABLE `users` ( `created_at` timestamp NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `username` (`username`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -248,3 +441,4 @@ CREATE TABLE `users` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; +-- Dump completed on 2026-06-29 20:43:24 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() From 1f25b5d04d0f9f12075a7f0216f851aaf5a125f9 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 18:13:12 -0500 Subject: [PATCH 03/10] Fix facts_collector JARVIS site URL (was :1972 DO port, now correct URL) Co-Authored-By: Claude Sonnet 4.6 --- api/endpoints/facts_collector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/endpoints/facts_collector.php b/api/endpoints/facts_collector.php index 360a6c0..32841c7 100644 --- a/api/endpoints/facts_collector.php +++ b/api/endpoints/facts_collector.php @@ -181,7 +181,7 @@ function collect_all(): array { $results['sites'] = 'skipped (fresh)'; } else try { $sites = [ - "jarvis" => "http://jarvis.orbishosting.com:1972", + "jarvis" => "https://jarvis.orbishosting.com", 'tomsjavajive' => 'https://tomsjavajive.com', 'epictravelexp'=> 'https://epictravelexpeditions.com', 'parkersling' => 'https://parkerslingshotrentals.com', From 08fbfaa3e46d8d502a85c30f1d9b3b10d7a2834b Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 18:15:53 -0500 Subject: [PATCH 04/10] Seed kb_intents/preferences, fix usage_patterns column, update schema, fix site URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db/seed_kb.sql: 25 intent patterns + user prefs (Myron / Mr. Blair) - usage_patterns: renamed last_used→last_seen to match chat.php - facts_collector: JARVIS self-check URL was port 1972 (DO), now correct URL - db/schema.sql: reflects current live DB schema Co-Authored-By: Claude Sonnet 4.6 --- db/schema.sql | 22 ++++++++-------- db/seed_kb.sql | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 db/seed_kb.sql diff --git a/db/schema.sql b/db/schema.sql index 5742f63..705fee3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -55,7 +55,7 @@ CREATE TABLE `agent_metrics` ( PRIMARY KEY (`id`), KEY `idx_agent_time` (`agent_id`,`recorded_at`), KEY `idx_recorded` (`recorded_at`) -) ENGINE=InnoDB AUTO_INCREMENT=29445 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=31422 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -182,7 +182,7 @@ CREATE TABLE `conversations` ( PRIMARY KEY (`id`), KEY `idx_session` (`session_id`), KEY `idx_created` (`created_at`) -) ENGINE=InnoDB AUTO_INCREMENT=325 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=335 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -206,7 +206,7 @@ CREATE TABLE `ha_entities` ( UNIQUE KEY `uk_agent_entity` (`agent_id`,`entity_id`), KEY `idx_domain` (`domain`), KEY `idx_updated` (`updated_at`) -) ENGINE=InnoDB AUTO_INCREMENT=8436 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=77909 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -226,7 +226,7 @@ CREATE TABLE `kb_facts` ( `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `unique_fact` (`category`,`fact_key`,`host`) -) ENGINE=InnoDB AUTO_INCREMENT=39129 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=41478 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -247,7 +247,7 @@ CREATE TABLE `kb_intents` ( `active` tinyint(1) DEFAULT 1, `created_at` timestamp NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -266,7 +266,7 @@ CREATE TABLE `kb_ollama_models` ( `pulled_at` timestamp NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `model_name` (`model_name`) -) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -283,7 +283,7 @@ CREATE TABLE `kb_preferences` ( `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `pref_key` (`pref_key`) -) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -340,7 +340,7 @@ CREATE TABLE `network_devices` ( `created_at` timestamp NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uk_ip` (`ip`) -) ENGINE=InnoDB AUTO_INCREMENT=409 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=5556 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -406,10 +406,10 @@ CREATE TABLE `usage_patterns` ( `hour` tinyint(2) NOT NULL, `dow` tinyint(1) NOT NULL, `hit_count` int(11) DEFAULT 1, - `last_used` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `last_seen` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uk_intent_time` (`intent_name`,`hour`,`dow`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -441,4 +441,4 @@ CREATE TABLE `users` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-06-29 20:43:24 +-- Dump completed on 2026-06-29 23:15:44 diff --git a/db/seed_kb.sql b/db/seed_kb.sql new file mode 100644 index 0000000..bdf573e --- /dev/null +++ b/db/seed_kb.sql @@ -0,0 +1,70 @@ +-- JARVIS KB Seed Data +-- Preferences +INSERT INTO kb_preferences (pref_key, pref_value) VALUES + ('user_name', 'Myron'), + ('user_title', 'Mr. Blair'), + ('ai_model', 'llama3.1:8b'), + ('timezone', 'America/Chicago') +ON DUPLICATE KEY UPDATE pref_value = VALUES(pref_value); + +-- Intents: greeting, time, system, network, proxmox, ollama, tasks, HA +INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active) VALUES + +-- Greetings +('greeting', '(?i)^(hello|hi|hey|good (morning|afternoon|evening)|what.?s up|howdy)\\b', 'Good {current_time}, {user_title}. All systems are online. How can I assist you?', 'system', 'response', 10, 1), + +-- Time / date +('current_time', '(?i)\\b(what.?s the (time|current time)|what time is it|tell me the time)\\b', 'It is currently {current_time}, {user_title}.', NULL, 'response', 9, 1), +('current_date', '(?i)\\b(what.?s (today.?s date|the date)|what day is it|today.?s date)\\b', 'Today is {current_date}, {user_title}.', NULL, 'response', 9, 1), + +-- System status +('system_status', '(?i)\\b(system (status|health)|how.?s (the system|everything)|jarvis status|all systems)\\b', 'JARVIS is fully operational, {user_title}. CPU: {cpu_usage}%, Memory: {mem_percent}% used ({mem_used_gb}GB / {mem_total_gb}GB). Disk: {disk_used} used of {disk_total}. Uptime: {uptime}. Network agents: {online_count}/{total_count} online.', 'system', 'response', 8, 1), +('cpu_status', '(?i)\\b(cpu|processor) (usage|load|status|percent|utilization)\\b', 'Current CPU usage is {cpu_usage}%, {user_title}. Load averages: {load_1m} (1m), {load_5m} (5m), {load_15m} (15m).', 'system', 'response', 8, 1), +('memory_status', '(?i)\\b(memory|ram|mem) (usage|status|free|used|available)\\b', 'Memory: {mem_used_gb}GB used of {mem_total_gb}GB ({mem_percent}% utilized), {user_title}. Free: {mem_free_gb}GB.', 'system', 'response', 8, 1), +('disk_status', '(?i)\\b(disk|storage|drive) (usage|space|status|free|used|available)\\b', 'Disk status: {disk_used} used of {disk_total} total, {disk_free} free, {user_title}.', 'system', 'response', 8, 1), +('uptime', '(?i)\\b(uptime|how long.*running|how long.*up|server uptime)\\b', 'JARVIS has been running for {uptime}, {user_title}.', 'system', 'response', 7, 1), + +-- Network status +('network_status', '(?i)\\b(network (status|health|agents)|agents (online|status)|how many (agents|devices) (online|running))\\b', 'Network status: {online_count} of {total_count} agents are online, {user_title}.', 'network', 'response', 8, 1), +('network_scan', '(?i)\\b(run (a )?network scan|scan (the )?network|nmap scan|network devices)\\b', 'Initiating network scan, {user_title}.', NULL, 'action', 7, 1), + +-- Proxmox +('proxmox_status', '(?i)\\b(proxmox (status|health)|vm (status|count|summary)|virtual machines|how many vms)\\b', 'Proxmox: {vm_running} of {vm_total} VMs/containers running, {user_title}. Host CPU: {pve_cpu_percent}%, Memory: {pve_mem_used_gb}GB / {pve_mem_total_gb}GB ({pve_mem_percent}%).', 'proxmox', 'response', 8, 1), +('vm_suggestions', '(?i)\\b(vm (resources|performance|usage)|check vms|resource usage)\\b', 'Checking VM resource usage, {user_title}.', 'proxmox', 'action', 7, 1), + +-- Ollama / AI +('ollama_status', '(?i)\\b(ollama (status|health|models)|ai models|llm status|local (ai|models))\\b', 'Ollama is {status} with {model_count} model(s) available: {available_models}, {user_title}.', 'ollama', 'response', 7, 1), + +-- Site health +('site_status', '(?i)\\b(site(s)? (status|health|up|down)|website status|are (the )?sites (up|down))\\b', 'Site health — jarvis: {jarvis}, orbishosting: {orbishosting}, tomtomgames: {tomtomgames}, tomsjavajive: {tomsjavajive}, parkerslingshotrentals: {parkersling}, epictravelexpeditions: {epictravelexp}, {user_title}.', 'sites', 'response', 7, 1), + +-- Tasks / planner +('task_count', '(?i)\\b(how many tasks|pending tasks|task (count|summary)|my tasks)\\b', 'You have {pending_count} pending tasks and {overdue_count} overdue, {user_title}.', NULL, 'response', 7, 1), +('planner_briefing', '(?i)\\b((daily )?briefing|what.?s (on|happening) today|today.?s schedule|morning briefing)\\b', 'Fetching your daily briefing, {user_title}.', NULL, 'action', 8, 1), + +-- Home Assistant +('ha_lights_on', '(?i)\\b(turn (on|off) (the |all )?lights?|lights? (on|off)|switch (on|off) (the )?lights?)\\b', 'Sending light command, {user_title}.', NULL, 'action', 8, 1), +('ha_scene', '(?i)\\b(activate (a |the )?scene|set (a |the )?scene|home scene)\\b', 'Activating home scene, {user_title}.', NULL, 'action', 7, 1), + +-- Jellyfin +('jellyfin_now_playing', '(?i)\\b(what.?s (playing|on)|now playing|jellyfin.*playing|playing.*jellyfin)\\b', 'Checking Jellyfin now playing, {user_title}.', NULL, 'action', 7, 1), +('jellyfin_library', '(?i)\\b(jellyfin (library|media|shows?|movies?)|media library|show.*library)\\b', 'Fetching Jellyfin library, {user_title}.', NULL, 'action', 6, 1), +('jellyfin_pause', '(?i)\\b(pause (jellyfin|playback|media)|stop (playing|jellyfin))\\b', 'Pausing Jellyfin, {user_title}.', NULL, 'action', 7, 1), + +-- DO server +('do_status', '(?i)\\b(do (server|status)|digital ocean (status|server)|vps status)\\b', 'Digital Ocean server is {do_status}, {user_title}.', 'do_server', 'response', 7, 1), + +-- Focus / panels +('focus_mode', '(?i)\\b(focus (mode|on)|enable focus|concentration mode)\\b', 'Enabling focus mode, {user_title}.', NULL, 'action', 6, 1), +('show_panels', '(?i)\\b(show (all )?panels|expand (all|everything)|full view)\\b', 'Expanding all panels, {user_title}.', NULL, 'action', 6, 1), + +-- Help +('help', '(?i)^(help|what can you do|commands|capabilities|what do you know)\\s*\\??$', 'I can help you with: system status, network status, VM/Proxmox status, Ollama AI models, site health, tasks and planner briefings, Jellyfin media, Home Assistant lights and devices, and general questions via Ollama. What would you like to know, {user_title}?', NULL, 'response', 5, 1) + +ON DUPLICATE KEY UPDATE + pattern = VALUES(pattern), + response_template = VALUES(response_template), + active = 1; + +SELECT COUNT(*) AS intents_seeded FROM kb_intents; +SELECT COUNT(*) AS prefs_seeded FROM kb_preferences; From c1275d47a6264b9505a8706085e37015efb182be Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 19:44:39 -0500 Subject: [PATCH 05/10] Add PVE1 probe scripts to repo (netscan, ping-probe, phone-probe) Scripts were running on PVE1 but not tracked in git. Pulling current versions that push to http://10.48.200.211 (was old DO server IP). --- agent/pve1-probes/jarvis-netscan.sh | 63 +++++++++++++++ agent/pve1-probes/jarvis-phone-probe.sh | 56 +++++++++++++ agent/pve1-probes/jarvis-ping-probe.py | 100 ++++++++++++++++++++++++ 3 files changed, 219 insertions(+) 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 diff --git a/agent/pve1-probes/jarvis-netscan.sh b/agent/pve1-probes/jarvis-netscan.sh new file mode 100644 index 0000000..1ab6c66 --- /dev/null +++ b/agent/pve1-probes/jarvis-netscan.sh @@ -0,0 +1,63 @@ +#!/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="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" +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..b2ea9c3 --- /dev/null +++ b/agent/pve1-probes/jarvis-phone-probe.sh @@ -0,0 +1,56 @@ +#!/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="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" +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 "") + +DEVICES="[" +FIRST=1 +for PHONE in "${PHONES[@]}"; do + IFS='|' read -r IP ALIAS EXT MAC <<< "$PHONE" + + # Ping probe + if ping -c 1 -W 2 "$IP" > /dev/null 2>&1; then + STATUS="online" + else + STATUS="offline" + fi + + # SIP check — skip for external phones (ext=none) + if [ "$EXT" = "none" ]; then + SIP="external" + elif [ -n "$REG_OUTPUT" ] && echo "$REG_OUTPUT" | grep -q "^${EXT},"; then + SIP="registered" + else + SIP="unregistered" + fi + + [ $FIRST -eq 0 ] && DEVICES+="," + DEVICES+="{\"ip\":\"$IP\",\"alias\":\"$ALIAS\",\"mac\":\"$MAC\",\"vendor\":\"Yealink\",\"status\":\"$STATUS\",\"sip_status\":\"$SIP\",\"extension\":\"$EXT\"}" + FIRST=0 +done +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 "{\"devices\":$DEVICES}" > /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..620faad --- /dev/null +++ b/agent/pve1-probes/jarvis-ping-probe.py @@ -0,0 +1,100 @@ +#!/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) + if alive: + update_status(agent_id, api_key, status) + +if __name__ == "__main__": + main() From 90e4ded7c93fec494076cc3ef44d3dcc249e10c6 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 20:58:22 -0500 Subject: [PATCH 06/10] Fix 8 issues from code review - ha-poller: replace recursive main() retry with while loop (stack overflow fix) - ha-poller: advance last_push on empty HA response (log spam fix) - ha-poller: use datetime.now(timezone.utc) instead of deprecated utcnow() - ping-probe: always call update_status() unconditionally so offline devices register as offline - agent.php: heartbeat reads status from payload instead of hardcoding 'online' - phone-probe: delegate JSON building to python3 (bash concatenation injection fix) - netscan + phone-probe: read registration key from /etc/jarvis-agent/reg-key - admin/index.php: sync ha_list skipDomains with ha.php (14 missing domains added) - facts_collector: self-check JARVIS via 127.0.0.1 instead of Cloudflare hairpin --- agent/jarvis-ha-poller.py | 7 ++--- agent/pve1-probes/jarvis-netscan.sh | 6 +++- agent/pve1-probes/jarvis-phone-probe.sh | 38 ++++++++++++++++++------- agent/pve1-probes/jarvis-ping-probe.py | 3 +- api/endpoints/agent.php | 3 +- api/endpoints/facts_collector.php | 2 +- public_html/admin/index.php | 4 ++- 7 files changed, 43 insertions(+), 20 deletions(-) diff --git a/agent/jarvis-ha-poller.py b/agent/jarvis-ha-poller.py index 372859b..d5c1015 100644 --- a/agent/jarvis-ha-poller.py +++ b/agent/jarvis-ha-poller.py @@ -175,7 +175,7 @@ def fetch_ha_states(cfg: dict) -> list: 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") + lc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") entities.append({ "entity_id": entity_id, @@ -194,13 +194,11 @@ def main(): heartbeat_every = int(cfg.get("heartbeat_every", 10)) api_key = state.get("api_key", "") - if not 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) - main() - return headers = {"X-Agent-Key": api_key} last_push = 0 @@ -229,6 +227,7 @@ def main(): last_push = now else: log("No HA entities fetched (HA down or token invalid?)") + last_push = now time.sleep(heartbeat_every) diff --git a/agent/pve1-probes/jarvis-netscan.sh b/agent/pve1-probes/jarvis-netscan.sh index 1ab6c66..57ecbab 100644 --- a/agent/pve1-probes/jarvis-netscan.sh +++ b/agent/pve1-probes/jarvis-netscan.sh @@ -4,7 +4,11 @@ JARVIS_URL="http://10.48.200.211" JARVIS_HOST="jarvis.orbishosting.com" -REG_KEY="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" +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) diff --git a/agent/pve1-probes/jarvis-phone-probe.sh b/agent/pve1-probes/jarvis-phone-probe.sh index b2ea9c3..936fe70 100644 --- a/agent/pve1-probes/jarvis-phone-probe.sh +++ b/agent/pve1-probes/jarvis-phone-probe.sh @@ -5,7 +5,11 @@ JARVIS_URL="http://10.48.200.211" JARVIS_HOST="jarvis.orbishosting.com" -REG_KEY="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518" +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 @@ -21,19 +25,17 @@ PHONES=( REG_OUTPUT=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \ root@$FUSION_HOST "fs_cli -x 'show registrations'" 2>/dev/null || echo "") -DEVICES="[" -FIRST=1 +# 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" - # Ping probe if ping -c 1 -W 2 "$IP" > /dev/null 2>&1; then STATUS="online" else STATUS="offline" fi - # SIP check — skip for external phones (ext=none) if [ "$EXT" = "none" ]; then SIP="external" elif [ -n "$REG_OUTPUT" ] && echo "$REG_OUTPUT" | grep -q "^${EXT},"; then @@ -42,15 +44,31 @@ for PHONE in "${PHONES[@]}"; do SIP="unregistered" fi - [ $FIRST -eq 0 ] && DEVICES+="," - DEVICES+="{\"ip\":\"$IP\",\"alias\":\"$ALIAS\",\"mac\":\"$MAC\",\"vendor\":\"Yealink\",\"status\":\"$STATUS\",\"sip_status\":\"$SIP\",\"extension\":\"$EXT\"}" - FIRST=0 + RESULTS="${RESULTS}${IP}\t${ALIAS}\t${MAC}\t${STATUS}\t${SIP}\t${EXT}\n" done -DEVICES+="]" + +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 "{\"devices\":$DEVICES}" > /dev/null 2>&1 + -d "$JSON" > /dev/null 2>&1 diff --git a/agent/pve1-probes/jarvis-ping-probe.py b/agent/pve1-probes/jarvis-ping-probe.py index 620faad..72e68f8 100644 --- a/agent/pve1-probes/jarvis-ping-probe.py +++ b/agent/pve1-probes/jarvis-ping-probe.py @@ -93,8 +93,7 @@ def main(): status = "online" if alive else "offline" print(f"{agent_id} ({ip}): {status}", flush=True) heartbeat(agent_id, api_key, alive) - if alive: - update_status(agent_id, api_key, status) + update_status(agent_id, api_key, status) if __name__ == "__main__": main() diff --git a/api/endpoints/agent.php b/api/endpoints/agent.php index f768250..32a2132 100644 --- a/api/endpoints/agent.php +++ b/api/endpoints/agent.php @@ -111,7 +111,8 @@ switch ($agentAction) { // ── HEARTBEAT ──────────────────────────────────────────────────────────── case 'heartbeat': - update_agent_seen($agent['agent_id'], 'online', trim($data['version'] ?? '') ?: null); + $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( diff --git a/api/endpoints/facts_collector.php b/api/endpoints/facts_collector.php index 32841c7..4b9bcc9 100644 --- a/api/endpoints/facts_collector.php +++ b/api/endpoints/facts_collector.php @@ -181,7 +181,7 @@ function collect_all(): array { $results['sites'] = 'skipped (fresh)'; } else try { $sites = [ - "jarvis" => "https://jarvis.orbishosting.com", + "jarvis" => "http://127.0.0.1", 'tomsjavajive' => 'https://tomsjavajive.com', 'epictravelexp'=> 'https://epictravelexpeditions.com', 'parkersling' => 'https://parkerslingshotrentals.com', diff --git a/public_html/admin/index.php b/public_html/admin/index.php index c76d3e9..a440ce7 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -242,7 +242,9 @@ if ($action) { $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']; + 'assist_satellite','input_button','media_player','scene','water_heater', + 'alarm_control_panel','automation','script','calendar','notify', + 'weather','camera','siren','remote','todo','lawn_mower']; $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', '_siren_on','_email_on','_manual_record','_infrared_', 'do_not_disturb','matter_server','zerotier','mariadb', From 651455cb47cf15b1e406c528a9966b4a912735d8 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 1 Jul 2026 03:49:10 -0500 Subject: [PATCH 07/10] Fix guardian mode: create missing tables on startup, fix conversations column name - Add CREATE TABLE IF NOT EXISTS for guardian_config and guardian_events in reactor lifespan so tables are created automatically on next restart - Fix conversations column bug: reactor used 'message' but schema has 'content'; fix INSERT and SELECT queries to use content (SELECT aliases it as 'message' so the JSON response key stays the same) - Add guardian tables to schema.sql for documentation Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01X8tDRrQqgLjqXebMCBNcP3 --- db/schema.sql | 37 +++++++++++++++++++++++++++++++++++++ deploy/reactor.py | 33 +++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index 705fee3..b87638a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -441,4 +441,41 @@ CREATE TABLE `users` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; +-- +-- Table structure for table `guardian_config` +-- + +CREATE TABLE IF NOT EXISTS `guardian_config` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `key_name` varchar(64) NOT NULL, + `value` varchar(255) NOT NULL DEFAULT '', + `updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_key` (`key_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Table structure for table `guardian_events` +-- + +CREATE TABLE IF NOT EXISTS `guardian_events` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `event_type` varchar(64) NOT NULL, + `severity` enum('info','warning','critical') NOT NULL DEFAULT 'info', + `agent_id` varchar(64) NOT NULL DEFAULT '', + `hostname` varchar(128) NOT NULL DEFAULT '', + `metric` varchar(64) NOT NULL DEFAULT '', + `value` float NOT NULL DEFAULT 0, + `threshold` float NOT NULL DEFAULT 0, + `message` text NOT NULL, + `ai_analysis` text NOT NULL DEFAULT '', + `acknowledged` tinyint(1) NOT NULL DEFAULT 0, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_severity` (`severity`), + KEY `idx_ack` (`acknowledged`), + KEY `idx_created` (`created_at`), + KEY `idx_agent` (`agent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Dump completed on 2026-06-29 23:15:44 diff --git a/deploy/reactor.py b/deploy/reactor.py index 7519758..01ee171 100644 --- a/deploy/reactor.py +++ b/deploy/reactor.py @@ -1051,7 +1051,7 @@ 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) + """INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian', 'assistant', %s, NOW())""", (message,) ) @@ -1806,7 +1806,7 @@ Keep the tone confident and action-oriented. Format with clear sections. Under 4 # 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())", + "INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())", (review_text,) ) @@ -2209,6 +2209,31 @@ 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,)) + await db_execute("""CREATE TABLE IF NOT EXISTS guardian_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + key_name VARCHAR(64) NOT NULL, + value VARCHAR(255) NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_key (key_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""") + await db_execute("""CREATE TABLE IF NOT EXISTS guardian_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(64) NOT NULL, + severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info', + agent_id VARCHAR(64) NOT NULL DEFAULT '', + hostname VARCHAR(128) NOT NULL DEFAULT '', + metric VARCHAR(64) NOT NULL DEFAULT '', + value FLOAT NOT NULL DEFAULT 0, + threshold FLOAT NOT NULL DEFAULT 0, + message TEXT NOT NULL, + ai_analysis TEXT NOT NULL DEFAULT '', + acknowledged TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_severity (severity), + KEY idx_ack (acknowledged), + KEY idx_created (created_at), + KEY idx_agent (agent_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""") asyncio.create_task(job_poller()) asyncio.create_task(heartbeat_loop()) asyncio.create_task(guardian_loop()) @@ -2728,13 +2753,13 @@ 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 " + "SELECT id, content AS 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 " + "SELECT id, content AS message, created_at FROM conversations " "WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10" ) return rows or [] From a9ea75db98494a759ddf2003ef73598acada2f70 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 1 Jul 2026 03:56:11 -0500 Subject: [PATCH 08/10] Fix Arc Reactor restart: use bash for source, add systemd service, fix deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin panel restart command used shell_exec which runs /bin/sh (dash on Ubuntu) — dash doesn't support 'source', so the venv never activated and the reactor silently never started. - admin/index.php: wrap restart in bash -c so 'source' works; try systemctl first then fall back to nohup - deploy/jarvis-arc.service: add proper systemd unit so reactor auto-starts on boot and auto-restarts on crash - deploy/jarvis-deploy.sh: install+enable service file when it changes; mkdir log dir before restart; fall back to nohup if systemctl not set up yet Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01X8tDRrQqgLjqXebMCBNcP3 --- deploy/jarvis-arc.service | 17 +++++++++++++++++ deploy/jarvis-deploy.sh | 15 ++++++++++++--- public_html/admin/index.php | 2 +- 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 deploy/jarvis-arc.service 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-deploy.sh b/deploy/jarvis-deploy.sh index 8cda0ca..eb5f936 100755 --- a/deploy/jarvis-deploy.sh +++ b/deploy/jarvis-deploy.sh @@ -80,10 +80,19 @@ while IFS= read -r path; do systemctl reload lsws 2>/dev/null || systemctl restart lsws 2>/dev/null log "OLS reloaded for JARVIS deploy" - # Sync reactor.py to runtime location if it changed - if echo "$CHANGED" | grep -q 'deploy/reactor.py'; then + # Sync reactor.py + service file to runtime location if they changed + if echo "$CHANGED" | grep -q 'deploy/reactor.py\|deploy/jarvis-arc.service'; then + mkdir -p /opt/jarvis-arc cp "$path/deploy/reactor.py" /opt/jarvis-arc/reactor.py - systemctl restart jarvis-arc + 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 + mkdir -p /home/jarvis.orbishosting.com/logs + 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 (reactor.py changed)" fi fi diff --git a/public_html/admin/index.php b/public_html/admin/index.php index a440ce7..8941d02 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -550,7 +550,7 @@ if ($action) { j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']); } else { bad('Unknown cron worker'); } } elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') { - shell_exec('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 &'); + shell_exec('bash -c "mkdir -p /home/jarvis.orbishosting.com/logs; systemctl restart jarvis-arc 2>/dev/null || (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 &)"'); j(['ok'=>true,'msg'=>'Arc Reactor restarting']); } else { bad('Invalid worker action'); } break; From 435af8ccc925b180e78919cc0591dd8420691477 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 1 Jul 2026 04:02:30 -0500 Subject: [PATCH 09/10] Wire Ollama properly: fix IP, upgrade to llama3.1:8b, use for guardian/sitrep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reactor.py: fix OLLAMA_HOST .95 → .210 (actual Ollama VM IP) - reactor.py: upgrade OLLAMA_MODEL 1b → 8b (llama3.1:8b has tool-calling, 131K ctx) - reactor.py: guardian alerts and sitrep now use ollama instead of groq (free, on-LAN, no quota) - config.example.php: same IP/model fixes + fix GROQ_MODEL_SEARCH to compound-beta-mini (groq/ prefix causes 404) - deploy script: patch live config.php on every deploy to correct Ollama IP/model and Groq model name Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01X8tDRrQqgLjqXebMCBNcP3 --- api/config.example.php | 8 ++++---- deploy/jarvis-deploy.sh | 12 ++++++++++++ deploy/reactor.py | 8 ++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/api/config.example.php b/api/config.example.php index ca1968b..2fed500 100644 --- a/api/config.example.php +++ b/api/config.example.php @@ -18,13 +18,13 @@ define(chr(39)+'CLAUDE_MODEL'.chr(39), chr(39)+'claude-sonnet-4-6'.chr(39)) define(chr(39)+'CLAUDE_MAX_TOKENS'.chr(39), 1024); define(chr(39)+'GROQ_API_KEY'.chr(39), chr(39)+'gsk_...'.chr(39)); -define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'groq/compound-mini'.chr(39)); +define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'compound-beta-mini'.chr(39)); define(chr(39)+'GROQ_MODEL_GENERAL'.chr(39), chr(39)+'llama-3.3-70b-versatile'.chr(39)); define(chr(39)+'GROQ_TIMEOUT'.chr(39), 30); -define(chr(39)+'OLLAMA_HOST'.chr(39), chr(39)+'http://10.48.200.95:11434'.chr(39)); -define(chr(39)+'OLLAMA_MODEL_PRIMARY'.chr(39), chr(39)+'llama3.2:1b'.chr(39)); -define(chr(39)+'OLLAMA_MODEL_HEAVY'.chr(39), chr(39)+'llama3.1:70b'.chr(39)); +define(chr(39)+'OLLAMA_HOST'.chr(39), chr(39)+'http://10.48.200.210:11434'.chr(39)); +define(chr(39)+'OLLAMA_MODEL_PRIMARY'.chr(39), chr(39)+'llama3.1:8b'.chr(39)); +define(chr(39)+'OLLAMA_MODEL_HEAVY'.chr(39), chr(39)+'llama3.1:8b'.chr(39)); define(chr(39)+'OLLAMA_TIMEOUT'.chr(39), 90); define(chr(39)+'LOCAL_SUBNET'.chr(39), chr(39)+'10.48.200'.chr(39)); diff --git a/deploy/jarvis-deploy.sh b/deploy/jarvis-deploy.sh index eb5f936..531bd8a 100755 --- a/deploy/jarvis-deploy.sh +++ b/deploy/jarvis-deploy.sh @@ -80,6 +80,18 @@ while IFS= read -r path; do 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 + # Sync reactor.py + service file to runtime location if they changed if echo "$CHANGED" | grep -q 'deploy/reactor.py\|deploy/jarvis-arc.service'; then mkdir -p /opt/jarvis-arc diff --git a/deploy/reactor.py b/deploy/reactor.py index 01ee171..ffa89cd 100644 --- a/deploy/reactor.py +++ b/deploy/reactor.py @@ -49,8 +49,8 @@ CLAUDE_API_KEY = "sk-ant-api03-JL6vjFeyEfajQmaTOmsT6AfLLPs2icrIAvvJ0hdi4DuMi0155 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" +OLLAMA_HOST = "http://10.48.200.210:11434" +OLLAMA_MODEL = "llama3.1:8b" GMAIL_USER = "myronblair@gmail.com" GMAIL_PASS = "demsvdylwweacbcx" @@ -1022,7 +1022,7 @@ async def guardian_loop() -> None: "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") + ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "ollama") # Update the most recent guardian event with AI analysis await db_execute( """UPDATE guardian_events SET ai_analysis=%s @@ -1065,7 +1065,7 @@ async def handle_sitrep(payload: dict) -> dict: payload: { detail: brief|full, provider: groq } """ detail = payload.get("detail", "full") - provider = payload.get("provider", "groq") + provider = payload.get("provider", "ollama") log.info(f"[GUARDIAN] SITREP requested (detail={detail})") From 05522edb1d776b12be599a72b27f4de127bae7d0 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 1 Jul 2026 04:05:37 -0500 Subject: [PATCH 10/10] Add llava vision: use Ollama llava:7b for all image analysis, Claude as fallback - Add _ollama_vision_call() using Ollama /api/generate with images array - handle_screenshot: try llava first (free, on-LAN), fall back to Claude on error; text-only snapshots now use ollama instead of groq - handle_vision: same llava-first/Claude-fallback pattern; caller can force provider='ollama' or 'claude' explicitly; stored provider_used reflects actual provider that ran Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01X8tDRrQqgLjqXebMCBNcP3 --- deploy/reactor.py | 119 +++++++++++++++++++++++++++++----------------- 1 file changed, 75 insertions(+), 44 deletions(-) diff --git a/deploy/reactor.py b/deploy/reactor.py index ffa89cd..7f53cef 100644 --- a/deploy/reactor.py +++ b/deploy/reactor.py @@ -175,6 +175,16 @@ async def _ollama_call(messages: list, system: str = "") -> str: data = await resp.json() return data.get("response", "") +async def _ollama_vision_call(image_b64: str, prompt: str) -> str: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{OLLAMA_HOST}/api/generate", + json={"model": "llava:7b", "prompt": prompt, "images": [image_b64], "stream": False}, + timeout=aiohttp.ClientTimeout(total=120), + ) as resp: + data = await resp.json() + return data.get("response", "").strip() + async def handle_llm(payload: dict) -> dict: message = payload.get("message", "") system = payload.get("system", "You are JARVIS, an Iron Man-style AI assistant.") @@ -658,38 +668,44 @@ async def handle_screenshot(payload: dict) -> dict: height = result.get("height", 0) file_size = result.get("file_size", 0) - # Run Claude vision analysis if we have an image + # Run vision analysis if we have an image — llava first, Claude fallback 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)") + analysis = await _ollama_vision_call(image_b64, analyze_prompt) + provider_used = "ollama:llava" + log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)") except Exception as e: - log.warning(f"[VISION] Claude vision failed: {e}") - analysis = f"Vision analysis unavailable: {e}" + log.warning(f"[VISION] llava failed: {e} — falling back to Claude") + try: + import anthropic + client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY) + msg = await client.messages.create( + model="claude-opus-4-8-20251101", + max_tokens=1024, + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": {"type": "base64", + "media_type": "image/png", "data": image_b64}}, + {"type": "text", "text": analyze_prompt}, + ], + }], + ) + analysis = msg.content[0].text if msg.content else "" + provider_used = "claude" + log.info(f"[VISION] Claude fallback analysis complete ({len(analysis)} chars)") + except Exception as e2: + log.warning(f"[VISION] Claude fallback also failed: {e2}") + analysis = f"Vision analysis unavailable: {e2}" elif do_analyze and not image_b64 and result.get("snapshot_type") == "text": - # Text-only sysinfo snapshot — summarize with LLM + # Text-only sysinfo snapshot — summarize with Ollama 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" + analysis = await llm_call([{"role": "user", "content": prompt}], "ollama") + provider_used = "ollama" except Exception as e: analysis = f"Analysis unavailable: {e}" @@ -742,32 +758,47 @@ async def handle_vision(payload: dict) -> dict: if not image_b64: raise ValueError("No image data provided") - log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}") + log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname} provider={provider}") - 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}") + analysis = "" + provider_used = provider + + if provider == "ollama" or provider == "llava": + analysis = await _ollama_vision_call(image_b64, prompt) + provider_used = "ollama:llava" + else: + # Default: llava first, Claude fallback + try: + analysis = await _ollama_vision_call(image_b64, prompt) + provider_used = "ollama:llava" + log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)") + except Exception as e: + log.warning(f"[VISION] llava failed: {e} — falling back to Claude") + try: + import anthropic + client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY) + msg = await client.messages.create( + model="claude-opus-4-8-20251101", + max_tokens=2048, + messages=[{ + "role": "user", + "content": [ + {"type": "image", "source": {"type": "base64", + "media_type": "image/png", "data": image_b64}}, + {"type": "text", "text": prompt}, + ], + }], + ) + analysis = msg.content[0].text if msg.content else "" + provider_used = "claude" + except Exception as e2: + raise RuntimeError(f"Vision analysis failed (llava: {e}, claude: {e2})") # Update stored screenshot if we have an ID if screenshot_id: await db_execute( "UPDATE agent_screenshots SET vision_analysis=%s, vision_provider=%s WHERE id=%s", - (analysis, "claude", int(screenshot_id)) + (analysis, provider_used, int(screenshot_id)) ) return { @@ -775,7 +806,7 @@ async def handle_vision(payload: dict) -> dict: "screenshot_id": screenshot_id, "prompt": prompt, "analysis": analysis, - "provider": "claude", + "provider": provider_used, }