From e0d0244fa970f844a02939d1b2da0fb62ad914d2 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 30 Jul 2026 23:39:56 +0400 Subject: [PATCH] Fold SPEC/maven/ROADMAP into DESIGN.md and drop the stale session logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 root markdown files, ~4,900 lines against ~33,000 lines of Go, with at least three pairs contradicting each other. When five documents describe the architecture, the code becomes the only trustworthy one — which defeats the point of having them. That drift is why the resident-model question had four incompatible answers. SPEC.md, maven.md and ROADMAP.md are deduped into DESIGN.md rather than concatenated, with a "Superseded" section carrying eight retired decisions and what replaced each: classifier-owns-the-route (the cascade is still the live path, but as a stopgap, not a design to extend), faster-whisper/vosk/silero, the small-model phrasing claim, sqlcipher, the Kotlin/Spring sketches, obsidian->chroma, script deployment, and FloorEnrollment. Superseded material is kept and marked rather than deleted, so it cannot read as current. SESSION-05/06-07-2026.md and PLANS.md are removed outright — git history holds them, and both were verified tracked before deletion. Go doc comments citing the deleted files are repointed to the equivalent DESIGN.md sections. Several asserted designs that were already retired, so the claims are corrected and not just relinked: stt.go named faster-whisper as production (it is whisper.cpp), tts.go named silero (it is piper), intent.go still described the classifier as owning the route, and stale vosk/chroma vocabulary is replaced. ECOSYSTEM-SPEC.md references are deliberately untouched — that is a different document, and a naive grep for SPEC.md matches it. Root markdown drops from 4,880 to ~3,700 lines. The review's ~1,500 target is not reachable while keeping the files it also said to keep — those alone are 2,553 lines — so trimming further needs a separate decision on MAVEN_ECOSYSTEM_ARCHITECTURE.md and PROGRESS.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik --- DESIGN.md | 790 +++++++++++++++++++++++++++++++++++ PLANS.md | 25 -- PROGRESS.md | 6 +- ROADMAP.md | 759 --------------------------------- SESSION-05-07-2026.md | 130 ------ SESSION-06-07-2026.md | 347 --------------- SPEC.md | 286 ------------- START.md | 6 +- cmd/mavenclient/main.go | 8 +- internal/auth/tier.go | 2 +- internal/delivery/channel.go | 2 +- internal/router/intent.go | 30 +- internal/stt/stt.go | 21 +- internal/tool/tool.go | 5 +- internal/tts/tts.go | 12 +- maven-feature-ranking.md | 6 +- maven.md | 413 ------------------ 17 files changed, 845 insertions(+), 2003 deletions(-) create mode 100644 DESIGN.md delete mode 100644 PLANS.md delete mode 100644 ROADMAP.md delete mode 100644 SESSION-05-07-2026.md delete mode 100644 SESSION-06-07-2026.md delete mode 100644 SPEC.md delete mode 100644 maven.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..4c68984 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,790 @@ +# Maven — Design + +> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md` +> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan, +> 2026-07-06). Those three files are gone; git history holds them. +> This is the single design document: principles, target state, and the +> execution ledger. `REARCH.md` remains authoritative wherever it disagrees +> with anything here. Everything the three sources asserted that is no longer +> the intended design is preserved under **§ Superseded** — do not read that +> section as current. + +--- + +## Identity + +**Maven** — self-hosted personal assistant. Manages your day, acts on your +homelab. One daemon on homesrv (always-on, not the workstation), multiple +client surfaces. All local, never phones home. + +Primary name is "Maven", with feminine-gendered Russian self-reference +("она", "меня", "помогла"). Clients may choose their own UI label. Consistent +character — tone, values, phrasing — pinned in prompt; it is what makes +restraint legible. + +## Non-goals + +Outside boundary: not Alexa/Siri on steroids, not a smart device. + +Inside boundary — the ones that actually constrain the build: + +- **Not autonomous** — suggests and acts on command. Proactive triggers never + get unsandboxed action rights. "backup failed, rerun it?" — never reruns it + herself. suggest ≠ act is the safety model. +- **Not a guesser-of-truth** — inference changes whether she asks, never what + she records. A confident wrong fact is worse than a known gap. +- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when + uncertain. Load-bearing. +- **Not a stranger** — runs on your stuff, your model, your data. Never + phones home. +- **Not a relationship** — mom-tone is a function that makes nudges land, not + emotional company. Names the drift a warm small model falls into. + +## Capabilities + +- **Reactive** — converse (voice in → STT → router → LLM → TTS, and text); + act (function calls into the homelab). +- **Proactive** — health nudges (hydration, meals, breaks, shower, sleep, + cleanup); user reminders (stated future intent, fires once); deliver (voice + when near, ntfy/telegram when away); restrain (quiet hours, per-rule + cooldowns, snooze-memory, self-quieting). +- **Capture** — throw facts/notes/tasks at it mid-flow. +- **State** — self (timestamped facts about you), presence (inferred, decaying + confidence, never one signal), activity, environment (homelab health, + calendar, weather). +- **Memory** — long-term recall and personalization. +- **Feedback** — nudge outcomes (acted/snoozed/ignored) tune the rules; + corrections are recorded; self-quieting falls out of this. +- **Surface** — you talk to it (phone page, PC client), it reaches you, and it + can prove it's you (auth). + +## 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, and reads are user-scoped. Shared state (house chores, +shared calendar busyness) is explicitly cross-partition via a `shared` / +`household` namespace. The router owns attribution — speaker recognition for +voice, surface ownership for text. + +**This is post-MVP and fenced.** The schema has no `user_id` columns. Adding +them later is a migration, not a rewrite, because append-only means no +existing row needs updating. **An agent must not introduce user-scoping +mechanisms while single-user is the only operational mode.** Revisit when a +second person is actually in the house — speaker attribution needs the second +voice to train against. + +--- + +## Architecture + +- Daemon lives on homesrv (always-on), not the workstation. +- The trigger loop is dumb: ticks ~60s, no LLM, evaluates deterministic + predicates against state. +- The LLM wakes only when a predicate fires; its job is narrow — phrase, + never drive the loop. +- Presence is a decaying confidence score over multiple weak signals with + hysteresis; never trust one source. +- Self-facts → care nudges. World-facts → ops + context. Same engine. +- Rules as code, not a config DSL — revisit at ~30 rules. +- Proactive triggers: read + suggest only, never action rights. + +**Build order:** the state layer is first. Nothing proactive works without +state to evaluate predicates against — it is the floor, built before the +loop, phrasing, or delivery. + +### Storage — sqlite + +Single-user, no concurrent writers, on a box already tight on RAM → sqlite, +not postgres. Library not a process, no port to harden, backup is `cp`. +Giving up postgres `LISTEN/NOTIFY` is a non-loss: the loop polls anyway. + +At-rest encryption is AES-256-GCM with a tmpfs working copy +(`internal/store/crypt.go`), **not** sqlcipher. The key is read at daemon +start, never hardcoded. + +```sql +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; +PRAGMA foreign_keys=ON; +PRAGMA busy_timeout=5000; +``` + +### Schema — append-only, three shapes + +Never UPDATE a value. A wrong fact is superseded, not overwritten — this +keeps the audit trail. Current value = latest non-voided row for a key. + +**facts** — substrate, all observations (self + env): + +```sql +facts ( + id, ts, -- ts = valid-time (true-as-of), not insert-time + kind, -- 'self' | 'env' | 'config' + key, value, -- value json if structured + source, -- tap:* | infer:* | poll:* | ambient | promote | feedback + confidence, -- 1.0 taps only; <1 inferred + voids_id -- correction points at the fact it cancels +) +-- index (key, ts desc) +``` + +**reminders** — user intent, fires once: + +```sql +reminders ( id, created_ts, fire_ts, payload, status ) -- pending|fired|cancelled +``` + +**nudges** — every proactive send + outcome. This table IS the restraint +memory: + +```sql +nudges ( id, ts, rule, channel, message, outcome, outcome_ts ) -- pending|acted|snoozed|ignored +``` + +**Presence is not a table** — it is a pure function over recent facts, +computed each tick. The only stateful bit is hysteresis: + +```sql +presence_state ( last_bucket, last_score, updated_ts ) +``` + +Facts additionally carry `Subject`/`EntityID`/`ResolutionState` for +entity-aware resolution against Nexus (see `MAVEN_ECOSYSTEM_ARCHITECTURE.md`). + +### Trigger model + +- The loop ticks ~60s, no LLM. 99% of ticks evaluate a few predicates and die + for free. +- A predicate is `(State) -> Boolean`, **pure, no I/O** → unit-tests with a + fake State, zero infra. +- `since(key)==null` → don't fire. Silence on no-data is "shuts up when + uncertain." +- **The gate is universal, applied by the loop, never per-rule** — + quiet-hours, presence, cooldown, snooze, calendar-busy all live in one + `fires()`. Cross-cutting restraint lives in one place or it drifts. +- One nudge per tick (max severity), never dogpile. + +### Rules decide, LLM phrases + +The rule decides whether Maven speaks — absolute, deterministic. The LLM only +words it: input `(rule, severity, context)`, output `message`. **No +`send`/veto bool** — a nondeterministic small model never gets to silently +kill a greenlit nudge. Suppression context ("don't nag mid-meeting") moves +INTO the gate as an env predicate, not the LLM's job. + +### User reminders — a separate class + +- Relative → absolute **at capture** ("in 4h" → store `now+4h`, never the + string). +- Reuses the loop, not a second scheduler — just a predicate: + `fire_ts <= now AND pending`. +- **Bypasses the restraint gate** — "wake me 7" fires in quiet hours; that's + the point. Snooze still applies. Two delivery paths. + +--- + +## Reactive path — routing + +**Target design: LLM-as-router** (see `REARCH.md` and `CLAUDE.md`). One +resident model emits GBNF-constrained structured JSON, and the same model +phrases replies; the embedder is a RAG hint, not a routing gate. The +committed default today is the classifier/embedder cascade, which is an +interim stopgap — see **§ Superseded**. + +### A cascade, not one decider + +Not alternatives — layers: + +- **Stage 0 — exact match (regex/grammar).** Wake-word + known command + grammar. "maven, restart nginx" hits the allowlist directly and skips + everything downstream. Lowest latency; boring high-frequency acts for free. +- **Stage 1 — route decision.** The resident LLM (target) or the + nearest-centroid classifier (current stopgap). Any LLM error falls through + to the classifier so a turn never breaks on the model. +- **Stage 2 — slot extraction, per intent.** Classification gives *what + kind*, not *the args*. Reminders need a datetime, acts need fn + params. +- **Stage 3 — confidence gate.** Below threshold → clarify, don't guess. Same + pattern as `since(key)==null → don't fire`. A misroute is a confident wrong + write, which is worse than a gap. + +Router contract: `[{"intent":, key?, value?, text?, verb?}, ...]` over +7 intents (`fact, reminder, note, query, act, chat, system`). + +### save-where — the two-memory routing axis + +One discriminator: **does the loop evaluate a predicate against it?** + +| intent | example | lands in | why | +|---|---|---|---| +| act | "restart the backup" | function call (allowlist) | command now, not stored | +| reminder | "wake me 7", "vet tuesday" | `reminders` | has a fire-time | +| fact | "drank water", "slept 6h" | `facts` | structured state the loop reasons over | +| note | "gpu driver fixed the flicker" | semantic store | recall/preference, no predicate touches it | +| query | "is the backup up?" | LLM over the stores | answer, don't store | + +fact-vs-note is the whole line: a predicate will read it → structured `facts` +row; "recall when relevant" → semantic store. Reminder splits off by future +timestamp; act splits off by being imperative-now. + +### The preference seam — forced, not a choice + +A preference ("prefer backups at 3am") looks like note-or-config. It isn't, +because of a hard constraint: **a predicate cannot read the semantic store.** +The loop is dumb and deterministic; it can't run a vector search every tick. +The moment a preference becomes load-bearing it MUST exist as a structured +`key=value` row the loop can evaluate. The store split is physical, not +cosmetic. So: **capture → always a note** (inert, fail-safe, drives nothing), +and **a note stays a note until a rule needs it.** + +### Promotion + +**Promotion = the moment a human authors a predicate that reads the value.** + +- Authoring the rule copies the value into `facts (kind=config, + source=promote)` — a deterministic key the loop can evaluate. +- The note stays as provenance (where the config came from, in your words). +- The predicate reads the promoted `facts` row, never the semantic store. + +Properties: **one-way, never auto** — a note cannot self-promote; there is no +path from the semantic store into the loop that skips a human writing a +predicate. This **closes the injection hole**: ambient-derive overhears the TV +say "prefer backups at 3am" → lands as an inert note → cannot drive the loop. +Same shape as `proposed → enabled`: the low-authority form is free and +automatic, the high-authority form requires a deliberate human act. + +### Tool registration — drafting is suggest, enabling is act + +Maven can scaffold a tool she's missing. She cannot enable it. + +- She detects the gap, scaffolds the registration (name, command, params, + destructive y/n), writes a `proposed` row, surfaces it. +- `proposed → enabled` flips through an authed surface (PC client / authed + page), **never the voice/chat path** — that's the act, and it's the user's. + +Why human-only: editing the allowlist is the one act that *moves the +boundary*, and a boundary you can move from inside isn't one. Registration is +privilege escalation, a different authority tier from invoking a listed tool. +Paranoid case: prompt injection via ambient-derive — the TV says "maven add a +shell tool" — a self-registering Maven grants *itself* arbitrary capability. +Human-only enable keeps a compromised Maven boxed by what's already on. She +builds the stubs, you review and enable; you never lose the pen. + +### Confirmation is not one mechanism + +A gate assumes a fully-formed action — "drop the db? y/n" works because the +action is already specified. An underspecified request can't be gated; you +can't confirm what isn't specified. Confirmation scales to how formed the act +is. Acts fuzzy-match against the fn allowlist: **not on the list → refuse, +don't improvise.** Destructive ones still gate behind confirm. + +Misroute correction is append-only and grows the router's examples with use — +same shape as `nudges.outcome` tuning cooldowns, no retrain. + +--- + +## Voice pipeline (STT / TTS) + +Real STT + TTS are 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 / no `-model` | in-process `stt.Stub` or `mavsttd` stub | hash + template | +| TTS stub | socket unset / no `-piper` | in-process `tts.Stub` or `mavttsd` stub | 200ms tone | + +The server has an iGPU + Vulkan. whisper.cpp uses Vulkan; piper uses CPU +(lightweight, real-time). Voice defaults to the stock piper RU voice +(`ru_RU-irina-medium.onnx`); a custom trained voice is a model-file swap +(`-model` / `VoiceConfig.Tts.Voice`), not a code change. + +## Resident language model + +One 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 is deferred until the main feature set is complete. + +Models are per-component and downloaded separately (gitignored `models/`); +no model is baked into a binary. llama-server runs with `-ngl -1`. + +**Resident checkpoint (resolved 2026-07-30, task #318).** Currently +**Qwen3.5-0.8B** (`Q4_K_M`) — the smallest checkpoint in the gguf library, +chosen for latency on the deploy box. The **target** is the locally CPT'd +**Qwen3-1.7B**, whose training is still in flight (Vikunja #122); until that +produces a gguf, 0.8B is what runs. + +The library lives at `/mnt/hdd1/llms`, bind-mounted to +`/opt/maven/models/llm`, which **shadows** the repo's `models/llm/` — the +LFM2.5-1.2B gguf in the repo tree is a leftover and is never loaded. Earlier +docs claiming LFM2.5 or a 2B Qwen as resident described states that are no +longer current; see § Superseded. + +Persona is configurable: `voice.persona` in `mavend.json` is prepended to +every system prompt; empty means the built-in feminine-gendered Russian +persona. + +## ML & hardware profile + +| Resource | Available | Used by | +|----------|-----------|---------| +| CPU | Ryzen 5 5600U, 13GB RAM | loop, router, delivery | +| iGPU | Vega, Vulkan | whisper.cpp (STT), piper (TTS), llama-server offload | +| llama-server | `n_gpu_layers: 99` | resident model — Qwen3.5-0.8B now, Qwen3-1.7B target (#122) | + +--- + +## State — signal sources + +**Explicit taps set truth. Passive signals drive prompting.** A passive +signal can never write a self-fact — it only makes Maven ask. The boundary +lives in `source`; rules trust provenance. + +- **self / taps (1.0):** water, meal, shower — phone / voice / telegram. +- **self / passive (activity, not truth):** desk-idle, voice_active, sleep. +- **presence (weak, decaying, multi-source):** wg handshake, page heartbeat, + desk-not-idle. *No LAN sweeps* — too invasive; wg + heartbeat get ~90%. +- **env / polled:** healthcheck (disk/service/backup/cert), calendar + (CalDAV), weather. +- **provenance-scoping:** a rule on `service_down` trusts only + `source=poll:healthcheck`. A compromised poller must not be able to forge a + trigger. + +### Presence — concrete scoring + +**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive +indicators of one latent binary ("kami here?"). + +``` +p_i = weight_i · exp(-Δt_i / τ_i) # one signal's decayed contribution +P = 1 − Π (1 − p_i) # combine +``` + +Diminishing returns on stacking weak signals; never exceeds 1.0. "Any one +signal raises confidence, no single one owns it" falls straight out. +Weighted-sum was rejected: heartbeat+wg fresh would peg identical to +everything-fresh — overcounts. + +**Signals.** `Δt` = `now − latest fact ts` for that key. No fact → drops out +of the product (not a zero). + +| signal | source | fresh weight | τ (min) | rationale | +|---|---|---|---|---| +| desk active | `infer:hyprland` (not-idle) | 0.90 | 8 | input = human at keyboard. strongest. τ forgives read/think gaps | +| page heartbeat | `infer:heartbeat` | 0.60 | 4 | a surface you use is open + alive. pings ~30s; 4min gap = gone | +| wg handshake | `infer:wg` | 0.40 | 20 | device on tunnel. coarse — pocket or three rooms away | + +**Threshold + hysteresis (schmitt trigger):** + +``` +ENTER (away → present): P ≥ 0.55 +EXIT (present → away): P < 0.30 +cold start: away # fail-closed; same as since(key)==null → don't fire +``` + +A wide band is stable. A lone fresh wg (0.40) can't *enter* present but +*holds* it while decaying — phone-on-network alone never declares you here. + +| scenario | P | bucket | +|---|---|---| +| at desk, typing | ~0.90 | present | +| at desk, 5min reading (client open) | ~0.79 | present | +| desk-only, ~9min zero input | <0.30 | → away | +| couch, phone page open, no desk | 0.60 | present | +| left house, only wg lingering | decays ~20min | → away | +| cold boot, nothing | 0 | away | + +Implemented in core (`internal/store/presence.go`) — presence reads State +under the lock; it is predicate input, not a module. Each tick: score → +resolve against the last bucket → persist `presence_state`. Decay uses +wall-clock Δt, so tick jitter causes no drift. + +Boundaries are **hand-tuned, NOT feedback-tuned** — keep presence numbers out +of the auto-tuner or a weird week drifts you silently invisible. **Presence = +reachability, not wakefulness** — sleep/quiet-hours are handled separately in +the gate. Stated caveat: noisy-OR assumes independence and desk+heartbeat +correlate; co-firing slightly overcounts, which is fine — genuinely co-firing +IS stronger evidence. + +--- + +## Proactive + +### Listening — three modes, not one + +"Ambient listening" was the wrong frame; there are three capabilities split +by trigger + retention. The threat model is local-only: the file at rest and +who can reach the box. + +1. **address-capture** — "maven, note this" out loud. Hands-free voice path, + no tap. Raw audio dies after deriving the fact. **← MVP pick.** Lowest + retention, highest daily payoff. +2. **meeting-record** — deliberate start/stop, verbatim transcript *kept*. + Retention is the point (overrides any "always delete" rule). Post-MVP. +3. **ambient-derive** — background overhearing, low-confidence candidate + facts, raw ephemeral, only the derived fact survives. Needs the confidence + model working first. Last. + +Only mode 3 is always-on. Modes 1–2 fire heavy transcription on an explicit +trigger → the CPU idles otherwise. Always-on means lightweight VAD (+ maybe +owner-detect) only. **Speaker-scoping is attribution metadata, not a +kill-gate** — tag `source=ambient:self | ambient:other`, so per-person +retention becomes a `WHERE` clause. + +`cmd/mavwaked/` ships as energy-VAD only, no wake-word model — every +utterance fires, capped at `SurfaceVoice` (L0). Its 30ms/16kHz frame shape +matches silero-vad ONNX input 1:1, so swapping in a real wake-word model +(silero-vad / openWakeWord) is a local change in `vad.go`. Hardware topology +is settled: mavwaked is a *client* binary (desk PC / Pi with a mic, systemd +user unit), not a homesrv daemon. + +### Delivery / channel routing + +Routing = `f(severity, presence)`. Presence decides *reachability*, severity +decides *insistence*. Both are needed. + +| | present | away | +|---|---|---| +| **sev1–2** (care) | voice | **drop** | +| **sev3** (ops, soft) | voice | ntfy, once | +| **sev4** (ops, hard) | voice + ntfy | telegram, repeat til ack | + +sev ≤ 2 drops on away, sev ≥ 3 holds: a missed water nudge is noise, a missed +backup failure isn't. Away-channels (ntfy/telegram) leave the box — the one +path that crosses "never phones home," through your own relay. **Minimal +body** — "disk low on homesrv," not detail; don't make notifications a +shoulder-surf exfil surface. + +The same table governs runtime fallthrough: when the dispatcher chose voice +but no session is live at push time (`ErrNoSession`), it falls through to the +next channel on this table rather than stopping. Delivery is durable — +`BeginDeliveryAttempt` before `Send`, `CompleteDeliveryAttempt` after, with a +stale `pending` row reconciled to `unknown` at startup (never silently +resent or dropped). + +### Feedback loop (outcomes → tune cooldowns) + +The `nudges.outcome` column IS the signal — no new storage. Cooldown becomes +a function of recent outcomes, not a constant. + +- mostly `ignored` → nagging into the void → lengthen cooldown / raise + threshold +- mostly `acted` → landing → leave it, or cautiously shorten +- mostly `snoozed` → right nudge, wrong *time* → shift the window, not the + frequency + +**Tunes parameters, never logic.** It can widen a cooldown, nudge a +threshold, shift a window; it cannot rewrite a predicate or invent a rule. +Bounded knobs (`cooldown ∈ [min,max]`) so a weird week can't mutate Maven +silent or stalker. Dead simple at MVP: a ratio over the last N, not a learned +model — `ignored_rate > 0.7 → cooldown *= 1.5, capped`. **Persist the +adjusted cooldown as a fact** (`source=feedback`) — it survives restart and +stays visible; why Maven went quiet should be a query, not a mystery. + +### Quiet hours + +The loop reads a `quiet_hours` config fact. A voice toggle ("тихий режим") +writes it; a time-window schedule in config and calendar-busy also gate the +same way, written at tick boundaries. + +--- + +## Auth + +### A cascade, not a pick-one + +Same shape as `confirmation is not one mechanism` — each layer answers a +different question. + +| layer | question | mechanism | surface | +|---|---|---|---| +| 0 — network | on the tunnel at all? | WireGuard | everything. floor | +| 1 — device | enrolled box? | mTLS client cert, terminated at proxy (optional) | PC client, authed page | +| 2 — session | you, this session? | passkey / WebAuthn | PC client, authed page | +| 3 — step-up | you, *right now*, for this act? | passkey user-verification gesture | registration-enable, destructive acts, **core cold-start unlock** | + +wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is +"authed" at layer 0 only — that gap is why the upper layers exist. **Passkey +over password/token** because step-up is load-bearing: WebAuthn gives +per-assertion user verification for free, and the biometric/PIN gesture IS the +human-in-the-loop. No shared secret on the box to steal; the private key stays +in the enclave/TPM. **mTLS (layer 1) is the optional one** — if dropping a +layer, drop mTLS, never the passkey. + +`internal/webauthn/` does real WebAuthn (ES256, sign-count regression) and +`cmd/mavweb/webauthn.go` serves enroll + assert; a successful assert bumps the +session to L3 for 5 minutes. + +### The invariant — surface caps authority + +**Auth tier is a property of the surface, and the surface caps maximum +authority.** You cannot step up past what the channel structurally carries. + +- **voice** presents layer 0 + speaker attribution and STOPS. Speaker + verification is attribution, not auth. A room mic is reachable by anyone + present → voice is *structurally incapable* of layer 3. +- **telegram inbound** = possession of a telegram account + a chat-id + allowlist; telegram's auth, outside our control. Weak tier → read + soft + acts, never destructive, never registration. + +So voice/chat can never reach registration-enable — **not because auth +"failed" but because the channel can't carry the proof.** Destructive acts +always gate behind an authed surface for the final confirm. + +### Key provenance + +**The theorem:** no unattended key source survives a powered-on stolen box. +Anything the daemon fetches with no human present, a thief who grabs the +running laptop fetches too. The question was never "find the secure source" — +it's **pick the failure mode:** unattended-but-loses-to-running-theft, or +theft-resistant-but-attended. Structural; you can't have both. + +**The trap — TPM alone:** TPM sealing binds release to PCRs and defeats +offline disk extraction (real, worth having), but against full-box theft it +does nothing — PCRs still match, the thief boots, the TPM unseals on cue. A +laptop is portable, so full-box theft is the *likely* case. + +**The resolution — cold-start IS a layer-3 act:** unlocking the DB makes +Maven's entire memory readable, the single highest-authority op. "Always-on" +means it doesn't need babysitting during *normal operation*; it does NOT mean +it survives a cold boot with nobody around. Unlock is **remote-attended** — +`systemd-ask-password` over ssh, or pushed through the passkey-authed page. +Theft = the box reboots into a locked daemon and stays there. + +| layer | mechanism | buys | +|---|---|---| +| disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline extraction dead (TPM), powered theft needs the PIN in your head | +| db key | not at rest: supplied at daemon start, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal | + +TPM+PIN is the honest middle; a yubikey is the upgrade path *if carried on +your body*. **Runtime:** key in daemon RAM → `mlock` the page (no +swap-to-disk), swap off or encrypted, zero on shutdown. **The trade:** a cold +reboot needs you (remotely) present; in exchange a stolen laptop — running or +off — is a brick holding ciphertext. + +Implemented shape (`internal/webauthn/keywrap.go`): **the key is wrapped, not +derived.** A passkey assertion doesn't produce deterministic bytes (WebAuthn +signatures are randomized), so at enrollment a random 32-byte AES key is +generated, wrapped with HKDF-SHA256(credential public key, salt) + +AES-256-GCM, and stored on disk; at cold-start the assertion unwraps it. The +daemon boots *locked* — the IPC server serves only the unlock method +(`MethodStoreEncryptionKey`/`MethodUnlock`), and the loop, voice and delivery +don't start until unlock succeeds. mavweb serves the passkey page while +locked; other pages return 503. An env-key fallback is preserved for dev/CI +and recovery. This is disk-theft protection, not RAM-capture protection: root +on the host can still dump the key after unlock, but a stolen disk or a +`docker inspect` no longer yields it. + +### Core/module key isolation + +The convenience (reboot attendance collapses to *core cold-start only*) is +contingent on one thing: **the key lives in core's address space and nowhere +else.** + +- **core = the only key-holder.** The daemon holding the unlocked DB + the + trigger loop. Unlocked once (remote-attended), runs for weeks. It reboots + almost never *because it was deliberately given nothing that churns* — no + tool code, no model weights. +- **modules = restart-free, key-free, fail-independent.** STT/TTS, phrasing, + tool executors, delivery. Update/crash/swap one → none touch the unlock. + "Update the tool module, no attendance" is correct *by construction*: the + module never had the key. + +Enforced by an **IPC boundary, not a shared address space** (unix domain +socket, local-only). **Core mediates and never hands back a DB handle** — +modules send requests *to* core ("write this fact" / "read presence"). +**Module compromise ≤ module authority:** the worst a popped TTS does is send +garbage audio. The discipline: **nothing enters core's process unless it must +read state under the lock.** The loop and predicates qualify; phrasing, +routing, transcription, tool execution and delivery all read *derived* data. +Erode this and you buy back the attendance you just eliminated. systemd +topology: core = one unit, each module its own unit, `After=core.socket`, +socket-activated, `Restart=on-failure`. + +### The through-line + +Network → box → process: the same question at three radii. + +- **network (wg):** who reaches the box +- **box (LUKS+TPM+PIN, at-rest encryption):** what a dead/stolen box gives up +- **process (core/module socket):** what a compromised module reaches + +Every cut is the same instinct — *a boundary you can move from inside isn't +one*, *compromised X can't forge Y*, *attribution is not auth*. Auth didn't +add a new principle; it applied the existing one at smaller and smaller scope. + +--- + +## Calendar + +Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is +read + write: read to detect busy/available (gating nudges) and answer "what's +on my calendar"; write to schedule and move events. The feed is a separate +binary, `cmd/mavcaldav`, which polls Radicale and writes `calendar_busy` plus +per-event facts through CoreAPI, on value change only (same append-only +discipline as `mavpoll`). + +## Deployment + +| Phase | Mechanism | Notes | +|-------|-----------|-------| +| Then | scripts (`start-maven.sh`, `kill-maven.sh`) | manual start/stop in tmux | +| Now | Docker (one image, several daemon containers) | `docker-compose.yml` | +| Alt | systemd user units | one per binary, socket-activated modules | + +Invariant: **core is rarely redeployed, components are.** The IPC boundary +(worker STT/TTS sockets, `internal/ipc` CoreAPI socket) means `mavsttd`, +`mavttsd`, `mavpoll`, `mavweb` restart independently without touching the +daemon. + +## Client protocol + +The voice wire protocol (length-prefixed JSON 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 published spec is `PROTOCOL.md` — **generated from +`internal/voice/wire.go`**, not composed freehand, so it can't drift from +code. It covers transport (4-byte big-endian length prefix), methods +(`PushToTalk`, `Pong`), push kinds (`AudioNudge`), surface identity +(header field, cap enforced server-side), error codes, and how passkey +assertions are carried for step-up. + +The act allowlist is config-driven (`deploy/mavend.json` seeds a homelab set: +read-only status/ps/uptime/df/free/logs, gated restart/stop/reboot). +Broadening to home automation, media or comms is JSON, not code. + +--- + +## Execution ledger + +Condensed from `ROADMAP.md` (2026-07-06). The live queue is +`20-07-2026-BACKLOG.md`; current state is `PROGRESS.md`. + +| # | Item | Prio | Status | +|---|------|------|--------| +| 1.1 | Kuma API key for `service_down` polling | P1 | done `eda434f` | +| 1.2 | Voice bind verify + stale comment fix | P1 | done `eda434f` | +| 1.3 | desk_active presence script on desk PC | P1 | **not done** — operator action on `linux` (systemd user timer + hypridle listener); 0 facts ever written, presence runs on `page_heartbeat` alone | +| 2.1 | Cold-start unlock (passkey → L3 key seam) | P2 | code done `b0932a1`+`15fe7bb`, **tests missing** — wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC rejects non-unlock methods | +| 3.1 | Always-on listening | P3 | MVP `e57647c` (energy-VAD only); remaining: wake-word model in `vad.go` | +| 3.2 | Conversation depth (multi-turn) | P3 | done `05236ad` — anaphora resolver + cross-intent `followUpMerge` + `Session.History` | +| 3.3 | Latency / streaming (streaming STT/TTS, barge-in) | P3 | not started; recommended path is WebSocket voice, keeping TCP for non-browser clients | +| 4.1 | Routing quality (dev embedder) | P4 | done `b7eb53a` — `make download-embedder`, configurable `voice.query_min_score` | +| 4.2 | Act surface broadening | P4 | not a code item (operator config) | +| 4.3 | LTM ANN index | P4 | deferred — `memory.Store` is the swap point; brute-force cosine is sub-ms at single-user scale. Revisit past ~10k rows | +| 4.4 | Persona prompt | P4 | done `b7eb53a` — `voice.persona` | +| 4.5 | Custom TTS voice | P4 | not started; operator work (record ~50–100 clips, train a piper voice), code already supports the swap | +| 5.1 | Multi-user | P5 | deferred by design — do not start without an explicit operator decision | + +Also landed from the SPEC's original open items: protocol doc, away-channel +fallthrough, CalDAV poller, quiet-hours schedule, tools enable/disable page +(`/tools`, gated at step-up), and note RAG (the `query` intent phrases from +gated top-k notes instead of dumping a verbatim note). + +Conventions retained from the roadmap: **done when** = a checkable finish +criterion; name the exact files, not "somewhere in internal/"; every code item +ends with `make test` green (gofmt + vet + `-race`), no exceptions. + +--- + +## Open questions + +Router+invocation, two-memory routing, presence, and auth were once listed +here and are resolved by the sections above. + +**Impactful — gate behaviour or the MVP surface:** + +- **stage-3 confidence threshold** — the gate-or-clarify number. Defines how + often Maven asks vs. guesses on free-form input. +- **quiet-hours definition** — fixed clock vs derived from sleep facts. +- **confirmation tier model** — only the forced pin is settled (registration + is out-of-band, human-only). The rest is a sketch: confirmation scales with + specification + reversibility (just-do / binary-confirm / + clarify-then-confirm / out-of-band). Open sub-item: destructive-act confirm + — policy-level vs per-function flag. +- **ask-password transport** — `systemd-ask-password` over ssh vs a + passkey-authed page push for cold-start unlock. Both work; unpicked. +- **wake-word hardware** — USB mic on a client box (fast path) vs an ESP32-S3 + room device (the "real" version). The code is the same either way; the + hardware changes the deploy — and it decides where an ambient reply is + spoken (speaker on the capture device vs the PWA if a session is live). + +**Plumbing / deferred:** + +- **passkey enrollment bootstrap** — the first credential on a fresh device, + before a passkey exists to authenticate with (trust-on-first-use gap). +- **mTLS in or out** — provisioning cost on mobile vs paranoia payoff. + Leaning optional. +- **PIN vs yubikey for LUKS** — PIN-in-head pinned for now. +- **session lifetime / re-auth cadence** — unset. +- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one + note in one utterance. Needs a second pass or it loses half. +- **query read-path** — semantic RAG vs a structured read, depending on the + ask. +- **presence — away tap override** — an explicit `away` tap as a hard + override. Clean extension, deferred; scoring stands without it. +- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning + against real signal traces. +- **presence — wg home-vs-cellular** — let wg carry more weight when clearly + home. Needs the signal to exist first. +- **listening modes 2–3** — meeting-record + ambient-derive; ambient-derive + needs the confidence model first. +- **LLM dialogue manager** — the router/phraser deciding "ask for X" vs "act." + Blocked on the resident-model question (task #318). + +--- + +## Superseded + +Kept for provenance. **None of this is the current or intended design.** + +- **Classifier-owns-the-route.** `maven.md` argued the route decision must + stay deterministic — "classifier owns the route, the SLM stays in its + phrasing lane" — with an embedding + nearest-centroid stage 1 over ~10 + examples per intent, and misroutes appended as new centroid examples. + *Replaced by* LLM-as-router (`REARCH.md`): one resident model emits + GBNF-constrained JSON and also phrases replies; the embedder is demoted to + a RAG hint. The classifier cascade is still the code path that runs today + (`llmrouter` is wired nil) but it is an interim stopgap, and it is the known + cause of weak RU query handling — not a design to extend. +- **Named STT/TTS model picks.** `maven.md` picked faster-whisper small/int8 + as primary STT with vosk RU for a low-latency command grammar, and silero + (license unverified) as TTS with piper RU as the floor, all on + onnxruntime/CPU. *Replaced by* whisper.cpp (CGo, Vulkan) in `cmd/mavsttd` + and piper as the production TTS in `cmd/mavttsd`. Several Go doc comments + still cite the old picks by way of `maven.md § stt/tts`. +- **Small-model phrasing claim.** `maven.md` specified "lfm2.5 / sub-1b for + phrasing — prompted, not trained," and `SPEC.md` named a specific resident + size. Both are superseded by the RU-CPT + joint persona/router SFT plan. + *Resolved 2026-07-30 (#318):* the resident checkpoint is **Qwen3.5-0.8B** + now, with the CPT'd **Qwen3-1.7B** as the target (#122). Note the resident + model is no longer described as untrained — the target is trained + end-to-end, which is the substantive change from the old claim. +- **sqlcipher at rest.** `maven.md` specified sqlcipher with the key read at + daemon start. *Replaced by* AES-256-GCM with a tmpfs working copy + (`internal/store/crypt.go`). The key-provenance argument above survives + unchanged; only the cipher layer differs. +- **Kotlin/Spring implementation sketches.** `maven.md` gave the presence + scorer as Kotlin (`data class Signal`, `presenceScore`, `resolve`) and cited + Spring Security's passkey support as in-stack. *Replaced by* Go throughout; + the presence math is unchanged and lives in `internal/store/presence.go`. +- **obsidian → chroma for long-term memory.** `maven.md` specified Obsidian + as canonical markdown with a derived Chroma embedding index, and listed the + chunking mechanics as unbuilt. *Replaced by* sqlite-backed vector storage + (`internal/store/memory.go` behind `internal/memory.Store`); no Chroma, no + Obsidian. Wherever this document says "semantic store," that is what it + means. +- **Script-based deployment.** `start-maven.sh` / `kill-maven.sh` in tmux was + the "now" row of the SPEC deployment table. *Replaced by* the Docker + deployment (one image, several daemon containers). +- **`FloorEnrollment` as the auth floor.** `SPEC.md`'s week-1 floor granted + full L3 to any same-uid caller with only the wg tunnel underneath. + *Replaced by* real WebAuthn enroll/assert plus the wrapped-key cold-start + path; the passkey step-up item is landed. diff --git a/PLANS.md b/PLANS.md deleted file mode 100644 index 2a59c8b..0000000 --- a/PLANS.md +++ /dev/null @@ -1,25 +0,0 @@ -# Maven mavweb — Ethos migration plan - -## Task -Adopt Ethos design language (#36, prio:3). - -## Scope -- Go + html/template + vanilla CSS/JS -- Add tokens.css with :root --ethos-* variables -- Replace literal colors/spacing/radii -- Restructure dash.html / history.html / notifications.html / trace.html into shell -- Tables: sort/filter/resize/hide/pin/pagination -- Row click → inspector -- Keyboard: Ctrl+K, Ctrl+/, Esc, Enter, Space, Alt+←, Alt+→ -- Status vocabulary -- Density Compact default - -## Notes -Largest rewrite due to stack mismatch. No SPA migration — keep server-rendered. - -## Status -- [x] PLANS.md created -- [ ] tokens.css -- [ ] Shell restructure per page -- [ ] Tables refactor -- [ ] Keyboard shortcuts diff --git a/PROGRESS.md b/PROGRESS.md index 7023320..41770d6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -121,7 +121,7 @@ Caveats / gotchas: ### Done since last revision (overnight-jul6, 2026-07-06) -Seven tasks (`SESSION-06-07-2026.md`), one commit each, merged to `master`. +Seven tasks (session board `SESSION-06-07-2026.md`, deleted 2026-07-30 — see git history), one commit each, merged to `master`. This session was run through **opencode**, not Claude Code (co-author trailer). Since then (**2026-07-06, second session**): @@ -210,7 +210,7 @@ verification commit left it misaligned, so `gofmt -l` still flagged it despite t ### Done since the jul5 revision (overnight-jul5, 2026-07-05) -The overnight session (`SESSION-05-07-2026.md`, 25 tasks) closed the previous +The overnight session (`SESSION-05-07-2026.md`, deleted 2026-07-30 — see git history; 25 tasks) closed the previous "not built yet" items 1–3 and added feature depth: - **At-rest encryption** — the on-disk db is AES-256-GCM ciphertext; the daemon @@ -394,6 +394,6 @@ Not neglect — the one item where doing nothing now beats doing something: - **The hard part is speaker attribution, and it needs the second voice.** A voice-print discriminator (kami vs gf vs unknown) can't be trained or tuned with one voice in the house. Plumbing before the model is pipe with no water. -- **It's fenced deliberately** (`DO NOT TOUCH THIS PHASE` in SPEC.md) so an +- **It's fenced deliberately** (`DO NOT TOUCH THIS PHASE` in `DESIGN.md` § Users) so an autonomous agent doesn't add `user_id` columns while touching the store and commit us to a schema before the constraints that shape it exist. diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index b50beab..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,759 +0,0 @@ -# Maven — Roadmap Spec (post-jul6, 2026-07-06) - -> The north star (`SPEC.md`) is settled; this is the execution plan for -> everything between "works" and "the thing the spec describes." Each item -> names the exact files, interfaces, and done-when criteria so an agent -> (or human) can execute without guessing. Priority is ops → security → -> dealbreakers → depth → deferred. Estimates are wall-clock for one -> focused session, not calendar time. - -## Conventions - -- **Done when** = a checkable finish criterion. If it can't be checked, it - isn't done. -- **Files** = the exact paths to touch. No "somewhere in internal/." -- **Interface** = the seam a new piece plugs into. If the seam doesn't - exist yet, the item says so and names where it's added. -- **Deps** = what must land first. Items with no deps can start now. -- Every code item ends with `make test` green (gofmt + vet + 303+ tests, - `-race`). No exceptions. - ---- - -## P1 — Ops quick wins (do today, ~2h total) - -These are not code problems — they're operator actions or trivial fixes -that unlock already-built features. Highest ROI per minute on the list. - -### 1.1 Kuma API key for `service_down` polling - -**Status:** done (`eda434f`, 2026-07-06). Key `uk5_mavpoll-key` created, wired -into mavpoll. Agent caught a real bug: kuma expects the key as the *password* -field, not username. mavpoll switched to `network_mode: host` (compose bridge -couldn't reach localhost netdata/kuma). - -**Vikunja:** #16 (prio 2) -**Est:** ~5 min -**Deps:** none - -**Status now:** `cmd/mavpoll/main.go:54` defines `-kuma-key` (basic-auth -username, empty password). `pollKuma` (line 238) calls -`p.get(ctx, p.kumaURL, p.kumaKey)` which sets basic-auth. Without the key, -`pollKuma` gets 401 and logs "no monitor_status metrics" — the whole -`service_down` sev4 rule (`internal/loop/rules.go` ServiceDownRule) is -dark. Netdata polling works independently. - -**The gap:** the key string doesn't exist. Kuma's `/metrics` endpoint -requires an API key (Settings → API Keys). - -**Steps:** -1. Uptime Kuma UI → Settings → API Keys → create key (any label, e.g. - "mavpoll"). -2. Add `-kuma http://:3001/metrics -kuma-key ` to the - `mavpoll` service command in `docker-compose.yml:78`. -3. `docker compose up -d mavpoll && docker logs mavpoll` — confirm a - `service_down=up (poll:uptimekuma)` line, not "no monitor_status - metrics." -4. Stop a monitored service, confirm `service_down=down` appears within - one poll interval (60s default). - -**Done when:** `docker logs mavpoll` shows `service_down=up` on a healthy -stack, and toggling a monitored service flips it to `down` within 60s. -The ServiceDownRule then fires a sev4 nudge through the normal dispatcher. - -### 1.2 Voice bind verification + stale comment fix - -**Status:** done (`eda434f`, 2026-07-06). Stale comment replaced with verified -note. `nc -z` confirmed mavweb reaches `mavend:9100` cross-container. - -**Vikunja:** #18 (prio 2) -**Est:** ~30 min -**Deps:** none - -**Status now:** `deploy/mavend.json:10` already has -`"bind": "0.0.0.0:9100"`. The config validator (`config.go:402-404`) -rejects `voice.enabled` with empty `bind` — so the config is correct. -BUT `docker-compose.yml:66-69` has a stale comment: "currently unset. -Until that's configured, voice-over-web is inert." The comment is wrong; -the config is right. The task is now: deploy, verify, fix the comment. - -**The gap:** unverified on the target host. The bind is set; whether -mavweb can actually reach `mavend:9100` cross-container is untested. - -**Steps:** -1. Fix the stale comment in `docker-compose.yml:66-69` — replace with: - `# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can` - `# reach it cross-container. Verified .` -2. `docker compose up -d && docker compose logs mavend` — confirm - `voice listening on 0.0.0.0:9100`. -3. From the mavweb container: `curl -sS telnet://mavend:9100` or open - the PWA and do a push-to-talk round trip. Confirm a reply comes back. -4. If voice fails cross-container: check `docker network ls`, confirm - both containers are on the same compose network (default bridge for - the project). The `sockets` volume is for IPC; voice is TCP. - -**Done when:** a PWA push-to-talk round trip works in the Docker deploy -(STT → router → TTS → reply audio plays), and the stale comment is -fixed. Update PROGRESS.md "Ops footnote" (line 284-286) to "verified." - -### 1.3 Deploy desk_active presence script on desk PC - -**Status:** not done. Script exists (`scripts/desk-active.sh`, complete) but -the systemd user timer + hypridle listener were not installed on the desk PC -(`linux`). This is an operator action on a different machine — the agent -couldn't reach it from the homesrv context. 0 facts ever written; `/dash` -presence still runs on `page_heartbeat` alone. - -**Vikunja:** #15 (prio 3) -**Est:** ~1 h -**Deps:** none (the script is complete; this is a workstation deploy) - -**Status now:** `scripts/desk-active.sh` is a complete one-shot poster -(25 lines). It POSTs to `mavweb /api/signal?key=desk_active` over wg. -`cmd/mavweb/main.go:36-40` allowlists `desk_active` → source -`infer:hyprland`. `internal/store/presence.go:45` gives desk_active the -strongest weight (0.90, τ=8min). The script's header (lines 12-18) -documents the exact hypridle + systemd timer wiring. Zero facts have -ever been written — the timer isn't installed on the desk PC. - -**The gap:** the systemd user timer + hypridle listener don't exist on -the workstation (linux, arch, hyprland). - -**Steps (on the desk PC, not homesrv):** -1. Copy `scripts/desk-active.sh` to `~/.local/bin/desk-active.sh`, - `chmod +x`. -2. Create `~/.config/systemd/user/maven-desk.service`: - ``` - [Unit] - Description=maven desk-active presence ping - [Service] - Type=oneshot - Environment=MAVEN_URL=https://maven.kvmx.ru:9443 - ExecStart=%h/.local/bin/desk-active.sh - ``` -3. Create `~/.config/systemd/user/maven-desk.timer`: - ``` - [Unit] - Description=maven desk-active presence (60s) - [Timer] - OnBootSec=10s - OnUnitActiveSec=60s - AccuracySec=5s - [Install] - WantedBy=timers.target - ``` -4. Add to `~/.config/hypridle.conf` (the script header lines 14-18 show - this exactly): - ``` - listener { - timeout = 120 - on-timeout = systemctl --user stop maven-desk.timer - on-resume = systemctl --user start maven-desk.timer - } - ``` -5. `systemctl --user daemon-reload && systemctl --user enable --now - maven-desk.timer`. Reload hypridle (or restart the session). -6. On homesrv: confirm `desk_active` facts appear — `mavweb /dash` - presence should flip from "away" to "present" within 60s of activity, - and back to "away" ~8min after going idle. - -**Done when:** `/dash` reads "present" while the desk PC is in use, and -"away" within ~8min of hypridle triggering (2min idle + 6min decay). -Presence is now 2 signals (desk_active + page_heartbeat) instead of 1. - ---- - -## P2 — Security (real code) - -### 2.1 Cold-start unlock (passkey → L3 key seam) - -**Status:** code done, **tests missing** (`b0932a1` + `15fe7bb`, 2026-07-06). -`internal/webauthn/keywrap.go` (HKDF-SHA256 + AES-256-GCM, stdlib-only), -locked-mode boot in `cmd/mavend/main.go` (`lockedAPI` stub, `srv.Check` -allowlist), `MethodStoreEncryptionKey`/`MethodUnlock` IPC, mavweb -`RegisterFinish` wraps + `AssertFinish` unlocks. Env-key fallback preserved. -**Gap:** the roadmap's done-when #4 required three new test cases -(wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC rejects -non-unlock methods) — none were written. `make test` is green by omission, -not coverage. Write these before relying on the cold-start path with real keys. - -**Vikunja:** #14 (prio 2) -**Est:** ~1 day -**Deps:** none (the seam is documented in code comments, just not wired) - -**Status now:** the at-rest AES key comes from `config.DBEncryptionKey()` -(`config.go:433`) which reads `db_key_env` (env var) or `db_key_b64` -(config). `cmd/mavend/main.go:76-85` calls this, then -`store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)`. The key lives -in the container env (`docker-compose.yml:28` `env_file: -[./deploy/db_key.env]`). The seam is documented in three places: -- `config.go:44-46`: "the passkey op produces the 32 bytes and calls - store.OpenEncrypted directly, bypassing config" -- `store/crypt.go:40-43`: "this same []byte seam is where the L3 - passkey-derived cold-start key plugs in later (auth/tier.go Layer3)" -- `auth/tier.go:42-44`: Layer3 = "core cold-start unlock" - -The passkey infrastructure exists: `internal/webauthn/` does real -WebAuthn (ES256, sign-count regression), `cmd/mavweb/webauthn.go` serves -enroll + assert, `PasskeySession.Assert` bumps to L3 for 5min -(`mavend/main.go:194-196`). But the assertion only flips the *session* -tier — it doesn't produce key material. The store is already open by the -time the session exists. - -**The gap:** the daemon opens the store at boot from env, before any -passkey assertion is possible. There's no path where a passkey gesture -*produces the 32 bytes* that `store.OpenEncrypted` consumes. The key is -in the container env, which means anyone with `docker inspect` or root -on homesrv can read it — the encryption protects against disk theft, not -against container compromise. - -**Design decision — how the passkey produces the key:** - -The cleanest path that fits the existing seams: - -1. **The key is wrapped, not derived.** A passkey assertion doesn't - produce 32 deterministic bytes (WebAuthn signatures are randomized). - Instead: at enrollment, generate a random 32-byte AES key, encrypt it - with a key derived from the passkey credential, store the wrapped blob - on disk. At cold-start, the passkey assertion unwraps it. - -2. **KDF:** HKDF-SHA256. Input: the credential public key bytes (stable - across assertions) + a salt stored alongside the wrapped blob. Output: - 32 bytes. This avoids the "sha256(passphrase)" trap `crypt.go:43` - warns about — the input is high-entropy key material, not a - passphrase. **New dep:** `golang.org/x/crypto/hkdf` (not currently in - go.mod — add it). Alternatively, implement HKDF-SHA256 from stdlib - (`crypto/hmac` + `crypto/sha256`) in ~30 lines to avoid the dep; the - spec doesn't care which, just don't use bare sha256. - -3. **Flow:** - - **Enroll** (`/auth/webauthn/register/finish`): after - `FinishRegistration` succeeds, generate random 32-byte AES key, - derive wrap-key via HKDF(credPublicKey, salt), AES-GCM-wrap the AES - key, write `{salt, wrapped}` to a file (e.g. - `~/.config/maven/db_key.wrapped`). The raw AES key is returned to - mavend in-process (not over the wire) and used to open the store. - - **Cold-start** (daemon boot): mavend starts *locked* — the store - isn't open, the IPC server serves only `MethodAssertStepUp` (or a - new `MethodUnlock`). mavweb's passkey assert calls the unlock RPC, - which derives the wrap-key from the credential, unwraps the blob, - and calls `store.OpenEncrypted`. The daemon then wires the rest - (loop, voice, delivery) and flips to "unlocked" mode. - - **Fallback:** `db_key_env` still works (dev/CI, or recovery if the - wrapped key is lost). The daemon tries wrapped-key unlock first; if - no wrapped file exists, falls back to env. This preserves the dev - path (no passkey enrolled = env key = plaintext-in-RAM as today). - -**Files:** -- `internal/webauthn/` — add `WrapKey(credPublicKey []byte) ([]byte, - error)` and `UnwrapKey(credPublicKey, blob []byte) ([]byte, error)`. - HKDF-SHA256, salt in the blob. -- `cmd/mavweb/webauthn.go` — in `RegisterFinish`, after - `h.store.Save(id, publicKey)`, call `webauthn.WrapKey(publicKey)`, - write the wrapped blob to a path (new flag `-wrapped-key-file`, default - `./db_key.wrapped`). -- `cmd/mavend/main.go` — restructure boot: if wrapped-key file exists, - start in locked mode (IPC serves only unlock); else fall back to env - key (current path). Add `MethodUnlock` to the IPC API - (`internal/ipc/`) that takes the unwrapped key and opens the store. -- `internal/ipc/` — new RPC `Unlock(key []byte) error` on the CoreAPI - interface (or a separate `UnlockAPI`). mavweb calls it after a - successful assert. -- `cmd/mavweb/main.go` — after `AssertFinish` succeeds, if a wrapped-key - file exists, read it, call `core.Unlock(unwrappedKey)`. -- `internal/store/crypt.go` — no change (the seam already takes - `[]byte`); the unlock path just calls `OpenEncrypted` with the - unwrapped key instead of `config.DBEncryptionKey()`. - -**Locked-mode behavior:** the daemon starts the IPC server and a minimal -"waiting for unlock" state. The voice server, loop, and delivery don't -start until unlock succeeds. mavweb serves `/auth/passkey` (so the user -can assert) but `/dash`, `/tools`, `/api/ptt` return 503 with a -"daemon locked" message. This is the one user-visible behavior change — -a cold homesrv now needs a passkey gesture before maven is live. - -**Done when:** -1. A fresh deploy with no `db_key.env` but an enrolled passkey: daemon - starts locked, mavweb `/auth/passkey` assert unlocks it, `/dash` - comes alive, the store opens with the unwrapped key. -2. A deploy with `db_key.env` set (dev/CI): daemon starts unlocked - (fallback path), no passkey needed. -3. Wrong passkey / deleted wrapped file + no env: daemon stays locked, - logs "unlock failed," doesn't crash. -4. `make test` green. New tests: wrap/unwrap round-trip, wrong-cred - unwrap fails, locked-mode IPC rejects non-unlock methods. - -**Security note:** this moves the key from "container env (root-readable)" -to "wrapped on disk, unwrappable only with a passkey gesture." Root on -homesrv can still dump the unwrapped key from mavend's RAM after unlock -— this is disk-theft protection, not RAM-capture protection (same threat -model as `crypt.go:33` documents). The win is: a stolen disk or a -`docker inspect` no longer yields the key. - ---- - -## P3 — "Voice assistant" dealbreakers (big effort, high impact) - -These three gaps define why maven is "a dictaphone with a brain" instead -of "something you talk to across the kitchen." Each is a multi-day -architecture item, not a config tweak. - -### 3.1 Always-on listening (wake word + ambient capture) - -**Status:** MVP (`e57647c` + `5d1850c`, 2026-07-06). `cmd/mavwaked/` (main + -vad + vad_test, 10 `-race` tests) ships as **energy-VAD only, no wake-word -model** — every utterance fires. `SurfaceVoice` (L0) caps it. 30ms/16kHz -frame shape matches silero-vad ONNX input 1:1, so the wake-word swap is a -local change in `vad.go`. Hardware topology settled: client box with mic -(desk PC / pi), not homesrv. mavwaked is a client binary (systemd user unit), -not a docker daemon — the agent first added it to Docker, then reverted -(`5d1850c`). Remaining: wake-word model (openWakeWord / silero-vad ONNX). - -**Est:** 1-2 weeks + hardware -**Deps:** a capture device (USB mic or a dedicated ESP32-S3 box) - -**Status now:** voice is push-to-talk in the PWA (`cmd/mavweb/` serves -the record button). The voice server (`internal/voice/server.go`) is a -TCP listener that waits for `PushToTalk` frames — the client decides -when to send audio. There's no wake-word detection, no ambient capture, -no always-on mic. `internal/router/stage0.go:24-28` has a wakeword -*grammar* (a "maven," prefix fast-path) but it's for the text-after-STT, -not for audio-level detection. `auth/tier.go:55-58` documents -`SurfaceVoice` as "a room mic / wake-word path" — the surface exists in -the auth model, the hardware path doesn't. - -**The gap:** no always-on audio capture, no wake-word model. Three -sub-problems: - -1. **Wake-word detection** — a small model that runs continuously on a - mic stream and fires when it hears "maven" (or a chosen phrase). -2. **Ambient capture** — after the wake word, capture N seconds of audio - and send it as a `PushToTalk` frame (the existing path). -3. **Hardware** — a mic that's always on. Options: (a) a USB mic on - homesrv, (b) an ESP32-S3 with I2S mic that streams over wg, (c) a - dedicated Pi. (a) is simplest; (b) is the "room device" the spec - wants. - -**Design:** - -- **Wake-word engine:** openWakeWord (Python, ONNX, ~10MB models) or - Porcupine (Picovoice, free tier, binary). openWakeWord fits the - self-hosted/no-phone-home invariant better. Run it as a new module - `cmd/mavwaked/` (mirrors `mavsttd`/`mavttsd` shape): reads audio from - a device, runs the wake model, on detection sends a trigger to mavend. -- **New module `cmd/mavwaked/`:** - - Flag `-device` (alsa device, e.g. `hw:1,0`), `-model` (ONNX wake - model path), `-core` (mavend IPC socket) or `-voice` (voice TCP - addr). - - Reads 16kHz mono PCM from the device (PortAudio or - `malgo`/miniaudio — CGo, like mavsttd). - - Runs the wake model on a sliding window; on detection, captures - ~5s of audio (configurable) and sends it as a `PushToTalk` frame to - the voice server. - - The voice server's existing `HandlePushToTalk` does the rest (STT → - router → TTS → reply). The reply goes... where? This is the open - question — see below. -- **The reply problem:** push-to-talk replies go back to the PWA that - sent the request. An ambient wake-word path has no PWA. Options: - (a) play the reply through a speaker on the capture device (the - ESP32/USB-mic box needs a speaker), (b) send the reply to the PWA if - a session is live (fallback to ntfy if not). (a) is the "room device" - path; (b) is the "homesrv with a speaker" path. Pick based on - hardware. -- **Auth surface:** `SurfaceVoice` (L0) is already in the auth model. - The wake-word path uses it — the voice server's `serveConn` - (`server.go:128-134`) has a TODO for the auth handshake populating the - surface; today it defaults to `SurfacePCClient`. The mavwaked module - would set `Surface=voice` in its `PushToTalkReq`, capping it at L0 - (no destructive acts, no registration — exactly the spec's invariant). - -**Files:** -- `cmd/mavwaked/` — new module (main.go + audio capture + wake model). -- `internal/voice/wire.go` — confirm `PushToTalkReq.Surface` is settable - to `voice` (it is — `server.go:184-186` reads `req.Surface`). -- `internal/voice/server.go:128-134` — replace the floor - `SurfacePCClient` default with surface-from-handshake (or from the - req field, which already wins). -- `docker-compose.yml` — add `mavwaked` service with `/dev/snd` device - mapping. -- `deploy/mavend.json` — no change (voice server already binds - 0.0.0.0:9100). - -**Done when:** saying "maven, ..." across the room (no button press) -triggers a capture → STT → router → TTS → reply, with the reply -audible on the capture device's speaker (or the PWA if one's live). The -auth surface is `voice` (L0) — destructive acts are refused. `make -test` green (the new module needs unit tests for the wake-detection -logic, mocked audio input). - -**Open question for the operator:** pick the hardware before starting. -A USB mic on homesrv is the fast path; an ESP32-S3 room device is the -"real" version. The code is the same either way (mavwaked reads a -device); the hardware changes the deploy. - -### 3.2 Conversation depth (multi-turn dialogue) - -**Status:** done (`05236ad`, 2026-07-06). Path 1 (rule-based deepening) -implemented: `AnaphoraResolver` in `router/slots.go` (RU pronouns: -это/он/она/оно/тот/мой + inflections), `followUpMerge` extended for -cross-intent (Query/Fact/Reminder after Fact with anaphora inherits key + -time), `Session.History []Turn` added, fact-by-key lookup in `applyAction`. -7 new test cases including the exact done-when scenarios. Path 2 (LLM -dialogue manager) remains future — the sub-1B phraser can't drive it. - -**Est:** 3-5 days -**Deps:** none (the dialogue scaffold is wired; this deepens it) - -**Status now:** `internal/dialogue/` has `Session` + `SessionStore` + -`InheritSlots` (pure). `cmd/mavend/voice.go:339-349` wires it: a 2-min -session carries slots across same-intent turns -(`followUpMerge` in `cmd/mavend/followup.go`). So «напомни завтра» → -«…позвонить маме» works — the second turn inherits the time slot. But: -- Only **same-intent** turns carry (a different intent is a fresh - session — `followup.go:41`). -- No **anaphora resolution** — "она" / "он" / "это" don't refer back to - prior entities. -- No **LLM-driven dialogue** — the sub-1B phraser (`llmphraser.go`) - only words replies; it doesn't decide what to ask next. -- The session is **single-slot** (one `voiceDialogueID` — single-user - box, `voice.go:264-266`). - -**The gap:** real multi-turn needs (a) anaphora resolution, (b) the -router or a dialogue manager deciding "I need to ask for X" vs "I have -enough to act," (c) cross-intent context. The current `followUpMerge` -is bounded gap-filling, not dialogue. - -**Design:** - -This is the item where the sub-1B phraser isn't enough. Two paths: - -1. **Rule-based deepening (fast, limited):** extend `followUpMerge` to - handle cross-intent slot inheritance for common patterns (e.g. - `IntentQuery` after `IntentFact` — "я пил воду?" after "запиши что я - пил воду"). Add anaphora resolution for pronouns that reference the - prior turn's key entity. This is more `followup.go` logic, no LLM. - Covers maybe 60% of real follow-ups. - -2. **LLM dialogue manager (slow, general):** add a dialogue turn where - the phraser gets the conversation history and decides: act, ask-for- - clarification, or ask-for-missing-slot. This needs a bigger model - than the 1.2B phraser (or a dedicated dialogue prompt) and a - conversation-history buffer in the `Session`. The `Session` struct - (`internal/dialogue/`) would grow a `History []Turn` field. - -**Recommended path:** start with (1) — it's testable, deterministic, and -covers the common cases. (2) is a "when the phraser model is upgraded" -item. - -**Files (path 1):** -- `cmd/mavend/followup.go` — extend `followUpMerge` to handle - cross-intent patterns. Add anaphora resolution (a pronoun → prior - `Slots.Key` mapping). -- `internal/dialogue/session.go` — add `History []Turn` to `Session` - (even if path 1 doesn't use it yet, the field should exist for path - 2). -- `cmd/mavend/followup_test.go` — new cases: cross-intent inheritance, - anaphora resolution. -- `internal/router/slots.go` — pronoun detection in the slot extractor - (она/он/это/тот/та → reference marker). - -**Done when:** a two-turn exchange like «запиши что я пил воду» → «когда -я это сделал?» answers from the fact just recorded (cross-intent, -anaphora "это" → "пил воду"). A three-turn exchange that should *not* -carry context («запиши что я пил воду» → «какая погода в москве?» → -«когда я пил воду?») correctly treats the middle turn as a break. `make -test` green with the new cases. - -### 3.3 Latency / streaming - -**Status:** not started. Correctly deferred — the roadmap itself flagged this -as "most likely to be deferred" and lowest-ROI of the dealbreakers. - -**Est:** 1-2 weeks -**Deps:** none (architecture rework) - -**Status now:** every voice exchange is a full round trip: record full -clip → upload → whisper (batch) → route → phrase (batch) → piper (batch) -→ play. No streaming either direction. No barge-in (you can't interrupt -maven mid-reply). `internal/voice/server.go` reads one `PushToTalk` frame -(one audio blob) and returns one `PushToTalkResp` (one reply blob). The -wire protocol (`internal/voice/wire.go`) is request/response, not -streaming. - -**The gap:** three sub-problems: - -1. **Streaming STT** — whisper.cpp supports streaming (partial - transcription as audio arrives). `cmd/mavsttd` would need a streaming - mode (send partial results, not one final blob). -2. **Streaming TTS** — piper can synthesize in chunks. `cmd/mavttsd` - would stream audio back as it's generated, not one blob. -3. **Barge-in** — the client needs to signal "stop talking, I'm talking - now" mid-reply. The wire protocol needs a new method (e.g. - `MethodBargeIn`) or a cancel on the stream. - -**Design:** - -This is the biggest architecture item. The wire protocol changes from -request/response to bidirectional streaming. Two options: - -1. **WebSocket voice** — replace the TCP length-prefixed protocol with - WebSocket frames. `coder/websocket` is already a dep (mavweb uses it - for ntfy). The voice server gets a `ws.Serve` path; the PWA gets a - `WebSocket` client. Streaming STT/TTS ride the same ws. Barge-in is - a control frame. -2. **Keep TCP, add streaming frames** — extend the length-prefixed - protocol with `MethodStreamAudio` (client → server, chunked) and - `MethodStreamReply` (server → client, chunked). More work, same - result. - -**Recommended:** (1) WebSocket — it's the standard, the dep is present, -and the PWA already speaks ws (for ntfy). The TCP path stays for -non-browser clients (the protocol doc `PROTOCOL.md` would note both). - -**Files:** -- `internal/voice/wire.go` — new streaming methods + frame types. -- `internal/voice/server.go` — WebSocket accept path, streaming - handler. -- `internal/voice/client.go` — WebSocket client. -- `cmd/mavsttd/` — streaming transcribe mode (partial results). -- `cmd/mavttsd/` — streaming synthesize mode (chunked audio). -- `cmd/mavweb/main.go` — PWA ws client for voice (replaces the current - fetch-based `/api/ptt`). -- `PROTOCOL.md` — regenerate from the new `wire.go`. - -**Done when:** a push-to-talk exchange shows partial transcription -within ~500ms of starting to speak (not after the full clip uploads), -and the reply starts playing before the full TTS is generated. Barge-in -(mid-reply speak) stops the TTS and starts a new turn. `make test` -green. The old TCP path still works for non-browser clients (backward -compat). - -**Note:** this is the item most likely to be deferred — it's a -quality-of-experience improvement, not a capability gap. The -dealbreaker is always-on listening (3.1); streaming makes it feel -better but doesn't change what maven *is*. - ---- - -## P4 — Capability depth (built but thin) - -### 4.1 Routing quality (dev embedder) - -**Status:** done (`b7eb53a`, 2026-07-06). `make download-embedder` fetches -Xenova/paraphrase-multilingual-MiniLM-L12-v2 (~90MB ONNX) + tokenizer. -AGENTS.md documents embedder + libonnxruntime setup. `queryMinScore` is now -configurable (`voice.query_min_score`, default 0.55) instead of a hardcoded -const. - -**Est:** 2-4 h -**Deps:** none - -**Status now:** `deploy/mavend.json:14-18` configures the ONNX embedder -(production). `cmd/mavend/voice.go:150-165` loads it when configured, -falls back to `HashEmbedder` (1024-dim, rune-based token overlap) when -not. The dev/preview path (AGENTS.md preview instructions) runs without -the embedder → weak RU recall → many commands fall to "clarify." The -`queryMinScore` gate (`voice.go:382`) is 0.55, tuned for ONNX; the Hash -floor rarely clears it. - -**The gap:** no dev embedder model is documented or shipped. A developer -running the preview has to either (a) download the ONNX model manually, -or (b) accept weak routing. - -**Steps:** -1. Document the ONNX embedder model download in `AGENTS.md` (or a new - `MODELS.md`): which model (multilingual sentence embedder), where to - put it (`models/embedder/model.onnx` + `tokenizer.json`), where to - get `libonnxruntime.so`. -2. Add a `make download-embedder` target that fetches the model (curl - from a pinned URL — HuggingFace, sha256-checked). -3. Optionally: lower `queryMinScore` for the Hash floor (a config knob, - not a code change — `voice.router_threshold` exists, but - `queryMinScore` is a const at `voice.go:382`). Make it configurable: - add `voice.query_min_score` to `VoiceConfig`, default 0.55. - -**Done when:** a developer running the AGENTS.md preview with the -downloaded embedder gets confident RU routing (most commands route -correctly, not to "clarify"). `make test` green. - -### 4.2 Act surface broadening - -**Status:** not a code item — operator config. The seeded homelab set -(status/ps/uptime/df/free/logs read-only, restart/stop/reboot gated) ships -in `deploy/mavend.json`. Broadening to home-automation/media/comms is -editing JSON, not code. - -**Est:** ongoing config -**Deps:** none - -**Status now:** `deploy/mavend.json:20-33` seeds 11 tools (6 read-only, -5 destructive). `internal/tool/tool.go` runs them (argv, no shell). -`voice.go:174-176` seeds them at boot. The allowlist is config-driven — -broadening is editing `mavend.json`, not code. - -**The gap:** the seeded set is homelab-focused. Broadening to -home-automation (lights, thermostat), media (play music), or -communication (send message) is config + new tool entries. - -**This is not a code item** — it's operator config. The only code -change that might help: a `mavweb /tools` UI for adding tools without -editing JSON (the page exists, but it enables *proposed* tools; adding a -new one from scratch is JSON-only). Low priority. - -**Done when:** (operator-defined) — e.g. "lights on/off" works by voice -after adding the tool to `mavend.json` and the act seed file. - -### 4.3 LTM ANN (approximate nearest neighbor) - -**Status:** deferred (correctly). The `memory.Store` interface is the swap -point; brute-force cosine is sub-ms at single-user scale. Not started until -note+fact count exceeds ~10k and `Search` latency shows up in profiles. - -**Est:** ~1 day -**Deps:** none (the interface is the swap point) - -**Status now:** `internal/memory/store.go` defines `Store` interface -(`Insert`, `Search`). `internal/store/memory.go` implements it with -brute-force cosine (full scan, `Search` loads every row). The comment -at `memory.go:24-28` says "an ANN index is the swap for later, behind -this same interface." At single-user scale (thousands of rows) a full -scan is sub-millisecond. - -**The gap:** none *yet*. This is a "when it bites" item. The swap point -is the `memory.Store` interface — a new implementation (e.g. -`internal/memory/ann.go` using hnswlib or a sqlite-vec extension) drops -in without touching `voice.go` or `recall.go`. - -**When to do this:** when note+fact count exceeds ~10k and `Search` -latency shows up in profiles. Not now. - -**Done when:** (future) a new `memory.Store` impl with ANN search -passes the existing `memory_test.go` suite and shows <1ms latency at -10k+ vectors. Not started until the scale problem is real. - -### 4.4 Persona prompt - -**Status:** done (`b7eb53a`, 2026-07-06). `Persona` field in `VoiceConfig`, -`llmphraser` prepends to `systemPrompt()` + `querySystemPrompt()`. Empty = -current hardcoded feminine-gendered Russian persona (backward compat). - -**Est:** 2-4 h -**Deps:** none - -**Status now:** the phraser has hardcoded system prompts: -- `llmphraser.go:188` — notes query: "You are maven, a self-hosted - personal assistant answering from your notes..." -- `llmphraser.go:298-299` — nudge: "You are maven, a self-hosted - personal assistant. Generate brief, natural nudge messages..." -- `router.KnowledgePrompt()` — general knowledge (the deduped single - source). - -The persona ("feminine-gendered Russian self-reference, she/her") is -baked into these strings, not configurable. `internal/voice/replier.go` -documents a "personality-prompted nudge tone" vs "chat tone" but the -prompts are inline. - -**The gap:** no configurable persona. Changing maven's character means -editing Go strings and recompiling. - -**Design:** -- Add `voice.persona` to `VoiceConfig` (`config.go`) — a string (or path - to a file) holding the persona prompt prefix. -- `llmphraser.go` reads it (passed via `Config` or a new field) and - prepends to every system prompt. Default = the current hardcoded - string (backward compat). -- The three prompt sites (notes, nudge, knowledge) all call a - `personaPrompt(cfg, base)` helper that concatenates. - -**Files:** -- `internal/config/config.go` — add `Persona string` to `VoiceConfig`. -- `internal/phraser/llmphraser.go` — accept persona in `Config`, prepend - to system prompts. -- `cmd/mavend/voice.go` — pass `cfg.Voice.Persona` into the phraser - config. -- `deploy/mavend.json` — document the field (empty = current behavior). - -**Done when:** setting `voice.persona` in `mavend.json` changes maven's -reply character (e.g. more formal, different gender, different name) -without recompiling. Empty = current behavior. `make test` green. - -### 4.5 Custom TTS voice - -**Status:** not started. Mostly operator work (record ~50-100 clips, train a -piper model). The code already supports it — `-model` flag takes any piper -voice file, `VoiceConfig.Tts.Voice` names it. - -**Est:** ~1 day + training time -**Deps:** none (piper supports custom voices) - -**Status now:** `cmd/mavttsd/main.go:7` documents the default voice: -`models/tts/ru_RU-irina-medium.onnx`. `docker-compose.yml:57` mounts it. -The `-model` flag takes any piper voice file. `VoiceConfig.Tts.Voice` -(`config.go:281`) allows naming a voice when the worker supports -multiple. - -**The gap:** the voice is the stock irina model. A kami-picked voice -(specific person, specific tone) needs a piper fine-tune: record ~50-100 -clips of the target voice, train a piper model, drop the `.onnx` file -into `models/tts/`. - -**Steps:** -1. Record or source ~50-100 clean clips of the target voice (16kHz - mono, ~5-10s each, varied sentences). -2. Train a piper voice (`piper train` — see piper docs for the dataset - format + training script). -3. Output: `ru_RU--medium.onnx` → `models/tts/`. -4. Update `deploy/mavend.json:13` `tts.voice` or the `mavttsd -model` - flag in `docker-compose.yml:57`. - -**This is mostly operator work** (recording + training), not maven code. -The code already supports it — it's a model-file swap. - -**Done when:** maven's replies use the custom voice. `make test` green -(tests use the Stub TTS, unaffected). - ---- - -## P5 — Deferred by design - -### 5.1 Multi-user (SPEC item 8) - -**Status:** deferred by design. SPEC fences this explicitly (`DO NOT TOUCH -THIS PHASE`). No second user exists. The append-only schema makes it a -migration (add `user_id` columns + backfill), not a rewrite. Speaker -attribution needs the second voice to train against. - -**When to revisit:** when a second person is actually in the house and -using maven. Not before. - -**Do not start this** without an explicit operator decision. An -autonomous agent that adds `user_id` columns while touching the store -commits the project to a schema before the constraints that shape it -exist. - ---- - -## Summary table - -| # | Item | Prio | Est | Type | Deps | Status | -|---|------|------|-----|------|------|--------| -| 1.1 | Kuma API key | P1 | 5m | ops | — | done `eda434f` | -| 1.2 | Voice bind verify + comment fix | P1 | 30m | ops | — | done `eda434f` | -| 1.3 | desk_active deploy | P1 | 1h | ops | — | not done (operator action on `linux`) | -| 2.1 | Cold-start unlock | P2 | 1d | code | — | code done `b0932a1`+`15fe7bb`, **tests missing** | -| 3.1 | Always-on listening | P3 | 1-2w | code+hw | hardware decision | MVP `e57647c` (VAD only, no wake word) | -| 3.2 | Conversation depth | P3 | 3-5d | code | — | done `05236ad` | -| 3.3 | Latency/streaming | P3 | 1-2w | code | — | not started (deferred) | -| 4.1 | Routing quality (dev embedder) | P4 | 2-4h | code+docs | — | done `b7eb53a` | -| 4.2 | Act surface | P4 | ongoing | config | — | not a code item (config) | -| 4.3 | LTM ANN | P4 | 1d | code | scale problem | deferred (scale) | -| 4.4 | Persona prompt | P4 | 2-4h | code | — | done `b7eb53a` | -| 4.5 | Custom TTS voice | P4 | 1d+train | ops | — | not started (ops) | -| 5.1 | Multi-user | P5 | deferred | — | second user | deferred by design | - -**Remaining work (in priority order):** -1. **2.1 tests** — write the 3 missing keywrap/locked-mode test cases (~30 min) -2. **1.3 desk_active** — install systemd timer + hypridle on `linux` (~1h, your hands) -3. **3.1 wake word** — swap energy-VAD for silero-vad/openWakeWord ONNX in `vad.go` -4. **3.3 streaming** — lowest ROI, defer until 3.1 is real -5. **4.5 custom voice** — when recording is done diff --git a/SESSION-05-07-2026.md b/SESSION-05-07-2026.md deleted file mode 100644 index 822e85a..0000000 --- a/SESSION-05-07-2026.md +++ /dev/null @@ -1,130 +0,0 @@ -# Overnight Session — 2026-07-05 - -Branch: `overnight-jul5` (from `infra/spine-migrations-encryption-mavweb`) -Architect: `.opencode/agent/architect.md` - ---- - -## Phase 0 — Setup - -- [x] Branch `overnight-jul5` created from `infra/spine-migrations-encryption-mavweb` -- [x] `.opencode/agent/architect.md` — orchestrator agent -- [x] `SESSION-05-07-2026.md` — this file - ---- - -## Phase 1 — Housekeeping - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 2 | **Makefile hygiene** — `-race -coverprofile` in `make test`, add `build-caldav` target | b9248ef | done | -| 3 | **db_key.env hygiene** — remove live key from repo, add to `.gitignore`, keep `.env.example` | already-done | done | -| 4 | **Passkey persistence** — store credentials on disk (JSON file) instead of in-memory map | 44807b6 | done | - -Notes: -- Live AES-256 key in `deploy/db_key.env` must be removed from git history. Rotate after. - ---- - -## Phase 2 — Test Coverage Gaps - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 5 | **mavcaldav tests** — `cmd/mavcaldav/` (314 lines, 0 coverage). CalDAV polling, iCal parsing, value-change filtering | 6daa96b | done | -| 6 | **mavttsd tests** — `cmd/mavttsd/` (Piper handler). TTS worker protocol round-trip | a80b919 | done | -| 7 | **voicesink tests** — `internal/delivery/voicesink/`. Voice channel dispatch, ErrNoSession mapping | ffef44f | done | -| 8 | **mavweb tests** — extend to cover `main.go` routes (server setup, template parsing, route registration, startup flags) | 185f4f5 | done | - -Notes: -- mavweb `handlers_test.go` exists (14 cases). The gap is the non-handler code in `main.go` (549 lines) and `webauthn.go` (213 lines). -- voicesink has 0 tests but is critical — it's the bridge between delivery routing and voice sessions. - ---- - -## Phase 3 — Easy Features (from feature ranking) - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 9 | **Command history** — read-only query over existing facts. New endpoint or /dash section | f8ba396 | done | -| 10 | **Revert / undo** — void the latest row for a fact key. Wrapper around existing append-only void mechanism | a02e10f | done | -| 11 | **Stale-reminder burst collapse** — digest overdue reminders on boot instead of firing all at once. Cosmetic: group into a single notification | ca081ce | done | - -Notes: -- Command history: the data is already there (`RecentFacts`). This is exposing it better. -- Revert: `internal/store` already supports voiding (`VoidFact`). This is a thin API + confirm gate. -- Burst collapse: the `DueReminders` query in `internal/store/reminders.go` already returns all pending due reminders. The fix is in `internal/loop/gather.go` to collapse them. - ---- - -## Phase 4 — Doable Features - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 12 | **Recurring reminders** — cron+next_fire_ts cols, RescheduleReminder, dispatcher logic | 1eca17f | done | -| 13 | **Capability model** — `scope` column on tools table, migration, UI, tests | 6b80fd0 | done | -| 14 | **Notification batching/digest mode** — in-memory queue, configurable window/max_items/severity_ceiling | 354990f | done | -| 15 | **Rule trace/explanation engine** — ExplainTick/ExplainGate, TickTrace IPC, daemon cache | 2689db1 | done | -| 16 | **Backup/restore automation** — scripts/maven-backup.sh with backup/restore/verify/list | 3f09cdb | done | - -Notes: -- Recurring reminders: needs schema migration (#1). Add `cron TEXT` and `next_fire_ts INTEGER` to reminders table. -- Capability model: ranking says "cheap now, expensive to retrofit once tools surface passes ~15 entries." -- Rule trace: the data is ephemeral — can log predicate results per tick to a ring buffer or a `rule_evals` table. - ---- - -## Phase 5 — Web UI Re-imagination - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 17 | **In-process auth gate for /tools** — local PasskeySession check on POST, returns 403 if unasserted | 5afff00 | done | -| 18 | **Digest / notification history UI** — /notifications page with recent nudge history | 5c34fb1 | done | -| 19 | **Rule trace page** — /trace route showing predicate eval results per rule per tick | 85013f7 | done | -| 20 | **Command history page** — new `/history` route showing recent facts/commands | f8ba396 | done | -| 21 | **PWA icons** — SVG icon + manifest.json icons array | 00a3bba | done | -| 22 | **Language unification** — bilingual cheatsheet with RU/EN toggle in nav + ?lang= param | c225ba3 | done | - -Notes: -- In-process auth gate added for POST /tools (5afff00). Digest UI (#18) depends on #14; trace page (#19) depends on #15. -- PWA manifest currently has `"icons": []` — no icons, mobile add-to-home-screen shows a blank tile. - ---- - -## Phase 6 — Final Pass - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 23 | Run `make test` with race detector — fix any races | ccb1d78 | done | -| 24 | `docker compose build` — verify all six daemons compile | ccb1d78 | done | -| 25 | Final review pass — check for debugging artifacts, commented code, TODO stubs | ccb1d78 | done | - ---- - -## Cross-reference: feature ranking → this session - -| Ranking item | Session task | -|---|---| -| Infra #2 (mavweb/mavcaldav tests) | #5, #8 | -| Easy: command history | #9 | -| Easy: revert/undo | #10 | -| Easy: capability model | #13 | -| Easy: stale-reminder collapse | #11 | -| Doable: recurring reminders | #12 | -| Doable: passkey persistence | #4 | -| Doable: backup/restore | #16 | -| Doable: testing infra | #2 | -| Doable: rule trace/explanation | #15 | -| Doable: notification batching/digest | #14 | - ---- - -## Caveats & Gotchas - -- **Destroy-confirm policy** is listed as "mandatory" in ranking but already shipped in tool executor (PROGRESS.md confirms). No action needed. -- **Quiet-hours definition** is listed as "mandatory" but already shipped (`3623305`). No action needed. -- **At-rest encryption** is listed as infra #1 in ranking but already done (`047a813`). No action needed. -- **Schema migrations** — `migrations.go` exists and is wired. Any new schema change (recurring reminders) uses it. -- **Docker** is already deployed and build-tested (commits `04c8dd1`–`7683a9b`). The "not build-tested" comment in Dockerfile is stale. -- **Systemd** is explicitly replaced by Docker per commit `04c8dd1`: "Chosen Docker over interim systemd units." -- Race detector may surface pre-existing races in the IPC or worker packages — fix them but don't scope-creep into a full refactor. -- Passkey persistence has a chicken-and-egg problem with cold-start unlock (needs passkey to unlock, passkey needs re-enroll after restart). Fix just the mavweb credential storage; the cold-start unlock flow is a separate feature. diff --git a/SESSION-06-07-2026.md b/SESSION-06-07-2026.md deleted file mode 100644 index d223012..0000000 --- a/SESSION-06-07-2026.md +++ /dev/null @@ -1,347 +0,0 @@ -# Overnight Session — 2026-07-06 - -Branch: `overnight-jul6` (from `master`) -Executor: a single unsupervised agent working through the night. - ---- - -## READ THIS FIRST — Operating rules (do not skip) - -You are working **unsupervised**. Optimize for *not breaking anything* over -finishing every task. A half-finished task that compiles and is committed is a -success; a clever half-rewrite that breaks the build is a failure. - -**Hard rules:** - -1. **One task = one commit.** Never batch two tasks into one commit. Commit - message: `maven: (task N)`. Sign-off line required (see repo - convention — Co-Authored-By trailer). -2. **TDD, always.** For every task that touches Go: write the test first, watch - it fail, then write code until it passes. Tests live next to the code as - `*_test.go`. Copy the style of the nearest existing test file. -3. **After every task, run the gate before committing:** - ``` - gofmt -l . # must print nothing - go build ./... # must succeed - go vet ./... # must be clean - go test ./... # must be green - ``` - If any step fails and you cannot fix it in ~15 min, **`git stash` or revert - that task, write a note in the task's Status cell ("BLOCKED: "), and - move to the next task.** Do not leave a broken tree. -4. **Never invent config keys, function names, or file paths.** Every new thing - copies an existing pattern named in the task. If you can't find the pattern, - mark the task BLOCKED and skip it. -5. **Tools/acts: never add a tool without `"destructive": true` unless it is - provably read-only** (see Task 2). A destructive act that runs from voice - without a confirm gate is the worst possible bug. When unsure → destructive. -6. **Do NOT attempt the "DEFERRED — needs human" section at the bottom.** Those - need hardware or protocol decisions. Touching them unsupervised will waste - the night. They are listed only so you don't rediscover them. -7. Prefer additive changes. Do not refactor existing packages. Do not touch - `cmd/mavweb/`, encryption, or the store schema unless a task says to. - -**Work top-to-bottom.** Tasks are ordered by value-per-risk: safest and most -self-contained first. If you run out of night, the earlier tasks are the ones -that matter. - ---- - -## Key facts about the codebase (so you don't have to rediscover them) - -- **Router cascade**: `internal/router/`. Intents are the constants in - `intent.go` (`act, reminder, fact, note, query, system`). Adding an intent = - add a const there + seed examples + a handler case. -- **Intent seeds**: `models/seeds/.txt`, one example per line, `#` - comments allowed. Loaded by `seedClassifier` in `cmd/mavend/voice.go`. To - teach the classifier a new phrase, add a line to the right seed file — no code - change needed. -- **Voice intent dispatch**: `cmd/mavend/voice.go`, the big `switch dec.Intent` - (search `case router.IntentQuery:` ~line 412). Each intent returns a Russian - reply string. This is where a new intent's behaviour hangs. -- **Tools/acts**: enabled allowlist lives in config `voice.tools` (see - `internal/config/config.go` `ToolConfig`). Executor: `internal/tool/tool.go`. - Args are argv, never shell. Destructive tools return `ErrNeedsConfirm`. -- **Config**: `internal/config/config.go`. Seed/prod config: `deploy/mavend.json`. -- **Store** (facts, notes, reminders, tools): `internal/store/`. CalDAV events - are written as facts with `source=caldav` plus a `calendar_busy` key (per the - poller in `mavpoll`/`mavcaldav`). -- **Embedder**: `voice.embedder` config → ONNX; nil → `router.NewHashEmbedder` - floor. Wiring is in `cmd/mavend/voice.go` ~line 144. -- **Language is Russian.** Maven refers to herself in the **feminine**. All - user-facing reply strings are RU. Copy tone from existing replies. - ---- - -## Phase 0 — Setup (do this once, first) - -- [ ] `git checkout master && git pull` (if remote), then - `git checkout -b overnight-jul6` -- [ ] Run the full gate (`go build ./... && go vet ./... && go test ./...`) on a - clean tree to confirm a green baseline **before** you change anything. If - baseline is red, STOP and record it here — do not build on a broken tree. - ---- - -## Task 1 — Embedder config validation + docs (safest, do first) - -**Goal:** make embedder misconfiguration fail loudly instead of silently -falling back to the Hash floor. - -**Files:** `internal/config/config.go` (Validate path), its `*_test.go`, -`deploy/mavend.json`, and a short note in `START.md` or `PROGRESS.md`. - -**Do:** -1. Find where `VoiceConfig` / `EmbedderConfig` is validated (look for a - `Validate()` method or the load path in `config.go`). Add a check: if - `Embedder` is non-nil, then **all three** of `ModelPath`, `TokenizerPath`, - `LibPath` must be non-empty — a partially-filled embedder block is a config - error (`return fmt.Errorf(...)`). If `Embedder` is nil, that's fine (Hash - floor) — no error. -2. In `cmd/mavend/voice.go` around the embedder wiring (~line 144–159), make the - "falling back to HashEmbedder" path an explicit `log.Printf("voice: embedder - not configured, using HashEmbedder floor")` if it isn't already. -3. Add a test to `config_test.go` covering: all-three-set → ok; one-missing → - error; nil → ok. -4. Document the `voice.embedder` block (all three paths, and "omit the block to - use the floor") in `START.md` near other config docs. - -**Done when:** new test passes, gate green, docs updated. One commit. - ---- - -## Task 2 — Seed the tool allowlist with safe homelab acts - -**Goal:** give the voice `act` path a useful, SAFE starter allowlist. - -**Files:** `deploy/mavend.json` (`voice.tools`), and `models/seeds/act.txt`. - -**Do:** -1. Add tools to `voice.tools` in `deploy/mavend.json`. Each: `name`, `cmd` - (argv prefix), `scope`, `destructive`. Classify carefully: - - **Read-only (destructive: false)** — safe to fire from voice: - `systemctl status`, `docker ps`, `uptime`, `df`, `free`, journal *reads* - (`journalctl -n 50 -u ` — note the unit comes as an arg). - - **Destructive: true** — must confirm: `systemctl restart`, `systemctl stop`, - `docker restart`, `reboot`, `docker stop`. - - When unsure → `destructive: true`. -2. Add matching spoken RU phrasings to `models/seeds/act.txt` (e.g. «покажи - статус nginx», «перезапусти nginx», «сколько места на диске») so the - classifier routes them to `act`. One per line. -3. There is **no Go change** here if the executor already reads `voice.tools`. - Verify by reading the wiring — if tools are loaded from config into the store - allowlist at boot, you're done. If not, mark BLOCKED (don't build new wiring). - -**Done when:** `go test ./...` still green (config parses), the JSON is valid -(`go run` the daemon far enough to parse, or a small config-load test). One commit. - -**Guardrail:** double-check no `restart`/`stop`/`reboot`/`rm`/`kill` entry has -`destructive: false`. This is the single most important check of the night. - ---- - -## Task 3 — Calendar event querying ("что у меня завтра?") - -**Goal:** answer calendar questions from CalDAV facts already in the store. - -**Files:** `internal/router/` (slots + a `query` sub-path, or reuse `IntentQuery` -with a calendar slot), `cmd/mavend/voice.go` (handler), `models/seeds/query.txt`, -and tests. - -**Approach (keep it simple — don't add a new intent if you can avoid it):** -1. The data is already there: CalDAV events are facts with `source=caldav`. Find - the store method that reads facts by source/date (grep `caldav` in - `internal/store/`). If none scopes by date, add a small read helper - `CalendarEvents(ctx, from, to time.Time)` next to the existing facts queries — - copy the style of an existing `store/facts.go` query, with a test. -2. Add date-scope parsing: «сегодня» → today, «завтра» → tomorrow. Put this in a - small helper in `internal/router/slots.go` (copy the RU parsing style in - `slots_ru_test.go`). Test it directly. -3. In the `IntentQuery` handler in `voice.go`, detect a calendar question (the - utterance mentions планы/календарь/завтра/сегодня + no note match, OR a - dedicated keyword check *before* the notes RAG lookup). Read events for the - scoped day, format an RU reply: empty → «на сегодня ничего нет», one/many → - list them. Keep formatting in a tested pure helper. -4. Seed `models/seeds/query.txt` with the example phrasings. - -**Done when:** helper tests + a handler-level test pass, gate green. One commit -(or two: store helper, then handler — that's fine, keep them separate). - -**If store scoping turns out hard:** ship just the date parser + formatter as -pure tested helpers and wire them to a naive "read all caldav facts, filter in -Go" — personal scale, correctness over efficiency. Do not add schema. - ---- - -## Task 4 — General-knowledge routing to the phraser - -**Goal:** route open factual questions to the phraser with an anti-hallucination -system prompt and a fallback. - -**Files:** `cmd/mavend/voice.go` (query handler), phraser call site (grep -`phraser` / `Phrase` in voice.go and `internal/phraser/`), `models/seeds/query.txt`, -tests. - -**Do:** -1. In the `IntentQuery` handler, **after** the notes-RAG lookup fails to clear - `queryMinScore` (currently returns «у меня нет заметок…»), instead of giving - up, hand the question to the phraser with a system prompt like: «Ответь кратко - из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай.» (feminine - self-reference). -2. **Fallback gate:** if the phraser returns empty, errors, or the phraser is the - Stub (not configured), return «не знаю» / the existing no-answer reply. Never - fabricate. -3. Keep the prompt construction in a small pure function so you can unit-test it - (assert the system prompt text + that empty phraser output → fallback). - -**Done when:** prompt-construction test + fallback test pass, gate green. One -commit. - -**Risk note:** the phraser is a small model and will hallucinate. The fallback is -the point of this task — test it hard. Do not remove the notes-RAG path; this is -a *fallback after* it. - ---- - -## Task 5 — Weather module skeleton (pure, no network at night) - -**Goal:** a pluggable weather provider interface + an unconfigured stub. **No -live API calls.** - -**Files:** new `internal/weather/` package, `internal/config/config.go` -(a `WeatherConfig` block, copy `PhraserConfig` shape), tests. - -**Do:** -1. `internal/weather/weather.go`: define - `type Provider interface { CurrentWeather(ctx, location string) (Weather, error) }` - and a `Weather` struct (temp, condition, location). Add a `StubProvider` that - returns a sentinel `ErrNotConfigured` (or a "погода не настроена" message). -2. Config: add `Weather *WeatherConfig` to `VoiceConfig` (fields: `Provider`, - `APIKey`, `DefaultLocation` — all omitempty). **No API key in the repo.** -3. Optionally add an Open-Meteo provider *struct that is not called at night* - (no key needed) — but if you write it, do NOT make a network call in tests; - test against a mocked HTTP round-tripper only. If that's too fiddly, ship just - the interface + stub and leave a `// TODO: open-meteo provider` — that's fine. -4. **Add a real Open-Meteo provider** (keyless — no API key needed). Endpoint: - `https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..¤t_weather=true`. - Geocode via `https://geocoding-api.open-meteo.com/v1/search?name=`. - Keep the `*http.Client` injectable (a struct field) so tests use a mocked - round-tripper — **no real network call in any test.** Config selects - provider by `voice.weather.provider` ("open-meteo" | "" → stub). -5. **Wire it into voice.go.** In the `IntentQuery` handler, detect a weather - question (keywords погода/градус/температура, or a `query_weather` sub-path) - → call the configured provider with `DefaultLocation` or a parsed location → - format an RU reply. Unconfigured → the stub's «погода не настроена» message. - Seed `models/seeds/query.txt` with «какая погода», «какая погода в москве». - Bound the provider call with a context timeout (~5s) so a slow API can't hang - the voice turn. - -**Done when:** stub + mocked-Open-Meteo tests pass (round-trip against a fake -transport, unconfigured → stub message, location parsing), gate green. Split into -two commits if helpful: provider+interface, then voice wiring. - ---- - -## Task 6 — Dialogue state scaffold (pure data structures) - -**Goal:** a session/context data layer for future multi-turn. **No LLM, no -wiring into the live path unless trivial and tested.** - -**Files:** new `internal/dialogue/` package + tests only. - -**Do:** -1. `internal/dialogue/session.go`: a `Session` holding last-turn intent + slots, - a timestamp, and a TTL (default ~2min, configurable via a field). A - `SessionStore` (in-memory map keyed by session id) with `Get`, `Put`, and - TTL-based expiry. -2. A pure `InheritSlots(prev, cur Slots) Slots` helper: carry forward slots the - current turn is missing (e.g. previous had a location, current didn't → use - previous). Copy the `Slots` shape from `internal/router/intent.go`. -3. Tests: context carry-over, slot inheritance, session expiry, missing prior - session. This is the whole task — it's a tested library, not a feature. - -**Done when:** tests pass, gate green. Then (only if the library is solid and -gate is green) **wire a minimal read seam into voice.go**: on a follow-up-shaped -utterance, look up the prior session's slots and fill the current turn's missing -slots via `InheritSlots` before routing. Keep the session store's lifetime owned -by the handler struct. If wiring gets fiddly or risks the live path, ship the -tested library and mark the wiring BLOCKED — the library is the required part. -Separate commits: library, then wiring. - ---- - -## Task 7 — Long-term memory vector-store interface (pure) - -**Goal:** an interface + in-memory implementation for a future vector backend. - -**Files:** new `internal/memory/store.go` + tests only. - -**Do:** -1. `type Store interface { Insert(ctx, id string, vec []float32, meta map[string]string) error; Search(ctx, vec []float32, topK int) ([]Result, error) }`. - `Result` = id, score, meta. -2. An `InMemoryStore` implementing it with cosine similarity (copy the `cosine` - function idea from `internal/router/classifier.go` — you may factor a shared - helper, but simplest is to reimplement locally; don't refactor the router). -3. Tests: insert→search round-trip, cosine ordering (nearest first), topK - truncation, metadata filtering if you add it. In-memory only. - -**Done when:** tests pass, gate green (library commit). Then **wire the embedding -pipeline**: in the `IntentNote` handler in `voice.go`, after `WriteNote`, also -`Insert` the note's embedding + metadata (id, source, ts) into the memory Store. -Use the **same embedder** the classifier uses (already in scope as `h.embedder`). -Make the memory Store a field on the handler, defaulting to `InMemoryStore` so -nothing external is required. Wrap the Insert in its own error branch — a memory -Insert failure must **not** fail the note write (log and continue). Separate -commit for the wiring. - ---- - -## Phase Final — Verification pass (always do this last) - -- [ ] `gofmt -l .` prints nothing -- [ ] `go build ./...` succeeds -- [ ] `go vet ./...` clean -- [ ] `go test ./...` green -- [ ] `docker compose build` succeeds (all daemons compile) — if docker is - unavailable in the environment, note it and rely on `go build ./...`. -- [ ] `git log --oneline master..HEAD` — confirm one commit per completed task, - each message names its task, no "wip"/debug commits. -- [ ] Grep for accidents: `grep -rn "destructive.*false" deploy/mavend.json` and - eyeball every hit; `grep -rniE "TODO|FIXME|panic\(|fmt.Println" cmd internal` - — no stray debug prints, no new panics in live paths. -- [ ] Update the Status column of each task in this file (Done / BLOCKED:reason / - Skipped) so the human can see what happened at a glance. - ---- - -## Status board (fill this in as you go) - -| # | Task | Commit | Status | -|---|------|--------|--------| -| 1 | Embedder config validation + docs | `b778f0b` | Done | -| 2 | Seed safe tool allowlist | `3b8fb69` | Done | -| 3 | Calendar querying | `cf066bd` | Done | -| 4 | General-knowledge phraser routing | `428af3f` | Done | -| 5 | Weather skeleton (pure) | `e030466` | Done | -| 6 | Dialogue scaffold (pure) | `79eb43e` | Done | -| 7 | Memory vector interface (pure) | `880715f` | Done | -| F | Final verification pass | `d52f60c` | Done — all gates green | - ---- - -## DEFERRED — needs a human, DO NOT ATTEMPT unsupervised - -These were in the original plan. They require hardware or protocol decisions and -will burn the night if attempted blind. Left here only so you don't rediscover -them and think they were forgotten. - -- **Always-on listening / wake word** (`internal/wake/`, `cmd/mavmic/`): needs a - hardware decision (USB mic vs Pi vs smart speaker) and a Porcupine license/key. - Human input required. -- **Streaming STT over WebSocket + barge-in** (`internal/voice` receive path): - changes the wire protocol (`PROTOCOL.md`) and the STT worker contract. Too - invasive to do safely unsupervised; risks breaking the working full-clip path. -- **Streaming TTS**: blocked on Piper. Out of scope. - -If you finish Tasks 1–7 with time to spare, do NOT start these. Instead: improve -test coverage on what you built, expand the seed files, and improve docs. diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 5ee7cb6..0000000 --- a/SPEC.md +++ /dev/null @@ -1,286 +0,0 @@ -# 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. - -## 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 -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 diff --git a/START.md b/START.md index f0ced45..74cf6a7 100644 --- a/START.md +++ b/START.md @@ -28,7 +28,11 @@ cd "$ROOT" ./mavend -config mavend.json ``` -Config path: `~/.config/maven/mavend.json`. Full example with all options: +Config path: `~/.config/maven/mavend.json`. Full example with all options. + +> The `phraser.model_path` below is an example — point it at whatever GGUF you +> have locally. The deployed value lives in `deploy/mavend.json`, currently +> `Qwen3.5-0.8B.Q4_K_M.gguf`; the target is the CPT'd Qwen3-1.7B (#122). ```json { diff --git a/cmd/mavenclient/main.go b/cmd/mavenclient/main.go index d64c756..e711404 100644 --- a/cmd/mavenclient/main.go +++ b/cmd/mavenclient/main.go @@ -1,9 +1,9 @@ // Package main is mavenclient — maven's reference client. // -// Per the spec (maven.md § stt/tts): capture lives on the client; the -// server transcribes + synthesises on demand. The PC client runs vosk-ru -// (wake-word + stage-0 grammar, real-time on a Pi) + VAD, ships ONE clean -// audio blob per utterance on activation. The server never owns a mic. +// Per DESIGN.md § Voice pipeline (STT / TTS): capture lives on the client; +// the server transcribes + synthesises on demand. The PC client runs the +// wake-word / VAD gate (cmd/mavwaked) and ships ONE clean audio blob per +// utterance on activation. The server never owns a mic. // // This binary is the floor reference: there is NO wake-word / VAD here // (production PC client libraries); it ships ONE wav file from disk per diff --git a/internal/auth/tier.go b/internal/auth/tier.go index 18dd753..4c5589b 100644 --- a/internal/auth/tier.go +++ b/internal/auth/tier.go @@ -1,7 +1,7 @@ // Package auth is maven's authority layer — the 4-layer cascade and the // "surface caps authority" invariant. // -// Spec contract (from maven.md § auth): +// Spec contract (from DESIGN.md § Auth): // // a cascade, not a pick-one — each layer answers a different question: // diff --git a/internal/delivery/channel.go b/internal/delivery/channel.go index eeee0a2..aa54c12 100644 --- a/internal/delivery/channel.go +++ b/internal/delivery/channel.go @@ -1,6 +1,6 @@ // Package delivery is maven's channel-routing + dispatch layer. // -// Spec contract (from maven.md § delivery / channel routing): +// Spec contract (from DESIGN.md § Delivery / channel routing): // // - routing = f(severity, presence). presence decides REACHABILITY; severity // decides INSISTENCE. need both. diff --git a/internal/router/intent.go b/internal/router/intent.go index 4623bf8..3ecc4ef 100644 --- a/internal/router/intent.go +++ b/internal/router/intent.go @@ -1,17 +1,24 @@ // Package router is maven's reactive path — the cascade that turns a free-form // utterance into a deterministic Decision. // -// Spec contract (from maven.md § reactive path — router): +// Spec contract (from DESIGN.md § Reactive path — routing): // -// - routing is a DECISION, and every decision in maven stays deterministic. -// a classifier owns the route; the SLM stays in its phrasing lane. same -// boundary as "rules decide, llm phrases," extended to the reactive path. -// - a CASCADE, not classifier-vs-deterministic — layers: +// - the TARGET design is LLM-as-router: the resident model (Qwen3-1.7B) +// emits GBNF-constrained structured JSON for the route, and the same +// model phrases replies; the embedder is a RAG hint, not a routing gate. +// the classifier/embedder cascade below is the committed default today, +// but it is an interim stopgap (DESIGN.md § Superseded, "classifier-owns- +// the-route") and the known cause of weak RU query handling — not a +// design to extend. +// - a CASCADE, not one decider — layers: // stage 0 — exact match (regex/grammar). wake-word + known command // grammar. "maven, restart nginx" hits the allowlist directly, -// skips the classifier. lowest latency — the vosk command path. -// stage 1 — intent classifier. embed utterance, nearest-centroid over -// labeled intents. one forward pass, ~30ms cpu, similarity score. +// skips the classifier. lowest latency — the client-side wake-word/ +// command-grammar path (cmd/mavwaked). +// stage 1 — route decision. the resident model (target), else the +// nearest-centroid classifier over embedded labeled intents (today's +// stopgap: one forward pass, ~30ms cpu, similarity score). any LLM +// error falls through to the classifier so a turn never breaks. // stage 2 — slot extraction, per intent. classification gives *what kind*, // not *the args*. reminders need a datetime, acts need fn+params. // stage 3 — confidence gate. below threshold → clarify, don't guess. @@ -28,14 +35,15 @@ package router import "time" -// Intent — the six save-where labels from the spec's routing table. The +// Intent — the seven save-where labels from DESIGN.md's routing table. The // discriminator is "does the loop evaluate a predicate against it?": // // - act: command now, not stored (function call into the allowlist) // - reminder: has a fire-time → reminders table (sqlite). bypasses the gate // - fact: structured state the loop reasons over → facts (sqlite) -// - note: recall/preference, no predicate touches it → chroma -// - query: answer, don't store → slm reads sqlite or chroma (RAG) +// - note: recall/preference, no predicate touches it → semantic store +// - query: answer, don't store → the resident model reads sqlite or the +// semantic store (RAG) // - chat: conversational, no store side-effect — LLM replies from // dialogue history + general knowledge // diff --git a/internal/stt/stt.go b/internal/stt/stt.go index 805891f..615a1d1 100644 --- a/internal/stt/stt.go +++ b/internal/stt/stt.go @@ -9,23 +9,22 @@ // audio bytes for a tiny bit of variation per utterance; the *content* // of the audio doesn't matter, only the wire shape round-trips. // -// - Remote: dials a worker module process at a unix socket (cmd/mavsttd -// today; a faster-whisper / vosk-backed process when models land). The -// swap is one constructor change at the daemon seam; the boundary is the -// same. +// - Remote: dials a worker module process at a unix socket (cmd/mavsttd, +// whisper.cpp-backed). The swap is one constructor change at the daemon +// seam; the boundary is the same. // // The Daemon picks the implementation from config. With no models on disk, // it wires Stub (the audio path is "live" end to end, the transcribe step // returns a canned string the router + action path operate on); with a // worker socket configured, it wires Remote. // -// Per spec (maven.md § stt/tts): faster-whisper small/int8 is the production -// stt; vosk-ru runs on the client (wake-word + stage-0 grammar), not here. -// The server-side stt module is the heavy multilingual path; vosk's -// stage-0 grammar hits the router directly via the client's stage-0 surface -// and never crosses this seam — that path is post-MVP (the client doesn't -// exist yet). Today's Remote + Stub both return plain text the router -// classifies. +// Per DESIGN.md § Voice pipeline (STT / TTS): whisper.cpp (CGo, Vulkan) in +// cmd/mavsttd is the production stt — the older faster-whisper/vosk picks are +// retired (DESIGN.md § Superseded, "named STT/TTS model picks"). The +// server-side stt module is the heavy multilingual path; the client's +// wake-word + stage-0 command grammar (cmd/mavwaked) hits the router directly +// and never crosses this seam. Today's Remote + Stub both return plain text +// the router classifies. package stt import ( diff --git a/internal/tool/tool.go b/internal/tool/tool.go index d89a8ac..abe0977 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -2,7 +2,7 @@ // store's allowlist, and drafts 'proposed' scaffolds for acts that aren't on // it yet. // -// Boundary discipline (maven.md "tool registration — drafting is suggest, +// Boundary discipline (DESIGN.md § "Tool registration — drafting is suggest, // enabling is act"): // // - The store is the allowlist. Only status='enabled' rows run. A verb not @@ -16,7 +16,8 @@ // - Destructive tools don't run on first hearing: Exec returns ErrNeedsConfirm // and the handler runs a confirm turn ("выполнить X? да/нет"); only a // confirmed re-Exec runs them. A gate assumes a fully-formed action, which -// an enabled+matched act is (maven.md "confirmation is not one mechanism"). +// an enabled+matched act is (DESIGN.md § "Confirmation is not one +// mechanism"). package tool import ( diff --git a/internal/tts/tts.go b/internal/tts/tts.go index 089e49a..31019fb 100644 --- a/internal/tts/tts.go +++ b/internal/tts/tts.go @@ -5,12 +5,12 @@ // PCM, headerless per the audio package; the voice sink + reference client // wrap it in a WAV at the disk edge. // -// Per spec (maven.md § stt/tts): silero (ru-native) is the production tts, -// piper (ru) is the safe floor. Both are CPU-only on the ryzen box; both -// ship as separate worker module processes (cmd/mavttsd today with the -// Stub handler; production swaps in onnxruntime / espeak-ng in the same -// main, no tts-package change). The daemon wires one — Remote pointing at -// the worker socket if configured, Stub otherwise. +// Per DESIGN.md § Voice pipeline (STT / TTS): piper is the production tts +// (subprocess + espeak-ng, CPU-only on the ryzen box, driven by cmd/mavttsd); +// the older silero pick is retired (DESIGN.md § Superseded, "named STT/TTS +// model picks"). A different voice is a model-file swap, not a code change. +// The daemon wires one impl — Remote pointing at the worker socket if +// configured, Stub otherwise. // // The Stub returns a short deterministic tone (a 200ms mid-frequency sine // burst) so the voice loop round-trips end-to-end without a model. The diff --git a/maven-feature-ranking.md b/maven-feature-ranking.md index 6aca91c..5c2ae35 100644 --- a/maven-feature-ranking.md +++ b/maven-feature-ranking.md @@ -1,6 +1,6 @@ # maven — feature ranking -> dated 2026-07-03. companion to `maven.md`. ranks everything discussed post-repo-state against the infra blockers, not a replacement for the build order. +> dated 2026-07-03. companion to `DESIGN.md` (folded from the former `maven.md`). ranks everything discussed post-repo-state against the infra blockers, not a replacement for the build order. --- @@ -20,7 +20,7 @@ nothing feature-level below should land before 1–2 are done. 3–4 can interle ### mandatory things that block correctness or safety of stuff already shipped — not new capability, just closing gaps in existing design. -- **destructive-confirm policy** — open question in `maven.md`, blocks correx and any new tool domain from having a coherent risk tier +- **destructive-confirm policy** — open question in `DESIGN.md` § open questions, blocks correx and any new tool domain from having a coherent risk tier - **quiet-hours definition** — open question, blocks proactive delivery being trustworthy - **schema migrations** — sqlcipher rollout alone forces a schema touch. want this mechanism before that, not after. @@ -30,7 +30,7 @@ cheap, no dependencies, no new invariants. - **grocery / `list_items` table** — fourth append-only shape (item, status, list-tag), no predicate touches it, multi-adder just works for free - **go.mod tidy** - **capability model** (deepseek) — `homelab.docker.restart` instead of flat `tool→enabled`. cheap now, expensive to retrofit once tools surface passes ~15 entries. time-sensitive, not urgent. -- **conversation repair** — already free: `maven.md` has "misroute correction = new centroid example," this is just naming the existing mechanism as a feature +- **conversation repair** — already free: `DESIGN.md` has "misroute correction = new centroid example," this is just naming the existing mechanism as a feature - **command history** — read-only query over existing facts, no new mechanism - **clarification templates** — canned phrasing for the router's existing confidence-gate fallback, phraser-lane only - **pronunciation dictionary** — tts config, no architecture diff --git a/maven.md b/maven.md deleted file mode 100644 index 2a8d91c..0000000 --- a/maven.md +++ /dev/null @@ -1,413 +0,0 @@ -# maven - -a self-hosted personal assistant. manages your day, acts on your homelab. all software, fully local — never phones home. - -> consolidated from: spec, decisions, router, two-memory, presence, auth, stt/tts. dated 2026-06-30. - ---- - -## capabilities - -### reactive -- **converse** — voice in → stt → router → llm → tts, and text -- **act** — function calls into the homelab ("do digital things") - -### proactive -- **health nudges** — hydration, meals, breaks, shower, sleep, cleanup -- **user reminders** — your stated future intent ("remind me tuesday", "wake me 7"); scheduled, fires once -- **deliver** — voice when near, ntfy/telegram/matrix when away -- **restrain** — quiet hours, per-rule cooldowns, snooze-memory, self-quieting - -### capture -- throw facts/notes/tasks at it mid-flow → feeds memory - -### state -- **self** — timestamped facts about you (meals, water, sleep, desk time) -- **presence** — are you here (inferred, decaying confidence — never one signal) -- **activity** — what you're doing right now -- **environment** — world facts that gate or trigger: homelab health, calendar, weather - -### memory -- long-term recall, personalization (obsidian → chroma) - -### feedback -- nudge outcomes (acted / snoozed / ignored) tune the rules -- you correct a bad fact; it knows its own hit rate -- self-quieting falls out of this - -### identity -- consistent character — tone, values, phrasing. pinned in prompt, makes restraint legible - -### surface -- talk to it (phone page, pc client) -- it reaches you (the channels above) -- **prove it's you** — auth; this thing holds your life and can act - ---- - -## non-goals — what maven isn't - -**outside boundary** -- not alexa/siri on steroids -- not a smart device - -**inside boundary** (the ones that actually constrain the build) -- **not autonomous** — suggests and acts on command; proactive triggers never get unsandboxed action rights. "backup failed, rerun it?" — never reruns it herself. suggest≠act is the safety model -- **not a guesser-of-truth** — inference changes whether she asks, never what she records. a confident wrong fact is worse than a known gap -- **not a nag** — she'd rather miss a nudge than be mutable. shuts up when uncertain. load-bearing -- **not a stranger** — runs on your stuff, your model, your data. never phones home -- **not a relationship** — mom-tone is a function that makes nudges land, not emotional company. names the drift a warm small model falls into - ---- - -## architecture - -- daemon lives on homesrv (always-on), not the workstation -- trigger loop is dumb: ticks ~1min, no llm, evaluates deterministic predicates against state -- llm wakes only when a predicate fires; job is narrow — phrase + (no) final veto, not drive the loop -- lfm2.5 / sub-1b for phrasing — prompted, not trained. training effort stays on tts/stt/personality -- presence = decaying confidence score over multiple weak signals, with hysteresis; never trust one source -- self-facts → care nudges. world-facts → ops + context. same engine -- rules as code, not a config DSL — until ~30 rules and you feel the pain -- proactive triggers: read + suggest only, never action rights - -### build order -state layer is first. nothing proactive works without state to evaluate predicates against — it's the floor, built before the loop, phrasing, or delivery. - -### storage — sqlite -- single-user, no concurrent writers, on a box already tight on ram → sqlite, not postgres. library not a process, no port to harden, backup is `cp` -- **sqlcipher** at-rest. key read at daemon start, not hardcoded -- give up postgres `LISTEN/NOTIFY` — loop polls anyway, non-loss - -```sql -PRAGMA journal_mode=WAL; -PRAGMA synchronous=NORMAL; -PRAGMA foreign_keys=ON; -PRAGMA busy_timeout=5000; -``` - -### schema — append-only, three shapes -never UPDATE a value. a wrong fact is superseded, not overwritten — keeps the audit trail. current value = latest non-voided row for a key. - -**facts** — substrate, all observations (self + env) -```sql -facts ( - id, ts, -- ts = valid-time (true-as-of), not insert-time - kind, -- 'self' | 'env' | 'config' - key, value, -- value json if structured - source, -- tap:* | infer:* | poll:* | ambient | promote | feedback - confidence, -- 1.0 taps only; <1 inferred - voids_id -- correction points at the fact it cancels -) --- index (key, ts desc) -``` - -**reminders** — user intent, fires once -```sql -reminders ( id, created_ts, fire_ts, payload, status ) -- pending|fired|cancelled -``` - -**nudges** — every proactive send + outcome. this table IS the restraint memory -```sql -nudges ( id, ts, rule, channel, message, outcome, outcome_ts ) -- pending|acted|snoozed|ignored -``` - -**presence is not a table** — pure function over recent facts, computed each tick. only stateful bit is hysteresis: -```sql -presence_state ( last_bucket, last_score, updated_ts ) -``` - -### trigger model -- loop ticks ~60s, no llm. 99% of ticks evaluate a few predicates and die for free -- a predicate is `(State) -> Boolean`, **pure, no i/o** → unit-tests with a fake State, zero infra -- `since(key)==null` → don't fire. silence on no-data = "shuts up when uncertain" -- **the gate is universal, applied by the loop, never per-rule** — quiet-hours, presence, cooldown, snooze, calendar-busy all live in one `fires()`. cross-cutting restraint in one place or it drifts -- one nudge per tick (max severity), never dogpile -- rules as code, not a DSL. revisit at ~30 rules - -### rules decide, llm phrases -the rule decides whether maven speaks — absolute, deterministic. the llm only words it: input `(rule, severity, context)`, output `message`. **no `send`/veto bool.** a nondeterministic sub-1b model never gets to silently kill a greenlit nudge. suppression-context ("don't nag mid-meeting") moves INTO the gate as an env predicate, not the llm's job. - -### user reminders — separate class -- relative→absolute **at capture** ("in 4h" → store `now+4h`, never the string) -- reuses the loop, not a second scheduler — just a predicate: `fire_ts <= now AND pending` -- **bypasses the restraint gate** — "wake me 7" fires in quiet hours; that's the point. snooze still applies. two delivery paths - ---- - -## reactive path — router - -### the decision: classifier, not slm -routing is a *decision*, and every decision in maven stays deterministic. an slm router is the one nondeterministic decider banned everywhere else. a classifier gives a confidence-scored distribution over a fixed label set, which is what routing actually wants. **classifier owns the route. slm stays in its phrasing lane.** same boundary as `rules decide, llm phrases`, extended to the reactive path. - -### a cascade, not classifier-vs-deterministic -not alternatives — layers. -- **stage 0 — exact match (regex/grammar).** wake-word + known command grammar. "maven, restart nginx" hits the allowlist directly, skips the classifier. lowest latency — the vosk command path. boring high-frequency acts for free -- **stage 1 — intent classifier.** everything free-form. embed utterance, nearest-centroid over labeled intents. one forward pass, ~30ms cpu, yields a similarity score to threshold -- **stage 2 — slot extraction, per intent.** classification gives *what kind*, not *the args*. reminders need a datetime, acts need fn + params. you parse -- **stage 3 — confidence gate.** below threshold → clarify, don't guess. same pattern as `since(key)==null → don't fire`. a misrouted fact is a confident wrong write — worse than a gap - -### save-where — the two-memory routing axis -one discriminator: **does the loop evaluate a predicate against it?** - -| intent | example | lands in | why | -|---|---|---|---| -| act | "restart the backup" | function call (allowlist) | command now, not stored | -| reminder | "wake me 7", "vet tuesday" | `reminders` (sqlite) | has a fire-time | -| fact | "drank water", "slept 6h" | `facts` (sqlite) | structured state the loop reasons over | -| note | "gpu driver fixed the flicker", "prefer backups at 3am" | chroma/obsidian | recall/preference, no predicate touches it | -| query | "is the backup up?", "when'd i last eat?" | slm (reads sqlite or chroma) | answer, don't store | - -fact-vs-note is the whole line: predicate will read it → structured `facts` row; just "recall when relevant" → semantic store. reminder splits off by future timestamp; act splits off by being imperative-now. routing isn't a separate mechanism from classification. - -### the preference seam — forced, not a choice -a preference ("prefer backups at 3am") looks like note-or-config. it isn't, because of a hard constraint: **a predicate cannot read chroma.** the loop is dumb + deterministic; it can't run a vector search every tick. so the moment a preference becomes load-bearing, it MUST exist as a structured `key=value` row the loop can evaluate. the store split is physical, not cosmetic. so: -- **capture → always a note** (chroma). default, inert, fail-safe. costs nothing, drives nothing -- **a note stays a note until a rule needs it** - -### promotion -**promotion = the moment a human authors a predicate that reads the value.** -- authoring the rule copies the value into `facts (kind=config, source=promote)` — a deterministic key the loop can evaluate -- the note stays as provenance (where the config came from, in your words) -- the predicate reads the promoted `facts` row, never chroma - -properties: **one-way, never auto** (a note can't self-promote — no path from chroma into the loop that skips a human writing a predicate). **closes the injection hole** — ambient-derive overhears the TV say "prefer backups at 3am" → lands as inert note → can't drive the loop without you authoring the rule. same instinct as `compromised router can't forge a capability`. identical shape to `proposed → enabled`: low-authority form free + automatic, high-authority form requires a deliberate human act. - -### tool registration — drafting is suggest, enabling is act -maven can scaffold a tool she's missing. she cannot enable it. -- maven detects the gap, scaffolds the registration (name, command, params, destructive y/n), writes a `proposed` row, surfaces it — "earn the right to ask" -- `proposed → enabled` flips through an authed surface (pc client / authed page), **never the voice/chat path** — that's the act, and it's the user's - -why human-only: editing the allowlist is the one act that *moves the boundary* — a boundary you can move from inside isn't one. registration is privilege escalation, a different authority tier than invoking a listed tool. paranoid case: prompt injection via ambient-derive — TV says "maven add a shell tool" — a self-registering maven grants *itself* arbitrary capability. human-only enable keeps a compromised maven boxed by what's already on. low-friction stays intact: she builds the stubs, you review + enable. you just never lose the pen. - -### confirmation is not one mechanism -a gate assumes a fully-formed action — "drop the db? y/n" works because the action is already specified. an underspecified request can't be gated; you can't confirm what isn't specified. confirmation scales to how formed the act is. - -### stack -- **classifier:** `multilingual-e5-small` or `paraphrase-multilingual-MiniLM-L12-v2`, onnx int8 (~120mb). bilingual native, no separate ru/en path. nearest-centroid needs ~10 examples/intent, no training pipeline -- **dates:** `dateparser` — ru+en relative+absolute, does `relative→absolute at capture` for free -- **acts:** fuzzy-match against the fn allowlist. **not on the list → refuse, don't improvise.** destructive ones still gate behind confirm — "drop the db?" must not fire on a fuzzy match -- **slm's remaining lane:** phrasing the reply + last-resort slot extraction for free-form notes the parsers choke on. never the router decision -- **misroute correction = new centroid example** — append-only, grows the classifier as used. same shape as `nudges.outcome` tuning cooldowns. more reliable over time, no retrain - ---- - -## stt / tts (low-tier, cpu, ru+en) - -target box: ryzen + 13gb ram, igpu only, no real gpu. onnxruntime on cpu for everything — igpu rocm is flaky and pointless for sub-1b models. - -picks: -- **stt:** faster-whisper small (int8) — primary, ru+en, handles mid-sentence code-switching -- **stt (low-latency path):** vosk ru — known command grammar; runs alongside fw (this is stage-0) -- **tts:** silero (ru-native) — *verify license + voices first*; piper ru as the safe floor - -| model | role | params | langs (ru?) | license | cpu fit | -|---|---|---|---|---|---| -| **faster-whisper** small/int8 | stt | ~240M | 99+ incl ru ✅ | MIT | ~95% large-v3 acc @ 6x speed | -| **vosk** (ru model) | stt | tiny | 20+ incl ru ✅ | Apache 2.0 | real-time, native streaming, runs on a pi | -| moonshine | stt | 245M | english only ❌ | MIT | streaming, ~6x smaller than whisper-lg | -| **silero** | tts | small | russian-native ✅ | check it* | fast on cpu | -| **piper** (ru voices) | tts | tiny | 30+ incl ru ✅ | GPL-3.0 (fork) | real-time on a pi, audibly synthetic | -| xtts v2 | tts | ~470M | 17 incl ru ✅ | CPML (non-commercial) | cpu works, 5-10x slower, heavier | -| kokoro | tts | 82M | no russian ❌ | Apache 2.0 | fast on cpu, en-first | - -bolded = the actual picks. - -notes: faster-whisper = ctranslate2 int8, ~4x faster than vanilla whisper, lower memory. kokoro is in the table only as a "don't bother for ru" marker. ram math: fw ~0.5–1gb + tts ~1gb → ~10gb left for the router llm + os, comfortable, no swap. silero row is prior knowledge, no source pulled — **confirm license + current ru voices before wiring in.** bilingual / mid-sentence code-switch → lean on faster-whisper, not vosk. - ---- - -## state — signal sources - -**explicit taps set truth. passive signals drive prompting.** a passive signal can never write a self-fact — it only makes maven ask. boundary lives in `source`; rules trust provenance. - -- **self / taps (1.0):** water, meal, shower — phone / voice / telegram -- **self / passive (activity, not truth):** desk-idle (hyprland / GetLastInputInfo), voice_active, sleep -- **presence (weak, decaying, multi-source):** wg handshake, page heartbeat, hyprland-not-idle. *no LAN sweeps* — too invasive; wg+heartbeat get ~90% -- **env / polled:** healthcheck (disk/service/backup/cert), calendar (caldav/.ics), weather -- **provenance-scoping:** a rule on `service_down` trusts only `source=poll:healthcheck`. a compromised poller must not be able to forge a trigger - -### presence — concrete scoring - -pinned: pure function over recent facts, computed each tick; decaying confidence over multiple weak signals, never one authoritative source; hysteresis to stop flapping. - -**combiner — noisy-OR, not weighted sum.** these are independent-ish positive indicators of one latent binary ("kami here?"). -``` -p_i = weight_i · exp(-Δt_i / τ_i) # one signal's decayed contribution -P = 1 − Π (1 − p_i) # combine -``` -diminishing returns on stacking weak signals, never exceeds 1.0. "any one signal raises confidence, no single one owns it" falls straight out. weighted-sum rejected: heartbeat+wg fresh would peg identical to everything-fresh — overcounts. - -**signals — weights + decay.** `Δt` = `now − latest fact ts` for that key. no fact → drops out of the product (not a zero). - -| signal | source | fresh weight | τ (min) | rationale | -|---|---|---|---|---| -| desk active | `infer:hyprland` (not-idle) | 0.90 | 8 | input = human at keyboard. strongest. τ forgives read/think gaps | -| page heartbeat | `infer:heartbeat` | 0.60 | 4 | a surface you use is open + alive. pings ~30s; 4min gap = gone | -| wg handshake | `infer:wg` | 0.40 | 20 | device on tunnel. coarse — pocket or three rooms away. weakest, slowest | - -**threshold + hysteresis (schmitt trigger):** -``` -ENTER (away → present): P ≥ 0.55 -EXIT (present → away): P < 0.30 -cold start: away # fail-closed; same as since(key)==null → don't fire -``` -wide band = stable. a lone fresh wg (0.40) can't *enter* present but *holds* it while decaying — phone-on-network alone never declares you here. - -| scenario | P | bucket | -|---|---|---| -| at desk, typing | ~0.90 | present | -| at desk, 5min reading (client open) | ~0.79 | present | -| desk-only, ~9min zero input | <0.30 | → away | -| couch, phone page open, no desk | 0.60 | present | -| left house, only wg lingering | decays ~20min | → away | -| cold boot, nothing | 0 | away | - -implementation (core — presence reads State under the lock, predicate input, not a module): -```kotlin -data class Signal(val key: String, val weight: Double, val tauMin: Double) - -val SIGNALS = listOf( - Signal("desk_active", 0.90, 8.0), - Signal("page_heartbeat", 0.60, 4.0), - Signal("wg_handshake", 0.40, 20.0), -) - -fun presenceScore(now: Instant, state: State): Double { - var pAway = 1.0 - for (s in SIGNALS) { - val last = state.lastTs(s.key) ?: continue // no data → drops out - val dt = Duration.between(last, now).toMillis() / 60000.0 - pAway *= (1.0 - s.weight * exp(-dt / s.tauMin)) // noisy-OR - } - return 1.0 - pAway -} - -fun resolve(score: Double, last: Bucket) = when (last) { - PRESENT -> if (score < 0.30) AWAY else PRESENT - AWAY -> if (score >= 0.55) PRESENT else AWAY -} -``` -each tick: `score = presenceScore(now, state)` → `bucket = resolve(score, state.lastBucket)` → persist `presence_state`. decay uses wall-clock Δt, so 60s tick jitter causes no drift. - -boundaries: **hand-tuned, NOT feedback-tuned** — keep presence numbers out of the auto-tuner or a weird week drifts you silently invisible. **presence = reachability, not wakefulness** — sleep/quiet-hours handled separately in the gate. feeds delivery routing directly. caveat (stated, not litigated): noisy-OR assumes independence; desk+heartbeat correlate (pc client open ⇒ both fire) — co-firing slightly overcounts, which is fine, genuinely co-firing IS stronger evidence. - ---- - -## proactive - -### listening — three modes, not one -"ambient listening" was the wrong frame — three capabilities split by trigger + retention. threat model is local-only: the file at rest (sqlcipher, extend to any retained audio/transcript) and who can reach the box (auth). that's it. - -1. **address-capture** — "maven, note this" out loud. hands-free voice path, no tap. raw audio dies after deriving the fact. **← mvp pick.** lowest retention, highest daily payoff, ~free (voice path minus the tap) -2. **meeting-record** — deliberate start/stop, verbatim transcript *kept*. retention is the point (overrides any "always delete" rule). post-mvp -3. **ambient-derive** — background overhearing, low-confidence candidate facts, raw ephemeral, only derived fact survives. needs the confidence model working first. last - -only mode 3 is always-on. modes 1–2 fire heavy transcription on explicit trigger/record → ryzen idles. always-on = lightweight VAD (+ maybe owner-detect) only. **speaker-scoping = attribution metadata, not a kill-gate** — tag `source=ambient:self | ambient:other`. per-person retention becomes a `WHERE` clause — "delete what you have from gf" is a query, not a problem. - -### delivery / channel routing -routing = `f(severity, presence)`. presence decides *reachability*; severity decides *insistence*. need both. - -| | present | away | -|---|---|---| -| **sev1–2** (care) | voice | **drop** | -| **sev3** (ops, soft) | voice | ntfy, once | -| **sev4** (ops, hard) | voice + ntfy | telegram, repeat til ack | - -sev ≤ 2 drops on away, sev ≥ 3 holds. a missed water nudge is noise; a missed backup-failure isn't. away-channels (ntfy/telegram) leave the box — the one path that crosses "never phones home," through your relay. **minimal body** — "disk low on homesrv," not detail. don't make notifications a shoulder-surf exfil surface. - -### feedback loop (outcomes → tune cooldowns) -the `nudges.outcome` column IS the signal — no new storage. cooldown becomes a function of recent outcomes, not a constant. -- mostly `ignored` → nagging into the void → lengthen cooldown / raise threshold -- mostly `acted` → landing → leave, or cautiously shorten -- mostly `snoozed` → right nudge wrong *time* → shift the window, not the frequency - -**tunes parameters, never logic.** can widen cooldown, nudge threshold, shift window. cannot rewrite a predicate or invent a rule. bounded knobs (`cooldown ∈ [min,max]`) so a weird week can't mutate maven silent or stalker. **dead simple at mvp:** a ratio over last N, not a learned model. `ignored_rate > 0.7 → cooldown *= 1.5, capped`. introspectable. **persist the adjusted cooldown as a fact** (`source=feedback`) — survives restart, stays visible; why maven went quiet should be a query, not a mystery. - ---- - -## auth - -### a cascade, not a pick-one -same shape as `confirmation is not one mechanism` — each layer answers a different question. - -| layer | question | mechanism | surface | -|---|---|---|---| -| 0 — tunnel | on the network at all? | wireguard | everything. floor | -| 1 — device | enrolled box? | mTLS client cert, terminated at proxy | pc client, authed page | -| 2 — session | you, this session? | passkey / webauthn | pc client, authed page | -| 3 — step-up | you, *right now*, for this act? | passkey user-verification gesture | registration-enable, destructive acts, **core cold-start** | - -wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is "authed" at layer 0 only — that gap is why the upper layers exist. **passkey over password/token** because step-up is load-bearing: webauthn gives per-assertion user-verification for free — the biometric/PIN gesture IS the human-in-the-loop. no shared secret on the box to steal; private key stays in enclave/TPM. spring security 6.4+ has native passkey support → in-stack for kotlin/spring. **mTLS (layer 1) optional** — if dropping one, drop mTLS, never the passkey. - -### the invariant — surface caps authority -**auth tier is a property of the surface; the surface caps maximum authority.** you can't step up past what the channel structurally carries. -- **voice** presents layer 0 + speaker-attribution and STOPS. speaker verification is attribution, not auth. a room mic is reachable by gf, the TV, anyone present → voice is *structurally incapable* of layer 3 -- **telegram inbound** = possession of a telegram account + chat-id allowlist. telegram's auth, outside our control. weak tier → read + soft acts, never destructive, never registration - -so voice/chat can never reach registration-enable — **not because auth "failed" but because the channel can't carry the proof.** - -### key-provenance -**the theorem:** no unattended key source survives a powered-on stolen box. anything the daemon fetches with no human present, a thief who grabs the running laptop fetches too. so the question was never "find the secure source" — it's **pick the failure mode:** unattended-but-loses-to-running-theft, or theft-resistant-but-attended. structural, can't have both. - -**the trap — TPM-alone:** TPM-sealing binds release to PCRs, defeats offline disk extraction (real, worth having). but against full-box theft it does nothing — PCRs still match, thief boots, TPM unseals on cue. a laptop is portable → full-box theft is the *likely* case. TPM-alone protects the scenario you're less exposed to. - -**the resolution — cold-start IS a layer-3 act:** unlocking the db makes maven's entire memory readable — the single highest-authority op. so **daemon cold-start unlock IS layer-3 step-up.** "always-on" = doesn't need babysitting during *normal operation*; does NOT mean *survives a cold boot with nobody around*. unlock is **remote-attended** — `systemd-ask-password` over ssh, or pushed through the passkey-authed page. theft = box reboots into a locked daemon and stays there. - -concrete stack: - -| layer | mechanism | buys | -|---|---|---| -| disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline-extraction dead (TPM), powered-theft needs PIN-in-head | -| sqlcipher key | not at rest. supplied at daemon start via `systemd` credentials / `ask-password`, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal | - -TPM+PIN is the honest middle. yubikey challenge-response is the upgrade path *if carried on your body* — don't reach for it yet. **runtime:** key in daemon RAM → `mlock` the page (no swap-to-disk), swap off or encrypted. zero on shutdown. **the trade:** a cold reboot needs you (remotely) present; in exchange a stolen laptop — running or off — is a brick holding ciphertext. matches the fail-closed posture. - -### core/module key-isolation -the convenience (reboot-attendance collapses to *core cold-start only*) is contingent on one thing: **the key lives in core's address space and nowhere else.** - -- **core = the only key-holder.** daemon holding sqlcipher-unlocked db + trigger loop. unlocked once (remote-attended), runs weeks. reboots almost never *because it was deliberately given nothing that churns* — no tool code, no model weights, no router. just state + loop -- **modules = restart-free, key-free, fail-independent.** stt/tts, router/classifier, tool executors, delivery. update/crash/swap one → none touch the unlock. "update the tool module, no attendance" is correct *by construction* — the module never had the key - -enforce it: **IPC boundary, not shared address space** (unix domain socket, local-only — a crashing tts can't read the key page). **core mediates, never hands back a db handle** — modules send requests *to* core ("write this fact" / "read presence"); core never returns raw db access. **module compromise ≤ module authority** — worst a popped tts does is send garbage audio. - -the discipline: **nothing enters core's process unless it must read state under the lock.** loop qualifies. predicates qualify. phrasing, routing, transcription, tool execution, delivery all read *derived* data or send requests — none need the raw key. erode this and you buy back the attendance you just eliminated. **systemd topology:** core = one unit; each module its own unit, `After=core.socket`, socket-activated, `Restart=on-failure`. - -### the through-line -network → box → process: same question at three radii. -- **network (wg):** who reaches the box -- **box (LUKS+TPM+PIN, sqlcipher):** what a dead/stolen box gives up -- **process (core/module socket):** what a compromised module reaches - -every cut is the same instinct — *a boundary you can move from inside isn't one*, *compromised X can't forge Y*, *attribution is not auth*. auth didn't add a new principle; it applied the existing one at smaller and smaller scope. - ---- - -## open questions - -> four items the old `decisions` doc listed as open — **router + invocation**, **two-memory routing**, **presence (concretely)**, **auth** — are now resolved by their respective sections above and are NOT listed here. - -### impactful — gate behaviour or the mvp surface - -- **stage-3 confidence threshold** — the gate-or-clarify number for the router. unset. defines how often maven asks vs. guesses on free-form input; the whole reactive mvp feel rides on it -- **quiet-hours definition** — fixed clock vs derived from sleep facts. gates *all* proactive delivery; can't ship the loop's restraint without it -- **confirmation tier model** — only the forced pin is settled (**registration is out-of-band, human-only**). the rest is a sketch: confirmation scales with specification + reversibility (rough buckets: just-do / binary-confirm / clarify-then-confirm / out-of-band). don't anchor on the count or any `f()`. open sub-item: **destructive-act confirm — policy-level vs per-function flag** -- **ask-password transport** — `systemd-ask-password` over ssh vs passkey-authed page push for cold-start unlock. both work; unpicked. blocks remote-attended boot being real - -### plumbing / deferred — won't block the core build - -- **passkey enrollment bootstrap** — first credential on a fresh device before a passkey exists to authenticate with. the trust-on-first-use gap -- **mTLS in or out** — provisioning cost on mobile vs paranoia payoff. leaning optional -- **PIN vs yubikey for LUKS** — PIN-in-head pinned for now; yubikey deferred unless carry-on-body becomes the model -- **session lifetime / re-auth cadence** — how long a layer-2 session lives before forcing re-assertion. unset -- **core↔module wire format** — socket decided, the protocol on it isn't (length-prefixed json vs something tighter) -- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one note in one utterance. classifier picks one label/utterance → compound splits (second pass) or loses half -- **query read-path** — "when do i like backups?" is RAG-over-chroma, not the sqlite read the router table implies. query-answering is chroma-RAG OR sqlite-read depending on the ask -- **obsidian↔chroma mechanics** — canonical md, derived embedding index, chunking. implementation of `obsidian → chroma`, unbuilt -- **presence — away tap override** — explicit `away` tap as hard override (voids presence to away for a duration). clean extension, deferred; scoring stands without it -- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning once the loop runs against real signal traces -- **presence — wg home-vs-cellular** — distinguish home-wifi-via-wg vs cellular-via-wg to let wg carry more weight when clearly home. needs the signal to exist first -- **personality prompt** — the identity character spec. unwritten -- **custom tts voice — train on a hand-picked voice** — future: replace the piper ru floor (irina) with a voice kami picks himself, trained/fine-tuned. per the stt/tts note, *training effort is where the personality budget goes* — this is that budget. piper supports voice training; xtts/silero fine-tune too. unscoped: pick the voice + the trainer, dataset size, cpu-vs-gpu train. deferred until the reactive+proactive core is solid; the irina floor ships first. -- **daemon runtime** — deferred until the homelab tool-call surface is real -- **listening modes 2–3** — meeting-record + ambient-derive. post-mvp; ambient-derive needs the confidence model working first