diff --git a/public_html/assets/js/jarvis-protocols.js b/public_html/assets/js/jarvis-protocols.js
deleted file mode 100644
index 0ab625e..0000000
--- a/public_html/assets/js/jarvis-protocols.js
+++ /dev/null
@@ -1,1668 +0,0 @@
-// ── ARC REACTOR STATUS ────────────────────────────────────────────────
-let _arcOnline = false;
-let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
-
-async function checkArcStatus() {
- const dot = document.getElementById('bb-arc-dot');
- const sta = document.getElementById('bb-arc-status');
- if (!dot || !sta) return;
- try {
- const d = await api('arc?action=status');
- if (d && d.online) {
- _arcOnline = true;
- dot.className = 'bb-dot online';
- const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
- sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
- _arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
- done: d.jobs_done||0, failed: d.jobs_failed||0 };
- } else {
- _arcOnline = false;
- dot.className = 'bb-dot offline';
- sta.textContent = 'OFFLINE';
- }
- } catch(e) {
- _arcOnline = false;
- dot.className = 'bb-dot offline';
- sta.textContent = 'OFFLINE';
- }
-}
-
-// Submit a job to the Arc Reactor and return job_id
-async function arcSubmitJob(type, payload, priority) {
- payload = payload || {};
- priority = priority || 5;
- const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
- return d.job_id || null;
-}
-
-// Poll a job until done or failed (max 120s), calling onProgress each tick
-async function arcWaitJob(jobId, onProgress) {
- var start = Date.now();
- while (Date.now() - start < 120000) {
- const d = await api('arc?action=job_get&id=' + jobId);
- if (onProgress) onProgress(d);
- if (d.status === 'done') return d;
- if (d.status === 'failed') throw new Error(d.error || 'Job failed');
- await new Promise(function(r){ setTimeout(r, 1500); });
- }
- throw new Error('Arc Reactor job timed out');
-}
-
-
-// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
-let _intelPollTimer = null;
-let _intelActiveJobs = new Set();
-let _intelLastLoad = 0;
-
-async function loadIntel() {
- const el = document.getElementById('intel-list');
- if (!el) return;
- _intelLastLoad = Date.now();
-
- try {
- // Fetch recent research + tool_loop jobs
- const [resJobs, toolJobs] = await Promise.all([
- api('arc?action=jobs&status=&limit=20').catch(() => []),
- Promise.resolve([]),
- ]);
- const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
-
- if (!jobs.length) {
- el.innerHTML = '
✓ You\'re viewing JARVIS on a tablet or mobile device.
' +
- 'The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).
' +
- 'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.
';
- } else if (_agentOnline) {
- title.textContent = '● AGENT CONNECTED';
- content.innerHTML =
- '✓ JARVIS Agent is active on this machine.
' +
- 'After install, the AGENT indicator turns green within 30 seconds.
';
- }
- modal.classList.add('open');
-}
-
-document.addEventListener('click', function(e) {
- if (e.target === document.getElementById('agentModal'))
- document.getElementById('agentModal').classList.remove('open');
-});
-
-
-
-
-// ── SITES MANAGER ────────────────────────────────────────────────────
-let sitesData = {};
-
-function openSitesModal() {
- document.getElementById('sitesModal').style.display = 'flex';
- loadSites();
-}
-function closeSitesModal() {
- document.getElementById('sitesModal').style.display = 'none';
-}
-// Close on backdrop click
-document.getElementById('sitesModal').addEventListener('click', function(e) {
- if (e.target === this) closeSitesModal();
-});
-
-async function loadSites() {
- document.getElementById('sites-grid').innerHTML = 'LOADING SITE SETTINGS...
';
- const res = await api('sites');
- if (!res.success) {
- document.getElementById('sites-grid').innerHTML = 'Searching...
';
- try {
- const d = await api('history?q=' + encodeURIComponent(q));
- if (!d.results || !d.results.length) {
- el.innerHTML = '
-
- ${role} ${r.role.toUpperCase()}
- ${ts}
-
-
${snippet.replace(/
-
`;
- }).join('');
- } catch(e) {
- el.innerHTML = '
Search failed
';
- }
-}
-document.getElementById('searchModal')?.addEventListener('click', e => {
- if (e.target === document.getElementById('searchModal')) closeSearchModal();
-});
-
-// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
-const _shownSuggestions = new Set();
-async function checkSuggestions() {
- const d = await api('suggestions').catch(() => null);
- if (!d || !d.suggestions || !d.suggestions.length) return;
- for (const s of d.suggestions) {
- const key = s.intent + ':' + d.hour + ':' + d.dow;
- if (_shownSuggestions.has(key)) continue;
- _shownSuggestions.add(key);
- // Show as a soft suggestion chip in chat
- const log = document.getElementById('chatLog');
- const chip = document.createElement('div');
- chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
- chip.innerHTML = `
`;
- log.appendChild(chip);
- log.scrollTop = log.scrollHeight;
- break; // show max one suggestion at a time
- }
-}
-
-function sendSuggestion(intent, btn) {
- btn.closest('div').remove();
- const prompts = {
- 'network_scan': 'run a network scan',
- 'jellyfin_now_playing': 'what is playing on Jellyfin',
- 'ha_scene': 'what scenes are available',
- 'planner:briefing': 'daily briefing',
- 'vm_suggestions': 'VM resource suggestions',
- 'focus_mode': 'focus mode',
- };
- const msg = prompts[intent] || intent.replace(/_/g,' ');
- document.getElementById('textInput').value = msg;
- sendMessage();
-}
-
-// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
-function mobSwitch(which) {
- if (window.innerWidth > 900) return;
- const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
- Object.entries(panels).forEach(([k, id]) => {
- document.getElementById(id)?.classList.toggle('mob-active', k === which);
- });
- document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
- document.getElementById('mob-btn-' + which)?.classList.add('active');
- if (which === 'right') loadNews();
-}
-function initMobile() {
- if (window.innerWidth > 900) return;
- ['leftPanel','centerPanel','rightPanel'].forEach(id =>
- document.getElementById(id)?.classList.remove('mob-active'));
- document.getElementById('leftPanel')?.classList.add('mob-active');
- document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
- document.getElementById('mob-btn-left')?.classList.add('active');
-}
-window.addEventListener('resize', initMobile);
-
-// ── COMMAND PALETTE (Ctrl+K) ──────────────────────────────────────────────
-const _PALETTE_COMMANDS = [
- { label: 'Run a network scan', q: 'run a network scan', group: 'Network' },
- { label: 'Show online devices', q: 'who is online on the network', group: 'Network' },
- { label: 'Proxmox status', q: 'proxmox status', group: 'Network' },
- { label: 'Check agent status', q: 'check all agents', group: 'Agents' },
- { label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' },
- { label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' },
- { label: 'Daily briefing', q: 'daily briefing', group: 'Planner' },
- { label: 'My tasks today', q: 'my tasks today', group: 'Planner' },
- { label: 'My calendar', q: 'my calendar', group: 'Planner' },
- { label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' },
- { label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' },
- { label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' },
- { label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' },
- { label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'},
- { label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'},
- { label: 'Focus mode', q: 'focus mode', group: 'UI' },
- { label: 'Show all panels', q: 'show all panels', group: 'UI' },
- { label: 'Check alerts', q: 'check alerts', group: 'System' },
- { label: 'Site health', q: 'site health', group: 'System' },
- { label: 'System status', q: 'system status', group: 'System' },
- { label: 'Check inbox', q: 'check inbox', group: 'Comms' },
- { label: 'Search history…', q: '', group: 'Chat', search: true },
-];
-
-let _paletteOpen = false;
-
-function openPalette() {
- if (_paletteOpen) return;
- _paletteOpen = true;
- const ov = document.getElementById('cmdPalette');
- if (!ov) return;
- ov.style.display = 'flex';
- const inp = document.getElementById('cmdPaletteInput');
- inp.value = '';
- renderPaletteItems('');
- requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); });
-}
-
-function closePalette() {
- if (!_paletteOpen) return;
- _paletteOpen = false;
- const ov = document.getElementById('cmdPalette');
- if (!ov) return;
- ov.classList.remove('open');
- setTimeout(() => { ov.style.display = 'none'; }, 180);
-}
-
-function renderPaletteItems(q) {
- const list = document.getElementById('cmdPaletteList');
- if (!list) return;
- const low = q.toLowerCase().trim();
- const filtered = low
- ? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low))
- : _PALETTE_COMMANDS;
-
- let currentGroup = null;
- list.innerHTML = '';
- filtered.forEach((cmd, i) => {
- if (cmd.group !== currentGroup) {
- currentGroup = cmd.group;
- const g = document.createElement('div');
- g.className = 'cp-group';
- g.textContent = cmd.group;
- list.appendChild(g);
- }
- const row = document.createElement('div');
- row.className = 'cp-item' + (i === 0 ? ' cp-active' : '');
- row.dataset.q = cmd.q;
- row.dataset.search = cmd.search ? '1' : '';
- const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'),
- m => `
${m}`);
- row.innerHTML = `
◈${lbl}↵`;
- row.addEventListener('click', () => firePaletteItem(row));
- list.appendChild(row);
- });
-}
-
-function movePaletteSelection(dir) {
- const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item'));
- if (!items.length) return;
- const cur = items.findIndex(el => el.classList.contains('cp-active'));
- const next = (cur + dir + items.length) % items.length;
- items.forEach(el => el.classList.remove('cp-active'));
- items[next].classList.add('cp-active');
- items[next].scrollIntoView({ block: 'nearest' });
-}
-
-function firePaletteItem(el) {
- if (!el) {
- const active = document.querySelector('#cmdPaletteList .cp-active');
- if (!active) return;
- el = active;
- }
- const q = el.dataset.q;
- const isSearch = el.dataset.search === '1';
- closePalette();
- if (isSearch) {
- if (typeof openSearchModal === 'function') openSearchModal();
- return;
- }
- if (q) {
- document.getElementById('textInput').value = q;
- sendMessage();
- }
-}
-
-// Keyboard events
-document.addEventListener('keydown', e => {
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
- e.preventDefault();
- _paletteOpen ? closePalette() : openPalette();
- return;
- }
- if (!_paletteOpen) return;
- if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
- if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); }
- if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); }
- if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); }
-});
-
-// Filter on type
-document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
- renderPaletteItems(e.target.value);
-});
-
-// Close on backdrop click
-document.getElementById('cmdPalette')?.addEventListener('click', e => {
- if (e.target.id === 'cmdPalette') closePalette();
-});
-
-// ── AGENT TOPOLOGY MAP ─────────────────────────────────────────────────────────────
-let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = [];
-
-function toggleAgentTopo() {
- _agentTopoMode = !_agentTopoMode;
- const btn = document.getElementById('agent-topo-btn');
- const list = document.getElementById('agents-list');
- const cvs = document.getElementById('agentTopoCanvas');
- if (!btn || !list || !cvs) return;
- btn.classList.toggle('active', _agentTopoMode);
- if (_agentTopoMode) {
- list.style.display = 'none'; cvs.style.display = 'block';
- _buildAgentTopoData(); _drawAgentTopo();
- } else {
- list.style.display = 'block'; cvs.style.display = 'none';
- if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; }
- }
-}
-
-function _buildAgentTopoData() {
- // Build node list from rendered agent cards
- _agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}];
- document.querySelectorAll('.agent-card').forEach(el => {
- const nameEl = el.querySelector('.agent-name, [class*="name"]');
- if (!nameEl) return;
- const name = nameEl.textContent.trim();
- const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online');
- const lname = name.toLowerCase();
- let type = 'linux';
- if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox';
- else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant';
- else if (lname.includes('windows') || lname.includes('mini')) type = 'windows';
- _agentTopoData.push({id:name, label:name.substring(0,12), online, type});
- });
- // Fallback: use last known registered agent list if cards not rendered
- if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') {
- (_lastAgents || []).forEach(a => {
- _agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'});
- });
- }
-}
-
-function _drawAgentTopo() {
- const cvs = document.getElementById('agentTopoCanvas');
- if (!cvs || !_agentTopoMode) return;
- const ctx = cvs.getContext('2d');
- const rect = cvs.getBoundingClientRect();
- const W = rect.width || 280, H = rect.height || 260;
- const dpr = window.devicePixelRatio || 1;
- cvs.width = W * dpr; cvs.height = H * dpr;
- ctx.scale(dpr, dpr);
- const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68};
- const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'};
- // Assign positions
- const byType = {};
- _agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); });
- _agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2;
- Object.entries(byType).forEach(([tp, nodes]) => {
- const rf = typeRing[tp] || 0.68;
- const r = Math.min(W, H) / 2 * rf;
- nodes.forEach((n, i) => {
- const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2;
- n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r;
- });
- });
- let t = 0;
- function frame() {
- if (!_agentTopoMode) return;
- t += 0.007; ctx.clearRect(0, 0, W, H);
- // Orbit rings
- [0.28, 0.48, 0.68].forEach(rf => {
- ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2);
- ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke();
- });
- // Edges
- _agentTopoData.slice(1).forEach(n => {
- if (!n.x) return;
- const col = typeColor[n.type] || '0,190,255';
- ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y);
- ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)';
- ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke();
- });
- // Particles
- _agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => {
- const p = ((t*0.35+i*0.41)%1);
- const col = typeColor[n.type]||'0,190,255';
- const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p;
- ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2);
- ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill();
- });
- // Nodes
- _agentTopoData.forEach((n,i) => {
- if (!n.x) return;
- const col = typeColor[n.type]||'0,190,255';
- const nr = n.type==='hub' ? 13 : 7;
- const pulse = Math.sin(t+i*0.9)*0.25+0.75;
- if (n.online||n.type==='hub') {
- const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5);
- g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')');
- g.addColorStop(1,'transparent');
- ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2);
- ctx.fillStyle=g; ctx.fill();
- }
- ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2);
- ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)';
- ctx.fill();
- ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke();
- ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)';
- ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace';
- ctx.textAlign='center';
- ctx.fillText(n.label, n.x, n.y+nr+9);
- });
- _agentTopoRaf = requestAnimationFrame(frame);
- }
- frame();
-}
diff --git a/public_html/assets/js/panels/jarvis-agents.js b/public_html/assets/js/panels/jarvis-agents.js
new file mode 100644
index 0000000..f0bacd7
--- /dev/null
+++ b/public_html/assets/js/panels/jarvis-agents.js
@@ -0,0 +1,715 @@
+// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
+let _missionsOpenCards = new Set();
+
+async function loadMissionsHud() {
+ const el = document.getElementById('missions-hud');
+ if (!el) return;
+ try {
+ const missions = await api('arc?action=missions');
+ const list = Array.isArray(missions) ? missions : [];
+
+ let html = '
';
+
+ if (!list.length) {
+ html += '
◈ NO MISSIONS
Create workflows in Admin → Mission Ops
';
+ el.innerHTML = html;
+ return;
+ }
+
+ const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
+ for (const m of list) {
+ const isOpen = _missionsOpenCards.has(m.id);
+ const icon = trigIcons[m.trigger_type] || '◈';
+ const enabled = m.enabled;
+ const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
+ html += `
+
+ ${icon}
+ ${escHtml(m.name)}
+ ${m.trigger_type.replace('_',' ').toUpperCase()}
+ ${m.run_count||0} runs
+
+
+ ${m.description ? `
${escHtml(m.description)}
` : ''}
+
Last run: ${lastRun} · ${m.run_count||0} total runs
+
+
+
+
+
+
`;
+ }
+ el.innerHTML = html;
+ } catch(e) {
+ if (el) el.innerHTML = '
MISSIONS OFFLINE
';
+ }
+}
+
+function toggleMissionCard(id) {
+ const card = document.getElementById('mission-card-' + id);
+ if (!card) return;
+ if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
+ else _missionsOpenCards.add(id);
+ card.classList.toggle('open');
+}
+
+async function hudRunMission(id) {
+ const btn = document.getElementById('mission-run-btn-' + id);
+ const res = document.getElementById('mission-run-result-' + id);
+ if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
+ if (res) res.textContent = '';
+ try {
+ const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
+ const s = data.status || 'done';
+ const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
+ if (res) res.style.color = color;
+ if (res) res.textContent = `◈ ${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
+ if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
+ setTimeout(loadMissionsHud, 2000);
+ } catch(e) {
+ if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
+ if (res) res.textContent = '✗ Run failed';
+ }
+}
+
+// ── DIRECTIVES HUD ────────────────────────────────────────────────────────────
+let _dirOpenCards = new Set();
+
+async function loadDirectivesHud() {
+ const el = document.getElementById('directives-hud');
+ if (!el) return;
+ try {
+ const d = await api('directives/list?status=active');
+ const list = (d.directives || []);
+
+ let html = '
';
+
+ if (!list.length) {
+ html += '
◈ NO ACTIVE DIRECTIVES
Create objectives in Admin → Directives
';
+ el.innerHTML = html;
+ return;
+ }
+
+ const catColors = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--panel-border)',other:'var(--text-dim)'};
+ for (const dir of list) {
+ const pct = Math.min(100, Math.round(dir.progress || 0));
+ const isOpen = _dirOpenCards.has(dir.id);
+ const color = catColors[dir.category] || 'var(--cyan)';
+ const fillColor = pct >= 80 ? '#00ff88' : pct >= 40 ? '#ffd700' : '#ff6644';
+ const daysLeft = dir.target_date
+ ? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000) : null;
+ const dueTxt = daysLeft !== null
+ ? (daysLeft < 0 ? `OVERDUE ${Math.abs(daysLeft)}d` : `${daysLeft}d left`)
+ : '';
+ const dueColor = daysLeft !== null && daysLeft < 0 ? '#ff2244' : daysLeft < 14 ? '#ffd700' : 'var(--text-dim)';
+
+ html += `
+
+ ${dir.category.toUpperCase()}
+ ${escHtml(dir.title)}
+ ${pct}%
+ ${dueTxt ? `${dueTxt}` : ''}
+
+
+
+
${dir.kr_count||0} KEY RESULTS · ${dir.link_count||0} LINKED ITEMS
+
+
+
`;
+ }
+ el.innerHTML = html;
+ } catch(e) {
+ if (el) el.innerHTML = '
DIRECTIVES OFFLINE
';
+ }
+}
+
+function toggleDirCard(id) {
+ const card = document.getElementById('dir-card-' + id);
+ if (!card) return;
+ if (_dirOpenCards.has(id)) _dirOpenCards.delete(id);
+ else _dirOpenCards.add(id);
+ card.classList.toggle('open');
+}
+
+async function hudDirectiveReview(id) {
+ const res = await api('arc?action=job_create', 'POST', {
+ type: 'directive_review', payload: {directive_id: id, provider: 'claude'}, priority: 6,
+ });
+ if (res.job_id) {
+ addMessage('jarvis', `◈ DIRECTIVE REVIEW initiated (Job #${res.job_id}). Analyzing objectives and key results now. Results will appear here shortly.`);
+ speak(`Directive review underway. I'll brief you on your progress in a moment.`);
+ }
+}
+
+// ── MEMORY CORE — bottom bar count ────────────────────────────────────────────
+async function updateMemoryCount() {
+ try {
+ const stats = await api('memory?action=stats');
+ const el = document.getElementById('bb-memory-count');
+ const dot = document.getElementById('bb-memory-dot');
+ if (el && stats) {
+ const total = stats.total || 0;
+ el.textContent = total + ' FACTS';
+ if (dot) dot.style.background = total > 0 ? 'var(--cyan)' : 'rgba(0,212,255,0.3)';
+ }
+ } catch(e) {}
+}
+
+// ── CLEARANCE PROTOCOL HUD ─────────────────────────────────────────────────────
+const _clrOpenCards = new Set();
+
+async function updateClearanceBanner() {
+ try {
+ const pending = await api('arc?action=clearance_pending');
+ const list = Array.isArray(pending) ? pending : [];
+ const count = list.length;
+ const banner = document.getElementById('clearance-banner');
+ const badge = document.getElementById('clr-tab-badge');
+ const bcount = document.getElementById('clr-banner-count');
+ if (banner) {
+ if (count > 0) {
+ banner.classList.add('active');
+ if (bcount) bcount.textContent = count;
+ } else {
+ banner.classList.remove('active');
+ }
+ }
+ if (badge) {
+ if (count > 0) { badge.style.display = 'inline'; badge.textContent = count; }
+ else badge.style.display = 'none';
+ }
+ } catch(e) {}
+}
+
+async function loadClearanceHud() {
+ const el = document.getElementById('clearance-hud');
+ if (!el) return;
+ try {
+ const [pendingRes, rulesRes, historyRes] = await Promise.all([
+ api('arc?action=clearance_pending'),
+ api('arc?action=clearance_rules'),
+ api('arc?action=clearance_history&limit=20')
+ ]);
+ const pending = Array.isArray(pendingRes) ? pendingRes : [];
+ const rules = Array.isArray(rulesRes) ? rulesRes : [];
+ const history = Array.isArray(historyRes) ? historyRes : [];
+
+ let html = '
';
+
+ // Pending requests
+ html += `
PENDING AUTHORIZATION (${pending.length})
`;
+ if (!pending.length) {
+ html += '
◈ NO PENDING CLEARANCE REQUESTS
';
+ } else {
+ for (const cr of pending) {
+ const isOpen = _clrOpenCards.has(cr.id);
+ const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload || '{}') : (cr.job_payload || {});
+ const created = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
+ const expires = cr.expires_at ? new Date(cr.expires_at).toLocaleString() : '';
+ html += `
+
+ ${escHtml(cr.job_type.toUpperCase().replace(/_/g,' '))}
+ ${cr.risk_level.toUpperCase()}
+ #${cr.id}
+
+
+
${escHtml(cr.description || 'No description')}
+
+ Requested: ${created}${expires ? ' · Expires: ' + expires : ''}
+
+
+ Payload: ${escHtml(JSON.stringify(pl))}
+
+
+
+
+
+
+
`;
+ }
+ }
+
+ // Rules
+ html += `
CLEARANCE RULES
`;
+ if (!rules.length) {
+ html += '
No rules configured
';
+ } else {
+ html += '
';
+ for (const r of rules) {
+ const enClass = r.enabled ? 'clr-rule-enabled' : 'clr-rule-disabled';
+ const enLabel = r.enabled ? 'ON' : 'OFF';
+ const reqLabel = r.require_approval ? 'REQUIRES APPROVAL' : 'AUTO-ALLOW';
+ const autoTxt = r.auto_approve_after_min ? ` · AUTO ${r.auto_approve_after_min}m` : '';
+ html += `
+ ${r.job_type.replace(/_/g,' ').toUpperCase()}
+ ${r.risk_level.toUpperCase()}
+ ${reqLabel}${autoTxt}
+
+
`;
+ }
+ html += '
';
+ }
+
+ // Recent history
+ html += `
RECENT HISTORY
`;
+ const recentDecided = history.filter(h => h.status !== 'pending').slice(0, 10);
+ if (!recentDecided.length) {
+ html += '
No history yet
';
+ } else {
+ html += '
';
+ for (const h of recentDecided) {
+ const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
+ html += `
+ ◈
+ ${h.job_type.replace(/_/g,' ').toUpperCase()}
+ ${h.status.toUpperCase()}
+ ${ts}
+
`;
+ }
+ html += '
';
+ }
+
+ el.innerHTML = html;
+ await updateClearanceBanner();
+ } catch(e) {
+ if (el) el.innerHTML = '
CLEARANCE SYSTEM OFFLINE
';
+ }
+}
+
+function toggleClrCard(id) {
+ const card = document.getElementById('clr-card-' + id);
+ if (!card) return;
+ if (_clrOpenCards.has(id)) _clrOpenCards.delete(id);
+ else _clrOpenCards.add(id);
+ card.classList.toggle('open');
+}
+
+async function hudClearanceDecide(id, action) {
+ const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
+ if (!confirm(`${label} clearance request #${id}?`)) return;
+ const note = action === 'deny' ? (prompt('Reason for denial (optional):') || '') : '';
+ try {
+ const res = await api(`arc?action=clearance_${action}&id=${id}`, 'POST', { decided_by: 'admin', note });
+ const msg = action === 'approve'
+ ? `◈ Clearance #${id} authorized. Job dispatched.`
+ : `◈ Clearance #${id} denied${note ? ': ' + note : ''}.`;
+ addMessage('jarvis', msg);
+ speak(action === 'approve' ? 'Clearance granted. Job dispatched.' : 'Request denied.');
+ await loadClearanceHud();
+ } catch(e) {
+ addMessage('system', 'Clearance action failed.');
+ }
+}
+
+async function hudClearanceRuleToggle(id, newEnabled) {
+ try {
+ await api(`arc?action=clearance_rule_update&id=${id}`, 'POST', { enabled: newEnabled });
+ await loadClearanceHud();
+ } catch(e) {}
+}
+
+async function loadAgents() {
+ const [listData, metricsData] = await Promise.all([
+ api('agent/list'),
+ api('agent/status')
+ ]);
+ const agents = listData.agents || [];
+ const metrics = metricsData.metrics || {};
+ // Fetch sparkline data (non-blocking)
+ api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
+ renderAgentsTab(agents, metrics);
+}
+
+async function addNetworkDevice() {
+ const ip = prompt('IP address (e.g. 10.48.200.43):');
+ if (!ip) return;
+ const name = prompt('Device name (e.g. Yealink Phone):');
+ if (!name) return;
+ const type = prompt('Type (server, voip, nas, printer, device):', 'device') || 'device';
+ const r = await api('network/add', 'POST', {ip, alias: name, type});
+ if (r.error) { alert('Error: ' + r.error); return; }
+ loadNetwork();
+}
+
+async function deleteNetworkDevice(ip, evt) {
+ evt.stopPropagation();
+ if (!confirm('Remove ' + ip + ' from the network list?')) return;
+ const r = await api('network/delete', 'POST', {ip});
+ if (r.error) { alert('Error: ' + r.error); return; }
+ loadNetwork();
+}
+
+let _agentSparkData = {};
+function sparkline(points, width=80, height=20, color='var(--cyan)') {
+ if (!points || points.length < 2) return '';
+ const max = Math.max(...points, 1);
+ const min = Math.min(...points);
+ const range = max - min || 1;
+ const step = width / (points.length - 1);
+ const pts = points.map((v, i) => {
+ const x = i * step;
+ const y = height - ((v - min) / range) * (height - 2) - 1;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(' ');
+ return `
`;
+}
+
+function renderAgentsTab(agents, metrics) {
+ const el = document.getElementById('agents-list');
+ if (!el) return;
+ if (!agents.length) {
+ el.innerHTML = '
NO AGENTS REGISTERED
';
+ return;
+ }
+ el.innerHTML = agents.map(ag => {
+ const m = metrics[ag.agent_id] || {};
+ const sys = m.system || {};
+ const alive = ag.status === 'online';
+ const cpu = sys.cpu_percent != null ? Math.round(sys.cpu_percent) : '--';
+ const mem = sys.memory ? Math.round(sys.memory.percent) : '--';
+ const memUsed = sys.memory ? Math.round(sys.memory.used_mb / 1024 * 10) / 10 + 'GB' : '--';
+ const memTot = sys.memory ? Math.round(sys.memory.total_mb / 1024 * 10) / 10 + 'GB' : '--';
+ const disks = sys.disk || [];
+ const maxDisk = disks.length ? Math.max(...disks.map(d => parseInt(d.percent)||0)) : null;
+ const uptime = sys.uptime ? sys.uptime.human : (alive ? 'ONLINE' : 'OFFLINE');
+ const since = ag.last_seen ? ag.last_seen.replace('T',' ').replace(/\.\d+Z$/,'') : '--';
+
+ const gauge = (val, unit='%', warn=80, crit=90) => {
+ const v = typeof val === 'number' ? val : parseInt(val);
+ if (isNaN(v)) return `
--`;
+ const col = v >= crit ? 'var(--red)' : v >= warn ? '#f5a623' : 'var(--green)';
+ return `
`;
+ };
+
+ const svcs = (sys.services || []).filter(s => s.status !== 'inactive' || true)
+ .map(s => `
${s.service}: ${s.status}`)
+ .join('');
+
+ const ctxKey = 'agent_' + ag.agent_id;
+ _panelCtx[ctxKey] = {type:'agent', label: ag.hostname, agent_id: ag.agent_id,
+ hostname: ag.hostname, status: ag.status, cpu, mem};
+
+ return `
+
+
+
${ag.hostname}
+
${ag.agent_type.toUpperCase()} · ${ag.ip_address}
+
${alive ? 'ONLINE' : 'OFFLINE'}
+
+ ${alive ? `
+
+
MEM ${memUsed}/${memTot}
${gauge(mem)}
+
DISK
${maxDisk != null ? gauge(maxDisk) : '
--'}
+
+
+
+
CPU 2H
+ ${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
+
+
+
MEM 2H
+ ${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
+
+
` : ''}
+
+
UP: ${uptime} · SEEN: ${since}
+ ${svcs ? `
${svcs}
` : ''}
+
+ ${alive ? `
+
+
+
` : ''}
+
`;
+ }).join('');
+}
+
+function openAgentModal() {
+ const os = detectOS();
+ const title = document.getElementById('agentModalTitle');
+ const content = document.getElementById('agentModalContent');
+ const modal = document.getElementById('agentModal');
+ const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
+ const baseUrl = 'https://jarvis.orbishosting.com/agent';
+ const jUrl = 'https://jarvis.orbishosting.com';
+
+ if (os === 'tablet') {
+ title.textContent = '● JARVIS — TABLET / MOBILE';
+ content.innerHTML =
+ '
✓ You\'re viewing JARVIS on a tablet or mobile device.
' +
+ '
The JARVIS Agent runs on desktop and server platforms (Windows, macOS, Linux).
' +
+ 'Tablets and phones can browse the full JARVIS dashboard but do not need an agent installed — all data comes from your other monitored machines.
';
+ } else if (_agentOnline) {
+ title.textContent = '● AGENT CONNECTED';
+ content.innerHTML =
+ '
✓ JARVIS Agent is active on this machine.
' +
+ '
' +
+ 'Host: ' + (_myAgent?.hostname||'—') + '
' +
+ 'IP: ' + (_myAgent?.ip_address||'—') + '
' +
+ 'Type: ' + (_myAgent?.agent_type||'—').toUpperCase() + '
' +
+ 'Reporting: CPU · Memory · Disk · Services · Uptime
';
+ } else {
+ const inst = {
+ windows: {
+ label:'Windows',
+ cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
+ dl: baseUrl+'/install-windows.ps1',
+ note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
+ },
+ mac: {
+ label:'macOS',
+ cmd:'bash <(curl -sSL '+baseUrl+'/install-mac.sh) \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
+ dl: baseUrl+'/install-mac.sh',
+ note:'Run in Terminal. Installs as a launchd background service.'
+ },
+ linux: {
+ label:'Linux',
+ cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
+ dl: baseUrl+'/install.sh',
+ note:'Run in terminal. Installs as a systemd service.'
+ },
+ unknown: {
+ label:'Your System',
+ cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
+ dl: 'https://jarvis.orbishosting.com/agent/',
+ note:'Choose your platform installer from the JARVIS agent directory.'
+ }
+ };
+ const i = inst[os] || inst.unknown;
+ const osBadge = {windows:'🪟 WINDOWS', mac:'🍎 MACOS', linux:'🐧 LINUX', unknown:'❓ UNKNOWN'}[os] || os.toUpperCase();
+ title.textContent = '● INSTALL AGENT · ' + (inst[os] ? inst[os].label.toUpperCase() : 'YOUR SYSTEM');
+ content.innerHTML =
+ '
DETECTED: ' + osBadge + '
' +
+ '
'+i.note+'
' +
+ '
'+i.cmd+'
' +
+ '
↓ DOWNLOAD INSTALLER' +
+ '
After install, the AGENT indicator turns green within 30 seconds.
';
+ }
+ modal.classList.add('open');
+}
+
+document.addEventListener('click', function(e) {
+ if (e.target === document.getElementById('agentModal'))
+ document.getElementById('agentModal').classList.remove('open');
+});
+
+
+
+
+// ── SITES MANAGER ────────────────────────────────────────────────────
+let sitesData = {};
+
+function openSitesModal() {
+ document.getElementById('sitesModal').style.display = 'flex';
+ loadSites();
+}
+function closeSitesModal() {
+ document.getElementById('sitesModal').style.display = 'none';
+}
+// Close on backdrop click
+document.getElementById('sitesModal').addEventListener('click', function(e) {
+ if (e.target === this) closeSitesModal();
+});
+
+async function loadSites() {
+ document.getElementById('sites-grid').innerHTML = '
LOADING SITE SETTINGS...
';
+ const res = await api('sites');
+ if (!res.success) {
+ document.getElementById('sites-grid').innerHTML = '
FAILED TO LOAD SETTINGS
';
+ return;
+ }
+ sitesData = res.sites;
+ // Pre-fill global key from first site
+ const firstKey = Object.values(res.sites)[0]?.api_key || '';
+ document.getElementById('global-api-key').value = firstKey;
+ renderSiteCards();
+}
+
+function renderSiteCards() {
+ const grid = document.getElementById('sites-grid');
+ let html = '';
+ for (const [id, s] of Object.entries(sitesData)) {
+ html += `
+
+
+
${s.name.toUpperCase()}
+
${s.url}
+
+
+
+
+
ADMIN NOTIFICATION EMAIL
+
+
+
+
+
+
+
`;
+ }
+ grid.innerHTML = html;
+}
+
+async function pushApiKey() {
+ const key = document.getElementById('global-api-key').value.trim();
+ const status = document.getElementById('push-status');
+ if (!key) { status.style.color='#f44'; status.textContent='✗ API KEY REQUIRED'; return; }
+ status.style.color='var(--text-dim)'; status.textContent='PUSHING TO ALL SITES...';
+ const res = await api('sites', 'POST', {action:'push_key', api_key:key});
+ if (res.success) {
+ const ok = Object.values(res.results).filter(Boolean).length;
+ const total = Object.keys(res.results).length;
+ status.style.color = ok === total ? 'var(--cyan)' : '#fa0';
+ status.textContent = `✓ PUSHED TO ${ok}/${total} SITES`;
+ for (const id of Object.keys(sitesData)) sitesData[id].api_key = key;
+ } else {
+ status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
+ }
+}
+
+async function saveSite(id) {
+ const status = document.getElementById(id + '-status');
+ status.style.color='var(--text-dim)'; status.textContent='SAVING...';
+ const res = await api('sites', 'POST', {
+ action: 'save',
+ site: id,
+ from_email: document.getElementById(id+'-from_email').value.trim(),
+ from_name: document.getElementById(id+'-from_name').value.trim(),
+ admin_email: document.getElementById(id+'-admin_email').value.trim(),
+ });
+ if (res.success) {
+ status.style.color='var(--cyan)'; status.textContent='✓ SAVED';
+ setTimeout(() => { status.textContent=''; }, 3000);
+ } else {
+ status.style.color='#f44'; status.textContent='✗ ' + (res.error || 'FAILED');
+ }
+}
+
+// ── VISION PROTOCOL — screenshot lightbox ────────────────────────────────────
+function openVisionLightbox(title) {
+ const lb = document.getElementById('vision-lightbox');
+ document.getElementById('vision-lb-title').textContent = title || '◈ VISION PROTOCOL';
+ document.getElementById('vision-lb-img').style.display = 'none';
+ document.getElementById('vision-lb-img').src = '';
+ document.getElementById('vision-lb-analysis').textContent = '';
+ document.getElementById('vision-lb-spinner').style.display = 'block';
+ lb.classList.add('open');
+}
+
+function closeVisionLightbox() {
+ document.getElementById('vision-lightbox').classList.remove('open');
+}
+
+async function agentScreenshot(hostname) {
+ openVisionLightbox('◈ VISION PROTOCOL — ' + hostname.toUpperCase());
+ const arcRes = await api('arc?action=job_create', 'POST', {
+ type: 'screenshot',
+ payload: {agent: hostname, analyze: true},
+ priority: 8,
+ }).catch(() => null);
+
+ if (!arcRes || !arcRes.job_id) {
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ document.getElementById('vision-lb-analysis').textContent = 'Failed to submit screenshot job — Arc Reactor may be offline.';
+ return;
+ }
+
+ // Poll for result
+ const jobId = arcRes.job_id;
+ let tries = 0;
+ const poll = async () => {
+ tries++;
+ const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
+ if (job && job.status === 'done') {
+ const r = job.result || {};
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ if (r.has_image && r.screenshot_id) {
+ // Fetch full screenshot with image
+ const full = await api('arc?action=screenshot_get&id=' + r.screenshot_id).catch(() => null);
+ if (full && full.image_b64) {
+ const img = document.getElementById('vision-lb-img');
+ img.src = 'data:image/png;base64,' + full.image_b64;
+ img.style.display = 'block';
+ }
+ }
+ document.getElementById('vision-lb-analysis').textContent =
+ r.analysis || (r.has_image ? 'Screenshot captured — no analysis available.' : JSON.stringify(r.snapshot || r, null, 2));
+ } else if (job && job.status === 'failed') {
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ document.getElementById('vision-lb-analysis').textContent = 'Screenshot failed: ' + (job.error || 'Unknown error');
+ } else if (tries < 30) {
+ setTimeout(poll, 2000);
+ } else {
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ document.getElementById('vision-lb-analysis').textContent = 'Timed out waiting for screenshot.';
+ }
+ };
+ setTimeout(poll, 2000);
+}
+
+async function agentSysinfo(hostname) {
+ openVisionLightbox('⚡ FIELD SYSINFO — ' + hostname.toUpperCase());
+ const arcRes = await api('arc?action=job_create', 'POST', {
+ type: 'sysinfo',
+ payload: {agent: hostname, analyze: true},
+ priority: 7,
+ }).catch(() => null);
+
+ if (!arcRes || !arcRes.job_id) {
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ document.getElementById('vision-lb-analysis').textContent = 'Failed to submit sysinfo job.';
+ return;
+ }
+
+ const jobId = arcRes.job_id;
+ let tries = 0;
+ const poll = async () => {
+ tries++;
+ const job = await api('arc?action=job_get&id=' + jobId).catch(() => null);
+ if (job && job.status === 'done') {
+ const r = job.result || {};
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ const snap = r.snapshot || {};
+ const snapText = Object.entries(snap)
+ .filter(([k]) => !['success','screenshot_available','snapshot_type'].includes(k))
+ .map(([k,v]) => `${k.toUpperCase().replace(/_/g,' ')}: ${Array.isArray(v) ? v.join('\n ') : v}`)
+ .join('\n');
+ document.getElementById('vision-lb-analysis').textContent =
+ (r.analysis ? r.analysis + '\n\n─────────────────────\n\n' : '') + (snapText || JSON.stringify(r, null, 2));
+ } else if (job && job.status === 'failed') {
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ document.getElementById('vision-lb-analysis').textContent = 'Sysinfo failed: ' + (job.error || 'Unknown error');
+ } else if (tries < 20) {
+ setTimeout(poll, 2000);
+ } else {
+ document.getElementById('vision-lb-spinner').style.display = 'none';
+ document.getElementById('vision-lb-analysis').textContent = 'Timed out.';
+ }
+ };
+ setTimeout(poll, 2000);
+}
+
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape') closeVisionLightbox();
+});
+
diff --git a/public_html/assets/js/panels/jarvis-arc.js b/public_html/assets/js/panels/jarvis-arc.js
new file mode 100644
index 0000000..8d9e428
--- /dev/null
+++ b/public_html/assets/js/panels/jarvis-arc.js
@@ -0,0 +1,608 @@
+// ── ARC REACTOR STATUS ────────────────────────────────────────────────
+let _arcOnline = false;
+let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
+
+async function checkArcStatus() {
+ const dot = document.getElementById('bb-arc-dot');
+ const sta = document.getElementById('bb-arc-status');
+ if (!dot || !sta) return;
+ try {
+ const d = await api('arc?action=status');
+ if (d && d.online) {
+ _arcOnline = true;
+ dot.className = 'bb-dot online';
+ const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
+ sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
+ _arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
+ done: d.jobs_done||0, failed: d.jobs_failed||0 };
+ } else {
+ _arcOnline = false;
+ dot.className = 'bb-dot offline';
+ sta.textContent = 'OFFLINE';
+ }
+ } catch(e) {
+ _arcOnline = false;
+ dot.className = 'bb-dot offline';
+ sta.textContent = 'OFFLINE';
+ }
+}
+
+// Submit a job to the Arc Reactor and return job_id
+async function arcSubmitJob(type, payload, priority) {
+ payload = payload || {};
+ priority = priority || 5;
+ const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
+ return d.job_id || null;
+}
+
+// Poll a job until done or failed (max 120s), calling onProgress each tick
+async function arcWaitJob(jobId, onProgress) {
+ var start = Date.now();
+ while (Date.now() - start < 120000) {
+ const d = await api('arc?action=job_get&id=' + jobId);
+ if (onProgress) onProgress(d);
+ if (d.status === 'done') return d;
+ if (d.status === 'failed') throw new Error(d.error || 'Job failed');
+ await new Promise(function(r){ setTimeout(r, 1500); });
+ }
+ throw new Error('Arc Reactor job timed out');
+}
+
+
+// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
+let _intelPollTimer = null;
+let _intelActiveJobs = new Set();
+let _intelLastLoad = 0;
+
+async function loadIntel() {
+ const el = document.getElementById('intel-list');
+ if (!el) return;
+ _intelLastLoad = Date.now();
+
+ try {
+ // Fetch recent research + tool_loop jobs
+ const [resJobs, toolJobs] = await Promise.all([
+ api('arc?action=jobs&status=&limit=20').catch(() => []),
+ Promise.resolve([]),
+ ]);
+ const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
+
+ if (!jobs.length) {
+ el.innerHTML = '
◈ NO INTEL JOBS
Say "research [topic]" to activate
';
+ stopIntelPolling();
+ return;
+ }
+
+ // Check for active jobs
+ const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running');
+ if (hasActive) startIntelPolling(); else stopIntelPolling();
+
+ let html = '
';
+ for (const job of jobs) {
+ const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running';
+ const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running';
+ const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase();
+ const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM';
+
+ // Get result details if done
+ let bodyHtml = '';
+ if (job.status === 'done' && job.result) {
+ let r = job.result;
+ if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} }
+ if (typeof r === 'object') {
+ const synthesis = (r.synthesis || r.result || r.response || '').trim();
+ const sources = r.sources || [];
+ const query = r.query || r.task || '';
+ const provider = r.provider || '';
+
+ bodyHtml = `
`;
+ if (provider) bodyHtml += `
PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}
`;
+ if (synthesis) bodyHtml += `
${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}
`;
+ if (sources.length) {
+ bodyHtml += '
SOURCES
';
+ sources.slice(0,5).forEach((s,i) => {
+ const title = escHtml((s.title||s.url||'').substring(0,60));
+ const url = escHtml(s.url||'');
+ bodyHtml += `
`;
+ });
+ bodyHtml += '
';
+ }
+ bodyHtml += '
';
+ }
+ } else if (job.status === 'running' || job.status === 'queued') {
+ const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...';
+ bodyHtml = `
`;
+ } else if (job.status === 'failed' && job.error) {
+ bodyHtml = `
${escHtml(job.error.substring(0,200))}
`;
+ }
+
+ const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : '';
+ const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : '';
+
+ html += `
+
+ ${typeLabel}
+ #${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))}
+ ${ts}
+ ${statusLabel}
+
+ ${bodyHtml}
+
`;
+ }
+ el.innerHTML = html;
+
+ } catch(e) {
+ if (el) el.innerHTML = '
INTEL OFFLINE
';
+ }
+}
+
+function toggleIntelCard(id) {
+ const card = document.getElementById('intel-card-' + id);
+ if (!card) return;
+ if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id);
+ else _intelActiveJobs.add(id);
+ card.classList.toggle('open');
+}
+
+function startIntelPolling() {
+ if (_intelPollTimer) return;
+ _intelPollTimer = setInterval(() => {
+ if (document.getElementById('tab-intel')?.classList.contains('active')) {
+ loadIntel();
+ }
+ }, 4000);
+}
+
+function stopIntelPolling() {
+ if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; }
+}
+
+function escHtml(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+function intelPrompt() {
+ const input = document.getElementById('textInput');
+ if (input) { input.value = 'research '; input.focus(); }
+}
+
+// Called when arc_job is returned from chat response
+function onArcJobStarted(jobId, jobType) {
+ const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
+ if (commsTypes.includes(jobType)) {
+ const commsBtn = document.getElementById('tab-btn-comms');
+ if (commsBtn) commsBtn.click();
+ startCommsPolling();
+ } else {
+ _intelActiveJobs.add(jobId);
+ const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]');
+ if (intelTab) intelTab.click();
+ startIntelPolling();
+ }
+}
+
+// ── COMMS PROTOCOL — email triage HUD ────────────────────────────────────
+let _commsPollTimer = null;
+let _commsFilter = 'priority';
+let _commsOpenCards = new Set();
+
+async function loadComms() {
+ const el = document.getElementById('comms-list');
+ if (!el) return;
+
+ try {
+ const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter);
+ const items = Array.isArray(res) ? res : (res.items || []);
+
+ if (!items.length) {
+ el.innerHTML = '
'
+ + '
◈ NO TRIAGE DATA
Say "check my email" to activate
';
+ stopCommsPolling();
+ return;
+ }
+
+ const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
+ const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'ℹ', promo:'📢', spam:'🗑'};
+
+ let html = '
';
+ html += '';
+ html += '';
+ html += '
';
+ html += '';
+
+ for (const item of items) {
+ const cat = item.category || 'info';
+ const icon = catIcons[cat] || '◈';
+ const prio = item.priority || 0;
+ const isOpen = _commsOpenCards.has(item.id);
+ const hasReply = item.draft_reply && item.draft_reply.trim().length > 5;
+
+ html += `
+
+ ${icon} ${cat.toUpperCase()}
+ ${escHtml((item.subject||'(no subject)').substring(0,60))}
+ ${prio}/10
+
+
+
FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}
+
${escHtml(item.summary||'')}
+ ${hasReply ? `
DRAFT REPLY
${escHtml(item.draft_reply)}
` : ''}
+
+ ${hasReply ? `` : ''}
+ ${hasReply ? `` : ''}
+
+
+
+
`;
+ }
+
+ el.innerHTML = html;
+
+ } catch(e) {
+ if (el) el.innerHTML = '
COMMS OFFLINE
';
+ }
+}
+
+function toggleCommsCard(id) {
+ const card = document.getElementById('comms-card-' + id);
+ if (!card) return;
+ if (_commsOpenCards.has(id)) _commsOpenCards.delete(id);
+ else _commsOpenCards.add(id);
+ card.classList.toggle('open');
+}
+
+function commsSetFilter(f) {
+ _commsFilter = f;
+ loadComms();
+}
+
+async function commsDismiss(id) {
+ await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {});
+ loadComms();
+}
+
+async function commsCopyReply(id) {
+ const draft = document.querySelector(`#comms-draft-${id}`);
+ if (draft) {
+ navigator.clipboard.writeText(draft.innerText).catch(() => {});
+ const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
+ if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
+ }
+}
+
+async function commsSendReply(id) {
+ const btn = document.getElementById('comms-send-' + id);
+ const draft = document.getElementById('comms-draft-' + id);
+ if (!btn || !draft) return;
+ btn.disabled = true;
+ btn.textContent = '◈ SENDING…';
+ try {
+ const res = await api('arc', 'POST', {
+ action: 'job_create',
+ type: 'send_email',
+ payload: { triage_id: id, content: draft.innerText },
+ priority: 8,
+ });
+ if (res.job_id) {
+ btn.textContent = '◈ SENT ✓';
+ btn.style.color = '#00ff88';
+ setTimeout(() => loadComms(), 3000);
+ loadCommsOutbox();
+ } else {
+ btn.disabled = false;
+ btn.textContent = '◈ SEND REPLY';
+ alert('Send failed: ' + (res.error || 'unknown error'));
+ }
+ } catch(e) {
+ btn.disabled = false;
+ btn.textContent = '◈ SEND REPLY';
+ }
+}
+
+function commsShowCompose() {
+ const existing = document.getElementById('comms-compose-modal');
+ if (existing) existing.remove();
+ const modal = document.createElement('div');
+ modal.className = 'comms-compose-modal';
+ modal.id = 'comms-compose-modal';
+ modal.innerHTML = `
+
+
◈ COMPOSE MESSAGE
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ document.body.appendChild(modal);
+ modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
+}
+
+let _ccDraftedBody = '';
+
+async function commsComposeDraft() {
+ const to = document.getElementById('cc-to')?.value.trim();
+ const subject = document.getElementById('cc-subject')?.value.trim();
+ const instructions = document.getElementById('cc-instructions')?.value.trim();
+ const account = document.getElementById('cc-account')?.value;
+ const status = document.getElementById('cc-status');
+ if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
+ if (status) status.textContent = '◈ DRAFTING…';
+ try {
+ const res = await api('arc', 'POST', {
+ action: 'job_create', type: 'compose_email',
+ payload: { recipient: to, subject, instructions, account, auto_send: false },
+ priority: 7,
+ });
+ if (!res.job_id) throw new Error(res.error || 'No job');
+ // poll for result
+ let attempts = 0;
+ const poll = async () => {
+ const job = await api('arc?action=job_get&id=' + res.job_id);
+ if (job.status === 'done' && job.result?.drafted_body) {
+ _ccDraftedBody = job.result.drafted_body;
+ document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
+ document.getElementById('cc-preview').style.display = 'block';
+ document.getElementById('cc-send-btn').style.display = '';
+ if (status) status.textContent = '◈ DRAFT READY — Review and send';
+ } else if (job.status === 'failed') {
+ if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
+ } else if (attempts++ < 20) {
+ setTimeout(poll, 1500);
+ } else {
+ if (status) status.textContent = '◈ Job still running — check INTEL tab';
+ }
+ };
+ setTimeout(poll, 1500);
+ } catch(e) {
+ if (status) status.textContent = '✗ Error: ' + e.message;
+ }
+}
+
+async function commsComposeAndSend() {
+ const to = document.getElementById('cc-to')?.value.trim();
+ const subject = document.getElementById('cc-subject')?.value.trim();
+ const account = document.getElementById('cc-account')?.value;
+ const status = document.getElementById('cc-status');
+ const btn = document.getElementById('cc-send-btn');
+ if (!to || !_ccDraftedBody) return;
+ if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
+ if (status) status.textContent = '◈ TRANSMITTING…';
+ try {
+ const res = await api('arc', 'POST', {
+ action: 'job_create', type: 'send_email',
+ payload: { to_email: to, subject, body: _ccDraftedBody, account },
+ priority: 9,
+ });
+ if (res.job_id) {
+ if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
+ setTimeout(() => {
+ document.getElementById('comms-compose-modal')?.remove();
+ loadCommsOutbox();
+ }, 1500);
+ } else {
+ if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
+ if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
+ }
+ } catch(e) {
+ if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
+ if (status) status.textContent = '✗ Error: ' + e.message;
+ }
+}
+
+async function loadCommsOutbox() {
+ const el = document.getElementById('comms-outbox');
+ if (!el) return;
+ try {
+ const data = await api('arc?action=comms_sent&limit=20');
+ const sent = Array.isArray(data) ? data : (data.sent || []);
+ if (!sent.length) {
+ el.innerHTML = '
No sent messages yet
';
+ return;
+ }
+ const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
+ let html = '';
+ for (const m of sent) {
+ const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
+ const sc = m.status || 'sent';
+ html += `
+
+
TO: ${escHtml((m.to_email||'').substring(0,40))}
+
${sc.toUpperCase()}
+
+
${escHtml((m.subject||'(no subject)').substring(0,60))}
+
${ts} · ${m.account||'gmail'}
+
`;
+ }
+ el.innerHTML = html;
+ } catch(e) {
+ el.innerHTML = '
OUTBOX OFFLINE
';
+ }
+}
+
+function commsTriageNow() {
+ const input = document.getElementById('textInput');
+ if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
+}
+
+function startCommsPolling() {
+ if (_commsPollTimer) return;
+ _commsPollTimer = setInterval(() => {
+ if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
+ }, 8000);
+}
+
+function stopCommsPolling() {
+ if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; }
+}
+
+// ── GUARDIAN MODE ─────────────────────────────────────────────────────────────
+let _guardianPollTimer = null;
+let _guardianChatTimer = null;
+let _guardianLastChat = '';
+let _guardianUnread = 0;
+
+async function loadGuardian() {
+ const el = document.getElementById('guardian-list');
+ if (!el) return;
+
+ try {
+ const [statusData, eventsData] = await Promise.all([
+ api('arc?action=guardian_status').catch(() => ({})),
+ api('arc?action=guardian_events&limit=40').catch(() => []),
+ ]);
+
+ const events = Array.isArray(eventsData) ? eventsData : [];
+ const status = statusData || {};
+ const counts = status.counts || {};
+ const unread = parseInt(counts.unread || 0);
+ const critU = parseInt(counts.critical_unread || 0);
+
+ _guardianUnread = unread;
+ _updateGuardianBadge(unread, critU);
+ if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') {
+ new Notification('JARVIS ALERT', {
+ body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.',
+ icon: '/favicon.ico',
+ });
+ }
+
+ const lastScan = status.last_scan
+ ? new Date(status.last_scan + 'Z').toLocaleTimeString()
+ : '—';
+
+ let html = `
+ ◈ GUARDIAN MODE
+
+ ${status.enabled ? '● ACTIVE' : '○ INACTIVE'}
+
+ SCAN: ${lastScan}
+ ${unread ? `` : ''}
+
+
`;
+
+ if (!events.length) {
+ html += '
◈ ALL CLEAR
Guardian is watching...
';
+ } else {
+ for (const ev of events) {
+ const sev = ev.severity || 'info';
+ const acked = ev.acknowledged;
+ const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : '';
+ const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',
+ mem_high:'⚡',disk_high:'💾',service_down:'✗',
+ service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈';
+ html += `
+
${sev.toUpperCase()}
+
+
${typeIco} ${escHtml(ev.message||'')}
+ ${ev.ai_analysis ? `
${escHtml(ev.ai_analysis.substring(0,200))}
` : ''}
+
+
+ ${ts}
+ ${!acked ? `` : ''}
+
+
`;
+ }
+ }
+ el.innerHTML = html;
+ startGuardianPolling();
+
+ } catch(e) {
+ if (el) el.innerHTML = '
GUARDIAN OFFLINE
';
+ }
+}
+
+function _updateGuardianBadge(unread, critical) {
+ const dot = document.getElementById('bb-guardian-dot');
+ const badge = document.getElementById('bb-guardian-badge');
+ const status = document.getElementById('bb-guardian-status');
+ if (!dot) return;
+ dot.className = 'bb-dot';
+ if (critical > 0) {
+ dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)';
+ } else if (unread > 0) {
+ dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623';
+ } else {
+ dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)';
+ }
+ if (unread > 0) {
+ badge.textContent = unread; badge.style.display = 'inline';
+ } else {
+ badge.style.display = 'none';
+ }
+}
+
+async function guardianAck(id) {
+ await api('arc?action=guardian_ack&id=' + id).catch(() => {});
+ const ev = document.getElementById('gev-' + id);
+ if (ev) ev.classList.add('acked');
+ _guardianUnread = Math.max(0, _guardianUnread - 1);
+ _updateGuardianBadge(_guardianUnread, 0);
+}
+
+async function guardianAckAll() {
+ await api('arc?action=guardian_ack').catch(() => {});
+ loadGuardian();
+}
+
+function guardianSitrep() {
+ const input = document.getElementById('textInput');
+ if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
+}
+
+function switchGuardianTab() {
+ const btn = document.getElementById('tab-btn-guardian');
+ if (btn) btn.click();
+}
+
+function startGuardianPolling() {
+ if (_guardianPollTimer) return;
+ _guardianPollTimer = setInterval(() => {
+ if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian();
+ else _refreshGuardianBadge();
+ }, 30000);
+}
+
+async function _refreshGuardianBadge() {
+ const s = await api('arc?action=guardian_status').catch(() => null);
+ if (!s) return;
+ const counts = s.counts || {};
+ _updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0));
+}
+
+// Proactive chat polling — checks for guardian-injected messages every 30s
+let _proactiveChatLastId = 0;
+async function _pollProactiveChat() {
+ try {
+ const rows = await api('arc?action=guardian_chat').catch(() => []);
+ if (!Array.isArray(rows)) return;
+ for (const row of rows) {
+ if (row.id > _proactiveChatLastId) {
+ _proactiveChatLastId = row.id;
+ // Don't spam on first load — only show messages from last 5 min
+ const age = Date.now() - new Date(row.created_at + 'Z').getTime();
+ if (age < 300000) {
+ addMessage('jarvis', row.message);
+ speak(row.message);
+ }
+ }
+ }
+ } catch(e) {}
+}
+
diff --git a/public_html/assets/js/panels/jarvis-assistant.js b/public_html/assets/js/panels/jarvis-assistant.js
new file mode 100644
index 0000000..79cf66e
--- /dev/null
+++ b/public_html/assets/js/panels/jarvis-assistant.js
@@ -0,0 +1,345 @@
+// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
+function openSearchModal() {
+ document.getElementById('searchModal').style.display = 'flex';
+ document.getElementById('searchInput').focus();
+}
+function closeSearchModal() {
+ document.getElementById('searchModal').style.display = 'none';
+ document.getElementById('searchResults').innerHTML = '
Type to search your JARVIS conversations
';
+ document.getElementById('searchInput').value = '';
+}
+async function runSearch() {
+ const q = document.getElementById('searchInput').value.trim();
+ if (!q) return;
+ const el = document.getElementById('searchResults');
+ el.innerHTML = '
Searching...
';
+ try {
+ const d = await api('history?q=' + encodeURIComponent(q));
+ if (!d.results || !d.results.length) {
+ el.innerHTML = '
No results for "' + q + '"
';
+ return;
+ }
+ el.innerHTML = d.results.map(r => {
+ const role = r.role === 'user' ? '👤' : '🤖';
+ const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
+ const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
+ return `
+
+ ${role} ${r.role.toUpperCase()}
+ ${ts}
+
+
${snippet.replace(/
+
`;
+ }).join('');
+ } catch(e) {
+ el.innerHTML = '
Search failed
';
+ }
+}
+document.getElementById('searchModal')?.addEventListener('click', e => {
+ if (e.target === document.getElementById('searchModal')) closeSearchModal();
+});
+
+// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
+const _shownSuggestions = new Set();
+async function checkSuggestions() {
+ const d = await api('suggestions').catch(() => null);
+ if (!d || !d.suggestions || !d.suggestions.length) return;
+ for (const s of d.suggestions) {
+ const key = s.intent + ':' + d.hour + ':' + d.dow;
+ if (_shownSuggestions.has(key)) continue;
+ _shownSuggestions.add(key);
+ // Show as a soft suggestion chip in chat
+ const log = document.getElementById('chatLog');
+ const chip = document.createElement('div');
+ chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
+ chip.innerHTML = `
`;
+ log.appendChild(chip);
+ log.scrollTop = log.scrollHeight;
+ break; // show max one suggestion at a time
+ }
+}
+
+function sendSuggestion(intent, btn) {
+ btn.closest('div').remove();
+ const prompts = {
+ 'network_scan': 'run a network scan',
+ 'jellyfin_now_playing': 'what is playing on Jellyfin',
+ 'ha_scene': 'what scenes are available',
+ 'planner:briefing': 'daily briefing',
+ 'vm_suggestions': 'VM resource suggestions',
+ 'focus_mode': 'focus mode',
+ };
+ const msg = prompts[intent] || intent.replace(/_/g,' ');
+ document.getElementById('textInput').value = msg;
+ sendMessage();
+}
+
+// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
+function mobSwitch(which) {
+ if (window.innerWidth > 900) return;
+ const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
+ Object.entries(panels).forEach(([k, id]) => {
+ document.getElementById(id)?.classList.toggle('mob-active', k === which);
+ });
+ document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
+ document.getElementById('mob-btn-' + which)?.classList.add('active');
+ if (which === 'right') loadNews();
+}
+function initMobile() {
+ if (window.innerWidth > 900) return;
+ ['leftPanel','centerPanel','rightPanel'].forEach(id =>
+ document.getElementById(id)?.classList.remove('mob-active'));
+ document.getElementById('leftPanel')?.classList.add('mob-active');
+ document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
+ document.getElementById('mob-btn-left')?.classList.add('active');
+}
+window.addEventListener('resize', initMobile);
+
+// ── COMMAND PALETTE (Ctrl+K) ──────────────────────────────────────────────
+const _PALETTE_COMMANDS = [
+ { label: 'Run a network scan', q: 'run a network scan', group: 'Network' },
+ { label: 'Show online devices', q: 'who is online on the network', group: 'Network' },
+ { label: 'Proxmox status', q: 'proxmox status', group: 'Network' },
+ { label: 'Check agent status', q: 'check all agents', group: 'Agents' },
+ { label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' },
+ { label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' },
+ { label: 'Daily briefing', q: 'daily briefing', group: 'Planner' },
+ { label: 'My tasks today', q: 'my tasks today', group: 'Planner' },
+ { label: 'My calendar', q: 'my calendar', group: 'Planner' },
+ { label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' },
+ { label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' },
+ { label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' },
+ { label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' },
+ { label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'},
+ { label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'},
+ { label: 'Focus mode', q: 'focus mode', group: 'UI' },
+ { label: 'Show all panels', q: 'show all panels', group: 'UI' },
+ { label: 'Check alerts', q: 'check alerts', group: 'System' },
+ { label: 'Site health', q: 'site health', group: 'System' },
+ { label: 'System status', q: 'system status', group: 'System' },
+ { label: 'Check inbox', q: 'check inbox', group: 'Comms' },
+ { label: 'Search history…', q: '', group: 'Chat', search: true },
+];
+
+let _paletteOpen = false;
+
+function openPalette() {
+ if (_paletteOpen) return;
+ _paletteOpen = true;
+ const ov = document.getElementById('cmdPalette');
+ if (!ov) return;
+ ov.style.display = 'flex';
+ const inp = document.getElementById('cmdPaletteInput');
+ inp.value = '';
+ renderPaletteItems('');
+ requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); });
+}
+
+function closePalette() {
+ if (!_paletteOpen) return;
+ _paletteOpen = false;
+ const ov = document.getElementById('cmdPalette');
+ if (!ov) return;
+ ov.classList.remove('open');
+ setTimeout(() => { ov.style.display = 'none'; }, 180);
+}
+
+function renderPaletteItems(q) {
+ const list = document.getElementById('cmdPaletteList');
+ if (!list) return;
+ const low = q.toLowerCase().trim();
+ const filtered = low
+ ? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low))
+ : _PALETTE_COMMANDS;
+
+ let currentGroup = null;
+ list.innerHTML = '';
+ filtered.forEach((cmd, i) => {
+ if (cmd.group !== currentGroup) {
+ currentGroup = cmd.group;
+ const g = document.createElement('div');
+ g.className = 'cp-group';
+ g.textContent = cmd.group;
+ list.appendChild(g);
+ }
+ const row = document.createElement('div');
+ row.className = 'cp-item' + (i === 0 ? ' cp-active' : '');
+ row.dataset.q = cmd.q;
+ row.dataset.search = cmd.search ? '1' : '';
+ const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'),
+ m => `
${m}`);
+ row.innerHTML = `
◈${lbl}↵`;
+ row.addEventListener('click', () => firePaletteItem(row));
+ list.appendChild(row);
+ });
+}
+
+function movePaletteSelection(dir) {
+ const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item'));
+ if (!items.length) return;
+ const cur = items.findIndex(el => el.classList.contains('cp-active'));
+ const next = (cur + dir + items.length) % items.length;
+ items.forEach(el => el.classList.remove('cp-active'));
+ items[next].classList.add('cp-active');
+ items[next].scrollIntoView({ block: 'nearest' });
+}
+
+function firePaletteItem(el) {
+ if (!el) {
+ const active = document.querySelector('#cmdPaletteList .cp-active');
+ if (!active) return;
+ el = active;
+ }
+ const q = el.dataset.q;
+ const isSearch = el.dataset.search === '1';
+ closePalette();
+ if (isSearch) {
+ if (typeof openSearchModal === 'function') openSearchModal();
+ return;
+ }
+ if (q) {
+ document.getElementById('textInput').value = q;
+ sendMessage();
+ }
+}
+
+// Keyboard events
+document.addEventListener('keydown', e => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault();
+ _paletteOpen ? closePalette() : openPalette();
+ return;
+ }
+ if (!_paletteOpen) return;
+ if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
+ if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); }
+ if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); }
+ if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); }
+});
+
+// Filter on type
+document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
+ renderPaletteItems(e.target.value);
+});
+
+// Close on backdrop click
+document.getElementById('cmdPalette')?.addEventListener('click', e => {
+ if (e.target.id === 'cmdPalette') closePalette();
+});
+
+// ── AGENT TOPOLOGY MAP ─────────────────────────────────────────────────────────────
+let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = [];
+
+function toggleAgentTopo() {
+ _agentTopoMode = !_agentTopoMode;
+ const btn = document.getElementById('agent-topo-btn');
+ const list = document.getElementById('agents-list');
+ const cvs = document.getElementById('agentTopoCanvas');
+ if (!btn || !list || !cvs) return;
+ btn.classList.toggle('active', _agentTopoMode);
+ if (_agentTopoMode) {
+ list.style.display = 'none'; cvs.style.display = 'block';
+ _buildAgentTopoData(); _drawAgentTopo();
+ } else {
+ list.style.display = 'block'; cvs.style.display = 'none';
+ if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; }
+ }
+}
+
+function _buildAgentTopoData() {
+ // Build node list from rendered agent cards
+ _agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}];
+ document.querySelectorAll('.agent-card').forEach(el => {
+ const nameEl = el.querySelector('.agent-name, [class*="name"]');
+ if (!nameEl) return;
+ const name = nameEl.textContent.trim();
+ const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online');
+ const lname = name.toLowerCase();
+ let type = 'linux';
+ if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox';
+ else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant';
+ else if (lname.includes('windows') || lname.includes('mini')) type = 'windows';
+ _agentTopoData.push({id:name, label:name.substring(0,12), online, type});
+ });
+ // Fallback: use last known registered agent list if cards not rendered
+ if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') {
+ (_lastAgents || []).forEach(a => {
+ _agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'});
+ });
+ }
+}
+
+function _drawAgentTopo() {
+ const cvs = document.getElementById('agentTopoCanvas');
+ if (!cvs || !_agentTopoMode) return;
+ const ctx = cvs.getContext('2d');
+ const rect = cvs.getBoundingClientRect();
+ const W = rect.width || 280, H = rect.height || 260;
+ const dpr = window.devicePixelRatio || 1;
+ cvs.width = W * dpr; cvs.height = H * dpr;
+ ctx.scale(dpr, dpr);
+ const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68};
+ const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'};
+ // Assign positions
+ const byType = {};
+ _agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); });
+ _agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2;
+ Object.entries(byType).forEach(([tp, nodes]) => {
+ const rf = typeRing[tp] || 0.68;
+ const r = Math.min(W, H) / 2 * rf;
+ nodes.forEach((n, i) => {
+ const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2;
+ n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r;
+ });
+ });
+ let t = 0;
+ function frame() {
+ if (!_agentTopoMode) return;
+ t += 0.007; ctx.clearRect(0, 0, W, H);
+ // Orbit rings
+ [0.28, 0.48, 0.68].forEach(rf => {
+ ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2);
+ ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke();
+ });
+ // Edges
+ _agentTopoData.slice(1).forEach(n => {
+ if (!n.x) return;
+ const col = typeColor[n.type] || '0,190,255';
+ ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y);
+ ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)';
+ ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke();
+ });
+ // Particles
+ _agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => {
+ const p = ((t*0.35+i*0.41)%1);
+ const col = typeColor[n.type]||'0,190,255';
+ const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p;
+ ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2);
+ ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill();
+ });
+ // Nodes
+ _agentTopoData.forEach((n,i) => {
+ if (!n.x) return;
+ const col = typeColor[n.type]||'0,190,255';
+ const nr = n.type==='hub' ? 13 : 7;
+ const pulse = Math.sin(t+i*0.9)*0.25+0.75;
+ if (n.online||n.type==='hub') {
+ const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5);
+ g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')');
+ g.addColorStop(1,'transparent');
+ ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2);
+ ctx.fillStyle=g; ctx.fill();
+ }
+ ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2);
+ ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)';
+ ctx.fill();
+ ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke();
+ ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)';
+ ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace';
+ ctx.textAlign='center';
+ ctx.fillText(n.label, n.x, n.y+nr+9);
+ });
+ _agentTopoRaf = requestAnimationFrame(frame);
+ }
+ frame();
+}
diff --git a/public_html/index.html b/public_html/index.html
index 4b512cf..3513da2 100644
--- a/public_html/index.html
+++ b/public_html/index.html
@@ -418,10 +418,12 @@
style="position:fixed;top:-9999px;left:-9999px;width:320px;height:240px">
-
-
-
-
+
+
+
+
+
+