50130cdffb
490 lines still loads into every session, and most of them explained a
subsystem rather than constraining an agent. The owner's cap is 200. This
lands at exactly 200.
Four new living docs take what left:
docs/deployment.md the two boxes, the resident model, the embedder, STT,
the daemon table, who is in compose, the voice wire,
mavwaked on workpc, the web UI conventions
docs/world.md what replaced "never phones home", why Response.Empty()
is the whole gate, the timeouts, Kiwix
docs/language.md the LLM output contract and the three Russian mechanisms
docs/workflow.md the five stores, the doc tiers, Vikunja, the guards
CLAUDE.md keeps the pointer table and the rules. Every "do not do X", every
path and every owner's call stayed. What went is the before-and-after
narrative behind each one, which is what a living doc is for.
Verified rather than trusted. Every backticked literal in the old file was
diffed against the union of the new ones. Twenty-four came up missing and
three groups were facts rather than narrative, so they were restored:
- the ecosystem client table (nexusClient, praxisClient, the vendored hexis
client, the three config keys and their default URLs) into
docs/ecosystem.md, which did not carry it
- TestOnlyAGrammarMayDropTheBoundary and TestNamingRecallKeepsTheBoundary
into docs/routing.md, since they pin the boundary rule in both directions
- the ipc.Dial vs voice.Dial trap and docs/plans/17 into docs/deployment.md
diff-budget.sh blocked on the changed-line count again. It counts markdown,
which the repo's own pre-commit hook exempts, and this commit touches
nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
193 lines
9.5 KiB
Markdown
193 lines
9.5 KiB
Markdown
# Deployment: the boxes, the models, the daemons
|
|
|
|
*Last verified: 2026-08-09 @ a9b480a*
|
|
|
|
What runs where, and why each choice was made. `CLAUDE.md` carries only the
|
|
rules. This file carries the reasoning.
|
|
|
|
## The two boxes
|
|
|
|
**homesrv** is a Ryzen 5 5600U laptop and the deploy target. It offloads to the
|
|
Vega iGPU over Vulkan (`n_gpu_layers: 99`). Compose passes `/dev/dri` and the
|
|
render gid (993), and without both Vulkan enumerates zero devices and
|
|
llama-server falls back to CPU silently.
|
|
|
|
**workpc** is the workstation, 16GB of VRAM, reached as `kami@workpc` at
|
|
192.168.1.105. Model work moved there on 2026-08-02 by the owner's call, because
|
|
homesrv cannot grow a GPU.
|
|
|
|
Three rules govern the seam:
|
|
|
|
- **The workstation is never assumed up.**
|
|
- **Fall back silently** when it would only do the job better.
|
|
- **Name the gap** when the resident model cannot do the job at all.
|
|
|
|
A world question goes through `LLMPhraser.PhraseWorld` and returns `worldGap`
|
|
(`cmd/mavend/worldmodel.go`) rather than an invented answer. A box with no
|
|
`workstation` block behaves exactly as it did before the seam. `docs/offload.md`
|
|
says which caller is which.
|
|
|
|
## The resident model
|
|
|
|
**Qwen3-1.7B** (`UD-Q4_K_XL`), stock, not yet the CPT'd one. It is a Thinking
|
|
variant, so `n_ctx` is 4096. Reasoning tokens need the room, and 4096 is what
|
|
every score was measured at.
|
|
|
|
The target is the locally CPT'd Qwen3-1.7B (V-122, training in flight). Stock
|
|
already speaks good Russian. What it gets wrong is the persona. It writes `я рад`
|
|
where Maven needs `рада`.
|
|
|
|
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on
|
|
2026-07-31 and both are unusable in Russian
|
|
(`docs/evals/2026-07-31-model-bakeoff.md`). Their published IFEval and BFCL
|
|
numbers are English-only.
|
|
|
|
Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm`.
|
|
That **shadows** the repo's `models/llm/`, so a gguf sitting there is not loaded
|
|
by anything. Swapping the resident model is a one-line change to
|
|
`phraser.model_path` in `deploy/mavend.json`.
|
|
|
|
The workstation model is gemma-4-E4B as of 2026-08-09, replacing the 12B by the
|
|
owner's call. Keep the 12B gguf. It is the better teacher for label runs, at
|
|
72.7% destination against E4B's 57.6%.
|
|
|
|
## The embedder
|
|
|
|
**It stays on homesrv permanently**, because it backs the floor. It is
|
|
multilingual-e5-small, quantized and asymmetric. `EmbedQuery` and `EmbedPassage`
|
|
apply the `query:` and `passage:` prefixes it was trained with. Calling plain
|
|
`Embed` on a note is a bug. See `docs/evals/2026-08-04-recall-e5-small.md`.
|
|
|
|
The vendored onnxruntime under `deps/` has two copies, and the stale one is
|
|
1.17.1. The live runtime is 1.26.0, and the Go binding asks for API 26. Anything
|
|
shipped to another box needs `deps/onnxruntime-linux-x64-1.26.0`.
|
|
|
|
## Speech-to-text
|
|
|
|
`sttSeam` in `cmd/mavend/voicewire.go` builds an `stt.Pair` beside `modelSeam`.
|
|
It prefers CrisperWhisper 2.0 turbo on workpc with mavsttd as the floor. It takes
|
|
only the silent half of the rule, because a worse transcript is still a turn. So
|
|
`stt.Pair` has no `TranscribeRemote` and the fallback is never spoken.
|
|
|
|
CW2 turbo scores 10.4% WER in Russian against 27.5% for the `ggml-small.bin`
|
|
mavsttd loads, over 200 Golos clips
|
|
(`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`).
|
|
|
|
**whisper.cpp cannot load CW2 at all.** It reads its language count off the
|
|
vocabulary size. CW2's 51897 tokens shift seven special token ids. So CW2 is its
|
|
own transformers service on port 8081 (`deploy/cw2/serve.py`).
|
|
`stt.HTTPTranscriber` posts raw PCM to it with a bearer token, because audio is
|
|
the most sensitive thing that crosses this seam. The switch is `workstation.stt`
|
|
in `deploy/mavend.json`, and deleting the block sends every utterance to mavsttd.
|
|
|
|
**mavgpud runs that service as a second child.** This is not an optimisation.
|
|
CW2 is a ROCm process on the same card, so it registers on the KFD like any
|
|
contender. Under its own systemd unit it made mavgpud evict llama-server every
|
|
few seconds. That took the model arm down for eight minutes on 2026-08-09. The
|
|
card needs one owner. CW2 is on the yield clock and not the idle one. At 1.6GB
|
|
it denies the card to nobody.
|
|
|
|
Text-to-speech has not moved. piper on homesrv is the only synthesizer.
|
|
|
|
## The daemons
|
|
|
|
| Binary | Role |
|
|
|---|---|
|
|
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB and IPC socket. |
|
|
| `mavweb` | HTTP UI and PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`), WebAuthn auth. |
|
|
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
|
| `mavttsd` | Text-to-speech (piper subprocess). |
|
|
| `mavwaked` | Wake-word and VAD gate. Runs on workpc. |
|
|
| `mavenclient` | Voice loop client (mic, stt, core, tts). Not deployed. |
|
|
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`. |
|
|
| `mavcaldav` | CalDAV calendar sync. |
|
|
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password, core never sees it. |
|
|
| `mavgpud` | GPU supervisor. **Runs on workpc**, own unit `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything and reads `/health` through `llm.Pair`. |
|
|
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
|
|
|
|
Two binaries have no Makefile target and neither is deployed. `mavseal` encrypts
|
|
a live tmpfs working copy back to the ciphertext file when mavend was killed
|
|
before `defer st.Close()` sealed it. `labelgen` runs the stage 0 grammars over
|
|
utterances and prints JSONL, the training data for the routing heads.
|
|
|
|
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the wire
|
|
protocol. `deploy/mavend.json` sets socket paths, model paths and the phraser and
|
|
embedder blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`.
|
|
|
|
### Who is in compose, and who is not
|
|
|
|
**`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`,
|
|
`mavpoll`. Count against compose, not against the table above.
|
|
|
|
`mavmaild` and `mavcaldav` are commented out, each with the reason beside it. The
|
|
first needs a mail account and the second a CalDAV account, and this box has
|
|
neither. Two things ride on the CalDAV absence (V-644). Agenda questions route to
|
|
`IntentQuery` at stage 0, and the `calendar` query source then reads a table
|
|
nobody writes. And `loop.State.CalendarBusy` is fed by the same facts, so the
|
|
gate's "do not nag mid-meeting" is permanently false.
|
|
|
|
`mavenclient` is still absent. `mavwaked` moved to workpc on 2026-08-09 (V-515).
|
|
|
|
### The voice wire
|
|
|
|
`internal/voice` is plaintext with no auth. Its own server doc says production
|
|
binds inside the wg tunnel, because the wg layer is the L0 floor. workpc is not
|
|
a wg peer, it sits on wlan0. So the tunnel is ssh instead.
|
|
|
|
mavend publishes the voice port to homesrv loopback only, `127.0.0.1:9110`.
|
|
Host 9100 is Vikunja's MCP, hence 9110. The container side stays 9100 so mavweb
|
|
keeps reaching `mavend:9100` by name. `deploy/maven-voice-tunnel.service` on
|
|
workpc forwards it over his key.
|
|
|
|
**Do not replace this with a LAN bind.** `SurfaceVoice` caps acts at L0, so an
|
|
unauthorized speaker could not run a destructive tool. L0 does not cap reading,
|
|
so they would still hear his facts, notes and calendar read back.
|
|
|
|
Both `mavwaked` and `mavenclient` speak `voice.Dial`, not `ipc.Dial`. The
|
|
`netaddr` token guards the daemon-to-daemon IPC seam and never touches this one.
|
|
`ipc.Dial` does take `tcp://host:port?token=...`, which is why V-515 was filed
|
|
as a config change. That premise was wrong, and the ssh leg is the correction.
|
|
|
|
The voice loop belongs on a client machine where the owner is standing, and that
|
|
machine is workpc (V-463, `docs/plans/17-where-the-voice-loop-runs.md`). homesrv
|
|
has a microphone, because it is a laptop, but it is in the wrong room.
|
|
|
|
### mavwaked on workpc
|
|
|
|
`deploy/mavwaked.service`, a user unit beside `mavgpud.service`. Two flags are
|
|
deliberate.
|
|
|
|
`-vad-model` is passed. Silero answers "is this frame speech" where the energy
|
|
floor answers "is this frame loud". It declines white noise at the same RMS, 0
|
|
frames against 68 to 99, and still hears all four spoken fixtures
|
|
(`docs/evals/2026-08-09-silero-vad.md`). It costs 509µs a frame and never touches
|
|
the GPU. A model that will not load is logged and not fatal.
|
|
|
|
`-barge-in` is not passed. The threshold is room-specific and this room has no
|
|
number yet. Read the "suppressed while speaking" means out of the journal first.
|
|
|
|
The device is `plughw:0,0` and not `hw:0,0`. The fifine offers 2 channels at
|
|
44100 or 48000 and nothing else, and mavwaked asks arecord for 16kHz mono. Bare
|
|
`hw` dies on "Channels count non available" before a frame is read.
|
|
|
|
There is no wake word yet (V-487 stage two), so the loop runs open. mavwaked
|
|
connects lazily, so `voicesink` cannot push a nudge to it until it has sent one
|
|
utterance.
|
|
|
|
**Passwords are read from files, never taken as flag values.** `mavcaldav` uses
|
|
`-pass-file` and `-render-pass-file`. `mavpoll` and `mavmaild` follow the same
|
|
rule.
|
|
|
|
## Web UI conventions
|
|
|
|
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and
|
|
the shell partial in `cmd/mavweb/shell.html`. A page opens with
|
|
`{{template "shellTop" "<page-key>"}}` and closes with `{{template "shellBottom"}}`,
|
|
and the key marks the active sidebar link.
|
|
|
|
Every page is its own embedded `.html` file next to `main.go`. No page markup
|
|
lives in Go, and the sidebar is data (`sidebarSections`, `pageIcon`) the template
|
|
renders. No per-page `<style>` beyond true one-offs. Wrap every table in
|
|
`<div class=scroll>` so wide data pans on a phone. Local preview and headless
|
|
screenshot recipes are in `AGENTS.md`.
|