spec: pin Radicale + cmd/mavcaldav, add acceptance criteria per open item
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
This commit is contained in:
@@ -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 <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:
|
||||
|
||||
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 <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:
|
||||
"вот что я нашла: <summary>".
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user