Give the AI subsystem one profile table and one collection, read everywhere

This commit is contained in:
Gmer4Lfe
2026-08-08 22:35:37 -04:00
parent 0f4d381ac0
commit 0f92609425
10 changed files with 355 additions and 103 deletions
+41 -42
View File
@@ -82,48 +82,10 @@ require_once __DIR__ . '/config.php';
define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs');
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc'];
// ── What each profile is allowed to see and do ───────────────────────────────────────────────
// A profile is a contract plus a set of inputs, and the inputs are the half that has to be
// enforced rather than requested. This table is that half, in one place.
//
// It exists because the alternative already failed. The same permissions used to live as a dozen
// `$profile === 'varaverk' || $profile === 'troubleshoot'` conditions spread across the worker,
// and answering "may chat ever be shown a log?" meant reading all of them. It could — a gate
// added for run-outcome questions granted it by omission, and the chat profile, whose entire
// value is that it has NOT been shown this installation, was one phrasing away from being handed
// a health sweep and 120 lines of log. Nothing about that was visible at the point of the
// mistake. Here it would have been one missing word on one line.
//
// A capability is permission, not need. varaverk holds 'health' but only attaches it when the
// question looks diagnostic; troubleshoot attaches it always. The gates decide whether an input
// is warranted, this decides whether it is allowed, and a gate can never widen the grant.
//
// The ordering is deliberate: chat holds nothing, and that emptiness is a guarantee, not an
// oversight. Anything added to it stops being general chat and becomes an assistant that
// sometimes lies about this installation.
const VV_AI_CAPS = [
// retrieval passages from the index, and the kind filter the page exposes for them
'retrieve' => ['varaverk', 'troubleshoot'],
'kind_filter' => ['varaverk'],
// live health sweep measured at question time
'health' => ['varaverk', 'troubleshoot'],
// run record + log tail for a script named in the question
'run_evidence' => ['varaverk', 'troubleshoot'],
// log tail for whatever the operator currently has open
'scoped_log' => ['troubleshoot'],
// operator-written history of what previously went wrong with this thing
'incidents' => ['varaverk', 'troubleshoot'],
// deterministic "where does this conf key actually live" lookup
'conf_lookup' => ['varaverk', 'troubleshoot'],
// may file a bug report against Varaverk itself
'file_bugs' => ['troubleshoot'],
// destructive-operation scan of generated shell
'code_scan' => ['code'],
];
function vv_ai_profile_can(string $profile, string $cap): bool {
return in_array($profile, VV_AI_CAPS[$cap] ?? [], true);
}
// Profiles — what each one is, and what it is allowed to see and do. One table, in one file,
// read by everything: this endpoint, the worker, the shared chat include and the Scheduler dock.
// vv_ai_profile_can() and friends come from there.
require_once __DIR__ . '/ai_profiles.php';
// Whether a General Chat message is really about this installation. Shared by the deterministic
// backstop and the handoff, so both agree by construction: a question the backstop would have
@@ -472,6 +434,43 @@ function vv_ai_stats(): array {
];
}
// ── Shared collection ─────────────────────────────────────────────────────────
// vv_ai_stats() costs about a second on this host — vv_ai_runtime_stats() alone is 60-480ms
// depending on how quickly Ollama and nvidia-smi answer, and it was being paid by the AI tab
// every 30 seconds per open tab, plus again by anything else that wanted the same numbers.
//
// So it is collected once, by Tools/api_cache_writer.php, into the 'ai' cache; every surface
// reads that. This is the same arrangement the monitor and arrs payloads already use and for the
// same reason — polling faster cannot make the figures newer, it only decides how soon a page
// notices the writer's update.
//
// ?live=1 stays available for the one case that needs it: you changed something and want to see
// the result rather than a payload written before you changed it.
function vv_ai_stats_cached(bool $live = false): array {
if (!$live) {
$c = vv_cache_read('ai', 300);
if ($c !== null) return $c;
}
return vv_ai_stats();
}
// The Monitor tab's slice of the same collection. Derived rather than collected: taking the AI
// row's figures from a second call to vv_ai_runtime_stats() would pay the whole cost twice per
// cache write, and — worse — the dashboard and the AI tab could disagree about whether the model
// is resident, because they would have asked at different moments.
//
// Tokens are not part of vv_ai_stats(): that function's shape is the AI tab's banner contract,
// and the ledger read is 15ms, so it is fetched here rather than widening the payload everything
// else carries.
function vv_ai_monitor_block(array $stats): array {
return [
'model' => $stats['model'] ?? '',
'runtime' => $stats['runtime'] ?? [],
'index' => $stats['index'] ?? [],
'tokens' => vv_ai_token_stats()['today'] ?? null,
];
}
// Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string].
function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array {
$cfg = vv_ai_config();
+64 -24
View File
@@ -57,10 +57,16 @@
// vv_ai_chat_list_markup($prefix) the stored-conversations list container
//
// DEPENDS ON
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
// api/readscript.php source viewer contents
// include/ai_profiles.php the profile registry, served to the browser rather than restated
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Required directly, not assumed. The Monitor tab pulls this file in without include/ai.php,
// so relying on something else having loaded the registry first works on the AI tab and fatals
// on the dashboard.
require_once __DIR__ . '/ai_profiles.php';
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
@@ -145,18 +151,41 @@ function vv_ai_chat_assets(): void {
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
/* ── Compact form, for a chat living inside a Monitor card ──────────────── */
/* Not a different component — the same markup with the chrome pulled in. The card supplies its
own heading and border, so the transcript drops its own and the hint text goes away rather
than wrapping to three lines at this width. */
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0; }
/* Not a different component — the same markup with the chrome pulled in. The card supplies the
heading and the border, so the transcript drops its own and the hint text goes away rather
than wrapping to three lines at this width.
Surfaces are re-based on the card, not merely un-bordered. The standalone palette paints a
#0b0b0b transcript because on the AI tab it sits on the page ground with nothing beside it
to compare against; dropped into a .vv-card (#1e1e1e) that same fill reads as a hole cut in
the card rather than as part of it. Transparent here, and the remaining controls move to the
values the plugin already uses inside a card — .vv-btn-sm is #2a2a2a on #555 — so the whole
thing reads as one object.
This is the second hardcoded dark palette in the plugin, which is exactly one too many. When
theming happens it wants tokens (--vv-surface, --vv-line) defined once in varaverk.css, and
this block becomes a token swap instead of a second set of literals. */
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0;
background:transparent; }
.vv-ai-c .vv-ai-empty { padding:26px 14px; }
.vv-ai-c .vv-ai-composer { border:none; padding:8px 0 0; background:none; }
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px; }
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; }
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px;
background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; background:#2a2a2a;
border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-prof:hover { border-color:#888; color:#fff; }
.vv-ai-c .vv-ai-prof.active { background:#152238; border-color:#4a7ab0; color:#9bd; }
.vv-ai-c .vv-ai-prof-hint { display:none; }
.vv-ai-c .vv-ai-hint { display:none; }
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
.vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
.vv-ai-c .vv-ai-think { background:#161616; border-left-color:#3a3a3a; }
.vv-ai-c .vv-ai-body pre { background:#161616; border-color:#3a3a3a; }
.vv-ai-c .vv-ai-body code { background:#2a2a2a; }
.vv-ai-c .vv-ai-src { border-top-color:#333; }
.vv-ai-c .vv-ai-switch { border-top-color:#333; }
/* ── Stored conversations ───────────────────────────────────────────────── */
.vv-ai-clist { display:flex; flex-direction:column; gap:1px; }
@@ -171,6 +200,14 @@ function vv_ai_chat_assets(): void {
.vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; }
.vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; }
.vv-ai-crow-x:hover { color:#e57; }
/* Hover lifts off the card rather than sinking into it. #141414 is a highlight against the AI
tab's #0e0e0e panel and a shadow against a #1e1e1e card — the same value reads as the
opposite gesture depending on what it sits on. */
.vv-ai-c .vv-ai-crow:hover { background:#2a2a2a; }
.vv-ai-c .vv-ai-crow-t { color:#ccc; }
.vv-ai-c .vv-ai-crow-m { color:#666; }
.vv-ai-c .vv-ai-crow-x { color:#666; }
.vv-ai-c .vv-ai-none { color:#666; }
.vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
/* ── Source overlay ─────────────────────────────────────────────────────── */
@@ -195,21 +232,21 @@ function vv_ai_chat_assets(): void {
</div>
</div>
<?php
// The profile registry, served rather than restated. This used to be a literal table in the
// script below that had already drifted from the PHP — it knew three profiles where the server
// knew four, which is why troubleshoot could not be offered here and the Scheduler dock had to
// hand-roll its own labels.
vv_ai_profiles_script();
?>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// Server-side is the authority on retrieval depth and history; these are for the UI only, and
// they must not drift from VV_AI_PROFILES in api/ai.php.
const PROFILES = {
varaverk: { label: 'Varaverk Assistant', turns: 3, kind: true,
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
chat: { label: 'General Chat', turns: 8, kind: false,
hint: 'Ordinary conversation. Hands anything about this install to the assistant on its own.' },
code: { label: 'Code Sketcher', turns: 4, kind: false,
hint: 'Drafts short scripts for Custom Scripts. First drafts — test before trusting.' },
};
window.VvAiProfiles = PROFILES;
// The server is the authority. It re-derives depth and capability on every request regardless
// of what is here — these values draw buttons and decide whether to show the kind filter, they
// do not make policy.
const PROFILES = window.VvAiProfiles;
const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
@@ -678,9 +715,9 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<div class="vv-ai-profiles" id="<?= $p ?>-profiles">
<?php foreach (['varaverk' => 'Varaverk Assistant', 'chat' => 'General Chat',
'code' => 'Code Sketcher'] as $key => $label): ?>
<button class="vv-ai-prof" data-prof="<?= $key ?>" type="button"><?= $label ?></button>
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
<button class="vv-ai-prof" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>" type="button"
title="<?= htmlspecialchars($def['hint'], ENT_QUOTES) ?>"><?= htmlspecialchars($def['label']) ?></button>
<?php endforeach; ?>
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
</div>
@@ -705,9 +742,12 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
// The stored-conversations container. Rendered separately from the chat because the two live in
// different cards on Monitor and in different parts of the column on the AI tab.
function vv_ai_chat_list_markup(string $prefix): void {
//
// Takes compact for the same reason the chat does: these rows are painted against whatever they
// sit on, and a card and a panel are not the same ground.
function vv_ai_chat_list_markup(string $prefix, bool $compact = false): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
?>
<div id="<?= $p ?>-chats"><div class="vv-ai-none">loading…</div></div>
<div id="<?= $p ?>-chats"<?= $compact ? ' class="vv-ai-c"' : '' ?>><div class="vv-ai-none">loading…</div></div>
<?php
}
+192
View File
@@ -0,0 +1,192 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The one definition of what an AI profile is. Every consumer — the endpoint, the worker, the
// shared chat include, the Scheduler dock — reads it from here instead of restating it.
//
// WHY THIS EXISTS
// A profile used to be defined in five places: history depth in api/ai.php, capabilities in
// include/ai.php, label/hint/depth again in the chat's JavaScript, a prompt branch in the
// worker, and a label map in pages/scheduler.php. They had already drifted — the JavaScript
// knew three profiles where PHP knew four, so the shared chat could not offer troubleshoot at
// all and the Scheduler dock hand-rolled its own labels to compensate. The include carried a
// comment telling the next person not to let the two tables diverge, which is a comment doing
// a data structure's job.
//
// WHAT LIVES HERE, AND WHAT DELIBERATELY DOES NOT
// Here: anything more than one file needs to agree on — the set of profiles, their labels and
// hints, history depth, capabilities, and whether a profile is offered as a button.
//
// Not here: the system prompts. They are long, delicate, and have exactly one consumer, so
// moving them would be churn against the most sensitive text in the subsystem for no reduction
// in duplication. Tools/ai_chat_worker.php still owns them; it just keys off ids validated
// here rather than an if-chain that invents its own vocabulary.
//
// CAPABILITIES ARE PER PROFILE, NOT PER CAPABILITY
// The old table was inverted — capability => [profiles] — which reads well when adding a
// capability and badly when answering the question actually asked at runtime, which is always
// "what can this profile do". Same content, turned the right way round.
//
// A profile is a contract plus a set of inputs, and the inputs are the half that has to be
// enforced rather than requested. The caps list is that half.
//
// It exists because the alternative already failed. The same permissions used to live as a
// dozen `$profile === 'varaverk' || $profile === 'troubleshoot'` conditions spread across the
// worker, and answering "may chat ever be shown a log?" meant reading all of them. It could —
// a gate added for run-outcome questions granted it by omission, and the chat profile, whose
// entire value is that it has NOT been shown this installation, was one phrasing away from
// being handed a health sweep and 120 lines of log. Nothing about that was visible at the
// point of the mistake. Here it would have been one missing word on one line.
//
// A capability is permission, not need. varaverk holds 'health' but only attaches it when the
// question looks diagnostic; troubleshoot attaches it always. The gates decide whether an
// input is warranted, this decides whether it is allowed, and a gate can never widen the grant.
//
// chat holding an empty capability list is a guarantee, not an oversight. Anything added to it
// stops being general chat and becomes an assistant that sometimes lies about this
// installation.
//
// EXPORTS
// vv_ai_profiles() the whole table
// vv_ai_profile($id) one entry, or null
// vv_ai_profile_ok($id) is this a real profile
// vv_ai_profile_turns($id) history depth in turns
// vv_ai_profile_can($id,$cap) capability check
// vv_ai_profile_caps($id) every capability a profile holds
// vv_ai_profile_label($id) display name, falling back to the id
// vv_ai_profiles_ui() those offered as buttons, in order
// vv_ai_profiles_max_turns() deepest window of any profile
// vv_ai_profiles_client() the subset the browser needs, for json_encode
// vv_ai_profiles_script() publishes that subset as window.VvAiProfiles, once per page
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// turns — conversation depth. Varaverk Assistant spends ~2500 of its 16384 on retrieved
// passages so it cannot afford much; troubleshoot carries a whole log tail and can
// afford least of all; the two that retrieve nothing can hold a real conversation.
// caps — see VV_AI_CAP_MEANING below.
// ui — appears in the profile bar. troubleshoot is false because it is entered by opening a
// log on the Scheduler tab, not by choosing it: it is a context you are in, and a
// button offering it from the dashboard would produce a troubleshooting contract with
// nothing to troubleshoot.
const VV_AI_PROFILES_DEF = [
'varaverk' => [
'label' => 'Varaverk Assistant',
'short' => 'Assistant',
'hint' => 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.',
'turns' => 3,
'ui' => true,
'caps' => ['retrieve', 'kind_filter', 'health', 'run_evidence', 'incidents', 'conf_lookup'],
],
'chat' => [
'label' => 'General Chat',
'short' => 'Chat',
'hint' => 'Ordinary conversation. Hands anything about this install to the assistant on its own.',
'turns' => 8,
'ui' => true,
'caps' => [],
],
'code' => [
'label' => 'Code Sketcher',
'short' => 'Code',
'hint' => 'Drafts short scripts for Custom Scripts. First drafts — test before trusting.',
'turns' => 4,
'ui' => true,
'caps' => ['code_scan'],
],
'troubleshoot' => [
'label' => 'Troubleshoot',
'short' => 'Troubleshoot',
'hint' => 'Reasons from the log you have open, then the docs. Files a bug only when the evidence shows one.',
'turns' => 2,
'ui' => false,
'caps' => ['retrieve', 'health', 'run_evidence', 'scoped_log', 'incidents', 'conf_lookup', 'file_bugs'],
],
];
// Documentation only — nothing branches on it. Kept beside the table because a capability name
// with no stated meaning is how one ends up granted to a profile that should not have it.
const VV_AI_CAP_MEANING = [
'retrieve' => 'retrieval passages from the documentation index',
'kind_filter' => 'the retrieval kind filter the page exposes',
'health' => 'live health sweep measured at question time',
'run_evidence' => 'run record and log tail for a script named in the question',
'scoped_log' => 'log tail for whatever the operator currently has open',
'incidents' => 'operator-written history of what previously went wrong with this thing',
'conf_lookup' => 'deterministic "where does this conf key actually live" lookup',
'file_bugs' => 'may file a bug report against Varaverk itself',
'code_scan' => 'destructive-operation scan of generated shell',
];
function vv_ai_profiles(): array { return VV_AI_PROFILES_DEF; }
function vv_ai_profile(string $id): ?array {
return VV_AI_PROFILES_DEF[$id] ?? null;
}
function vv_ai_profile_ok(string $id): bool {
return isset(VV_AI_PROFILES_DEF[$id]);
}
// Falls back rather than throwing. An unknown id reaching here is a caller that failed to
// validate, and the shallowest window is the safe way to be wrong: it spends the least context.
function vv_ai_profile_turns(string $id): int {
return (int)(VV_AI_PROFILES_DEF[$id]['turns'] ?? 3);
}
function vv_ai_profile_caps(string $id): array {
return VV_AI_PROFILES_DEF[$id]['caps'] ?? [];
}
function vv_ai_profile_can(string $id, string $cap): bool {
return in_array($cap, VV_AI_PROFILES_DEF[$id]['caps'] ?? [], true);
}
function vv_ai_profile_label(string $id): string {
return VV_AI_PROFILES_DEF[$id]['label'] ?? $id;
}
// Insertion order is the button order, and varaverk is first because the strict profile is the
// one to land on: a misuse there costs "the docs don't cover that" rather than an invented claim
// about the system.
function vv_ai_profiles_ui(): array {
return array_filter(VV_AI_PROFILES_DEF, fn($p) => !empty($p['ui']));
}
// The cap api/ai.php applies when storing a conversation, which has to hold the deepest window
// any profile could later ask for — a chat saved under one profile can be reopened under another.
function vv_ai_profiles_max_turns(): int {
return max(array_column(VV_AI_PROFILES_DEF, 'turns'));
}
// What the browser needs, and nothing more. The prompts are not here to be sent and the server
// re-derives depth and capability on every request regardless — this is for drawing buttons and
// deciding whether to show the kind filter, not for the client to make policy with.
function vv_ai_profiles_client(): array {
$out = [];
foreach (VV_AI_PROFILES_DEF as $id => $p) {
$out[$id] = [
'label' => $p['label'],
'short' => $p['short'],
'hint' => $p['hint'],
'turns' => $p['turns'],
'ui' => (bool)$p['ui'],
'kind' => in_array('kind_filter', $p['caps'], true),
];
}
return $out;
}
// Publishes the registry to the browser as window.VvAiProfiles, once per page however many
// surfaces ask for it. Emitted as its own tag so any script block that consumes it stays pure
// JavaScript and remains syntax-checkable outside PHP.
//
// Both the shared chat include and the Scheduler dock call this. Before it existed the dock had
// its own literal `{ code: 'Code', troubleshoot: 'Troubleshoot' }` map, which is how a fourth
// copy of the profile list came to exist in the first place.
function vv_ai_profiles_script(): void {
static $done = false;
if ($done) return;
$done = true;
echo '<script>window.VvAiProfiles = '
. json_encode(vv_ai_profiles_client(), JSON_UNESCAPED_SLASHES) . ";</script>\n";
}