The dispatcher already routes sev4-away → telegram. The gap is purely the runtime ErrNoSession fallthrough in voicesink.go. Points agent at the existing TODO, tells it to converge not duplicate.
12 KiB
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 <path> |
whisper.cpp (CGo, Vulkan) |
| TTS | voice.tts.socket in config |
cmd/mavttsd -piper <bin> -model <path> |
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.
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:
- Speaker attribution — the router produces a
speakerlabel per utterance ("kami", "gf", "unknown"). Voice uses speaker embedding / voice-print match; text/telegram uses explicit surface ownership or a command prefix. - Per-user partitioning —
facts.user_id,notes.user_id,reminders.user_id. Queries scope to the current speaker's partition. - Cross-user reads — explicit, e.g. "show kami's calendar" or "remind us both". The router decides from the utterance form.
- Shared namespace — household chores, shared calendar, home automation.
A
user_id = 0oruser_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 |
LFM 2.5-1.2B phraser |
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 gatecalendar_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 <core> -url <radicale> -user <u> -pass <p>
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