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