diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 1641777..cc1a4f4 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1626,6 +1626,21 @@ AI_SEARCH_K=8 # chunks retrieved per query AI_SEARCH_PER_FILE=3 # cap per file so one document cannot fill the context +# ━━━ AI Memory ━━━ +# A small operator-maintained file the assistant is given at the start of every conversation: +# who you are, how this install is set up, decisions already made, things it should stop asking. +# +# Injected into the prompt, never indexed. It lives under DATA_DIR, which is gitignored — that +# is deliberate and load-bearing. Indexing it would embed a file that changes constantly, and +# vector similarity is the wrong way to retrieve "things I was told to remember"; it also keeps +# personal notes out of a repository that gets pushed. +# +# The character cap is a context budget, not a style guide. At 16384 the retrieved passages, +# the model's reasoning and the conversation history are already competing; memory takes its +# share off the top of every single turn, so keep it short and factual. + AI_MEMORY_FILE="$DATA_DIR/ai_memory.md" + AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded + # ━━━ AI Retrieval Bridge ━━━ # AI/ai_serve.js — a retrieval-only HTTP endpoint for clients that cannot see this filesystem. # Open-WebUI runs in its own container with no WebGUI session, and nginx applies auth_request to diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 00fcce5..55dc130 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -162,6 +162,19 @@ $system = "You are Varaverk's documentation assistant. Varaverk is this user's p . "not contain the answer, say so plainly and name what is missing — do not fill the " . "gap from general knowledge. Prefer the user's own terminology.\n\n"; +// Memory first, before the passages. It is the standing context — who the operator is and what +// has already been decided — so it should frame everything that follows rather than read as one +// more retrieved document. Marked as operator-authored so the model treats it as fact about the +// installation rather than as a source to cite. +$mem = vv_ai_memory_read(); +if ($mem['exists'] && trim($mem['text']) !== '') { + $system .= "WHAT YOU ALREADY KNOW ABOUT THIS OPERATOR AND INSTALLATION\n" + . "Written by the operator, not retrieved. Treat it as established fact about this " + . "system, prefer it over anything in the passages that contradicts it, and do not " + . "cite it as a numbered source.\n\n" + . trim($mem['text']) . "\n\n"; +} + if ($diagBlock !== '') { $system .= "This is a diagnostic question, so live system state is included alongside the " . "documentation.\n\n" diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index b9cd52e..72218e8 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -59,7 +59,9 @@ // REQUEST // GET ?action=stats banner payload // GET ?action=poll&token= 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 @@ -131,6 +133,23 @@ if ($action === 'poll') { 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; } diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index a357539..c0307cf 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -67,6 +67,7 @@ // Status vv_ai_stats(), vv_ai_index_stats(), vv_ai_runtime_stats(), vv_ai_index_meta() // Models vv_ai_models_available(), vv_ai_models_loaded() // Diagnosis vv_ai_health(), vv_ai_recent_logs() +// Memory vv_ai_memory_path(), vv_ai_memory_read(), vv_ai_memory_write() // Retrieval vv_ai_retrieve() // Jobs vv_ai_job_dir(), vv_ai_job_path(), vv_ai_job_read() // @@ -452,6 +453,78 @@ function vv_ai_retrieve(string $query, string $kind = '', string $section = '', 'scanned' => $d['scanned'] ?? 0]; } +// ── Memory ─────────────────────────────────────────────────────────────────────────────────── +// +// A small operator-maintained file handed to the model at the start of every conversation. +// Injected, never indexed: it changes constantly, and vector similarity is the wrong retrieval +// mechanism for "things I was told to remember". Living under DATA_DIR keeps it out of the +// repository and therefore out of the index by construction. + +function vv_ai_memory_path(): string { + $cfg = vv_ai_config(); + $vars = vv_conf_vars(); + $p = trim($vars['AI_MEMORY_FILE'] ?? ''); + $p = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $p); + return $p !== '' ? $p : DATA_DIR . '/ai_memory.md'; +} + +function vv_ai_memory_max(): int { + $vars = vv_conf_vars(); + $n = (int)($vars['AI_MEMORY_MAX_CHARS'] ?? 4000); + return max(200, min($n, 20000)); +} + +// Returns ['text'=>string,'chars'=>int,'truncated'=>bool,'exists'=>bool]. +// Truncates rather than refusing: an over-long memory file should cost its own tail, not the +// whole conversation, and the notice tells the model its knowledge is incomplete rather than +// letting it assume it saw everything. +function vv_ai_memory_read(): array { + $p = vv_ai_memory_path(); + $max = vv_ai_memory_max(); + + if (!file_exists($p)) return ['text' => '', 'chars' => 0, 'truncated' => false, 'exists' => false]; + + $raw = (string)@file_get_contents($p); + $len = mb_strlen($raw); + if ($len <= $max) { + return ['text' => $raw, 'chars' => $len, 'truncated' => false, 'exists' => true]; + } + return [ + 'text' => mb_substr($raw, 0, $max) . "\n\n[memory truncated at {$max} characters]", + 'chars' => $len, + 'truncated' => true, + 'exists' => true, + ]; +} + +// Atomic, and refuses to exceed the cap. The cap is a context budget shared with retrieval and +// reasoning on every turn, so it is enforced on write rather than silently trimmed on read. +function vv_ai_memory_write(string $text): array { + $p = vv_ai_memory_path(); + $max = vv_ai_memory_max(); + $len = mb_strlen($text); + + if ($len > $max) { + return ['ok' => false, 'error' => "Memory is {$len} characters; the limit is {$max}. " + . "It is included in every prompt, so it competes with retrieval for context."]; + } + + $dir = dirname($p); + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + return ['ok' => false, 'error' => 'Cannot create ' . $dir]; + } + + $tmp = $p . '.vv.tmp'; + if (@file_put_contents($tmp, $text) === false) { + return ['ok' => false, 'error' => 'Cannot write ' . $tmp]; + } + if (!@rename($tmp, $p)) { + @unlink($tmp); + return ['ok' => false, 'error' => 'Cannot install ' . $p]; + } + return ['ok' => true, 'chars' => $len]; +} + 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; diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 27b11a7..c16460f 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -200,6 +200,7 @@ if (is_dir('/var/log/varaverk')) { + Ctrl+Enter to send · 3-turn history · build · JS NOT RUNNING @@ -208,6 +209,22 @@ if (is_dir('/var/log/varaverk')) { + +
@@ -530,6 +547,50 @@ if (is_dir('/var/log/varaverk')) { if (s && s.path) vvAiOpen(s.path); }; + // ── Memory panel ──────────────────────────────────────────────────────── + // Live character count against the cap, because the budget is the whole point: this text is + // prepended to every single turn and competes with retrieval for a 16k context. + let memMax = 4000; + function memCount() { + const n = $('vv-ai-mem-text').value.length; + const el = $('vv-ai-mem-count'); + el.textContent = n + ' / ' + memMax; + el.style.color = n > memMax ? '#e57' : (n > memMax * 0.8 ? '#ffb74d' : '#3a3a3a'); + $('vv-ai-mem-save').disabled = n > memMax; + } + function memOpen() { + const w = $('vv-ai-memwrap'); + if (w.style.display !== 'none') { w.style.display = 'none'; return; } + $('vv-ai-mem-status').textContent = 'loading…'; + w.style.display = ''; + fetch(API + '?action=memory_get').then(r => r.json()).then(d => { + if (!d.ok) { $('vv-ai-mem-status').textContent = d.error || 'failed to load'; return; } + memMax = d.max || 4000; + $('vv-ai-mem-text').value = d.memory || ''; + $('vv-ai-mem-status').textContent = d.exists ? d.path : 'not created yet — ' + d.path; + memCount(); + }).catch(e => { $('vv-ai-mem-status').textContent = 'failed to load: ' + e; }); + } + function memSave() { + $('vv-ai-mem-save').disabled = true; + $('vv-ai-mem-status').textContent = 'saving…'; + fetch(API, { method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: new URLSearchParams({ action: 'memory_set', memory: $('vv-ai-mem-text').value }) }) + .then(r => r.json()).then(d => { + $('vv-ai-mem-save').disabled = false; + $('vv-ai-mem-status').textContent = d.ok + ? 'saved — applies from your next message' + : (d.error || 'save failed'); + }) + .catch(e => { $('vv-ai-mem-save').disabled = false; + $('vv-ai-mem-status').textContent = 'save failed: ' + e; }); + } + $('vv-ai-mem').addEventListener('click', memOpen); + $('vv-ai-mem-cancel').addEventListener('click', () => { $('vv-ai-memwrap').style.display = 'none'; }); + $('vv-ai-mem-save').addEventListener('click', memSave); + $('vv-ai-mem-text').addEventListener('input', memCount); + // ── Wiring ────────────────────────────────────────────────────────────── $('vv-ai-send').addEventListener('click', send); $('vv-ai-input').addEventListener('keydown', e => {