35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
236 lines
12 KiB
Markdown
236 lines
12 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`. A LAN URL with an empty token fails config validation;
|
|
`workstation.stt.disabled: true` is the STT-only dark state;
|
|
`workstation.disabled: true` disables both remote arms. The model has its own
|
|
`model_disabled` switch because CW2 can remain live without the model proxy.
|
|
|
|
**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`. Its LAN port needs a bearer token in the file named by `token_file`, matching `MAVEN_GPU_TOKEN` on homesrv, or it refuses to start (V-673). |
|
|
| `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:CARD=Gen,DEV=0`, the Scarlett Solo. It is `plughw` and
|
|
not `hw` because mavwaked asks arecord for 16kHz mono. No microphone here
|
|
offers that, so bare `hw` dies on "Channels count non available" before a
|
|
frame is read. It is named `CARD=Gen` and not card 4 because a USB card number
|
|
moves when something else is replugged.
|
|
|
|
It used to be the fifine on card 0, and that cost three days. mavwaked logged
|
|
zero completed utterances across them, before the wake word existed and after.
|
|
The fifine returns RMS 0.00004 with its capture switch on and its ALSA volume
|
|
at the full 496 of 496. That silence is in the hardware and no flag reaches
|
|
it. Over the same eight seconds of speech the onboard ALC897 read 0.142 and
|
|
the camera 0.289, both clipping at peak 1.0. The Scarlett read 0.003 clean.
|
|
|
|
Check the level before blaming the gate. Stop the unit, run `arecord` against
|
|
the device for five seconds, and measure. A live room floor reads near 0.001.
|
|
|
|
The three `-wake-` flags require the keyword "Мэйвен" (V-487 stage two). Drop
|
|
them and the loop runs open, which is what it did before. The threshold is the
|
|
binary's default of 0.999 and is not passed. Over 65 minutes of held-out
|
|
Russian speech it woke her 0.9 times an hour against 2.8 at 0.99. That cost one
|
|
lost render out of 126 (`docs/evals/2026-08-09-wake-word.md`).
|
|
|
|
The three sessions are pinned to one thread each. onnxruntime otherwise sizes
|
|
its pool to every core and spins between runs, which took mavwaked from 68% of
|
|
one core to 335%. With the cap it sits at 81%, so the gate costs about 13%.
|
|
|
|
A keyword shorter than 1.32s can be heard too late to be used. The head scores
|
|
1.28s of audio, and the VAD has closed the utterance by then.
|
|
"Мэйвен, <request>" is unaffected. A bare "Мэйвен" is the case that fails.
|
|
|
|
mavwaked connects at startup and holds the conn, so a nudge routed to voice
|
|
reaches the speaker before he has said anything (V-671). It used to connect
|
|
lazily, which made the failure silent rather than absent: after one utterance
|
|
the session existed, `PushToMostRecent` succeeded, the dispatcher stopped
|
|
rerouting to telegram and ntfy, and mavwaked discarded the audio.
|
|
|
|
**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
|
|
|
|
Every HTTP response carries a server-generated `X-Request-ID`. Error responses
|
|
use `application/problem+json` with three fields: a sanitized `error`, a stable
|
|
low-cardinality `code`, and that `request_id`. `writeProblem` is the only error
|
|
writer: it records the full wrapped error beside the same ID in the mavweb log,
|
|
while implementation paths, transport messages and credentials never reach the
|
|
browser. A page that can usefully degrade instead of failing keeps HTTP 200 and
|
|
renders the same code/request reference beside its sanitized panel error.
|
|
|
|
The `/ecosystem` panel uses its HTTP request ID as `X-Correlation-ID` on its
|
|
direct Nexus, Praxis and Hexis reads. Calls routed through mavend retain
|
|
mavend's action-scoped correlation contract instead.
|
|
|
|
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`.
|