Compare commits

13 Commits

Author SHA1 Message Date
myron 26b501b600 fix: address 8 code-review findings in KB topic manager
- Fix 1 (ORDER BY): use table alias t.id to force integer PK ordering;
  topic_id alias was causing alphabetical sort, breaking rotation offsets
- Fix 2 (PDOException): wrap INSERT/UPDATE in try/catch in kb_topic_save;
  duplicate topic_id now returns clean JSON error instead of HTTP 500
- Fix 3 (XSS/onclick): DEL button passes only t.id; tmDelete looks up
  name from _topicsData, removing JSON.stringify from onclick attribute
- Fix 4 (validation): topic_id must contain at least one [a-z0-9] after
  sanitization; "@@@" to "___" no longer passes required-field check
- Fix 5 (stale logs): guard comment and log messages updated from 20h to
  4h to match the actual 14400s threshold
- Fix 6 (dedup): tmEsc() replaced with existing esc() throughout; removed
  duplicate function definition
- Fix 7 (toggle rollback): tmToggle uses inline fetch to revert el.checked
  on API failure instead of leaving the UI in wrong state
- Fix 8 (confirm): tmDelete uses openModal confirmation instead of
  confirm() dialog, consistent with project live-popup convention

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 10:26:47 -05:00
myron 7327104612 feat: DB-driven KB topic manager — admin UI + migration
- Create kb_generator_topics table (140 topics migrated from hardcoded array)
- kb_intent_generator.php now loads active topics from DB instead of $BATCHES array
- Admin panel: MANAGE TOPICS button opens full topic manager modal
  - Browse all 140 topics with search, category, and active/disabled filters
  - Inline active toggle per topic (checkbox)
  - Add new topic form (topic_id, category, name, description)
  - Edit existing topics (all fields)
  - Delete topics with confirmation
  - Run count display
- Backend API cases: kb_topics, kb_topic_save, kb_topic_delete, kb_topic_toggle
- Topics can now be added/managed without any code changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 07:04:20 -05:00
myron a13f750846 feat(kb-intent): expand to 140 topics, 109 KB topic library
Rich topic library across mathematics (12), sciences (13), history (10),
government/economics, literature, life skills (13), technology (5),
Texas/local (4), national US (3), world affairs (5), human sexuality (2),
astronomy (3), space (2), culture/arts (8), sports (8), food/drink (4),
home/DIY (3), wellness (4), tech continued (3), national continued (2),
world continued (2), medicine (3), math continued (2), communication (2).

Each topic has detailed multi-subtopic descriptions (~200 chars each)
vs prior 8-word descriptions — significantly richer Groq prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 06:50:12 -05:00
myron 3c8dd8e206 feat(kb-intent): 122-topic rotation engine, every-6h cron
- Expanded $BATCHES from 25 to 122 topics across: life skills, personal
  finance, Texas/local, US national, world geopolitics, human sexuality
  (educational), deep astronomy (15 topics), and space exploration (15 topics)
- Rotation engine: 25 topics per run cycling through all 122 in order;
  wraps to topic 1 on cycle complete (~30h full cycle at 6h interval)
- batch_offset tracked in kb_facts for cross-run persistence
- Cron changed from 0 3 * * * to 0 */6 * * * (every 6 hours)
- 20h skip guard reduced to 4h so 6h cron isn't blocked
- max_tokens bumped 3500 → 5000 to prevent JSON truncation errors
- JSON extraction: partial recovery + raw snippet logged on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:53:57 -05:00
myron 554488aefa fix(kb-intent): reduce batch size 40→20 intents, raise max_tokens to 5000
Token budget was too low for 40 intents — responses were truncated mid-JSON,
causing "No JSON array found" on ~88% of batches. Fixes 880 errors/run.
Also adds partial-JSON truncation recovery and raw response debug logging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:44:22 -05:00
myron e99c3aa171 feat(admin): KB intent generator — force run, background on close, history panel
- Always runs when triggered from admin (JARVIS_FORCE_RUN=1 bypasses 20h guard)
- Closing popup stops UI polling but server job continues; toast confirms background run
- History panel at top of popup shows last success time/count + failures from last 7 days
- New intent_gen_history backend case parses cron.log for status summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:39:41 -05:00
myron 2528b37ea1 docs: update infrastructure reference for July 2026 session
- VM 210: correct SSH path, add llava:7b vision model
- Add Arc Reactor section (service, logs, SETUP button, vision cascade)
- JARVIS AI tiers: llama3.2 → llama3.1:8b, add vision tier
- Backup Systems: add JARVIS database backup (path, format, retention)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:32:31 -05:00
myron 46dabe3c31 feat(admin): Arc Reactor SETUP button — live log popup
- Replace arcSetup() confirm/toast with openModal() live popup
- Add arc_setup_log PHP backend (polls /var/log/jarvis/arc-setup.log)
- Color-coded line output: green=ok/done/active, red=error/fail, yellow=warn
- Auto-detects completion keywords and updates status header
- CLOSE button stops polling and refreshes worker table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:30:39 -05:00
myron d6a1fc9456 Fix backup script: tar.gz output, lockfile, correct log path, install at /usr/local/bin
- Renamed output to jarvis_backup_TIMESTAMP.tar.gz (admin panel pattern)
- Log now goes to /var/backups/jarvis/backup.log (admin panel reads this)
- Lockfile at /var/backups/jarvis/backup.lock (prevents concurrent runs)
- tmpdir + trap cleanup for safe temp handling
- Install at /usr/local/bin/jarvis-backup.sh (admin panel trigger path)
2026-07-01 22:20:58 -05:00
myron 2f74b98bbc Admin panel: add Arc Reactor SETUP button, fix RESTART to use systemctl
- SETUP button in Workers > Daemons runs full deploy:
  copies reactor.py + requirements.txt from deploy/, creates venv,
  pip install, installs jarvis-arc.service, systemctl enable + restart
- RESTART button now calls systemctl restart jarvis-arc (was nohup pkill)
- arcSetup() JS function with confirm dialog and loading state
- Setup log written to /var/log/jarvis/arc-setup.log
2026-07-01 22:13:28 -05:00
myron ed15ff12dd Add _vision_call() helper with Claude -> Ollama -> graceful fallback
- Centralises all vision logic in one place instead of duplicated inline blocks
- Claude remains primary; set OLLAMA_VISION_MODEL env var to enable a local
  vision model (e.g. llava, moondream) as automatic fallback
