From 8331be18ab9f9be94ee02be25e235706de4bac8d Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 12:51:51 +0200 Subject: [PATCH] spec: pin Radicale + cmd/mavcaldav, add acceptance criteria per open item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Claude's review: - Acceptance criteria (done-when) for all 8 open items - PROTOCOL.md must be generated from wire.go, not freehand - Resolved CalDAV: Radicale, new cmd/mavcaldav module - Reordered to core→capabilities→harden priority - Hard guard: multi-user schema is do-not-touch this phase - Dropped Nextcloud mention --- SPEC.md | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 SPEC.md diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8c66f7e --- /dev/null +++ b/SPEC.md @@ -0,0 +1,270 @@ +# 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. + +## 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` | 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. + +--- + +### 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