mirror of
https://github.com/myronblair/jarvis
synced 2026-07-27 16:22:55 -05:00
Adopt live production state as git baseline
This commit is contained in:
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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}"]);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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
@@ -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}"]);
|
||||
}
|
||||
@@ -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"),
|
||||
]);
|
||||
@@ -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);
|
||||
@@ -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')]);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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)]);
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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 (2–5 sentences or a short structured list)
|
||||
"c" – category: the category string provided
|
||||
|
||||
Rules:
|
||||
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
|
||||
- Patterns should NOT start with ^ or end with $ (they are substring matches)
|
||||
- Responses must be factually accurate
|
||||
- Do not duplicate intent names; every "n" must be unique within this batch
|
||||
- Return exactly 20 intents
|
||||
SYS;
|
||||
|
||||
/* ── insert helper ── */
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
function safe_insert(array $intent, string $batchCategory): void {
|
||||
global $inserted, $skipped, $errors;
|
||||
$name = trim($intent['n'] ?? '');
|
||||
$pattern = trim($intent['p'] ?? '');
|
||||
$response = trim($intent['r'] ?? '');
|
||||
$category = trim($intent['c'] ?? $batchCategory);
|
||||
|
||||
if (!$name || !$pattern || !$response) { $errors++; return; }
|
||||
if (strlen($name) > 64) $name = substr($name, 0, 64);
|
||||
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
|
||||
if (strlen($response) < 30) { $skipped++; return; } // too short
|
||||
|
||||
try {
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
pattern=VALUES(pattern),
|
||||
response_template=VALUES(response_template),
|
||||
fact_category=VALUES(fact_category)',
|
||||
[$name, $pattern, $response, $category, 'response', 5, 1]
|
||||
);
|
||||
$inserted++;
|
||||
} catch (Exception $e) {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── main generation loop ── */
|
||||
$totalBatches = count($runBatches);
|
||||
foreach ($runBatches as $idx => $batch) {
|
||||
$num = $idx + 1;
|
||||
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
|
||||
|
||||
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
|
||||
. "Subtopics to cover: {$batch['desc']}.\n"
|
||||
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
|
||||
. "Category string to use: \"{$batch['category']}\".";
|
||||
|
||||
$raw = groq($SYSTEM, $user, 5000);
|
||||
if ($raw === null) {
|
||||
log_line(" ✗ API call failed after retries – skipping batch.");
|
||||
$errors += 20;
|
||||
sleep(8);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip markdown code fences if model added them
|
||||
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
|
||||
$raw = preg_replace('/^```\s*/m', '', $raw);
|
||||
$raw = trim($raw);
|
||||
|
||||
// Extract JSON array; fall back to partial recovery for truncated responses
|
||||
$items = null;
|
||||
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
|
||||
$items = json_decode($m[0], true);
|
||||
if (!is_array($items)) {
|
||||
log_line(" ✗ JSON parse failed – skipping batch.");
|
||||
$errors += 20; sleep(8); continue;
|
||||
}
|
||||
} else {
|
||||
$start = strpos($raw, '[');
|
||||
if ($start !== false) {
|
||||
$partial = substr($raw, $start);
|
||||
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
|
||||
$recovered = '[' . implode(',', $objs[0]) . ']';
|
||||
$items = json_decode($recovered, true);
|
||||
if (is_array($items) && count($items) > 0)
|
||||
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
|
||||
}
|
||||
}
|
||||
if (!is_array($items) || count($items) === 0) {
|
||||
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
|
||||
$errors += 20; sleep(8); continue;
|
||||
}
|
||||
}
|
||||
|
||||
$batchInserted = 0;
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) continue;
|
||||
safe_insert($item, $batch['category']);
|
||||
$batchInserted++;
|
||||
}
|
||||
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
|
||||
|
||||
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
|
||||
if ($num < $totalBatches) sleep(8);
|
||||
}
|
||||
|
||||
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
|
||||
|
||||
/* ── cleanup phase ── */
|
||||
log_line('Starting cleanup phase...');
|
||||
|
||||
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
|
||||
$dups = JarvisDB::query(
|
||||
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
|
||||
);
|
||||
$dupsPruned = 0;
|
||||
foreach ($dups as $dup) {
|
||||
$rows = JarvisDB::query(
|
||||
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
|
||||
[$dup['intent_name']]
|
||||
);
|
||||
array_shift($rows);
|
||||
foreach ($rows as $row) {
|
||||
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
|
||||
$dupsPruned++;
|
||||
}
|
||||
}
|
||||
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
|
||||
|
||||
// 2. Remove intents with very short responses (< 40 chars)
|
||||
$shortPruned = JarvisDB::execute(
|
||||
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
|
||||
);
|
||||
log_line(" Short-response rows pruned: {$shortPruned}");
|
||||
|
||||
// 3. Trim whitespace on all generated intents
|
||||
JarvisDB::execute(
|
||||
"UPDATE kb_intents SET
|
||||
intent_name = TRIM(intent_name),
|
||||
pattern = TRIM(pattern),
|
||||
response_template = TRIM(response_template),
|
||||
fact_category = TRIM(fact_category)
|
||||
WHERE priority = 5"
|
||||
);
|
||||
log_line(' Whitespace trimmed on all generated intents.');
|
||||
|
||||
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
|
||||
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
|
||||
$badPattern = 0;
|
||||
$fixedPattern = 0;
|
||||
foreach ($all as $row) {
|
||||
$pat = normalize_pattern($row['pattern']);
|
||||
if ($pat !== $row['pattern']) {
|
||||
// Update to normalized form
|
||||
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
|
||||
$fixedPattern++;
|
||||
} elseif (@preg_match($pat, '') === false) {
|
||||
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
|
||||
$badPattern++;
|
||||
} else {
|
||||
// Valid pattern — make sure it's active
|
||||
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
|
||||
}
|
||||
}
|
||||
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
|
||||
|
||||
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
|
||||
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
|
||||
log_line(" Re-activated previously inactive intents: {$reactivated}");
|
||||
|
||||
// 6. Final stats
|
||||
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
|
||||
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
|
||||
|
||||
/* ── record last-run timestamp ── */
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'last_run', NOW(), 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
|
||||
[]
|
||||
);
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'batch_offset', ?, 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||
[$nextOffset]
|
||||
);
|
||||
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'last_inserted', ?, 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||
[$inserted]
|
||||
);
|
||||
|
||||
log_line('Done.');
|
||||
@@ -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 (2–5 sentences or a short structured list)
|
||||
"c" – category: the category string provided
|
||||
|
||||
Rules:
|
||||
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
|
||||
- Patterns should NOT start with ^ or end with $ (they are substring matches)
|
||||
- Responses must be factually accurate
|
||||
- Do not duplicate intent names; every "n" must be unique within this batch
|
||||
- Return exactly 40 intents
|
||||
SYS;
|
||||
|
||||
/* ── insert helper ── */
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
function safe_insert(array $intent, string $batchCategory): void {
|
||||
global $inserted, $skipped, $errors;
|
||||
$name = trim($intent['n'] ?? '');
|
||||
$pattern = trim($intent['p'] ?? '');
|
||||
$response = trim($intent['r'] ?? '');
|
||||
$category = trim($intent['c'] ?? $batchCategory);
|
||||
|
||||
if (!$name || !$pattern || !$response) { $errors++; return; }
|
||||
if (strlen($name) > 64) $name = substr($name, 0, 64);
|
||||
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
|
||||
if (strlen($response) < 30) { $skipped++; return; } // too short
|
||||
|
||||
try {
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
pattern=VALUES(pattern),
|
||||
response_template=VALUES(response_template),
|
||||
fact_category=VALUES(fact_category)',
|
||||
[$name, $pattern, $response, $category, 'response', 5, 1]
|
||||
);
|
||||
$inserted++;
|
||||
} catch (Exception $e) {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── main generation loop ── */
|
||||
$totalBatches = count($BATCHES);
|
||||
foreach ($BATCHES as $idx => $batch) {
|
||||
$num = $idx + 1;
|
||||
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
|
||||
|
||||
$user = "Generate 40 KB intents for the topic: {$batch['topic']}.\n"
|
||||
. "Subtopics to cover: {$batch['desc']}.\n"
|
||||
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
|
||||
. "Category string to use: \"{$batch['category']}\".";
|
||||
|
||||
$raw = groq($SYSTEM, $user, 3500);
|
||||
if ($raw === null) {
|
||||
log_line(" ✗ API call failed – skipping batch.");
|
||||
$errors += 40;
|
||||
sleep(3);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip markdown code fences if model added them
|
||||
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
|
||||
$raw = preg_replace('/^```\s*/m', '', $raw);
|
||||
$raw = trim($raw);
|
||||
|
||||
// Extract JSON array (even if the model added preamble text)
|
||||
if (!preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
|
||||
log_line(" ✗ No JSON array found in response – skipping batch.");
|
||||
$errors += 40;
|
||||
sleep(2);
|
||||
continue;
|
||||
}
|
||||
$items = json_decode($m[0], true);
|
||||
if (!is_array($items)) {
|
||||
log_line(" ✗ JSON parse failed – skipping batch.");
|
||||
$errors += 40;
|
||||
sleep(2);
|
||||
continue;
|
||||
}
|
||||
|
||||
$batchInserted = 0;
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) continue;
|
||||
safe_insert($item, $batch['category']);
|
||||
$batchInserted++;
|
||||
}
|
||||
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
|
||||
|
||||
// Polite delay between API calls to avoid rate limiting
|
||||
if ($num < $totalBatches) sleep(2);
|
||||
}
|
||||
|
||||
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
|
||||
|
||||
/* ── cleanup phase ── */
|
||||
log_line('Starting cleanup phase...');
|
||||
|
||||
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
|
||||
$dups = JarvisDB::query(
|
||||
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
|
||||
);
|
||||
$dupsPruned = 0;
|
||||
foreach ($dups as $dup) {
|
||||
$rows = JarvisDB::query(
|
||||
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
|
||||
[$dup['intent_name']]
|
||||
);
|
||||
// Delete all but the first (longest response)
|
||||
array_shift($rows);
|
||||
foreach ($rows as $row) {
|
||||
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
|
||||
$dupsPruned++;
|
||||
}
|
||||
}
|
||||
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
|
||||
|
||||
// 2. Remove intents with very short responses (< 40 chars) — likely garbage
|
||||
$shortPruned = JarvisDB::execute(
|
||||
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
|
||||
);
|
||||
log_line(" Short-response rows pruned: {$shortPruned}");
|
||||
|
||||
// 3. Trim whitespace on all text columns for the auto-generated ones
|
||||
JarvisDB::execute(
|
||||
"UPDATE kb_intents SET
|
||||
intent_name = TRIM(intent_name),
|
||||
pattern = TRIM(pattern),
|
||||
response_template = TRIM(response_template),
|
||||
fact_category = TRIM(fact_category)
|
||||
WHERE priority = 5"
|
||||
);
|
||||
log_line(' Whitespace trimmed on all generated intents.');
|
||||
|
||||
// 4. Deactivate intents whose pattern is invalid PCRE
|
||||
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE active=1 AND priority=5');
|
||||
$badPattern = 0;
|
||||
foreach ($all as $row) {
|
||||
if (@preg_match($row['pattern'], '') === false) {
|
||||
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
|
||||
$badPattern++;
|
||||
}
|
||||
}
|
||||
log_line(" Bad PCRE patterns deactivated: {$badPattern}");
|
||||
|
||||
// 5. Stats
|
||||
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
|
||||
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
|
||||
|
||||
/* ── record last-run timestamp in kb_facts ── */
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'last_run', NOW(), 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
|
||||
[]
|
||||
);
|
||||
JarvisDB::execute(
|
||||
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
|
||||
VALUES ('kb_generator', 'last_inserted', ?, 'local')
|
||||
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
|
||||
[$inserted]
|
||||
);
|
||||
|
||||
log_line('Done.');
|
||||
@@ -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.');
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)]);
|
||||
@@ -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')]);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -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";
|
||||
@@ -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]);
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user