Files
Maven/SPEC.md
T
kami 877136aa61 spec: item 2 clarify ErrNoSession gap vs store.Away routing
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.
2026-07-03 13:07:53 +02:00

278 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 16 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 6771).
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, sev12 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 <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