AI_INDEX_ON_PULL was designed, defaulted false, and never read by anything —
so nothing has ever rebuilt the index automatically. A pull is the only thing
that changes tracked files on a server, which makes it the only moment the
index can go stale, and a timer would do nothing 23 times a day while still
drifting from the pull that matters.
Staleness is invisible in the answers: the index keeps returning the old text
with confident, correct-looking citations. Gated on pull success, on
AI_INDEX_ON_PULL and on AI_ENABLED, and never fatal — a git pull must not fail
because an embedding call timed out.
"What is Varaverk" matched the PURPOSE intent, so retrieval returned every
script's one-line purpose and the model answered that the context does not
define the system — while README.md sat in the index unread. A definitional
question with no explicit filter now goes to kind=readme.
Varaverk is a coined word with no spell-check, so it arrives as varavrk,
veraverk, varavek. Edit distance catches those without a pattern that needs
extending per typo. This is search, not identity — a near-miss only widens a
document search, unlike hostname resolution where it must never resolve.
Custom Scripts run as root on a schedule, so the expensive mistake is not
tangled logic — it is a simple script aimed one directory too high. The prompt
now asks for the dry-run form and an explicit line on what gets destroyed, and
a scan of the generated code blocks raises a banner regardless of whether the
model bothered to warn. Scans fenced code only, so prose mentioning rm does not
trip it.
Given its own broken script and the real error, it produced the correct fix but
explained --no-overwrite away as 'introduced in a later version of rsync'. No
rsync version has ever had that flag, and the operator is on current 3.4.4 — so
the repair was right and the diagnosis was a second fabrication that would send
someone hunting a nonexistent upgrade.
The pattern required three underscore-separated segments, so RSYNC_ENABLED and
AI_ENABLED slipped through — exactly the names most likely to be asked about.
Also dropped the case-insensitive flag, which made the SCREAMING_CASE branch
match ordinary lowercase words containing underscores.
Asked what mover_stop.sh does, it invented a description and dressed it in
real memory facts so it read as authoritative — the exact failure the profile
split exists to prevent. A polite instruction in a system prompt is not a
guard. The rule now leads and is absolute, and a deterministic check repeats
it immediately before the message whenever the question names a shell script,
a SCREAMING_CASE conf key, or Varaverk itself. Detection can only make the
model more cautious, so a false positive costs a redirect, not an answer.
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.
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.
The pattern required a tier prefix, so the master gate the rsync page renders
alongside the tier toggles was rejected as an invalid flag name and sprang
back to off. There was no way to enable syncing from the UI. Masked until now
because every POST to this endpoint was being swallowed as multipart.
A multipart POST to the plugin API hangs and never completes on this host: no
status code, and no server-side trace of any kind. The correlation was exact —
every page using URLSearchParams worked, every page using FormData hung, which
is why scheduler and partnership appeared fine while docker, rsync, settings,
auth, arrs, fallback and monitor did not. URLSearchParams has the same append
API and fetch sets the urlencoded content type for it, so each site is a
one-token change with the payload logic untouched.
A multipart POST to this endpoint hangs: no status code is ever returned, and
the request never reaches PHP — no CSRF termination, no fatal, no entry log —
despite leaving the browser with a valid token and a correct body. Every POST
on this host that demonstrably works, including Unraid's own, is
x-www-form-urlencoded. Same fields and same $_POST server-side.
A POST the browser demonstrably sent — correct token, correct body — left no
trace: no CSRF termination, no fatal, and no action log. Rejected-before-PHP
and died-inside-the-include are indistinguishable from outside, so the first
statement now records that execution arrived.
The build-stamp edit replaced the header block's closing tag instead of
keeping it, opening a second php block inside the first. Deployed broken
because the lint in that commit printed OK unconditionally rather than
gating on php -l.
The tab bar uses Unraid's localURL, which swaps content by AJAX without
tearing down the previous page's JavaScript, so old copies keep their timers
and their state. That makes "is the browser running what I deployed"
unanswerable from the server, and explains banner polls arriving far faster
than the 30s timer. The page now stamps its build, says plainly when its own
script is not the one running, and stops the previous copy's timers.
A turn that ended without finish() left busy=true, so send() returned at line
one for every subsequent click — no fetch, no error, and the original
"starting…" still on screen. That is a hang which produces no request and so
no server-side trace of any kind. It now says what happened, and the pending
indicator carries an elapsed counter so stalled and merely slow look different.
GET reaches the endpoint and POST does not, with nothing in the request log,
no CSRF termination and no PHP error — so it fails in the browser before the
request goes out, and the only symptom was the pending indicator sitting
there. Reads the response as text before parsing so an empty body reports as
rejected-before-execution rather than a JSON error, wraps the synchronous
path, and surfaces script errors into the transcript.
There is no nginx access log on this host and the CSRF prepend exits with an
empty body, so a request that never arrived and one that arrived and failed
were indistinguishable — the page just sat at "starting…". The job-file write
was also suppressed with @, which would produce exactly that hang: a token
returned for a job that can never report.
Asked what was wrong, it answered "AI_ENABLED=false" — read from the conf
template, which records the shipped default, while the live value was true.
Passing checks are what tell the model the current value of a setting;
without them a documented default fills the silence. The prompt now states
explicitly that live state overrides any documented default.
vv_meta uses columns k/v, so the embedder-match check read nothing and
reported "unrecorded" — a silent pass on the one mismatch that returns
confident nonsense instead of an error. And Ollama reports tags fully
qualified while conf carries the bare name, so an installed embedding model
was reported missing. A health panel that cries wolf is worse than none.
The failures this subsystem actually has are configuration drift, so each
check names the setting to change rather than reporting that retrieval
failed. Notably it catches a conf model tag that is no longer installed, and
an index built by a different embedder than the one configured — vectors
from two models are not comparable, and that failure returns confident
nonsense rather than erroring. Diagnostic questions also get recent log
warnings, attached only then because they cost budget the passages need.
Token and poll rather than SSE, so the api layer keeps one response
convention and reuses the pattern manual_sync already proved. History is
capped at three turns because the model is only fully offloaded at 16384
context and unbounded history would cross that silently. The tab exists
only while AI_ENABLED is true, rejected server-side and not merely hidden.
Pasted into Open-WebUI rather than installed by a script: tools live in its own
database, and writing there means guessing at the schema and access control of
a live app. The secret is a Valve so this file stays committable.
Open-WebUI runs in its own container with no WebGUI session, and nginx applies
auth_request to everything it serves, so it cannot call the plugin API. Same
shape as the arr webhook listener: node outside nginx, shared secret, one
read-only verb. Serves chunks rather than answers because the caller already
has a model loaded.
Both filters were parsed and honoured under `search` but never passed through
under `ask`, so --section=CONFIGURATION silently retrieved from the whole index
and returned a plausible answer built from the wrong chunks.
The index already stored chunk origin and search.js already filtered on it;
only the wrapper refused the flag. Intent routing boosts PURPOSE for "what
is X", which buried README.md and made the system answer that it had no
definition of itself.
Merging the raw host.conf.template against a live host conf matched HOSTN_
against HOST1_, classified all 140 real keys as deprecated and would have
removed every credential on the host. Also points the Ollama model default
at a tag that still exists.
Unraid already enforces CSRF on every POST via auto_prepend, but its
injector is jQuery-only — the plugin's native fetch() calls carried no
token and were being terminated before the endpoint ran, silently,
because csrf_terminate exits with an empty body that r.json() swallows.
Writing down what each endpoint actually guarantees made the places it
didn't obvious — shell arguments reaching a crontab or a bash -c
unescaped, master.conf written without tmp+rename, and conf edits that
could be saved without ever being parsed.
ai_index.sh and ai_query.sh follow the usual conventions — fail-closed gate,
root check, lock, dry-run, status — with Node doing only the vector maths and
SQLite blobs, the same split api_cache_writer.sh uses for PHP.
AI_* and HOST*_OLLAMA_* land in both confs and both templates in this pass.
Everything ships off: AI_ENABLED false, every AI_ASSIST_* false, conf writes
disabled with an empty whitelist. Nothing in the ecosystem consults it.
Chunker splits on the header sections the audit standardised, then sub-splits
named-paragraph safeguards — without that a specific question about one of
rsync.sh's fourteen safeguards scored below unrelated chunks, because the other
thirteen dominated the vector. Index is SQLite with raw float32 blobs and is
incremental on mtime; a no-op re-index takes 66ms.
The stale defaults were found by asking the system a question and checking its
answer: it correctly reported what mover_stop.sh's header claimed, and the
header was wrong.
Documents each tab's purpose, what it renders, and which endpoints it polls.
Pages that start with markup get the header in a <?php ?> block so it never
reaches the browser.
Also corrects the layer diagram in README-unraid.md: eight of eleven pages hold
no PHP logic and poll api/ for everything — only auth, monitor and scheduler
require an include/ file directly.
The 2026-07-19 prune covered the skip list and intentional stops but not the
per-container strike counts, which have the same failure: set_strikes 0 only
fires when a container is seen running again, so an uninstalled one keeps its
count forever and every consumer reads the host as unhealthy. Reserved daemon_*
keys are excluded.
All 16 include/ files now carry PURPOSE / DESIGN PRINCIPLES / OPERATIONAL
SAFEGUARDS / EXPORTS / CONFIGURATION, keeping the first three section names
identical to the bash headers so retrieval can route across both languages.
monitor.php read six watchdog state files from /tmp while the watchdogs write
to STATE_DIR, so every strike set came back empty and the summary reported
healthy unconditionally. docs.php gained path containment before it is wired
to a page.
The Authelia config default pointed at /mnt/user/appdata/Authelia, which does
not exist — the auth stack lives in the Critical-Data share so it gets synced.
Because conf_populate only fills empty fields, that wrong non-empty default
blocked auto-detection permanently and left the auth page's rules panel dead.
Also corrects three schedule comments that no longer matched varaverk.cron and
two references to scripts that have since been renamed or split.
The header audit and per-folder docs pass produced a corpus with properties
worth capturing before they are forgotten: deterministic chunk boundaries,
section type as a retrieval filter, and an index that cannot leak credentials
because the conf files were never tracked.
The repo front page read like generic documentation rather than a record of
why any of this exists. Adds the origin story, the current honest state, real
failure history, and fixes drift in platform_*() naming, the fallback tier
variable pattern, and the Plugin/unraid script locations.
It is the front page and it read like a spec sheet. Adds the incidents the safeguards came
from, an honest note about what is actually switched on, and the layers built since.
The folder that translates the OS into Varaverk's vocabulary had no docs of its own, so the
adapter contract and the three-layer web UI were only discoverable by reading the code.
The fallback docs used an older COVERS naming in the wrong conf file, and rsync's merge-run
mode — the only path that carries --delete — was undocumented entirely.
The docs had drifted from the scripts — a script that no longer exists, three wrong variable
names, a reversed run order, and seven scheduled scripts that were never documented at all.
Finishes the pass: every script now documents its safeguards, and the deliberate absences
in the sourced libraries are recorded so they are not "corrected" later.
On this host "authelia" matches both Authelia and Authelia-Secondary, and taking the first
match writes the wrong instance into the conf every other script then trusts.
A copy truncates the live conf and writes into it, so anything sourcing load_config.sh
during that window reads a partial file with empty path variables.
Artist and title strings are full of regex metacharacters, so a new candidate could match
an unrelated history entry and be silently skipped as already seen.
The estimates in the original note were wrong in both directions, and Unraid's Apply does
not reliably recreate a container, which is worth knowing before the next tuning pass.
Captures the reasoning behind a not-yet-built feature so the constraints survive the
session, chiefly that AI stays enhancement-only and never load-bearing.
MY_ID is not exported and is unset until detect_hosts runs, so the DDNS and NPM checks
were silently resolving empty config and skipping themselves every cycle.
No such rc.d service exists on Unraid (emhttpd isn't managed via a
standalone rc.emhttp script) and nothing in the codebase calls
platform_restart_service/platform_stop_service with 'emhttp' — confirmed
during a post-7.3.2-upgrade audit of all platform-specific paths.
Unraid's real event name is unmounting_disks, not disks_unmounting — the
mismatch meant emhttp_event never found this hook, so array_stopping.sh
never ran on real array stop.
Extends the Sonarr-only corruption scan into a generic per-arr loop (same
pattern as arr_full_rescan.sh) instead of a second script, since the
scan/strike/remediate logic is identical and only the API shape differs.
Radarr's moviefile list is fetched batched to include secondary/alternate-
edition files, not just each movie's primary file.
Also fixes two bugs found while testing: build_arr_path_map()'s internal
non-local `for key in ...` loop was clobbering the per-arr API key
variable, and arr_api()'s error output (stdout, not stderr) was getting
appended into the batch fetch file on any single failed call, corrupting
jq's parse of the whole batch and silently zeroing out that arr's results.
stop_local_containers() reads LOCAL_CRITICAL_CONTAINER_NAMES, which was
never set here — only rsync.sh sets it, in a child process that can't
propagate back. Local stop always no-op'd, so the rebuild-on-new-image
step never had anything to act on.
Neither remove_from_skip_list() nor clear_intentional_stop() can ever
fire for a container that's been fully uninstalled — both only trigger
on seeing it running again. Confirmed live 2026-07-19: Healarr sat on
the skip list nagging "manual intervention needed" every single cycle
for weeks after being removed. Now checks docker inspect for each
entry every cycle and drops it automatically if the container no
longer exists at all.
Measured live: the apikey --name lookup takes 2.2-2.7s baseline with
the system idle, against a 5s timeout that left almost no margin.
Load spikes (confirmed correlated with resource_watchdog pressure
events in the same log window) pushed it over 5s, and the script
couldn't distinguish "timed out" from "genuinely missing" — it fell
through to the create path and logged a false renewal every time,
even though the on-disk key file's timestamp never actually changed.
Bumped to 15s for real headroom.
FAILURE_COUNTS persisted across runs but was never reset on success —
confirmed live 2026-07-19 that Sekirei S06E04 sat permanently flagged
chronic at count 4 despite already having hasFile=true. "Consecutive
failures" was really being measured as cumulative-for-all-time. Now
prunes any media_id's count at the end of each arr's pass if it no
longer appears in that run's problem-item set, so a resolved item's
history doesn't linger and falsely trip the circuit breaker later.
docker_update.sh rebuilds (stop+recreate) any container whose image
changed, in every mode — but for daily/weekly that was always followed
by the restart script's own unconditional pass, stopping and starting
the same container twice back to back. docker_update.sh now records
which containers it rebuilt this run to a file; docker_daily_restart.sh
and docker_weekly_restart.sh read it and skip those specifically,
still restarting everything else as before. A file older than
DOCKER_UPDATE_REBUILT_STALE_HOURS (default 12) is discarded rather
than trusted, so a missed or failed update run can't suppress a
restart indefinitely.
The candidate objects from /manualimport only nest ids under .series.id
and .movie.id, but Sonarr/Radarr's ManualImport command requires them
flattened to top-level seriesId/episodeIds or movieId — every call was
failing instantly with "Series/Movie with ID 0 does not exist" while the
caller only checked for HTTP 201 (accepted), so every "smart-imported"
this script has ever logged was actually a silent no-op. Now flattens
the ids before submitting and briefly polls the command afterward so a
fast failure falls through to the normal blocklist+research path instead
of being reported as success.
Template was missing theme.mkv/theme.mp4 and all trailer patterns,
including trailer-*.* for Trailarr's hyphenated TV trailer naming
(trailer-2.mkv) — a fresh install would hit the same false-positive
orphan bug just fixed live on prod.
Radarr 6+ supports multiple tracked files per movie (alternate editions/extras),
but the movie list's embedded movieFile.path only ever reflects the primary one.
Relying on it alone flagged legitimately-tracked secondary edition files as
orphans. Now batch-fetches /moviefile?movieId=X across all tracked movies to
catch every file, not just the primary.
Full-library scans are slow to reach any one show by chance — this lets a test
target a specific known-corrupt file/show without waiting on scan order.
Healarr does the same job but crashes on a Go concurrency bug (unsynchronized
map access) whenever multiple corruption events land close together — confirmed
via its own crash log, not fixable from our side. Processing one file at a time
here sidesteps the whole bug class instead of trying to work around it.
Delete + explicit EpisodeSearch rather than relying on Sonarr's own background
missing-search cycle, since that skips unmonitored episodes and this shouldn't.
Mirrors the Sonarr fix — same MoveMovieService one-at-a-time drain architecture,
never confirmed live on the Radarr side but the DB-instant/move-async split
is identical, so the same batch-verification race applies.
Confirmed live: DB fields flip instantly on the moveFiles=true PUT but the
physical move drains async, one command at a time — a batch could report
every series moved while most were still sitting at the old path.
arr_cache_prefill moved from critical (30min) to intermediate (4h) —
nothing in the critical tier reads this cache, and every actual
consumer already tolerates staleness well past 4h. Classification
scans run in daily, after the cleanup scripts (not before) — cleanup
frees disk space from orphans/junk first, so the searches --move
triggers for undownloaded relocated entries have headroom for the
resulting grabs.
Acts on forward misplacements and reverse-kids-leak (adult content in
the kids root — not a judgment call, same reasoning as the Radarr
side), moving to the new SONARR_GENERAL_ROOT config var. Reverse-anime
-leak stays report-only — deliberate style placements like Castlevania
are common and valid there. episodeFileCount is Sonarr's hasFile
equivalent: series with 0 files get relocated + an immediate
SeriesSearch instead of a file move. One series at a time, verified
after each — matches the race condition found doing this by hand for
the Fate/Zero and Fate/Stay Night moves earlier in the session.
Unlike reverse-anime-leak (deliberate style placements like Castlevania
are common and valid there), reverse-kids-leak's signal is specifically
"adult certification with zero Family/Animation genre" — confirmed live
the 4 titles it catches (Addams Family, Saving Mr. Banks, Dark Shadows,
The DUFF) are all genuinely non-kids content. Moves them to the new
RADARR_GENERAL_ROOT config var (container-path literal for the general
Movies root, needed since RADARR_MOVIES_ROOT is a filesystem path).