5fe8f228c1
Add a read-only /ecosystem page that consumes the sibling services' JSON APIs (Nexus entities, Praxis attention, Hexis capabilities), fetched concurrently with honest per-panel error states. Siblings stay headless — mavweb is their human surface (arch §16). Wired via mavweb -nexus/-praxis/-hexis flags; mavweb joins the ecosystem compose network. Fix mobile horizontal overflow across all pages: .content is a flex child with default min-width:auto, so it refused to shrink below the tables' intrinsic width. min-width:0 lets wide tables pan inside .scroll instead of dragging the page sideways. Verified via CDP geometry check (scrollWidth === clientWidth at 430px). Also includes in-progress Ethos UI redesign, ecosystem deploy compose, and planning docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
287 lines
12 KiB
Markdown
287 lines
12 KiB
Markdown
# 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.
|
||
|
||
## 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 <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
|