job state // GET ?action=memory_get the operator memory file and its budget // POST action=ask question=… [history=] [kind=…] [think=0|1] // POST action=memory_set memory=… replace the memory file // POST action=clear token= discard a finished job // // RESPONSE // stats {"ok":true,"stats":{…}} // ask {"ok":true,"token":""} // poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}} // clear {"ok":true} // {"ok":false,"error":…} // // DEPENDS ON // include/ai.php vv_ai_stats(), vv_ai_config(), vv_ai_job_*() // Tools/ai_chat_worker.php the detached worker // ═══════════════════════════════════════════════════════════════════════════════════════════════ // First executable statement, deliberately dependency-free. A request that is rejected by the // CSRF prepend never reaches here and a request that dies inside the include never reaches the // action log below, and those two look identical from outside — which is what made a POST that // the browser demonstrably sent leave no trace anywhere on the server. @file_put_contents('/var/log/varaverk/ai.log', date('Y-m-d H:i:s') . ' ENTER ' . ($_SERVER['REQUEST_METHOD'] ?? '?') . ' ' . ($_SERVER['REQUEST_URI'] ?? '?') . ' ct=' . substr($_SERVER['CONTENT_TYPE'] ?? '-', 0, 40) . ' len=' . ($_SERVER['CONTENT_LENGTH'] ?? '-') . "\n", FILE_APPEND | LOCK_EX); header('Content-Type: application/json'); header('Cache-Control: no-store, no-cache'); require_once dirname(__DIR__) . '/include/ai.php'; // History depth is per profile, and decided here rather than by the page. Varaverk Assistant // spends ~2500 of its 16384 on retrieved passages, so it cannot afford deep history; the other // two retrieve nothing and can carry a real conversation. Reasoning is not stored in history, // so it does not compound. const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4]; const VV_AI_MAX_TURNS = 3; // fallback when a profile is not recognised const VV_AI_MAX_QUESTION = 4000; // characters const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped $isPost = $_SERVER['REQUEST_METHOD'] === 'POST'; $action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats')); // Request trace. There is no nginx access log on this host and the CSRF prepend exits with an // empty body, so without this there is no way to tell "the request never arrived" from "the // request arrived and failed" — which is exactly the ambiguity that made the first hang // undiagnosable. Excludes poll, which would otherwise write a line per second per open tab. function vv_ai_log(string $msg): void { if (!is_dir('/var/log/varaverk')) return; @file_put_contents('/var/log/varaverk/ai.log', date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX); } if ($action !== 'poll') { vv_ai_log(sprintf('%s action=%s from=%s', $_SERVER['REQUEST_METHOD'] ?? '?', $action ?: '(none)', $_SERVER['REMOTE_ADDR'] ?? '?')); } // Host gate, ahead of the dispatch rather than inside each action. Varaverk.page omits the tab // on any host but HOST1, but a hidden link is not access control and this endpoint is reachable // directly. Every action is refused rather than just the expensive ones — there is no such thing // as a read this host is entitled to, since the index and the model are not here. if (!vv_is_ai_host()) { http_response_code(404); echo json_encode(['ok' => false, 'error' => 'AI is not available on this host']); exit; } // ── stats ───────────────────────────────────────────────────────────────────── if ($action === 'stats') { echo json_encode(['ok' => true, 'stats' => vv_ai_stats()]); exit; } // ── tokens ──────────────────────────────────────────────────────────────────── // Separate from stats rather than folded into it. stats is polled every 30 seconds by every // open tab; this reads a file that grows without bound between prunes. The totals only move // when a turn completes, and the page knows exactly when that happened, so it asks then. if ($action === 'tokens') { echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]); exit; } // ── poll ────────────────────────────────────────────────────────────────────── if ($action === 'poll') { $token = trim($_GET['token'] ?? ''); if (vv_ai_job_path($token) === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } $job = vv_ai_job_read($token); if ($job === null) { // The worker writes its first state after this request may already have arrived. echo json_encode(['ok' => true, 'job' => ['status' => 'pending']]); exit; } echo json_encode(['ok' => true, 'job' => $job]); exit; } // ── memory ──────────────────────────────────────────────────────────────────── if ($action === 'memory_get') { $m = vv_ai_memory_read(); echo json_encode(['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'], 'max' => vv_ai_memory_max(), 'exists' => $m['exists'], 'path' => vv_ai_memory_path()]); exit; } if ($action === 'memory_set') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $r = vv_ai_memory_write((string)($_POST['memory'] ?? '')); vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error'])); echo json_encode($r + ['max' => vv_ai_memory_max()]); exit; } // ── clear ───────────────────────────────────────────────────────────────────── if ($action === 'clear') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } $p = vv_ai_job_path(trim($_POST['token'] ?? '')); if ($p === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; } if (file_exists($p)) @unlink($p); echo json_encode(['ok' => true]); exit; } // ── ask ─────────────────────────────────────────────────────────────────────── if ($action === 'ask') { if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; } if (!vv_ai_enabled()) { echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']); exit; } $cfg = vv_ai_config(); if ($cfg['model'] === '') { echo json_encode(['ok' => false, 'error' => 'No generation model configured']); exit; } $question = trim($_POST['question'] ?? ''); if ($question === '') { echo json_encode(['ok' => false, 'error' => 'question is required']); exit; } if (mb_strlen($question) > VV_AI_MAX_QUESTION) { echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit; } $profile = trim($_POST['profile'] ?? 'varaverk'); if (!isset(VV_AI_PROFILES[$profile])) { echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit; } $maxTurns = VV_AI_PROFILES[$profile] ?? VV_AI_MAX_TURNS; // The retrieval filter only means anything to the profile that retrieves. $kind = $profile === 'varaverk' ? trim($_POST['kind'] ?? '') : ''; if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit; } // Validate per message rather than trusting the blob: a crafted history could otherwise // inject a system role, or push the context past the offload ceiling. $clean = []; $hist = json_decode($_POST['history'] ?? '[]', true); if (is_array($hist)) { foreach ($hist as $m) { $role = $m['role'] ?? ''; $text = trim((string)($m['content'] ?? '')); if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue; $clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)]; } } if (count($clean) > $maxTurns * 2) { $clean = array_slice($clean, -($maxTurns * 2)); } $dir = vv_ai_job_dir(); foreach (glob($dir . '/*.json') ?: [] as $old) { if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old); } $token = bin2hex(random_bytes(16)); $jobFile = vv_ai_job_path($token); $worker = dirname(__DIR__) . '/Tools/ai_chat_worker.php'; if (!file_exists($worker)) { echo json_encode(['ok' => false, 'error' => 'ai_chat_worker.php not found']); exit; } // Not suppressed: if the job file cannot be written the worker has nowhere to report and // the page polls a token that will never resolve — which looks exactly like a hang. if (file_put_contents($jobFile, json_encode(['status' => 'pending'])) === false) { vv_ai_log('ask FAILED — cannot write ' . $jobFile); echo json_encode(['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR]); exit; } $cmd = 'nohup php ' . escapeshellarg($worker) . ' ' . escapeshellarg($jobFile) . ' ' . escapeshellarg($question) . ' ' . escapeshellarg(json_encode($clean)) . ' ' . escapeshellarg($kind) . ' ' . escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' ' . escapeshellarg($profile) . ' >/dev/null 2>&1 true, 'token' => $token]); exit; } echo json_encode(['ok' => false, 'error' => 'Unknown action']);