# Maven — Roadmap Spec (post-jul6, 2026-07-06) > The north star (`SPEC.md`) is settled; this is the execution plan for > everything between "works" and "the thing the spec describes." Each item > names the exact files, interfaces, and done-when criteria so an agent > (or human) can execute without guessing. Priority is ops → security → > dealbreakers → depth → deferred. Estimates are wall-clock for one > focused session, not calendar time. ## Conventions - **Done when** = a checkable finish criterion. If it can't be checked, it isn't done. - **Files** = the exact paths to touch. No "somewhere in internal/." - **Interface** = the seam a new piece plugs into. If the seam doesn't exist yet, the item says so and names where it's added. - **Deps** = what must land first. Items with no deps can start now. - Every code item ends with `make test` green (gofmt + vet + 303+ tests, `-race`). No exceptions. --- ## P1 — Ops quick wins (do today, ~2h total) These are not code problems — they're operator actions or trivial fixes that unlock already-built features. Highest ROI per minute on the list. ### 1.1 Kuma API key for `service_down` polling **Vikunja:** #16 (prio 2) **Est:** ~5 min **Deps:** none **Status now:** `cmd/mavpoll/main.go:54` defines `-kuma-key` (basic-auth username, empty password). `pollKuma` (line 238) calls `p.get(ctx, p.kumaURL, p.kumaKey)` which sets basic-auth. Without the key, `pollKuma` gets 401 and logs "no monitor_status metrics" — the whole `service_down` sev4 rule (`internal/loop/rules.go` ServiceDownRule) is dark. Netdata polling works independently. **The gap:** the key string doesn't exist. Kuma's `/metrics` endpoint requires an API key (Settings → API Keys). **Steps:** 1. Uptime Kuma UI → Settings → API Keys → create key (any label, e.g. "mavpoll"). 2. Add `-kuma http://:3001/metrics -kuma-key ` to the `mavpoll` service command in `docker-compose.yml:78`. 3. `docker compose up -d mavpoll && docker logs mavpoll` — confirm a `service_down=up (poll:uptimekuma)` line, not "no monitor_status metrics." 4. Stop a monitored service, confirm `service_down=down` appears within one poll interval (60s default). **Done when:** `docker logs mavpoll` shows `service_down=up` on a healthy stack, and toggling a monitored service flips it to `down` within 60s. The ServiceDownRule then fires a sev4 nudge through the normal dispatcher. ### 1.2 Voice bind verification + stale comment fix **Vikunja:** #18 (prio 2) **Est:** ~30 min **Deps:** none **Status now:** `deploy/mavend.json:10` already has `"bind": "0.0.0.0:9100"`. The config validator (`config.go:402-404`) rejects `voice.enabled` with empty `bind` — so the config is correct. BUT `docker-compose.yml:66-69` has a stale comment: "currently unset. Until that's configured, voice-over-web is inert." The comment is wrong; the config is right. The task is now: deploy, verify, fix the comment. **The gap:** unverified on the target host. The bind is set; whether mavweb can actually reach `mavend:9100` cross-container is untested. **Steps:** 1. Fix the stale comment in `docker-compose.yml:66-69` — replace with: `# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can` `# reach it cross-container. Verified .` 2. `docker compose up -d && docker compose logs mavend` — confirm `voice listening on 0.0.0.0:9100`. 3. From the mavweb container: `curl -sS telnet://mavend:9100` or open the PWA and do a push-to-talk round trip. Confirm a reply comes back. 4. If voice fails cross-container: check `docker network ls`, confirm both containers are on the same compose network (default bridge for the project). The `sockets` volume is for IPC; voice is TCP. **Done when:** a PWA push-to-talk round trip works in the Docker deploy (STT → router → TTS → reply audio plays), and the stale comment is fixed. Update PROGRESS.md "Ops footnote" (line 284-286) to "verified." ### 1.3 Deploy desk_active presence script on desk PC **Vikunja:** #15 (prio 3) **Est:** ~1 h **Deps:** none (the script is complete; this is a workstation deploy) **Status now:** `scripts/desk-active.sh` is a complete one-shot poster (25 lines). It POSTs to `mavweb /api/signal?key=desk_active` over wg. `cmd/mavweb/main.go:36-40` allowlists `desk_active` → source `infer:hyprland`. `internal/store/presence.go:45` gives desk_active the strongest weight (0.90, τ=8min). The script's header (lines 12-18) documents the exact hypridle + systemd timer wiring. Zero facts have ever been written — the timer isn't installed on the desk PC. **The gap:** the systemd user timer + hypridle listener don't exist on the workstation (linux, arch, hyprland). **Steps (on the desk PC, not homesrv):** 1. Copy `scripts/desk-active.sh` to `~/.local/bin/desk-active.sh`, `chmod +x`. 2. Create `~/.config/systemd/user/maven-desk.service`: ``` [Unit] Description=maven desk-active presence ping [Service] Type=oneshot Environment=MAVEN_URL=https://maven.kvmx.ru:9443 ExecStart=%h/.local/bin/desk-active.sh ``` 3. Create `~/.config/systemd/user/maven-desk.timer`: ``` [Unit] Description=maven desk-active presence (60s) [Timer] OnBootSec=10s OnUnitActiveSec=60s AccuracySec=5s [Install] WantedBy=timers.target ``` 4. Add to `~/.config/hypridle.conf` (the script header lines 14-18 show this exactly): ``` listener { timeout = 120 on-timeout = systemctl --user stop maven-desk.timer on-resume = systemctl --user start maven-desk.timer } ``` 5. `systemctl --user daemon-reload && systemctl --user enable --now maven-desk.timer`. Reload hypridle (or restart the session). 6. On homesrv: confirm `desk_active` facts appear — `mavweb /dash` presence should flip from "away" to "present" within 60s of activity, and back to "away" ~8min after going idle. **Done when:** `/dash` reads "present" while the desk PC is in use, and "away" within ~8min of hypridle triggering (2min idle + 6min decay). Presence is now 2 signals (desk_active + page_heartbeat) instead of 1. --- ## P2 — Security (real code) ### 2.1 Cold-start unlock (passkey → L3 key seam) **Vikunja:** #14 (prio 2) **Est:** ~1 day **Deps:** none (the seam is documented in code comments, just not wired) **Status now:** the at-rest AES key comes from `config.DBEncryptionKey()` (`config.go:433`) which reads `db_key_env` (env var) or `db_key_b64` (config). `cmd/mavend/main.go:76-85` calls this, then `store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)`. The key lives in the container env (`docker-compose.yml:28` `env_file: [./deploy/db_key.env]`). The seam is documented in three places: - `config.go:44-46`: "the passkey op produces the 32 bytes and calls store.OpenEncrypted directly, bypassing config" - `store/crypt.go:40-43`: "this same []byte seam is where the L3 passkey-derived cold-start key plugs in later (auth/tier.go Layer3)" - `auth/tier.go:42-44`: Layer3 = "core cold-start unlock" The passkey infrastructure exists: `internal/webauthn/` does real WebAuthn (ES256, sign-count regression), `cmd/mavweb/webauthn.go` serves enroll + assert, `PasskeySession.Assert` bumps to L3 for 5min (`mavend/main.go:194-196`). But the assertion only flips the *session* tier — it doesn't produce key material. The store is already open by the time the session exists. **The gap:** the daemon opens the store at boot from env, before any passkey assertion is possible. There's no path where a passkey gesture *produces the 32 bytes* that `store.OpenEncrypted` consumes. The key is in the container env, which means anyone with `docker inspect` or root on homesrv can read it — the encryption protects against disk theft, not against container compromise. **Design decision — how the passkey produces the key:** The cleanest path that fits the existing seams: 1. **The key is wrapped, not derived.** A passkey assertion doesn't produce 32 deterministic bytes (WebAuthn signatures are randomized). Instead: at enrollment, generate a random 32-byte AES key, encrypt it with a key derived from the passkey credential, store the wrapped blob on disk. At cold-start, the passkey assertion unwraps it. 2. **KDF:** HKDF-SHA256. Input: the credential public key bytes (stable across assertions) + a salt stored alongside the wrapped blob. Output: 32 bytes. This avoids the "sha256(passphrase)" trap `crypt.go:43` warns about — the input is high-entropy key material, not a passphrase. **New dep:** `golang.org/x/crypto/hkdf` (not currently in go.mod — add it). Alternatively, implement HKDF-SHA256 from stdlib (`crypto/hmac` + `crypto/sha256`) in ~30 lines to avoid the dep; the spec doesn't care which, just don't use bare sha256. 3. **Flow:** - **Enroll** (`/auth/webauthn/register/finish`): after `FinishRegistration` succeeds, generate random 32-byte AES key, derive wrap-key via HKDF(credPublicKey, salt), AES-GCM-wrap the AES key, write `{salt, wrapped}` to a file (e.g. `~/.config/maven/db_key.wrapped`). The raw AES key is returned to mavend in-process (not over the wire) and used to open the store. - **Cold-start** (daemon boot): mavend starts *locked* — the store isn't open, the IPC server serves only `MethodAssertStepUp` (or a new `MethodUnlock`). mavweb's passkey assert calls the unlock RPC, which derives the wrap-key from the credential, unwraps the blob, and calls `store.OpenEncrypted`. The daemon then wires the rest (loop, voice, delivery) and flips to "unlocked" mode. - **Fallback:** `db_key_env` still works (dev/CI, or recovery if the wrapped key is lost). The daemon tries wrapped-key unlock first; if no wrapped file exists, falls back to env. This preserves the dev path (no passkey enrolled = env key = plaintext-in-RAM as today). **Files:** - `internal/webauthn/` — add `WrapKey(credPublicKey []byte) ([]byte, error)` and `UnwrapKey(credPublicKey, blob []byte) ([]byte, error)`. HKDF-SHA256, salt in the blob. - `cmd/mavweb/webauthn.go` — in `RegisterFinish`, after `h.store.Save(id, publicKey)`, call `webauthn.WrapKey(publicKey)`, write the wrapped blob to a path (new flag `-wrapped-key-file`, default `./db_key.wrapped`). - `cmd/mavend/main.go` — restructure boot: if wrapped-key file exists, start in locked mode (IPC serves only unlock); else fall back to env key (current path). Add `MethodUnlock` to the IPC API (`internal/ipc/`) that takes the unwrapped key and opens the store. - `internal/ipc/` — new RPC `Unlock(key []byte) error` on the CoreAPI interface (or a separate `UnlockAPI`). mavweb calls it after a successful assert. - `cmd/mavweb/main.go` — after `AssertFinish` succeeds, if a wrapped-key file exists, read it, call `core.Unlock(unwrappedKey)`. - `internal/store/crypt.go` — no change (the seam already takes `[]byte`); the unlock path just calls `OpenEncrypted` with the unwrapped key instead of `config.DBEncryptionKey()`. **Locked-mode behavior:** the daemon starts the IPC server and a minimal "waiting for unlock" state. The voice server, loop, and delivery don't start until unlock succeeds. mavweb serves `/auth/passkey` (so the user can assert) but `/dash`, `/tools`, `/api/ptt` return 503 with a "daemon locked" message. This is the one user-visible behavior change — a cold homesrv now needs a passkey gesture before maven is live. **Done when:** 1. A fresh deploy with no `db_key.env` but an enrolled passkey: daemon starts locked, mavweb `/auth/passkey` assert unlocks it, `/dash` comes alive, the store opens with the unwrapped key. 2. A deploy with `db_key.env` set (dev/CI): daemon starts unlocked (fallback path), no passkey needed. 3. Wrong passkey / deleted wrapped file + no env: daemon stays locked, logs "unlock failed," doesn't crash. 4. `make test` green. New tests: wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC rejects non-unlock methods. **Security note:** this moves the key from "container env (root-readable)" to "wrapped on disk, unwrappable only with a passkey gesture." Root on homesrv can still dump the unwrapped key from mavend's RAM after unlock — this is disk-theft protection, not RAM-capture protection (same threat model as `crypt.go:33` documents). The win is: a stolen disk or a `docker inspect` no longer yields the key. --- ## P3 — "Voice assistant" dealbreakers (big effort, high impact) These three gaps define why maven is "a dictaphone with a brain" instead of "something you talk to across the kitchen." Each is a multi-day architecture item, not a config tweak. ### 3.1 Always-on listening (wake word + ambient capture) **Est:** 1-2 weeks + hardware **Deps:** a capture device (USB mic or a dedicated ESP32-S3 box) **Status now:** voice is push-to-talk in the PWA (`cmd/mavweb/` serves the record button). The voice server (`internal/voice/server.go`) is a TCP listener that waits for `PushToTalk` frames — the client decides when to send audio. There's no wake-word detection, no ambient capture, no always-on mic. `internal/router/stage0.go:24-28` has a wakeword *grammar* (a "maven," prefix fast-path) but it's for the text-after-STT, not for audio-level detection. `auth/tier.go:55-58` documents `SurfaceVoice` as "a room mic / wake-word path" — the surface exists in the auth model, the hardware path doesn't. **The gap:** no always-on audio capture, no wake-word model. Three sub-problems: 1. **Wake-word detection** — a small model that runs continuously on a mic stream and fires when it hears "maven" (or a chosen phrase). 2. **Ambient capture** — after the wake word, capture N seconds of audio and send it as a `PushToTalk` frame (the existing path). 3. **Hardware** — a mic that's always on. Options: (a) a USB mic on homesrv, (b) an ESP32-S3 with I2S mic that streams over wg, (c) a dedicated Pi. (a) is simplest; (b) is the "room device" the spec wants. **Design:** - **Wake-word engine:** openWakeWord (Python, ONNX, ~10MB models) or Porcupine (Picovoice, free tier, binary). openWakeWord fits the self-hosted/no-phone-home invariant better. Run it as a new module `cmd/mavwaked/` (mirrors `mavsttd`/`mavttsd` shape): reads audio from a device, runs the wake model, on detection sends a trigger to mavend. - **New module `cmd/mavwaked/`:** - Flag `-device` (alsa device, e.g. `hw:1,0`), `-model` (ONNX wake model path), `-core` (mavend IPC socket) or `-voice` (voice TCP addr). - Reads 16kHz mono PCM from the device (PortAudio or `malgo`/miniaudio — CGo, like mavsttd). - Runs the wake model on a sliding window; on detection, captures ~5s of audio (configurable) and sends it as a `PushToTalk` frame to the voice server. - The voice server's existing `HandlePushToTalk` does the rest (STT → router → TTS → reply). The reply goes... where? This is the open question — see below. - **The reply problem:** push-to-talk replies go back to the PWA that sent the request. An ambient wake-word path has no PWA. Options: (a) play the reply through a speaker on the capture device (the ESP32/USB-mic box needs a speaker), (b) send the reply to the PWA if a session is live (fallback to ntfy if not). (a) is the "room device" path; (b) is the "homesrv with a speaker" path. Pick based on hardware. - **Auth surface:** `SurfaceVoice` (L0) is already in the auth model. The wake-word path uses it — the voice server's `serveConn` (`server.go:128-134`) has a TODO for the auth handshake populating the surface; today it defaults to `SurfacePCClient`. The mavwaked module would set `Surface=voice` in its `PushToTalkReq`, capping it at L0 (no destructive acts, no registration — exactly the spec's invariant). **Files:** - `cmd/mavwaked/` — new module (main.go + audio capture + wake model). - `internal/voice/wire.go` — confirm `PushToTalkReq.Surface` is settable to `voice` (it is — `server.go:184-186` reads `req.Surface`). - `internal/voice/server.go:128-134` — replace the floor `SurfacePCClient` default with surface-from-handshake (or from the req field, which already wins). - `docker-compose.yml` — add `mavwaked` service with `/dev/snd` device mapping. - `deploy/mavend.json` — no change (voice server already binds 0.0.0.0:9100). **Done when:** saying "maven, ..." across the room (no button press) triggers a capture → STT → router → TTS → reply, with the reply audible on the capture device's speaker (or the PWA if one's live). The auth surface is `voice` (L0) — destructive acts are refused. `make test` green (the new module needs unit tests for the wake-detection logic, mocked audio input). **Open question for the operator:** pick the hardware before starting. A USB mic on homesrv is the fast path; an ESP32-S3 room device is the "real" version. The code is the same either way (mavwaked reads a device); the hardware changes the deploy. ### 3.2 Conversation depth (multi-turn dialogue) **Est:** 3-5 days **Deps:** none (the dialogue scaffold is wired; this deepens it) **Status now:** `internal/dialogue/` has `Session` + `SessionStore` + `InheritSlots` (pure). `cmd/mavend/voice.go:339-349` wires it: a 2-min session carries slots across same-intent turns (`followUpMerge` in `cmd/mavend/followup.go`). So «напомни завтра» → «…позвонить маме» works — the second turn inherits the time slot. But: - Only **same-intent** turns carry (a different intent is a fresh session — `followup.go:41`). - No **anaphora resolution** — "она" / "он" / "это" don't refer back to prior entities. - No **LLM-driven dialogue** — the sub-1B phraser (`llmphraser.go`) only words replies; it doesn't decide what to ask next. - The session is **single-slot** (one `voiceDialogueID` — single-user box, `voice.go:264-266`). **The gap:** real multi-turn needs (a) anaphora resolution, (b) the router or a dialogue manager deciding "I need to ask for X" vs "I have enough to act," (c) cross-intent context. The current `followUpMerge` is bounded gap-filling, not dialogue. **Design:** This is the item where the sub-1B phraser isn't enough. Two paths: 1. **Rule-based deepening (fast, limited):** extend `followUpMerge` to handle cross-intent slot inheritance for common patterns (e.g. `IntentQuery` after `IntentFact` — "я пил воду?" after "запиши что я пил воду"). Add anaphora resolution for pronouns that reference the prior turn's key entity. This is more `followup.go` logic, no LLM. Covers maybe 60% of real follow-ups. 2. **LLM dialogue manager (slow, general):** add a dialogue turn where the phraser gets the conversation history and decides: act, ask-for- clarification, or ask-for-missing-slot. This needs a bigger model than the 1.2B phraser (or a dedicated dialogue prompt) and a conversation-history buffer in the `Session`. The `Session` struct (`internal/dialogue/`) would grow a `History []Turn` field. **Recommended path:** start with (1) — it's testable, deterministic, and covers the common cases. (2) is a "when the phraser model is upgraded" item. **Files (path 1):** - `cmd/mavend/followup.go` — extend `followUpMerge` to handle cross-intent patterns. Add anaphora resolution (a pronoun → prior `Slots.Key` mapping). - `internal/dialogue/session.go` — add `History []Turn` to `Session` (even if path 1 doesn't use it yet, the field should exist for path 2). - `cmd/mavend/followup_test.go` — new cases: cross-intent inheritance, anaphora resolution. - `internal/router/slots.go` — pronoun detection in the slot extractor (она/он/это/тот/та → reference marker). **Done when:** a two-turn exchange like «запиши что я пил воду» → «когда я это сделал?» answers from the fact just recorded (cross-intent, anaphora "это" → "пил воду"). A three-turn exchange that should *not* carry context («запиши что я пил воду» → «какая погода в москве?» → «когда я пил воду?») correctly treats the middle turn as a break. `make test` green with the new cases. ### 3.3 Latency / streaming **Est:** 1-2 weeks **Deps:** none (architecture rework) **Status now:** every voice exchange is a full round trip: record full clip → upload → whisper (batch) → route → phrase (batch) → piper (batch) → play. No streaming either direction. No barge-in (you can't interrupt maven mid-reply). `internal/voice/server.go` reads one `PushToTalk` frame (one audio blob) and returns one `PushToTalkResp` (one reply blob). The wire protocol (`internal/voice/wire.go`) is request/response, not streaming. **The gap:** three sub-problems: 1. **Streaming STT** — whisper.cpp supports streaming (partial transcription as audio arrives). `cmd/mavsttd` would need a streaming mode (send partial results, not one final blob). 2. **Streaming TTS** — piper can synthesize in chunks. `cmd/mavttsd` would stream audio back as it's generated, not one blob. 3. **Barge-in** — the client needs to signal "stop talking, I'm talking now" mid-reply. The wire protocol needs a new method (e.g. `MethodBargeIn`) or a cancel on the stream. **Design:** This is the biggest architecture item. The wire protocol changes from request/response to bidirectional streaming. Two options: 1. **WebSocket voice** — replace the TCP length-prefixed protocol with WebSocket frames. `coder/websocket` is already a dep (mavweb uses it for ntfy). The voice server gets a `ws.Serve` path; the PWA gets a `WebSocket` client. Streaming STT/TTS ride the same ws. Barge-in is a control frame. 2. **Keep TCP, add streaming frames** — extend the length-prefixed protocol with `MethodStreamAudio` (client → server, chunked) and `MethodStreamReply` (server → client, chunked). More work, same result. **Recommended:** (1) WebSocket — it's the standard, the dep is present, and the PWA already speaks ws (for ntfy). The TCP path stays for non-browser clients (the protocol doc `PROTOCOL.md` would note both). **Files:** - `internal/voice/wire.go` — new streaming methods + frame types. - `internal/voice/server.go` — WebSocket accept path, streaming handler. - `internal/voice/client.go` — WebSocket client. - `cmd/mavsttd/` — streaming transcribe mode (partial results). - `cmd/mavttsd/` — streaming synthesize mode (chunked audio). - `cmd/mavweb/main.go` — PWA ws client for voice (replaces the current fetch-based `/api/ptt`). - `PROTOCOL.md` — regenerate from the new `wire.go`. **Done when:** a push-to-talk exchange shows partial transcription within ~500ms of starting to speak (not after the full clip uploads), and the reply starts playing before the full TTS is generated. Barge-in (mid-reply speak) stops the TTS and starts a new turn. `make test` green. The old TCP path still works for non-browser clients (backward compat). **Note:** this is the item most likely to be deferred — it's a quality-of-experience improvement, not a capability gap. The dealbreaker is always-on listening (3.1); streaming makes it feel better but doesn't change what maven *is*. --- ## P4 — Capability depth (built but thin) ### 4.1 Routing quality (dev embedder) **Est:** 2-4 h **Deps:** none **Status now:** `deploy/mavend.json:14-18` configures the ONNX embedder (production). `cmd/mavend/voice.go:150-165` loads it when configured, falls back to `HashEmbedder` (1024-dim, rune-based token overlap) when not. The dev/preview path (AGENTS.md preview instructions) runs without the embedder → weak RU recall → many commands fall to "clarify." The `queryMinScore` gate (`voice.go:382`) is 0.55, tuned for ONNX; the Hash floor rarely clears it. **The gap:** no dev embedder model is documented or shipped. A developer running the preview has to either (a) download the ONNX model manually, or (b) accept weak routing. **Steps:** 1. Document the ONNX embedder model download in `AGENTS.md` (or a new `MODELS.md`): which model (multilingual sentence embedder), where to put it (`models/embedder/model.onnx` + `tokenizer.json`), where to get `libonnxruntime.so`. 2. Add a `make download-embedder` target that fetches the model (curl from a pinned URL — HuggingFace, sha256-checked). 3. Optionally: lower `queryMinScore` for the Hash floor (a config knob, not a code change — `voice.router_threshold` exists, but `queryMinScore` is a const at `voice.go:382`). Make it configurable: add `voice.query_min_score` to `VoiceConfig`, default 0.55. **Done when:** a developer running the AGENTS.md preview with the downloaded embedder gets confident RU routing (most commands route correctly, not to "clarify"). `make test` green. ### 4.2 Act surface broadening **Est:** ongoing config **Deps:** none **Status now:** `deploy/mavend.json:20-33` seeds 11 tools (6 read-only, 5 destructive). `internal/tool/tool.go` runs them (argv, no shell). `voice.go:174-176` seeds them at boot. The allowlist is config-driven — broadening is editing `mavend.json`, not code. **The gap:** the seeded set is homelab-focused. Broadening to home-automation (lights, thermostat), media (play music), or communication (send message) is config + new tool entries. **This is not a code item** — it's operator config. The only code change that might help: a `mavweb /tools` UI for adding tools without editing JSON (the page exists, but it enables *proposed* tools; adding a new one from scratch is JSON-only). Low priority. **Done when:** (operator-defined) — e.g. "lights on/off" works by voice after adding the tool to `mavend.json` and the act seed file. ### 4.3 LTM ANN (approximate nearest neighbor) **Est:** ~1 day **Deps:** none (the interface is the swap point) **Status now:** `internal/memory/store.go` defines `Store` interface (`Insert`, `Search`). `internal/store/memory.go` implements it with brute-force cosine (full scan, `Search` loads every row). The comment at `memory.go:24-28` says "an ANN index is the swap for later, behind this same interface." At single-user scale (thousands of rows) a full scan is sub-millisecond. **The gap:** none *yet*. This is a "when it bites" item. The swap point is the `memory.Store` interface — a new implementation (e.g. `internal/memory/ann.go` using hnswlib or a sqlite-vec extension) drops in without touching `voice.go` or `recall.go`. **When to do this:** when note+fact count exceeds ~10k and `Search` latency shows up in profiles. Not now. **Done when:** (future) a new `memory.Store` impl with ANN search passes the existing `memory_test.go` suite and shows <1ms latency at 10k+ vectors. Not started until the scale problem is real. ### 4.4 Persona prompt **Est:** 2-4 h **Deps:** none **Status now:** the phraser has hardcoded system prompts: - `llmphraser.go:188` — notes query: "You are maven, a self-hosted personal assistant answering from your notes..." - `llmphraser.go:298-299` — nudge: "You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages..." - `router.KnowledgePrompt()` — general knowledge (the deduped single source). The persona ("feminine-gendered Russian self-reference, she/her") is baked into these strings, not configurable. `internal/voice/replier.go` documents a "personality-prompted nudge tone" vs "chat tone" but the prompts are inline. **The gap:** no configurable persona. Changing maven's character means editing Go strings and recompiling. **Design:** - Add `voice.persona` to `VoiceConfig` (`config.go`) — a string (or path to a file) holding the persona prompt prefix. - `llmphraser.go` reads it (passed via `Config` or a new field) and prepends to every system prompt. Default = the current hardcoded string (backward compat). - The three prompt sites (notes, nudge, knowledge) all call a `personaPrompt(cfg, base)` helper that concatenates. **Files:** - `internal/config/config.go` — add `Persona string` to `VoiceConfig`. - `internal/phraser/llmphraser.go` — accept persona in `Config`, prepend to system prompts. - `cmd/mavend/voice.go` — pass `cfg.Voice.Persona` into the phraser config. - `deploy/mavend.json` — document the field (empty = current behavior). **Done when:** setting `voice.persona` in `mavend.json` changes maven's reply character (e.g. more formal, different gender, different name) without recompiling. Empty = current behavior. `make test` green. ### 4.5 Custom TTS voice **Est:** ~1 day + training time **Deps:** none (piper supports custom voices) **Status now:** `cmd/mavttsd/main.go:7` documents the default voice: `models/tts/ru_RU-irina-medium.onnx`. `docker-compose.yml:57` mounts it. The `-model` flag takes any piper voice file. `VoiceConfig.Tts.Voice` (`config.go:281`) allows naming a voice when the worker supports multiple. **The gap:** the voice is the stock irina model. A kami-picked voice (specific person, specific tone) needs a piper fine-tune: record ~50-100 clips of the target voice, train a piper model, drop the `.onnx` file into `models/tts/`. **Steps:** 1. Record or source ~50-100 clean clips of the target voice (16kHz mono, ~5-10s each, varied sentences). 2. Train a piper voice (`piper train` — see piper docs for the dataset format + training script). 3. Output: `ru_RU--medium.onnx` → `models/tts/`. 4. Update `deploy/mavend.json:13` `tts.voice` or the `mavttsd -model` flag in `docker-compose.yml:57`. **This is mostly operator work** (recording + training), not maven code. The code already supports it — it's a model-file swap. **Done when:** maven's replies use the custom voice. `make test` green (tests use the Stub TTS, unaffected). --- ## P5 — Deferred by design ### 5.1 Multi-user (SPEC item 8) **Status:** `SPEC.md:264-270` fences this explicitly: "DO NOT TOUCH THIS PHASE." No second user exists. The append-only schema (`schema.sql`) makes it a migration (add `user_id` columns + backfill to "kami"), not a rewrite. Speaker attribution needs the second voice to train against. **When to revisit:** when a second person is actually in the house and using maven. Not before. **Do not start this** without an explicit operator decision. An autonomous agent that adds `user_id` columns while touching the store commits the project to a schema before the constraints that shape it exist. --- ## Summary table | # | Item | Prio | Est | Type | Deps | |---|------|------|-----|------|------| | 1.1 | Kuma API key | P1 | 5m | ops | — | | 1.2 | Voice bind verify + comment fix | P1 | 30m | ops | — | | 1.3 | desk_active deploy | P1 | 1h | ops | — | | 2.1 | Cold-start unlock | P2 | 1d | code | — | | 3.1 | Always-on listening | P3 | 1-2w | code+hw | hardware decision | | 3.2 | Conversation depth | P3 | 3-5d | code | — | | 3.3 | Latency/streaming | P3 | 1-2w | code | — | | 4.1 | Routing quality (dev embedder) | P4 | 2-4h | code+docs | — | | 4.2 | Act surface | P4 | ongoing | config | — | | 4.3 | LTM ANN | P4 | 1d | code | scale problem | | 4.4 | Persona prompt | P4 | 2-4h | code | — | | 4.5 | Custom TTS voice | P4 | 1d+train | ops | — | | 5.1 | Multi-user | P5 | deferred | — | second user | **Recommended order:** 1.1 → 1.2 → 1.3 (today, ~2h) → 2.1 (security, ~1d) → 4.1 + 4.4 (quick depth, same day) → 3.2 (conversation, ~3-5d) → 3.1 (always-on, needs hardware pick first) → 3.3 (streaming, lowest ROI of the dealbreakers) → 4.5 (custom voice, when recording is done).