Add three chat profiles: Varaverk Assistant, General Chat, Code Sketcher

Explicit buttons rather than an automatic router. Misclassifying a Varaverk
question as chat produces a confident invention about the user's system, which
is exactly what retrieval exists to prevent — with buttons there is no hidden
heuristic to be wrong and the strict profile is the default you land on.

Only Varaverk Assistant retrieves; the other two would be carrying passages
that cannot help write a folder-copy script. Memory goes to all three, since
that is what lets chat know the setup without claiming authority over it.
History depth is per profile and set server-side: retrieval costs ~2500 of
16384, so the profiles that skip it can hold a real conversation. Code
Sketcher is told to flag flags it is unsure of, after it invented
rsync --no-overwrite.
This commit is contained in:
Gmer4Lfe
2026-08-03 17:36:24 -04:00
parent d0b3588f6c
commit 8d393a1e1c
3 changed files with 157 additions and 43 deletions
+75 -31
View File
@@ -82,11 +82,14 @@ if (PHP_SAPI !== 'cli') {
require_once dirname(__DIR__) . '/include/ai.php';
[$jobFile, $question, $historyJson, $kind, $think] = array_slice($argv, 1, 5) + array_fill(0, 5, '');
[$jobFile, $question, $historyJson, $kind, $think, $profile] =
array_slice($argv, 1, 6) + array_fill(0, 6, '');
if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
$profile = in_array($profile, ['varaverk', 'chat', 'code'], true) ? $profile : 'varaverk';
function jw(string $f, array $d): void {
file_put_contents($f, json_encode($d));
}
@@ -94,40 +97,47 @@ function jw(string $f, array $d): void {
$cfg = vv_ai_config();
$t0 = microtime(true);
jw($jobFile, ['status' => 'retrieving']);
// Only the Varaverk profile retrieves. The other two answer from memory and the model's own
// knowledge, so passages would be noise competing for context — documentation cannot help write
// a folder-copy script, and it has nothing to say about how someone's day is going.
$sources = [];
$context = '';
$tRetrieve = 0.0;
$r = vv_ai_retrieve($question, $kind);
if (!$r['ok']) {
jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
exit(1);
}
if (!$r['results']) {
jw($jobFile, ['status' => 'error',
'error' => 'No relevant documentation found. Try rephrasing, or use the readme filter '
. 'for questions about what something is.']);
exit(0);
}
if ($profile === 'varaverk') {
jw($jobFile, ['status' => 'retrieving']);
$tRetrieve = microtime(true) - $t0;
$r = vv_ai_retrieve($question, $kind);
if (!$r['ok']) {
jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
exit(1);
}
if (!$r['results']) {
jw($jobFile, ['status' => 'error',
'error' => 'No relevant documentation found. Try rephrasing, use the readme filter '
. 'for questions about what something is, or switch to General Chat if this '
. 'is not a Varaverk question.']);
exit(0);
}
$sources = array_map(fn($x) => [
'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
], $r['results']);
$tRetrieve = microtime(true) - $t0;
jw($jobFile, ['status' => 'generating', 'sources' => $sources]);
$sources = array_map(fn($x) => [
'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
], $r['results']);
$context = '';
foreach ($r['results'] as $i => $x) {
$label = implode(' ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
$context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
foreach ($r['results'] as $i => $x) {
$label = implode(' ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
$context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
}
}
// Diagnostic questions get live state as well as documentation. The docs say what a script is
// supposed to do; only the logs and the current config say what it actually did. Attached only
// when the question is asking why something failed — otherwise it is a few thousand tokens of
// noise competing with the retrieved passages for a context budget that is already tight.
$diagnostic = (bool)preg_match(
$diagnostic = $profile === 'varaverk' && (bool)preg_match(
'/\b(why|fail(ed|ing|ure)?|error|broken?|not work|isn.t work|wrong|stuck|hang|'
. 'never runs?|didn.t|won.t|debug|troubleshoot|diagnos)/i',
$question
@@ -155,12 +165,46 @@ if ($diagnostic) {
}
}
$system = "You are Varaverk's documentation assistant. Varaverk is this user's private "
. "two-server Unraid media ecosystem; it is not in your training data, so the material "
. "below is the only thing you know about it.\n\n"
. "Answer only from this material and cite the passages inline as [1], [2]. If it does "
. "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";
// One prompt per profile. Explicit profiles rather than an automatic router: misclassifying a
// Varaverk question as chat produces a confident invention about the user's system, which is
// precisely what retrieval exists to prevent. The user always knows which contract is in force,
// and the strict profile is the default.
if ($profile === 'chat') {
$system = "You are the Varaverk assistant, talking with the operator of a private two-server "
. "Unraid media ecosystem called Varaverk. This is ordinary conversation — no "
. "documentation has been retrieved for it.\n\n"
. "Talk like a knowledgeable colleague. Be warm and direct, follow a tangent if one "
. "is interesting, and use your general knowledge freely.\n\n"
. "The one hard rule: you have NOT been given Varaverk's documentation in this mode. "
. "Anything you know about their setup comes from the memory section below and from "
. "what they tell you. If they ask something specific about how Varaverk works — a "
. "script, a config variable, a safeguard — say you would need the Varaverk Assistant "
. "profile for that, and do not guess. Being wrong about their own system is worse "
. "than sending them one click away.\n\n";
} elseif ($profile === 'code') {
$system = "You are drafting a short shell script for the operator of an Unraid server, to be "
. "saved as a Varaverk Custom Script. Assume bash on Unraid: GNU coreutils, paths "
. "under /mnt/user, no systemd, and no packages beyond what Unraid ships.\n\n"
. "Write the smallest script that does the job. Include a shebang, quote variables, "
. "check that inputs exist, and exit non-zero on failure. Prefer plain coreutils and "
. "rsync over anything exotic.\n\n"
. "This is the important part: you sometimes invent command-line flags that sound "
. "plausible but do not exist. After the script, list every flag or command you are "
. "not completely certain about, and say how to check it — 'verify with rsync --help' "
. "or similar. A stated assumption the operator can check beats a confident mistake "
. "they run at 3am. If you are unsure a flag exists, say so rather than picking the "
. "one that sounds right.\n\n"
. "Treat this as a first draft to be tested, and say so.\n\n";
} else {
$system = "You are Varaverk's documentation assistant. Varaverk is this user's private "
. "two-server Unraid media ecosystem; it is not in your training data, so the material "
. "below is the only thing you know about it.\n\n"
. "Answer only from this material and cite the passages inline as [1], [2]. If it does "
. "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
@@ -190,7 +234,7 @@ if ($diagBlock !== '') {
. $diagBlock;
}
$system .= "PASSAGES\n" . $context;
if ($context !== '') $system .= "PASSAGES\n" . $context;
$messages = [['role' => 'system', 'content' => $system]];
$hist = json_decode($historyJson ?: '[]', true);
+20 -7
View File
@@ -89,7 +89,12 @@ header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php';
const VV_AI_MAX_TURNS = 3; // user+assistant pairs retained; see OPERATIONAL MODEL
// 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
@@ -179,7 +184,14 @@ if ($action === 'ask') {
echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit;
}
$kind = trim($_POST['kind'] ?? '');
$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;
}
@@ -196,8 +208,8 @@ if ($action === 'ask') {
$clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
}
}
if (count($clean) > VV_AI_MAX_TURNS * 2) {
$clean = array_slice($clean, -(VV_AI_MAX_TURNS * 2));
if (count($clean) > $maxTurns * 2) {
$clean = array_slice($clean, -($maxTurns * 2));
}
$dir = vv_ai_job_dir();
@@ -226,12 +238,13 @@ if ($action === 'ask') {
. escapeshellarg($question) . ' '
. escapeshellarg(json_encode($clean)) . ' '
. escapeshellarg($kind) . ' '
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0')
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' '
. escapeshellarg($profile)
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
vv_ai_log(sprintf('ask token=%s rc=%d kind=%s q=%s',
substr($token, 0, 12), $rc, $kind ?: '-', mb_substr($question, 0, 80)));
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
substr($token, 0, 12), $rc, $profile, $kind ?: '-', mb_substr($question, 0, 80)));
echo json_encode(['ok' => true, 'token' => $token]);
exit;
+62 -5
View File
@@ -83,6 +83,16 @@ if (is_dir('/var/log/varaverk')) {
.vv-ai-warn { color:#ffb74d !important; }
.vv-ai-bad { color:#e57 !important; }
/* ── Profiles ───────────────────────────────────────────────────────────── */
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; }
/* ── Chat ───────────────────────────────────────────────────────────────── */
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
@@ -180,6 +190,13 @@ if (is_dir('/var/log/varaverk')) {
</div>
</div>
<div class="vv-ai-profiles">
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
<button class="vv-ai-prof" data-prof="chat" type="button">General Chat</button>
<button class="vv-ai-prof" data-prof="code" type="button">Code Sketcher</button>
<span class="vv-ai-prof-hint" id="vv-ai-prof-hint"></span>
</div>
<div class="vv-ai-chat" id="vv-ai-chat">
<div class="vv-ai-empty">
Ask Varaverk about itself.<br>
@@ -238,11 +255,23 @@ if (is_dir('/var/log/varaverk')) {
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
const MAXTURN = 3;
const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
let history = []; // {role, content} — trimmed to MAXTURN pairs
// Server-side is the authority on retrieval and history depth; these are for the UI only.
// Always starts on varaverk — the strict profile is the one you land on, so a misuse costs a
// "the docs don't cover that" rather than an invented claim about the system.
const PROFILES = {
varaverk: { turns: 3, kind: true,
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
chat: { turns: 8, kind: false,
hint: 'Ordinary conversation. Knows your memory notes, but not the docs — it\'ll point you back here for specifics.' },
code: { turns: 4, kind: false,
hint: 'Drafts short scripts for Custom Scripts. First drafts — it flags flags it isn\'t sure of. Test before trusting.' },
};
let profile = 'varaverk';
let history = []; // {role, content} — trimmed per profile
let busy = false;
let lastSources = [];
@@ -463,9 +492,10 @@ if (is_dir('/var/log/varaverk')) {
try {
const body = new URLSearchParams({
action: 'ask',
profile: profile,
question: q,
history: JSON.stringify(history.slice(-MAXTURN * 2)),
kind: $('vv-ai-kind').value,
history: JSON.stringify(history.slice(-PROFILES[profile].turns * 2)),
kind: PROFILES[profile].kind ? $('vv-ai-kind').value : '',
think: $('vv-ai-think').checked ? '1' : '0',
});
res = fetch(API, {
@@ -516,7 +546,8 @@ if (is_dir('/var/log/varaverk')) {
if (j.status === 'done') {
addAnswer(j);
history.push({ role: 'assistant', content: j.answer });
if (history.length > MAXTURN * 2) history = history.slice(-MAXTURN * 2);
const cap = PROFILES[profile].turns * 2;
if (history.length > cap) history = history.slice(-cap);
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
@@ -547,6 +578,32 @@ if (is_dir('/var/log/varaverk')) {
if (s && s.path) vvAiOpen(s.path);
};
// ── Profiles ────────────────────────────────────────────────────────────
// Switching clears the conversation history sent to the model but leaves the transcript on
// screen. Carrying turns across a profile change would mean feeding cited, retrieval-grounded
// answers into a mode that has no retrieval — the model would keep referring to sources it can
// no longer see. The visible marker is so the transcript still reads honestly afterwards.
function setProfile(p) {
if (!PROFILES[p] || p === profile) return;
profile = p;
document.querySelectorAll('.vv-ai-prof').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
$('vv-ai-prof-hint').textContent = PROFILES[p].hint;
$('vv-ai-kind').style.display = PROFILES[p].kind ? '' : 'none';
if (history.length) {
const label = document.querySelector('.vv-ai-prof[data-prof="' + p + '"]').textContent;
chat().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(label)
+ ' — earlier turns are no longer carried</div>'));
scroll();
}
history = [];
$('vv-ai-input').focus();
}
document.querySelectorAll('.vv-ai-prof').forEach(b =>
b.addEventListener('click', () => setProfile(b.dataset.prof)));
$('vv-ai-prof-hint').textContent = PROFILES[profile].hint;
// ── 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.