Files
jarvis/api/endpoints/chat.php
T
myron 9f92e4d5e4 Add JARVIS improvements: mobile UI, sparklines, suggestions, multi-step commands, Arc Reactor health, tier badges
- 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>
2026-06-17 02:49:05 +00:00

2306 lines
120 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
]);