Files
Maven/docs/design.md
T

1130 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Maven — Design
*Last verified: 2026-08-13 @ a0e6643 + V-542 working tree. Living doc: correct it in place, do not append.*
> 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. `docs/rearchitecture.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. Inference and data stay on the box; she may READ external
sources (see Non-goals — "never phones home" is deprecated).
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. No
telemetry, no cloud model, no third-party account. She may READ external
sources to answer world questions (Kiwix first, then optional search); she
never reports anything about you to anyone, and your notes and facts are
never used as search input. **"Never phones home" as an absolute is
deprecated** — owner's call, 2026-07-31: a small model does not know enough
to be useful without reading.
- **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, one-shot or recurring);
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, one-shot or recurring:
```sql
reminders (
id, created_ts, fire_ts, next_fire_ts, payload,
status, -- pending | fired | cancelled
cron, -- empty for one-shot
delivery_group, phrase_body, phrase_summary, phrase_mood,
delivery_attempts, next_attempt_ts,
delivery_blocked_ts, delivery_blocked_error
)
```
Reminder payload is immutable, but its delivery lifecycle is deliberately not.
The only ordinary transitions are `pending -> fired` after a definite send and
`pending -> cancelled` through `CancelReminder`. A recurring success advances
`next_fire_ts` and clears the occurrence-scoped presentation instead of making
the row terminal. `MarkReminder` cannot cancel; that keeps every cancellation
behind the delivery-race checks rather than leaving a legacy write bypass.
`delivery_attempts` is the durable outbox. An occurrence or collapsed catch-up
bundle owns one non-empty `delivery_group`; its cached phrase and outbox row use
that same identity. Once an attempt is `pending`, `sent`, or `unknown`, Maven
refuses to claim that cancellation succeeded because the presentation may
already be outside the process. Cancelling first clears the group atomically,
so a sender cannot begin against the cancelled occurrence. Cancelling one row
in a collapsed group also invalidates the cached presentation on every pending
sibling before a later retry can repeat the old count.
**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 `docs/ecosystem.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:
`next_fire_ts <= now AND pending`, excluding retry waits, blocked delivery,
and occurrences with an ambiguous live outbox result.
- A one-shot success moves the row to `fired`. A recurring success advances the
same row to the next cron occurrence and gives that occurrence a fresh
delivery identity.
- Cancellation is an identity operation, not a relevance ranking. A direct
command must resolve one pending row by subject and/or time; multiple matches
are bound to the exact list Maven speaks and require an ordinal follow-up.
Questions, reports, prohibitions, stale ordinals, and unread times mutate
nothing.
- **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 `docs/rearchitecture.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":<enum>, key?, value?, text?, verb?}, ...]` over
7 intents (`fact, reminder, note, query, act, chat, system`).
#### "второй" points at the list she just read
Landed 2026-08-04 (Vikunja #448). The dialogue session carried the intent, the
slots and the history, and not the list. She recited five tasks, he said
"второй", and the word had nothing to point at.
`Session.Candidates` holds what she just offered, bound at the moment she speaks
it and in the order she speaks it (`tasks.Spoken`). Binding afterwards would
resolve the word against a fresh query, and the list changes between two turns.
`cmd/mavend/ordinal.go` reads the position before routing and dispatches on the
candidate's kind.
An ordinal with no verb is read back, not acted on — "второй" names a task, it
does not say what to do with it. With a verb ("первую сделал", "последнюю
убери") the task moves and the list is spent, because a second ordinal against a
list that no longer holds closes the wrong work. A position she never read is
answered with how many she did read, not routed as a fresh sentence.
#### Saying she got it wrong is a feature
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
corrected utterance as a new classifier example, and until now nothing in the
daemon called it, so the mechanism existed and the behaviour did not.
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
things happen in one turn: the classifier learns the utterance under the named
intent, the request is redone under it, and she says the correction landed. The
utterance he is correcting TO is the one with no "не" in front of it.
Read before routing, next to the confirm and clarify turns, because a correction
routed as a fresh utterance files the correction itself. One turn is correctable
once, inside five minutes, and only turns she acted on — a clarify asked instead
of acting, so there is nothing yet to be wrong about.
#### A restart expires a parked question
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
restart; the clarify question parked behind it does not, and neither do the
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
Three reasons, in the order they settle it:
- The clock stops meaning anything. A parked question carries a 90s TTL and an
attempt count. A restart is a gap of unknown length, so a restored question is
either already dead or pretending to be young.
- Restoring the question restores the request behind it. He asked for something,
she asked back, and then the daemon went away. Acting on that minutes later,
against words he has probably given up on, is the misroute the stage 3 gate
exists to avoid.
- She does not announce it either. The expiry notice needs to know a question
was parked, and knowing that across a restart means storing it. One sentence,
in the rare window where he speaks within 90s of a restart, does not pay for a
marker that outlives the thing it describes. His next words route fresh, which
is the correct answer with or without the notice.
So the notice stays what it is: the in-process TTL case, where she really did
wait and really did let go.
#### A parked question may step aside three times
Decided 2026-08-07 (V-654). A side query or an aside suspends the parked
question instead of dropping it. The words are answered as themselves, and the
question comes back on the end of the same reply.
Neither bound on a question reaches that path. No attempt is spent, because a
side query is not a failed answer, so `MaxAttempts` never applies.
`noteSuspended` also restarts the 90s clock, since she is about to speak the
question again. So the TTL cannot arrive while he keeps talking.
Measured on 2026-08-07: one unfilled time slot rode the tail of six consecutive
unrelated replies. It stopped only when a seventh turn happened to read as a
failed answer. See `docs/evals/2026-08-07-week-of-usage.md`.
`PendingQuestion.Suspends` counts the step-asides. `MaxSuspends` is 3, matching
`DefaultMaxAttempts`. Past it she lets the request go, with the same
`clarifyDropped` line every other drop uses. The owner's rule is unchanged. A
question still ends by being answered or by being let go out loud. This only
recognises three unrelated requests in a row as the second of those.
The count is of CONSECUTIVE step-asides. It resets the moment he answers, in
`resolveClarifyAnswer`. An answer that gives her nothing she asked for resets it
too. "Позвонить маме" against a question about the time is still him in the
exchange. The retry it costs is bound enough on its own.
#### And it may ride four turns in all
Decided 2026-08-08 (V-663), because the bound above did not move the number it
was written for. Twenty-six of 140 turns carried a tail before it landed and
twenty-six carried one after.
Two bounds rearm each other. An aside spends no attempt, so `MaxAttempts` never
reaches it. A turn that reads as a failed answer zeroes `Suspends`, so
`MaxSuspends` never reaches the asides. Alternating them, each bound is restored
by the other's traffic. Measured on 2026-08-08: one question about a reminder's
day rode turns 7 to 13. It ended only because turn 14 was a new request.
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
taken out. It is set once, incremented only in `noteSuspended`, carried across
the re-park in `askRemainingGap`, and read by nothing that could lower it.
`MaxRides` is 4, one looser than `MaxSuspends` so that the tighter statement
about a run stays reachable.
This is a bound, not a cure. It ends the measured ride one turn early. Most of
that ride's length is attempts, spent because `classifyTurnRole` reads "спасибо"
and "привет" as failed answers to a question about a day. That is the next
thing to fix and it is not a bound.
The re-ask is also two sentences rather than one. It used to be spliced onto the
answer with a comma. On a real answer that buries the question in the tail of
one run-on thought:
> вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить
> напоминание?
#### Conversation context is independent of intent
Decided 2026-08-13 (V-542). A conversation is a sequence of turns, not a run of
one route label. The utterance "давай поболтаем: я купил новый монитор" may
correctly produce a grounded fact, and the next question may correctly route as
query. Neither decision is permission to discard the words that make "он" in
the next turn mean the monitor.
`dialogue.Session.Utterance` therefore stores the exact user turn separately
from `Slots.Text`. Slots are intent payloads: a fact may normalize them, a
stage-0 route may leave them empty, and a continuation may deliberately carry
an older topic. None of those is a transcript. `Session.History` holds up to
four prior turns in speaking order and is persisted with the session; blobs
written by older binaries fall back to their old `Slots.Text` field until they
expire.
`Session.Conversational` is orthogonal state too. A chat route sets it, as does
an explicit cooperative opener such as "давай поговорим" even when the
substantive clause routes fact. The opener is recognised from the closed marker
plus `lexicon.ConversationVerbs`, with Russian forms compared by `morph`; there
is no route regex or substring carve-out. Conversational state carries across
later intents and uses the existing 15-minute chat TTL instead of expiring the
anchor after two minutes.
After routing, `followUpMerge` may use state the router cannot see. A routed
query containing an anaphoric pronoun and a live prior transcript becomes chat,
with query-only source provenance cleared. `PhraseChat` then receives the prior
turns and the current utterance exactly once. This is narrower than adding the
transcript to every query source: a non-anaphoric calendar, recall or world
question still walks its evidence chain unchanged. Acts are never widened by
this rule; an unresolved "выключи его" still has no executable function and
must fail closed.
An explicit conversational opener does not suppress a substantive side effect.
The monitor statement remains a fact and also becomes the dialogue anchor.
Conversation state and save-where are orthogonal, so making the first route
chat would merely lose a true fact to work around a session defect.
### 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.
#### Risk tiers, not one boolean
`Destructive` on a tool row is one bit set by whoever ticked the checkbox on
`/tools`. It is a mechanism, and it never said which acts are destructive,
whether a confirmed act stays confirmed, or what a new tool domain inherits.
`internal/tool/risk.go` is the policy (Vikunja #449). The tier is DERIVED from
the row, not stored, so it can be argued with in one place instead of being
whatever the last person to enable the tool believed.
| Tier | What it is | What it costs |
|---|---|---|
| `safe` | a read, or a change he can undo by saying the opposite | runs on first hearing |
| `destructive` | it changes something real and undoing it takes work | one confirm turn, every time |
| `irreversible` | the thing does not come back: a wipe, a format, a delete with no bin | voice may not authorise it at all |
Three rules fall out, and they are the part that was missing:
- **Which acts are destructive is not only the checkbox.** A house row always
is, because there is no read-only way to turn the heating off. A row whose
argv names one of the irreversible verbs always is, whatever the row says.
- **A confirmed act never stays confirmed.** At any tier. A confirmation binds
one capability, one target and one argument list, and it dies with the parked
turn (90s). "The same act again" is a new act. A sticky confirm is a standing
grant and nothing on the voice path may hold one.
- **A new domain inherits `destructive`, not `safe`.** A dispatch shape the
policy does not recognise gets the confirm turn. A domain argues its way down
to running freely; it never has to argue its way up to being gated.
#### Capability ids
A row is also read as a dotted capability id, `scope.domain.action` — the same
shape Hexis has always spoken, which made the local surface the odd one out
(Vikunja #452). `homelab.docker.restart`, `house.lock.unlock`,
`mcp_vikunja.vikunja.delete_task`.
Derived, not stored, for the reason the tier is: a derivation is one place to
argue with. The name is still the primary key and nothing about lookup or
execution changed — this is a way to READ the allowlist, not a second one.
`/tools` groups the enabled rows by `scope.domain` and prints the id and the
tier beside each, because a flat list stops answering "what can she do to the
house" somewhere around fifteen rows.
`MatchCapability` widens one way: `house` and `house.lock` both cover
`house.lock.unlock`, and nothing lets a narrower id claim a wider pattern.
The irreversible tier is refused rather than asked about, because a confirm
turn would be theatre: everything that proposed the act — an STT guess, a
router guess, a fuzzy allowlist match — is a guess, and a spoken "да" checks
none of it. She names the gap and he runs it himself. The row stays enabled;
refusing to run it from voice is not the same as taking it off the allowlist.
---
## 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 <path>` | whisper.cpp (CGo, Vulkan) |
| TTS | `voice.tts.socket` in config | `cmd/mavttsd -piper <bin> -model <path>` | piper (subprocess, espeak-ng) |
| STT stub | socket unset / 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.
#### A fact per monitor, not an aggregate
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
It used to fold the whole gauge into a single boolean, and the nudge could then
only say that something on homesrv was down. That is not something he can act
on, so the rule shipped disabled.
Three things follow from the split:
- The key set is no longer known at wiring time. A rule declares
`WantPrefixes` and the gatherer resolves the family per tick, which is the
only prefix read in the loop.
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
silenced nothing, because some other monitor kept the boolean at "down".
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
The rule is also edge-triggered: it fires on a transition it has not already
nudged about (`State.NudgedSince`). A polled fact is written only when the
value changes, but the predicate reads the current value, so without the edge
check a service that stays down qualifies on every tick and cooldown is the
only brake.
### 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 12 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 |
|---|---|---|
| **sev12** (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 leaves the box for a person to see, 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.
---
## A list is the fourth shape
Facts, notes and tasks were the three append-only shapes. `list_items` is the
fourth (Vikunja #453): an item, a status, and a list tag.
It is not a task. Milk is not work, nothing prioritises it, and the ranker must
not start counting groceries as outstanding errands. It is not a fact either,
because it claims nothing about the world. What it is, is a set that grows and
shrinks.
The property that makes the separate table worth it: no predicate reads a list.
Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So
two people adding to the same list at once cost nothing — there is no order to
disagree about and no lifecycle past crossed-off.
The unique index is the tasks one, per list, and live rows only. Saying "молоко"
twice before the shop is one line; saying it again next week, after the last one
was crossed off, is a new line.
Spoken, it is four turns: add, read back, cross one item off, cross the lot off.
All four are matched deterministically in `internal/router/list.go` and all four
run at stage 0, because an add and a read-back are cheap and should not depend on
the resident model having a good turn. Crossing one item off claims the turn only
when the list holds that item, which is what keeps "купил новый ноутбук" a note.
## 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 `docs/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 the Vikunja board
(project Maven, ID 2); this table is history, not a work list.
| # | 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 | repaired V-542 on 2026-08-13 — intent-independent utterance history; anaphoric queries reach chat context across fact/query/chat boundaries |
| 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 ~50100 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.
---
## Prior art — external memory systems
Read before proposing a change to how memory is extracted or read back. Nothing
here is adopted. Each entry says what does not transfer and what a cheap
experiment against it would be.
### VoiceMem (github.com/xzf-thu/VoiceMem, Apache 2.0)
A streaming memory system for voice assistants, surveyed 2026-08-30. Python,
Chinese-first. A "left brain" of keyed structured facts and a "right brain" of
affect and relationship nodes, both extracted and queried while the user is
still speaking.
**What does not transfer.** Its speech stack is Paraformer-zh streaming STT with
Qwen-Omni or Step-Audio2-Mini as the conversational model. Maven runs
whisper.cpp, piper and Qwen3-1.7B. It is a Python library, so adopting the code
means a Python service on homesrv behind a new daemon seam (`docs/offload.md`),
which is a large cost for a Chinese-tuned pipeline. The licence is not the
barrier.
**What is worth taking.**
- **Streaming extraction.** Extraction and retrieval start on partial
transcripts, not on a final one. Maven's `runTurn` waits for mavsttd to
finish. This is the larger win and the larger change, because it touches both
the STT seam and the turn ladder.
- **A hard retrieval token budget.** They report roughly 430 memory tokens per
query. At `n_ctx` 4096 the constraint binds directly on what memory may put in
front of the resident model. This is the cheapest experiment: measure Maven's
existing recall evals against a capped budget.
- **The fact/affect split.** The factual half is what Maven already has. Affect
and relationship nodes have no equivalent here, and they bear on § save-where.
- **A shared embedder.** They also use multilingual-E5, so their retrieval
scoring ports without a model change.
**Read their numbers carefully.** 91.2% on LoCoMo against 61.68% for Mem0 is
self-reported by the authors with no independent replication found. The 134ms
figure is memory-system latency, not a turn including STT and the resident
model. Both LoCoMo and PersonaMem are English and Chinese, so no claim there
holds for Russian recall until a translated fixture exists. Any number taken
from this section into Maven's prose needs a `docs/evals/` file behind it.
## 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. Prior art in § Prior art — external memory systems (VoiceMem).
- **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 23** — 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 (`docs/rearchitecture.md`): one resident model emits
GBNF-constrained JSON and also phrases replies; the embedder is demoted to
a RAG hint. *Landed 2026-07-31:* the LLM router is on by default and set
`true` in `deploy/mavend.json`. The classifier cascade stays as the failure
floor — it runs when there is no llama-server to talk to and on any per-turn
LLM error — but routing by seed similarity is the known cause of weak RU
query handling and is 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), revised 2026-07-31:* the resident checkpoint is
stock **Qwen3-1.7B** (`UD-Q4_K_XL`, `n_ctx` 4096), which replaced
Qwen3.5-0.8B after measuring better on both fixtures
(`docs/evals/2026-07-31-model-bakeoff.md`). The CPT'd **Qwen3-1.7B** remains the target
(#122); what stock gets wrong is the persona, not the Russian. 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.