Give the assistant a standing memory file

A small operator-written file handed to the model at the start of every
conversation — who you are, how this install is set up, what has already been
decided. Injected ahead of the retrieved passages and marked operator-authored
so it outranks anything they contradict, and never cited as a source.

Deliberately not indexed and deliberately under DATA_DIR: it changes
constantly, vector similarity is the wrong way to retrieve things you were
told to remember, and gitignoring it keeps personal notes out of a pushed
repository. The character cap is a context budget — this text costs its share
of 16k on every single turn.
This commit is contained in:
Gmer4Lfe
2026-08-03 17:11:27 -04:00
parent 7625e923e5
commit d0b3588f6c
5 changed files with 181 additions and 0 deletions
+15
View File
@@ -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
+13
View File
@@ -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"
+19
View File
@@ -59,7 +59,9 @@
// REQUEST
// GET ?action=stats banner payload
// GET ?action=poll&token=<hex32> job state
// GET ?action=memory_get the operator memory file and its budget
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
// POST action=memory_set memory=… replace the memory file
// POST action=clear token=<hex32> 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; }
+73
View File
@@ -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;
+61
View File
@@ -200,6 +200,7 @@ if (is_dir('/var/log/varaverk')) {
<option value="doc">Design notes</option>
</select>
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-think" checked> reasoning</label>
<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>
<button class="vv-ai-btn ghost" id="vv-ai-clear" type="button">Clear</button>
<span class="vv-ai-hint">Ctrl+Enter to send · 3-turn history · build <?=$_vv_ai_build?>
<span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>
@@ -208,6 +209,22 @@ if (is_dir('/var/log/varaverk')) {
</div>
</div>
<div id="vv-ai-memwrap" style="display:none;border:1px solid #262626;border-radius:6px;
background:#0e0e0e;padding:10px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:#4a4a4a;">
Standing memory — given to the assistant at the start of every conversation</span>
<span id="vv-ai-mem-count" style="margin-left:auto;font-size:10px;color:#3a3a3a;font-family:monospace;"></span>
</div>
<textarea id="vv-ai-mem-text" class="vv-ai-input" rows="10" spellcheck="false"
placeholder="Who you are, how this install is set up, decisions already made, things it should stop asking.&#10;&#10;e.g.&#10;- HOST2 (unRAID-Jayred36) is being rebuilt and is offline. Do not suggest syncing to it.&#10;- RSYNC_ENABLED is deliberately false until HOST2 is onboarded.&#10;- I verify everything before trusting it. Show your sources."></textarea>
<div style="display:flex;gap:8px;align-items:center;margin-top:7px;">
<span id="vv-ai-mem-status" style="font-size:10px;color:#4a4a4a;"></span>
<button class="vv-ai-btn ghost" style="margin-left:auto" id="vv-ai-mem-cancel" type="button">Close</button>
<button class="vv-ai-btn" id="vv-ai-mem-save" type="button">Save</button>
</div>
</div>
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
<div class="vv-ai-view-box">
<div class="vv-ai-view-h">
@@ -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 => {