# Maven — Project Spec > Generated 2026-07-03 from a live QA session. This is the north star, not a > roadmap. Priority is: core works → add capabilities → harden. Details change; > the principles and target state are settled. ## Identity **Maven** — self-hosted personal assistant. One daemon on homesrv, multiple client surfaces. All local, never phones home. Primary name is "Maven" with feminine-gendered Russian self-reference ("она", "меня", "помогла"). Clients are free to choose their own UI label. ## Users | Phase | Users | Data model | |-------|-------|------------| | Now (MVP) | just me | Single-user, no namespace | | Soon | me + gf | Per-user namespace (facts/notes/reminders partitioned by speaker attribution) | Per-user means: when the router attributes an utterance to user X, writes go into X's partition. Reads are user-scoped too. Shared state (house chores, shared calendar busyness) is explicitly cross-partition via a `shared` or `household` namespace. The router owns attribution — speaker recognition (for voice) plus surface ownership (for text). ## Voice pipeline Real STT + TTS are already wired and tested. The stub floor exists for CI and for the "no models on disk" bootstrap. | Component | When active | Module | Handler | |-----------|-------------|--------|---------| | STT | `voice.stt.socket` in config | `cmd/mavsttd -model ` | whisper.cpp (CGo, Vulkan) | | TTS | `voice.tts.socket` in config | `cmd/mavttsd -piper -model ` | piper (subprocess, espeak-ng) | | STT stub | socket unset or module without `-model` | in-process `stt.Stub` or `mavsttd` stub | hash + template | | TTS stub | socket unset or module without `-piper` | in-process `tts.Stub` or `mavttsd` stub | 200ms tone | The server has iGPU + Vulkan. whisper.cpp already uses Vulkan; piper uses CPU (lightweight, real-time). Stubs let the daemon exercise end-to-end without any model files. ## Resident language model One Qwen3-1.7B llama-server process serves both the grammar-constrained route contract and the persona/response contract. The target artifact is produced by RU continued pretraining followed by joint persona/router SFT. Stage-0 grammar, the classifier and stub phrasing remain availability fallbacks. A larger on-demand reasoner and custom TTS training are deferred until the main feature set is complete. ## Auth model A cascade, not a pick-one: | Layer | Question | Mechanism | Surface | |-------|----------|-----------|---------| | 0 — network | On the tunnel? | WireGuard | everything | | 1 — device | Enrolled box? | mTLS (long-term, optional) | PC client | | 2 — session | You, now? | Passkey / WebAuthn | authed surface | | 3 — step-up | You, *right now*, for this act? | Passkey user-verification gesture | destructive tool confirm, registration enable, cold-start unlock | Week 1 floor: layer 0 only (WG tunnel), `FloorEnrollment` grants full L3 to any same-uid caller. The passkey layer is the deferred build — the code has the auth scaffolding (`internal/auth/`), just not the WebAuthn dance. **Key invariant:** voice/chat structurally cannot reach layer 3. A room mic is reachable by anyone present → destructive acts always gate behind an authed surface for final confirm. ## Away-channel fallthrough When no voice session is active and a nudge fires: | Severity | Voice available | No voice | |----------|----------------|----------| | sev1-2 (care) | voice | **drop** (silent, non-critical) | | sev3 (ops soft) | voice | ntfy | | sev4 (ops hard) | voice + ntfy | telegram, repeat-til-ack | The current code returns `ErrNoSession` and stops. Target: the dispatcher falls through the routing table to the next channel when voice returns no-session, matching the table above. The `voicesink` and `dispatcher` need this reroute path wired. ## Calendar Integration with **Radicale** (self-hosted CalDAV). Not Nextcloud. Scope: **read + write events**: - Read: detect busy/available (gate nudges), answer "what's on my calendar" - Write: "schedule a meeting at 3pm", "move the dentist appointment" The `calendar_busy` config fact already exists and the loop gate reads it. The feed is a **new `cmd/mavcaldav` module** (separate binary) that polls Radicale and writes `calendar_busy` + events as facts through CoreAPI. ## Deployment | Phase | Mechanism | Notes | |-------|-----------|-------| | Now | scripts (`start-maven.sh`, `kill-maven.sh`) | manual start/stop in tmux | | Soon | systemd user units | one per binary, socket-activated modules | | Future | Docker / Podman | single compose, or dockerfiles per component | Invariant: **core is rarely redeployed, components are**. The IPC boundary (`internal/worker` STT/TTS sockets, `internal/ipc` CoreAPI socket) means `mavsttd`, `mavttsd`, `mavpoll`, `mavweb` can restart independently without touching the daemon. systemd `After=mavend.socket`, `Restart=on-failure` per module. ## Client protocol The voice wire protocol (JSON length-prefixed frames over TCP) is designed for **multiple client implementations**. The reference PWA at `cmd/mavweb` is one client — any app (phone, desktop CLI, smartwatch) can implement the same frame protocol. The protocol needs a **published spec document** so third parties can write clients without reading the Go source. Spec document lives in `PROTOCOL.md` and covers: - Transport: TCP, length-prefixed JSON frames (4-byte big-endian length) - Methods: `PushToTalk`, `Pong` - Push kinds: `AudioNudge` - Surface identity: header field, cap enforcement server-side - Error codes The auth section of the spec documents how passkey assertions are carried over the wire (for the step-up layer). ## Multi-user architecture (gf phase) When the second user arrives: 1. **Speaker attribution** — the router produces a `speaker` label per utterance ("kami", "gf", "unknown"). Voice uses speaker embedding / voice-print match; text/telegram uses explicit surface ownership or a command prefix. 2. **Per-user partitioning** — `facts.user_id`, `notes.user_id`, `reminders.user_id`. Queries scope to the current speaker's partition. 3. **Cross-user reads** — explicit, e.g. "show kami's calendar" or "remind us both". The router decides from the utterance form. 4. **Shared namespace** — household chores, shared calendar, home automation. A `user_id = 0` or `user_id = 'shared'` convention. This is a **post-MVP** concern. Single-user works for now; the schema has no `user_id` columns yet. Adding them later is a migration, not a rewrite, because the append-only design means no existing row needs updating. ## ML & hardware profile | Resource | Available | Used by | |----------|-----------|---------| | CPU | Ryzen, 13GB RAM | loop, router classifier, delivery | | iGPU | Vulkan-capable | whisper.cpp (STT), piper (TTS) | | GPU layers | llama-server with `-ngl -1` | Qwen3-Maven-1.7B resident model | Models are per-component, downloaded separately (gitignored `models/` dir). No model is baked into the binary. ## Open design items Priority: core works → add capabilities → harden. Items 1–6 are capabilities; 7 is hardening; 8 is post-MVP and explicitly out of scope this phase. Each item includes a "done when…" line so an autonomous agent has a checkable finish criterion. --- ### 1. Protocol spec (`PROTOCOL.md`) Document the voice wire format so the protocol is multi-client by design. **Must be generated from `internal/voice/wire.go`**, not composed freehand — the wire.go comments are already complete; the spec must not drift from code. - Transport: TCP, length-prefixed JSON frames (4-byte big-endian length) - Methods: `PushToTalk`, `Pong` - Push kinds: `AudioNudge` - Surface identity: header field, cap enforcement server-side - Error codes Done when: a new client implementor can build a working PushToTalk round-trip from PROTOCOL.md alone, and `diff PROTOCOL.md internal/voice/wire.go` shows no contradictions in constants or method names. --- ### 2. Away-channel fallthrough Wire the dispatcher to fall through when voice returns `ErrNoSession`, matching the table in § Away-channel fallthrough (lines 67–71). The `voicesink` and `dispatcher` need this reroute path wired. Done when: a test (or manual trace) where voice returns `ErrNoSession` and a sev3 nudge lands on ntfy, sev4 on telegram-repeat-til-ack, sev1–2 drops silently. Existing delivery test patterns in `internal/delivery/` show the shape. Note: the `store.Away` routing path is already built and tested (`TestDispatchNudgeOpsHardAwayTelegramRepeatUntilAck` passes). The gap is the **runtime** `ErrNoSession` path — when the dispatcher chose voice (severity table said voice was available) but no session is live at push time. `voicesink.go:62` has a TODO for this. Wire the fallthrough onto the same routing table the Away path uses; converge, don't duplicate. --- ### 3. CalDAV poller (`cmd/mavcaldav`) New separate binary that polls Radicale (CalDAV) on a configurable interval and writes facts through CoreAPI: - `calendar_busy` — boolean, read by the loop gate - `calendar_event` — per-event facts for "what's on my calendar" queries Writes only on value change (same append-only discipline as `mavpoll`). Done when: `cmd/mavcaldav -socket -url -user -pass

