Adopt live production state as git baseline

This commit is contained in:
root
2026-07-07 07:55:47 -05:00
commit 588cfe3f10
85 changed files with 30896 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* JARVIS Configuration Template
* Copy to config.php and fill in your values.
*/
define(chr(39).'JARVIS_USER_NAME'.chr(39), chr(39).'Your Name'.chr(39));
define(chr(39).'JARVIS_USER_TITLE'.chr(39), chr(39).'Mr. Blair'.chr(39));
define(chr(39).'JARVIS_CODENAME'.chr(39), chr(39).'JARVIS'.chr(39));
define(chr(39).'DB_HOST'.chr(39), chr(39).'localhost'.chr(39));
define(chr(39).'DB_NAME'.chr(39), chr(39).'jarvis_db'.chr(39));
define(chr(39).'DB_USER'.chr(39), chr(39).'jarvis_user'.chr(39));
define(chr(39).'DB_PASS'.chr(39), chr(39).'YOUR_DB_PASSWORD'.chr(39));
define(chr(39).'CLAUDE_API_KEY'.chr(39), chr(39).'sk-ant-api03-...'.chr(39));
define(chr(39)+'CLAUDE_MODEL'.chr(39), chr(39)+'claude-sonnet-4-6'.chr(39));
define(chr(39)+'CLAUDE_MAX_TOKENS'.chr(39), 1024);
define(chr(39)+'GROQ_API_KEY'.chr(39), chr(39)+'gsk_...'.chr(39));
define(chr(39)+'GROQ_MODEL_SEARCH'.chr(39), chr(39)+'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.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));
define(chr(39)+'LOCAL_GATEWAY'.chr(39), chr(39)+'10.48.200.1'.chr(39));
define(chr(39)+'JARVIS_IP'.chr(39), chr(39)+'10.48.200.84'.chr(39));
define(chr(39)+'DO_SERVER_IP'.chr(39), chr(39)+'0.0.0.0'.chr(39));
define(chr(39)+'DO_SSH_USER'.chr(39), chr(39)+'root'.chr(39));
define(chr(39)+'DO_SSH_PASS'.chr(39), chr(39)+'YOUR_DO_SSH_PASSWORD'.chr(39));
define(chr(39)+'PROXMOX_HOST'.chr(39), chr(39)+'10.48.200.90'.chr(39));
define(chr(39)+'PROXMOX_PORT'.chr(39), 8006);
define(chr(39)+'PROXMOX_NODE'.chr(39), chr(39)+'pve'.chr(39));
define(chr(39)+'PROXMOX_USER'.chr(39), chr(39)+'root@pam'.chr(39));
define(chr(39)+'PROXMOX_TOKEN_ID'.chr(39), chr(39)+'jarvis'.chr(39));
define(chr(39)+'PROXMOX_TOKEN_VAL'.chr(39), chr(39)+'YOUR_PROXMOX_API_TOKEN'.chr(39));
define(chr(39)+'HA_URL'.chr(39), chr(39)+'http://10.48.200.97:8123'.chr(39));
define(chr(39)+'HA_TOKEN'.chr(39), chr(39)+'YOUR_HA_LONG_LIVED_TOKEN'.chr(39));
define(chr(39)+'SESSION_LIFETIME'.chr(39), 86400 * 7);
define(chr(39)+'SITE_URL'.chr(39), chr(39)+'https://jarvis.orbishosting.com'.chr(39));
error_reporting(0);
ini_set(chr(39)+'display_errors'.chr(39), 0);
ini_set(chr(39)+'log_errors'.chr(39), 1);
ini_set(chr(39)+'error_log'.chr(39), chr(39)+'/var/log/apache2/jarvis_errors.log'.chr(39));
date_default_timezone_set(chr(39)+'America/Chicago'.chr(39));
+261
View File
@@ -0,0 +1,261 @@
<?php
/**
* JARVIS Agent API
* Handles registration, heartbeats, metrics, HA state pushes, and command polling
* from remote jarvis-agent clients on external networks.
*
* Auth: POST /api/agent/register → uses AGENT_REGISTRATION_KEY
* All other actions → uses X-Agent-Key header (per-agent key)
*/
header('Content-Type: application/json');
$agentAction = $action ?? ($parts[1] ?? '');
// ── Helpers ───────────────────────────────────────────────────────────────────
function agent_error(int $code, string $msg): void {
http_response_code($code);
echo json_encode(['error' => $msg]);
exit;
}
function agent_ok(array $payload = []): void {
echo json_encode(array_merge(['ok' => true], $payload));
exit;
}
function generate_api_key(): string {
return bin2hex(random_bytes(24));
}
function get_agent_by_key(string $key): ?array {
$rows = JarvisDB::query(
'SELECT * FROM registered_agents WHERE api_key = ? LIMIT 1',
[$key]
);
return $rows[0] ?? null;
}
function update_agent_seen(string $agentId, string $status = 'online', ?string $version = null): void {
if ($version !== null) {
JarvisDB::query(
'UPDATE registered_agents SET last_seen = NOW(), status = ?, version = ? WHERE agent_id = ?',
[$status, $version, $agentId]
);
} else {
JarvisDB::query(
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
[$status, $agentId]
);
}
}
// ── Auth (all actions except register) ───────────────────────────────────────
$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? '';
$browserActions = ['list', 'status', 'myip'];
if ($agentAction !== 'register') {
if (in_array($agentAction, $browserActions)) {
$token = $_SESSION['jarvis_token'] ?? '';
$localIP = $_SERVER['REMOTE_ADDR'] ?? '';
if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1'])) {
agent_error(401, 'Unauthorized');
}
$agent = null;
} else {
if (empty($agentKey)) agent_error(401, 'X-Agent-Key header required');
$agent = get_agent_by_key($agentKey);
if (!$agent) agent_error(401, 'Invalid agent key');
}
}
// ── Route ─────────────────────────────────────────────────────────────────────
switch ($agentAction) {
// ── REGISTER ─────────────────────────────────────────────────────────────
case 'register':
if ($method !== 'POST') agent_error(405, 'POST only');
$regKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ($data['registration_key'] ?? '');
if (!hash_equals(AGENT_REGISTRATION_KEY, $regKey)) agent_error(403, 'Invalid registration key');
$hostname = trim($data['hostname'] ?? '');
$agentType = $data['agent_type'] ?? 'linux';
$ipAddress = $data['ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
$capabilities = $data['capabilities'] ?? [];
$agentId = $data['agent_id'] ?? ($hostname . '_' . substr(md5($hostname . $ipAddress), 0, 8));
$version = trim($data['version'] ?? '');
if (!$hostname) agent_error(400, 'hostname required');
if (!in_array($agentType, ['linux', 'homeassistant', 'proxmox', 'windows', 'macos'])) agent_error(400, 'Invalid agent_type');
// Upsert agent
$existing = JarvisDB::query('SELECT api_key FROM registered_agents WHERE agent_id = ?', [$agentId]);
if ($existing) {
$apiKey = $existing[0]['api_key'];
JarvisDB::query(
'UPDATE registered_agents SET hostname=?, agent_type=?, ip_address=?, capabilities=?, version=?, last_seen=NOW(), status="online" WHERE agent_id=?',
[$hostname, $agentType, $ipAddress, json_encode($capabilities), $version ?: null, $agentId]
);
} else {
$apiKey = generate_api_key();
JarvisDB::query(
'INSERT INTO registered_agents (agent_id, hostname, agent_type, ip_address, api_key, capabilities, version, last_seen, status) VALUES (?,?,?,?,?,?,?,NOW(),"online")',
[$agentId, $hostname, $agentType, $ipAddress, $apiKey, json_encode($capabilities), $version ?: null]
);
}
agent_ok(['agent_id' => $agentId, 'api_key' => $apiKey]);
// ── HEARTBEAT ────────────────────────────────────────────────────────────
case 'heartbeat':
$hbStatus = in_array($data['status'] ?? '', ['online','offline']) ? $data['status'] : 'online';
update_agent_seen($agent['agent_id'], $hbStatus, trim($data['version'] ?? '') ?: null);
// Return any pending commands for this agent
$commands = JarvisDB::query(
'SELECT id, command_type, command_data FROM agent_commands WHERE agent_id = ? AND status = "pending" ORDER BY created_at ASC LIMIT 10',
[$agent['agent_id']]
);
// Mark as delivered
if ($commands) {
$cmdIds = array_column($commands, 'id');
$placeholders = implode(',', array_fill(0, count($cmdIds), '?'));
JarvisDB::query("UPDATE agent_commands SET status='delivered', delivered_at=NOW() WHERE id IN ($placeholders)", $cmdIds);
foreach ($commands as &$cmd) {
$cmd['command_data'] = json_decode($cmd['command_data'] ?? '{}', true);
}
}
agent_ok(['commands' => $commands ?: []]);
// ── METRICS ──────────────────────────────────────────────────────────────
case 'metrics':
if ($method !== 'POST') agent_error(405, 'POST only');
update_agent_seen($agent['agent_id']);
$metricType = $data['type'] ?? 'system';
$metricData = $data['data'] ?? [];
JarvisDB::query(
'INSERT INTO agent_metrics (agent_id, metric_type, metric_data) VALUES (?,?,?)',
[$agent['agent_id'], $metricType, json_encode($metricData)]
);
// Prune old metrics (keep 24h)
JarvisDB::query(
'DELETE FROM agent_metrics WHERE agent_id = ? AND recorded_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)',
[$agent['agent_id']]
);
agent_ok();
// ── HA STATE PUSH ────────────────────────────────────────────────────────
case 'ha_state':
if ($method !== 'POST') agent_error(405, 'POST only');
update_agent_seen($agent['agent_id']);
$entities = $data['entities'] ?? [];
if (empty($entities)) agent_error(400, 'entities array required');
foreach ($entities as $e) {
$entityId = $e['entity_id'] ?? '';
$entityName = $e['name'] ?? $e['friendly_name'] ?? $entityId;
$domain = explode('.', $entityId)[0] ?? 'unknown';
$state = $e['state'] ?? '';
$attrs = $e['attributes'] ?? [];
$lastChanged = $e['last_changed'] ?? date('Y-m-d H:i:s');
if (!$entityId) continue;
JarvisDB::query(
'INSERT INTO ha_entities (agent_id, entity_id, entity_name, domain, state, attributes, last_changed, updated_at)
VALUES (?,?,?,?,?,?,?,NOW())
ON DUPLICATE KEY UPDATE entity_name=VALUES(entity_name), state=VALUES(state), attributes=VALUES(attributes), last_changed=VALUES(last_changed), updated_at=NOW()',
[$agent['agent_id'], $entityId, $entityName, $domain, $state, json_encode($attrs), $lastChanged]
);
}
// Also update kb_facts for compatibility with existing KB engine
$entityMap = [];
$all = JarvisDB::query('SELECT entity_id, entity_name, domain, state, attributes FROM ha_entities WHERE agent_id = ?', [$agent['agent_id']]);
foreach ($all as $row) {
$entityMap[$row['entity_id']] = [
'entity_id' => $row['entity_id'],
'name' => $row['entity_name'],
'domain' => $row['domain'],
'state' => $row['state'],
'attributes'=> json_decode($row['attributes'] ?? '{}', true),
];
}
JarvisDB::query(
'INSERT INTO kb_facts (category, fact_key, fact_value) VALUES ("ha_entities", "ha/entity_map", ?)
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()',
[json_encode($entityMap)]
);
agent_ok(['accepted' => count($entities)]);
// ── COMMAND RESULT ───────────────────────────────────────────────────────
case 'command_result':
if ($method !== 'POST') agent_error(405, 'POST only');
$cmdId = (int)($data['command_id'] ?? 0);
$result = $data['result'] ?? [];
$status = ($data['success'] ?? true) ? 'executed' : 'failed';
JarvisDB::query(
'UPDATE agent_commands SET status=?, executed_at=NOW(), result=? WHERE id=? AND agent_id=?',
[$status, json_encode($result), $cmdId, $agent['agent_id']]
);
agent_ok();
// ── LIST (admin: get all agents status) ──────────────────────────────────
case 'list':
// Mark agents offline if last_seen > 2 minutes ago
JarvisDB::query(
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
);
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, capabilities FROM registered_agents ORDER BY agent_type, hostname');
foreach ($agents as &$a) {
$a['capabilities'] = json_decode($a['capabilities'] ?? '[]', true);
}
$realIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
$realIp = trim(explode(',', $realIp)[0]);
agent_ok(['agents' => $agents, 'my_ip' => $realIp]);
// ── LATEST METRICS (for dashboard display) ───────────────────────────────
case 'status':
$agentIdReq = $data['agent_id'] ?? ($parts[2] ?? '');
$whereAgent = $agentIdReq ? 'AND agent_id = ?' : '';
$params = $agentIdReq ? [$agentIdReq] : [];
$latest = JarvisDB::query(
"SELECT agent_id, metric_type, metric_data, recorded_at
FROM agent_metrics
WHERE (agent_id, metric_type, recorded_at) IN (
SELECT agent_id, metric_type, MAX(recorded_at)
FROM agent_metrics $whereAgent
GROUP BY agent_id, metric_type
)
ORDER BY agent_id, metric_type",
$params
);
$grouped = [];
foreach ($latest as $row) {
$grouped[$row['agent_id']][$row['metric_type']] = json_decode($row['metric_data'], true);
$grouped[$row['agent_id']]['recorded_at'] = $row['recorded_at'];
}
agent_ok(['metrics' => $grouped]);
// ── MY IP (browser client IP detection) ──────────────────────────────────
case 'myip':
agent_ok(['ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
default:
agent_error(404, 'Unknown agent action: ' . $agentAction);
}
+238
View File
@@ -0,0 +1,238 @@
<?php
/**
* JARVIS Alerts API
* GET /api/alerts — return active alerts (auto-generates agent alerts first)
* POST /api/alerts/resolve — resolve an alert by id
* POST /api/alerts — manually create an alert
*/
// ── Auto-generate alerts from agent data ─────────────────────────────────────
function refresh_agent_alerts(): void {
// Thresholds
$CPU_WARN = 85;
$MEM_WARN = 85;
$DISK_WARN = 88;
$DISK_CRIT = 95;
// ── Mark auto-resolve alerts whose condition cleared ──────────────────────
// We'll re-evaluate below and upsert; first collect keys that are still active
$still_active = [];
// ── Offline agents ────────────────────────────────────────────────────────
$offline = JarvisDB::query(
"SELECT agent_id, hostname FROM registered_agents
WHERE status='offline' OR last_seen < DATE_SUB(NOW(), INTERVAL 3 MINUTE)"
);
foreach ($offline as $ag) {
$key = 'agent:' . $ag['agent_id'] . ':offline';
upsert_alert($key, 'critical', 'Agent Offline: ' . $ag['hostname'],
'JARVIS Agent on ' . $ag['hostname'] . ' is not responding. Last contact was more than 3 minutes ago.');
$still_active[$key] = true;
}
// ── Metric-based alerts ───────────────────────────────────────────────────
// Get latest system metrics for each agent
$latest = JarvisDB::query(
"SELECT m.agent_id, m.metric_data, a.hostname
FROM agent_metrics m
JOIN registered_agents a ON a.agent_id = m.agent_id
WHERE m.metric_type = 'system'
AND (m.agent_id, m.recorded_at) IN (
SELECT agent_id, MAX(recorded_at) FROM agent_metrics
WHERE metric_type = 'system'
GROUP BY agent_id
)
AND m.recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
foreach ($latest as $row) {
$d = json_decode($row['metric_data'] ?? '{}', true);
$hn = $row['hostname'];
$id = $row['agent_id'];
// CPU
$cpu = (float)($d['cpu_percent'] ?? 0);
if ($cpu >= $CPU_WARN) {
$key = 'agent:' . $id . ':cpu_high';
$sev = $cpu >= 95 ? 'critical' : 'warning';
upsert_alert($key, $sev, 'High CPU: ' . $hn,
round($cpu, 1) . '% CPU utilization on ' . $hn . '. Sustained high load detected.');
$still_active[$key] = true;
}
// Memory
$mem_pct = (float)($d['memory']['percent'] ?? 0);
if ($mem_pct >= $MEM_WARN) {
$key = 'agent:' . $id . ':mem_high';
$sev = $mem_pct >= 95 ? 'critical' : 'warning';
upsert_alert($key, $sev, 'High Memory: ' . $hn,
round($mem_pct, 1) . '% memory used on ' . $hn .
' (' . round($d['memory']['used_mb'] ?? 0) . '/' .
round($d['memory']['total_mb'] ?? 0) . ' MB).');
$still_active[$key] = true;
}
// Disk
foreach (($d['disk'] ?? []) as $disk) {
$pct = (int)($disk['percent'] ?? 0);
if ($pct >= $DISK_WARN) {
$mount = $disk['mount'] ?? '/';
$key = 'agent:' . $id . ':disk:' . str_replace('/', '_', $mount);
$sev = $pct >= $DISK_CRIT ? 'critical' : 'warning';
upsert_alert($key, $sev, 'Disk Full: ' . $hn . ' ' . $mount,
$mount . ' is ' . $pct . '% full on ' . $hn .
' (' . ($disk['used'] ?? '?') . ' of ' . ($disk['size'] ?? '?') . ' used).');
$still_active[$key] = true;
}
}
// Services down — alert AND dispatch auto-restart command
foreach (($d['services'] ?? []) as $svc) {
if (($svc['status'] ?? '') === 'active') continue;
if (($svc['status'] ?? '') === 'unknown') continue;
$svcName = $svc['service'] ?? '';
$key = 'agent:' . $id . ':svc:' . $svcName;
upsert_alert($key, 'warning', 'Service Down: ' . $svcName . ' on ' . $hn,
$svcName . ' is ' . ($svc['status'] ?? 'inactive') . ' on ' . $hn . '.');
$still_active[$key] = true;
// Auto-dispatch restart if no pending command already queued
$pending = JarvisDB::query(
"SELECT id FROM agent_commands WHERE agent_id=? AND command_type='restart_service'
AND status IN ('pending','delivered') AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)
AND JSON_EXTRACT(command_data,'$.service')=?",
[$id, $svcName]
);
if (empty($pending)) {
JarvisDB::query(
"INSERT INTO agent_commands (agent_id, command_type, command_data, status)
VALUES (?,?,?,?)",
[$id, 'restart_service', json_encode(['service' => $svcName]), 'pending']
);
}
}
// NordVPN (nordlynx interface)
$nordvpn = $d['nordvpn'] ?? null;
if ($nordvpn !== null && !($nordvpn['active'] ?? true)) {
$key = 'agent:' . $id . ':nordvpn_down';
upsert_alert($key, 'critical', 'VPN Down: ' . $hn,
'nordlynx interface is down on ' . $hn . '. Downloads may be unprotected or blocked.');
$still_active[$key] = true;
$pending = JarvisDB::query(
"SELECT id FROM agent_commands WHERE agent_id=? AND command_type='restart_service'
AND status IN ('pending','delivered') AND created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)
AND JSON_EXTRACT(command_data,'$.service')=?",
[$id, 'nordvpnd']
);
if (empty($pending)) {
JarvisDB::query(
"INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)",
[$id, 'restart_service', json_encode(['service' => 'nordvpnd']), 'pending']
);
}
}
}
// ── Site health alerts from kb_facts ──────────────────────────────────────
$siteKeys = ['jarvis','tomsjavajive','epictravelexp','parkersling','orbishosting','orbisportal','tomtomgames'];
$siteNames = [
'jarvis' => 'jarvis.orbishosting.com',
'tomsjavajive' => 'tomsjavajive.com',
'epictravelexp'=> 'epictravelexpeditions.com',
'parkersling' => 'parkerslingshotrentals.com',
'orbishosting' => 'orbishosting.com',
'orbisportal' => 'orbis.orbishosting.com',
'tomtomgames' => 'tomtomgames.com',
];
$siteFacts = JarvisDB::query(
"SELECT fact_key, fact_value FROM kb_facts WHERE category='sites'
AND updated_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)"
);
foreach ($siteFacts as $sf) {
$skey = $sf['fact_key'];
$status = $sf['fact_value'];
$domain = $siteNames[$skey] ?? $skey;
if ($status !== 'up') {
$alertKey = 'site:' . $skey . ':down';
upsert_alert($alertKey, 'critical', 'Site Down: ' . $domain,
$domain . ' returned status ' . $status . '. Site may be unreachable.');
$still_active[$alertKey] = true;
}
}
// ── Auto-resolve alerts whose condition has cleared ────────────────────────
if (!empty($still_active)) {
$active_keys = array_keys($still_active);
// Get all auto-resolvable alerts that are unresolved
$open_auto = JarvisDB::query(
"SELECT id, source_key FROM alerts WHERE resolved=0 AND auto_resolve=1 AND source_key IS NOT NULL"
);
foreach ($open_auto as $row) {
if (!isset($still_active[$row['source_key']])) {
JarvisDB::query(
'UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?',
[$row['id']]
);
}
}
} else {
// Nothing active — resolve all auto alerts
JarvisDB::query(
"UPDATE alerts SET resolved=1, resolved_at=NOW()
WHERE resolved=0 AND auto_resolve=1"
);
}
}
function upsert_alert(string $key, string $sev, string $title, string $msg): void {
$existing = JarvisDB::query(
'SELECT id, severity FROM alerts WHERE source_key=? AND resolved=0 LIMIT 1',
[$key]
);
if ($existing) {
// Update severity/message if changed (e.g., warning → critical)
if ($existing[0]['severity'] !== $sev) {
JarvisDB::query(
'UPDATE alerts SET severity=?, title=?, message=?, created_at=NOW() WHERE id=?',
[$sev, $title, $msg, $existing[0]['id']]
);
}
} else {
JarvisDB::query(
'INSERT INTO alerts (alert_type, title, message, severity, source_key, auto_resolve) VALUES (?,?,?,?,?,1)',
['agent', $title, $msg, $sev, $key]
);
}
}
// ── Route ─────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
// Rate-limit agent alert refresh to once per 60 seconds via kb_facts lock
$last_refresh = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='agent' AND fact_key='alert_refresh' LIMIT 1");
$last_ts = !empty($last_refresh) ? (int)$last_refresh[0]['fact_value'] : 0;
if (time() - $last_ts >= 60) {
JarvisDB::query(
"INSERT INTO kb_facts (category, fact_key, fact_value, host) VALUES ('agent', 'alert_refresh', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[time()]
);
refresh_agent_alerts();
}
$alerts = JarvisDB::query(
'SELECT * FROM alerts WHERE resolved=0 ORDER BY severity DESC, created_at DESC LIMIT 30'
);
echo json_encode(['alerts' => $alerts ?: [], 'count' => count($alerts ?: [])]);
} elseif ($method === 'POST' && ($action === 'resolve' || ($data['action'] ?? '') === 'resolve')) {
$id = (int)($data['id'] ?? 0);
JarvisDB::query('UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', [$id]);
echo json_encode(['success' => true]);
} elseif ($method === 'POST') {
JarvisDB::query(
'INSERT INTO alerts (alert_type, title, message, severity) VALUES (?,?,?,?)',
[$data['type'] ?? 'system', $data['title'] ?? 'Alert', $data['message'] ?? '', $data['severity'] ?? 'info']
);
echo json_encode(['success' => true]);
}
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* JARVIS Arc Reactor Bridge
* Proxies job requests to the Arc Reactor daemon at 127.0.0.1:7474
*/
define('ARC_REACTOR_URL', 'http://127.0.0.1:7474');
define('ARC_TIMEOUT', 8);
function arc_request(string $method, string $path, array $body = []): array {
$url = ARC_REACTOR_URL . $path;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => ARC_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
}
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err || $raw === false) {
return ['error' => 'Arc Reactor unreachable: ' . $err, 'online' => false];
}
$decoded = json_decode($raw, true);
return $decoded ?? ['error' => 'Invalid response from Arc Reactor', 'raw' => substr($raw, 0, 200)];
}
// ── ROUTING ───────────────────────────────────────────────────────────────────
// arc action comes from query string or POST body (not the URL path segment)
global $data;
$action = $_GET['action'] ?? $data['action'] ?? '';
switch ($action) {
// GET /api/arc?action=status
case 'status':
$result = arc_request('GET', '/status');
if (!isset($result['online'])) $result['online'] = false;
echo json_encode($result);
break;
// POST /api/arc — create a job
case 'job_create':
$type = $data['type'] ?? '';
$payload = $data['payload'] ?? [];
$priority = (int)($data['priority'] ?? 5);
if (!$type) { http_response_code(400); echo json_encode(['error' => 'Missing job type']); break; }
$result = arc_request('POST', '/job', [
'type' => $type,
'payload' => $payload,
'priority' => $priority,
'created_by' => 'jarvis_ui',
]);
echo json_encode($result);
break;
// GET /api/arc?action=job_get&id=123
case 'job_get':
$id = (int)($data['id'] ?? $_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing job id']); break; }
echo json_encode(arc_request('GET', "/job/{$id}"));
break;
// GET /api/arc?action=jobs&status=done&limit=20
case 'jobs':
$status = $_GET['status'] ?? $data['status'] ?? '';
$limit = (int)($_GET['limit'] ?? $data['limit'] ?? 50);
$qs = http_build_query(array_filter(['status' => $status, 'limit' => $limit]));
echo json_encode(arc_request('GET', '/jobs' . ($qs ? "?{$qs}" : '')));
break;
// DELETE /api/arc?action=job_cancel&id=123
case 'job_cancel':
$id = (int)($data['id'] ?? $_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing job id']); break; }
echo json_encode(arc_request('DELETE', "/job/{$id}"));
break;
// DELETE /api/arc?action=purge
case 'purge':
echo json_encode(arc_request('DELETE', '/jobs/purge'));
break;
// Quick ping test
case 'ping':
$result = arc_request('POST', '/job', [
'type' => 'ping',
'payload' => [],
'priority' => 9,
'created_by' => 'jarvis_ping',
]);
echo json_encode($result);
break;
// GET /api/arc?action=triage&limit=50&filter=priority
// Returns email_triage rows for the COMMS tab
case 'triage':
$limit = min((int)($_GET['limit'] ?? 50), 100);
$filter = $_GET['filter'] ?? 'priority';
if ($filter === 'urgent') {
$sql = "SELECT id, account, from_name, from_email, subject, date_received,
category, priority, summary, draft_reply, action_taken, created_at
FROM email_triage
WHERE action_taken != 'dismissed' AND category = 'urgent'
ORDER BY priority DESC, created_at DESC LIMIT ?";
} elseif ($filter === 'action') {
$sql = "SELECT id, account, from_name, from_email, subject, date_received,
category, priority, summary, draft_reply, action_taken, created_at
FROM email_triage
WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting')
ORDER BY priority DESC, created_at DESC LIMIT ?";
} elseif ($filter === 'priority') {
$sql = "SELECT id, account, from_name, from_email, subject, date_received,
category, priority, summary, draft_reply, action_taken, created_at
FROM email_triage
WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting')
AND priority >= 5
ORDER BY priority DESC, created_at DESC LIMIT ?";
} else {
$sql = "SELECT id, account, from_name, from_email, subject, date_received,
category, priority, summary, draft_reply, action_taken, created_at
FROM email_triage
WHERE action_taken != 'dismissed'
ORDER BY priority DESC, created_at DESC LIMIT ?";
}
$rows = JarvisDB::query($sql, [$limit]);
echo json_encode($rows ?: []);
break;
// POST /api/arc?action=triage_action&id=123 body: { action: "dismissed"|"replied"|"done" }
case 'triage_action':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
$actionTaken = $data['action'] ?? 'dismissed';
$allowed = ['dismissed', 'replied', 'done', 'snoozed'];
if (!$id || !in_array($actionTaken, $allowed)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid id or action']);
break;
}
JarvisDB::execute(
"UPDATE email_triage SET action_taken = ? WHERE id = ?",
[$actionTaken, $id]
);
echo json_encode(['ok' => true, 'id' => $id, 'action_taken' => $actionTaken]);
break;
// GET /api/arc?action=screenshots&limit=20&agent=hostname
case 'screenshots':
$limit = min((int)($_GET['limit'] ?? 20), 100);
$agent = $_GET['agent'] ?? '';
$url = 'http://127.0.0.1:7474/screenshots?' . http_build_query(array_filter(['limit' => $limit, 'agent' => $agent]));
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
$raw = curl_exec($ch); curl_close($ch);
echo $raw ?: '[]';
break;
// GET /api/arc?action=screenshot_get&id=123
case 'screenshot_get':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/screenshots/{$id}"));
break;
// DELETE /api/arc?action=screenshot_delete&id=123
case 'screenshot_delete':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('DELETE', "/screenshots/{$id}"));
break;
// ── GUARDIAN MODE ─────────────────────────────────────────────────────────
case 'guardian_status':
echo json_encode(arc_request('GET', '/guardian/status'));
break;
case 'guardian_events':
$limit = (int)($_GET['limit'] ?? 30);
$unread = !empty($_GET['unread']) ? 'true' : '';
$severity = $_GET['severity'] ?? '';
$since = $_GET['since'] ?? '';
$qs = http_build_query(array_filter([
'limit' => $limit,
'unread' => $unread,
'severity' => $severity,
'since' => $since,
]));
echo json_encode(arc_request('GET', '/guardian/events' . ($qs ? "?{$qs}" : '')));
break;
case 'guardian_ack':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if ($id) {
echo json_encode(arc_request('POST', "/guardian/events/{$id}/ack"));
} else {
echo json_encode(arc_request('POST', '/guardian/events/ack_all'));
}
break;
case 'guardian_chat':
$since = $_GET['since'] ?? '';
$qs = $since ? '?since=' . urlencode($since) : '';
echo json_encode(arc_request('GET', '/guardian/chat' . $qs));
break;
// ── COMMS v2 ──────────────────────────────────────────────────────────────
// GET /api/arc?action=comms_sent&limit=50&status=sent
case 'comms_sent':
$limit = min((int)($_GET['limit'] ?? 50), 200);
$status = $_GET['status'] ?? '';
$qs = http_build_query(array_filter(['limit' => $limit, 'status' => $status]));
echo json_encode(arc_request('GET', '/comms/sent' . ($qs ? "?{$qs}" : '')));
break;
// GET /api/arc?action=comms_sent_get&id=123
case 'comms_sent_get':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/comms/sent/{$id}"));
break;
// DELETE /api/arc?action=comms_sent_delete&id=123
case 'comms_sent_delete':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('DELETE', "/comms/sent/{$id}"));
break;
// ── MISSION OPS ───────────────────────────────────────────────────────────
// GET /api/arc?action=missions
case 'missions':
echo json_encode(arc_request('GET', '/missions'));
break;
// GET /api/arc?action=mission_get&id=123
case 'mission_get':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/missions/{$id}"));
break;
// GET /api/arc?action=mission_runs&id=123
case 'mission_runs':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
$limit = (int)($_GET['limit'] ?? 20);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/missions/{$id}/runs?limit={$limit}"));
break;
// POST /api/arc?action=mission_create — body: { name, description, trigger_type, trigger_config, steps }
case 'mission_create':
echo json_encode(arc_request('POST', '/missions', $data));
break;
// POST /api/arc?action=mission_update — body: { id, name, ... steps }
case 'mission_update':
$id = (int)($data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('PUT', "/missions/{$id}", $data));
break;
// DELETE /api/arc?action=mission_delete&id=123
case 'mission_delete':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('DELETE', "/missions/{$id}"));
break;
// POST /api/arc?action=mission_run&id=123
case 'mission_run':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('POST', "/missions/{$id}/run", ['trigger_source' => 'manual']));
break;
// POST /api/arc?action=mission_toggle&id=123 body: { enabled: 1|0 }
case 'mission_toggle':
$id = (int)($data['id'] ?? 0);
$enabled = (int)($data['enabled'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('PUT', "/missions/{$id}", ['enabled' => $enabled]));
break;
// GET /api/arc?action=clearance_pending
case 'clearance_pending':
echo json_encode(arc_request('GET', '/clearance/pending'));
break;
// GET /api/arc?action=clearance_history
case 'clearance_history':
$limit = (int)($_GET['limit'] ?? 50);
echo json_encode(arc_request('GET', "/clearance/history?limit={$limit}"));
break;
// POST /api/arc?action=clearance_approve&id=123 body: { decided_by: "..." }
case 'clearance_approve':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
$decided_by = $data['decided_by'] ?? 'admin';
echo json_encode(arc_request('POST', "/clearance/{$id}/approve", ['decided_by' => $decided_by]));
break;
// POST /api/arc?action=clearance_deny&id=123 body: { decided_by: "...", note: "..." }
case 'clearance_deny':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
$decided_by = $data['decided_by'] ?? 'admin';
$note = $data['note'] ?? '';
echo json_encode(arc_request('POST', "/clearance/{$id}/deny", ['decided_by' => $decided_by, 'note' => $note]));
break;
// GET /api/arc?action=clearance_rules
case 'clearance_rules':
echo json_encode(arc_request('GET', '/clearance/rules'));
break;
// PUT /api/arc?action=clearance_rule_update&id=123 body: { require_approval: 0|1, auto_approve_after_min: N, ... }
case 'clearance_rule_update':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
unset($data['id']);
echo json_encode(arc_request('PUT', "/clearance/rules/{$id}", $data));
break;
// POST /api/arc?action=clearance_rule_create body: { job_type, risk_level, require_approval, ... }
case 'clearance_rule_create':
echo json_encode(arc_request('POST', '/clearance/rules', $data));
break;
default:
http_response_code(404);
echo json_encode(['error' => "Unknown arc action: {$action}"]);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Auth endpoint
if ($method === 'POST') {
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if (!$username || !$password) {
http_response_code(400);
echo json_encode(['error' => 'Credentials required']);
exit;
}
$user = JarvisDB::single(
'SELECT * FROM users WHERE username = ?', [$username]
);
if ($user && password_verify($password, $user['password_hash'])) {
$token = bin2hex(random_bytes(32));
$_SESSION['jarvis_token'] = $token;
$_SESSION['jarvis_user_id'] = $user['id'];
$_SESSION['jarvis_name'] = $user['display_name'];
JarvisDB::execute(
'UPDATE users SET last_seen = NOW() WHERE id = ?', [$user['id']]
);
// Use stored address preference for greeting
$addrRow = JarvisDB::query(
"SELECT pref_value FROM kb_preferences WHERE pref_key='user_title' LIMIT 1"
);
$userAddr = $addrRow[0]['pref_value'] ?? $user['display_name'];
echo json_encode([
'success' => true,
'token' => $token,
'display_name' => $userAddr,
'greeting' => "Welcome back, {$userAddr}. All systems are online and awaiting your command.",
]);
} else {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
}
} elseif ($method === 'DELETE') {
session_destroy();
echo json_encode(['success' => true, 'message' => 'Session terminated.']);
} else {
// Check session status
$loggedIn = !empty($_SESSION['jarvis_token']);
echo json_encode([
'authenticated' => $loggedIn,
'name' => $_SESSION['jarvis_name'] ?? null,
]);
}
+283
View File
@@ -0,0 +1,283 @@
<?php
/**
* JARVIS Calendar Sync — iCloud CalDAV + Google/ICS feeds
* Fetches events, parses them, syncs into appointments table.
* Runs via cron (/usr/local/lsws/lsphp85/bin/lsphp /path/to/calendar_sync.php)
* or via HTTP: GET /api/calendar/sync
*/
$isCLI = php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed';
if (!class_exists('JarvisDB')) {
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
}
// ── ICS Parser ───────────────────────────────────────────────────────────────
function parseICS(string $ics): array {
$events = [];
// Unfold lines (RFC 5545: lines folded with CRLF + whitespace)
$ics = preg_replace('/\r\n[ \t]/', '', $ics);
$ics = preg_replace('/\n[ \t]/', '', $ics);
preg_match_all('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ics, $blocks);
foreach ($blocks[1] as $block) {
$e = [];
// Parse each property line
foreach (explode("\n", $block) as $line) {
$line = trim($line);
if (!$line) continue;
// Split on first colon not inside quotes
$pos = strpos($line, ':');
if ($pos === false) continue;
$prop = strtoupper(trim(substr($line, 0, $pos)));
$val = trim(substr($line, $pos + 1));
// Extract param-stripped property name (e.g. DTSTART;TZID=... → DTSTART)
$propBase = explode(';', $prop)[0];
// Extract TZID param if present
$tzid = null;
if (preg_match('/TZID=([^;:]+)/i', $prop, $tzm)) $tzid = $tzm[1];
$e[$propBase] = ['val' => $val, 'tzid' => $tzid, 'raw_prop' => $prop];
}
if (empty($e['SUMMARY']) || empty($e['UID'])) continue;
$uid = $e['UID']['val'];
$summary = calDecode($e['SUMMARY']['val']);
$desc = isset($e['DESCRIPTION']) ? calDecode($e['DESCRIPTION']['val']) : '';
$loc = isset($e['LOCATION']) ? calDecode($e['LOCATION']['val']) : '';
$startRaw = $e['DTSTART']['val'] ?? null;
$endRaw = $e['DTEND']['val'] ?? ($e['DURATION']['val'] ?? null);
if (!$startRaw) continue;
$startDt = parseCalDate($startRaw, $e['DTSTART']['tzid'] ?? null);
$endDt = $endRaw ? parseCalDate($endRaw, $e['DTEND']['tzid'] ?? null) : null;
$allDay = (strlen($startRaw) === 8); // YYYYMMDD format = all-day
if (!$startDt) continue;
// Skip events more than 1 year in the past
if (strtotime($startDt) < time() - 365 * 86400) continue;
$events[] = compact('uid','summary','desc','loc','startDt','endDt','allDay');
}
return $events;
}
function parseCalDate(string $raw, ?string $tzid): ?string {
$raw = preg_replace('/[^0-9T Z]/i', '', $raw);
// All-day: YYYYMMDD
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $raw, $m))
return "{$m[1]}-{$m[2]}-{$m[3]} 00:00:00";
// Datetime: YYYYMMDDTHHMMSS[Z]
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/', $raw, $m)) {
$ts = gmmktime((int)$m[4],(int)$m[5],(int)$m[6],(int)$m[2],(int)$m[3],(int)$m[1]);
// Convert to local (server) timezone
return date('Y-m-d H:i:s', $ts - date('Z'));
}
return null;
}
function calDecode(string $s): string {
// Decode quoted-printable and backslash escapes
$s = str_replace(['\\n','\\N','\\,','\;'], ["\n"," ",",",";"], $s);
return trim($s);
}
// ── CalDAV Client ─────────────────────────────────────────────────────────────
function caldavRequest(string $method, string $url, string $user, string $pass,
string $body = '', array $extraHeaders = []): array {
$ch = curl_init($url);
$headers = array_merge([
'Content-Type: text/xml; charset=utf-8',
'Prefer: return-minimal',
], $extraHeaders);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_USERPWD => "$user:$pass",
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return ['code' => $code, 'body' => $resp ?: '', 'error' => $err];
}
function xmlVal(string $xml, string $tag): ?string {
if (preg_match('~<(?:[^:]+:)?' . preg_quote($tag, '~') . '[^>]*>(.*?)</(?:[^:]+:)?' . preg_quote($tag, '~') . '>~s', $xml, $m))
return trim(html_entity_decode(strip_tags($m[1])));
return null;
}
function fetchICloudCalendars(string $user, string $pass): array {
$results = [];
// Step 1: Discover principal
$propfind = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>';
$r = caldavRequest('PROPFIND', 'https://caldav.icloud.com/', $user, $pass, $propfind, ['Depth: 0']);
$principal = xmlVal($r['body'], 'current-user-principal');
if (!$principal) $principal = xmlVal($r['body'], 'href');
if (!$principal) return ['error' => 'Cannot discover principal: HTTP ' . $r['code']];
// Make absolute URL
if (!str_starts_with($principal, 'http')) $principal = 'https://caldav.icloud.com' . $principal;
// Step 2: Get calendar home
$propfind2 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>';
$r2 = caldavRequest('PROPFIND', $principal, $user, $pass, $propfind2, ['Depth: 0']);
$calHome = xmlVal($r2['body'], 'calendar-home-set');
if (!$calHome) $calHome = xmlVal($r2['body'], 'href');
if (!$calHome) return ['error' => 'Cannot find calendar home'];
if (!str_starts_with($calHome, 'http')) $calHome = 'https://caldav.icloud.com' . $calHome;
// Step 3: List calendars
$propfind3 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><c:supported-calendar-component-set/></d:prop></d:propfind>';
$r3 = caldavRequest('PROPFIND', $calHome, $user, $pass, $propfind3, ['Depth: 1']);
// Parse calendar URLs from multistatus response
preg_match_all('~<d:response>(.*?)</d:response>~s', $r3['body'], $responses);
foreach ($responses[1] as $resp) {
$href = xmlVal($resp, 'href');
$name = xmlVal($resp, 'displayname');
// Only include actual calendars (not the home itself)
if ($href && $href !== parse_url($calHome, PHP_URL_PATH) && strpos($resp, 'VEVENT') !== false) {
$calUrl = str_starts_with($href, 'http') ? $href : 'https://caldav.icloud.com' . $href;
$results[] = ['url' => $calUrl, 'name' => $name ?: 'iCloud Calendar'];
}
}
return ['calendars' => $results, 'home' => $calHome];
}
function fetchCalDAVEvents(string $calUrl, string $user, string $pass): array {
// Fetch events from 1 year ago to 2 years ahead
$from = gmdate('Ymd\THis\Z', strtotime('-1 year'));
$to = gmdate('Ymd\THis\Z', strtotime('+2 years'));
$report = <<<XML
<?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="$from" end="$to"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>
XML;
$r = caldavRequest('REPORT', $calUrl, $user, $pass, $report, ['Depth: 1', 'Content-Type: text/xml']);
if ($r['code'] !== 207) return ['error' => 'CalDAV REPORT failed: HTTP ' . $r['code']];
// Extract calendar-data from response
$allICS = '';
preg_match_all('~<(?:[^:]+:)?calendar-data[^>]*>(.*?)</(?:[^:]+:)?calendar-data>~s', $r['body'], $m);
foreach ($m[1] as $icsBlock) {
$allICS .= html_entity_decode($icsBlock) . "\n";
}
return ['events' => parseICS("BEGIN:VCALENDAR\n" . $allICS . "\nEND:VCALENDAR")];
}
// ── Sync events to DB ─────────────────────────────────────────────────────────
function syncEvents(array $events, string $source, string $feedName): array {
$inserted = $updated = $skipped = 0;
foreach ($events as $ev) {
$uid = md5($source . ':' . $ev['uid']); // deterministic per source
$existing = JarvisDB::single('SELECT id, start_at, title FROM appointments WHERE external_uid=? AND external_source=?', [$uid, $source]);
$cat = 'personal';
if (preg_match('/\b(work|meeting|office|client|project|call|conference|interview|standup|sprint)\b/i', $ev['summary'])) $cat = 'work';
if (preg_match('/\b(doctor|medical|dentist|pharmacy|hospital|clinic|appointment)\b/i', $ev['summary'])) $cat = 'medical';
if ($existing) {
// Update if start time changed
if ($existing['start_at'] !== $ev['startDt']) {
JarvisDB::execute(
'UPDATE appointments SET title=?, description=?, location=?, start_at=?, end_at=?, all_day=?, category=?, last_synced=NOW() WHERE id=?',
[$ev['summary'], $ev['desc'], $ev['loc'], $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $cat, $existing['id']]
);
$updated++;
} else {
$skipped++;
}
} else {
JarvisDB::execute(
'INSERT INTO appointments (title, description, location, category, start_at, end_at, all_day, external_uid, external_source, last_synced)
VALUES (?,?,?,?,?,?,?,?,?,NOW())',
[$ev['summary'], $ev['desc'], $ev['loc'], $cat, $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $uid, $source]
);
$inserted++;
}
}
// Remove events that no longer exist in the feed (deleted from calendar)
$syncedUids = array_map(fn($ev) => md5($source . ':' . $ev['uid']), $events);
if ($syncedUids) {
$placeholders = implode(',', array_fill(0, count($syncedUids), '?'));
JarvisDB::execute(
"DELETE FROM appointments WHERE external_source=? AND external_uid NOT IN ($placeholders) AND external_uid IS NOT NULL",
array_merge([$source], $syncedUids)
);
}
return compact('inserted', 'updated', 'skipped');
}
// ── Main sync runner ──────────────────────────────────────────────────────────
function runSync(): array {
$results = [];
// ── iCloud CalDAV ──────────────────────────────────────────────────────
if (defined('ICLOUD_USER') && ICLOUD_USER && defined('ICLOUD_PASS') && ICLOUD_PASS) {
$calInfo = fetchICloudCalendars(ICLOUD_USER, ICLOUD_PASS);
if (isset($calInfo['error'])) {
$results['icloud'] = 'Error: ' . $calInfo['error'];
} else {
$totalEvents = [];
foreach ($calInfo['calendars'] as $cal) {
$evData = fetchCalDAVEvents($cal['url'], ICLOUD_USER, ICLOUD_PASS);
if (!empty($evData['events'])) $totalEvents = array_merge($totalEvents, $evData['events']);
}
$r = syncEvents($totalEvents, 'icloud', 'iCloud');
JarvisDB::execute("UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE source='icloud'", [count($totalEvents)]);
$results['icloud'] = "OK: {$r['inserted']} new, {$r['updated']} updated, {$r['skipped']} unchanged (" . count($totalEvents) . " events total)";
}
}
// ── Google Calendar ICS ────────────────────────────────────────────────
$feeds = JarvisDB::query("SELECT * FROM calendar_feeds WHERE source IN ('google','ics') AND active=1") ?? [];
foreach ($feeds as $feed) {
if (!$feed['ics_url']) continue;
$ch = curl_init($feed['ics_url']);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>20, CURLOPT_FOLLOWLOCATION=>true, CURLOPT_SSL_VERIFYPEER=>true]);
if ($feed['username']) curl_setopt($ch, CURLOPT_USERPWD, $feed['username'] . ':' . $feed['password']);
$ics = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$ics) {
$results[$feed['name']] = "Error fetching ICS (HTTP $code)";
continue;
}
$events = parseICS($ics);
$r = syncEvents($events, $feed['source'] . '_' . $feed['id'], $feed['name']);
JarvisDB::execute('UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE id=?', [count($events), $feed['id']]);
$results[$feed['name']] = "OK: {$r['inserted']} new, {$r['updated']} updated (" . count($events) . " events)";
}
return $results;
}
// ── Entry point ───────────────────────────────────────────────────────────────
if ($isCLI) {
echo date('Y-m-d H:i:s') . " Calendar sync started\n";
$r = runSync();
foreach ($r as $src => $msg) echo " $src: $msg\n";
echo date('Y-m-d H:i:s') . " Done\n";
} else {
// HTTP endpoint
$r = runSync();
echo json_encode(['status' => 'ok', 'results' => $r, 'timestamp' => date('c')]);
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
<?php
// JARVIS Directives — OKR / mission goal tracking
// Actions: list | get | save | delete | key_result_update | link | unlink | review
switch ($action) {
// GET directives/list — all active directives with key results + progress
case 'list':
$status = $_GET['status'] ?? 'active';
$category = $_GET['category'] ?? '';
$where = '1=1';
$params = [];
if ($status && $status !== 'all') { $where .= ' AND d.status=?'; $params[] = $status; }
if ($category) { $where .= ' AND d.category=?'; $params[] = $category; }
$rows = JarvisDB::query(
"SELECT d.*,
COUNT(kr.id) AS kr_count,
COALESCE(SUM(kr.current_value),0) AS kr_current_sum,
COALESCE(SUM(kr.target_value),0) AS kr_target_sum,
(SELECT COUNT(*) FROM directive_links dl WHERE dl.directive_id=d.id) AS link_count
FROM directives d
LEFT JOIN directive_key_results kr ON kr.directive_id=d.id
WHERE {$where}
GROUP BY d.id
ORDER BY d.priority DESC, d.target_date ASC, d.created_at DESC",
$params
) ?: [];
// Compute progress pct per directive
foreach ($rows as &$r) {
$r['progress'] = ($r['kr_target_sum'] > 0)
? (float)round($r['kr_current_sum'] / $r['kr_target_sum'] * 100, 1)
: 0;
}
unset($r);
echo json_encode(['directives' => $rows]);
break;
// GET directives/get?id=X — full directive with key results and links
case 'get':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'Missing id']); break; }
$d = JarvisDB::single("SELECT * FROM directives WHERE id=?", [$id]);
if (!$d) { echo json_encode(['error' => 'Not found']); break; }
$krs = JarvisDB::query("SELECT * FROM directive_key_results WHERE directive_id=? ORDER BY id ASC", [$id]) ?: [];
$links = JarvisDB::query(
"SELECT dl.*, t.title AS task_title, a.title AS appt_title
FROM directive_links dl
LEFT JOIN tasks t ON dl.link_type='task' AND t.id=dl.link_id
LEFT JOIN appointments a ON dl.link_type='appointment' AND a.id=dl.link_id
WHERE dl.directive_id=?
ORDER BY dl.created_at DESC",
[$id]
) ?: [];
$sum_cur = array_sum(array_column($krs, 'current_value'));
$sum_tgt = array_sum(array_column($krs, 'target_value'));
$d['progress'] = ($sum_tgt > 0) ? round($sum_cur / $sum_tgt * 100, 1) : 0;
$d['key_results'] = $krs;
$d['links'] = $links;
echo json_encode($d);
break;
// POST directives/save — create or update directive + key results
case 'save':
$id = (int)($data['id'] ?? 0);
$title = trim($data['title'] ?? '');
$description = trim($data['description'] ?? '');
$category = $data['category'] ?? 'work';
$status = $data['status'] ?? 'active';
$priority = (int)($data['priority'] ?? 5);
$target_date = !empty($data['target_date']) ? $data['target_date'] : null;
if (!$title) { echo json_encode(['error' => 'Title required']); break; }
if ($id) {
JarvisDB::execute(
"UPDATE directives SET title=?,description=?,category=?,status=?,priority=?,target_date=?,updated_at=NOW() WHERE id=?",
[$title,$description,$category,$status,$priority,$target_date,$id]
);
} else {
$id = JarvisDB::insert(
"INSERT INTO directives (title,description,category,status,priority,target_date) VALUES (?,?,?,?,?,?)",
[$title,$description,$category,$status,$priority,$target_date]
);
}
// Replace key results if provided
if (isset($data['key_results']) && is_array($data['key_results'])) {
JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]);
foreach ($data['key_results'] as $kr) {
$krtitle = trim($kr['title'] ?? ''); if (!$krtitle) continue;
JarvisDB::execute(
"INSERT INTO directive_key_results (directive_id,title,current_value,target_value,unit) VALUES (?,?,?,?,?)",
[$id, $krtitle, (float)($kr['current_value']??0), (float)($kr['target_value']??100), $kr['unit']??'%']
);
}
}
echo json_encode(['ok' => true, 'id' => $id]);
break;
// POST directives/delete?id=X
case 'delete':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'Missing id']); break; }
JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]);
JarvisDB::execute("DELETE FROM directive_links WHERE directive_id=?", [$id]);
JarvisDB::execute("DELETE FROM directives WHERE id=?", [$id]);
echo json_encode(['ok' => true]);
break;
// POST directives/key_result_update — update a single KR's current value
case 'key_result_update':
$krid = (int)($data['id'] ?? 0);
$value = (float)($data['current_value'] ?? 0);
if (!$krid) { echo json_encode(['error' => 'Missing kr id']); break; }
JarvisDB::execute(
"UPDATE directive_key_results SET current_value=?, updated_at=NOW() WHERE id=?",
[$value, $krid]
);
echo json_encode(['ok' => true]);
break;
// POST directives/link — link a task or appointment to a directive
case 'link':
$did = (int)($data['directive_id'] ?? 0);
$link_type = $data['link_type'] ?? 'task';
$link_id = (int)($data['link_id'] ?? 0);
$note = trim($data['note'] ?? '');
if (!$did) { echo json_encode(['error' => 'Missing directive_id']); break; }
JarvisDB::execute(
"INSERT INTO directive_links (directive_id,link_type,link_id,note) VALUES (?,?,?,?)",
[$did, $link_type, $link_id ?: null, $note]
);
echo json_encode(['ok' => true]);
break;
// POST directives/unlink?id=X — remove a link
case 'unlink':
$lid = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$lid) { echo json_encode(['error' => 'Missing id']); break; }
JarvisDB::execute("DELETE FROM directive_links WHERE id=?", [$lid]);
echo json_encode(['ok' => true]);
break;
// GET directives/summary — compact progress snapshot for chat/briefing injection
case 'summary':
$rows = JarvisDB::query(
"SELECT d.id, d.title, d.category, d.target_date,
COALESCE(SUM(kr.current_value),0) AS kr_cur,
COALESCE(SUM(kr.target_value),1) AS kr_tgt
FROM directives d
LEFT JOIN directive_key_results kr ON kr.directive_id=d.id
WHERE d.status='active'
GROUP BY d.id
ORDER BY d.priority DESC, d.target_date ASC
LIMIT 10"
) ?: [];
$summary = [];
foreach ($rows as $r) {
$pct = round($r['kr_cur'] / max($r['kr_tgt'], 1) * 100, 0);
$summary[] = [
'id' => $r['id'],
'title' => $r['title'],
'category' => $r['category'],
'target_date' => $r['target_date'],
'progress' => $pct,
];
}
echo json_encode(['directives' => $summary]);
break;
default:
http_response_code(404);
echo json_encode(['error' => "Unknown directives action: {$action}"]);
}
+94
View File
@@ -0,0 +1,94 @@
<?php
// Digital Ocean server monitoring — read local /proc directly (no SSH loopback)
// CPU usage (sample over 200ms)
function getCpuPct(): float {
$s1 = file_get_contents("/proc/stat");
usleep(200000);
$s2 = file_get_contents("/proc/stat");
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s1, $m1);
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s2, $m2);
$idle1 = $m1[4]; $total1 = $m1[1]+$m1[2]+$m1[3]+$m1[4];
$idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4];
$dt = $total2 - $total1;
return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0;
}
$memLines = [];
foreach (file("/proc/meminfo") as $l) {
[$k, $v] = explode(":", $l, 2) + [null, null];
if ($k) $memLines[trim($k)] = (int)trim($v);
}
$memTotal = $memLines["MemTotal"] ?? 0;
$memFree = $memLines["MemAvailable"] ?? 0;
$memUsed = $memTotal - $memFree;
$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0];
$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0];
$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
$diskPct = trim($dfOut);
// Services
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
$svcMap = [];
foreach ($svcNames as $s) {
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
if ($status === "active") $svcMap[$s] = true;
}
// Site health from kb_facts
$siteLabels = [
"jarvis" => "jarvis.orbishosting.com",
"tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com",
"orbishosting" => "orbishosting.com",
"orbisportal" => "orbis.orbishosting.com",
"tomtomgames" => "tomtomgames.com",
];
$sites = [];
$rows = JarvisDB::query(
"SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' AND updated_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE) ORDER BY fact_key"
);
foreach ($rows as $r) {
$label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"];
$sites[$label] = $r["fact_value"];
}
$uptimeDays = intdiv($uptime, 86400);
$uptimeHrs = intdiv($uptime % 86400, 3600);
// DO server agent metrics (jarvis-do agent reporting via Tailscale)
$doAgent = JarvisDB::query(
"SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
);
$doMet = [];
if (!empty($doAgent[0]['metric_data'])) {
$dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
$doMet = [
"cpu" => $dm['cpu_percent'] ?? 0,
"mem" => $dm['memory']['percent'] ?? 0,
"disk" => (int)($dm['disk'][0]['percent'] ?? 0),
"uptime" => $dm['uptime']['human'] ?? "--",
"online" => true,
];
}
echo json_encode([
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
"reachable" => true,
"cpu_pct" => getCpuPct(),
"memory" => [
"total_mb" => round($memTotal / 1024),
"used_mb" => round($memUsed / 1024),
"percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0,
],
"disk_used_pct" => $diskPct,
"load_1m" => $load,
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
"services" => $svcMap,
"sites" => $sites,
"do_server" => $doMet,
"timestamp" => date("c"),
]);
+238
View File
@@ -0,0 +1,238 @@
<?php
// JARVIS Email — IMAP reader + action item extractor for Gmail, Outlook, iCloud.
// GET ?account=all|gmail|outlook|icloud → recent + unread summary (cached 5 min)
// GET ?action=action_items → detected tasks/appointments from emails
// POST action=create_task → create task from email_action id
// POST action=create_appt → create appointment from email_action id
// POST action=dismiss → dismiss email_action
// GET ?force=1 → bypass cache
// ── IMAP fetch ────────────────────────────────────────────────────────────────
function imapFetch(string $host, string $user, string $pass, int $maxMsgs = 15): array {
if (!$user || !$pass) return ['error' => 'not_configured'];
$mbox = @imap_open($host, $user, $pass, 0, 1, ['DISABLE_AUTHENTICATOR' => 'GSSAPI']);
if (!$mbox) return ['error' => imap_last_error() ?: 'connection_failed'];
$total = imap_num_msg($mbox);
$unseen = imap_search($mbox, 'UNSEEN') ?: [];
$unread = count($unseen);
$msgs = [];
for ($i = $total; $i >= max(1, $total - $maxMsgs + 1); $i--) {
$hdr = @imap_headerinfo($mbox, $i);
if (!$hdr) continue;
$from = $hdr->from[0] ?? null;
$fromName = $from && isset($from->personal) ? imap_utf8($from->personal) : '';
$fromEmail = $from ? ($from->mailbox . '@' . ($from->host ?? '')) : '';
$subject = isset($hdr->subject) ? imap_utf8($hdr->subject) : '(no subject)';
$date = isset($hdr->date) ? date('M j g:ia', strtotime($hdr->date)) : '';
$dateRaw = isset($hdr->date) ? date('Y-m-d H:i:s', strtotime($hdr->date)) : null;
$isUnread = in_array($i, $unseen);
$msgId = md5(($hdr->message_id ?? '') ?: ($fromEmail . $subject . $date));
// Preview
$preview = '';
$struct = @imap_fetchstructure($mbox, $i);
if ($struct) {
if ($struct->type === 0) {
$raw = @imap_fetchbody($mbox, $i, '1');
$enc = $struct->encoding ?? 0;
if ($enc === 3) $raw = base64_decode($raw);
elseif ($enc === 4) $raw = quoted_printable_decode($raw);
$preview = mb_substr(strip_tags($raw), 0, 400);
} else {
foreach (($struct->parts ?? []) as $idx => $part) {
if ($part->type === 0) {
$raw = @imap_fetchbody($mbox, $i, (string)($idx + 1));
$enc = $part->encoding ?? 0;
if ($enc === 3) $raw = base64_decode($raw);
elseif ($enc === 4) $raw = quoted_printable_decode($raw);
$preview = mb_substr(strip_tags($raw), 0, 400);
break;
}
}
}
}
$preview = trim(preg_replace('/\s+/', ' ', $preview));
$msgs[] = [
'id' => $i,
'msg_id' => $msgId,
'from_name' => $fromName ?: $fromEmail,
'from_email' => $fromEmail,
'subject' => $subject,
'date' => $date,
'date_raw' => $dateRaw,
'unread' => $isUnread,
'preview' => $preview,
];
}
imap_close($mbox);
return ['total' => $total, 'unread' => $unread, 'messages' => $msgs];
}
// ── Action item detection ─────────────────────────────────────────────────────
function detectActionItem(string $subject, string $preview): array {
$text = strtolower($subject . ' ' . mb_substr($preview, 0, 500));
$apptKw = ['meeting','zoom call','teams call','video call','conference call','join us','calendar invite',
'appointment','interview','webinar','you are invited','let\'s meet','lets meet',
'scheduled for','scheduled at','set up a call','book a call','at \d{1,2}(:\d{2})?\s*(am|pm)'];
$taskKw = ['action required','action needed','please review','please respond','please confirm',
'please send','please provide','please complete','please sign','please approve',
'follow up','follow-up','reminder:','deadline','due by','due date',
'asap','urgent','your attention','needs your','waiting for you',
'can you','could you','would you please','i need you to','kindly'];
$apptScore = 0;
$taskScore = 0;
foreach ($apptKw as $kw) {
if (@preg_match('/' . $kw . '/i', $text)) { $apptScore += 25; break; }
}
foreach ($taskKw as $kw) {
if (strpos($text, $kw) !== false) { $taskScore += 20; break; }
}
// Bonus: explicit deadline date in subject
if (preg_match('/\b(by\s+)?(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{1,2}\/\d{1,2})\b/i', $subject)) {
$taskScore += 15;
}
// Suggested date extraction
$sugDate = null;
if (preg_match('/\b(tomorrow|today|next\s+\w+|this\s+(monday|tuesday|wednesday|thursday|friday)|monday|tuesday|wednesday|thursday|friday|\d{1,2}[\/\-]\d{1,2}(?:[\/\-]\d{2,4})?)\b/i', $subject . ' ' . mb_substr($preview, 0, 200), $dm)) {
$ts = strtotime($dm[0]);
if ($ts !== false && $ts > time() - 86400) $sugDate = date('Y-m-d', $ts);
}
$type = null;
if ($apptScore >= 25 && $apptScore >= $taskScore) $type = 'appointment';
elseif ($taskScore >= 20) $type = 'task';
return ['type' => $type, 'suggested_date' => $sugDate];
}
// ── Upsert email actions ──────────────────────────────────────────────────────
function storeEmailActions(string $account, array $messages): void {
foreach ($messages as $msg) {
$ai = detectActionItem($msg['subject'], $msg['preview'] ?? '');
if (!$ai['type']) continue;
JarvisDB::execute(
"INSERT INTO email_actions (account,message_uid,from_email,from_name,subject,preview,received_at,action_type,suggested_title,suggested_date)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE action_type=VALUES(action_type), suggested_title=VALUES(suggested_title),
suggested_date=VALUES(suggested_date), from_name=VALUES(from_name),
preview=VALUES(preview), received_at=VALUES(received_at)",
[$account, $msg['msg_id'], $msg['from_email'], $msg['from_name'], $msg['subject'],
mb_substr($msg['preview'] ?? '', 0, 500), $msg['date_raw'],
$ai['type'], mb_substr($msg['subject'], 0, 255), $ai['suggested_date']]
);
}
}
// ── Route ─────────────────────────────────────────────────────────────────────
$actionType = $data['action'] ?? $_GET['action'] ?? '';
// ── Create task from email action ─────────────────────────────────────────────
if ($method === 'POST' && $actionType === 'create_task') {
$id = (int)($data['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'Missing id']); exit; }
$ea = JarvisDB::single('SELECT * FROM email_actions WHERE id=?', [$id]);
if (!$ea) { echo json_encode(['error' => 'Not found']); exit; }
$title = trim($data['title'] ?? $ea['suggested_title']);
$due_date = trim($data['due_date'] ?? $ea['suggested_date'] ?? '');
$notes = "From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}";
$taskId = JarvisDB::insert(
'INSERT INTO tasks (title,notes,category,priority,due_date) VALUES (?,?,?,?,?)',
[$title, $notes, 'work', 'normal', $due_date ?: null]
);
JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $id]);
echo json_encode(['success' => true, 'task_id' => $taskId]);
exit;
}
// ── Create appointment from email action ──────────────────────────────────────
if ($method === 'POST' && $actionType === 'create_appt') {
$id = (int)($data['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'Missing id']); exit; }
$ea = JarvisDB::single('SELECT * FROM email_actions WHERE id=?', [$id]);
if (!$ea) { echo json_encode(['error' => 'Not found']); exit; }
$title = trim($data['title'] ?? $ea['suggested_title']);
$start_at = trim($data['start_at'] ?? '');
if (!$start_at && $ea['suggested_date']) $start_at = $ea['suggested_date'] . ' 09:00:00';
if (!$start_at) $start_at = date('Y-m-d') . ' 09:00:00';
$desc = "From: {$ea['from_name']} <{$ea['from_email']}>";
$apptId = JarvisDB::insert(
'INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)',
[$title, $desc, 'work', $start_at]
);
JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $id]);
echo json_encode(['success' => true, 'appointment_id' => $apptId]);
exit;
}
// ── Dismiss email action ──────────────────────────────────────────────────────
if ($method === 'POST' && $actionType === 'dismiss') {
$id = (int)($data['id'] ?? 0);
if ($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?', [$id]);
echo json_encode(['success' => true]);
exit;
}
// ── List detected action items ────────────────────────────────────────────────
if ($actionType === 'action_items') {
$rows = JarvisDB::query(
"SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL
ORDER BY received_at DESC LIMIT 50"
) ?? [];
echo json_encode(['action_items' => $rows, 'count' => count($rows)]);
exit;
}
// ── Inbox fetch (main) ────────────────────────────────────────────────────────
$account = $data['account'] ?? $_GET['account'] ?? 'all';
$force = !empty($data['force']) || !empty($_GET['force']);
$cacheKey = 'email_' . $account;
$cacheTtl = 300;
if (!$force) {
$cached = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key=?", [$cacheKey]);
if ($cached && (time() - (int)$cached['ts']) < $cacheTtl) {
$out = json_decode($cached['data'], true);
$out['cache_age_s'] = time() - (int)$cached['ts'];
// Merge unactioned email_actions count
$out['action_items_count'] = (int)(JarvisDB::single("SELECT COUNT(*) c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL")['c'] ?? 0);
echo json_encode($out);
exit;
}
}
$result = ['accounts' => []];
$accounts_to_fetch = [];
if (in_array($account, ['all', 'gmail']) && defined('GMAIL_USER') && GMAIL_USER) $accounts_to_fetch['gmail'] = ['{imap.gmail.com:993/imap/ssl}INBOX', GMAIL_USER, GMAIL_PASS];
if (in_array($account, ['all', 'outlook']) && defined('OUTLOOK_USER') && OUTLOOK_USER) $accounts_to_fetch['outlook'] = ['{outlook.office365.com:993/imap/ssl}INBOX', OUTLOOK_USER, OUTLOOK_PASS];
if (in_array($account, ['all', 'icloud']) && defined('ICLOUD_USER') && ICLOUD_USER) $accounts_to_fetch['icloud'] = ['{imap.mail.me.com:993/imap/ssl}INBOX', ICLOUD_USER, ICLOUD_PASS];
foreach ($accounts_to_fetch as $acct => [$host, $user, $pass]) {
$r = imapFetch($host, $user, $pass, 15);
$result['accounts'][$acct] = $r;
if (!empty($r['messages'])) storeEmailActions($acct, $r['messages']);
}
// Build summary
$totalUnread = 0;
$allMessages = [];
foreach ($result['accounts'] as $acct => $r) {
if (isset($r['unread'])) $totalUnread += (int)$r['unread'];
foreach ($r['messages'] ?? [] as $m) {
$m['account'] = $acct;
$allMessages[] = $m;
}
}
// Sort by date descending
usort($allMessages, fn($a,$b) => strtotime($b['date_raw']??'0') - strtotime($a['date_raw']??'0'));
$result['summary'] = [
'total_unread' => $totalUnread,
'recent' => array_slice($allMessages, 0, 15),
'fetched_at' => date('c'),
];
$result['action_items_count'] = (int)(JarvisDB::single("SELECT COUNT(*) c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL")['c'] ?? 0);
JarvisDB::execute(
"INSERT INTO api_cache (cache_key,data,updated_at) VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE data=VALUES(data),updated_at=NOW()",
[$cacheKey, json_encode($result)]
);
$result['cache_age_s'] = 0;
echo json_encode($result);
+256
View File
@@ -0,0 +1,256 @@
<?php
/**
* JARVIS Facts Collector
* HTTP endpoint: /api/facts/collect (POST or GET)
* CLI/cron: php facts_collector.php
* Gathers live system, network, Proxmox, HA, and Ollama facts kb_facts table.
*/
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed');
// Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
if (!class_exists('KBEngine')) {
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/kb_engine.php';
}
function collect_all(): array {
$results = [];
$ttl = 300; // 5-minute TTL on live facts
// Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh.
// Comparison is done entirely in SQL (via NOW()) rather than PHP's time()/strtotime()
// — this file's config.php sets date_default_timezone_set('America/Chicago'), which
// makes strtotime() misinterpret MySQL's naive (UTC) datetime strings as being in
// Chicago time, throwing every freshness check off by the UTC offset (previously
// caused "sites" to always look artificially fresh and never actually refresh).
$fresh = function(string $cat, int $secs): bool {
$row = JarvisDB::query(
'SELECT (updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)) AS is_fresh FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
[$secs, $cat]
);
if (empty($row)) return false;
return (bool) $row[0]['is_fresh'];
};
// ── System ────────────────────────────────────────────────────────────
try {
$stat1 = file_get_contents('/proc/stat');
usleep(200000);
$stat2 = file_get_contents('/proc/stat');
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d");
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d");
$dIdle = $cpu2[3] - $cpu1[3];
$dTotal = array_sum($cpu2) - array_sum($cpu1);
$cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0;
KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl);
$memLines = file('/proc/meminfo');
$mem = [];
foreach ($memLines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$total = round($mem['MemTotal'] / 1048576, 1);
$avail = round($mem['MemAvailable'] / 1048576, 1);
$used = round($total - $avail, 1);
$free = round($mem['MemFree'] / 1048576, 1);
$memPct = $total > 0 ? round($used / $total * 100) : 0;
KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl);
KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl);
KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl);
KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl);
$la = explode(' ', file_get_contents('/proc/loadavg'));
KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl);
KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl);
KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl);
$sec = (int) file_get_contents('/proc/uptime');
KBEngine::storeFact('system', 'uptime',
intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours',
'local', $ttl);
$df = disk_free_space('/');
$dt = disk_total_space('/');
KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl);
KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl);
KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl);
$results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)";
} catch (Exception $e) {
$results['system'] = 'error: ' . $e->getMessage();
}
// ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ──
try {
$rows = JarvisDB::query(
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
$total = count($rows);
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
$results['network'] = "ok ({$online}/{$total} online)";
} catch (Exception $e) {
$results['network'] = 'error: ' . $e->getMessage();
}
// ── Proxmox (TTL 10 min) ─────────────────────────────────────────────
if ($fresh('proxmox', 600)) {
$results['proxmox'] = 'skipped (fresh)';
} else try {
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
$vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth);
$cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth);
if (isset($nd['data'])) {
$cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1);
$memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1);
$memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1);
$memPct = $memT > 0 ? round($memU / $memT * 100) : 0;
KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl);
}
$all = array_merge($vms['data'] ?? [], $cts['data'] ?? []);
$running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running'));
KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl);
$results['proxmox'] = "ok ({$running}/" . count($all) . " running)";
} else {
$results['proxmox'] = 'skipped (no token)';
}
} catch (Exception $e) {
$results['proxmox'] = 'error: ' . $e->getMessage();
}
// ── Home Assistant — skipped (HA agent pushes entities every 30s) ────
$results['ha'] = 'skipped (agent push active)';
// ── Digital Ocean ─────────────────────────────────────────────────────
try {
exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);;
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
$results['do_server'] = "ok ({$doStatus})";
} catch (Exception $e) {
$results['do_server'] = 'error: ' . $e->getMessage();
}
// ── Ollama (TTL 15 min) ───────────────────────────────────────────────
if ($fresh('ollama', 900)) {
$results['ollama'] = 'skipped (fresh)';
} else try {
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
$ch = curl_init($ollamaHost . '/api/tags');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
$models = json_decode($resp, true)['models'] ?? [];
$names = array_column($models, 'name');
KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null);
KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl);
KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl);
foreach ($models as $m) {
JarvisDB::execute(
'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?)
ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()',
[$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)]
);
}
$results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')';
} else {
KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl);
$results['ollama'] = 'unreachable (VM may be booting)';
}
} catch (Exception $e) {
$results['ollama'] = 'error: ' . $e->getMessage();
}
// ── Site Health (TTL 5 min) ───────────────────────────────────────────
if ($fresh('sites', 300)) {
$results['sites'] = 'skipped (fresh)';
} else try {
$sites = [
"jarvis" => "http://127.0.0.1",
'tomsjavajive' => 'https://tomsjavajive.com',
'epictravelexp'=> 'https://epictravelexpeditions.com',
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
'orbishosting' => 'https://orbishosting.com',
'orbisportal' => 'https://orbis.orbishosting.com',
'tomtomgames' => 'https://tomtomgames.com',
];
$down = [];
foreach ($sites as $key => $url) {
$parsed = parse_url($url);
$host = $parsed['host'] ?? $url;
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
// All JARVIS-hosted sites are served from this same OLS instance.
$localUrl = $url; // external check
$ch = curl_init($localUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_NOBODY => true,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
KBEngine::storeFact('sites', $key, $status, $url, 180);
if ($status !== 'up') $down[] = "$key($code)";
}
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down);
} catch (Exception $e) {
$results['sites'] = 'error: ' . $e->getMessage();
}
// Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
// which POSTs nmap results to /api/netscan every 3 minutes.
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
return $results;
}
function pve_api_get(string $url, string $authHeader): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 5,
]);
$resp = curl_exec($ch);
curl_close($ch);
return $resp ? (json_decode($resp, true) ?? []) : [];
}
// ── Entry point ───────────────────────────────────────────────────────────
$results = collect_all();
if ($isCLI) {
echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n";
foreach ($results as $k => $v) {
echo " {$k}: {$v}\n";
}
} else {
echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]);
}
+141
View File
@@ -0,0 +1,141 @@
<?php
// Home Assistant endpoint — entities served from api_cache (refreshed every 5 min by cron)
// Live service calls (turn on/off) still go direct to HA.
function haRequest(string $path, string $method = 'GET', array $payload = []): ?array {
if (HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false) {
return null;
}
$ch = curl_init(HA_URL . '/api' . $path);
$headers = [
'Authorization: Bearer ' . HA_TOKEN,
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_SSL_VERIFYPEER => false,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300 && $resp) {
return json_decode($resp, true);
}
return null;
}
$configured = !(HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false);
if (!$configured) {
echo json_encode(['configured' => false, 'message' => 'HA token not configured.', 'entities' => []]);
exit;
}
// Scene list
if ($method === 'GET' && $action === 'scenes') {
$states = haRequest('/states') ?? [];
$scenes = [];
foreach ($states as $s) {
if (str_starts_with($s['entity_id'] ?? '', 'scene.')) {
$scenes[] = [
'entity_id' => $s['entity_id'],
'name' => $s['attributes']['friendly_name'] ?? $s['entity_id'],
];
}
}
echo json_encode(['scenes' => $scenes]);
exit;
}
// Scene activate
if ($method === 'POST' && $action === 'scene_activate') {
$entity_id = $data['entity_id'] ?? '';
if ($entity_id && str_starts_with($entity_id, 'scene.')) {
$result = haRequest('/services/scene/turn_on', 'POST', ['entity_id' => $entity_id]);
echo json_encode(['success' => true, 'entity_id' => $entity_id]);
} else {
echo json_encode(['error' => 'Invalid scene entity_id']);
}
exit;
}
// Live service call (toggle device) — always direct to HA, never cached
if ($method === 'POST' && $action === 'service') {
$domain = $data['domain'] ?? '';
$service = $data['service'] ?? '';
$entity_id = $data['entity_id'] ?? '';
if ($domain && $service && $entity_id) {
$result = haRequest("/services/{$domain}/{$service}", 'POST', ['entity_id' => $entity_id]);
echo json_encode(['success' => true, 'result' => $result]);
} else {
echo json_encode(['error' => 'Missing domain/service/entity_id']);
}
exit;
}
// Serve entities from ha_entities table (real-time agent push data)
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
'device_tracker','event','image','person','zone','tts','conversation',
'assist_satellite','input_button','media_player','scene','water_heater',
'alarm_control_panel','automation','script','calendar','notify','weather','camera','siren','remote','todo','lawn_mower'];
$skipKeywords = [
// HACS / system toggles
'pre_release','get_hacs','matter_server','zerotier','mariadb',
'spotify_connect','file_editor','ssh_web','uptime_kuma','adguard_',
'folding_home','music_assistant','mealie','mosquitto','social_to',
'assist_microphone','cec_scanner','esphome_device_builder',
// Camera controls
'_record','_ftp_','_push_','_hub_ringtone','_siren_on',
'_email_on','_manual_record','_infrared_','motion_detection',
'front_yard_record','down_hill_record','camera1_record',
'back_yard_record','nvr_',
// Echo / smart display noise
'do_not_disturb',
// Konnected security panel switches
'floodlight',
'konnected',
// Energy / power monitoring (sensors, not controls)
'_energy','_power','_voltage','_current','_consumption',
'electricity_maps',
];
$rows = JarvisDB::query(
"SELECT entity_id, entity_name, domain, state, UNIX_TIMESTAMP(updated_at) as updated_ts
FROM ha_entities
WHERE state NOT IN ('unavailable','unknown')
ORDER BY domain, entity_name"
) ?? [];
$grouped = [];
$latestTs = 0;
foreach ($rows as $e) {
$dom = $e['domain'];
if (in_array($dom, $skipDomains)) continue;
$skip = false;
if ($dom === 'switch') {
foreach ($skipKeywords as $kw) {
if (strpos($e['entity_id'], $kw) !== false) { $skip = true; break; }
}
}
if ($skip) continue;
if ((int)$e['updated_ts'] > $latestTs) $latestTs = (int)$e['updated_ts'];
$grouped[$dom][] = [
'entity_id' => $e['entity_id'],
'name' => $e['entity_name'],
'state' => $e['state'],
];
}
echo json_encode([
'configured' => true,
'entities' => $grouped,
'cache_age_s' => $latestTs > 0 ? (int)(time() - $latestTs) : -1,
'cached_at' => $latestTs > 0 ? date('c', $latestTs) : null,
]);
+21
View File
@@ -0,0 +1,21 @@
<?php
// Chat history search endpoint
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
AuthMiddleware::requireAuth();
$q = trim($_GET['q'] ?? '');
if (strlen($q) < 2) {
echo json_encode(['results' => [], 'error' => 'Query too short']);
exit;
}
$rows = JarvisDB::query(
"SELECT role, content, created_at FROM conversations
WHERE content LIKE ? ORDER BY created_at DESC LIMIT 25",
['%' . $q . '%']
) ?? [];
echo json_encode(['results' => $rows, 'total' => count($rows)]);
+109
View File
@@ -0,0 +1,109 @@
<?php
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
AuthMiddleware::requireAuth();
$action = $_GET['action'] ?? 'sessions';
function jf_get(string $path): array {
$url = JELLYFIN_URL . $path . (str_contains($path, '?') ? '&' : '?') . 'api_key=' . JELLYFIN_API_KEY;
$ctx = stream_context_create(['http' => ['timeout' => 5, 'ignore_errors' => true]]);
$raw = @file_get_contents($url, false, $ctx);
return $raw ? (json_decode($raw, true) ?? []) : [];
}
switch ($action) {
case 'sessions':
$sessions = jf_get('/Sessions');
$active = array_filter($sessions, fn($s) => isset($s['NowPlayingItem']));
$out = [];
foreach ($active as $s) {
$np = $s['NowPlayingItem'];
$pos = $s['PlayState']['PositionTicks'] ?? 0;
$dur = $np['RunTimeTicks'] ?? 0;
$out[] = [
'session_id' => $s['Id'],
'user' => $s['UserName'] ?? 'Unknown',
'device' => $s['DeviceName'] ?? '',
'client' => $s['Client'] ?? '',
'title' => $np['Name'] ?? '',
'type' => $np['Type'] ?? '',
'series' => $np['SeriesName'] ?? null,
'year' => $np['ProductionYear'] ?? null,
'paused' => $s['PlayState']['IsPaused'] ?? false,
'position_pct'=> $dur > 0 ? round($pos / $dur * 100) : 0,
];
}
echo json_encode(['sessions' => array_values($out), 'total_active' => count($out)]);
break;
case 'library':
$movies = jf_get('/Items?IncludeItemTypes=Movie&Recursive=true&Limit=0');
$series = jf_get('/Items?IncludeItemTypes=Series&Recursive=true&Limit=0');
$episodes= jf_get('/Items?IncludeItemTypes=Episode&Recursive=true&Limit=0');
echo json_encode([
'movies' => $movies['TotalRecordCount'] ?? 0,
'series' => $series['TotalRecordCount'] ?? 0,
'episodes' => $episodes['TotalRecordCount'] ?? 0,
]);
break;
case 'search':
$q = trim($_GET['q'] ?? '');
if (!$q) { echo json_encode(['results' => []]); break; }
$data = jf_get('/Search/Hints?SearchTerm=' . urlencode($q) . '&Limit=8&IncludeItemTypes=Movie,Series,Episode');
$hints = $data['SearchHints'] ?? [];
$results = array_map(fn($h) => [
'id' => $h['ItemId'],
'name' => $h['Name'],
'type' => $h['Type'],
'year' => $h['ProductionYear'] ?? null,
'series'=> $h['Series'] ?? null,
], $hints);
echo json_encode(['results' => $results]);
break;
case 'recent':
$data = jf_get('/Items/Latest?Limit=8&IncludeItemTypes=Movie,Episode&Fields=Overview');
$out = array_map(fn($i) => [
'name' => $i['Name'],
'type' => $i['Type'],
'series' => $i['SeriesName'] ?? null,
'year' => $i['ProductionYear'] ?? null,
], is_array($data) ? $data : []);
echo json_encode(['recent' => $out]);
break;
case 'control':
$jfSessionId = $_GET['session_id'] ?? '';
$jfCommand = $_GET['command'] ?? '';
// General commands supported by Jellyfin session control
$allowed = ['TogglePause', 'Stop', 'NextTrack', 'PreviousTrack', 'VolumeUp', 'VolumeDown'];
if (!$jfSessionId || !in_array($jfCommand, $allowed, true)) {
// No session_id: get the first active session automatically
if (!$jfSessionId && in_array($jfCommand, $allowed, true)) {
$sessions = jf_get('/Sessions');
foreach ($sessions as $s) {
if (isset($s['NowPlayingItem'])) { $jfSessionId = $s['Id']; break; }
}
}
if (!$jfSessionId) { echo json_encode(['error' => 'No active session or invalid command']); break; }
}
$url = JELLYFIN_URL . '/Sessions/' . rawurlencode($jfSessionId) . '/Command/' . $jfCommand
. '?api_key=' . JELLYFIN_API_KEY;
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'timeout' => 5,
'header' => 'Content-Type: application/json',
'content' => '{}',
'ignore_errors' => true,
]]);
@file_get_contents($url, false, $ctx);
echo json_encode(['success' => true, 'command' => $jfCommand, 'session_id' => $jfSessionId]);
break;
default:
echo json_encode(['error' => 'Unknown action']);
}
+329
View File
@@ -0,0 +1,329 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $recentRun && $recentRun['is_recent']) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── system prompt ── */
/* RESTORED 2026-07-07: this and safe_insert() below were lost during the 2026-07-05 rotation-engine
refactor (moving from a hardcoded $BATCHES array to the kb_generator_topics table). Their absence
caused an uncaught TypeError on every run since (undefined $SYSTEM passed to groq()'s non-nullable
string param), silently swallowed by config.php's error_reporting(0) the cron looked like it was
running fine but died instantly on batch 1 every single time. Restored from kb_intent_generator.php.bak2,
with the intent count adjusted from 40 to 20 to match this version's actual per-topic request below. */
$SYSTEM = <<<'SYS'
You are an expert educator generating KB (knowledge-base) intents for an AI assistant called JARVIS.
Each intent is a question/phrase a student might ask, paired with a clear educational answer.
Respond ONLY with a valid JSON array (no markdown, no backticks, no commentary).
Each element must have exactly these keys:
"n" intent_name: unique snake_case identifier 60 chars, prefixed with the batch id given
"p" pattern: a PHP PCRE regex (use (?i) for case-insensitive) that matches the question
"r" response: a thorough but concise educational answer (25 sentences or a short structured list)
"c" category: the category string provided
Rules:
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
- Patterns should NOT start with ^ or end with $ (they are substring matches)
- Responses must be factually accurate
- Do not duplicate intent names; every "n" must be unique within this batch
- Return exactly 20 intents
SYS;
/* ── insert helper ── */
$inserted = 0;
$skipped = 0;
$errors = 0;
function safe_insert(array $intent, string $batchCategory): void {
global $inserted, $skipped, $errors;
$name = trim($intent['n'] ?? '');
$pattern = trim($intent['p'] ?? '');
$response = trim($intent['r'] ?? '');
$category = trim($intent['c'] ?? $batchCategory);
if (!$name || !$pattern || !$response) { $errors++; return; }
if (strlen($name) > 64) $name = substr($name, 0, 64);
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
if (strlen($response) < 30) { $skipped++; return; } // too short
try {
JarvisDB::execute(
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
pattern=VALUES(pattern),
response_template=VALUES(response_template),
fact_category=VALUES(fact_category)',
[$name, $pattern, $response, $category, 'response', 5, 1]
);
$inserted++;
} catch (Exception $e) {
$errors++;
}
}
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
+284
View File
@@ -0,0 +1,284 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Weekly Sunday 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000): ?string {
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err || !$raw) return null;
$d = json_decode($raw, true);
return $d['choices'][0]['message']['content'] ?? null;
}
/* ── daily guard: skip if already ran within last 20 hours ── */
$lastRun = JarvisDB::single(
"SELECT updated_at FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
if ($lastRun && (time() - strtotime($lastRun['updated_at'])) < 72000) {
log_line('Skipping ran within last 6 days (next run Sunday 3am).');
exit(0);
}
log_line('Starting weekly KB intent generation run.');
/* ── topic batches (25 topics × ~40 intents = 1,000+) ── */
$BATCHES = [
['id' => 'math_elem', 'category' => 'math_elementary', 'topic' => 'Elementary school mathematics',
'desc' => 'counting, basic arithmetic, place value, simple fractions, 2D/3D shapes, measurement, telling time, money, patterns'],
['id' => 'math_mid', 'category' => 'math_middle', 'topic' => 'Middle school mathematics',
'desc' => 'ratios, percentages, integers, exponents, pre-algebra equations, coordinate planes, probability, statistics (mean/median/mode), geometry area/volume'],
['id' => 'math_high', 'category' => 'math_high', 'topic' => 'High school mathematics',
'desc' => 'quadratic equations, polynomials, functions, logarithms, trigonometry (SOH-CAH-TOA, unit circle), sequences, permutations and combinations, matrices'],
['id' => 'math_college', 'category' => 'math_college', 'topic' => 'College-level mathematics',
'desc' => 'limits and continuity, derivatives, integrals, chain rule, L\'Hopital, differential equations, vectors, eigenvalues, hypothesis testing, normal distribution'],
['id' => 'bio_cell', 'category' => 'biology', 'topic' => 'Cell biology and genetics',
'desc' => 'organelles, mitosis vs meiosis, DNA structure, protein synthesis (transcription/translation), Mendelian genetics, dominant/recessive, mutations, genetic disorders'],
['id' => 'bio_body', 'category' => 'biology', 'topic' => 'Human body systems',
'desc' => 'skeletal, muscular, cardiovascular, respiratory, digestive, nervous, endocrine, immune, reproductive, and excretory systems'],
['id' => 'bio_ecology', 'category' => 'biology', 'topic' => 'Ecology and evolution',
'desc' => 'ecosystems, biomes, food webs, trophic levels, symbiosis (mutualism/commensalism/parasitism), natural selection, adaptation, speciation, biodiversity'],
['id' => 'chem_basics', 'category' => 'chemistry', 'topic' => 'Chemistry fundamentals',
'desc' => 'atomic structure, periodic table trends, ionic vs covalent bonds, Lewis structures, polarity, molecular geometry (VSEPR), intermolecular forces'],
['id' => 'chem_rxns', 'category' => 'chemistry', 'topic' => 'Chemical reactions and thermochemistry',
'desc' => 'balancing equations, stoichiometry, limiting reagents, reaction types, enthalpy, entropy, Gibbs free energy, Le Chatelier\'s principle, equilibrium constants'],
['id' => 'phys_mech', 'category' => 'physics', 'topic' => 'Physics mechanics and energy',
'desc' => 'kinematics, Newton\'s three laws, friction, momentum, conservation of energy, work and power, circular motion, gravitation, projectile motion'],
['id' => 'phys_em', 'category' => 'physics', 'topic' => 'Physics waves, electricity and magnetism',
'desc' => 'wave properties (amplitude, frequency, wavelength), sound, light spectrum, reflection/refraction, Ohm\'s law, series vs parallel circuits, magnetic fields, electromagnetic induction'],
['id' => 'earth_sci', 'category' => 'earth_science', 'topic' => 'Earth and environmental science',
'desc' => 'rock cycle, plate tectonics, earthquakes, volcanoes, atmosphere layers, weather vs climate, greenhouse effect, water cycle, ocean currents, soil formation'],
['id' => 'astronomy', 'category' => 'astronomy', 'topic' => 'Astronomy and space science',
'desc' => 'solar system planets, star life cycles, galaxies, Big Bang theory, light-years, telescopes, space exploration milestones, dark matter/energy, black holes'],
['id' => 'hist_us', 'category' => 'history_us', 'topic' => 'United States history',
'desc' => 'colonial era, American Revolution, Constitution, westward expansion, Civil War, Reconstruction, Gilded Age, WWI, Great Depression, WWII, Civil Rights, Cold War, modern era'],
['id' => 'hist_world', 'category' => 'history_world', 'topic' => 'World history',
'desc' => 'ancient civilisations (Egypt, Greece, Rome, Mesopotamia, China, India), Medieval period, Renaissance, Reformation, age of exploration, colonialism, Industrial Revolution, WWI, WWII, decolonisation, Cold War'],
['id' => 'geo_world', 'category' => 'geography', 'topic' => 'World geography',
'desc' => 'continents and oceans, countries and capitals, physical features (mountains, rivers, deserts), climate zones, latitude/longitude, map projections, time zones, population distribution'],
['id' => 'civics', 'category' => 'civics', 'topic' => 'Civics and US government',
'desc' => 'three branches of government, checks and balances, Bill of Rights, Constitutional amendments, federalism, Electoral College, legislative process, Supreme Court landmark cases, political parties'],
['id' => 'econ', 'category' => 'economics', 'topic' => 'Economics',
'desc' => 'supply and demand, market equilibrium, elasticity, GDP, inflation, unemployment, fiscal vs monetary policy, comparative advantage, market structures (monopoly, oligopoly, perfect competition), opportunity cost'],
['id' => 'lit_writing', 'category' => 'literature', 'topic' => 'Literature and writing',
'desc' => 'figurative language (simile, metaphor, personification, irony), plot structure, literary themes, point of view, genre types, essay structure, thesis statements, grammar rules, poetry forms, citing sources'],
['id' => 'cs_prog', 'category' => 'computer_science', 'topic' => 'Computer science and programming',
'desc' => 'variables, data types, loops, conditionals, functions, OOP concepts, arrays/lists, sorting and searching algorithms, time/space complexity, binary, hexadecimal, recursion, debugging'],
['id' => 'cs_systems', 'category' => 'computer_science', 'topic' => 'Computer systems and networking',
'desc' => 'CPU/RAM/storage, operating systems, file systems, TCP/IP, DNS, HTTP/HTTPS, databases (SQL vs NoSQL), cybersecurity threats (phishing, SQL injection, XSS, malware), encryption basics, cloud computing'],
['id' => 'psych', 'category' => 'psychology', 'topic' => 'Psychology',
'desc' => 'classical and operant conditioning, Maslow\'s hierarchy, Piaget\'s stages, Erikson\'s stages, memory types, sleep stages, cognitive biases, Freud\'s theory, social influence, abnormal psychology basics'],
['id' => 'phil', 'category' => 'philosophy', 'topic' => 'Philosophy and logic',
'desc' => 'Socrates/Plato/Aristotle, ethical theories (utilitarianism, deontology, virtue ethics), epistemology, deductive vs inductive reasoning, logical fallacies, existentialism, free will vs determinism, political philosophy'],
['id' => 'health_sci', 'category' => 'health', 'topic' => 'Health and life skills',
'desc' => 'nutrition (macronutrients, vitamins, minerals), exercise physiology, mental health (anxiety, depression, stress response), reproductive health, substance abuse, first aid, disease prevention, sleep hygiene'],
['id' => 'arts_music', 'category' => 'arts', 'topic' => 'Arts and music',
'desc' => 'elements of art (line, shape, color, texture), colour theory, major art movements, famous artists and their works, music notes and scales, rhythm and meter, instrument families, major composers and genres'],
];
/* ── system prompt ── */
$SYSTEM = <<<'SYS'
You are an expert educator generating KB (knowledge-base) intents for an AI assistant called JARVIS.
Each intent is a question/phrase a student might ask, paired with a clear educational answer.
Respond ONLY with a valid JSON array (no markdown, no backticks, no commentary).
Each element must have exactly these keys:
"n" intent_name: unique snake_case identifier ≤ 60 chars, prefixed with the batch id given
"p" pattern: a PHP PCRE regex (use (?i) for case-insensitive) that matches the question
"r" response: a thorough but concise educational answer (25 sentences or a short structured list)
"c" category: the category string provided
Rules:
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
- Patterns should NOT start with ^ or end with $ (they are substring matches)
- Responses must be factually accurate
- Do not duplicate intent names; every "n" must be unique within this batch
- Return exactly 40 intents
SYS;
/* ── insert helper ── */
$inserted = 0;
$skipped = 0;
$errors = 0;
function safe_insert(array $intent, string $batchCategory): void {
global $inserted, $skipped, $errors;
$name = trim($intent['n'] ?? '');
$pattern = trim($intent['p'] ?? '');
$response = trim($intent['r'] ?? '');
$category = trim($intent['c'] ?? $batchCategory);
if (!$name || !$pattern || !$response) { $errors++; return; }
if (strlen($name) > 64) $name = substr($name, 0, 64);
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
if (strlen($response) < 30) { $skipped++; return; } // too short
try {
JarvisDB::execute(
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
pattern=VALUES(pattern),
response_template=VALUES(response_template),
fact_category=VALUES(fact_category)',
[$name, $pattern, $response, $category, 'response', 5, 1]
);
$inserted++;
} catch (Exception $e) {
$errors++;
}
}
/* ── main generation loop ── */
$totalBatches = count($BATCHES);
foreach ($BATCHES as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 40 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 3500);
if ($raw === null) {
log_line(" ✗ API call failed skipping batch.");
$errors += 40;
sleep(3);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array (even if the model added preamble text)
if (!preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
log_line(" ✗ No JSON array found in response skipping batch.");
$errors += 40;
sleep(2);
continue;
}
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 40;
sleep(2);
continue;
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls to avoid rate limiting
if ($num < $totalBatches) sleep(2);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
// Delete all but the first (longest response)
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars) — likely garbage
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all text columns for the auto-generated ones
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Deactivate intents whose pattern is invalid PCRE
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE active=1 AND priority=5');
$badPattern = 0;
foreach ($all as $row) {
if (@preg_match($row['pattern'], '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
}
}
log_line(" Bad PCRE patterns deactivated: {$badPattern}");
// 5. Stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp in kb_facts ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
@@ -0,0 +1,270 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() — this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $recentRun && $recentRun['is_recent']) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* JARVIS Memory Core API
*/
require_once __DIR__ . '/../../api/lib/db.php';
$action = $_GET['action'] ?? '';
$data = json_decode(file_get_contents('php://input'), true) ?? [];
function memArcGet(string $path): array {
$ch = curl_init('http://127.0.0.1:7474' . $path);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
return json_decode($raw, true) ?: [];
}
function memArcPost(string $path, array $body): array {
$ch = curl_init('http://127.0.0.1:7474' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>5,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
return json_decode($raw, true) ?: [];
}
function memArcDelete(string $path): array {
$ch = curl_init('http://127.0.0.1:7474' . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_CUSTOMREQUEST=>'DELETE', CURLOPT_TIMEOUT=>5,
]);
$raw = curl_exec($ch); curl_close($ch);
return json_decode($raw, true) ?: ['ok' => true];
}
switch ($action) {
case 'list':
$limit = min((int)($_GET['limit'] ?? 100), 500);
$category = $_GET['category'] ?? '';
$search = $_GET['search'] ?? '';
$qs = http_build_query(array_filter(['limit'=>$limit,'category'=>$category,'search'=>$search]));
echo json_encode(memArcGet('/memory/facts' . ($qs ? '?'.$qs : '')));
break;
case 'stats':
echo json_encode(memArcGet('/memory/stats'));
break;
case 'context':
$msg = $_GET['message'] ?? '';
$limit = (int)($_GET['limit'] ?? 12);
$qs = http_build_query(['message'=>$msg,'limit'=>$limit]);
echo json_encode(memArcGet('/memory/context?' . $qs));
break;
case 'store':
$subject = trim($data['subject'] ?? '');
$predicate = trim($data['predicate'] ?? 'is');
$object = trim($data['object'] ?? '');
$category = $data['category'] ?? 'fact';
if (!$subject || !$object) { http_response_code(400); echo json_encode(['error'=>'subject and object required']); break; }
echo json_encode(memArcPost('/memory/facts', ['subject'=>$subject,'predicate'=>$predicate,'object'=>$object,'category'=>$category]));
break;
case 'delete':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error'=>'Missing id']); break; }
echo json_encode(memArcDelete('/memory/facts/' . $id));
break;
case 'clear':
$category = $_GET['category'] ?? '';
$qs = $category ? '?category=' . urlencode($category) : '';
echo json_encode(memArcDelete('/memory/facts' . $qs));
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown memory action: ' . $action]);
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// Agent metrics history — returns CPU/RAM samples for sparklines
require_once __DIR__ . '/../config.php';
header('Content-Type: application/json');
$agentId = $_GET['agent_id'] ?? '';
$hours = min((int)($_GET['hours'] ?? 2), 24);
if (!$agentId) {
// Return all online agents' last 2h in one shot
$agents = JarvisDB::query(
"SELECT DISTINCT agent_id FROM agent_metrics WHERE recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR)",
[$hours]
) ?? [];
$out = [];
foreach ($agents as $a) {
$rows = JarvisDB::query(
"SELECT CAST(JSON_EXTRACT(metric_data, '$.cpu_percent') AS DECIMAL(5,1)) as cpu,
CAST(JSON_EXTRACT(metric_data, '$.memory.percent') AS DECIMAL(5,1)) as mem,
recorded_at
FROM agent_metrics
WHERE agent_id = ? AND recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
ORDER BY recorded_at ASC",
[$a['agent_id'], $hours]
) ?? [];
if ($rows) {
$out[$a['agent_id']] = array_map(fn($r) => [
'cpu' => (float)($r['cpu'] ?? 0),
'mem' => (float)($r['mem'] ?? 0),
], $rows);
}
}
echo json_encode($out);
} else {
$rows = JarvisDB::query(
"SELECT CAST(JSON_EXTRACT(metric_data, '$.cpu_percent') AS DECIMAL(5,1)) as cpu,
CAST(JSON_EXTRACT(metric_data, '$.memory.percent') AS DECIMAL(5,1)) as mem,
recorded_at
FROM agent_metrics
WHERE agent_id = ? AND recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR)
ORDER BY recorded_at ASC",
[$agentId, $hours]
) ?? [];
echo json_encode(array_map(fn($r) => [
'cpu' => (float)($r['cpu'] ?? 0),
'mem' => (float)($r['mem'] ?? 0),
], $rows));
}
+84
View File
@@ -0,0 +1,84 @@
<?php
// Network scan push endpoint — called by PVE1 cron with nmap results
// Authenticates via X-Registration-Key header (same key as agent installer)
define('NETSCAN_KEY', 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518');
if ($method !== 'POST') {
echo json_encode(['error' => 'POST only']); exit;
}
$reqKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? '';
if ($reqKey !== NETSCAN_KEY) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']); exit;
}
$body = file_get_contents('php://input');
$payload = json_decode($body, true);
$devices = $payload['devices'] ?? [];
if (empty($devices)) {
echo json_encode(['error' => 'No devices in payload']); exit;
}
$discoveredIPs = [];
$upserted = 0;
foreach ($devices as $d) {
$ip = trim($d['ip'] ?? '');
$mac = trim($d['mac'] ?? '');
$hostname = trim($d['hostname'] ?? '');
$vendor = trim($d['vendor'] ?? '');
// Respect explicit status from probe (e.g. phone probe knows if device is offline)
// Fall back to "online" for nmap results which only report reachable hosts
$status = in_array($d['status'] ?? '', ['online','offline']) ? $d['status'] : 'online';
if (!$ip) continue;
$discoveredIPs[] = $ip;
JarvisDB::execute(
'INSERT INTO network_devices (ip, mac, hostname, status, last_seen)
VALUES (?,?,?,?,NOW())
ON DUPLICATE KEY UPDATE
mac = COALESCE(NULLIF(VALUES(mac),""), mac),
hostname = COALESCE(NULLIF(VALUES(hostname),""), hostname),
status = VALUES(status),
last_seen = NOW()',
[$ip, $mac ?: null, $hostname ?: $vendor ?: null, $status]
);
if ($vendor) {
JarvisDB::execute(
'UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")',
[$vendor, $ip]
);
}
// Store SIP registration status in kb_facts if provided (VoIP probe)
$sipStatus = trim($d['sip_status'] ?? '');
$extension = trim($d['extension'] ?? '');
if ($sipStatus && $extension && $extension !== 'none') {
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('voip', ?, ?, ?)
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
["ext_{$extension}_sip", $sipStatus, $ip]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('voip', ?, ?, ?)
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
["ext_{$extension}_ip", $ip, $ip]
);
}
$upserted++;
}
// Mark anything NOT in this scan as offline if stale > 10 min
if (!empty($discoveredIPs)) {
$ph = implode(',', array_fill(0, count($discoveredIPs), '?'));
JarvisDB::execute(
"UPDATE network_devices SET status='offline'
WHERE ip NOT IN ($ph) AND last_seen < DATE_SUB(NOW(), INTERVAL 10 MINUTE)",
$discoveredIPs
);
}
echo json_encode(['ok' => true, 'upserted' => $upserted, 'total_discovered' => count($discoveredIPs)]);
+188
View File
@@ -0,0 +1,188 @@
<?php
// Network monitoring endpoint
function pingHost(string $ip): array {
$cmd = 'ping -c 1 -W 1 ' . escapeshellarg($ip) . ' 2>/dev/null';
$out = shell_exec($cmd);
$alive = $out && strpos($out, '1 received') !== false;
$latency = null;
if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) {
$latency = (float)$m[1];
}
return ['alive' => $alive, 'latency_ms' => $latency];
}
function scanSubnet(string $prefix, int $timeout = 10): array {
$cmd = 'nmap -sn --host-timeout 1s ' . escapeshellarg($prefix . '.0/24') .
' -oG - 2>/dev/null | grep "Up$" | awk \'{print $2}\'';
$out = shell_exec($cmd) ?? '';
$hosts = array_filter(explode("\n", trim($out)));
return array_values($hosts);
}
function getArpTable(): array {
$out = shell_exec('arp -n 2>/dev/null') ?? '';
$devices = [];
foreach (explode("\n", trim($out)) as $line) {
if (preg_match('/^([\d.]+)\s+\w+\s+([\w:]+)/', $line, $m)) {
$devices[$m[1]] = strtolower($m[2]);
}
}
return $devices;
}
$action = $action ?? 'status';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($action === 'scan') {
// JARVIS runs on DigitalOcean — cannot reach 10.48.200.x directly.
// Scan is delegated to the PVE1 agent (jarvis-netscan.sh runs nmap locally).
// This endpoint: queues the scan command to PVE1, returns current DB state immediately.
// Queue netscan to PVE1 agent
$pve1 = JarvisDB::single(
"SELECT agent_id FROM registered_agents WHERE ip_address='10.48.200.90' AND status='online' LIMIT 1"
);
$queued = false;
if ($pve1) {
JarvisDB::execute(
"INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)",
[$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending']
);
$queued = true;
}
// Return current online devices from DB (populated by PVE1 netscan every 3 min)
$devices = JarvisDB::query(
"SELECT ip, mac, hostname, alias, device_type as type, status, last_seen
FROM network_devices WHERE status='online' AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)
ORDER BY COALESCE(alias,hostname,ip)"
);
echo json_encode([
'devices' => $devices,
'count' => count($devices),
'queued' => $queued,
'scanned_at' => date('c'),
'note' => $queued
? 'Scan dispatched to PVE1 — results update in ~40 seconds.'
: 'Returning cached scan data. PVE1 auto-scans every 3 minutes.',
]);
} elseif ($action === 'add' && $method === 'POST') {
$ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP);
$alias = substr(trim($data['alias'] ?? ''), 0, 100);
$type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($data['type'] ?? 'device'));
if (!$ip) { echo json_encode(['error' => 'Invalid IP address']); exit; }
if (!$alias) { echo json_encode(['error' => 'Name is required']); exit; }
JarvisDB::execute(
'INSERT INTO network_devices (ip, alias, device_type, status) VALUES (?,?,?,\'unknown\')
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)',
[$ip, $alias, $type]
);
echo json_encode(['success' => true]);
} elseif ($action === 'delete' && $method === 'POST') {
$ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP);
if (!$ip) { echo json_encode(['error' => 'Invalid IP']); exit; }
// Don't allow deleting agent-managed entries
$isAgent = JarvisDB::query('SELECT id FROM registered_agents WHERE ip_address=? LIMIT 1', [$ip]);
if (!empty($isAgent)) { echo json_encode(['error' => 'Cannot delete agent-managed device']); exit; }
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$ip]);
echo json_encode(['success' => true]);
} else {
// Status: unified device list from agents + user-managed DB entries + external services
$devices = [];
// Mark agents offline if not heard from in 2 minutes
JarvisDB::execute(
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
);
// 1. Agent-based devices — status from heartbeat, no ping from DO needed
$agents = JarvisDB::query(
'SELECT agent_id, hostname, ip_address, status, last_seen, agent_type FROM registered_agents ORDER BY hostname'
);
$agentIPs = [];
foreach ($agents as $ag) {
$agentIPs[] = $ag['ip_address'];
$devices[] = [
'ip' => $ag['ip_address'],
'name' => $ag['hostname'],
'type' => 'agent',
'agent_id' => $ag['agent_id'],
'agent_type' => $ag['agent_type'],
'alive' => $ag['status'] === 'online',
'status' => $ag['status'],
'last_seen' => $ag['last_seen'],
'source' => 'agent',
'deletable' => false,
];
}
// 2. User-managed devices from DB (named/aliased entries not covered by agents)
$pinned = JarvisDB::query(
'SELECT ip, alias, device_type, status, last_seen FROM network_devices
WHERE alias IS NOT NULL AND alias != "" ORDER BY alias'
);
foreach ($pinned as $dev) {
if (in_array($dev['ip'], $agentIPs)) continue; // agent already covers this IP
$ping = pingHost($dev['ip']);
$newStatus = $ping['alive'] ? 'online' : 'offline';
JarvisDB::execute(
'UPDATE network_devices SET status=?, last_seen=NOW() WHERE ip=?',
[$newStatus, $dev['ip']]
);
$devices[] = [
'ip' => $dev['ip'],
'name' => $dev['alias'],
'type' => $dev['device_type'] ?: 'device',
'alive' => $ping['alive'],
'latency_ms' => $ping['latency_ms'],
'status' => $newStatus,
'last_seen' => $dev['last_seen'],
'source' => 'db',
'deletable' => true,
];
}
// 3. Netscan-discovered devices (PVE1 nmap push — status from last scan)
$discovered = JarvisDB::query(
'SELECT ip, mac, hostname, device_type, status, last_seen FROM network_devices
WHERE (alias IS NULL OR alias = "") AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)
ORDER BY ip'
);
foreach ($discovered as $dev) {
if (in_array($dev['ip'], $agentIPs)) continue;
$devices[] = [
'ip' => $dev['ip'],
'name' => $dev['hostname'] ?: ($dev['device_type'] ?: $dev['ip']),
'mac' => $dev['mac'],
'type' => $dev['device_type'] ?: 'device',
'alive' => $dev['status'] === 'online',
'status' => $dev['status'],
'last_seen' => $dev['last_seen'],
'source' => 'netscan',
'deletable' => false,
];
}
// 4. External services we can actually ping from DO
$external = [
['ip' => '134.209.72.226', 'name' => 'FusionPBX DO', 'type' => 'server'],
];
foreach ($external as $host) {
if (in_array($host['ip'], $agentIPs)) continue;
$ping = pingHost($host['ip']);
$devices[] = array_merge($host, [
'alive' => $ping['alive'],
'latency_ms' => $ping['latency_ms'],
'status' => $ping['alive'] ? 'online' : 'offline',
'source' => 'static',
'deletable' => false,
]);
}
echo json_encode(['devices' => $devices, 'timestamp' => date('c')]);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// News endpoint — serves from api_cache + custom pinned news from admin kb_facts
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
['news']
);
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
} else {
$out = [
'categories' => [],
'total' => 0,
'cache_age_s' => -1,
'message' => 'News feed warming up — available within 5 minutes.',
];
}
// Prepend custom/pinned news items added via admin portal
$custom = JarvisDB::query(
"SELECT fact_key as title, fact_value as url, updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC"
);
if (!empty($custom)) {
$pinned = array_map(fn($r) => [
'title' => $r['title'],
'url' => $r['url'] ?: null,
'source' => 'JARVIS',
'published' => $r['updated_at'],
'pinned' => true,
], $custom);
// Insert pinned as first category
$out['categories'] = array_merge(['pinned' => $pinned], $out['categories'] ?? []);
}
echo json_encode($out);
+159
View File
@@ -0,0 +1,159 @@
<?php
// JARVIS Planner — tasks, appointments, daily briefing
// ── Helpers ──────────────────────────────────────────────────────────────────
function parseNaturalDate(string $text): ?string {
$text = trim($text);
if (!$text || strtolower($text) === 'none') return null;
$ts = strtotime($text);
return ($ts !== false && $ts > 0) ? date('Y-m-d', $ts) : null;
}
function parseNaturalDatetime(string $text): ?string {
$text = trim($text);
if (!$text) return null;
$ts = strtotime($text);
return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : null;
}
// ── Route ─────────────────────────────────────────────────────────────────────
// $action from api.php: tasks | appointments | today | done | summary
switch ($action) {
// ── Tasks ─────────────────────────────────────────────────────────────────
case 'tasks':
if ($method === 'GET') {
$status = $_GET['status'] ?? '';
$category = $_GET['category'] ?? '';
$where = '1=1';
$params = [];
if ($status) { $where .= ' AND status=?'; $params[] = $status; }
if ($category) { $where .= ' AND category=?'; $params[] = $category; }
else { $where .= " AND status NOT IN ('done','cancelled')"; }
$rows = JarvisDB::query(
"SELECT * FROM tasks WHERE {$where} ORDER BY
FIELD(priority,'urgent','high','normal','low'), due_date ASC, created_at DESC",
$params
) ?? [];
echo json_encode(['tasks' => $rows]);
} elseif ($method === 'POST') {
$id = (int)($data['id'] ?? 0);
$title = trim($data['title'] ?? '');
$notes = trim($data['notes'] ?? '');
$category = $data['category'] ?? 'personal';
$priority = $data['priority'] ?? 'normal';
$status = $data['status'] ?? 'pending';
$due_date = parseNaturalDate($data['due_date'] ?? '');
$due_time = !empty($data['due_time']) ? $data['due_time'] : null;
if (!$title) { echo json_encode(['error' => 'Title required']); exit; }
if ($id) {
JarvisDB::execute(
'UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?',
[$title,$notes,$category,$priority,$status,$due_date,$due_time,$id]
);
echo json_encode(['success' => true, 'id' => $id]);
} else {
$newId = JarvisDB::insert(
'INSERT INTO tasks (title,notes,category,priority,due_date,due_time) VALUES (?,?,?,?,?,?)',
[$title,$notes,$category,$priority,$due_date,$due_time]
);
echo json_encode(['success' => true, 'id' => $newId]);
}
} elseif ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if ($id) { JarvisDB::execute('DELETE FROM tasks WHERE id=?', [$id]); }
echo json_encode(['success' => true]);
}
break;
// ── Mark task done ────────────────────────────────────────────────────────
case 'done':
$id = (int)($data['id'] ?? $_GET['id'] ?? 0);
if ($id) {
JarvisDB::execute(
"UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$id]
);
}
echo json_encode(['success' => true]);
break;
// ── Appointments ──────────────────────────────────────────────────────────
case 'appointments':
if ($method === 'GET') {
$from = $_GET['from'] ?? date('Y-m-d');
$to = $_GET['to'] ?? date('Y-m-d', strtotime('+90 days'));
$rows = JarvisDB::query(
"SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC",
[$from, $to]
) ?? [];
echo json_encode(['appointments' => $rows]);
} elseif ($method === 'POST') {
$id = (int)($data['id'] ?? 0);
$title = trim($data['title'] ?? '');
$description = trim($data['description'] ?? '');
$category = $data['category'] ?? 'personal';
$location = trim($data['location'] ?? '');
$all_day = (int)($data['all_day'] ?? 0);
$reminder = (int)($data['reminder_min'] ?? 30);
$start_raw = trim($data['start_at'] ?? '');
$end_raw = trim($data['end_at'] ?? '');
if (!$title || !$start_raw) { echo json_encode(['error' => 'Title and start time required']); exit; }
$start_at = parseNaturalDatetime($start_raw) ?? date('Y-m-d H:i:s', strtotime($start_raw));
$end_at = $end_raw ? (parseNaturalDatetime($end_raw) ?? null) : null;
if ($id) {
JarvisDB::execute(
'UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?',
[$title,$description,$category,$start_at,$end_at,$location,$all_day,$reminder,$id]
);
echo json_encode(['success' => true, 'id' => $id]);
} else {
$newId = JarvisDB::insert(
'INSERT INTO appointments (title,description,category,start_at,end_at,location,all_day,reminder_min) VALUES (?,?,?,?,?,?,?,?)',
[$title,$description,$category,$start_at,$end_at,$location,$all_day,$reminder]
);
echo json_encode(['success' => true, 'id' => $newId]);
}
} elseif ($method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if ($id) { JarvisDB::execute('DELETE FROM appointments WHERE id=?', [$id]); }
echo json_encode(['success' => true]);
}
break;
// ── Today / briefing summary ──────────────────────────────────────────────
case 'today':
default:
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$tasks_today = JarvisDB::query(
"SELECT * FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')",
[$today]
) ?? [];
$tasks_overdue = JarvisDB::query(
"SELECT * FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC",
[$today]
) ?? [];
$tasks_pending = JarvisDB::query(
"SELECT COUNT(*) cnt FROM tasks WHERE status='pending' AND (due_date IS NULL OR due_date >= ?)",
[$today]
);
$appts_today = JarvisDB::query(
"SELECT * FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC",
[$today]
) ?? [];
$appts_upcoming = JarvisDB::query(
"SELECT * FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 5",
[]
) ?? [];
echo json_encode([
'date' => $today,
'tasks_today' => $tasks_today,
'tasks_overdue' => $tasks_overdue,
'pending_count' => (int)($tasks_pending[0]['cnt'] ?? 0),
'appts_today' => $appts_today,
'appts_upcoming' => $appts_upcoming,
]);
break;
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// Proxmox API endpoint — serves from api_cache, refreshed every 5 min by cron
$isConfigured = !(PROXMOX_HOST === '10.48.200.X' || PROXMOX_TOKEN_VAL === 'YOUR_TOKEN_VALUE_HERE');
if (!$isConfigured) {
echo json_encode([
'configured' => false,
'message' => 'Proxmox API token not yet configured.',
'vms' => [], 'nodes' => [],
]);
exit;
}
// Serve from cache (refreshed by stats_cache.php cron every 5 min)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1',
['proxmox']
);
if ($cached && !empty($cached[0]['data'])) {
$row = $cached[0];
$data = json_decode($row['data'], true);
// Add cache age to response
$data['cache_age_s'] = (int)(time() - (int)$row['updated_ts']);
echo json_encode($data);
} else {
// Cache empty — return placeholder so UI shows something useful
echo json_encode([
'configured' => true,
'node' => PROXMOX_NODE,
'node_status' => null,
'vms' => [],
'containers' => [],
'vm_count' => 0,
'ct_count' => 0,
'cached_at' => null,
'cache_age_s' => -1,
'message' => 'Cache warming up — first update in under 5 minutes.',
]);
}
+176
View File
@@ -0,0 +1,176 @@
<?php
/**
* JARVIS Sites Manager
* Primary storage: site_settings table in jarvis_db (always accessible)
* Secondary sync: TJJ own DB settings table (kept in sync on save)
* File push: optional, best-effort write to config files where accessible
*/
$SITES = [
'tomsjavajive' => [
'name' => "Tom's Java Jive",
'url' => 'https://tomsjavajive.com',
// Also sync to TJJ's own settings table
'sync_db' => ['host'=>'localhost','name'=>'toms_tjj_db','user'=>'toms_tjj_user','pass'=>'+60wlPc+55e@gFq4'],
'sync_keys' => ['api_key'=>'cybermail_api_key','from_email'=>'cybermail_from_email','from_name'=>'cybermail_from_name','admin_email'=>'smtp_admin_email'],
],
'tomtomgames' => [
'name' => 'TomTomGames',
'url' => 'https://tomtomgames.com',
// Also try to push to file
'file' => '/home/tomtomgames.com/includes/config.php',
'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'SMTP_FROM','from_name'=>'SMTP_FROM_NAME','admin_email'=>'ADMIN_EMAIL'],
],
'epictravelexpeditions' => [
'name' => 'Epic Travel Expeditions',
'url' => 'https://epictravelexpeditions.com',
'file' => '/home/epictravelexpeditions.com/public_html/api/config.php',
'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'MAIL_FROM','from_name'=>'MAIL_FROM_NAME','admin_email'=>'ADMIN_EMAIL'],
],
'parkerslingshot' => [
'name' => 'Parker Slingshot',
'url' => 'https://parkerslingshot.epictravelexpeditions.com',
'file' => '/home/epictravelexpeditions.com/parkerslingshot/db.php',
'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'MAIL_FROM','from_name'=>'MAIL_FROM_NAME','admin_email'=>'ADMIN_EMAIL'],
],
'parkerslingshotrentals' => [
'name' => 'Parker Slingshot Rentals',
'url' => 'https://parkerslingshotrentals.com',
'file' => '/home/parkerslingshotrentals.com/public_html/db.php',
'file_keys' => ['api_key'=>'CYBERMAIL_API_KEY','from_email'=>'MAIL_FROM','from_name'=>'MAIL_FROM_NAME','admin_email'=>'ADMIN_EMAIL'],
],
];
// ── Primary storage: jarvis_db site_settings table ──────────────────
function ssGet(string $siteId, string $field): string {
$row = JarvisDB::single(
'SELECT setting_value FROM site_settings WHERE site_id=? AND setting_key=?',
[$siteId, $field]
);
return $row['setting_value'] ?? '';
}
function ssSet(string $siteId, string $field, string $value): bool {
JarvisDB::execute(
'INSERT INTO site_settings (site_id, setting_key, setting_value) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value), updated_at=NOW()',
[$siteId, $field, $value]
);
return true;
}
// ── Secondary sync: TJJ own DB ───────────────────────────────────────
function tjjSync(array $syncDb, array $syncKeys, string $field, string $value): void {
$key = $syncKeys[$field] ?? null;
if (!$key) return;
try {
$pdo = new PDO("mysql:host={$syncDb['host']};dbname={$syncDb['name']};charset=utf8mb4",
$syncDb['user'], $syncDb['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$exists = $pdo->prepare('SELECT id FROM settings WHERE setting_key=?');
$exists->execute([$key]);
if ($exists->fetch()) {
$pdo->prepare('UPDATE settings SET setting_value=? WHERE setting_key=?')
->execute([json_encode($value), $key]);
} else {
$pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)')
->execute([$key, json_encode($value)]);
}
} catch (Exception $e) { /* best-effort */ }
}
// ── File push: best-effort write to config file ──────────────────────
function filePush(string $file, string $constant, string $value): void {
if (!file_exists($file) || !is_writable($file)) return;
$content = file_get_contents($file);
$safe = str_replace("'", "\\'", $value);
$new = preg_replace(
"/define\s*\(\s*['\"]" . preg_quote($constant, '/') . "['\"],\s*['\"][^'\"]*['\"](\s*)\)/",
"define('" . $constant . "', '" . $safe . "'$1)",
$content
);
if ($new && $new !== $content) {
file_put_contents($file, $new);
}
}
// ── Initial population: seed jarvis_db from TJJ DB on first access ──
function seedFromTjjDb(string $siteId, array $syncDb, array $syncKeys): void {
foreach ($syncKeys as $field => $key) {
if (ssGet($siteId, $field) !== '') continue; // already have it
try {
$pdo = new PDO("mysql:host={$syncDb['host']};dbname={$syncDb['name']};charset=utf8mb4",
$syncDb['user'], $syncDb['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$s = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key=?');
$s->execute([$key]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if ($row) {
$v = json_decode($row['setting_value'], true) ?? $row['setting_value'];
ssSet($siteId, $field, $v);
}
} catch (Exception $e) { /* best-effort */ }
}
}
// ── Router ───────────────────────────────────────────────────────────
if ($method === 'GET') {
// Seed TJJ from its own DB if not yet in jarvis_db
if (isset($SITES['tomsjavajive']['sync_db'])) {
seedFromTjjDb('tomsjavajive', $SITES['tomsjavajive']['sync_db'], $SITES['tomsjavajive']['sync_keys']);
}
$result = [];
foreach ($SITES as $id => $site) {
$result[$id] = [
'name' => $site['name'],
'url' => $site['url'],
'api_key' => ssGet($id, 'api_key'),
'from_email' => ssGet($id, 'from_email'),
'from_name' => ssGet($id, 'from_name'),
'admin_email' => ssGet($id, 'admin_email'),
];
}
echo json_encode(['success' => true, 'sites' => $result]);
exit;
}
if ($method === 'POST') {
$action = $data['action'] ?? '';
$siteId = $data['site'] ?? '';
if ($action === 'push_key') {
$apiKey = trim($data['api_key'] ?? '');
if (!$apiKey) { echo json_encode(['success'=>false,'error'=>'API key required']); exit; }
$results = [];
foreach ($SITES as $id => $site) {
ssSet($id, 'api_key', $apiKey);
if (isset($site['sync_db'])) tjjSync($site['sync_db'], $site['sync_keys'], 'api_key', $apiKey);
if (isset($site['file'])) filePush($site['file'], $site['file_keys']['api_key'] ?? '', $apiKey);
$results[$id] = true;
}
echo json_encode(['success'=>true,'results'=>$results]);
exit;
}
if ($action === 'save' && $siteId && isset($SITES[$siteId])) {
$site = $SITES[$siteId];
$fields = ['api_key','from_email','from_name','admin_email'];
foreach ($fields as $field) {
if (!isset($data[$field])) continue;
$value = trim($data[$field]);
// 1. Always save to jarvis_db
ssSet($siteId, $field, $value);
// 2. Sync to TJJ own DB if applicable
if (isset($site['sync_db'])) tjjSync($site['sync_db'], $site['sync_keys'], $field, $value);
// 3. Push to file if accessible
if (isset($site['file']) && isset($site['file_keys'][$field]))
filePush($site['file'], $site['file_keys'][$field], $value);
}
echo json_encode(['success'=>true]);
exit;
}
echo json_encode(['success'=>false,'error'=>'Unknown action']);
exit;
}
echo json_encode(['success'=>false,'error'=>'Method not allowed']);
+347
View File
@@ -0,0 +1,347 @@
<?php
/**
* JARVIS Stats Cache Collector
* Runs every 5 min via cron. Fetches Proxmox + HA data and stores in api_cache.
* Keeps live API calls out of the request path.
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../api/lib/db.php';
function curlGet(string $url, array $headers, int $timeout = 10): ?string {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$out = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err) { echo "[cache] curl error: $err\n"; return null; }
return ($code >= 200 && $code < 300) ? $out : null;
}
function cacheStore(string $key, $data): void {
$json = is_string($data) ? $data : json_encode($data);
JarvisDB::execute(
'INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW())
ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()',
[$key, $json]
);
}
// ── Proxmox ──────────────────────────────────────────────────────────────
if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') {
$pveBase = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
$pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL];
// Cluster resources API — returns all VMs/CTs from ALL nodes (pve + pve2)
$clusterRaw = curlGet("$pveBase/cluster/resources?type=vm", $pveAuth);
$nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth);
$nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null;
$allResources = $clusterRaw ? (json_decode($clusterRaw, true)['data'] ?? []) : [];
function fmtUptime(int $sec): string {
$d = intdiv($sec, 86400); $h = intdiv($sec % 86400, 3600); $m = intdiv($sec % 3600, 60);
return ($d > 0 ? "{$d}d " : '') . "{$h}h {$m}m";
}
function fmtBytes(int $b): string {
if ($b >= 1073741824) return round($b/1073741824, 1) . ' GB';
if ($b >= 1048576) return round($b/1048576, 1) . ' MB';
return $b . ' B';
}
$vmDetails = []; $lxcDetails = [];
foreach ($allResources as $r) {
$memPct = ($r['maxmem'] ?? 0) > 0 ? round($r['mem'] / $r['maxmem'] * 100, 1) : 0;
$diskGb = round(($r['maxdisk'] ?? 0) / 1073741824, 1);
$cpuPct = round(($r['cpu'] ?? 0) * 100, 1);
$upSec = (int)($r['uptime'] ?? 0);
$entry = [
'vmid' => $r['vmid'],
'name' => $r['name'] ?? ($r['type'] === 'lxc' ? 'CT-' : 'VM-') . $r['vmid'],
'node' => $r['node'] ?? 'pve',
'type' => $r['type'] ?? 'qemu',
'status' => $r['status'] ?? 'unknown',
'cpu_pct' => $cpuPct,
'cpus' => $r['maxcpu'] ?? 1,
'mem_pct' => $memPct,
'mem_used_mb' => round(($r['mem'] ?? 0) / 1048576),
'mem_total_mb' => round(($r['maxmem'] ?? 0) / 1048576),
'disk_gb' => $diskGb,
'uptime_s' => $upSec,
'uptime_human' => $upSec > 0 ? fmtUptime($upSec) : '—',
'netin' => $r['netin'] ?? 0,
'netout' => $r['netout'] ?? 0,
'netin_fmt' => fmtBytes((int)($r['netin'] ?? 0)),
'netout_fmt' => fmtBytes((int)($r['netout'] ?? 0)),
];
if ($r['type'] === 'lxc') $lxcDetails[] = $entry;
else $vmDetails[] = $entry;
}
// Sort by node then vmid
usort($vmDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']);
usort($lxcDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']);
// Node summary for both nodes
$nodeInfo = [];
if ($nodeStatus) {
$ns = $nodeStatus;
$nodeInfo['pve'] = [
'cpu_pct' => round(($ns['cpu'] ?? 0) * 100, 1),
'mem_pct' => ($ns['memory']['total'] ?? 0) > 0
? round($ns['memory']['used'] / $ns['memory']['total'] * 100, 1) : 0,
'mem_used_gb' => round(($ns['memory']['used'] ?? 0) / 1073741824, 1),
'mem_total_gb' => round(($ns['memory']['total'] ?? 0) / 1073741824, 1),
'uptime' => fmtUptime((int)($ns['uptime'] ?? 0)),
'disk_used_gb' => round(($ns['rootfs']['used'] ?? 0) / 1073741824, 1),
'disk_total_gb'=> round(($ns['rootfs']['total'] ?? 0) / 1073741824, 1),
];
}
cacheStore('proxmox', [
'configured' => true,
'node' => PROXMOX_NODE,
'node_status' => $nodeStatus,
'node_info' => $nodeInfo,
'vms' => $vmDetails,
'containers' => $lxcDetails,
'vm_count' => count($vmDetails),
'ct_count' => count($lxcDetails),
'cached_at' => date('c'),
]);
echo '[cache] Proxmox: ' . count($vmDetails) . ' VMs, ' . count($lxcDetails) . " CTs cached (both nodes)\n";
}
// ── Home Assistant ────────────────────────────────────────────────────────
if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false) {
$haHeaders = [
'Authorization: Bearer ' . HA_TOKEN,
'Content-Type: application/json',
];
$statesRaw = curlGet(HA_URL . '/api/states', $haHeaders, 15);
$configRaw = curlGet(HA_URL . '/api/config', $haHeaders, 8);
$states = $statesRaw ? json_decode($statesRaw, true) : [];
$config = $configRaw ? json_decode($configRaw, true) : [];
// Controllable domains only — skip read-only sensors to keep list manageable
$interesting = ['light','switch','alarm_control_panel',
'lawn_mower','fan','lock','cover','climate','input_boolean'];
// Switches that are HA internals / camera settings, not physical devices
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
'_siren_on','_email_on','_manual_record','_infrared_',
'do_not_disturb','matter_server','zerotier','mariadb',
'spotify_connect','file_editor','ssh_web','uptime_kuma',
'folding_home','music_assistant','get_hacs','mealie',
'mosquitto','social_to','esphome_device','motion_detection',
'front_yard_record','down_hill_record','camera1_record',
'back_yard_record','nvr_','assist_microphone','cec_scanner',
'kiosker','hacs_pre','adguard'];
$grouped = [];
foreach (($states ?? []) as $entity) {
$domain = explode('.', $entity['entity_id'])[0];
if (!in_array($domain, $interesting)) continue;
if ($domain === 'switch') {
$skip = false;
foreach ($skipKeywords as $kw) {
if (strpos($entity['entity_id'], $kw) !== false) { $skip = true; break; }
}
if ($skip) continue;
}
if (!isset($grouped[$domain])) $grouped[$domain] = [];
$grouped[$domain][] = [
'entity_id' => $entity['entity_id'],
'name' => $entity['attributes']['friendly_name'] ?? $entity['entity_id'],
'state' => $entity['state'],
'last_changed' => $entity['last_changed'] ?? null,
];
}
cacheStore('ha_entities', [
'configured' => true,
'ha_version' => $config['version'] ?? 'unknown',
'location' => $config['location_name'] ?? 'Home',
'entity_count' => count($states ?? []),
'entities' => $grouped,
'cached_at' => date('c'),
]);
$total = array_sum(array_map('count', $grouped));
echo "[cache] HA: $total entities across " . count($grouped) . " domains cached\n";
}
// ── Weather (wttr.in — refresh every 30 min) ──────────────────────────────
$weatherRow = JarvisDB::query(
"SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='weather' LIMIT 1"
);
$weatherAge = $weatherRow ? (time() - (int)$weatherRow[0]['ts']) : PHP_INT_MAX;
if ($weatherAge > 1800) {
// wttr.in code → icon mapping
$wttrIcon = function(int $code): string {
if ($code === 113) return 'SUNNY';
if ($code === 116) return 'PARTLY CLOUDY';
if (in_array($code, [119,122])) return 'CLOUDY';
if (in_array($code, [143,248,260])) return 'FOGGY';
if (in_array($code, [176,263,266,293,296])) return 'LIGHT RAIN';
if (in_array($code, [299,302,305,308,353,356,359])) return 'RAIN';
if (in_array($code, [317,320,362,365])) return 'SLEET';
if (in_array($code, [323,326,329,332,335,338,368,371])) return 'SNOW';
if (in_array($code, [386,389,392,395,200])) return 'STORMS';
if (in_array($code, [281,284,311,314])) return 'FREEZING RAIN';
return 'MIXED';
};
$wttrEmoji = function(int $code): string {
if ($code === 113) return 'Sunny';
if ($code === 116) return 'Partly Cloudy';
if (in_array($code, [119,122])) return 'Cloudy';
if (in_array($code, [143,248,260])) return 'Foggy';
if (in_array($code, [176,263,266,293,296])) return 'Light Rain';
if (in_array($code, [299,302,305,308,353,356,359])) return 'Rain';
if (in_array($code, [317,320,362,365])) return 'Sleet';
if (in_array($code, [323,326,329,332,335,338,368,371])) return 'Snow';
if (in_array($code, [386,389,392,395,200])) return 'Thunderstorm';
return 'Mixed';
};
$weatherRaw = curlGet(
'https://wttr.in/FortWorth,TX?format=j1',
['User-Agent: curl/7.88 Jarvis/1.0'],
15
);
if ($weatherRaw) {
$w = json_decode($weatherRaw, true);
$cu = $w['current_condition'][0] ?? [];
$days = $w['weather'] ?? [];
$curCode = (int)($cu['weatherCode'] ?? 113);
$forecast = [];
foreach (array_slice($days, 0, 4) as $day) {
// max rain chance across 8 hourly slots
$rainPct = 0;
foreach ($day['hourly'] ?? [] as $h) {
$rainPct = max($rainPct, (int)($h['chanceofrain'] ?? 0));
}
$dayCode = (int)($day['hourly'][4]['weatherCode'] ?? 113);
$forecast[] = [
'date' => $day['date'] ?? '',
'day' => date('D', strtotime($day['date'] ?? 'now')),
'high' => (int)($day['maxtempF'] ?? 0),
'low' => (int)($day['mintempF'] ?? 0),
'rain_pct' => $rainPct,
'desc' => $wttrEmoji($dayCode),
'icon' => $wttrIcon($dayCode),
];
}
cacheStore('weather', [
'source' => 'wttr.in',
'location' => 'Fort Worth, TX',
'current' => [
'temp' => (int)($cu['temp_F'] ?? 0),
'feels' => (int)($cu['FeelsLikeF'] ?? 0),
'humidity' => (int)($cu['humidity'] ?? 0),
'wind' => (int)($cu['windspeedMiles'] ?? 0),
'desc' => $wttrEmoji($curCode),
'icon' => $wttrIcon($curCode),
'cloud' => (int)($cu['cloudcover'] ?? 0),
'vis' => (int)($cu['visibility'] ?? 0),
],
'forecast' => $forecast,
'cached_at' => date('c'),
]);
echo '[cache] Weather: ' . ($cu['temp_F'] ?? '?') . "°F, " . $wttrEmoji($curCode) . " (wttr.in) cached\n";
} else {
echo "[cache] Weather: wttr.in fetch failed\n";
}
} else {
echo '[cache] Weather: fresh (' . round($weatherAge/60) . " min old)\n";
}
// ── News (RSS feeds — refresh every 30 min) ───────────────────────────────
$newsRow = JarvisDB::query(
"SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='news' LIMIT 1"
);
$newsAge = $newsRow ? (time() - (int)$newsRow[0]['ts']) : PHP_INT_MAX;
if ($newsAge > 1800) {
$feeds = [
'headlines' => [
'https://feeds.bbci.co.uk/news/rss.xml',
'https://feeds.npr.org/1001/rss.xml',
'https://feeds.abcnews.com/abcnews/topstories',
],
'technology' => [
'http://feeds.arstechnica.com/arstechnica/index',
'https://www.theverge.com/rss/index.xml',
],
];
function parseRss(string $xml, int $max = 5): array {
$items = [];
if (!$xml) return $items;
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadXML($xml);
$nodes = $dom->getElementsByTagName('item');
$count = 0;
foreach ($nodes as $node) {
if ($count >= $max) break;
$title = $node->getElementsByTagName('title')->item(0)?->textContent ?? '';
$link = $node->getElementsByTagName('link')->item(0)?->textContent ?? '';
$pub = $node->getElementsByTagName('pubDate')->item(0)?->textContent ?? '';
$title = html_entity_decode(trim($title), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($title && strlen($title) > 5) {
$items[] = [
'title' => $title,
'link' => trim($link),
'pub' => $pub ? date('M j g:ia', strtotime($pub)) : '',
'source' => '',
];
$count++;
}
}
return $items;
}
$allNews = [];
foreach ($feeds as $category => $urls) {
$allNews[$category] = [];
foreach ($urls as $url) {
$xml = curlGet($url, ['User-Agent: Mozilla/5.0 Jarvis/1.0'], 12);
$items = parseRss($xml, 4);
// Tag source from URL
$src = preg_match('/bbc/i', $url) ? 'BBC' :
(preg_match('/npr/i', $url) ? 'NPR' :
(preg_match('/abcnews/i', $url) ? 'ABC' :
(preg_match('/arstechnica/i', $url) ? 'Ars Technica' :
(preg_match('/theverge/i', $url) ? 'The Verge' : 'News'))));
foreach ($items as &$it) { $it['source'] = $src; }
$allNews[$category] = array_merge($allNews[$category], $items);
if (count($allNews[$category]) >= 8) break;
}
// Trim to 8 per category
$allNews[$category] = array_slice($allNews[$category], 0, 8);
}
$totalItems = array_sum(array_map('count', $allNews));
cacheStore('news', [
'categories' => $allNews,
'cached_at' => date('c'),
'total' => $totalItems,
]);
echo "[cache] News: $totalItems articles cached\n";
} else {
echo '[cache] News: fresh (' . round($newsAge/60) . " min old)\n";
}
echo '[cache] Done at ' . date('Y-m-d H:i:s') . "\n";
+42
View File
@@ -0,0 +1,42 @@
<?php
// Proactive suggestions endpoint — returns time-based command suggestions
require_once __DIR__ . '/../config.php';
header('Content-Type: application/json');
$hour = (int)date('G');
$dow = (int)date('w'); // 0=Sun, 6=Sat
// Find intents used 3+ times at this hour and day-of-week
$rows = JarvisDB::query(
"SELECT intent_name, hit_count FROM usage_patterns
WHERE hour=? AND dow=? AND hit_count >= 3
ORDER BY hit_count DESC LIMIT 3",
[$hour, $dow]
) ?? [];
// Map intents to friendly suggestion prompts
$intentPrompts = [
'network_scan' => 'Run a network scan?',
'jellyfin_now_playing' => 'Check what\'s playing on Jellyfin?',
'jellyfin_library' => 'Check the Jellyfin library?',
'ha_scene' => 'Activate a home scene?',
'planner:briefing' => 'Get your daily briefing?',
'vm_suggestions' => 'Check VM resource usage?',
'jellyfin_pause' => 'Pause Jellyfin?',
'focus_mode' => 'Switch to focus mode?',
'show_panels' => 'Show all panels?',
];
$suggestions = [];
foreach ($rows as $r) {
$intent = $r['intent_name'];
if (isset($intentPrompts[$intent])) {
$suggestions[] = [
'intent' => $intent,
'prompt' => $intentPrompts[$intent],
'hit_count' => (int)$r['hit_count'],
];
}
}
echo json_encode(['suggestions' => $suggestions, 'hour' => $hour, 'dow' => $dow]);
+136
View File
@@ -0,0 +1,136 @@
<?php
// System stats endpoint — reads /proc directly, no shell injection risk
function getCpuUsage(): float {
$s1 = file('/proc/stat')[0];
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s1, $m1);
usleep(200000);
$s2 = file('/proc/stat')[0];
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s2, $m2);
$idle1 = $m1[4] + $m1[5];
$total1 = array_sum(array_slice($m1, 1));
$idle2 = $m2[4] + $m2[5];
$total2 = array_sum(array_slice($m2, 1));
$dTotal = $total2 - $total1;
$dIdle = $idle2 - $idle1;
return $dTotal > 0 ? round((($dTotal - $dIdle) / $dTotal) * 100, 1) : 0.0;
}
function getMemory(): array {
$lines = file('/proc/meminfo');
$mem = [];
foreach ($lines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$total = $mem['MemTotal'] ?? 0;
$available = $mem['MemAvailable'] ?? 0;
$used = $total - $available;
return [
'total_mb' => round($total / 1024),
'used_mb' => round($used / 1024),
'free_mb' => round($available / 1024),
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
];
}
function getDisk(): array {
$disks = [];
foreach (disk_total_space('/') as $dummy) break; // warm up
$total = disk_total_space('/');
$free = disk_free_space('/');
$used = $total - $free;
return [
'total_gb' => round($total / 1073741824, 1),
'used_gb' => round($used / 1073741824, 1),
'free_gb' => round($free / 1073741824, 1),
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
];
}
function getDisk2(): array {
$total = disk_total_space('/');
$free = disk_free_space('/');
$used = $total - $free;
return [
'total_gb' => round($total / 1073741824, 1),
'used_gb' => round($used / 1073741824, 1),
'free_gb' => round($free / 1073741824, 1),
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
];
}
function getUptime(): string {
$secs = (int)file_get_contents('/proc/uptime');
$d = intdiv($secs, 86400); $h = intdiv($secs % 86400, 3600);
$m = intdiv($secs % 3600, 60);
return "{$d}d {$h}h {$m}m";
}
function getLoadAvg(): array {
$l = explode(' ', file_get_contents('/proc/loadavg'));
return ['1m' => (float)$l[0], '5m' => (float)$l[1], '15m' => (float)$l[2]];
}
function getNetworkIO(): array {
$lines = file('/proc/net/dev');
$ifaces = [];
foreach ($lines as $line) {
if (strpos($line, ':') === false) continue;
[$name, $stats] = explode(':', $line, 2);
$name = trim($name);
if ($name === 'lo') continue;
$vals = preg_split('/\s+/', trim($stats));
$ifaces[$name] = [
'rx_mb' => round($vals[0] / 1048576, 2),
'tx_mb' => round($vals[8] / 1048576, 2),
];
}
return $ifaces;
}
function getServices(): array {
$services = ["nginx","php8.3-fpm","mariadb","redis-server","jarvis-arc","jarvis-agent"];
$result = [];
foreach ($services as $svc) {
$out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null');
$result[$svc] = trim($out ?? '') === 'active';
}
return $result;
}
function getTopProcesses(): array {
$out = shell_exec("ps aux --sort=-%cpu | awk 'NR>1 && NR<=6 {print $11\",\"$3\",\"$4}' 2>/dev/null");
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
[$cmd, $cpu, $mem] = explode(',', $line, 3);
$procs[] = ['cmd' => basename($cmd), 'cpu' => (float)$cpu, 'mem' => (float)$mem];
}
return $procs;
}
$cpu = getCpuUsage();
$memory = getMemory();
$disk = getDisk2();
$stats = [
'cpu' => $cpu,
'memory' => $memory,
'disk' => $disk,
'uptime' => getUptime(),
'load' => getLoadAvg(),
'network_io' => getNetworkIO(),
'services' => getServices(),
'processes' => getTopProcesses(),
'hostname' => gethostname(),
'ip' => JARVIS_IP,
'timestamp' => date('c'),
];
// Log to history
JarvisDB::execute(
'INSERT INTO metrics_history (metric_name, metric_value, host) VALUES (?,?,?),(?,?,?),(?,?,?)',
['cpu', $cpu, 'jarvis', 'memory', $memory['percent'], 'jarvis', 'disk', $disk['percent'], 'jarvis']
);
echo json_encode($stats);
+53
View File
@@ -0,0 +1,53 @@
<?php
// ElevenLabs TTS proxy — keeps API key server-side, streams MP3 back to browser.
// POST body: {"text":"..."} Returns: audio/mpeg or JSON error
$text = trim((json_decode(file_get_contents('php://input'), true) ?? [])['text'] ?? '');
if (!$text) {
http_response_code(400);
echo json_encode(['error' => 'No text']);
exit;
}
// Cap at 400 chars to protect free-tier quota
$text = mb_substr($text, 0, 400);
if (!defined('ELEVENLABS_API_KEY') || !ELEVENLABS_API_KEY) {
http_response_code(503);
echo json_encode(['error' => 'ElevenLabs not configured']);
exit;
}
$payload = json_encode([
'text' => $text,
'model_id' => ELEVENLABS_MODEL,
'voice_settings' => ['stability' => 0.45, 'similarity_boost' => 0.80, 'style' => 0.10],
]);
$ch = curl_init('https://api.elevenlabs.io/v1/text-to-speech/' . ELEVENLABS_VOICE_ID);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'xi-api-key: ' . ELEVENLABS_API_KEY,
'Content-Type: application/json',
'Accept: audio/mpeg',
],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$audio = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $audio) {
header('Content-Type: audio/mpeg');
header('Cache-Control: no-store');
echo $audio;
} else {
http_response_code(502);
echo json_encode(['error' => 'ElevenLabs error', 'code' => $code]);
}
+20
View File
@@ -0,0 +1,20 @@
<?php
// Weather endpoint — serves from api_cache (refreshed every 30 min by cron)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
['weather']
);
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
echo json_encode($out);
} else {
echo json_encode([
'current' => null,
'forecast' => [],
'cache_age_s' => -1,
'message' => 'Weather data warming up — available within 5 minutes.',
]);
}
+40
View File
@@ -0,0 +1,40 @@
<?php
class JarvisDB {
private static ?PDO $pdo = null;
public static function get(): PDO {
if (self::$pdo === null) {
self::$pdo = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false]
);
}
return self::$pdo;
}
public static function query(string $sql, array $params = []): array {
$stmt = self::get()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public static function execute(string $sql, array $params = []): int {
$stmt = self::get()->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
public static function single(string $sql, array $params = []): ?array {
$rows = self::query($sql, $params);
return $rows[0] ?? null;
}
public static function insert(string $sql, array $params = []): int {
$stmt = self::get()->prepare($sql);
$stmt->execute($params);
return (int)self::get()->lastInsertId();
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
/**
* JARVIS Knowledge Base + Intent Engine
* Matches user input against intent patterns, substitutes live facts from kb_facts.
* Returns a response if matched, or null to escalate to Ollama/Claude.
*/
class KBEngine {
/**
* Try to match the input against known intents.
* Returns ['reply' => string, 'intent' => string] or null if no match.
*/
public static function match(string $input): ?array {
$intents = JarvisDB::query(
'SELECT * FROM kb_intents WHERE active=1 ORDER BY priority DESC, id ASC'
);
if (!$intents) return null;
foreach ($intents as $intent) {
$pat = '~' . $intent['pattern'] . '~';
if (@preg_match($pat, $input)) {
$reply = self::fillTemplate(
$intent['response_template'],
$intent['fact_category']
);
return [
'reply' => $reply,
'intent' => $intent['intent_name'],
'action' => $intent['action_type'],
];
}
}
return null;
}
/**
* Replace {placeholder} tokens in a template with values from kb_facts,
* plus built-in dynamic tokens like {current_time}.
*/
private static function fillTemplate(string $template, ?string $category): string {
// Built-in tokens
$builtins = [
'current_time' => date('g:i A'),
'current_date' => date('l, F j Y'),
];
// Load user address preference
$prefRows = JarvisDB::query(
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
);
$prefs = [];
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
$builtins['user_title'] = $prefs['user_title'] ?? $prefs['user_name'] ?? 'Sir';
$builtins['user_name'] = $prefs['user_name'] ?? 'Myron';
// Computed builtins
$pendingRow = JarvisDB::single("SELECT COUNT(*) cnt FROM tasks WHERE status NOT IN ('done','cancelled')");
$builtins['pending_count'] = (string)($pendingRow['cnt'] ?? 0);
$overdueRow = JarvisDB::single("SELECT COUNT(*) cnt FROM tasks WHERE due_date < CURDATE() AND status NOT IN ('done','cancelled')");
$builtins['overdue_count'] = (string)($overdueRow['cnt'] ?? 0);
// Fetch all facts for this category
$facts = [];
if ($category) {
$rows = JarvisDB::query(
'SELECT fact_key, fact_value FROM kb_facts
WHERE category = ? AND (expires_at IS NULL OR expires_at > NOW())',
[$category]
);
foreach ($rows ?? [] as $r) {
$facts[$r['fact_key']] = $r['fact_value'];
}
}
// Pull network facts if template uses them
if (strpos($template, '{online_count}') !== false || strpos($template, '{total_count}') !== false) {
$netRows = JarvisDB::query("SELECT fact_key, fact_value FROM kb_facts WHERE category='network' AND (expires_at IS NULL OR expires_at > NOW())");
foreach ($netRows ?? [] as $r) { $facts[$r['fact_key']] = $r['fact_value']; }
}
// Pull system facts if template uses them
if (preg_match('/\{(cpu_usage|mem_|disk_|uptime|load_)\w*\}/', $template)) {
$sysRows = JarvisDB::query("SELECT fact_key, fact_value FROM kb_facts WHERE category='system' AND (expires_at IS NULL OR expires_at > NOW())");
foreach ($sysRows ?? [] as $r) { $facts[$r['fact_key']] = $r['fact_value']; }
}
$allTokens = array_merge($builtins, $facts);
// Replace placeholders
return preg_replace_callback('/\{([a-z0-9_]+)\}/', function ($m) use ($allTokens) {
return $allTokens[$m[1]] ?? '';
}, $template);
}
/**
* Store a fact in kb_facts (upsert).
*/
public static function storeFact(
string $category,
string $key,
string $value,
string $host = 'local',
?int $ttlSeconds = null
): void {
$expires = $ttlSeconds ? gmdate('Y-m-d H:i:s', time() + $ttlSeconds) : null;
JarvisDB::execute(
'INSERT INTO kb_facts (category, fact_key, fact_value, host, expires_at)
VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), expires_at=VALUES(expires_at), updated_at=NOW()',
[$category, $key, $value, $host, $expires]
);
}
/**
* Learn from conversation store interesting facts the user mentions.
*/
public static function learnFromConversation(string $input, string $reply): void {
// Preference learning: user states a preference
if (preg_match('/(?i)i (prefer|like|want|always)\s+(.+?)(?:\.|$)/', $input, $m)) {
$pref = trim($m[2]);
if (strlen($pref) < 120) {
JarvisDB::execute(
'INSERT INTO kb_preferences (pref_key, pref_value)
VALUES (?,?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)',
['learned_' . md5($pref), $pref]
);
}
}
}
/**
* Return a summary of what the KB knows (for system prompt injection).
*/
public static function getContextSummary(): string {
// Exclude entity_map — too large for Ollama 1B tokenizer
$facts = JarvisDB::query(
"SELECT category, fact_key, fact_value FROM kb_facts
WHERE fact_key != 'entity_map'
ORDER BY category, updated_at DESC"
);
if (!$facts) return '';
$byCategory = [];
foreach ($facts as $f) {
$byCategory[$f['category']][] = "{$f['fact_key']}={$f['fact_value']}";
}
$lines = [];
foreach ($byCategory as $cat => $items) {
$lines[] = strtoupper($cat) . ': ' . implode(', ', array_slice($items, 0, 8));
}
return implode("\n", $lines);
}
}