mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
9f92e4d5e4
- Mobile UI: 3-button bottom nav with panel switcher - Chat history search: search modal with keyword query - News filtering: category filter with localStorage persistence - Proactive reminders: planner/appointment alerts at login and every 5 min - Proactive alerts: polls every 60s, speaks new critical/warning alerts - Agent sparklines: 2h CPU+MEM sparkline on each online agent card - Tier source badge: KB/GROQ/CLAUDE/OLLAMA pill shown after each reply - VM suggestions: 24h resource analysis via voice command - HA scene control: fuzzy-match scene activation via voice - Jellyfin control: pause/stop/next/previous via voice and KB - Pattern suggestions: usage_patterns table + proactive chips every 30 min - Multi-step commands: compound "X and Y" command parsing (Tier 0.5) - Arc Reactor health: warning=amber/1.2s, critical=red/0.6s pulse encoding - Cross-session history: last 6 turns loaded from prior session - Restart agent: voice command to restart any JARVIS agent - New endpoints: history.php, metrics.php, suggestions.php, jellyfin.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2306 lines
120 KiB
PHP
2306 lines
120 KiB
PHP
<?php
|
||
// JARVIS Chat — Intent Engine → Ollama → Claude fallback chain
|
||
|
||
if ($method !== 'POST') {
|
||
echo json_encode(['error' => 'POST only']); exit;
|
||
}
|
||
|
||
$message = trim($data['message'] ?? '');
|
||
$sessionId = $data['session_id'] ?? session_id();
|
||
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
|
||
|
||
if (!$message) {
|
||
echo json_encode(['error' => 'Message required']); exit;
|
||
}
|
||
|
||
// Build context string from selected panel item
|
||
$ctxSnippet = '';
|
||
if ($panelCtx && is_array($panelCtx)) {
|
||
$type = $panelCtx['type'] ?? '';
|
||
switch ($type) {
|
||
case 'vm':
|
||
$ctxSnippet = sprintf(
|
||
'[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]',
|
||
$panelCtx['name'] ?? '?',
|
||
$panelCtx['vmid'] ?? '?',
|
||
$panelCtx['status'] ?? '?',
|
||
$panelCtx['cpu'] ?? '?',
|
||
$panelCtx['mem_mb'] ?? '?',
|
||
$panelCtx['maxmem_mb'] ?? '?',
|
||
$panelCtx['type_label'] ?? 'qemu'
|
||
);
|
||
break;
|
||
case 'network':
|
||
$ctxSnippet = sprintf(
|
||
'[Selected Device: %s — IP: %s, Status: %s%s]',
|
||
$panelCtx['name'] ?? '?',
|
||
$panelCtx['ip'] ?? '?',
|
||
$panelCtx['status'] ?? '?',
|
||
$panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : ''
|
||
);
|
||
break;
|
||
case 'alert':
|
||
$ctxSnippet = sprintf(
|
||
'[Selected Alert: %s — Severity: %s, Message: %s]',
|
||
$panelCtx['title'] ?? '?',
|
||
$panelCtx['severity'] ?? '?',
|
||
$panelCtx['message'] ?? '?'
|
||
);
|
||
break;
|
||
case 'news':
|
||
$ctxSnippet = sprintf(
|
||
'[Selected News Story: "%s" — Source: %s, Published: %s]',
|
||
$panelCtx['title'] ?? '?',
|
||
$panelCtx['source'] ?? '?',
|
||
$panelCtx['pub'] ?? 'unknown'
|
||
);
|
||
break;
|
||
case 'ha':
|
||
$ctxSnippet = sprintf(
|
||
'[Selected Home Device: %s (%s) — Current State: %s]',
|
||
$panelCtx['name'] ?? '?',
|
||
$panelCtx['entity_id'] ?? '?',
|
||
$panelCtx['state'] ?? '?'
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Save user message
|
||
JarvisDB::insert(
|
||
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
|
||
[$sessionId, 'user', $message]
|
||
);
|
||
|
||
// Conversation history — current session
|
||
$history = JarvisDB::query(
|
||
'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10',
|
||
[$sessionId]
|
||
) ?? [];
|
||
$history = array_reverse($history);
|
||
|
||
// Cross-session memory: last 6 turns from the most recent prior session
|
||
$priorSession = JarvisDB::query(
|
||
"SELECT session_id FROM conversations WHERE session_id != ? AND role='user'
|
||
ORDER BY created_at DESC LIMIT 1",
|
||
[$sessionId]
|
||
);
|
||
if ($priorSession && !empty($priorSession[0]['session_id'])) {
|
||
$priorHistory = JarvisDB::query(
|
||
'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 6',
|
||
[$priorSession[0]['session_id']]
|
||
) ?? [];
|
||
$history = array_merge(array_reverse($priorHistory), $history);
|
||
}
|
||
|
||
$reply = null;
|
||
$source = 'unknown';
|
||
|
||
// ── Load user address preference ──────────────────────────────────────────
|
||
$prefRows = JarvisDB::query(
|
||
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
|
||
);
|
||
$prefs = [];
|
||
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
|
||
$userName = $prefs['user_name'] ?? 'Myron';
|
||
$userTitle = $prefs['user_title'] ?? 'Mr. Blair';
|
||
// Address to use in responses
|
||
$userAddr = $userTitle;
|
||
|
||
// ── Tier 0.1: Name preference change ─────────────────────────────────────
|
||
if (!$reply) {
|
||
$lc = strtolower($message);
|
||
// Patterns: "call me X", "refer to me as X", "address me as X", "my name is X",
|
||
// "don't call me X", "stop calling me X", "just call me X"
|
||
if (preg_match(
|
||
'/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i',
|
||
$message, $m
|
||
)) {
|
||
$newName = trim(preg_replace('/\s+/', ' ', $m[1]));
|
||
// Strip trailing punctuation
|
||
$newName = rtrim($newName, '.!?,;');
|
||
if (strlen($newName) >= 2 && strlen($newName) <= 30) {
|
||
JarvisDB::execute(
|
||
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
|
||
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
|
||
[$newName]
|
||
);
|
||
$userTitle = $newName;
|
||
$userAddr = $newName;
|
||
$reply = "Understood. I'll address you as {$newName} from now on.";
|
||
$source = 'intent:name_pref';
|
||
}
|
||
} elseif (preg_match(
|
||
'/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i',
|
||
$message, $m
|
||
)) {
|
||
// "don't call me {$userAddr}" — switch to first name
|
||
JarvisDB::execute(
|
||
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
|
||
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
|
||
[$userName]
|
||
);
|
||
$userTitle = $userName;
|
||
$userAddr = $userName;
|
||
$reply = "Of course. I'll call you {$userName} going forward.";
|
||
$source = 'intent:name_pref';
|
||
}
|
||
}
|
||
|
||
// ── Tier 0: Home Assistant Control ───────────────────────────────────────
|
||
// Uses entity_map stored by facts_collector to resolve natural language → entity
|
||
$haEntityMapRow = JarvisDB::query(
|
||
'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=?
|
||
AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY updated_at DESC LIMIT 1',
|
||
['ha', 'entity_map']
|
||
);
|
||
$haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value']))
|
||
? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? [])
|
||
: [];
|
||
|
||
// Scene keywords for one-shot activations (no on/off needed)
|
||
$sceneKeywords = [
|
||
'good night' => 'scene.good_night',
|
||
'goodnight' => 'scene.good_night',
|
||
'good morning' => 'scene.good_morning',
|
||
'goodmorning' => 'scene.good_morning',
|
||
'goodbye' => 'scene.goodbye',
|
||
'bye' => 'scene.goodbye',
|
||
'kitchen lights on' => 'scene.kitchen_lights_on',
|
||
'kitchen on' => 'scene.kitchen_lights_on',
|
||
'kitchen lights off' => 'scene.kitchen_lights_off',
|
||
'kitchen off' => 'scene.kitchen_lights_off',
|
||
'front porch lights' => 'scene.outdoors_front_porch_lights',
|
||
'porch scene' => 'scene.outdoors_front_porch_lights',
|
||
'office dawn' => 'scene.office_ocean_dawn',
|
||
'good morning work' => 'scene.good_morning_work',
|
||
'morning work' => 'scene.good_morning_work',
|
||
'work morning' => 'scene.good_morning_work',
|
||
'master bedroom scene' => 'scene.master_bedroom_new_scene',
|
||
'bedroom scene' => 'scene.master_bedroom_new_scene',
|
||
'ceiling fan scene' => 'scene.master_bedroom_new_scene',
|
||
'porch lights on' => 'scene.outdoors_front_porch_lights',
|
||
'porch lights' => 'scene.outdoors_front_porch_lights',
|
||
];
|
||
|
||
$msgLower = strtolower(trim($message));
|
||
|
||
// Check for scene activation first
|
||
$sceneId = null;
|
||
foreach ($sceneKeywords as $kw => $sid) {
|
||
if (strpos($msgLower, $kw) !== false) {
|
||
$sceneId = $sid;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ($sceneId) {
|
||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||
$ch = curl_init($haUrl . '/api/services/scene/turn_on');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]),
|
||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
|
||
]);
|
||
$haResp = curl_exec($ch);
|
||
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
if ($haCode === 200) {
|
||
$sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId));
|
||
$reply = "Activating {$sceneName}, {$userAddr}.";
|
||
$source = 'ha:scene';
|
||
}
|
||
}
|
||
|
||
// Check for device on/off control
|
||
if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch)
|
||
|| (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) {
|
||
$turnOn = (bool) preg_match('/\bon\b/i', $message);
|
||
$turnOff = (bool) preg_match('/\boff\b/i', $message);
|
||
$haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null);
|
||
|
||
if ($haService && !empty($haEntityMap)) {
|
||
// Find best matching entity
|
||
$bestEid = null;
|
||
$bestScore = 0;
|
||
$bestName = '';
|
||
|
||
// Special: "all lights" / "everything"
|
||
if (preg_match('/(all|everything|every)/i', $message)) {
|
||
$bestEid = '__all_lights__';
|
||
$bestName = 'All lights';
|
||
$bestScore = 1;
|
||
}
|
||
|
||
if (!$bestEid) {
|
||
// Detect preferred domain from message
|
||
$preferLight = (bool) preg_match('/\blight(s)?\b/i', $message);
|
||
$preferSwitch = (bool) preg_match('/\b(switch|plug|outlet|strip)\b/i', $message);
|
||
|
||
// Strip control words — keep device/room name
|
||
$searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?|plugs?|strips?)\b/i', ' ', $msgLower);
|
||
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
|
||
|
||
// Empty search means generic light command (e.g. "turn on the lights") — all lights
|
||
if ($searchMsg === '' && $preferLight) {
|
||
$bestEid = '__all_lights__';
|
||
$bestName = 'All lights';
|
||
$bestScore = 1;
|
||
}
|
||
|
||
foreach ($haEntityMap as $eid => $info) {
|
||
if (($info['state'] ?? '') === 'unavailable') continue;
|
||
$nameLower = strtolower($info['name']);
|
||
$domain = $info['domain'] ?? '';
|
||
$score = 0;
|
||
|
||
// Exact name match = highest priority
|
||
if ($nameLower === $searchMsg) {
|
||
$score = 100;
|
||
// Search is substring of name
|
||
} elseif ($searchMsg && strpos($nameLower, $searchMsg) !== false) {
|
||
$score = 40;
|
||
// Name is substring of search (e.g. search="living room light", name="Living Room")
|
||
} elseif ($searchMsg && strpos($searchMsg, $nameLower) !== false) {
|
||
$score = 30;
|
||
} else {
|
||
// Word overlap scoring
|
||
$words = array_filter(explode(' ', $searchMsg));
|
||
foreach ($words as $w) {
|
||
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score += 8;
|
||
}
|
||
}
|
||
|
||
if ($score <= 0) continue;
|
||
|
||
// Domain preference bonus
|
||
if ($preferLight && $domain === 'light') $score += 20;
|
||
if ($preferSwitch && $domain === 'switch') $score += 20;
|
||
// Penalty for clearly wrong domain when user specified one
|
||
if ($preferLight && in_array($domain, ['media_player','sensor','climate'])) $score -= 15;
|
||
|
||
if ($score > $bestScore) {
|
||
$bestScore = $score;
|
||
$bestEid = $eid;
|
||
$bestName = $info['name'];
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($bestEid && $bestScore > 0) {
|
||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||
|
||
if ($bestEid === '__all_lights__') {
|
||
// Turn all lights on/off via domain targeting
|
||
$lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light'));
|
||
$payload = ['entity_id' => $lightIds];
|
||
$svcDomain = 'light';
|
||
} else {
|
||
$domain = $haEntityMap[$bestEid]['domain'] ?? 'switch';
|
||
$payload = ['entity_id' => $bestEid];
|
||
$svcDomain = $domain;
|
||
}
|
||
|
||
$ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
|
||
]);
|
||
$haResp = curl_exec($ch);
|
||
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($haCode === 200) {
|
||
$action = ($haService === 'turn_on') ? 'activated' : 'deactivated';
|
||
$label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName;
|
||
$reply = "{$label} {$action}, {$userAddr}.";
|
||
$source = 'ha:' . $haService;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Status query for HA entities
|
||
if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message)
|
||
&& !preg_match('/(turn|switch|put)/i', $message)) {
|
||
// Status query - find the entity and report its state
|
||
$searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower);
|
||
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
|
||
$bestEid = null; $bestScore = 0; $bestName = ''; $bestState = '';
|
||
foreach ($haEntityMap as $eid => $info) {
|
||
$nameLower = strtolower($info['name']);
|
||
$words = array_filter(explode(' ', $searchMsg));
|
||
$score = 0;
|
||
foreach ($words as $w) {
|
||
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
|
||
}
|
||
if ($score > $bestScore) {
|
||
$bestScore = $score; $bestEid = $eid;
|
||
$bestName = $info['name']; $bestState = $info['state'];
|
||
}
|
||
}
|
||
if ($bestEid && $bestScore > 0) {
|
||
$reply = "The {$bestName} is currently {$bestState}, {$userAddr}.";
|
||
$source = 'ha:status';
|
||
}
|
||
}
|
||
|
||
|
||
// ── Tier 0a: Brightness control ──────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(dim|brighten|brightness|set.*to\s+\d+\s*%|percent)\b/i', $message) && !empty($haEntityMap)) {
|
||
$pctMatch = null;
|
||
if (preg_match('/(\d+)\s*%/', $message, $pm)) {
|
||
$pctMatch = max(1, min(100, (int)$pm[1]));
|
||
} elseif (preg_match('/\b(full|max|maximum|hundred)\b/i', $message)) {
|
||
$pctMatch = 100;
|
||
} elseif (preg_match('/\b(half|fifty)\b/i', $message)) {
|
||
$pctMatch = 50;
|
||
} elseif (preg_match('/\b(low|dim|minimum|min|quarter)\b/i', $message)) {
|
||
$pctMatch = 20;
|
||
}
|
||
|
||
if ($pctMatch !== null) {
|
||
$searchMsg = preg_replace('/\b(dim|brighten|brightness|set|the|my|to|at|percent|%|\d+|jarvis|light|lights?)\b/i', ' ', $msgLower);
|
||
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
|
||
$bestEid = null; $bestScore = 0; $bestName = '';
|
||
foreach ($haEntityMap as $eid => $info) {
|
||
if ($info['domain'] !== 'light') continue;
|
||
if (($info['state'] ?? '') === 'unavailable') continue;
|
||
$nameLower = strtolower($info['name']);
|
||
$score = 0;
|
||
if ($searchMsg === '') { $bestEid = '__all_lights__'; $bestName = 'All lights'; $bestScore = 1; break; }
|
||
$words = array_filter(explode(' ', $searchMsg));
|
||
foreach ($words as $w) { if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score += 10; }
|
||
if ($score > $bestScore) { $bestScore = $score; $bestEid = $eid; $bestName = $info['name']; }
|
||
}
|
||
if ($bestEid && $bestScore >= 0) {
|
||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||
$brightness = (int)round($pctMatch * 2.55);
|
||
if ($bestEid === '__all_lights__') {
|
||
$lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light'));
|
||
$payload = ['entity_id' => $lightIds, 'brightness' => $brightness];
|
||
} else {
|
||
$payload = ['entity_id' => $bestEid, 'brightness' => $brightness];
|
||
}
|
||
$ch = curl_init($haUrl . '/api/services/light/turn_on');
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true,
|
||
CURLOPT_POSTFIELDS=>json_encode($payload),
|
||
CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]);
|
||
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||
if ($haCode === 200) {
|
||
$label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName;
|
||
$reply = "{$label} set to {$pctMatch}%, {$userAddr}.";
|
||
$source = 'ha:brightness';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0b: Media player control ────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(apple tv|appletv|pioneer|receiver|tv|television)\b/i', $message)
|
||
&& preg_match('/\b(turn|switch|power|on|off|pause|play|resume|stop|mute)\b/i', $message) && !empty($haEntityMap)) {
|
||
$mediaTurnOn = (bool) preg_match('/\b(on|play|resume|start)\b/i', $message);
|
||
$mediaTurnOff = (bool) preg_match('/\b(off|stop|shutdown|power off)\b/i', $message);
|
||
$mediaPause = (bool) preg_match('/\b(pause|mute)\b/i', $message);
|
||
$isAppleTV = (bool) preg_match('/\b(apple.?tv|appletv)\b/i', $message);
|
||
$isPioneer = (bool) preg_match('/\b(pioneer|receiver)\b/i', $message);
|
||
$isTv = (bool) preg_match('/\b(tv|television)\b/i', $message);
|
||
|
||
$targetEid = null; $targetName = '';
|
||
if ($isAppleTV) {
|
||
$targetEid = 'media_player.apple_tv_4k';
|
||
$targetName = 'Apple TV 4K';
|
||
} elseif ($isPioneer) {
|
||
$targetEid = 'media_player.vsx_822_2';
|
||
$targetName = 'Pioneer Receiver';
|
||
} elseif ($isTv) {
|
||
$targetEid = 'media_player.apple_tv_4k';
|
||
$targetName = 'Apple TV';
|
||
}
|
||
|
||
if ($targetEid) {
|
||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||
if ($mediaPause) {
|
||
$svc = 'media_player/media_pause';
|
||
} elseif ($mediaTurnOff) {
|
||
$svc = 'media_player/turn_off';
|
||
} else {
|
||
$svc = 'media_player/turn_on';
|
||
}
|
||
$ch = curl_init($haUrl . '/api/services/' . $svc);
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true,
|
||
CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$targetEid]),
|
||
CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]);
|
||
curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||
if ($haCode === 200) {
|
||
if ($mediaPause) $verb = 'paused';
|
||
elseif ($mediaTurnOff) $verb = 'powered off';
|
||
else $verb = 'powered on';
|
||
$reply = "{$targetName} {$verb}, {$userAddr}.";
|
||
$source = 'ha:media';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0c: Camera siren toggle ─────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(siren|alarm|honk|sound (the )?alarm)\b/i', $message)
|
||
&& preg_match('/\b(camera|back yard|backyard|carport|driveway|front yard)\b/i', $message)) {
|
||
$sirenOn = !preg_match('/\b(off|stop|disable|silence)\b/i', $message);
|
||
$haService = $sirenOn ? 'turn_on' : 'turn_off';
|
||
$sirenMap = [
|
||
'back yard' => ['switch.camera1_siren_on_event_2', 'Back Yard'],
|
||
'backyard' => ['switch.camera1_siren_on_event_2', 'Back Yard'],
|
||
'carport' => ['switch.camera1_siren_on_event_3', 'Carport'],
|
||
'front yard' => ['switch.front_yard_siren_on_event', 'Front Yard'],
|
||
'driveway' => ['switch.down_hill_siren_on_event', 'Driveway'],
|
||
];
|
||
$targetSiren = null; $targetLabel = 'Camera';
|
||
foreach ($sirenMap as $kw => [$eid, $lbl]) {
|
||
if (strpos($msgLower, $kw) !== false) { $targetSiren = $eid; $targetLabel = $lbl; break; }
|
||
}
|
||
if (!$targetSiren) {
|
||
$targetSiren = 'switch.camera1_siren_on_event_2';
|
||
$targetLabel = 'Back Yard';
|
||
}
|
||
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
|
||
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
|
||
$ch = curl_init($haUrl . '/api/services/switch/' . $haService);
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true,
|
||
CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$targetSiren]),
|
||
CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]);
|
||
curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||
if ($haCode === 200) {
|
||
$verb = $sirenOn ? 'activated' : 'silenced';
|
||
$reply = "{$targetLabel} camera siren {$verb}, {$userAddr}.";
|
||
$source = 'ha:siren';
|
||
}
|
||
}
|
||
|
||
// ── Email + Planner voice intents (action items, create task/appt from email) ─
|
||
if (!$reply && preg_match('/\b(email|emails|inbox|gmail|outlook|mail|unread|messages)\b/i', $message)) {
|
||
$lc = strtolower($message);
|
||
|
||
// ── Action items from email ───────────────────────────────────────────────
|
||
if (preg_match('/\b(action item|action required|need.*attention|follow.?up|things to do.*email|email.*task)\b/i', $message)) {
|
||
$rows = JarvisDB::query(
|
||
"SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 5"
|
||
) ?? [];
|
||
if (!$rows) {
|
||
$reply = "No email action items pending, {$userAddr}.";
|
||
} else {
|
||
$items = array_map(fn($r) =>
|
||
ucfirst($r['action_type']) . ': "' . mb_substr($r['subject'], 0, 60) . '" from ' . ($r['from_name'] ?: $r['from_email']),
|
||
$rows
|
||
);
|
||
$reply = count($rows) . " email action item" . (count($rows)>1?'s':'') . " need attention, {$userAddr}: " . implode('. ', $items) . '.';
|
||
}
|
||
$source = 'email:action_items';
|
||
|
||
// ── Create task from most recent action email ─────────────────────────────
|
||
} elseif (preg_match('/\b(create task|add task|make.*task|task.*from.*email)\b/i', $message)) {
|
||
$fromMatch = null;
|
||
if (preg_match('/\bfrom\s+([a-z][\w\s\.]+)/i', $message, $fm)) $fromMatch = trim($fm[1]);
|
||
$where = "WHERE dismissed=0 AND task_id IS NULL AND action_type IN ('task','appointment')";
|
||
$params = [];
|
||
if ($fromMatch) { $where .= ' AND (from_name LIKE ? OR from_email LIKE ?)'; $params[] = "%{$fromMatch}%"; $params[] = "%{$fromMatch}%"; }
|
||
$ea = JarvisDB::single("SELECT * FROM email_actions {$where} ORDER BY received_at DESC LIMIT 1", $params);
|
||
if ($ea) {
|
||
$title = mb_substr($ea['subject'], 0, 255);
|
||
$notes = "From: {$ea['from_name']} <{$ea['from_email']}>";
|
||
$taskId = JarvisDB::insert('INSERT INTO tasks (title,notes,category,priority) VALUES (?,?,?,?)',
|
||
[$title, $notes, 'work', 'normal']);
|
||
JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $ea['id']]);
|
||
$reply = "Task created from email: \"{$title}\", {$userAddr}.";
|
||
$source = 'email:create_task';
|
||
} else {
|
||
$reply = "No action-required emails found to create a task from, {$userAddr}.";
|
||
$source = 'email:create_task_none';
|
||
}
|
||
|
||
// ── Create appointment from meeting email ─────────────────────────────────
|
||
} elseif (preg_match('/\b(schedule|appointment|meeting|calendar).*\bemail\b|\bemail\b.*(schedule|appointment|meeting|calendar)\b/i', $message)) {
|
||
$ea = JarvisDB::single(
|
||
"SELECT * FROM email_actions WHERE dismissed=0 AND appointment_id IS NULL AND action_type='appointment' ORDER BY received_at DESC LIMIT 1"
|
||
);
|
||
if ($ea) {
|
||
$start = $ea['suggested_date'] ? $ea['suggested_date'] . ' 09:00:00' : date('Y-m-d') . ' 09:00:00';
|
||
$title = mb_substr($ea['subject'], 0, 255);
|
||
$apptId = JarvisDB::insert('INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)',
|
||
[$title, "From: {$ea['from_name']}", 'work', $start]);
|
||
JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $ea['id']]);
|
||
$reply = "Appointment scheduled from email: \"{$title}\" on " . date('l, M j', strtotime($start)) . ", {$userAddr}.";
|
||
$source = 'email:create_appt';
|
||
} else {
|
||
$reply = "No meeting emails found to schedule, {$userAddr}.";
|
||
$source = 'email:create_appt_none';
|
||
}
|
||
|
||
// ── Unread count ──────────────────────────────────────────────────────────
|
||
} elseif (preg_match('/\b(how many|any|check|count|unread)\b/i', $message) && !preg_match('/\bread\b/i', $message)) {
|
||
$emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email';
|
||
$account = 'all';
|
||
if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail';
|
||
if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook';
|
||
if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud';
|
||
$ch = curl_init($emailUrl . '?account=' . $account);
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]);
|
||
$emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||
if ($emailCode === 200 && $emailJson) {
|
||
$ed = json_decode($emailJson, true) ?? [];
|
||
$unread = (int)($ed['summary']['total_unread'] ?? 0);
|
||
$aiCount = (int)($ed['action_items_count'] ?? 0);
|
||
$parts = [];
|
||
foreach ($ed['accounts'] ?? [] as $a => $r) {
|
||
if (!empty($r['unread'])) $parts[] = $r['unread'] . ' on ' . ucfirst($a);
|
||
}
|
||
if ($unread === 0) {
|
||
$reply = "No unread emails, {$userAddr}.";
|
||
} else {
|
||
$bd = $parts ? ' (' . implode(', ', $parts) . ')' : '';
|
||
$reply = "You have {$unread} unread email" . ($unread>1?'s':'') . "{$bd}, {$userAddr}.";
|
||
}
|
||
if ($aiCount > 0) $reply .= " {$aiCount} require action.";
|
||
$source = 'email:count';
|
||
}
|
||
|
||
// ── Read recent emails ────────────────────────────────────────────────────
|
||
} else {
|
||
$emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email';
|
||
$account = 'all';
|
||
if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail';
|
||
if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook';
|
||
if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud';
|
||
$ch = curl_init($emailUrl . '?account=' . $account);
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]);
|
||
$emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||
if ($emailCode === 200 && $emailJson) {
|
||
$ed = json_decode($emailJson, true) ?? [];
|
||
$recent = $ed['summary']['recent'] ?? [];
|
||
$unread = (int)($ed['summary']['total_unread'] ?? 0);
|
||
$aiCount = (int)($ed['action_items_count'] ?? 0);
|
||
// filter by sender if mentioned
|
||
if (preg_match('/\bfrom\s+(.+)/i', $message, $fm)) {
|
||
$sender = strtolower(trim($fm[1]));
|
||
$recent = array_values(array_filter($recent, fn($m) =>
|
||
stripos($m['from_name']??'', $sender)!==false || stripos($m['from_email']??'', $sender)!==false));
|
||
}
|
||
$toRead = array_filter($recent, fn($m) => $m['unread']) ?: $recent;
|
||
$toRead = array_slice(array_values($toRead), 0, 3);
|
||
if (empty($toRead)) {
|
||
$reply = "No emails to report, {$userAddr}.";
|
||
} else {
|
||
$lines = [];
|
||
foreach ($toRead as $m) {
|
||
$acct = isset($m['account']) ? ' [' . strtoupper($m['account']) . ']' : '';
|
||
$lines[] = "From {$m['from_name']}: \"{$m['subject']}\", {$m['date']}{$acct}.";
|
||
}
|
||
$intro = $unread > 0 ? "You have {$unread} unread. " : "";
|
||
$reply = $intro . implode(' ', $lines);
|
||
if ($aiCount > 0) $reply .= " {$aiCount} require action — say 'email action items' for details.";
|
||
}
|
||
$source = 'email:read';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.5: Network Device Management ──────────────────────────────────
|
||
if (!$reply) {
|
||
// Flow state stored in kb_facts (session_write_close() is called before this runs)
|
||
$flowKey = substr(session_id() ?: 'anon', 0, 32);
|
||
// Expire stale chat flows older than 10 minutes
|
||
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)");
|
||
$flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]);
|
||
$devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null;
|
||
|
||
// Continue an active multi-step add-device flow
|
||
if ($devState && isset($devState['step'])) {
|
||
switch ($devState['step']) {
|
||
case 'waiting_ip':
|
||
if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) {
|
||
$newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']);
|
||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
|
||
$reply = "Got it — {$m[1]}. What should I call this device?";
|
||
} else {
|
||
$reply = "Please give me a valid IP address such as 192.168.1.100.";
|
||
}
|
||
$source = 'device:flow';
|
||
break;
|
||
|
||
case 'waiting_name':
|
||
$cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message));
|
||
$newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']);
|
||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
|
||
$reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip.";
|
||
$source = 'device:flow';
|
||
break;
|
||
|
||
case 'waiting_type':
|
||
$rawType = strtolower(trim($message));
|
||
$allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown'];
|
||
$devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown');
|
||
$devIp = $devState['ip'] ?? '';
|
||
$devName = $devState['name'] ?? '';
|
||
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
|
||
if ($devIp && $devName) {
|
||
JarvisDB::execute(
|
||
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
|
||
VALUES (?,?,?,'unknown',NOW())
|
||
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
|
||
[$devIp, $devName, $devType]
|
||
);
|
||
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status.";
|
||
} else {
|
||
$reply = "Something went wrong with the device registration. Please try again.";
|
||
}
|
||
$source = 'device:added';
|
||
break;
|
||
|
||
default:
|
||
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
|
||
}
|
||
}
|
||
|
||
// Start a new add-device flow or handle inline add
|
||
if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) {
|
||
$hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM);
|
||
$hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM);
|
||
$hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM);
|
||
if ($hasIp && $hasName) {
|
||
$devIp = $ipM[1];
|
||
$devName = trim($nameM[1]);
|
||
$devType = $hasType ? trim($typeM[1]) : 'unknown';
|
||
JarvisDB::execute(
|
||
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
|
||
VALUES (?,?,?,'unknown',NOW())
|
||
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
|
||
[$devIp, $devName, $devType]
|
||
);
|
||
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring.";
|
||
$source = 'device:added';
|
||
} elseif ($hasIp) {
|
||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]);
|
||
$reply = "I found the address {$ipM[1]}. What should I call this device?";
|
||
$source = 'device:flow';
|
||
} else {
|
||
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]);
|
||
$reply = "I'll add a new device to network monitoring. What's its IP address?";
|
||
$source = 'device:flow';
|
||
}
|
||
}
|
||
|
||
// Remove / delete device
|
||
if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) {
|
||
$target = trim($dm[2]);
|
||
$isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target);
|
||
$rows = $isIp
|
||
? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target])
|
||
: JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
|
||
if (count($rows)) {
|
||
$dev = $rows[0];
|
||
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]);
|
||
$label = $dev['alias'] ?? $dev['ip'];
|
||
$reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring.";
|
||
$source = 'device:removed';
|
||
}
|
||
}
|
||
|
||
// Update device name or type
|
||
if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) {
|
||
$target = trim($um[2]);
|
||
$newVal = trim($um[3]);
|
||
$rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
|
||
if (count($rows)) {
|
||
$dev = $rows[0];
|
||
JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]);
|
||
$reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'.";
|
||
$source = 'device:updated';
|
||
}
|
||
}
|
||
|
||
// List devices
|
||
if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) {
|
||
$rows = JarvisDB::query(
|
||
"SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias"
|
||
);
|
||
if (count($rows)) {
|
||
$items = array_map(fn($r) =>
|
||
($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'),
|
||
$rows
|
||
);
|
||
$reply = "Monitored devices: " . implode('; ', $items) . '.';
|
||
} else {
|
||
$reply = "No named devices in network monitoring yet. Say 'add a device' to get started.";
|
||
}
|
||
$source = 'device:list';
|
||
}
|
||
}
|
||
|
||
|
||
// ── Tier 0.7: Planner — full voice intent coverage ────────────────────────
|
||
if (!$reply) {
|
||
$lc = strtolower($message);
|
||
$today = date('Y-m-d');
|
||
|
||
// ── Daily briefing ────────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning|what do i have|what.?s on)\b/i', $message)
|
||
&& !preg_match('/\b(weather|news|temperature|forecast)\b/i', $message)) {
|
||
$tasks_today = JarvisDB::query("SELECT title,priority FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')", [$today]) ?? [];
|
||
$tasks_overdue = JarvisDB::query("SELECT COUNT(*) cnt FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled')", [$today]);
|
||
$appts_today = JarvisDB::query("SELECT title,start_at FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC", [$today]) ?? [];
|
||
$email_actions = JarvisDB::single("SELECT COUNT(*) cnt FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL");
|
||
$parts = [];
|
||
if ($appts_today) {
|
||
$ap = array_map(fn($a) => $a['title'] . ' at ' . date('g:i A', strtotime($a['start_at'])), $appts_today);
|
||
$parts[] = count($appts_today) . ' appointment' . (count($appts_today) > 1 ? 's' : '') . ': ' . implode(', ', $ap);
|
||
}
|
||
if ($tasks_today) {
|
||
$tl = array_map(fn($t) => $t['title'], $tasks_today);
|
||
$parts[] = count($tasks_today) . ' task' . (count($tasks_today) > 1 ? 's' : '') . ' due today: ' . implode(', ', $tl);
|
||
}
|
||
$ov = (int)($tasks_overdue['cnt'] ?? 0);
|
||
if ($ov > 0) $parts[] = $ov . ' overdue task' . ($ov > 1 ? 's' : '') . ' need attention';
|
||
$ai = (int)($email_actions['cnt'] ?? 0);
|
||
if ($ai > 0) $parts[] = $ai . ' email' . ($ai > 1 ? 's' : '') . ' require action';
|
||
// Include active directive progress summary
|
||
$active_dirs = JarvisDB::query(
|
||
"SELECT d.title,
|
||
COALESCE(SUM(kr.current_value),0) AS cur,
|
||
COALESCE(SUM(kr.target_value),1) AS tgt
|
||
FROM directives d
|
||
LEFT JOIN directive_key_results kr ON kr.directive_id=d.id
|
||
WHERE d.status='active'
|
||
GROUP BY d.id
|
||
ORDER BY d.priority DESC LIMIT 3"
|
||
) ?? [];
|
||
if ($active_dirs) {
|
||
$dp = array_map(fn($d) => $d['title'] . ' (' . round($d['cur'] / max($d['tgt'],1) * 100) . '%)', $active_dirs);
|
||
$parts[] = count($active_dirs) . ' active directive' . (count($active_dirs) > 1 ? 's' : '') . ': ' . implode(', ', $dp);
|
||
}
|
||
// Time-of-day greeting
|
||
$hour = (int)date('G'); // 0-23, America/Chicago set in config
|
||
if ($hour >= 5 && $hour < 12) $greet = "Good morning";
|
||
elseif ($hour >= 12 && $hour < 17) $greet = "Good afternoon";
|
||
elseif ($hour >= 17 && $hour < 22) $greet = "Good evening";
|
||
else $greet = "Good evening";
|
||
|
||
// Weather lead
|
||
$wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1");
|
||
if ($wRow && !empty($wRow[0]['data'])) {
|
||
$wd = json_decode($wRow[0]['data'], true);
|
||
$c = $wd['current'] ?? [];
|
||
if (!empty($c['temp']) && !empty($c['desc'])) {
|
||
$parts = array_merge(["It's {$c['temp']}°F and {$c['desc']} outside"], $parts);
|
||
}
|
||
}
|
||
|
||
// Offline agents
|
||
$offline_agents = JarvisDB::query(
|
||
"SELECT hostname FROM registered_agents WHERE status='offline' AND last_seen > DATE_SUB(NOW(), INTERVAL 7 DAY)"
|
||
) ?? [];
|
||
if ($offline_agents) {
|
||
$names = implode(', ', array_column($offline_agents, 'hostname'));
|
||
$parts[] = count($offline_agents) . ' agent' . (count($offline_agents) > 1 ? 's' : '') . ' offline: ' . $names;
|
||
}
|
||
|
||
$reply = $parts
|
||
? "{$greet}, {$userAddr}. " . implode('. ', $parts) . '.'
|
||
: "{$greet}, {$userAddr}. Your schedule is clear — all systems nominal, no tasks or appointments pending.";
|
||
$source = 'planner:briefing';
|
||
}
|
||
|
||
// ── Overdue tasks ─────────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(overdue|past due|late|missed.*task|task.*overdue)\b/i', $message)) {
|
||
$rows = JarvisDB::query("SELECT title, due_date FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC LIMIT 6", [$today]) ?? [];
|
||
if (!$rows) {
|
||
$reply = "No overdue tasks, {$userAddr}. You're all caught up.";
|
||
} else {
|
||
$items = array_map(fn($r) => $r['title'] . ' (was due ' . date('M j', strtotime($r['due_date'])) . ')', $rows);
|
||
$reply = count($rows) . " overdue task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:overdue';
|
||
}
|
||
|
||
// ── Urgent / high priority tasks ──────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(urgent|high priority|important|critical|asap)\b.*\btask|\btask.*\b(urgent|high priority|important|critical)\b/i', $message)) {
|
||
$rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE priority IN ('urgent','high') AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high'), due_date ASC LIMIT 6") ?? [];
|
||
if (!$rows) {
|
||
$reply = "No urgent or high priority tasks at the moment, {$userAddr}.";
|
||
} else {
|
||
$items = array_map(fn($r) => '[' . strtoupper($r['priority']) . '] ' . $r['title'], $rows);
|
||
$reply = count($rows) . " priority task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:priority_tasks';
|
||
}
|
||
|
||
// ── Tasks due this week ───────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(this week|week.?s tasks|due this week|tasks.*week)\b/i', $message)) {
|
||
$endOfWeek = date('Y-m-d', strtotime('sunday this week'));
|
||
$rows = JarvisDB::query("SELECT title, due_date, priority FROM tasks WHERE due_date BETWEEN ? AND ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC, FIELD(priority,'urgent','high','normal','low') LIMIT 8", [$today, $endOfWeek]) ?? [];
|
||
if (!$rows) {
|
||
$reply = "Nothing due this week, {$userAddr}.";
|
||
} else {
|
||
$items = array_map(fn($r) => $r['title'] . ' (' . date('D', strtotime($r['due_date'])) . ')', $rows);
|
||
$reply = count($rows) . " task" . (count($rows) > 1 ? 's' : '') . " due this week, {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:week_tasks';
|
||
}
|
||
|
||
// ── Work tasks ────────────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(work tasks|work.*todo|office tasks|work related)\b/i', $message)) {
|
||
$rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE category='work' AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 6") ?? [];
|
||
if (!$rows) {
|
||
$reply = "No work tasks pending, {$userAddr}.";
|
||
} else {
|
||
$items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows);
|
||
$reply = count($rows) . " work task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:work_tasks';
|
||
}
|
||
|
||
// ── Add task ──────────────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put.*task|add.*to.*list)\b/i', $message)) {
|
||
// Strip trigger at START only (non-greedy anchor prevents stripping mid-phrase words)
|
||
$title = preg_replace('/^\s*(?:jarvis\s+)?(?:add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put\s+\w+\s+on\s+(?:my\s+)?(?:task\s+)?list|add\s+(?:this\s+)?to\s+(?:my\s+)?list)\s*/i', '', $message);
|
||
$title = trim($title, '. ');
|
||
$dueDate = null;
|
||
// Extract date — "by Friday", "on Monday", "due tomorrow", OR bare date at end of phrase
|
||
$datePattern = '((?:next\s+\w+|this\s+(?:monday|tuesday|wednesday|thursday|friday)|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)(?:\s+at\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?)';
|
||
if (preg_match('/\s+(?:by|on|due|before|this|next)\s+' . $datePattern . '\s*$/i', $title, $dm)) {
|
||
$ts = strtotime(trim($dm[1]));
|
||
if ($ts !== false && $ts > time() - 86400) {
|
||
$dueDate = date('Y-m-d', $ts);
|
||
$title = trim(preg_replace('/\s+(?:by|on|due|before)\s+.*$/i', '', $title));
|
||
}
|
||
} elseif (preg_match('/\s+' . $datePattern . '\s*$/i', $title, $dm)) {
|
||
// Bare date at end: "call dentist tomorrow", "pay bills Monday"
|
||
$ts = strtotime(trim($dm[1]));
|
||
if ($ts !== false && $ts > time() - 86400 && $ts < time() + 365*86400) {
|
||
$dueDate = date('Y-m-d', $ts);
|
||
$title = trim(substr($title, 0, strrpos($title, $dm[0])));
|
||
}
|
||
}
|
||
$category = preg_match('/\b(work|meeting|project|client|office|report|email)\b/i', $title) ? 'work' : 'personal';
|
||
$priority = 'normal';
|
||
if (preg_match('/\b(urgent|asap|emergency|critical)\b/i', $title)) $priority = 'urgent';
|
||
elseif (preg_match('/\b(important|high priority)\b/i', $title)) $priority = 'high';
|
||
$title = trim($title);
|
||
if ($title) {
|
||
JarvisDB::execute('INSERT INTO tasks (title,category,priority,due_date) VALUES (?,?,?,?)', [$title, $category, $priority, $dueDate]);
|
||
$duePart = $dueDate ? ', due ' . date('l, M j', strtotime($dueDate)) : '';
|
||
$reply = "Task added: \"{$title}\"{$duePart}, {$userAddr}.";
|
||
$source = 'planner:task_add';
|
||
}
|
||
}
|
||
|
||
// ── List tasks ────────────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(my tasks|todo list|to.?do|pending tasks|what.*tasks|show.*tasks|task list|how many tasks|task count)\b/i', $message)) {
|
||
$rows = JarvisDB::query("SELECT title,priority,due_date FROM tasks WHERE status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 8") ?? [];
|
||
if (!$rows) {
|
||
$reply = "Your task list is clear, {$userAddr}. Nothing pending.";
|
||
} else {
|
||
$items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows);
|
||
$reply = "You have " . count($rows) . " pending task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:task_list';
|
||
}
|
||
|
||
// ── Mark task done ────────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(mark|complete|finished|done with|completed|check off|close)\b.{1,40}\btask\b|\btask\b.{1,20}\b(done|complete|finished)\b/i', $message)) {
|
||
$search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the|check|off|close|with)\b/i', ' ', $message);
|
||
$search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%';
|
||
$row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]);
|
||
if ($row) {
|
||
JarvisDB::execute("UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$row['id']]);
|
||
$reply = "Marked \"{$row['title']}\" as complete, {$userAddr}.";
|
||
$source = 'planner:task_done';
|
||
}
|
||
}
|
||
|
||
// ── Delete / cancel task ──────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(delete task|remove task|cancel task|drop task)\b/i', $message)) {
|
||
$search = preg_replace('/\b(delete|remove|cancel|drop|task)\b/i', ' ', $message);
|
||
$search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%';
|
||
$row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]);
|
||
if ($row) {
|
||
JarvisDB::execute("UPDATE tasks SET status='cancelled' WHERE id=?", [$row['id']]);
|
||
$reply = "Task \"{$row['title']}\" cancelled, {$userAddr}.";
|
||
$source = 'planner:task_cancel';
|
||
}
|
||
}
|
||
|
||
// ── Reschedule / move task ────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(reschedule|move|push|change due|update due)\b.*\btask\b/i', $message)) {
|
||
if (preg_match('/\bto\s+(.+)$/i', $message, $dm)) {
|
||
$ts = strtotime($dm[1]);
|
||
if ($ts !== false) {
|
||
$newDate = date('Y-m-d', $ts);
|
||
$search = preg_replace('/\b(reschedule|move|push|change|update|due|task|to\s+.+)$/i', ' ', $message);
|
||
$search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%';
|
||
$row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]);
|
||
if ($row) {
|
||
JarvisDB::execute("UPDATE tasks SET due_date=? WHERE id=?", [$newDate, $row['id']]);
|
||
$reply = "Moved \"{$row['title']}\" to " . date('l, M j', $ts) . ", {$userAddr}.";
|
||
$source = 'planner:task_reschedule';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Next appointment ──────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(next appointment|next meeting|next event|when.*next|upcoming appointment)\b/i', $message)) {
|
||
$row = JarvisDB::single("SELECT title, start_at, location FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 1");
|
||
if ($row) {
|
||
$when = date('l, M j \a\t g:i A', strtotime($row['start_at']));
|
||
$locPart = $row['location'] ? ' at ' . $row['location'] : '';
|
||
$reply = "Your next appointment is \"{$row['title']}\"{$locPart} on {$when}, {$userAddr}.";
|
||
} else {
|
||
$reply = "No upcoming appointments on your calendar, {$userAddr}.";
|
||
}
|
||
$source = 'planner:next_appt';
|
||
}
|
||
|
||
// ── Week / date range calendar view ──────────────────────────────────
|
||
if (!$reply && preg_match('/\b(this week|week.*calendar|calendar.*week|appointments.*week|what.*week)\b/i', $message)
|
||
&& !preg_match('/\btasks\b/i', $message)) {
|
||
$endOfWeek = date('Y-m-d', strtotime('sunday this week'));
|
||
$rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 8", [$today, $endOfWeek]) ?? [];
|
||
if (!$rows) {
|
||
$reply = "Nothing on your calendar this week, {$userAddr}.";
|
||
} else {
|
||
$items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows);
|
||
$reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . " this week, {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:week_calendar';
|
||
}
|
||
|
||
// ── Add appointment ───────────────────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(schedule|add\s+(?:an?\s+)?appointment|book\s+(?:an?\s+)?|set\s+up\s+(?:an?\s+)?meeting|add\s+(?:an?\s+)?meeting|add\s+to\s+(?:my\s+)?calendar)\b/i', $message)
|
||
&& !preg_match('/\b(my calendar|upcoming|list|show|what|from email)\b/i', $message)) {
|
||
// Strip trigger at START only — prevents eating words like "dentist appointment"
|
||
$raw = preg_replace('/^\s*(?:jarvis\s+)?(?:schedule|add\s+(?:an?\s+)?appointment|book\s+(?:a?n?\s+)?|set\s+up\s+(?:an?\s+)?meeting|add\s+(?:an?\s+)?meeting|add\s+to\s+(?:my\s+)?calendar)\s*/i', '', $message);
|
||
$raw = trim($raw);
|
||
|
||
// Normalize natural time words
|
||
$raw = preg_replace('/\bnoon\b/i', '12:00 pm', $raw);
|
||
$raw = preg_replace('/\bmidnight\b/i', '12:00 am', $raw);
|
||
$raw = preg_replace('/\bmidday\b/i', '12:00 pm', $raw);
|
||
|
||
// Step 1: extract time ("at 2pm", "at 2:30pm", "2pm", "14:00")
|
||
$timeStr = null;
|
||
if (preg_match('/\bat\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm))\b/i', $raw, $tm)) {
|
||
$timeStr = $tm[1];
|
||
$raw = trim(str_ireplace($tm[0], '', $raw));
|
||
} elseif (preg_match('/\b(\d{1,2}:\d{2}\s*(?:am|pm)?)\b/i', $raw, $tm)) {
|
||
$timeStr = $tm[1];
|
||
$raw = trim(str_ireplace($tm[0], '', $raw));
|
||
}
|
||
|
||
// Step 2: extract date (day names, "tomorrow", "next X", month+day)
|
||
$dateStr = null;
|
||
if (preg_match('/\b(tomorrow|today|next\s+\w+|this\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)|monday|tuesday|wednesday|thursday|friday|saturday|sunday|(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2})\b/i', $raw, $dm)) {
|
||
$dateStr = $dm[1];
|
||
$raw = trim(str_ireplace($dm[0], '', $raw));
|
||
}
|
||
|
||
// Step 3: build datetime
|
||
$dtParsed = null;
|
||
if ($dateStr || $timeStr) {
|
||
$dtInput = trim(($dateStr ?: 'today') . ' ' . ($timeStr ?: '09:00 am'));
|
||
$ts = strtotime($dtInput);
|
||
if ($ts !== false && $ts > time() - 3600) {
|
||
$dtParsed = date('Y-m-d H:i:s', $ts);
|
||
}
|
||
}
|
||
|
||
// Step 4: clean up title (remove leftover filler words)
|
||
$title = trim(preg_replace('/\s+/', ' ', preg_replace('/\b(on|at|the|a|an)\b\s*/i', ' ', $raw)));
|
||
$title = trim($title, ' .,');
|
||
|
||
if ($title && $dtParsed) {
|
||
$category = preg_match('/\b(work|meeting|office|client|project|call|conference|interview)\b/i', $title) ? 'work' : 'personal';
|
||
JarvisDB::execute('INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', [$title, $category, $dtParsed]);
|
||
$reply = "Scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}.";
|
||
$source = 'planner:appt_add';
|
||
} elseif ($title) {
|
||
$reply = "I can schedule that, {$userAddr} — what date and time?";
|
||
$source = 'planner:appt_need_time';
|
||
}
|
||
}
|
||
|
||
// ── Cancel / delete appointment ───────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(cancel|delete|remove)\b.*\b(appointment|meeting|event)\b/i', $message)) {
|
||
$search = preg_replace('/\b(cancel|delete|remove|appointment|meeting|event|the|my)\b/i', ' ', $message);
|
||
$search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%';
|
||
$row = JarvisDB::single("SELECT id, title FROM appointments WHERE title LIKE ? AND start_at > NOW() LIMIT 1", [$search]);
|
||
if ($row) {
|
||
JarvisDB::execute("DELETE FROM appointments WHERE id=?", [$row['id']]);
|
||
$reply = "Appointment \"{$row['title']}\" removed from your calendar, {$userAddr}.";
|
||
$source = 'planner:appt_cancel';
|
||
}
|
||
}
|
||
|
||
// ── View appointments / calendar ──────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(appointments|my calendar|my schedule|what.*scheduled|upcoming.*event|show.*calendar)\b/i', $message)) {
|
||
$rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 6", [$today, date('Y-m-d', strtotime('+7 days'))]) ?? [];
|
||
if (!$rows) {
|
||
$reply = "No appointments in the next 7 days, {$userAddr}.";
|
||
} else {
|
||
$items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows);
|
||
$reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
|
||
}
|
||
$source = 'planner:appt_list';
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.8: Weather & News intents ─────────────────────────────────────
|
||
if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) {
|
||
$wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1");
|
||
if ($wRow && !empty($wRow[0]['data'])) {
|
||
$wd = json_decode($wRow[0]['data'], true);
|
||
$c = $wd['current'] ?? [];
|
||
$fc = $wd['forecast'] ?? [];
|
||
$today = $fc[0] ?? [];
|
||
$tomorrow = $fc[1] ?? [];
|
||
$reply = sprintf(
|
||
'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' .
|
||
'Today\'s range: %d–%d°F. ' .
|
||
'Tomorrow: %s, %d–%d°F, %d%% chance of rain.',
|
||
$c['icon'] ?? '',
|
||
$c['desc'] ?? '',
|
||
$c['temp'] ?? 0,
|
||
$c['feels'] ?? 0,
|
||
$c['humidity'] ?? 0,
|
||
$c['wind'] ?? 0,
|
||
$today['low'] ?? 0,
|
||
$today['high'] ?? 0,
|
||
$tomorrow['desc'] ?? '',
|
||
$tomorrow['low'] ?? 0,
|
||
$tomorrow['high'] ?? 0,
|
||
$tomorrow['rain_pct'] ?? 0
|
||
);
|
||
$source = 'intent:weather';
|
||
}
|
||
}
|
||
|
||
if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) {
|
||
$nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1");
|
||
if ($nRow && !empty($nRow[0]['data'])) {
|
||
$nd = json_decode($nRow[0]['data'], true);
|
||
$cats = $nd['categories'] ?? [];
|
||
$lines = [];
|
||
foreach ($cats as $cat => $articles) {
|
||
if (!empty($articles)) {
|
||
$top3 = array_slice($articles, 0, 3);
|
||
foreach ($top3 as $a) {
|
||
$lines[] = '[' . $a['source'] . '] ' . $a['title'];
|
||
}
|
||
}
|
||
}
|
||
if ($lines) {
|
||
$reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.';
|
||
} else {
|
||
$reply = 'News feed is still loading, Sir. Please try again in a moment.';
|
||
}
|
||
$source = 'intent:news';
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9: Arc Protocols — research, triage, remote_exec, screenshot, sysinfo ─
|
||
$arcJobId = null;
|
||
|
||
// ── Memory Core helpers ───────────────────────────────────────────────────────
|
||
|
||
function getMemoryContext(string $message, int $limit = 12): string {
|
||
try {
|
||
$ch = curl_init('http://127.0.0.1:7474/memory/context?limit=' . $limit .
|
||
'&message=' . urlencode(substr($message, 0, 200)));
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 2,
|
||
CURLOPT_CONNECTTIMEOUT => 1,
|
||
]);
|
||
$raw = curl_exec($ch);
|
||
curl_close($ch);
|
||
if (!$raw) return '';
|
||
$data = json_decode($raw, true);
|
||
return $data['context'] ?? '';
|
||
} catch (Throwable $e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function memoryExtractAsync(string $userMsg, string $assistantMsg, string $sessionId): void {
|
||
// Fire-and-forget background memory extraction — does not block the response
|
||
$body = json_encode([
|
||
'type' => 'memory_extract',
|
||
'payload' => [
|
||
'user_message' => substr($userMsg, 0, 600),
|
||
'assistant_message' => substr($assistantMsg, 0, 600),
|
||
'conversation_id' => null,
|
||
],
|
||
'priority' => 2,
|
||
'created_by' => 'chat:' . $sessionId,
|
||
]);
|
||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => $body,
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => 1,
|
||
CURLOPT_CONNECTTIMEOUT => 1,
|
||
]);
|
||
@curl_exec($ch);
|
||
curl_close($ch);
|
||
}
|
||
|
||
// Helper: submit job to Arc Reactor
|
||
function arcPost(string $path, array $body): ?array {
|
||
$ch = curl_init('http://127.0.0.1:7474' . $path);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($body),
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => 5,
|
||
CURLOPT_CONNECTTIMEOUT => 3,
|
||
]);
|
||
$res = json_decode(curl_exec($ch), true);
|
||
curl_close($ch);
|
||
return $res;
|
||
}
|
||
|
||
function arcGet(string $path): ?array {
|
||
$ch = curl_init('http://127.0.0.1:7474' . $path);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 5,
|
||
CURLOPT_CONNECTTIMEOUT => 3,
|
||
]);
|
||
$res = json_decode(curl_exec($ch), true);
|
||
curl_close($ch);
|
||
return $res;
|
||
}
|
||
|
||
function arcSubmitJob(string $type, array $payload, string $sessionId): ?array {
|
||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode([
|
||
'type' => $type,
|
||
'payload' => $payload,
|
||
'priority' => 7,
|
||
'created_by' => 'chat:' . $sessionId,
|
||
]),
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => 5,
|
||
CURLOPT_CONNECTTIMEOUT => 3,
|
||
]);
|
||
$res = json_decode(curl_exec($ch), true);
|
||
curl_close($ch);
|
||
return $res;
|
||
}
|
||
|
||
// ── Tier 0.9a: Comms Protocol — gmail_triage detection ────────────────────
|
||
if (!$reply) {
|
||
$triagePatterns = [
|
||
'/^(?:jarvis[,\s]+)?(?:check|triage|sort)\s+(?:my\s+)?(?:email|inbox|gmail|mail)/i',
|
||
'/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:urgent|important)\s+(?:in\s+my\s+)?(?:email|inbox|mail)/i',
|
||
'/^(?:jarvis[,\s]+)?(?:any\s+)?(?:urgent|important)\s+(?:email|emails|messages)/i',
|
||
'/^(?:jarvis[,\s]+)?email\s+(?:briefing|brief|report|summary)/i',
|
||
];
|
||
foreach ($triagePatterns as $pat) {
|
||
if (preg_match($pat, $message)) {
|
||
$account = preg_match('/icloud/i', $message) ? 'icloud' : 'gmail';
|
||
$maxEmails = 20;
|
||
if (preg_match('/\b(\d+)\s+emails?\b/i', $message, $em)) $maxEmails = min((int)$em[1], 40);
|
||
$arcRes = arcSubmitJob('gmail_triage', [
|
||
'account' => $account,
|
||
'max_emails' => $maxEmails,
|
||
'provider' => 'claude',
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$acctLabel = strtoupper($account);
|
||
$reply = "◈ COMMS PROTOCOL ACTIVATED — Triaging your {$acctLabel} inbox (Job #{$arcJobId}). I'm fetching emails and running priority analysis now, {$userAddr}. Switch to the COMMS tab to see results.";
|
||
$source = 'arc:gmail_triage';
|
||
} else {
|
||
$reply = "Comms Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9b: Field Protocol — remote_exec detection ────────────────────
|
||
if (!$reply) {
|
||
$remotePatterns = [
|
||
'/^(?:jarvis[,\s]+)?restart\s+(.+?)\s+on\s+(.+)/i' => ['restart_service', 1, 2],
|
||
'/^(?:jarvis[,\s]+)?(?:run|execute|exec)\s+(.+?)\s+on\s+(.+)/i' => ['shell', 1, 2],
|
||
'/^(?:jarvis[,\s]+)?get\s+logs?\s+(?:from\s+|for\s+)?(.+?)\s+on\s+(.+)/i' => ['get_logs', 1, 2],
|
||
'/^(?:jarvis[,\s]+)?(?:ping|check)\s+agent\s+(.+)/i' => ['ping', 1, null],
|
||
'/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:running|the\s+status)\s+on\s+(.+)/i' => ['ping', 1, null],
|
||
];
|
||
foreach ($remotePatterns as $pat => $cfg) {
|
||
if (preg_match($pat, $message, $m)) {
|
||
[$cmdType, $cmdIdx, $agentIdx] = $cfg;
|
||
$cmdArg = $agentIdx ? trim($m[$cmdIdx]) : '';
|
||
$agentArg = $agentIdx ? trim($m[$agentIdx]) : trim($m[$cmdIdx]);
|
||
$cmdData = match($cmdType) {
|
||
'restart_service' => ['service' => $cmdArg],
|
||
'shell' => ['command' => $cmdArg],
|
||
'get_logs' => ['service' => $cmdArg, 'lines' => 50],
|
||
default => [],
|
||
};
|
||
$arcRes = arcSubmitJob('remote_exec', [
|
||
'agent' => $agentArg,
|
||
'command_type' => $cmdType,
|
||
'command_data' => $cmdData,
|
||
'timeout' => 35,
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ FIELD PROTOCOL ACTIVATED — Dispatching **{$cmdType}** to agent **{$agentArg}** (Job #{$arcJobId}). I'll relay the result when the field station responds, {$userAddr}.";
|
||
$source = 'arc:remote_exec';
|
||
} else {
|
||
$reply = "Field Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9c: Vision Protocol — screenshot, sysinfo, vision detection ───────
|
||
if (!$reply) {
|
||
// Screenshot patterns
|
||
$screenshotMatch = null;
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:show\s+(?:me\s+)?(?:the\s+)?screen\s+(?:on|of|from)|screenshot\s+(?:of\s+)?|grab\s+(?:a\s+)?(?:screenshot|screen\s+cap)\s+(?:of\s+|from\s+)?|what(?:\'s|\s+is)\s+(?:on|showing\s+on)\s+(?:the\s+)?screen\s+(?:on|of))\s+(.+)/i', $message, $mm)) {
|
||
$screenshotMatch = trim($mm[1]);
|
||
$arcRes = arcSubmitJob('screenshot', ['agent' => $screenshotMatch, 'analyze' => true], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ VISION PROTOCOL ACTIVATED — Capturing screen on **{$screenshotMatch}** (Job #{$arcJobId}). I'll analyze what I see and report back, {$userAddr}.";
|
||
$source = 'arc:screenshot';
|
||
} else {
|
||
$reply = "Vision Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
}
|
||
// Sysinfo patterns
|
||
elseif (preg_match('/^(?:jarvis[,\s]+)?(?:(?:what(?:\'s|\s+is)\s+(?:the\s+)?status\s+of|check\s+(?:the\s+)?(?:health|status)\s+of|how\s+is)\s+(.+)|system\s+(?:info|status|snapshot)\s+(?:on|from|for)\s+(.+))/i', $message, $mm)) {
|
||
$agentName = trim($mm[1] ?? $mm[2] ?? '');
|
||
// Only fire for explicit agent references (has a name), not generic questions
|
||
if ($agentName && strlen($agentName) > 2 &&
|
||
!preg_match('/\b(?:weather|today|things|everything|jarvis|yourself)\b/i', $agentName)) {
|
||
$arcRes = arcSubmitJob('sysinfo', ['agent' => $agentName, 'analyze' => true], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ FIELD PROTOCOL — Running system health check on **{$agentName}** (Job #{$arcJobId}). Snapshot incoming, {$userAddr}.";
|
||
$source = 'arc:sysinfo';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9d: Guardian Mode — sitrep detection ───────────────────────────
|
||
if (!$reply) {
|
||
$sitrepPatterns = [
|
||
'/^(?:jarvis[,\s]+)?(?:sitrep|sit\s+rep|situation\s+report|status\s+report)/i',
|
||
'/^(?:jarvis[,\s]+)?(?:give\s+me\s+a|run\s+a)\s+(?:sitrep|situation|status)\s*(?:report)?/i',
|
||
'/^(?:jarvis[,\s]+)?(?:how\s+(?:are|is)\s+(?:everything|all\s+systems?|things?)(?:\s+looking)?)/i',
|
||
'/^(?:jarvis[,\s]+)?(?:system\s+health|overall\s+status|all\s+systems\s+(?:check|status|go))/i',
|
||
'/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:the\s+)?overall\s+(?:system\s+)?status/i',
|
||
];
|
||
$isSimple = (bool) preg_match('/\b(?:brief|quick|short|summary)\b/i', $message);
|
||
foreach ($sitrepPatterns as $pat) {
|
||
if (preg_match($pat, $message)) {
|
||
$arcRes = arcSubmitJob('sitrep', [
|
||
'detail' => $isSimple ? 'brief' : 'full',
|
||
'provider' => 'claude',
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ GUARDIAN PROTOCOL — Generating situation report (Job #{$arcJobId}). Scanning all field stations and synthesizing a briefing now, {$userAddr}. Stand by.";
|
||
$source = 'arc:sitrep';
|
||
} else {
|
||
$reply = "Guardian Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9e: Intel Protocol — research & tool_loop detection ────────────
|
||
$intelPatterns = [
|
||
'/^(?:jarvis[,\s]+)?(?:research|investigate|deep[- ]dive|deep dive)\s+(.+)/i' => 'research',
|
||
'/^(?:jarvis[,\s]+)?(?:look\s+(?:up|into)|find\s+out\s+(?:about)?)\s+(.+)/i' => 'research',
|
||
'/^(?:jarvis[,\s]+)?(?:step[- ]by[- ]step|figure\s+out|analyze\s+and\s+report|work\s+through)\s+(.+)/i' => 'tool_loop',
|
||
'/^(?:jarvis[,\s]+)?(?:run\s+a\s+research\s+(?:job|task)\s+on)\s+(.+)/i' => 'research',
|
||
];
|
||
|
||
if (!$reply) {
|
||
foreach ($intelPatterns as $pattern => $jobType) {
|
||
if (preg_match($pattern, $message, $m)) {
|
||
$queryOrTask = trim($m[1]);
|
||
if (strlen($queryOrTask) < 3) break;
|
||
|
||
$depth = 'standard';
|
||
if (preg_match('/\b(?:quick|brief)\b/i', $message)) $depth = 'quick';
|
||
if (preg_match('/\b(?:deep|thorough|comprehensive|full)\b/i', $message)) $depth = 'deep';
|
||
|
||
$jobPayload = $jobType === 'research'
|
||
? ['query' => $queryOrTask, 'depth' => $depth, 'provider' => 'claude']
|
||
: ['task' => $queryOrTask, 'max_iterations' => 12, 'provider' => 'claude'];
|
||
|
||
$arcRes = arcSubmitJob($jobType, $jobPayload, $sessionId);
|
||
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
if ($jobType === 'research') {
|
||
$depthLabel = strtoupper($depth);
|
||
$reply = "◈ INTEL PROTOCOL ACTIVATED — Running {$depthLabel} research on **{$queryOrTask}** (Job #{$arcJobId}). I'm searching sources, extracting content, and synthesizing a briefing now, {$userAddr}. Switch to the INTEL tab to watch live progress.";
|
||
} else {
|
||
$reply = "◈ IRON PROTOCOL ACTIVATED — Multi-step analysis initiated for **{$queryOrTask}** (Job #{$arcJobId}). I'll work through this systematically using available tools, {$userAddr}. Results will appear in the INTEL tab.";
|
||
}
|
||
$source = "arc:{$jobType}";
|
||
} else {
|
||
$reply = "Intel Protocol is offline, {$userAddr}. Arc Reactor may be unavailable — I'll try to answer directly.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9f: Comms v2 — send_email, compose_email ─────────────────────────
|
||
if (!$reply) {
|
||
// "reply to [name/id] saying..." or "send [name] a reply..."
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:reply\s+to|send\s+(?:a\s+)?reply\s+to)\s+(.+?)\s+(?:saying|that|:)\s+(.+)/i', $message, $m)) {
|
||
$target = trim($m[1]);
|
||
$content = trim($m[2]);
|
||
$arcRes = arcSubmitJob('send_email', [
|
||
'target' => $target,
|
||
'content' => $content,
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ COMMS PROTOCOL — Sending reply to **{$target}** (Job #{$arcJobId}). Drafting and transmitting now, {$userAddr}.";
|
||
$source = 'arc:send_email';
|
||
} else {
|
||
$reply = "Comms Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
}
|
||
// "send email to [name] about [subject]" or "compose email to..."
|
||
elseif (preg_match('/^(?:jarvis[,\s]+)?(?:send\s+(?:an?\s+)?email\s+to|compose\s+(?:an?\s+)?email\s+to|write\s+(?:an?\s+)?email\s+to|draft\s+(?:an?\s+)?email\s+to)\s+(.+?)(?:\s+(?:about|regarding|re:?)\s+(.+))?$/i', $message, $m)) {
|
||
$recipient = trim($m[1]);
|
||
$instructions = isset($m[2]) ? trim($m[2]) : $message;
|
||
$arcRes = arcSubmitJob('compose_email', [
|
||
'recipient' => $recipient,
|
||
'instructions' => $instructions,
|
||
'auto_send' => false,
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ COMMS PROTOCOL — Composing email to **{$recipient}** (Job #{$arcJobId}). I'll draft it and show you before sending, {$userAddr}. Check the COMMS tab.";
|
||
$source = 'arc:compose_email';
|
||
} else {
|
||
$reply = "Comms Protocol is offline, {$userAddr}.";
|
||
$source = 'arc:offline';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9g: Comms v2 — schedule_event ─────────────────────────────────────
|
||
if (!$reply) {
|
||
$schedulePatterns = [
|
||
'/^(?:jarvis[,\s]+)?(?:schedule|book|set\s+up|create)\s+(?:a\s+)?(?:meeting|call|appointment|event|session)\s+(?:with|for|about)?\s*(.+)/i',
|
||
'/^(?:jarvis[,\s]+)?(?:add\s+(?:a\s+)?(?:meeting|call|appointment|event)\s+(?:to\s+my\s+calendar)?\s*(?:with|for|about)?\s*(.+))/i',
|
||
'/^(?:jarvis[,\s]+)?(?:put\s+(?:a\s+)?(?:meeting|call|appointment)\s+(?:on\s+(?:my\s+)?calendar|in\s+my\s+schedule)(?:\s+(?:with|for|about)?\s+(.+))?)/i',
|
||
];
|
||
foreach ($schedulePatterns as $pat) {
|
||
if (preg_match($pat, $message, $m)) {
|
||
$details = trim($m[1] ?? $message);
|
||
$arcRes = arcSubmitJob('schedule_event', [
|
||
'request' => $message,
|
||
'details' => $details,
|
||
'provider' => 'claude',
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ SCHEDULING PROTOCOL — Processing your calendar request (Job #{$arcJobId}). I'm parsing the details and creating the appointment now, {$userAddr}.";
|
||
$source = 'arc:schedule_event';
|
||
} else {
|
||
$reply = "Scheduling Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9h: Comms v2 — meeting_prep ────────────────────────────────────────
|
||
if (!$reply) {
|
||
$meetingPrepPatterns = [
|
||
'/^(?:jarvis[,\s]+)?(?:prep(?:are)?(?:\s+me)?\s+for|brief(?:ing)?\s+(?:me\s+)?(?:for|on|about)?)\s+(?:my\s+)?(?:next\s+)?(?:meeting|call|appointment)/i',
|
||
'/^(?:jarvis[,\s]+)?(?:what(?:\'s|\s+do\s+i\s+need\s+to\s+know)?\s+(?:is\s+)?(?:my\s+)?(?:next\s+)?meeting)/i',
|
||
'/^(?:jarvis[,\s]+)?(?:meeting\s+prep|pre[- ]meeting\s+(?:brief|notes|prep))/i',
|
||
'/^(?:jarvis[,\s]+)?(?:get\s+(?:me\s+)?ready\s+for\s+my\s+(?:next\s+)?(?:meeting|call))/i',
|
||
];
|
||
foreach ($meetingPrepPatterns as $pat) {
|
||
if (preg_match($pat, $message)) {
|
||
$arcRes = arcSubmitJob('meeting_prep', [
|
||
'provider' => 'claude',
|
||
'research' => true,
|
||
], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ MISSION PROTOCOL — Preparing your meeting briefing (Job #{$arcJobId}). I'm pulling your next appointment details and researching the participants, {$userAddr}. Stand by.";
|
||
$source = 'arc:meeting_prep';
|
||
} else {
|
||
$reply = "Mission Protocol is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9i: Directives — review objectives, progress check ─────────────────
|
||
if (!$reply) {
|
||
$dirReviewPatterns = [
|
||
'/^(?:jarvis[,\s]+)?(?:review\s+(?:my\s+)?(?:directives?|objectives?|goals?|OKRs?))/i',
|
||
'/^(?:jarvis[,\s]+)?(?:how\s+am\s+i\s+doing\s+on\s+(?:my\s+)?(?:directives?|objectives?|goals?))/i',
|
||
'/^(?:jarvis[,\s]+)?(?:directives?\s+(?:review|status|update|progress|briefing))/i',
|
||
'/^(?:jarvis[,\s]+)?(?:OKR\s+(?:review|update|status))/i',
|
||
'/^(?:jarvis[,\s]+)?(?:what(?:\'s|\s+is)\s+(?:my\s+)?(?:progress|status)\s+on\s+(?:my\s+)?(?:directives?|goals?|objectives?))/i',
|
||
];
|
||
foreach ($dirReviewPatterns as $pat) {
|
||
if (preg_match($pat, $message)) {
|
||
$arcRes = arcSubmitJob('directive_review', ['provider' => 'claude'], $sessionId);
|
||
if (isset($arcRes['job_id'])) {
|
||
$arcJobId = $arcRes['job_id'];
|
||
$reply = "◈ DIRECTIVE REVIEW INITIATED (Job #{$arcJobId}). I'm analyzing your active objectives and key results now, {$userAddr}. Stand by for your progress briefing.";
|
||
$source = 'arc:directive_review';
|
||
} else {
|
||
$reply = "Directive review is offline, {$userAddr}. Arc Reactor may be unavailable.";
|
||
$source = 'arc:offline';
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9j: Clearance Protocol — approve/deny voice commands ─────────────
|
||
if (!$reply) {
|
||
// "approve clearance 5", "authorize clearance", "approve all clearance"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:approve|authorize|grant)\s+(?:all\s+)?clearance(?:\s+(?:request\s+)?#?(\d+))?/i', $message, $m)) {
|
||
$crId = isset($m[1]) && $m[1] ? (int)$m[1] : null;
|
||
if ($crId) {
|
||
$resp = arcPost('/clearance/' . $crId . '/approve', ['decided_by' => 'voice']);
|
||
if (isset($resp['ok']) && $resp['ok']) {
|
||
$reply = "◈ Clearance request #{$crId} authorized, {$userAddr}. Job dispatched.";
|
||
} else {
|
||
$reply = "Clearance #{$crId} not found or already decided.";
|
||
}
|
||
} else {
|
||
// Approve all pending
|
||
$pending = arcGet('/clearance/pending') ?: [];
|
||
if (empty($pending)) {
|
||
$reply = "No pending clearance requests, {$userAddr}.";
|
||
} else {
|
||
$approved = 0;
|
||
foreach ($pending as $cr) {
|
||
$resp = arcPost('/clearance/' . $cr['id'] . '/approve', ['decided_by' => 'voice']);
|
||
if (isset($resp['ok']) && $resp['ok']) $approved++;
|
||
}
|
||
$reply = "◈ Authorized {$approved} clearance request" . ($approved !== 1 ? 's' : '') . ", {$userAddr}.";
|
||
}
|
||
}
|
||
$source = 'arc:clearance_approve';
|
||
}
|
||
}
|
||
if (!$reply) {
|
||
// "deny clearance 5", "reject clearance 5"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:deny|reject|refuse)\s+clearance(?:\s+(?:request\s+)?#?(\d+))?/i', $message, $m)) {
|
||
$crId = isset($m[1]) && $m[1] ? (int)$m[1] : null;
|
||
if ($crId) {
|
||
$resp = arcPost('/clearance/' . $crId . '/deny', ['decided_by' => 'voice', 'note' => 'denied by voice command']);
|
||
$reply = isset($resp['ok']) && $resp['ok']
|
||
? "Clearance #{$crId} denied, {$userAddr}."
|
||
: "Clearance #{$crId} not found or already decided.";
|
||
} else {
|
||
$reply = "Which clearance request should I deny? Say: deny clearance [number].";
|
||
}
|
||
$source = 'arc:clearance_deny';
|
||
}
|
||
}
|
||
if (!$reply) {
|
||
// "any pending clearance", "clearance status", "show clearance"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:(?:any\s+|show\s+)?pending\s+clearance|clearance\s+(?:status|requests?|pending|queue))/i', $message)) {
|
||
$pending = arcGet('/clearance/pending') ?: [];
|
||
$count = count($pending);
|
||
if ($count === 0) {
|
||
$reply = "No pending clearance requests, {$userAddr}. All clear.";
|
||
} else {
|
||
$list = array_map(fn($cr) => "#{$cr['id']} {$cr['job_type']} ({$cr['risk_level']})", $pending);
|
||
$reply = "◈ {$count} pending clearance request" . ($count !== 1 ? 's' : '') . ": " . implode(', ', $list) . ". Say 'approve clearance [number]' to authorize.";
|
||
}
|
||
$source = 'arc:clearance_status';
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.9k: Memory Core — remember/forget/recall voice commands ───────────
|
||
if (!$reply) {
|
||
// "remember that I prefer X", "remember X", "note that X"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:remember|note down|make a note|remember that|note that)\s+(?:that\s+)?(.+)/i', $message, $m)) {
|
||
$fact = trim($m[1]);
|
||
// Use LLM to parse the fact into subject/predicate/object — but for speed, use heuristic
|
||
$arcRes = arcPost('/job', [
|
||
'type' => 'memory_store',
|
||
'payload' => ['subject' => 'user', 'predicate' => 'note', 'object' => $fact, 'category' => 'instruction'],
|
||
'priority' => 8,
|
||
'created_by' => 'chat:' . $sessionId,
|
||
]);
|
||
$reply = "Noted, {$userAddr}. I'll remember that: {$fact}";
|
||
$source = 'memory:store';
|
||
}
|
||
}
|
||
if (!$reply) {
|
||
// "forget X", "forget that X", "remove memory X"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:forget|discard|remove|delete)\s+(?:that\s+|the\s+memory\s+(?:about\s+)?)?(.+)/i', $message, $m)) {
|
||
$keyword = trim($m[1]);
|
||
// Mark matching facts inactive
|
||
$affected = JarvisDB::execute(
|
||
"UPDATE memory_facts SET active=0 WHERE active=1 AND (subject LIKE ? OR object LIKE ? OR predicate LIKE ?)",
|
||
["%{$keyword}%", "%{$keyword}%", "%{$keyword}%"]
|
||
);
|
||
$reply = $affected
|
||
? "Memory cleared, {$userAddr}. Removed facts related to: {$keyword}."
|
||
: "Nothing in memory matched \"{$keyword}\", {$userAddr}.";
|
||
$source = 'memory:forget';
|
||
}
|
||
}
|
||
if (!$reply) {
|
||
// "what do you know about X", "what do you remember about X"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:what do you know about|what do you remember about|recall|memory about)\s+(.+)/i', $message, $m)) {
|
||
$keyword = trim($m[1]);
|
||
$facts = JarvisDB::query(
|
||
"SELECT * FROM memory_facts WHERE active=1 AND (subject LIKE ? OR object LIKE ?) ORDER BY confirmed_count DESC LIMIT 8",
|
||
["%{$keyword}%", "%{$keyword}%"]
|
||
) ?: [];
|
||
if (empty($facts)) {
|
||
$reply = "I don't have any stored memories about \"{$keyword}\", {$userAddr}.";
|
||
} else {
|
||
$lines = array_map(fn($f) => "{$f['subject']} {$f['predicate']}: {$f['object']}", $facts);
|
||
$reply = "◈ I know the following about \"{$keyword}\", {$userAddr}:\n" . implode("\n", $lines);
|
||
}
|
||
$source = 'memory:recall';
|
||
}
|
||
}
|
||
if (!$reply) {
|
||
// "how many memories", "memory status", "what do you know about me"
|
||
if (preg_match('/^(?:jarvis[,\s]+)?(?:(?:how many|show)\s+memories|memory\s+(?:status|count|summary)|what do you know about me)/i', $message)) {
|
||
$stats = JarvisDB::query(
|
||
"SELECT category, COUNT(*) cnt FROM memory_facts WHERE active=1 GROUP BY category ORDER BY cnt DESC"
|
||
) ?: [];
|
||
$total = array_sum(array_column($stats, 'cnt'));
|
||
if (!$total) {
|
||
$reply = "My memory core is empty, {$userAddr}. I'll start learning from our conversations.";
|
||
} else {
|
||
$breakdown = implode(', ', array_map(fn($s) => "{$s['cnt']} {$s['category']}", $stats));
|
||
$reply = "◈ Memory Core: {$total} facts stored — {$breakdown}. I use these to personalize responses.";
|
||
}
|
||
$source = 'memory:status';
|
||
}
|
||
}
|
||
|
||
// ── Tier 0.5: Multi-step command detection ──────────────────────────────────
|
||
// Detect "do X and Y" or "X then Y" compound commands (only when no reply yet)
|
||
if (!$reply) {
|
||
$compoundParts = preg_split('/\s+(?:and|then|also)\s+/i', $message, 3);
|
||
if (count($compoundParts) >= 2) {
|
||
$multiReplies = [];
|
||
$multiActionIntents = [];
|
||
foreach ($compoundParts as $part) {
|
||
$part = trim($part);
|
||
if (!$part) continue;
|
||
$mMatch = KBEngine::match($part);
|
||
if ($mMatch && ($mMatch['action'] ?? '') === 'action') {
|
||
$multiActionIntents[] = ['match' => $mMatch, 'part' => $part];
|
||
}
|
||
}
|
||
|
||
if (count($multiActionIntents) >= 2) {
|
||
foreach ($multiActionIntents as $ma) {
|
||
$mIntent = $ma['match']['intent'];
|
||
$mPart = $ma['part'];
|
||
$mReply = null;
|
||
|
||
if ($mIntent === 'ha_scene') {
|
||
$haStates = @json_decode(@file_get_contents(HA_URL.'/api/states', false,
|
||
stream_context_create(['http'=>['timeout'=>4,'header'=>'Authorization: Bearer '.HA_TOKEN]])), true) ?? [];
|
||
$haScenes = array_filter($haStates, fn($s) => str_starts_with($s['entity_id']??'','scene.'));
|
||
$mLow = strtolower($mPart); $best=null; $bestS=0;
|
||
foreach ($haScenes as $s) {
|
||
$name=strtolower($s['attributes']['friendly_name']??'');
|
||
$id=strtolower(str_replace(['scene.','_'],['',''],$s['entity_id']));
|
||
$score=0; foreach(array_filter(explode(' ',"$name $id")) as $tok)
|
||
if(strlen($tok)>2&&str_contains($mLow,$tok))$score++;
|
||
if($name&&str_contains($mLow,$name))$score+=5;
|
||
if($score>$bestS){$bestS=$score;$best=$s;}
|
||
}
|
||
if ($best && $bestS >= 1) {
|
||
@file_get_contents(HA_URL.'/api/services/scene/turn_on', false,
|
||
stream_context_create(['http'=>['method'=>'POST','timeout'=>4,
|
||
'header'=>"Authorization: Bearer ".HA_TOKEN."
|
||
Content-Type: application/json",
|
||
'content'=>json_encode(['entity_id'=>$best['entity_id']])]]));
|
||
$mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated';
|
||
}
|
||
} elseif (in_array($mIntent, ['jellyfin_pause','jellyfin_stop','jellyfin_next','jellyfin_previous'])) {
|
||
$jCmdMap = ['jellyfin_pause'=>'TogglePause','jellyfin_stop'=>'Stop','jellyfin_next'=>'NextTrack','jellyfin_previous'=>'PreviousTrack'];
|
||
$jCmd = $jCmdMap[$mIntent];
|
||
$jSessions = @json_decode(@file_get_contents(JELLYFIN_URL.'/Sessions?api_key='.JELLYFIN_API_KEY,false,
|
||
stream_context_create(['http'=>['timeout'=>4]])),true)??[];
|
||
foreach ($jSessions as $js) { if(isset($js['NowPlayingItem'])){
|
||
@file_get_contents(JELLYFIN_URL.'/Sessions/'.rawurlencode($js['Id']).'/Command/'.$jCmd.'?api_key='.JELLYFIN_API_KEY,
|
||
false,stream_context_create(['http'=>['method'=>'POST','timeout'=>4,'content'=>'{}','header'=>'Content-Type: application/json']]));
|
||
$mReply = 'Jellyfin ' . strtolower(str_replace('Track','',$jCmd)); break;
|
||
}}
|
||
} elseif ($mIntent === 'focus_mode') { $uiAction = 'focus_mode'; $mReply = 'focus mode on'; }
|
||
elseif ($mIntent === 'show_panels') { $uiAction = 'show_panels'; $mReply = 'panels shown'; }
|
||
elseif ($mIntent === 'network_scan') {
|
||
$devCount = JarvisDB::query("SELECT COUNT(*) as c FROM network_devices WHERE last_seen > DATE_SUB(NOW(),INTERVAL 15 MINUTE)")[0]['c'] ?? 0;
|
||
$mReply = "network scan queued ({$devCount} devices online)";
|
||
}
|
||
|
||
if ($mReply) $multiReplies[] = $mReply;
|
||
}
|
||
|
||
if (count($multiReplies) >= 2) {
|
||
$reply = "Done, {$userAddr}. " . implode(', and ', $multiReplies) . '.';
|
||
$source = 'intent:multi_step';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
|
||
if (!$reply) {
|
||
$matched = KBEngine::match($message);
|
||
if ($matched && $matched['action'] === 'response') {
|
||
$reply = $matched['reply'];
|
||
$source = 'intent:' . $matched['intent'];
|
||
}
|
||
}
|
||
|
||
// ── Tier 1b: Action Intents — handled directly, no LLM ──────────────────
|
||
if (!$reply) {
|
||
if (!isset($matched)) $matched = KBEngine::match($message);
|
||
if ($matched && $matched['action'] === 'action') {
|
||
switch ($matched['intent']) {
|
||
|
||
case 'vm_suggestions': {
|
||
$rows = JarvisDB::query(
|
||
"SELECT a.hostname, a.agent_type,
|
||
ROUND(AVG(CAST(JSON_EXTRACT(m.metric_data,'$.cpu_percent') AS DECIMAL(5,1))),1) as avg_cpu,
|
||
ROUND(AVG(CAST(JSON_EXTRACT(m.metric_data,'$.memory.percent') AS DECIMAL(5,1))),1) as avg_mem,
|
||
ROUND(MAX(CAST(JSON_EXTRACT(m.metric_data,'$.memory.total_mb') AS UNSIGNED))/1024,1) as ram_gb,
|
||
COUNT(*) as samples
|
||
FROM agent_metrics m
|
||
JOIN registered_agents a ON a.agent_id = m.agent_id
|
||
WHERE m.recorded_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||
AND a.agent_type IN ('linux','proxmox')
|
||
GROUP BY a.hostname, a.agent_type
|
||
HAVING samples > 20
|
||
ORDER BY avg_mem DESC"
|
||
) ?? [];
|
||
|
||
$suggestions = [];
|
||
foreach ($rows as $r) {
|
||
$cpu = (float)($r['avg_cpu'] ?? 0);
|
||
$mem = (float)($r['avg_mem'] ?? 0);
|
||
$ram = (float)($r['ram_gb'] ?? 0);
|
||
$host = $r['hostname'];
|
||
if (!$ram) continue;
|
||
|
||
// High CPU
|
||
if ($cpu >= 80)
|
||
$suggestions[] = "{$host} is averaging {$cpu}% CPU — consider increasing vCPU allocation or investigating load.";
|
||
// Low CPU + reasonable RAM (suggest checking allocation)
|
||
elseif ($cpu < 5 && $ram >= 2)
|
||
$suggestions[] = "{$host} averages only {$cpu}% CPU — vCPU allocation may be generous.";
|
||
|
||
// High MEM
|
||
if ($mem >= 85)
|
||
$suggestions[] = "{$host} is averaging {$mem}% memory use ({$ram}GB allocated) — consider increasing RAM.";
|
||
// Low MEM
|
||
elseif ($mem < 25 && $ram >= 2)
|
||
$suggestions[] = "{$host} uses only {$mem}% of its {$ram}GB RAM on average — allocation could be reduced.";
|
||
}
|
||
|
||
if (!$suggestions) {
|
||
$reply = "All VM resources look well-balanced, {$userAddr}. No over or under-allocation detected across the past 24 hours.";
|
||
} else {
|
||
$reply = "Resource analysis for the past 24 hours, {$userAddr}: " . implode(' ', $suggestions);
|
||
}
|
||
$source = 'intent:vm_suggestions';
|
||
break;
|
||
}
|
||
|
||
case 'ha_scene': {
|
||
// Fetch all HA scenes and fuzzy-match against the message
|
||
$haScenes = @json_decode(@file_get_contents(
|
||
HA_URL . '/api/states',
|
||
false,
|
||
stream_context_create(['http' => ['timeout' => 5, 'header' => 'Authorization: Bearer ' . HA_TOKEN]])
|
||
), true) ?? [];
|
||
|
||
$scenes = array_filter($haScenes, fn($s) => str_starts_with($s['entity_id'] ?? '', 'scene.'));
|
||
$msgLow = strtolower($message);
|
||
|
||
// Score each scene against the message
|
||
$best = null; $bestScore = 0;
|
||
foreach ($scenes as $s) {
|
||
$name = strtolower($s['attributes']['friendly_name'] ?? '');
|
||
$id = strtolower(str_replace(['scene.', '_'], ['', ' '], $s['entity_id']));
|
||
// Token overlap score
|
||
$tokens = array_filter(explode(' ', "$name $id"));
|
||
$score = 0;
|
||
foreach ($tokens as $tok) {
|
||
if (strlen($tok) > 2 && str_contains($msgLow, $tok)) $score++;
|
||
}
|
||
// Bonus for exact phrase match
|
||
if ($name && str_contains($msgLow, $name)) $score += 5;
|
||
if ($score > $bestScore) { $bestScore = $score; $best = $s; }
|
||
}
|
||
|
||
if ($best && $bestScore >= 1) {
|
||
$sceneName = $best['attributes']['friendly_name'] ?? $best['entity_id'];
|
||
@file_get_contents(
|
||
HA_URL . '/api/services/scene/turn_on',
|
||
false,
|
||
stream_context_create(['http' => [
|
||
'method' => 'POST',
|
||
'header' => "Authorization: Bearer " . HA_TOKEN . "
|
||
Content-Type: application/json",
|
||
'content' => json_encode(['entity_id' => $best['entity_id']]),
|
||
'timeout' => 5,
|
||
]])
|
||
);
|
||
$reply = "Activating {$sceneName}, {$userAddr}.";
|
||
} else {
|
||
// List available scenes
|
||
$names = array_map(fn($s) => $s['attributes']['friendly_name'] ?? $s['entity_id'], $scenes);
|
||
$list = implode(', ', array_values($names));
|
||
$reply = "I didn't catch which scene, {$userAddr}. Available scenes: {$list}.";
|
||
}
|
||
$source = 'intent:ha_scene';
|
||
break;
|
||
}
|
||
|
||
case 'restart_agent':
|
||
// Extract target hostname from message
|
||
$msgLow = strtolower($message);
|
||
$agentMap = [
|
||
'homebridge' => 'homebridge_b57cbaea',
|
||
'jellyfin' => 'jellyfin_7e386833',
|
||
'networkbackup' => 'networkbackup_NetworkB',
|
||
'network backup'=> 'networkbackup_NetworkB',
|
||
'novacpx' => 'novacpx_e3b07264',
|
||
'nova' => 'novacpx_e3b07264',
|
||
'mediastack' => 'MediaStack_2c00b1b8',
|
||
'media stack' => 'MediaStack_2c00b1b8',
|
||
'homeassistant' => 'homeassistant_ha',
|
||
'home assistant'=> 'homeassistant_ha',
|
||
];
|
||
$targetAgentId = null;
|
||
$targetName = null;
|
||
foreach ($agentMap as $keyword => $agentId) {
|
||
if (str_contains($msgLow, $keyword)) {
|
||
$targetAgentId = $agentId;
|
||
$targetName = ucfirst($keyword);
|
||
break;
|
||
}
|
||
}
|
||
if ($targetAgentId) {
|
||
JarvisDB::insert(
|
||
"INSERT INTO agent_commands (agent_id, command_type, command_data, status, created_at)
|
||
VALUES (?, 'restart_service', ?, 'pending', NOW())",
|
||
[$targetAgentId, json_encode(['service' => 'jarvis-agent'])]
|
||
);
|
||
$reply = "Restart command sent to the {$targetName} agent, {$userAddr}. It should come back online within 15 seconds.";
|
||
} else {
|
||
// Fall back to listing restartable agents
|
||
$reply = "Which agent should I restart, {$userAddr}? I can restart: HomeAssistant, Homebridge, Jellyfin, MediaStack, NetworkBackup, or NovaCPX.";
|
||
}
|
||
$source = 'intent:restart_agent';
|
||
break;
|
||
|
||
case 'focus_mode':
|
||
$uiAction = 'focus_mode';
|
||
$reply = "Focus mode activated, {$userAddr}. Side panels hidden.";
|
||
$source = 'intent:focus_mode';
|
||
break;
|
||
|
||
case 'show_panels':
|
||
$uiAction = 'show_panels';
|
||
$reply = "Full view restored, {$userAddr}. All panels visible.";
|
||
$source = 'intent:show_panels';
|
||
break;
|
||
|
||
case 'network_scan':
|
||
$online = JarvisDB::single(
|
||
"SELECT COUNT(*) cnt FROM network_devices WHERE status='online' AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)"
|
||
);
|
||
$total = JarvisDB::single(
|
||
"SELECT COUNT(*) cnt FROM network_devices WHERE last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)"
|
||
);
|
||
// Queue netscan to PVE1 agent for immediate refresh
|
||
$pve1 = JarvisDB::single(
|
||
"SELECT agent_id FROM registered_agents WHERE ip_address='10.48.200.90' AND status='online' LIMIT 1"
|
||
);
|
||
if ($pve1) {
|
||
JarvisDB::execute(
|
||
"INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)",
|
||
[$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending']
|
||
);
|
||
}
|
||
$o = (int)($online['cnt'] ?? 0);
|
||
$t = (int)($total['cnt'] ?? 0);
|
||
$reply = "Network status as of last scan: {$o} of {$t} devices online on 10.48.200.0/24, {$userAddr}. " .
|
||
($pve1 ? 'Fresh scan dispatched to PVE1 — the network panel will update in approximately 40 seconds.' :
|
||
'Automatic scan via PVE1 runs every 3 minutes.');
|
||
$source = 'intent:network_scan';
|
||
break;
|
||
|
||
case 'jellyfin_pause':
|
||
case 'jellyfin_stop':
|
||
case 'jellyfin_next':
|
||
case 'jellyfin_previous': {
|
||
$intentCmd = [
|
||
'jellyfin_pause' => 'TogglePause',
|
||
'jellyfin_stop' => 'Stop',
|
||
'jellyfin_next' => 'NextTrack',
|
||
'jellyfin_previous' => 'PreviousTrack',
|
||
][$matched['intent']];
|
||
// Get first active session
|
||
$jfSessions = @json_decode(@file_get_contents(
|
||
JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false,
|
||
stream_context_create(['http' => ['timeout' => 4]])
|
||
), true) ?? [];
|
||
$activeSession = null;
|
||
foreach ($jfSessions as $s) {
|
||
if (isset($s['NowPlayingItem'])) { $activeSession = $s; break; }
|
||
}
|
||
if (!$activeSession) {
|
||
$reply = "Nothing is currently playing on Jellyfin, {$userAddr}.";
|
||
} else {
|
||
$sid = $activeSession['Id'];
|
||
$url = JELLYFIN_URL . '/Sessions/' . rawurlencode($sid) . '/Command/' . $intentCmd
|
||
. '?api_key=' . JELLYFIN_API_KEY;
|
||
$ctx = stream_context_create(['http' => ['method' => 'POST', 'timeout' => 5,
|
||
'header' => 'Content-Type: application/json', 'content' => '{}', 'ignore_errors' => true]]);
|
||
@file_get_contents($url, false, $ctx);
|
||
$titles = [
|
||
'TogglePause' => 'Playback toggled, {$userAddr}.',
|
||
'Stop' => 'Playback stopped, {$userAddr}.',
|
||
'NextTrack' => 'Skipping to next track, {$userAddr}.',
|
||
'PreviousTrack' => 'Going back to previous, {$userAddr}.',
|
||
];
|
||
$reply = strtr($titles[$intentCmd], ['{$userAddr}' => $userAddr]);
|
||
}
|
||
$source = 'intent:jellyfin_control';
|
||
break;
|
||
}
|
||
|
||
case 'jellyfin_now_playing':
|
||
$jSessions = @json_decode(@file_get_contents(
|
||
JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false,
|
||
stream_context_create(['http' => ['timeout' => 4]])
|
||
), true) ?? [];
|
||
$active = array_filter($jSessions, fn($s) => isset($s['NowPlayingItem']));
|
||
if (!$active) {
|
||
$reply = "Nothing is currently playing on Jellyfin, {$userAddr}.";
|
||
} else {
|
||
$lines = [];
|
||
foreach ($active as $s) {
|
||
$np = $s['NowPlayingItem'];
|
||
$title = $np['SeriesName'] ? $np['SeriesName'] . ' — ' . $np['Name'] : $np['Name'];
|
||
$paused = $s['PlayState']['IsPaused'] ? ' (paused)' : '';
|
||
$lines[] = "{$s['UserName']} is watching {$title}{$paused} on {$s['DeviceName']}";
|
||
}
|
||
$reply = implode('. ', $lines) . '.';
|
||
}
|
||
$source = 'intent:jellyfin';
|
||
break;
|
||
|
||
case 'jellyfin_library':
|
||
$jMovies = @json_decode(@file_get_contents(JELLYFIN_URL . '/Items?IncludeItemTypes=Movie&Recursive=true&Limit=0&api_key=' . JELLYFIN_API_KEY, false, stream_context_create(['http' => ['timeout' => 4]])), true);
|
||
$jSeries = @json_decode(@file_get_contents(JELLYFIN_URL . '/Items?IncludeItemTypes=Series&Recursive=true&Limit=0&api_key=' . JELLYFIN_API_KEY, false, stream_context_create(['http' => ['timeout' => 4]])), true);
|
||
$movies = $jMovies['TotalRecordCount'] ?? 0;
|
||
$series = $jSeries['TotalRecordCount'] ?? 0;
|
||
$reply = "Jellyfin library: {$movies} movies and {$series} TV series, {$userAddr}.";
|
||
$source = 'intent:jellyfin';
|
||
break;
|
||
|
||
case 'alerts_show':
|
||
$activeAlerts = JarvisDB::query(
|
||
"SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10"
|
||
);
|
||
if (!$activeAlerts) {
|
||
$reply = "No active alerts, {$userAddr}. All systems appear nominal.";
|
||
} else {
|
||
$lines = array_map(fn($a) => "[{$a['severity']}] {$a['title']}: {$a['message']}", $activeAlerts);
|
||
$reply = count($activeAlerts) . " active alert" . (count($activeAlerts)>1?'s':'') . ", {$userAddr}: " . implode('; ', $lines) . '.';
|
||
}
|
||
$source = 'intent:alerts_show';
|
||
break;
|
||
|
||
case 'alerts_count':
|
||
$alertCount = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0");
|
||
$cnt = (int)($alertCount['cnt'] ?? 0);
|
||
$reply = $cnt > 0
|
||
? "There are currently {$cnt} unresolved alert" . ($cnt>1?'s':'') . ", {$userAddr}. Say 'show alerts' for details."
|
||
: "No active alerts at this time, {$userAddr}. All systems nominal.";
|
||
$source = 'intent:alerts_count';
|
||
break;
|
||
|
||
case 'alerts_clear':
|
||
$cleared = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0");
|
||
JarvisDB::execute("UPDATE alerts SET resolved=1 WHERE resolved=0");
|
||
$cnt = (int)($cleared['cnt'] ?? 0);
|
||
$reply = "Resolved {$cnt} alert" . ($cnt!==1?'s':'') . ", {$userAddr}. Alert panel cleared.";
|
||
$source = 'intent:alerts_clear';
|
||
break;
|
||
|
||
case 'agents_offline':
|
||
$offline = JarvisDB::query(
|
||
"SELECT hostname, ip_address, agent_type FROM registered_agents WHERE status='offline' ORDER BY last_seen DESC LIMIT 10"
|
||
);
|
||
if (!$offline) {
|
||
$reply = "All registered agents are currently online, {$userAddr}.";
|
||
} else {
|
||
$names = array_map(fn($a) => $a['hostname'] . ' (' . $a['ip_address'] . ')', $offline);
|
||
$reply = count($offline) . " agent" . (count($offline)>1?'s are':' is') . " offline, {$userAddr}: " . implode(', ', $names) . '.';
|
||
}
|
||
$source = 'intent:agents_offline';
|
||
break;
|
||
|
||
case 'agents_all':
|
||
$allAgents = JarvisDB::query(
|
||
"SELECT hostname, ip_address, status, agent_type FROM registered_agents ORDER BY FIELD(status,'online','offline','unknown'), hostname ASC"
|
||
);
|
||
if (!$allAgents) {
|
||
$reply = "No registered agents found, {$userAddr}.";
|
||
} else {
|
||
$onlineList = array_filter($allAgents, fn($a) => $a['status'] === 'online');
|
||
$offlineList = array_filter($allAgents, fn($a) => $a['status'] !== 'online');
|
||
$reply = count($allAgents) . " registered agents — " . count($onlineList) . " online, " . count($offlineList) . " offline, {$userAddr}.";
|
||
if ($onlineList) $reply .= ' Online: ' . implode(', ', array_map(fn($a) => $a['hostname'], $onlineList)) . '.';
|
||
if ($offlineList) $reply .= ' Offline: ' . implode(', ', array_map(fn($a) => $a['hostname'], $offlineList)) . '.';
|
||
}
|
||
$source = 'intent:agents_all';
|
||
break;
|
||
|
||
case 'agents_count':
|
||
$agentStats = JarvisDB::single(
|
||
"SELECT COUNT(*) total, SUM(status='online') online FROM registered_agents"
|
||
);
|
||
$t = (int)($agentStats['total'] ?? 0);
|
||
$o = (int)($agentStats['online'] ?? 0);
|
||
$reply = "{$t} agents registered — {$o} online, " . ($t-$o) . " offline, {$userAddr}.";
|
||
$source = 'intent:agents_count';
|
||
break;
|
||
|
||
case 'deploy_status':
|
||
$deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log';
|
||
if (file_exists($deployLog)) {
|
||
$lines = array_filter(array_map('trim', array_slice(file($deployLog), -20)));
|
||
$recent = array_slice(array_values($lines), -5);
|
||
$last = end($recent);
|
||
$reply = "Last deploy entry, {$userAddr}: " . htmlspecialchars_decode(strip_tags($last)) . '. Say "deploy log" to see the full recent history.';
|
||
} else {
|
||
$reply = "Deploy log not found, {$userAddr}. Check /home/jarvis.orbishosting.com/logs/deploy.log on the server.";
|
||
}
|
||
$source = 'intent:deploy_status';
|
||
break;
|
||
|
||
case 'deploy_force':
|
||
$reply = "Manual deploy is triggered by pushing to the GitHub main branch, {$userAddr}. The webhook at jarvis.orbishosting.com/webhook.php handles it automatically within 60 seconds. To hot-fix without a push, SCP the file directly to the server.";
|
||
$source = 'intent:deploy_force';
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ── Memory injection — fetch relevant facts before LLM tiers ─────────────
|
||
$memoryContext = '';
|
||
if (!$reply) {
|
||
$memoryContext = getMemoryContext($message, 12);
|
||
}
|
||
|
||
// ── Tier 2: Ollama local LLM (fast local fallback) ───────────────────────
|
||
if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) {
|
||
$ollamaHost = OLLAMA_HOST;
|
||
$ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b';
|
||
$timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45;
|
||
|
||
$ollamaMessages = [];
|
||
$ollamaMessages[] = ['role' => 'system', 'content' =>
|
||
"You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " .
|
||
'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.'];
|
||
$ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
|
||
|
||
$promptParts = [];
|
||
foreach ($ollamaMessages as $msg) {
|
||
$role = ucfirst($msg['role']);
|
||
$promptParts[] = "{$role}: {$msg['content']}";
|
||
}
|
||
$promptParts[] = 'Assistant:';
|
||
$promptStr = implode("\n\n", $promptParts);
|
||
|
||
$payload = [
|
||
'model' => $ollamaModel,
|
||
'prompt' => $promptStr,
|
||
'stream' => false,
|
||
'options' => ['temperature' => 0.7, 'num_predict' => 150],
|
||
];
|
||
|
||
$ch = curl_init($ollamaHost . '/api/generate');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_CONNECTTIMEOUT => 5,
|
||
]);
|
||
|
||
$resp = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code === 200 && $resp) {
|
||
$decoded = json_decode($resp, true);
|
||
$text = $decoded['response'] ?? null;
|
||
if ($text) {
|
||
$reply = trim($text);
|
||
$source = 'ollama:' . $ollamaModel;
|
||
}
|
||
}
|
||
// Silently fall through to Groq if Ollama fails or times out
|
||
}
|
||
|
||
// ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ─────────────
|
||
if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
|
||
$needsSearch = (bool) preg_match(
|
||
'/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i',
|
||
$message
|
||
);
|
||
$groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL;
|
||
|
||
$memSuffix = $memoryContext ? "\n\n{$memoryContext}" : '';
|
||
$groqMessages = [['role' => 'system', 'content' =>
|
||
"You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " .
|
||
"(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " .
|
||
'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') .
|
||
$memSuffix . '.'],
|
||
];
|
||
foreach (array_slice($history, -6) as $h) {
|
||
$groqMessages[] = ['role' => $h['role'], 'content' => $h['content']];
|
||
}
|
||
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
|
||
$groqMessages[] = ['role' => 'user', 'content' => $userMsg];
|
||
|
||
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode([
|
||
'model' => $groqModel,
|
||
'messages' => $groqMessages,
|
||
'max_tokens' => 400,
|
||
'temperature' => 0.7,
|
||
]),
|
||
CURLOPT_HTTPHEADER => [
|
||
'Authorization: Bearer ' . GROQ_API_KEY,
|
||
'Content-Type: application/json',
|
||
],
|
||
CURLOPT_TIMEOUT => GROQ_TIMEOUT,
|
||
CURLOPT_CONNECTTIMEOUT => 5,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
]);
|
||
|
||
$resp = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code === 200 && $resp) {
|
||
$decoded = json_decode($resp, true);
|
||
$text = $decoded['choices'][0]['message']['content'] ?? null;
|
||
if ($text) {
|
||
$reply = trim($text);
|
||
$source = 'groq:' . $groqModel;
|
||
}
|
||
}
|
||
// Silently fall through to Claude if Groq fails
|
||
}
|
||
|
||
// ── Tier 4: Claude API (final fallback) ──────────────────────────────────
|
||
if (!$reply) {
|
||
// Live context for Claude
|
||
$systemContext = '';
|
||
try {
|
||
$memLines = file('/proc/meminfo');
|
||
$mem = [];
|
||
foreach ($memLines as $l) {
|
||
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
|
||
}
|
||
$memPct = $mem['MemTotal'] > 0
|
||
? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100)
|
||
: '?';
|
||
$sec = (int) file_get_contents('/proc/uptime');
|
||
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
|
||
$load = explode(' ', file_get_contents('/proc/loadavg'));
|
||
$systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
|
||
} catch (Exception $e) {}
|
||
|
||
$alerts = JarvisDB::query(
|
||
'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3'
|
||
);
|
||
if ($alerts) {
|
||
$systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n";
|
||
}
|
||
|
||
$kbContext = KBEngine::getContextSummary();
|
||
|
||
$systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision.
|
||
|
||
Infrastructure:
|
||
- Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04)
|
||
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
|
||
- Proxmox Host: 10.48.200.90 (manages all VMs)
|
||
- Home Assistant: 10.48.200.97:8123
|
||
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
|
||
- Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
|
||
- Network: 10.48.200.0/24, FortiGate firewall
|
||
|
||
Live data:
|
||
{$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') .
|
||
($memoryContext ? "\n\n{$memoryContext}" : '') . "
|
||
Today: " . date('l, F j Y, g:i A T') . "
|
||
|
||
Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers.";
|
||
|
||
$messages = [];
|
||
foreach ($history as $h) {
|
||
$messages[] = ['role' => $h['role'], 'content' => $h['content']];
|
||
}
|
||
$messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
|
||
|
||
if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') {
|
||
$reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands.";
|
||
$source = 'fallback:no-key';
|
||
} else {
|
||
$payload = [
|
||
'model' => CLAUDE_MODEL,
|
||
'max_tokens' => CLAUDE_MAX_TOKENS,
|
||
'system' => $systemPrompt,
|
||
'messages' => $messages,
|
||
];
|
||
|
||
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||
CURLOPT_HTTPHEADER => [
|
||
'x-api-key: ' . CLAUDE_API_KEY,
|
||
'anthropic-version: 2023-06-01',
|
||
'Content-Type: application/json',
|
||
],
|
||
CURLOPT_TIMEOUT => 30,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
]);
|
||
|
||
$resp = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($code === 200) {
|
||
$decoded = json_decode($resp, true);
|
||
$reply = $decoded['content'][0]['text'] ?? null;
|
||
$source = 'claude';
|
||
} else {
|
||
$err = json_decode($resp, true);
|
||
$errMsg = $err['error']['message'] ?? '';
|
||
if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) {
|
||
$reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational.";
|
||
} else {
|
||
$reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg;
|
||
}
|
||
$source = 'claude:error';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Final fallback ─────────────────────────────────────────────────────────
|
||
if (!$reply) {
|
||
$reply = "My systems are processing your request, {$userAddr}. Please try again momentarily.";
|
||
$source = 'fallback';
|
||
}
|
||
|
||
// Save reply and learn
|
||
JarvisDB::insert(
|
||
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
|
||
[$sessionId, 'assistant', $reply]
|
||
);
|
||
KBEngine::learnFromConversation($message, $reply);
|
||
|
||
// Track usage pattern for action intents
|
||
if ($source && str_starts_with($source, 'intent:')) {
|
||
$intentKey = str_replace('intent:', '', $source);
|
||
JarvisDB::query(
|
||
"INSERT INTO usage_patterns (intent_name, hour, dow, hit_count)
|
||
VALUES (?, HOUR(NOW()), DAYOFWEEK(NOW())-1, 1)
|
||
ON DUPLICATE KEY UPDATE hit_count=hit_count+1, last_seen=NOW()",
|
||
[$intentKey]
|
||
);
|
||
}
|
||
|
||
// Memory Core — async extraction for LLM responses (don't extract from intent/KB/fallback)
|
||
if ($reply && !in_array(explode(':', $source)[0], ['intent', 'kb', 'fallback', 'memory', 'arc'])) {
|
||
memoryExtractAsync($message, $reply, $sessionId);
|
||
}
|
||
|
||
$uiAction = $uiAction ?? null;
|
||
echo json_encode([
|
||
'reply' => $reply,
|
||
'source' => $source,
|
||
'session_id' => $sessionId,
|
||
'timestamp' => date('c'),
|
||
'arc_job' => $arcJobId,
|
||
'open_network_map' => ($source === 'intent:network_scan'),
|
||
'ui_action' => $uiAction,
|
||
]);
|