` runs, polls Radicale, and a `calendar_busy` fact with the right value appears in the store. A `DueReminders`-style test proves the loop gate reads it. --- ### 4. Quiet-hours schedule The loop reads a `quiet_hours` config fact. Today a voice toggle writes it manually ("тихий режим"). Target: the fact is also set by a time-window schedule (e.g. "quiet from 23:00 to 08:00") or automatically from calendar-busy. The schedule lives in config (`voice.quiet_hours_window` or similar); the loop writes the config fact at tick boundaries when the window is active. Done when: setting a quiet window in config suppresses proactive nudges during those hours without the user saying "тихий режим", and calendar-busy also gates the same way. --- ### 5. Tool executor `/tools` page The executor + matcher are wired (`internal/tool`, `internal/store/tools.go`). Tools are proposed via voice, but enabling them requires an authed surface. The missing piece is the **`/tools` page at `cmd/mavweb`** serving enable/ disable/discover UI, gated at `AuthStepUp`. Done when: visiting `/tools` on mavweb shows proposed tools with an "Enable" button, and enabled tools with "Disable". Enabling fills cmd + destructive flag and writes to the store. The tool runs on the next matching utterance without a daemon restart (already true — store-backed). --- ### 6. Note RAG Today the `query` intent returns the verbatim top-k note. Target: feed gated top-k notes to the phraser (llama-server) to compose a natural answer: "вот что я нашла:

". Done when: asking "что я говорил про X" returns a phrased answer with content from the matching notes, not a raw note dump. --- ### 7. Passkey step-up WebAuthn enrollment + assertion in the authed surface. Replaces `FloorEnrollment` with real passkey verification for: - Destructive tool confirm (layer 3) - Registration enable (layer 3) - Cold-start unlock (layer 3) Done when: a destructive tool requires a WebAuthn gesture (biometric/PIN) before it fires; `FloorEnrollment` is removed; cold-start passes through the passkey page. --- ### 8. Multi-user schema **DO NOT TOUCH THIS PHASE.** Per-user partitioning (`facts.user_id`, `notes.user_id`, `reminders.user_id`) is a migration-later concern. The schema has no user_id columns; adding them when the second user arrives is a migration, not a rewrite, because append-only means no existing row needs updating. An autonomous agent must not introduce user-scoping mechanisms while single-user is the only operational mode. ## Non-goals (unchanged from `maven.md`) - Not a relationship simulator - Not a guesser of truth — inference changes whether she asks, never what she records - Not a nag — would rather miss a nudge than be mutable - Not autonomous — suggests and acts on command, never unsandboxed action rights