fe489dff6d
attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.
Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".
The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.
Routing fixture 58/82, unchanged from both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
922 lines
45 KiB
Markdown
922 lines
45 KiB
Markdown
# Maven — Design
|
||
|
||
*Last verified: 2026-08-02 @ 7079a24. 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, 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 `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:
|
||
`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 `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.
|
||
|
||
### 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.
|
||
|
||
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.
|
||
|
||
### 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 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 | 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 (`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.
|