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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. e.g. - HOST2 (unRAID-Jayred36) is being rebuilt and is offline. Do not suggest syncing to it. - RSYNC_ENABLED is deliberately false until HOST2 is onboarded. - 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 => {
|
||||
|
||||
Reference in New Issue
Block a user