# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS. Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`, compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way. **Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one. It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have: 67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the talk fixture. See `docs/evals/2026-07-31-model-bakeoff.md`. It is a Thinking variant, so `n_ctx` is 4096 — reasoning tokens need the room, and 4096 is what the scores above were measured at. The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight). Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`, masculine, where Maven needs `рада`. That is what the CPT is for. **Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers "столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in `/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's `models/llm/`, so the LFM2.5 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`. See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and `AGENTS.md` for local-preview + model-download recipes. **Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small, quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:` prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced MiniLM and bought ten points of recall@1 and 2.5× the speed; see `docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487 are the work. Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented answer. A box with no `workstation` block behaves exactly as it did before the seam: naming a gap requires a gap. The offload table in `docs/offload.md` says which caller is which. ## Build & test CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`: ```sh make build # all 9 binaries make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO) make test # go test -race across ./internal/... ./cmd/... with CGO env set ``` Run a single test (must carry the CGO env for packages that touch STT/TTS/voice): ```sh CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \ CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \ LD_LIBRARY_PATH="$(pwd)/deps/lib" \ deps/go/go/bin/go test -run TestName ./internal/router/ ``` Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`. ## The daemons (`cmd/`) | Binary | Role | |---|---| | `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. | | `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. | | `mavsttd` | Speech-to-text (whisper.cpp, CGO). | | `mavttsd` | Text-to-speech (piper subprocess). | | `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. | | `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. | | `mavpoll` | Telegram long-poll reach. | | `mavcaldav` | CalDAV calendar sync. | | `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. | Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored `deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks. **Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`). homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word daemon there listens to nobody. They belong on a client machine where he is standing. `ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing needs building to allow it, but no such machine exists yet. **The consequence: the wake word and the VAD gate are covered by unit tests and by nothing else, and no amount of sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers. ## The ecosystem: Nexus, Praxis, Hexis Maven is one of four services. It owns conversation and personal memory. It does not own identity, operational state, or execution. Full contract in `docs/ecosystem.md`. ```text Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates. ``` | Service | Owns | Maven's client | Configured at | |---|---|---|---| | **Nexus** | Canonical entity ids, names, aliases, relationships. Projects, services, devices, people, pets, places. | `nexusClient` in `cmd/mavend/ecosystem.go`, `POST /api/v1/resolve` | `nexus.url` (`http://nexus:9740`) | | **Praxis** | Operational attention and item lifecycle. What needs looking at, what changed, what is still unresolved. | `praxisClient`, the HTTP tools API under `/api/v1/tools/` | `praxis.url` (`http://praxis:8989`) | | **Hexis** | The capability registry and the only path to executing anything. | vendored `github.com/kami/hexis/pkg/client` | `hexis.url` (`http://hexis:9741`) | All three are `nil` unless configured, and every one of them degrades on its own. An outage means a named gap in the answer, never a broken turn and never a guess. Rules that are not negotiable: - **No component reads another component's database.** Praxis attention comes over HTTP, never from its SQLite file. - **Identity lives in Nexus.** Do not invent a local fact key for something Nexus resolves. `actionFact` already sets `Subject`, and `cmd/mavend/factenrichment.go` resolves it in the background against Nexus. - **Free text never reaches a mutating Hexis call.** Resolve to a canonical entity id first. Ambiguous resolution asks the owner, it does not pick. - **LLM output is not authorization.** Confirmation binds capability id, target entity, arguments, requester and expiry. See `cmd/mavend/confirm.go`. - **Praxis lifecycle words mean different things.** Surfaced is not acknowledged, acknowledged is not resolved, execution success is not recovery. Reading an item aloud calls `Surface`, never `Acknowledge`. - **No automatic attention-to-action path.** Digestion may summarise Praxis. It may not call Hexis. Every cross-service call carries a correlation id minted once per action (`withCorrelationID`), a contract version header, and `X-Requested-By: maven`. ## Routing — read this before touching the router `internal/router/` has TWO layered engines. **The LLM router is now the default and it is on in deploy** — this section used to say it was wired `nil`, which stopped being true on 2026-07-31. - **LLM router (the intended design, docs/rearchitecture.md):** the resident Qwen3-1.7B (`llmrouter.go`) emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via `pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router` (`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`. - **Classifier cascade (the failure floor, not dead code):** `classifier.go` + `embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades), and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known cause of weak RU query handling, but a turn must never break on the model. Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier fallback. Any LLM error falls through to the classifier so a turn never breaks on the model. Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8% full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from `docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores 77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy, not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency work off the bakeoff table. **The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`, Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a routing change against the classifier and the resident model, since those are what always answer. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja #359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see `gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway. Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly executing, so the fixture and the daemon disagree about what is correct there. The third, `"поужинал"`, was a real defect: the single-token rule was an English intuition and does not transfer to Russian, where one word is routinely a whole sentence. Narrowed 01-08-2026. `thinSingleToken` (`internal/router/singletoken.go`) still thins a bare one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, intent-only 74.0% → 75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule. Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to `IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% → 72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an explicit `(\s|[?!.]|$)`. Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside `AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar` (`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a latency and fixture defect, not a correctness one. The narrative rule reads the same `narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a joke, a bedtime story, herself — because the query chain has no source that answers those. New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) → 58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no llama-server in that run), so judge it again before quoting a cascade number. Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()` (`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because "отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability alias, and that slot is filled from the deployment's enabled tool names, which no Praxis alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5** (`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative** lifecycle word ("готово", "принято") needs an item named beside it, while a bare **imperative** ("закрывай") may ask which one. The bare arm additionally requires that the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead of the house. A demonstrative ("отметь это как сделанное") resolves against `h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to the cascade rather than transitioning the wrong item. ## LLM output contract All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and `internal/phraser/llmphraser.go`), with fallback to plain text and the legacy `{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract: `[{"intent":, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder, note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training workspace enforces that the Go and relabelling prompts remain identical. ## Russian patterns — three mechanisms, no fourth Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a pattern, and the resident model cannot be asked per turn either). A regex whose output is a fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an argv list — is not. Before writing a Russian word list, pick one of these: - **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives, capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months, spoken hours. Editing a word is a data change, and there is exactly one copy: months used to live in three files. Cardinals carry the oblique forms, because a spoken time declines and `в семь` / `к семи` are one hour. - **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm` and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb slot that means the imperative must be matched exactly — `говори` and `говорил` are one lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`). - **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the turn's own query vector. Same shape as the personal boundary in `personalboundary.go`, with one difference: a topic must clear the runner-up by `topicMargin`, because a false claim here spends a network scan rather than one honest "не знаю". The old keyword tests stay as the offline floor and may remain narrow, since they are no longer the only answer. - **The ecosystem trio** — when the answer is not in the utterance at all. Identity is Nexus's, never a local pattern. Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the `TestONNX*` tests, not eyeballed. ## Non-goals (hard constraints) Not a nag, not autonomous. Maven's persona is **feminine** — Russian self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его" (she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name ("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in `internal/phraser/eval/checks.go`, scored by `make eval-phrasing`. **"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer world questions, so she needs to read external sources. What replaces it: - **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing about Maven is reported to anyone, and inference stays on the box. - **His data first, then the world.** Every source that reads his facts, notes, calendar, tasks or house runs before anything outside, and the personal boundary sits between them. Reading beats recalling for a small model. - **In the world, live search leads and the ZIMs are the fallback** (owner's call, 2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on homesrv answer when the search is empty, unreachable, or the line is down. `Response.Empty()` is the whole gate and there is no quality threshold in front of it: the three signals one could read were measured on 2026-08-05 and none of them separate a real question from an invented one. Token overlap would cost "столица Франции" its answer, because the answer is Париж and that word is not in the question. See `docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed a turn is readable on `/chat`** as a badge beside the reply, carried on `ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It rides the context, so `handleText` keeps the one string signature the mic, telegram and the web share. - **External search is allowed and off unless configured**, like the weather and telegram capabilities. The code default is still off. `deploy/mavend.json` now ships a `search` block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns it off again. - **His notes and facts are never search input.** Looking up why the sky is blue and sending his stored personal notes to an upstream engine are different acts. Only the utterance goes out, never the persona block, history, or matched notes. ## 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" ""}}` 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 `