26 KiB
Varaverk AI Integration — Design Notes
Status: retrieval is built; integration is not. As of 2026-08-02 the AI_* and
HOST*_OLLAMA_* variables exist in both confs and both templates, and AI/ holds a working
index and query path — see the RAG section at the end of this document and AI/README-AI.md.
Everything else below remains design only. No Varaverk script consults AI. Every
AI_ASSIST_* toggle is false, AI_CONF_WRITE_ENABLED is false with an empty whitelist, and
host resolution across the mesh is specified but not implemented. Originally captured
2026-08-01 so the reasoning survives.
Ollama itself is installed, tuned and verified on HOST1 — qwen2.5-coder:14b for
generation, nomic-embed-text for embeddings, 16k context, pinned to the RTX 3080. See
Hardware Budget for measured numbers. That is the substrate, not the integration.
Origin: the RTX 3080 was freed when the Windows gaming VM was retired. It is bound to the
nvidia driver, not vfio — not reserved for passthrough, so there is no VM contention to
design around. Two goals at once: somewhere to learn local LLMs, and something Varaverk can
genuinely use.
Build order — deliberately lowest-risk first. Each stage must be boring before the next one starts:
- Chat assistant / settings helper / onboarding assistant — a wrong answer costs nothing
- Watchdog and discovery context — a wrong answer costs a bad suggestion, still gated
- Cleanup and sync decision aid — closest to destructive, last to be trusted
The regular script is always the backup, at every stage.
The governing principle
Varaverk works exactly as well with AI off as with it on.
Every script — including scripts written after this lands — is designed and hardened
without AI first. AI is added afterwards as enhancement, never as a dependency. A script
that cannot do its job when AI_ENABLED=false is a broken script, not an AI feature.
This is the constraint everything else in this document answers to. If a design decision makes AI load-bearing, that decision is wrong.
Corollary: AI never makes a destructive decision. The session that produced the current
safeguard layer (depth guards, strike thresholds, verification-after-write) exists because
config values and scan results feed rm -rf, chown -R, and rsync --delete. AI advises
at the points where a script currently stops and defers to a human. The deterministic guard
still pulls the trigger.
Two independent off-switches
| switch | meaning | source |
|---|---|---|
AI_ENABLED |
intent — do we want AI at all | master.conf |
| resolver result | availability — is there a reachable host | runtime probe |
Both must produce the identical code path when off. A caller that gets "no AI" from either must run its normal, non-AI logic — not a degraded variant, not a skipped step.
AI_ENABLED follows the fail-closed idiom standardised across the ecosystem:
[[ "${AI_ENABLED:-false}" != "true" ]] && <normal path>
Not == false. Anything that isn't exactly true means off, so a typo can never switch AI
on. (This is the same bug that was fixed in fallback.sh — see its FALLBACK_ENABLED gate.)
Configuration schema
Follows the existing rule: thresholds and toggles → master.conf; hardware, paths,
container names and per-host identity → host*.conf.
host*.conf — per-host, because only one node actually has the GPU
# ━━━ Ollama / AI ━━━
HOST1_OLLAMA_URL="http://localhost:11434" # empty on nodes without a local Ollama
HOST1_OLLAMA_CONTAINER="Ollama" # for docker_watchdog / restart lists
HOST1_OLLAMA_GPU_UUID="GPU-309357d8-2a13-09e0-84ac-fcfdcdf5c626"
HOST1_OLLAMA_MODEL="qwen2.5-coder:14b" # generation
HOST1_OLLAMA_EMBED_MODEL="nomic-embed-text" # embeddings — qwen cannot embed
A node with an empty OLLAMA_URL is not an error — it falls through to the resolver and
uses the mesh. HOST2 gets the section with blanks, exactly like the NPM/lldap credentials
it is already waiting on.
master.conf — shared behaviour
# ━━━ AI ━━━
AI_ENABLED=false # master switch — fail-closed, != "true" means off
AI_CONNECT_TIMEOUT=5 # probe timeout when resolving a host
AI_REQUEST_TIMEOUT=240 # must clear a cold load — measured 1m45s after tuning.
# KEEP_ALIVE=-1 means this only bites after a restart,
# but a first call that times out is the worst first
# impression a caller can have. Re-measure against a
# real RAG query before fixing this number.
AI_RESOLVE_CACHE_TTL=300 # don't re-probe the mesh on every script invocation
AI_MAX_RETRIES=1 # AI is enhancement — do not retry hard
# Per-feature toggles — enable narration long before enabling decision aid
AI_ASSIST_REPORTS=false # tier 1 — digest / coffee report narration
AI_ASSIST_WATCHDOG=false # tier 2 — context on a flagged condition
AI_ASSIST_DISCOVERY=false # tier 2 — discovery / classification judgment calls
AI_ASSIST_CLEANUP=false # tier 2 — HELD orphans, stuck-import triage
AI_ASSIST_ONBOARD=false # tier 3 — onboarding / settings assistance
# Conf writes — separate switch, off by default, see Conf Write Access below
AI_CONF_WRITE_ENABLED=false
AI_CONF_WRITE_KEYS=() # explicit whitelist; never paths or credentials
Per-feature toggles are load-bearing, not decoration. They are what lets AI narrate the
weekly digest for months before it is ever allowed near a cleanup decision. AI_ENABLED is
necessary but not sufficient — every feature stays individually off until it has earned it.
When these land,
Deployment/master.conf.templateandDeployment/host.conf.templatemust be updated in the same pass. That rule is not optional in this repo.
Host resolution
AI runs on the owner's node only. Remote mesh nodes reach it over Tailscale. No remote node needs a GPU, a model, or an Ollama container — only the resolver.
resolve_ollama_host() mirrors the existing Gitea locator in git_pull_execute.sh:
Ollama answering on localhost:11434? → use it (owner's node)
else discover_remote_nodes()
→ resolve_tailscale_ip(node)
→ probe each :11434 → first responsive wins
else → no AI host (== AI_ENABLED=false)
Helpers already exist in common.sh: discover_remote_nodes() (767),
resolve_tailscale_ip() (812), check_connectivity() (866).
Probe the API, not the container. The Gitea locator checks docker ps. Do not copy that
here — a container can be up while the model is unloaded, still pulling, or wedged. Probe
/api/tags. Same principle written into network_watchdog.sh's design principles:
verify the path, not the process.
Cache the resolution in /tmp state, like the arr cache. A cleanup script should not pay
a Tailscale round-trip to discover AI it may never call.
Known consequence
AI lives on HOST1, so during a fallback — HOST1 down, HOST2 covering — the mesh has no AI. That is precisely when a triage assistant would be most useful. Accepted: a second GPU on HOST2 is a lot of hardware for that window, and AI is enhancement-only by design. Worth knowing rather than discovering.
Where AI is allowed to act
Ranked by how much damage a wrong answer does.
Tier 1 — narration and summary (safe, do first)
- Sunday morning coffee report — turn metrics into prose
weekly_health_digest.sh— summarise, highlight what changed- Explain why a container is crash-looping from its logs
Tier 2 — triage and context on an existing flag (the real value) Places where a script already detects something and stops:
system_watchdog/stability_watchdogflags a condition → AI adds context, correlates with recent logs, suggests likely cause- Sonarr stuck-import triage — the "matched by series ID" recipe is textbook LLM work
HELDentries fromarr_download_orphan_cleaner.shreverse-anime-leakfrom the classification scans — currently report-only because it is a judgment call. That is exactly the shape AI suits.
Tier 3 — assisted configuration (needs the guardrails below)
- Onboarding a new host — the main motivation for conf write access
- AI-assisted settings tuning: rsync profiles, fallback tiers, auth stack
Never
- Deciding what to delete
- Choosing a path for any destructive operation
- Anything that bypasses a strike counter, age gate, or verification step
Conf write access
Wanted mainly for onboarding and assisted settings. This is the highest-risk item here.
Current state: there is no recovery path.
.gitignore:4 Configurations/host*.conf
.gitignore:5 Configurations/master.conf
.gitignore:6 Configurations/*.bak
Confs are gitignored — no git history to revert to — and so are the .bak files, so the
backup is not versioned either. The only fallback is a single .bak slot written by
conf_upgrade, and it goes stale immediately:
| file | modified | its .bak |
|---|---|---|
master.conf |
Jul 28 18:52 | Jul 28 18:52 |
host1.conf |
Aug 1 21:00 | Jul 3 17:46 |
A bad write to master.conf currently falls back to a file that may predate a month of edits.
Fix this before any AI writes anything.
Required before conf-write ships
- Key whitelist, not file access. Thresholds and toggles only —
*_WARN_GB,*_STRIKE_LIMIT,*_ENABLED, retention days. Never a path, never a credential, never a container list. A wrong threshold is recoverable; a wrong path is what the depth guards exist to catch. - Timestamped backups, plural —
master.conf.2026-08-01T21:00, retained. Not one clobbered slot. - Validate before commit —
bash -nthe candidate, then confirmload_config.shsources it cleanly. Never install a conf that has not been proven to parse. - Diff always logged. An AI conf change should be at least as visible as a container restart.
- Lock against concurrent readers — never rewrite a conf while scripts are mid-run.
Consider un-ignoring Configurations/ into a private repo. Then git diff and
git revert become the recovery mechanism and the history is free. This overlaps the
existing GitHub-mirror TODO, which is already blocked on the same question.
Security
Ollama has no authentication of any kind, and its API includes DELETE /api/delete
(wipe models) and POST /api/pull (fill the disk). It currently binds 0.0.0.0:11434 with
OLLAMA_ORIGINS=* — reachable from the entire LAN, not just Tailscale.
The design only needs loopback (owner) plus the Tailscale interface (mesh). 0.0.0.0 is
strictly wider than required, for no benefit. Restrict to loopback + Tailscale, or use
Tailscale ACLs to allow only mesh nodes. Node-level ACLs fit the mesh model better than
app-level auth Ollama cannot provide anyway.
Done 2026-08-01: /ext-varaverk is now mounted ro (was rw into live prod).
Verified on the running container — rw=false.
Still open: the LAN exposure above. Deliberately not folded into the tuning rebuild, since bind-address versus Tailscale ACL is a decision rather than a setting.
Hardware budget
RTX 3080, 10 GB, pinned to Ollama by UUID — isolated from the Quadro P2000 that Emby transcodes on. Do not let AI onto the P2000.
Tuned and measured 2026-08-01. These are observed values, not estimates.
| before | after | |
|---|---|---|
OLLAMA_NUM_PARALLEL |
2 | 1 |
OLLAMA_KV_CACHE_TYPE |
f16 | q8_0 |
OLLAMA_CONTEXT_LENGTH |
4096 | 16384 |
OLLAMA_FLASH_ATTENTION |
false | true |
| VRAM used | 9298 MiB (91%) | 8811 MiB (86%) |
| warm latency | 2.6s | 1.85s |
| cold load | 28.6s | 1m45s |
/api/ps confirms ctx=16384 — the increase is real, not just an env var.
4× the context for less VRAM than before. Flash Attention plus the quantized KV cache
more than paid for the increase. Cold load got much slower, which is irrelevant while
OLLAMA_KEEP_ALIVE=-1 pins both models — but it is felt after any container restart.
Flash Attention is mandatory, not optional
OLLAMA_KV_CACHE_TYPE=q8_0 will not load without it:
llama_init_from_model: V cache quantization requires flash_attn
llama-server process no longer running: exit status 1
Quantized K/V cache requires Flash Attention. Supported on Ampere and newer; the 3080 qualifies. If the KV cache type is ever changed back toward a quantized value, Flash Attention must be on or the model silently fails to load and every call errors.
Tuning order still matters
If these are ever re-tuned from defaults, the order is load-bearing — raising context first at high utilisation will OOM:
OLLAMA_NUM_PARALLEL→ 1 (each slot multiplies KV cache)OLLAMA_FLASH_ATTENTION→ true (prerequisite for the next step)OLLAMA_KV_CACHE_TYPE→ q8_0 (roughly halves KV memory)- then
OLLAMA_CONTEXT_LENGTHupward
32k was considered and rejected — projected ~1000 MiB of KV, leaving under 450 MiB headroom. 16k is the comfortable ceiling for a 14B on this card.
Concurrency
You cannot have 14B + long context + real parallelism on 10 GB. Pick two.
OLLAMA_MAX_QUEUE=512 means excess requests queue rather than fail, and at ~2s responses,
two or three users serialised is barely noticeable. Multi-user hits are expected to be rare.
So: parallelism stays at 1, the queue absorbs bursts, and the VRAM goes to context —
which is what RAG actually needs.
Applying template changes
Unraid's "Apply" does not reliably recreate the container. Observed 2026-08-01: the
template was saved correctly but the container was only restarted, so the env vars never
took effect — Created stayed unchanged while Started advanced. Env changes require a
remove-and-recreate.
Force it with Unraid's own script:
/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container Ollama
docker start Ollama # rebuild stops it — Ollama is not in unraid-autostart
Verify with docker inspect Ollama --format '{{.Created}}' — the timestamp must move.
Checking the env vars alone is not enough; a restart leaves the old ones in place and
looks like nothing happened.
RAG
No Python on Unraid, and none needed. Everything required is already present:
sqlite3 3.53, jq 1.8, node 22, php 8.4, awk.
- chunks + vectors → SQLite, one table
- similarity → cosine in PHP or Node; milliseconds at this corpus size, no vector DB container needed
- embed + generate → Ollama HTTP, same
curlpattern as every other integration here
Note qwen2.5-coder returns 501 — does not support embeddings. Embedding is
nomic-embed-text's job. Batch embedding works (n inputs → n vectors in one call) and is
required — indexing 50k lines one HTTP call at a time is not viable.
The corpus — and why its shape matters more than its size
As of 2026-08-01, after the header audit and the per-folder documentation pass:
| Layer | Size | What it answers |
|---|---|---|
| Script headers | 115 files × 6 sections = 690 chunks, 14,685 lines | "What does this script do, and why that way" |
| Folder docs | 18 README-*.md + 13 Manual-*.md |
"How does this group work" / "how do I do the thing" |
| Top-level | README.md, Manual.md |
"What is this system" |
| Conf templates | 2,175 lines, ~55% comment | The schema, self-describing |
| Bash bodies | 51,166 lines | Implementation — index last, lowest weight |
Markdown total: 13,516 lines. Still small enough that cosine over the whole set is milliseconds.
Chunking is already solved, and the audit is what solved it. Every script carries
PURPOSE / OPERATIONAL MODEL / DESIGN PRINCIPLES / OPERATIONAL SAFEGUARDS / CONFIGURATION / RUNTIME MODES — 115 of 115, no exceptions. Split on ^# SECTION NAME$ and every chunk is
a semantically coherent unit by construction. The single worst failure mode in naive RAG —
a fixed-size window cutting mid-thought and embedding two half-ideas as one vector — cannot
happen here. Median header is 117 lines, so a section lands around 130–200 tokens: comfortably
inside nomic-embed-text's window, no sub-splitting needed.
Store the section name as a column, not just as chunk text. This is the highest-value thing the audit bought and it should not be thrown away at index time. Section type is a free metadata filter, so retrieval can route before it computes similarity:
| Question shape | Filter to |
|---|---|
| "what stops X and Y overlapping" | OPERATIONAL SAFEGUARDS |
| "what variable controls X" | CONFIGURATION |
| "does this take --dry-run" | RUNTIME MODES |
| "why is it built this way" | DESIGN PRINCIPLES |
| "what does this script do" | PURPOSE |
Hybrid retrieval essentially for free, because every chunk already has a type.
Suggested table shape:
CREATE TABLE vv_chunks (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL, -- repo-relative
kind TEXT NOT NULL, -- header | readme | manual | template | body
section TEXT, -- PURPOSE, OPERATIONAL SAFEGUARDS, ... (NULL for md/body)
heading TEXT, -- md ## heading, for doc chunks
content TEXT NOT NULL,
vector BLOB NOT NULL, -- 768 float32
indexed INTEGER NOT NULL -- epoch; re-embed on mtime change only
);
Why this corpus is worth more than an equivalent pile of code
A model can read mover_stop.sh and describe what it does. What it cannot derive from any
amount of source is that a thing was done deliberately. The audit wrote those down:
- the API cache writers are lockless and unprivileged on purpose — regenerable within a minute, every consumer has a live fallback
removeCompletedDownloads/removeFailedDownloadsboth true is intended, not an oversight- the arr cleanup ctime gate depends on
media_shares_permissions.shstaying conditional — reverting either silently stops orphan collection mesh_monitor.sh,adapter.sh,decision_engine.sh,containers.shandapi_cache_writer.shcarry no root check and no lock by design — each documents why in its own header (libraries that must notexit, read-only probes, or regenerable output with a live fallback)
Without those in the index, the most likely contribution from an AI assistant reviewing this
repo is a confident regression: "I notice this script lacks a lock." Weight
DESIGN PRINCIPLES and OPERATIONAL SAFEGUARDS heavily for any suggest-a-change flow —
they are the guardrails against the assistant helpfully undoing a decision.
Indexing is safe by default — keep it that way
Configurations/*.conf is gitignored; Deployment/*.template is tracked and carries all the
explanatory comments. The corpus therefore describes the full schema while structurally
never containing a credential, because the credential-bearing files were never in the repo
to begin with.
Treat that as a deliberate boundary, not a happy accident:
- index tracked files only — never walk
Configurations/ordata/ - a live conf value that the model genuinely needs should arrive through a tool call at query time, subject to the same redaction rules as everything else in the Security section, not be baked into a vector at index time
- an embedded secret is unrevocable in a way a logged one is not — there is no rotation story for a value already averaged into a 768-dim float
Known gap — the PHP layer is not covered
78 PHP files under Plugin/unraid/; 2 carry a PURPOSE block. The entire web UI —
pages/, api/, include/ — is effectively invisible to retrieval.
Consequence: any "AI helper per Varaverk page" feature has this as a hard prerequisite. A page-scoped assistant that cannot retrieve the page's own logic is worse than no assistant.
include/ is the high-value subset to do first — 16 files, and both the pages and the API
endpoints route through the same vv_*() builders, so documenting it once covers both
callers. This is a follow-on pass, not a blocker for indexing bash.
Scheduled AI
Same orchestrator tiers as everything else, gated on AI_ENABLED plus a reachable host.
Natural fits: weekly digest narration, a periodic pass over HELD/report-only findings that
have accumulated, post-incident summaries after a watchdog event.
Must obey the existing tier discipline — an AI job that fails or times out is a non-fatal step like any other, and never blocks the rest of its tier.
UI
- Dedicated AI page in the plugin.
- Persistent conversation across pages. A floating widget is not required — a chat column is fine. The constraint is that Unraid's WebGUI is multi-page PHP with full reloads and no SPA shell, so persistence means conversation state lives server-side keyed by session, with the client re-hydrating per page.
- Per-page AI helpers — contextual assistance scoped to whatever that page is about.
"Too bad we can't just run the LLM inside Varaverk and cut out Ollama"
There is a real answer: you don't cut Ollama out, you absorb it.
Ollama does non-trivial work — model lifecycle, GPU scheduling, keep-alive, batching, an HTTP API. Reimplementing that in bash is not a good trade. But Varaverk already manages containers better than most things manage containers. Ollama becomes just another managed container:
- add to
HOST*_WATCHDOG_CONTAINERSsodocker_watchdog.shkeeps it healthy - add to a restart list so it gets the same proactive treatment as everything else
- give it a fallback tier if AI should survive a host outage
- let
docker_update.shhandle its image updates
That is more Varaverk-native than embedding a model runtime would be, and it costs nothing new — the machinery already exists and was audited this session.
Open questions
- Un-ignore
Configurations/into a private repo for conf history? (blocks conf-write, and overlaps the GitHub-mirror TODO) - Does the AI page need auth separate from the Unraid WebGUI, given remote mesh members?
- Retention/privacy for conversation history — logs may contain paths, container names, possibly credentials pasted by a user.
- Is a 7B worth it to buy context + parallelism headroom, or is 14B quality worth the serialisation? Defer until an actual problem is felt.
RAG — built 2026-08-02
Retrieval is live. AI/ holds the implementation; AI/README-AI.md documents it in full. What
follows is only what changed relative to the plan recorded above.
Corpus is larger than estimated. ~2,950 chunks across ~180 files, not the 690 header chunks projected. Sub-chunking is why — see below.
Named-paragraph sub-chunking was necessary, and was not in the plan. Section-level chunks
alone were too coarse. rsync.sh documents fourteen safeguards in one 2.8k-char
OPERATIONAL SAFEGUARDS block; a query about one of them scored 0.558, below unrelated chunks,
because the other thirteen dominated the vector. Splitting on the named-paragraph titles the
header convention already uses took the same query to 0.718 and first place. The parent section
name is carried onto each sub-chunk so routing still works.
Two chunker bugs worth remembering. The last section in a header (RUNTIME MODES in bash,
DEPENDS ON in a page) ran to EOF and swept up every unrelated comment in the file —
scheduler.php alone produced an 11k-char chunk of unrelated inline comments. And title
detection must require the next line to be indented; without that, any wrapped prose line
became a spurious chunk boundary mid-sentence.
Section routing is a boost, not a filter. Intent detection is a heuristic and must not be
able to exclude the chunk holding the answer. --section= forces a hard filter when wanted.
Vectors arrive pre-normalised. nomic-embed-text returns L2-normalised vectors (measured
norm 1.0000001), so cosine is a plain dot product. No normalising step, no magnitude cache.
node:sqlite over a native module. Still flagged experimental, chosen because it needs no
native compilation on Unraid. Acceptable because the index is disposable — if a Node upgrade
breaks it, rebuild takes minutes. PHP reads the same float32 blobs with unpack('f*', $blob)
when the UI needs them.
Retrieval quality, measured. 9/10 top-3 hit rate on known-answer questions; the tenth had the answer at ranks 2 and 3, so 10/10 for answer-present-in-context at k=6. Full retrieval plus generation runs about 43s warm.
The finding that validated the whole thing
First real end-to-end question asked which variable controls the mover's grace window. The model
answered MOVER_STOP_TIMEOUT, "defaults to 30 seconds", citing mover_stop.sh › CONFIGURATION.
Variable correct; the 30 was wrong — the real value is 300. The model was quoting the header
verbatim. The header was stale.
A sweep for the same pattern found six stale (default: N) claims across the repo — all
corrected in the same pass. This is the operating principle for the folder:
Retrieval is exactly as accurate as the documentation it points at. When an answer looks wrong, check the cited source before blaming the model.
It also means the index is a documentation-drift detector, not only a question-answering tool.
Still deliberately not built
Nothing consults this. Every AI_ASSIST_* toggle is false, AI_CONF_WRITE_ENABLED is false
with an empty key whitelist, and no watchdog, cleanup or fallback path calls it. Host resolution
across the Tailscale mesh is specified above but not implemented — ai_index.sh and
ai_query.sh currently require a local HOST*_OLLAMA_URL and fail with a clear message when it
is empty, rather than silently probing the mesh.