strtolower(trim($vars['AI_ENABLED'] ?? 'false')) === 'true', 'url' => rtrim(trim($vars["{$host}_OLLAMA_URL"] ?? ''), '/'), 'model' => trim($vars["{$host}_OLLAMA_MODEL"] ?? ''), 'embed_model' => trim($vars["{$host}_OLLAMA_EMBED_MODEL"] ?? 'nomic-embed-text'), 'db' => trim($vars['AI_INDEX_DB'] ?? '') ?: DATA_DIR . '/ai_index.db', 'k' => (int)($vars['AI_SEARCH_K'] ?? 8), 'per_file' => (int)($vars['AI_SEARCH_PER_FILE'] ?? 3), 'timeout' => (int)($vars['AI_REQUEST_TIMEOUT'] ?? 240), 'connect' => (int)($vars['AI_CONNECT_TIMEOUT'] ?? 5), ]; // AI_INDEX_DB is written as "$DATA_DIR/ai_index.db" in conf; the shell expands it, PHP does // not. Left as a literal it would name a file that cannot exist. $cfg['db'] = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $cfg['db']); return $cfg; } function vv_ai_enabled(): bool { return vv_ai_config()['enabled']; } // Index size, coverage and staleness. Staleness compares the newest git-tracked file against // the last build: the indexer only ever ingests tracked content, so anything else would report // permanent drift. function vv_ai_index_stats(): array { $cfg = vv_ai_config(); $out = ['exists' => false, 'chunks' => 0, 'files' => 0, 'size' => 0, 'built' => null, 'newest_source' => null, 'stale' => null, 'kinds' => []]; if (!file_exists($cfg['db'])) return $out; $out['exists'] = true; $out['size'] = (int)@filesize($cfg['db']); $db = escapeshellarg($cfg['db']); $q = fn(string $sql) => trim((string)@shell_exec('sqlite3 ' . $db . ' ' . escapeshellarg($sql) . ' 2>/dev/null')); $out['chunks'] = (int)$q('SELECT COUNT(*) FROM vv_chunks;'); $out['files'] = (int)$q('SELECT COUNT(*) FROM vv_files;'); $built = (int)$q('SELECT MAX(indexed) FROM vv_files;'); $out['built'] = $built ?: null; foreach (explode("\n", $q('SELECT kind, COUNT(*) FROM vv_chunks GROUP BY kind;')) as $line) { if (!$line) continue; [$k, $n] = array_pad(explode('|', $line, 2), 2, 0); if ($k !== '') $out['kinds'][$k] = (int)$n; } $newest = trim((string)@shell_exec( 'cd ' . escapeshellarg(SCRIPTS_DIR) . ' && git ls-files -z 2>/dev/null' . ' | xargs -0 stat -c %Y 2>/dev/null | sort -rn | head -1' )); if ($newest !== '') { $out['newest_source'] = (int)$newest; if ($out['built'] !== null) $out['stale'] = (int)$newest > $out['built']; } return $out; } // Ollama reachability and — the number that matters on this hardware — whether the generation // model is fully resident on the GPU. size_vram below size means layers are on the CPU, which // costs roughly 4x throughput and is invisible everywhere else. function vv_ai_runtime_stats(): array { $cfg = vv_ai_config(); $out = ['reachable' => false, 'loaded' => null, 'offload_pct' => null, 'context' => null, 'vram_used' => null, 'vram_total' => null, 'gpu' => null]; if ($cfg['url'] === '') return $out; $ctx = stream_context_create(['http' => [ 'method' => 'GET', 'timeout' => max(2, $cfg['connect']), 'ignore_errors' => true, ]]); $raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx); if ($raw === false) return $out; $out['reachable'] = true; $ps = json_decode($raw, true); foreach ($ps['models'] ?? [] as $m) { if (($m['name'] ?? '') !== $cfg['model']) continue; $size = (int)($m['size'] ?? 0); $vram = (int)($m['size_vram'] ?? 0); $out['loaded'] = true; $out['context'] = $m['context_length'] ?? null; $out['vram_used'] = $vram; $out['vram_total'] = $size; $out['offload_pct'] = $size > 0 ? (int)round($vram / $size * 100) : null; break; } if ($out['loaded'] === null) $out['loaded'] = false; $gpu = trim((string)@shell_exec( 'nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu ' . '--format=csv,noheader,nounits 2>/dev/null | tail -1' )); if ($gpu !== '') { $p = array_map('trim', explode(',', $gpu)); if (count($p) >= 4) { $out['gpu'] = ['name' => $p[0], 'mem_used' => (int)$p[1], 'mem_total' => (int)$p[2], 'util' => (int)$p[3]]; } } return $out; } function vv_ai_stats(): array { $cfg = vv_ai_config(); return [ 'enabled' => $cfg['enabled'], 'model' => $cfg['model'], 'embed_model' => $cfg['embed_model'], 'url' => $cfg['url'], 'k' => $cfg['k'], 'index' => vv_ai_index_stats(), 'runtime' => vv_ai_runtime_stats(), 'ts' => time(), ]; } // Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string]. function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array { $cfg = vv_ai_config(); if ($query === '') return ['ok' => false, 'error' => 'query is required', 'results' => []]; if ($cfg['url'] === '') return ['ok' => false, 'error' => 'Ollama URL is not configured', 'results' => []]; if (!file_exists($cfg['db'])) return ['ok' => false, 'error' => 'No index — run AI/ai_index.sh', 'results' => []]; if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { return ['ok' => false, 'error' => 'Unknown kind: ' . $kind, 'results' => []]; } $cli = SCRIPTS_DIR . '/AI/lib/cli.js'; if (!file_exists($cli)) return ['ok' => false, 'error' => 'AI/lib/cli.js not found', 'results' => []]; $k = max(1, min($k ?? $cfg['k'], 25)); $cmd = 'timeout ' . max(10, $cfg['connect'] * 6) . ' node --no-warnings ' . escapeshellarg($cli) . ' search' . ' --db=' . escapeshellarg($cfg['db']) . ' --url=' . escapeshellarg($cfg['url']) . ' --model=' . escapeshellarg($cfg['embed_model']) . ' --query=' . escapeshellarg($query) . ' --k=' . $k . ' --per-file=' . max(1, $cfg['per_file']) . ($kind !== '' ? ' --kind=' . escapeshellarg($kind) : '') . ($section !== '' ? ' --section=' . escapeshellarg($section) : '') . ' --json 2>/dev/null'; $raw = trim((string)@shell_exec($cmd)); if ($raw === '') return ['ok' => false, 'error' => 'Retrieval produced no output', 'results' => []]; $d = json_decode($raw, true); if (!is_array($d) || !isset($d['results'])) { return ['ok' => false, 'error' => 'Retrieval returned unparseable output', 'results' => []]; } return ['ok' => true, 'results' => $d['results'], 'intents' => $d['intents'] ?? [], 'scanned' => $d['scanned'] ?? 0]; } function vv_ai_job_dir(): string { if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true); return VV_AI_JOB_DIR; } // Hex-only, fixed length. The token composes a path, so the pattern is what confines it to the // job directory. function vv_ai_job_path(string $token): ?string { if (!preg_match('/^[0-9a-f]{32}$/', $token)) return null; return VV_AI_JOB_DIR . '/' . $token . '.json'; } function vv_ai_job_read(string $token): ?array { $p = vv_ai_job_path($token); if ($p === null || !file_exists($p)) return null; $d = json_decode(@file_get_contents($p) ?: '', true); return is_array($d) ? $d : null; }