- Graceful degradation message when no vision provider is available
- Both handle_screenshot and handle_vision now use _vision_call()
2026-07-01 21:37:11 -05:00
myron 3f18cec739 Add missing email_sent/email_actions/email_triage tables to schema; add sent_at column 2026-07-01 21:29:48 -05:00
myron 8911645c20 Add jarvis-backup.sh — daily mysqldump with 7-day retention
Backup script was never migrated when JARVIS moved from DO to VM 211.
Runs daily at 3am, stores compressed dumps in /var/backups/jarvis/,
logs to /var/log/jarvis/backup.log.
2026-07-01 21:22:02 -05:00
6 changed files with 1020 additions and 152 deletions
+267
View File
@@ -0,0 +1,267 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
$lastRun = JarvisDB::single(
"SELECT updated_at FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $lastRun && (time() - strtotime($lastRun['updated_at'])) < 14400) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
+55
View File
@@ -479,3 +479,58 @@ CREATE TABLE IF NOT EXISTS `guardian_events` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Dump completed on 2026-06-29 23:15:44
CREATE TABLE IF NOT EXISTS `email_triage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) NOT NULL,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`date_received` datetime DEFAULT NULL,
`category` varchar(32) NOT NULL DEFAULT 'info',
`priority` int(11) NOT NULL DEFAULT 3,
`summary` text DEFAULT NULL,
`draft_reply` text DEFAULT NULL,
`action_taken` varchar(32) NOT NULL DEFAULT 'none',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_msg` (`msg_id`),
KEY `idx_category` (`category`),
KEY `idx_action` (`action_taken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) DEFAULT NULL,
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`received_at` datetime DEFAULT NULL,
`suggested_title` varchar(255) DEFAULT NULL,
`suggested_date` date DEFAULT NULL,
`task_id` int(11) DEFAULT NULL,
`appointment_id` int(11) DEFAULT NULL,
`dismissed` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_dismissed` (`dismissed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_sent` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`to_email` varchar(255) NOT NULL,
`to_name` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`body` text DEFAULT NULL,
`triage_id` int(11) DEFAULT NULL,
`status` varchar(32) NOT NULL DEFAULT 'sent',
`sent_at` timestamp NULL DEFAULT current_timestamp(),
`error` text DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_account` (`account`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# JARVIS backup — DB dump as tar.gz, admin-panel compatible
BACKUP_DIR="/var/backups/jarvis"
LOG="$BACKUP_DIR/backup.log"
LOCK="$BACKUP_DIR/backup.lock"
DB_NAME="jarvis_db"
DB_USER="jarvis_user"
DB_PASS="J4rv1s_Pr0t0c0l_2026!"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
OUTFILE="$BACKUP_DIR/jarvis_backup_${TIMESTAMP}.tar.gz"
TMPDIR=$(mktemp -d)
mkdir -p "$BACKUP_DIR"
touch "$LOCK"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup..." >> "$LOG"
cleanup() { rm -rf "$TMPDIR"; rm -f "$LOCK"; }
trap cleanup EXIT
if mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$TMPDIR/jarvis_db.sql" 2>>"$LOG"; then
tar -czf "$OUTFILE" -C "$TMPDIR" jarvis_db.sql
SIZE=$(du -sh "$OUTFILE" | cut -f1)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup OK: $(basename "$OUTFILE") ($SIYE)" >> "$LOG"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: mysqldump failed" >> "$LOG"
exit 1
fi
find "$BACKUP_DIR" -name "jarvis_backup_*.tar.gz" -mtime +7 -delete
COUNT=$(ls "$BACKUP_DIR"/jarvis_backup_*.tar.gz 2>/dev/null | wc -l)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done. Files retained: $COUNT" >> "$LOG"
+67 -107
View File
@@ -41,16 +41,17 @@ DB_PORT = 3306
DB_USER = "jarvis_user"
DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
DB_NAME = "jarvis_db"
LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log"
LOG_FILE = "/var/log/jarvis/arc_reactor.log"
POLL_INTERVAL = 3
HEARTBEAT_INTERVAL = 30
CLAUDE_API_KEY = "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"
CLAUDE_MODEL = "claude-sonnet-4-6"
GROQ_API_KEY = "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"
GROQ_API_KEY = "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"
GROQ_MODEL = "llama-3.3-70b-versatile"
OLLAMA_HOST = "http://10.48.200.210:11434"
OLLAMA_MODEL = "llama3.1:8b"
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "") # e.g. "llava" or "moondream" -- empty = disabled
GMAIL_USER = "myronblair@gmail.com"
GMAIL_PASS = "demsvdylwweacbcx"
@@ -175,15 +176,56 @@ async def _ollama_call(messages: list, system: str = "") -> str:
data = await resp.json()
return data.get("response", "")
async def _ollama_vision_call(image_b64: str, prompt: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{OLLAMA_HOST}/api/generate",
json={"model": "llava:7b", "prompt": prompt, "images": [image_b64], "stream": False},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
data = await resp.json()
return data.get("response", "").strip()
# -- VISION CALL ----------------------------------------------------------
async def _vision_call(image_b64: str, prompt: str) -> tuple:
"""
Vision provider cascade: Claude -> Ollama vision model -> graceful fallback.
Returns (analysis: str, provider: str).
To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava").
"""
# 1. Claude (primary)
if CLAUDE_API_KEY:
try:
import anthropic as _anthropic
_client = _anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
_msg = await _client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
]}],
)
text = _msg.content[0].text if _msg.content else ""
log.info("[VISION] Claude vision OK")
return text, "claude"
except Exception as e:
log.warning(f"[VISION] Claude failed ({e}), trying next provider")
# 2. Ollama vision model (if configured via OLLAMA_VISION_MODEL env var)
if OLLAMA_VISION_MODEL:
try:
async with aiohttp.ClientSession() as _sess:
_payload = {"model": OLLAMA_VISION_MODEL, "prompt": prompt,
"images": [image_b64], "stream": False}
async with _sess.post(f"{OLLAMA_HOST}/api/generate", json=_payload,
timeout=aiohttp.ClientTimeout(total=120)) as _resp:
if _resp.status == 200:
_data = await _resp.json()
text = _data.get("response", "")
log.info(f"[VISION] Ollama vision OK ({OLLAMA_VISION_MODEL})")
return text, f"ollama/{OLLAMA_VISION_MODEL}"
raise RuntimeError(f"Ollama HTTP {_resp.status}")
except Exception as e:
log.warning(f"[VISION] Ollama vision failed ({e})")
# 3. No vision provider available
msg = ("[Vision unavailable] Claude credits depleted and no local vision model configured. "
"To enable: pull a vision model on Ollama (e.g. 'ollama pull llava') then set "
"OLLAMA_VISION_MODEL=llava in the jarvis-arc systemd environment.")
log.warning("[VISION] All vision providers unavailable")
return msg, "none"
async def handle_llm(payload: dict) -> dict:
message = payload.get("message", "")
@@ -668,44 +710,19 @@ async def handle_screenshot(payload: dict) -> dict:
height = result.get("height", 0)
file_size = result.get("file_size", 0)
# Run vision analysis if we have an image — llava first, Claude fallback
# Run Claude vision analysis if we have an image
analysis = ""
provider_used = ""
if do_analyze and image_b64:
try:
analysis = await _ollama_vision_call(image_b64, analyze_prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": analyze_prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
log.info(f"[VISION] Claude fallback analysis complete ({len(analysis)} chars)")
except Exception as e2:
log.warning(f"[VISION] Claude fallback also failed: {e2}")
analysis = f"Vision analysis unavailable: {e2}"
analysis, provider_used = await _vision_call(image_b64, analyze_prompt)
log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)")
elif do_analyze and not image_b64 and result.get("snapshot_type") == "text":
# Text-only sysinfo snapshot — summarize with Ollama
# Text-only sysinfo snapshot — summarize with LLM
try:
snap_text = json.dumps(result, indent=2)[:3000]
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
analysis = await llm_call([{"role": "user", "content": prompt}], "ollama")
provider_used = "ollama"
analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
provider_used = "groq"
except Exception as e:
analysis = f"Analysis unavailable: {e}"
@@ -758,41 +775,9 @@ async def handle_vision(payload: dict) -> dict:
if not image_b64:
raise ValueError("No image data provided")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname} provider={provider}")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}")
analysis = ""
provider_used = provider
if provider == "ollama" or provider == "llava":
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
else:
# Default: llava first, Claude fallback
try:
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
except Exception as e2:
raise RuntimeError(f"Vision analysis failed (llava: {e}, claude: {e2})")
analysis, provider_used = await _vision_call(image_b64, prompt)
# Update stored screenshot if we have an ID
if screenshot_id:
@@ -1053,7 +1038,7 @@ async def guardian_loop() -> None:
"for Myron. Be direct about severity and what action to take. "
"No markdown, no headers."
)
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "ollama")
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
# Update the most recent guardian event with AI analysis
await db_execute(
"""UPDATE guardian_events SET ai_analysis=%s
@@ -1082,7 +1067,7 @@ async def _guardian_inject_chat(message: str) -> None:
"""Write a proactive JARVIS message into the conversations table so the HUD picks it up."""
try:
await db_execute(
"""INSERT INTO conversations (session_id, role, content, created_at)
"""INSERT INTO conversations (session_id, role, message, created_at)
VALUES ('guardian', 'assistant', %s, NOW())""",
(message,)
)
@@ -1096,7 +1081,7 @@ async def handle_sitrep(payload: dict) -> dict:
payload: { detail: brief|full, provider: groq }
"""
detail = payload.get("detail", "full")
provider = payload.get("provider", "ollama")
provider = payload.get("provider", "groq")
log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
@@ -1837,7 +1822,7 @@ Keep the tone confident and action-oriented. Format with clear sections. Under 4
# Store in conversations so HUD can surface it
await db_execute(
"INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())",
"INSERT INTO conversations (session_id, role, message, created_at) VALUES ('guardian','assistant',%s,NOW())",
(review_text,)
)
@@ -2240,31 +2225,6 @@ async def lifespan(app: FastAPI):
log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}")
await get_pool()
await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,))
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_config (
id INT AUTO_INCREMENT PRIMARY KEY,
key_name VARCHAR(64) NOT NULL,
value VARCHAR(255) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_key (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info',
agent_id VARCHAR(64) NOT NULL DEFAULT '',
hostname VARCHAR(128) NOT NULL DEFAULT '',
metric VARCHAR(64) NOT NULL DEFAULT '',
value FLOAT NOT NULL DEFAULT 0,
threshold FLOAT NOT NULL DEFAULT 0,
message TEXT NOT NULL,
ai_analysis TEXT NOT NULL DEFAULT '',
acknowledged TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_severity (severity),
KEY idx_ack (acknowledged),
KEY idx_created (created_at),
KEY idx_agent (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
asyncio.create_task(job_poller())
asyncio.create_task(heartbeat_loop())
asyncio.create_task(guardian_loop())
@@ -2784,13 +2744,13 @@ async def guardian_chat_events(since: str = ""):
"""Return proactive guardian messages injected into conversations."""
if since:
rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations "
"SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC",
(since,)
)
else:
rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations "
"SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10"
)
return rows or []
@@ -1,5 +1,5 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
**Last Updated:** 2026-06-18
**Last Updated:** 2026-07-01
**Owner:** Myron Blair — myronblair@outlook.com
---
@@ -42,7 +42,7 @@ HOME NETWORK (FortiGate router at 10.48.200.1)
│ ├── VM 113 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
│ ├── VM 118 10.48.200.18 Homebridge
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
│ ├── VM 210 10.48.200.210 Ollama (local LLM) (local LLM)
│ ├── VM 210 10.48.200.210 Ollama (local LLM + vision) — llama3.1:8b, llava:7b
│ └── CT110 10.48.200.19 WireGuard exit container
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
@@ -305,17 +305,20 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
---
### VM 210 — Ollama Local LLM (PVE1)
### VM 210 — Ollama Local LLM + Vision (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.210 |
| **OS** | Ubuntu (cloud image) |
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat |
| **SSH** | `ssh root@10.48.200.210` via PVE1 hop — password: `Joker1974!!!` |
| **Purpose** | Local AI inference — chat (llama3.1:8b) + vision (llava:7b) |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
| **Models** | `llama3.1:8b` (chat/Tier 1), `llava:7b` (vision cascade) |
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
**Vision cascade:** Arc Reactor calls Claude first; if Claude credits depleted, falls back to llava:7b via Ollama.
Vision is enabled via: `/etc/systemd/system/jarvis-arc.service.d/vision.conf``OLLAMA_VISION_MODEL=llava:7b`
---
@@ -484,12 +487,30 @@ Voice (browser mic)
→ /api/chat.php (4-tier AI)
Tier 0.7: KB intents / planner (tasks, appointments)
Tier 1: Knowledge Base (MySQL)
Tier 1.5: Ollama (10.48.200.210:11434, llama3.2) — local LLM
Tier 1.5: Ollama (10.48.200.210:11434, llama3.1:8b) — local LLM
Vision: Ollama llava:7b (via Arc Reactor _vision_call cascade)
Tier 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker
```
### Arc Reactor (AI Job Processor)
**Service:** `jarvis-arc` (systemd) — port 7474
**Runtime:** `/opt/jarvis-arc/` (Python venv, `reactor.py`)
**Log:** `/var/log/jarvis/arc.log`
**Admin button:** Workers → Daemons → `SETUP` (live popup) / `RESTART`
**Vision:** Claude → Ollama llava:7b → graceful fallback
**Vision config:** `/etc/systemd/system/jarvis-arc.service.d/vision.conf`
```bash
systemctl status jarvis-arc
systemctl restart jarvis-arc
journalctl -u jarvis-arc -f
```
To re-deploy Arc Reactor from source:
Use **Workers → Daemons → SETUP** in JARVIS admin (live log popup shows progress).
### Deploy Pipeline
```
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
@@ -594,6 +615,14 @@ fs_cli -x "reloadacl" # reload ACL (safe)
## 10. BACKUP SYSTEMS
### JARVIS Database Backup
- **Script:** `/usr/local/bin/jarvis-backup.sh` (also at `/var/www/jarvis/deploy/`)
- **Output:** `/var/backups/jarvis/jarvis_backup_TIMESTAMP.tar.gz`
- **Log:** `/var/backups/jarvis/backup.log`
- **Retention:** 7 days (auto-purge)
- **Trigger:** JARVIS admin → Backups → RUN BACKUP NOW, or run script directly
- **DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
### DO Server Backup
- **Repo:** `myronblair/do-server-config`
- **Schedule:** Weekly, Sunday 4am
+565 -39
View File
@@ -242,9 +242,7 @@ if ($action) {
$search = strtolower(trim($_GET['search'] ?? ''));
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
'device_tracker','event','image','person','zone','tts','conversation',
'assist_satellite','input_button','media_player','scene','water_heater',
'alarm_control_panel','automation','script','calendar','notify',
'weather','camera','siren','remote','todo','lawn_mower'];
'assist_satellite','input_button'];
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
'_siren_on','_email_on','_manual_record','_infrared_',
'do_not_disturb','matter_server','zerotier','mariadb',
@@ -489,25 +487,26 @@ if ($action) {
$arcCounts = [];
foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt'];
$cronLast = [];
$cronLog = '/home/jarvis.orbishosting.com/logs/cron.log';
$cronLog = '/var/log/jarvis/cron.log';
if (file_exists($cronLog)) {
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar' " . escapeshellarg($cronLog) . " | tail -60")));
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar\\|intent' " . escapeshellarg($cronLog) . " | tail -60")));
foreach ($lines as $line) {
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
if (preg_match('/^\\[(\\d{2}{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
if (preg_match('/^\\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\\].*intent/i', $line, $m)) $cronLast['kb_intent_generator'] = $m[1];
}
}
if (empty($cronLast['stats_cache'])) {
$row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")');
if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t'];
}
$deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log';
$deployLog = '/var/log/jarvis/deploy.log';
if (file_exists($deployLog)) {
$last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1");
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1];
}
$wdLog = '/home/jarvis.orbishosting.com/logs/watchdog.log';
$wdLog = '/var/log/jarvis/watchdog.log';
if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog));
$bkLog = '/var/backups/jarvis/backup.log';
if (file_exists($bkLog)) {
@@ -520,9 +519,9 @@ if ($action) {
break;
case 'worker_action':
$wType = $data['worker_type'] ?? '';
$wId = $data['worker_id'] ?? '';
$wAction = $data['action'] ?? '';
$wType = $_REQUEST['worker_type'] ?? '';
$wId = $_REQUEST['worker_id'] ?? '';
$wAction = $_REQUEST['waction'] ?? $_REQUEST['action'] ?? '';
if ($wType === 'agent' && $wAction === 'update') {
JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)',
[$wId,'update','{}','pending']);
@@ -535,39 +534,153 @@ if ($action) {
j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']);
} elseif ($wType === 'cron' && $wAction === 'run') {
$scripts = [
'facts_collector'=>[true, '/home/jarvis.orbishosting.com/api/endpoints/facts_collector.php'],
'stats_cache' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/stats_cache.php'],
'calendar_sync' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/calendar_sync.php'],
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
'jarvis_watchdog'=>[false,'/usr/local/bin/jarvis-watchdog.sh'],
'facts_collector' =>[true, '/var/www/jarvis/api/endpoints/facts_collector.php'],
'stats_cache' =>[true, '/var/www/jarvis/api/endpoints/stats_cache.php'],
'calendar_sync' =>[true, '/var/www/jarvis/api/endpoints/calendar_sync.php'],
'kb_intent_generator' =>[true, '/var/www/jarvis/api/endpoints/kb_intent_generator.php'],
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
'jarvis_watchdog' =>[false,'/usr/local/bin/jarvis-watchdog.sh'],
];
if (isset($scripts[$wId])) {
[$isPhp,$path] = $scripts[$wId];
$env = ($wId === 'kb_intent_generator') ? 'JARVIS_FORCE_RUN=1 ' : '';
$cmd = $isPhp
? '/usr/local/lsws/lsphp85/bin/lsphp '.escapeshellarg($path).' >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 &'
: escapeshellcmd($path).' >> /home/jarvis.orbishosting.com/logs/deploy.log 2>&1 &';
? $env.'/usr/bin/php8.3 '.escapeshellarg($path).' >> /var/log/jarvis/cron.log 2>&1 &'
: escapeshellcmd($path).' >> /var/log/jarvis/deploy.log 2>&1 &';
shell_exec($cmd);
j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']);
} else { bad('Unknown cron worker'); }
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') {
shell_exec('bash -c "mkdir -p /home/jarvis.orbishosting.com/logs; systemctl restart jarvis-arc 2>/dev/null || (pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &)"');
j(['ok'=>true,'msg'=>'Arc Reactor restarting']);
shell_exec('systemctl restart jarvis-arc 2>&1');
k(['ok'=>true,'msg'=>'Arc Reactor restarting via systemd']);
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'setup') {
$setupLog = '/home/jarvis.orbishosting.com/logs/arc_reactor.log';
$cmd = 'bash -c "'.
'mkdir -p /opt/jarvis-arc /home/jarvis.orbishosting.com/logs && '.
'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py && '.
'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt && '.
'if [ ! -d /opt/jarvis-arc/venv ]; then python3 -m venv /opt/jarvis-arc/venv; fi && '.
'/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt && '.
'pkill -f reactor.py 2>/dev/null; sleep 1 && '.
'cd /opt/jarvis-arc && source venv/bin/activate && '.
'nohup python3 reactor.py >> '.$setupLog.' 2>&1 &'.
'" >> '.$setupLog.' 2>&1';
shell_exec($cmd);
j(['ok'=>true,'msg'=>'Arc Reactor setup started — check log in ~30s then restart']);
$log = '/var/log/jarvis/arc-setup.log';
$cmd = implode(' && ', [
'mkdir -p /opt/jarvis-arc /var/log/jarvis',
'cp /var/www/jarvis/deploy/reactor.py /opt/jarvis-arc/reactor.py',
'cp /var/www/jarvis/deploy/requirements.txt /opt/jarvis-arc/requirements.txt',
'[ ! -f /opt/jarvis-arc/venv/bin/activate ] && python3 -m venv /opt/jarvis-arc/venv || true',
'/opt/jarvis-arc/venv/bin/pip install -q -r /opt/jarvis-arc/requirements.txt',
'cp /var/www/jarvis/deploy/jarvis-arc.service /etc/systemd/system/jarvis-arc.service',
'systemctl daemon-reload',
'systemctl enable jarvis-arc',
'systemctl restart jarvis-arc',
]);
shell_exec("($cmd) >> " . escapeshellarg($log) . " 2>&1 &");
k(['ok'=>true,'msg'=>'Arc Reactor setup started — check ' . $log]);
} elseif ($wType === 'agent' && $wAction === 'update_status') {
$ag = JarvisDB::single('SELECT version, status FROM registered_agents WHERE agent_id=?', [$wId]);
j(['ok'=>true,'version'=>$ag['version']??null,'status'=>$ag['status']??'unknown']);
} else { bad('Invalid worker action'); }
break;
case 'intent_gen_history':
$logFile = '/var/log/jarvis/cron.log';
$result = ['last_success'=>null,'last_success_count'=>null,'failures'=>[]];
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$cutoff = time() - 7 * 86400;
foreach (array_reverse($lines) as $line) {
if (stripos($line, 'KB Intent Generator') === false) continue;
// parse timestamp
preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/', $line, $tm);
$ts = isset($tm[1]) ? strtotime($tm[1]) : 0;
// last success (done/complete)
if (!$result['last_success'] && preg_match('/done|complete/i', $line)) {
$result['last_success'] = $tm[1] ?? null;
preg_match('/inserted[:\s]+(\d+)/i', $line, $cnt);
$result['last_success_count'] = isset($cnt[1]) ? (int)$cnt[1] : null;
}
// failures in last 7 days
if ($ts >= $cutoff && preg_match('/error|fail/i', $line)) {
$result['failures'][] = $line;
if (count($result['failures']) >= 20) break;
}
}
$result['failures'] = array_reverse($result['failures']);
}
j($result);
case 'intent_gen_log':
$logFile = '/var/log/jarvis/cron.log';
$since = max(0, (int)($_GET['since'] ?? 0));
if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); }
$allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$total = count($allLines);
// snapshot=1 just returns the current line count (call BEFORE triggering run)
if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); }
// Return only KB Intent Generator lines from position $since onward
$out = [];
for ($i = $since; $i < $total; $i++) {
if (stripos($allLines[$i], 'KB Intent Generator') !== false) {
$out[] = $allLines[$i];
}
}
j(['lines'=>$out, 'next_line'=>$total]);
// ── KB GENERATOR TOPICS ─────────────────────────────────────────────
case 'kb_topics':
$where = ['1=1']; $params = [];
if (!empty($_GET['q'])) {
$q2 = '%'.$_GET['q'].'%';
$where[] = '(topic_name LIKE ? OR topic_id LIKE ? OR category LIKE ? OR description LIKE ?)';
$params = array_merge($params, [$q2,$q2,$q2,$q2]);
}
if (isset($_GET['cat']) && $_GET['cat'] !== '') { $where[] = 'category=?'; $params[] = $_GET['cat']; }
if (isset($_GET['active']) && $_GET['active'] !== '') { $where[] = 'active=?'; $params[] = (int)$_GET['active']; }
$topics = JarvisDB::query(
'SELECT id,topic_id,category,topic_name,description,active,run_count,last_run_at
FROM kb_generator_topics WHERE '.implode(' AND ',$where).' ORDER BY category,topic_name',
$params
);
$catRows = JarvisDB::query('SELECT DISTINCT category FROM kb_generator_topics ORDER BY category');
j(['topics'=>$topics, 'categories'=>array_column($catRows,'category')]);
case 'kb_topic_save':
$id = (int)($_POST['id'] ?? 0);
$tid = preg_replace('/[^a-z0-9_]/', '_', strtolower(trim($_POST['topic_id'] ?? '')));
$cat = trim($_POST['category'] ?? '');
$name = trim($_POST['topic_name'] ?? '');
$desc = trim($_POST['description'] ?? '');
$act = (int)!empty($_POST['active']);
if (!$tid || !preg_match('/[a-z0-9]/', $tid) || !$cat || !$name || !$desc)
bad('All fields required — topic_id must contain at least one letter or digit');
try {
if ($id) {
JarvisDB::execute(
'UPDATE kb_generator_topics SET topic_id=?,category=?,topic_name=?,description=?,active=?,updated_at=NOW() WHERE id=?',
[$tid,$cat,$name,$desc,$act,$id]
);
} else {
JarvisDB::execute(
'INSERT INTO kb_generator_topics (topic_id,category,topic_name,description,active) VALUES (?,?,?,?,?)',
[$tid,$cat,$name,$desc,$act]
);
}
} catch (\PDOException $e) {
bad(strpos($e->getMessage(), 'Duplicate entry') !== false
? 'topic_id already in use — choose a different slug'
: 'Database error');
}
j(['ok'=>true,'msg'=> $id ? 'Topic updated' : 'Topic created']);
case 'kb_topic_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM kb_generator_topics WHERE id=?', [$id]);
j(['ok'=>true]);
case 'kb_topic_toggle':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('UPDATE kb_generator_topics SET active=NOT active, updated_at=NOW() WHERE id=?', [$id]);
$row = JarvisDB::single('SELECT active FROM kb_generator_topics WHERE id=?', [$id]);
j(['ok'=>true,'active'=>(bool)$row['active']]);
case 'arc_setup_log':
$logFile = '/var/log/jarvis/arc-setup.log';
$since = max(0, (int)($_GET['since'] ?? 0));
if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); }
$allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$total = count($allLines);
if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); }
j(['lines'=>array_values(array_slice($allLines, $since)), 'next_line'=>$total]);
case 'arc_status':
$ch = curl_init('http://127.0.0.1:7474/status');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]);
@@ -1442,6 +1555,8 @@ select.filter-sel:focus{border-color:var(--cyan)}
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
<button class="btn btn-sm" style="border-color:#7f5fff;color:#7f5fff" onclick="openTopicsManager()">&#9881; MANAGE TOPICS</button>
<button class="btn btn-sm" style="border-color:#00d4ff;color:#00d4ff" onclick="runIntentGenerator()">&#9654; RUN GENERATOR</button>
</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
@@ -2174,7 +2289,8 @@ const CRON_DEFS = [
{id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'},
{id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'},
{id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true},
{id:'do_server_backup',label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
{id:'do_server_backup', label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
{id:'kb_intent_generator', label:'KB Intent Generator', schedule:'Daily 3am', host:'jarvis'},
];
function wBtn(col) {
const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)';
@@ -2201,12 +2317,422 @@ function wToast(msg,err=false) {
t.style.opacity='1';t.textContent=msg;
setTimeout(()=>{t.style.opacity='0';},3000);
}
async function runIntentGenerator() {
const modalHtml = `
<div style="font-family:var(--mono);font-size:0.72rem;line-height:1.8">
<div id="ig-history" style="background:#0a0f14;border:1px solid var(--border);border-radius:3px;padding:8px 12px;margin-bottom:10px;font-size:0.6rem;color:var(--text-dim)">Loading history...</div>
<div id="ig-status" style="color:var(--cyan);margin-bottom:8px;font-size:0.65rem;letter-spacing:1px">LAUNCHING...</div>
<div id="ig-log" style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:10px 12px;min-height:160px;max-height:320px;overflow-y:auto;white-space:pre-wrap;font-size:0.62rem;line-height:1.7;color:var(--text-dim)">Waiting for first output...</div>
<div id="ig-stats" style="margin-top:10px;display:flex;gap:20px;font-size:0.6rem;color:var(--text-dim)"></div>
</div>`;
openModal('&#9654; KB INTENT GENERATOR', modalHtml, null, null);
document.getElementById('modalSave').textContent = 'CLOSE';
const logEl = document.getElementById('ig-log');
const statEl = document.getElementById('ig-status');
const statsEl = document.getElementById('ig-stats');
const historyEl = document.getElementById('ig-history');
let _igStop = false;
let _igDone = false;
let lastLine = 0;
let dotted = 0;
// Close just stops polling — server job keeps running
const stopAndClose = () => {
_igStop = true;
closeModal();
if (!_igDone) toast('KB generator running in background', 'ok');
};
document.getElementById('modalSave').onclick = stopAndClose;
document.getElementById('modalClose').onclick = stopAndClose;
// Load history panel (async, non-blocking)
api('intent_gen_history').then(h => {
if (!historyEl) return;
let html = '';
if (h?.last_success) {
html += `<span style="color:var(--green)">&#10003; Last success: ${h.last_success}`;
if (h.last_success_count != null) html += ` (+${h.last_success_count} intents)`;
html += '</span>';
} else {
html += '<span style="color:var(--text-dim)">No recorded successes in log</span>';
}
if (h?.failures?.length) {
html += `<br><span style="color:var(--red)">&#9888; ${h.failures.length} failure line(s) in last 7 days:</span>`;
h.failures.slice(-3).forEach(f => {
html += `<div style="color:var(--red);opacity:0.7;margin-left:8px">${f.replace(/</g,'&lt;')}</div>`;
});
}
historyEl.innerHTML = html || '<span>No history found</span>';
}).catch(() => { if (historyEl) historyEl.textContent = 'History unavailable'; });
// Snapshot log position BEFORE triggering
const snap = await api('intent_gen_log', {snapshot:1});
lastLine = snap?.next_line ?? 0;
// Kick off the generator (JARVIS_FORCE_RUN=1 set server-side — bypasses 20h guard)
const kick = await api('worker_action', {worker_type:'cron', worker_id:'kb_intent_generator', waction:'run'});
if (!kick || !kick.ok) {
statEl.style.color = 'var(--red)';
statEl.textContent = 'LAUNCH FAILED: ' + (kick?.error || 'unknown error');
document.getElementById('modalSave').textContent = 'CLOSE';
return;
}
statEl.textContent = 'RUNNING — polling log every 3s...';
async function pollLog() {
if (_igStop) return;
try {
const res = await api('intent_gen_log', {since: lastLine});
if (res && Array.isArray(res.lines) && res.lines.length) {
lastLine = res.next_line;
const isFirst = logEl.textContent === 'Waiting for first output...';
if (isFirst) logEl.textContent = '';
res.lines.forEach(line => {
const div = document.createElement('div');
const lower = line.toLowerCase();
if (lower.includes('error') || lower.includes('fail'))
div.style.color = 'var(--red)';
else if (lower.includes('skip') || lower.includes('warn'))
div.style.color = 'var(--yellow)';
else if (lower.includes('insert') || lower.includes('added') || lower.includes('done') || lower.includes('complete') || lower.includes('cleanup') || lower.includes('force-run'))
div.style.color = 'var(--green)';
else
div.style.color = 'var(--text-dim)';
div.textContent = line;
logEl.appendChild(div);
});
logEl.scrollTop = logEl.scrollHeight;
const lastLines = res.lines.join(' ').toLowerCase();
if (lastLines.includes('kb intent generator: done') || lastLines.includes('total inserted') || lastLines.includes('cleanup complete')) {
_igDone = true;
statEl.style.color = 'var(--green)';
statEl.textContent = 'COMPLETE';
const total = (res.lines.join('\n').match(/inserted[:\s]+(\d+)/i) || [])[1];
if (total) statsEl.innerHTML = `<span style="color:var(--green)">+${total} intents added</span>`;
document.getElementById('modalSave').textContent = 'CLOSE';
return;
}
dotted = 0;
} else {
dotted++;
if (dotted < 30) {
const waitDiv = document.createElement('div');
waitDiv.style.color = 'rgba(0,212,255,0.3)';
waitDiv.textContent = '· waiting for output' + '.'.repeat(dotted % 4);
const last = logEl.lastChild;
if (last && last.textContent && last.textContent.startsWith('· waiting')) {
logEl.replaceChild(waitDiv, last);
} else {
if (logEl.textContent === 'Waiting for first output...') logEl.textContent = '';
logEl.appendChild(waitDiv);
}
logEl.scrollTop = logEl.scrollHeight;
} else {
statEl.style.color = 'var(--yellow)';
statEl.textContent = 'RUNNING IN BACKGROUND (check cron log for results)';
return;
}
}
} catch(e) {}
if (!_igStop) setTimeout(pollLog, 3000);
}
setTimeout(pollLog, 2000);
}
let _topicsData = [];
let _topicsCats = [];
async function openTopicsManager() {
const modalHtml = `
<div style="font-family:var(--mono);font-size:0.72rem">
<div id="tm-view-list">
<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap">
<input id="tm-search" type="text" placeholder="Search topics…" style="flex:1;min-width:140px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" oninput="tmFilter()">
<select id="tm-cat-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
<option value="">ALL CATEGORIES</option>
</select>
<select id="tm-active-filter" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit" onchange="tmFilter()">
<option value="">ALL STATUS</option>
<option value="1">ACTIVE</option>
<option value="0">DISABLED</option>
</select>
<button class="btn btn-sm btn-green" onclick="tmEditTopic(null)">+ ADD</button>
</div>
<div id="tm-stats" style="color:var(--text-dim);font-size:0.6rem;margin-bottom:6px;letter-spacing:0.5px"></div>
<div id="tm-list" style="max-height:370px;overflow-y:auto"><div class="loading">LOADING…</div></div>
</div>
<div id="tm-view-edit" style="display:none">
<div style="margin-bottom:12px">
<button class="btn btn-sm" onclick="tmShowList()">&#8592; BACK</button>
<span id="tm-edit-title" style="color:var(--cyan);margin-left:12px;font-size:0.65rem;letter-spacing:1px"></span>
</div>
<div style="display:grid;gap:8px">
<input type="hidden" id="tm-edit-id">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC ID (slug)
<input id="tm-edit-tid" type="text" placeholder="e.g. math_arith" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
</label>
<label style="font-size:0.6rem;color:var(--text-dim)">CATEGORY
<input id="tm-edit-cat" type="text" placeholder="e.g. mathematics" list="tm-cat-dl" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
<datalist id="tm-cat-dl"></datalist>
</label>
</div>
<label style="font-size:0.6rem;color:var(--text-dim)">TOPIC NAME
<input id="tm-edit-name" type="text" placeholder="e.g. Arithmetic and number sense" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;box-sizing:border-box">
</label>
<label style="font-size:0.6rem;color:var(--text-dim)">DESCRIPTION (subtopics to cover comma-separated)
<textarea id="tm-edit-desc" rows="5" placeholder="e.g. fractions, decimals, order of operations, prime numbers…" style="width:100%;margin-top:3px;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:5px 8px;font-size:0.7rem;border-radius:3px;font-family:inherit;resize:vertical;box-sizing:border-box"></textarea>
</label>
<label style="font-size:0.6rem;color:var(--text-dim);display:flex;align-items:center;gap:6px;cursor:pointer">
<input id="tm-edit-active" type="checkbox" checked style="cursor:pointer"> ACTIVE (included in rotation)
</label>
</div>
</div>
</div>`;
openModal('&#9881; KB TOPIC MANAGER', modalHtml, null, null);
const saveBtn = document.getElementById('modalSave');
const closeBtn = document.getElementById('modalClose');
saveBtn.textContent = 'SAVE TOPIC';
saveBtn.style.display = 'none';
saveBtn.onclick = tmSaveTopic;
closeBtn.onclick = closeModal;
await tmLoadTopics();
}
async function tmLoadTopics() {
const res = await api('kb_topics');
_topicsData = res?.topics || [];
_topicsCats = res?.categories || [];
const catSel = document.getElementById('tm-cat-filter');
if (catSel) {
const cur = catSel.value;
while (catSel.options.length > 1) catSel.remove(1);
_topicsCats.forEach(c => catSel.add(new Option(c.toUpperCase(), c)));
if (cur) catSel.value = cur;
}
const dl = document.getElementById('tm-cat-dl');
if (dl) { dl.innerHTML = ''; _topicsCats.forEach(c => { const o = document.createElement('option'); o.value=c; dl.appendChild(o); }); }
tmFilter();
}
function tmFilter() {
const q = (document.getElementById('tm-search')?.value || '').toLowerCase();
const cat = document.getElementById('tm-cat-filter')?.value || '';
const active = document.getElementById('tm-active-filter')?.value;
const rows = _topicsData.filter(t => {
if (cat && t.category !== cat) return false;
if (active !== '' && active !== null && active !== undefined && String(t.active) !== String(active)) return false;
if (q && !`${t.topic_name} ${t.topic_id} ${t.category} ${t.description}`.toLowerCase().includes(q)) return false;
return true;
});
const total = _topicsData.length;
const activeCount = _topicsData.filter(t => t.active == 1).length;
const statsEl = document.getElementById('tm-stats');
if (statsEl) statsEl.innerHTML =
`<span style="color:var(--cyan)">${total} topics</span> &nbsp;|&nbsp; ` +
`<span style="color:var(--green)">${activeCount} active</span> &nbsp;|&nbsp; ` +
`<span style="color:var(--yellow)">${total-activeCount} disabled</span>` +
(rows.length < total ? ` &nbsp;|&nbsp; <span style="color:var(--text-dim)">${rows.length} shown</span>` : '');
const listEl = document.getElementById('tm-list');
if (!listEl) return;
if (!rows.length) {
listEl.innerHTML = '<div style="color:var(--text-dim);padding:20px;text-align:center">No topics match filter</div>';
return;
}
listEl.innerHTML = `<table style="width:100%;border-collapse:collapse">
<thead><tr style="color:var(--cyan);font-size:0.58rem;border-bottom:1px solid var(--border)">
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC ID</th>
<th style="padding:4px 6px;text-align:left;font-weight:normal">CATEGORY</th>
<th style="padding:4px 6px;text-align:left;font-weight:normal">TOPIC NAME</th>
<th style="padding:4px 6px;text-align:center;font-weight:normal">ON</th>
<th style="padding:4px 6px;text-align:center;font-weight:normal">RUNS</th>
<th style="padding:4px 6px;text-align:right;font-weight:normal">ACTIONS</th>
</tr></thead>
<tbody>${rows.map(t => `
<tr style="border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.65rem">
<td style="padding:4px 6px;color:var(--text-dim);font-size:0.57rem">${esc(t.topic_id)}</td>
<td style="padding:4px 6px"><span style="background:var(--bg2);padding:1px 5px;border-radius:2px;font-size:0.57rem;white-space:nowrap">${esc(t.category)}</span></td>
<td style="padding:4px 6px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.description)}">${esc(t.topic_name)}</td>
<td style="padding:4px 6px;text-align:center">
<input type="checkbox" ${t.active==1?'checked':''} onchange="tmToggle(${t.id},this)" style="cursor:pointer;accent-color:var(--green)">
</td>
<td style="padding:4px 6px;text-align:center;color:var(--text-dim);font-size:0.6rem">${t.run_count||0}</td>
<td style="padding:4px 6px;text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-yellow" style="padding:2px 7px;font-size:0.58rem" onclick="tmEditTopic(${t.id})">EDIT</button>
<button class="btn btn-sm btn-red" style="padding:2px 7px;font-size:0.58rem;margin-left:3px" onclick="tmDelete(${t.id})">DEL</button>
</td>
</tr>`).join('')}
</tbody></table>`;
}
function tmShowList() {
document.getElementById('tm-view-list').style.display = '';
document.getElementById('tm-view-edit').style.display = 'none';
document.getElementById('modalSave').style.display = 'none';
}
function tmEditTopic(id) {
document.getElementById('tm-view-list').style.display = 'none';
document.getElementById('tm-view-edit').style.display = '';
document.getElementById('modalSave').style.display = '';
const tidEl = document.getElementById('tm-edit-tid');
if (id) {
const t = _topicsData.find(x => x.id == id);
if (!t) return;
document.getElementById('tm-edit-title').textContent = 'EDIT TOPIC: ' + t.topic_id;
document.getElementById('tm-edit-id').value = t.id;
tidEl.value = t.topic_id;
tidEl.readOnly = true;
tidEl.style.opacity = '0.6';
document.getElementById('tm-edit-cat').value = t.category;
document.getElementById('tm-edit-name').value = t.topic_name;
document.getElementById('tm-edit-desc').value = t.description;
document.getElementById('tm-edit-active').checked = t.active == 1;
} else {
document.getElementById('tm-edit-title').textContent = 'ADD NEW TOPIC';
document.getElementById('tm-edit-id').value = '';
tidEl.value = '';
tidEl.readOnly = false;
tidEl.style.opacity = '1';
document.getElementById('tm-edit-cat').value = '';
document.getElementById('tm-edit-name').value = '';
document.getElementById('tm-edit-desc').value = '';
document.getElementById('tm-edit-active').checked = true;
}
}
async function tmSaveTopic() {
const id = document.getElementById('tm-edit-id').value;
const data = {
id: id ? parseInt(id) : 0,
topic_id: document.getElementById('tm-edit-tid').value.trim(),
category: document.getElementById('tm-edit-cat').value.trim(),
topic_name: document.getElementById('tm-edit-name').value.trim(),
description: document.getElementById('tm-edit-desc').value.trim(),
active: document.getElementById('tm-edit-active').checked ? 1 : 0,
};
if (!data.topic_id || !data.category || !data.topic_name || !data.description) {
toast('All fields are required', 'err'); return;
}
apiPost('kb_topic_save', data, async (res) => {
toast(res?.msg || 'Saved', 'ok');
tmShowList();
await tmLoadTopics();
});
}
async function tmToggle(id, el) {
const prev = !el.checked;
const fd = new FormData();
fd.append('action', 'kb_topic_toggle');
fd.append('id', id);
try {
const r = await fetch(location.href, {method:'POST', body:fd});
const d = await r.json();
if (d.error) { toast(d.error, 'err'); el.checked = prev; return; }
const t = _topicsData.find(x => x.id == id);
if (t) t.active = d.active ? 1 : 0;
tmFilter();
} catch(e) { toast('Toggle failed', 'err'); el.checked = prev; }
}
async function tmDelete(id) {
const t = _topicsData.find(x => x.id == id);
const name = t?.topic_name || 'this topic';
openModal('CONFIRM DELETE',
`<div style="font-family:var(--mono);font-size:0.75rem;line-height:2;padding:4px 0">Delete topic:<br>` +
`<span style="color:var(--red)">${esc(name)}</span><br>` +
`<span style="color:var(--text-dim);font-size:0.65rem">This cannot be undone.</span></div>`,
() => { apiPost('kb_topic_delete', {id}, async () => { toast('Topic deleted', 'ok'); await openTopicsManager(); }); },
'DELETE');
}
async function arcSetup() {
const modalHtml = `
<div style="font-family:var(--mono);font-size:0.72rem;line-height:1.8">
<div id="as-status" style="color:var(--cyan);margin-bottom:8px;font-size:0.65rem;letter-spacing:1px">LAUNCHING...</div>
<div id="as-log" style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:10px 12px;min-height:160px;max-height:360px;overflow-y:auto;white-space:pre-wrap;font-size:0.62rem;line-height:1.7;color:var(--text-dim)">Waiting for output...</div>
</div>`;
openModal("&#9881; ARC REACTOR SETUP", modalHtml, null, null);
document.getElementById("modalSave").textContent = "CLOSE";
let _stop = false;
document.getElementById("modalSave").onclick = () => { _stop = true; closeModal(); loadWorkers(); };
document.getElementById("modalClose").onclick = () => { _stop = true; closeModal(); loadWorkers(); };
const logEl = document.getElementById("as-log");
const statEl = document.getElementById("as-status");
const snap = await api("arc_setup_log", {snapshot:1});
let lastLine = snap?.next_line ?? 0;
const kick = await api("worker", {type:"daemon", id:"arc_reactor", action:"setup"});
if (!kick?.ok) {
statEl.style.color = "var(--red)";
statEl.textContent = "LAUNCH FAILED: " + (kick?.msg || kick?.error || "unknown");
return;
}
statEl.textContent = "RUNNING — polling every 2s...";
async function pollLog() {
if (_stop) return;
try {
const res = await api("arc_setup_log", {since: lastLine});
if (res?.lines?.length) {
lastLine = res.next_line;
if (logEl.textContent === "Waiting for output...") logEl.textContent = "";
res.lines.forEach(line => {
const d = document.createElement("div");
const l = line.toLowerCase();
if (l.includes("error") || l.includes("fail")) d.style.color = "var(--red)";
else if (l.includes("warn") || l.includes("skip")) d.style.color = "var(--yellow)";
else if (l.includes("ok") || l.includes("done") || l.includes("active") ||
l.includes("success") || l.includes("restart") || l.includes("enabled")) d.style.color = "var(--green)";
else d.style.color = "var(--text-dim)";
d.textContent = line;
logEl.appendChild(d);
});
logEl.scrollTop = logEl.scrollHeight;
const joined = res.lines.join(" ").toLowerCase();
if (joined.includes("systemctl restart") || joined.includes("active") ||
joined.includes("done") || joined.includes("failed")) {
statEl.style.color = (joined.includes("error") || joined.includes("fail")) ? "var(--red)" : "var(--green)";
statEl.textContent = (joined.includes("error") || joined.includes("fail")) ? "SETUP FAILED" : "SETUP COMPLETE";
document.getElementById("modalSave").textContent = "CLOSE";
loadWorkers(); return;
}
}
} catch(e) {}
setTimeout(pollLog, 2000);
}
setTimeout(pollLog, 1500);
}
async function workerAction(type,id,action) {
if (type === 'agent' && action === 'update') {
await agentUpdateFlow(id);
return;
}
const res=await api('worker_action',{worker_type:type,worker_id:id,action});
const res=await api('worker_action',{worker_type:type,worker_id:id,waction:action});
if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);}
else wToast((res&&res.error)||'Action failed',true);
}
@@ -2231,7 +2757,7 @@ async function agentUpdateFlow(agentId) {
};
// Dispatch command
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update'});
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update'});
if (!res || !res.ok) {
log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)');
document.getElementById('modalSave').style.display = '';
@@ -2242,7 +2768,7 @@ async function agentUpdateFlow(agentId) {
log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)');
// Poll agent_commands for the result (max 90s)
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update_status'}).catch(()=>null);
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, waction:'update_status'}).catch(()=>null);
// Actually poll via workers_list for version change
const deadline = Date.now() + 90000;
let done = false;
@@ -2346,9 +2872,9 @@ async function loadWorkers() {
<td class="ts">jarvis-do :7474</td>
<td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td>
<td class="ts">${rinfo}</td>
<td style="display:flex;gap:4px">
<td style="white-space:nowrap">
<button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">&#8635; RESTART</button>
<button onclick="workerAction('daemon','arc_reactor','setup')" style="${wBtn('orange')}">&#9881; SETUP</button>
<button onclick="arcSetup()" style="${wBtn('cyan')};margin-left:6px">&#9881; SETUP</button>
</td>
</tr>`;
}