Files
Maven/maven.md
T
2026-07-03 00:32:48 +02:00

414 lines
30 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
a self-hosted personal assistant. manages your day, acts on your homelab. all software, fully local — never phones home.
> consolidated from: spec, decisions, router, two-memory, presence, auth, stt/tts. dated 2026-06-30.
---
## capabilities
### reactive
- **converse** — voice in → stt → router → llm → tts, and text
- **act** — function calls into the homelab ("do digital things")
### proactive
- **health nudges** — hydration, meals, breaks, shower, sleep, cleanup
- **user reminders** — your stated future intent ("remind me tuesday", "wake me 7"); scheduled, fires once
- **deliver** — voice when near, ntfy/telegram/matrix when away
- **restrain** — quiet hours, per-rule cooldowns, snooze-memory, self-quieting
### capture
- throw facts/notes/tasks at it mid-flow → feeds memory
### state
- **self** — timestamped facts about you (meals, water, sleep, desk time)
- **presence** — are you here (inferred, decaying confidence — never one signal)
- **activity** — what you're doing right now
- **environment** — world facts that gate or trigger: homelab health, calendar, weather
### memory
- long-term recall, personalization (obsidian → chroma)
### feedback
- nudge outcomes (acted / snoozed / ignored) tune the rules
- you correct a bad fact; it knows its own hit rate
- self-quieting falls out of this
### identity
- consistent character — tone, values, phrasing. pinned in prompt, makes restraint legible
### surface
- talk to it (phone page, pc client)
- it reaches you (the channels above)
- **prove it's you** — auth; this thing holds your life and can act
---
## non-goals — what maven isn't
**outside boundary**
- not alexa/siri on steroids
- not a smart device
**inside boundary** (the ones that actually constrain the build)
- **not autonomous** — suggests and acts on command; proactive triggers never get unsandboxed action rights. "backup failed, rerun it?" — never reruns it herself. suggest≠act is the safety model
- **not a guesser-of-truth** — inference changes whether she asks, never what she records. a confident wrong fact is worse than a known gap
- **not a nag** — she'd rather miss a nudge than be mutable. shuts up when uncertain. load-bearing
- **not a stranger** — runs on your stuff, your model, your data. never phones home
- **not a relationship** — mom-tone is a function that makes nudges land, not emotional company. names the drift a warm small model falls into
---
## architecture
- daemon lives on homesrv (always-on), not the workstation
- trigger loop is dumb: ticks ~1min, no llm, evaluates deterministic predicates against state
- llm wakes only when a predicate fires; job is narrow — phrase + (no) final veto, not drive the loop
- lfm2.5 / sub-1b for phrasing — prompted, not trained. training effort stays on tts/stt/personality
- presence = decaying confidence score over multiple weak signals, with hysteresis; never trust one source
- self-facts → care nudges. world-facts → ops + context. same engine
- rules as code, not a config DSL — until ~30 rules and you feel the pain
- proactive triggers: read + suggest only, never action rights
### build order
state layer is first. nothing proactive works without state to evaluate predicates against — it's the floor, built before the loop, phrasing, or delivery.
### storage — sqlite
- single-user, no concurrent writers, on a box already tight on ram → sqlite, not postgres. library not a process, no port to harden, backup is `cp`
- **sqlcipher** at-rest. key read at daemon start, not hardcoded
- give up postgres `LISTEN/NOTIFY` — loop polls anyway, non-loss
```sql
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
PRAGMA busy_timeout=5000;
```
### schema — append-only, three shapes
never UPDATE a value. a wrong fact is superseded, not overwritten — keeps the audit trail. current value = latest non-voided row for a key.
**facts** — substrate, all observations (self + env)
```sql
facts (
id, ts, -- ts = valid-time (true-as-of), not insert-time
kind, -- 'self' | 'env' | 'config'
key, value, -- value json if structured
source, -- tap:* | infer:* | poll:* | ambient | promote | feedback
confidence, -- 1.0 taps only; <1 inferred
voids_id -- correction points at the fact it cancels
)
-- index (key, ts desc)
```
**reminders** — user intent, fires once
```sql
reminders ( id, created_ts, fire_ts, payload, status ) -- pending|fired|cancelled
```
**nudges** — every proactive send + outcome. this table IS the restraint memory
```sql
nudges ( id, ts, rule, channel, message, outcome, outcome_ts ) -- pending|acted|snoozed|ignored
```
**presence is not a table** — pure function over recent facts, computed each tick. only stateful bit is hysteresis:
```sql
presence_state ( last_bucket, last_score, updated_ts )
```
### trigger model
- loop ticks ~60s, no llm. 99% of ticks evaluate a few predicates and die for free
- a predicate is `(State) -> Boolean`, **pure, no i/o** → unit-tests with a fake State, zero infra
- `since(key)==null` → don't fire. silence on no-data = "shuts up when uncertain"
- **the gate is universal, applied by the loop, never per-rule** — quiet-hours, presence, cooldown, snooze, calendar-busy all live in one `fires()`. cross-cutting restraint in one place or it drifts
- one nudge per tick (max severity), never dogpile
- rules as code, not a DSL. revisit at ~30 rules
### rules decide, llm phrases
the rule decides whether maven speaks — absolute, deterministic. the llm only words it: input `(rule, severity, context)`, output `message`. **no `send`/veto bool.** a nondeterministic sub-1b model never gets to silently kill a greenlit nudge. suppression-context ("don't nag mid-meeting") moves INTO the gate as an env predicate, not the llm's job.
### user reminders — separate class
- relative→absolute **at capture** ("in 4h" → store `now+4h`, never the string)
- reuses the loop, not a second scheduler — just a predicate: `fire_ts <= now AND pending`
- **bypasses the restraint gate** — "wake me 7" fires in quiet hours; that's the point. snooze still applies. two delivery paths
---
## reactive path — router
### the decision: classifier, not slm
routing is a *decision*, and every decision in maven stays deterministic. an slm router is the one nondeterministic decider banned everywhere else. a classifier gives a confidence-scored distribution over a fixed label set, which is what routing actually wants. **classifier owns the route. slm stays in its phrasing lane.** same boundary as `rules decide, llm phrases`, extended to the reactive path.
### a cascade, not classifier-vs-deterministic
not alternatives — layers.
- **stage 0 — exact match (regex/grammar).** wake-word + known command grammar. "maven, restart nginx" hits the allowlist directly, skips the classifier. lowest latency — the vosk command path. boring high-frequency acts for free
- **stage 1 — intent classifier.** everything free-form. embed utterance, nearest-centroid over labeled intents. one forward pass, ~30ms cpu, yields a similarity score to threshold
- **stage 2 — slot extraction, per intent.** classification gives *what kind*, not *the args*. reminders need a datetime, acts need fn + params. you parse
- **stage 3 — confidence gate.** below threshold → clarify, don't guess. same pattern as `since(key)==null → don't fire`. a misrouted fact is a confident wrong write — worse than a gap
### save-where — the two-memory routing axis
one discriminator: **does the loop evaluate a predicate against it?**
| intent | example | lands in | why |
|---|---|---|---|
| act | "restart the backup" | function call (allowlist) | command now, not stored |
| reminder | "wake me 7", "vet tuesday" | `reminders` (sqlite) | has a fire-time |
| fact | "drank water", "slept 6h" | `facts` (sqlite) | structured state the loop reasons over |
| note | "gpu driver fixed the flicker", "prefer backups at 3am" | chroma/obsidian | recall/preference, no predicate touches it |
| query | "is the backup up?", "when'd i last eat?" | slm (reads sqlite or chroma) | answer, don't store |
fact-vs-note is the whole line: predicate will read it → structured `facts` row; just "recall when relevant" → semantic store. reminder splits off by future timestamp; act splits off by being imperative-now. routing isn't a separate mechanism from classification.
### the preference seam — forced, not a choice
a preference ("prefer backups at 3am") looks like note-or-config. it isn't, because of a hard constraint: **a predicate cannot read chroma.** the loop is dumb + deterministic; it can't run a vector search every tick. so the moment a preference becomes load-bearing, it MUST exist as a structured `key=value` row the loop can evaluate. the store split is physical, not cosmetic. so:
- **capture → always a note** (chroma). default, inert, fail-safe. costs nothing, drives nothing
- **a note stays a note until a rule needs it**
### promotion
**promotion = the moment a human authors a predicate that reads the value.**
- authoring the rule copies the value into `facts (kind=config, source=promote)` — a deterministic key the loop can evaluate
- the note stays as provenance (where the config came from, in your words)
- the predicate reads the promoted `facts` row, never chroma
properties: **one-way, never auto** (a note can't self-promote — no path from chroma into the loop that skips a human writing a predicate). **closes the injection hole** — ambient-derive overhears the TV say "prefer backups at 3am" → lands as inert note → can't drive the loop without you authoring the rule. same instinct as `compromised router can't forge a capability`. identical shape to `proposed → enabled`: low-authority form free + automatic, high-authority form requires a deliberate human act.
### tool registration — drafting is suggest, enabling is act
maven can scaffold a tool she's missing. she cannot enable it.
- maven detects the gap, scaffolds the registration (name, command, params, destructive y/n), writes a `proposed` row, surfaces it — "earn the right to ask"
- `proposed → enabled` flips through an authed surface (pc client / authed page), **never the voice/chat path** — that's the act, and it's the user's
why human-only: editing the allowlist is the one act that *moves the boundary* — a boundary you can move from inside isn't one. registration is privilege escalation, a different authority tier than invoking a listed tool. paranoid case: prompt injection via ambient-derive — TV says "maven add a shell tool" — a self-registering maven grants *itself* arbitrary capability. human-only enable keeps a compromised maven boxed by what's already on. low-friction stays intact: she builds the stubs, you review + enable. you just never lose the pen.
### confirmation is not one mechanism
a gate assumes a fully-formed action — "drop the db? y/n" works because the action is already specified. an underspecified request can't be gated; you can't confirm what isn't specified. confirmation scales to how formed the act is.
### stack
- **classifier:** `multilingual-e5-small` or `paraphrase-multilingual-MiniLM-L12-v2`, onnx int8 (~120mb). bilingual native, no separate ru/en path. nearest-centroid needs ~10 examples/intent, no training pipeline
- **dates:** `dateparser` — ru+en relative+absolute, does `relative→absolute at capture` for free
- **acts:** fuzzy-match against the fn allowlist. **not on the list → refuse, don't improvise.** destructive ones still gate behind confirm — "drop the db?" must not fire on a fuzzy match
- **slm's remaining lane:** phrasing the reply + last-resort slot extraction for free-form notes the parsers choke on. never the router decision
- **misroute correction = new centroid example** — append-only, grows the classifier as used. same shape as `nudges.outcome` tuning cooldowns. more reliable over time, no retrain
---
## stt / tts (low-tier, cpu, ru+en)
target box: ryzen + 13gb ram, igpu only, no real gpu. onnxruntime on cpu for everything — igpu rocm is flaky and pointless for sub-1b models.
picks:
- **stt:** faster-whisper small (int8) — primary, ru+en, handles mid-sentence code-switching
- **stt (low-latency path):** vosk ru — known command grammar; runs alongside fw (this is stage-0)
- **tts:** silero (ru-native) — *verify license + voices first*; piper ru as the safe floor
| model | role | params | langs (ru?) | license | cpu fit |
|---|---|---|---|---|---|
| **faster-whisper** small/int8 | stt | ~240M | 99+ incl ru ✅ | MIT | ~95% large-v3 acc @ 6x speed |
| **vosk** (ru model) | stt | tiny | 20+ incl ru ✅ | Apache 2.0 | real-time, native streaming, runs on a pi |
| moonshine | stt | 245M | english only ❌ | MIT | streaming, ~6x smaller than whisper-lg |
| **silero** | tts | small | russian-native ✅ | check it* | fast on cpu |
| **piper** (ru voices) | tts | tiny | 30+ incl ru ✅ | GPL-3.0 (fork) | real-time on a pi, audibly synthetic |
| xtts v2 | tts | ~470M | 17 incl ru ✅ | CPML (non-commercial) | cpu works, 5-10x slower, heavier |
| kokoro | tts | 82M | no russian ❌ | Apache 2.0 | fast on cpu, en-first |
bolded = the actual picks.
notes: faster-whisper = ctranslate2 int8, ~4x faster than vanilla whisper, lower memory. kokoro is in the table only as a "don't bother for ru" marker. ram math: fw ~0.51gb + tts ~1gb → ~10gb left for the router llm + os, comfortable, no swap. silero row is prior knowledge, no source pulled — **confirm license + current ru voices before wiring in.** bilingual / mid-sentence code-switch → lean on faster-whisper, not vosk.
---
## state — signal sources
**explicit taps set truth. passive signals drive prompting.** a passive signal can never write a self-fact — it only makes maven ask. boundary lives in `source`; rules trust provenance.
- **self / taps (1.0):** water, meal, shower — phone / voice / telegram
- **self / passive (activity, not truth):** desk-idle (hyprland / GetLastInputInfo), voice_active, sleep
- **presence (weak, decaying, multi-source):** wg handshake, page heartbeat, hyprland-not-idle. *no LAN sweeps* — too invasive; wg+heartbeat get ~90%
- **env / polled:** healthcheck (disk/service/backup/cert), calendar (caldav/.ics), weather
- **provenance-scoping:** a rule on `service_down` trusts only `source=poll:healthcheck`. a compromised poller must not be able to forge a trigger
### presence — concrete scoring
pinned: pure function over recent facts, computed each tick; decaying confidence over multiple weak signals, never one authoritative source; hysteresis to stop flapping.
**combiner — noisy-OR, not weighted sum.** these are independent-ish positive indicators of one latent binary ("kami here?").
```
p_i = weight_i · exp(-Δt_i / τ_i) # one signal's decayed contribution
P = 1 Π (1 p_i) # combine
```
diminishing returns on stacking weak signals, never exceeds 1.0. "any one signal raises confidence, no single one owns it" falls straight out. weighted-sum rejected: heartbeat+wg fresh would peg identical to everything-fresh — overcounts.
**signals — weights + decay.** `Δt` = `now latest fact ts` for that key. no fact → drops out of the product (not a zero).
| signal | source | fresh weight | τ (min) | rationale |
|---|---|---|---|---|
| desk active | `infer:hyprland` (not-idle) | 0.90 | 8 | input = human at keyboard. strongest. τ forgives read/think gaps |
| page heartbeat | `infer:heartbeat` | 0.60 | 4 | a surface you use is open + alive. pings ~30s; 4min gap = gone |
| wg handshake | `infer:wg` | 0.40 | 20 | device on tunnel. coarse — pocket or three rooms away. weakest, slowest |
**threshold + hysteresis (schmitt trigger):**
```
ENTER (away → present): P ≥ 0.55
EXIT (present → away): P < 0.30
cold start: away # fail-closed; same as since(key)==null → don't fire
```
wide band = stable. a lone fresh wg (0.40) can't *enter* present but *holds* it while decaying — phone-on-network alone never declares you here.
| scenario | P | bucket |
|---|---|---|
| at desk, typing | ~0.90 | present |
| at desk, 5min reading (client open) | ~0.79 | present |
| desk-only, ~9min zero input | <0.30 | → away |
| couch, phone page open, no desk | 0.60 | present |
| left house, only wg lingering | decays ~20min | → away |
| cold boot, nothing | 0 | away |
implementation (core — presence reads State under the lock, predicate input, not a module):
```kotlin
data class Signal(val key: String, val weight: Double, val tauMin: Double)
val SIGNALS = listOf(
Signal("desk_active", 0.90, 8.0),
Signal("page_heartbeat", 0.60, 4.0),
Signal("wg_handshake", 0.40, 20.0),
)
fun presenceScore(now: Instant, state: State): Double {
var pAway = 1.0
for (s in SIGNALS) {
val last = state.lastTs(s.key) ?: continue // no data → drops out
val dt = Duration.between(last, now).toMillis() / 60000.0
pAway *= (1.0 - s.weight * exp(-dt / s.tauMin)) // noisy-OR
}
return 1.0 - pAway
}
fun resolve(score: Double, last: Bucket) = when (last) {
PRESENT -> if (score < 0.30) AWAY else PRESENT
AWAY -> if (score >= 0.55) PRESENT else AWAY
}
```
each tick: `score = presenceScore(now, state)``bucket = resolve(score, state.lastBucket)` → persist `presence_state`. decay uses wall-clock Δt, so 60s tick jitter causes no drift.
boundaries: **hand-tuned, NOT feedback-tuned** — keep presence numbers out of the auto-tuner or a weird week drifts you silently invisible. **presence = reachability, not wakefulness** — sleep/quiet-hours handled separately in the gate. feeds delivery routing directly. caveat (stated, not litigated): noisy-OR assumes independence; desk+heartbeat correlate (pc client open ⇒ both fire) — co-firing slightly overcounts, which is fine, genuinely co-firing IS stronger evidence.
---
## proactive
### listening — three modes, not one
"ambient listening" was the wrong frame — three capabilities split by trigger + retention. threat model is local-only: the file at rest (sqlcipher, extend to any retained audio/transcript) and who can reach the box (auth). that's it.
1. **address-capture** — "maven, note this" out loud. hands-free voice path, no tap. raw audio dies after deriving the fact. **← mvp pick.** lowest retention, highest daily payoff, ~free (voice path minus the tap)
2. **meeting-record** — deliberate start/stop, verbatim transcript *kept*. retention is the point (overrides any "always delete" rule). post-mvp
3. **ambient-derive** — background overhearing, low-confidence candidate facts, raw ephemeral, only derived fact survives. needs the confidence model working first. last
only mode 3 is always-on. modes 12 fire heavy transcription on explicit trigger/record → ryzen idles. always-on = lightweight VAD (+ maybe owner-detect) only. **speaker-scoping = attribution metadata, not a kill-gate** — tag `source=ambient:self | ambient:other`. per-person retention becomes a `WHERE` clause — "delete what you have from gf" is a query, not a problem.
### delivery / channel routing
routing = `f(severity, presence)`. presence decides *reachability*; severity decides *insistence*. need both.
| | present | away |
|---|---|---|
| **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 crosses "never phones home," through your relay. **minimal body** — "disk low on homesrv," not detail. don't make notifications a shoulder-surf exfil surface.
### feedback loop (outcomes → tune cooldowns)
the `nudges.outcome` column IS the signal — no new storage. cooldown becomes a function of recent outcomes, not a constant.
- mostly `ignored` → nagging into the void → lengthen cooldown / raise threshold
- mostly `acted` → landing → leave, or cautiously shorten
- mostly `snoozed` → right nudge wrong *time* → shift the window, not the frequency
**tunes parameters, never logic.** can widen cooldown, nudge threshold, shift window. cannot rewrite a predicate or invent a rule. bounded knobs (`cooldown ∈ [min,max]`) so a weird week can't mutate maven silent or stalker. **dead simple at mvp:** a ratio over last N, not a learned model. `ignored_rate > 0.7 → cooldown *= 1.5, capped`. introspectable. **persist the adjusted cooldown as a fact** (`source=feedback`) — survives restart, stays visible; why maven went quiet should be a query, not a mystery.
---
## auth
### a cascade, not a pick-one
same shape as `confirmation is not one mechanism` — each layer answers a different question.
| layer | question | mechanism | surface |
|---|---|---|---|
| 0 — tunnel | on the network at all? | wireguard | everything. floor |
| 1 — device | enrolled box? | mTLS client cert, terminated at proxy | pc client, authed page |
| 2 — session | you, this session? | passkey / webauthn | pc client, authed page |
| 3 — step-up | you, *right now*, for this act? | passkey user-verification gesture | registration-enable, destructive acts, **core cold-start** |
wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is "authed" at layer 0 only — that gap is why the upper layers exist. **passkey over password/token** because step-up is load-bearing: webauthn gives per-assertion user-verification for free — the biometric/PIN gesture IS the human-in-the-loop. no shared secret on the box to steal; private key stays in enclave/TPM. spring security 6.4+ has native passkey support → in-stack for kotlin/spring. **mTLS (layer 1) optional** — if dropping one, drop mTLS, never the passkey.
### the invariant — surface caps authority
**auth tier is a property of the surface; the surface caps maximum authority.** you can't step up past what the channel structurally carries.
- **voice** presents layer 0 + speaker-attribution and STOPS. speaker verification is attribution, not auth. a room mic is reachable by gf, the TV, anyone present → voice is *structurally incapable* of layer 3
- **telegram inbound** = possession of a telegram account + chat-id allowlist. telegram's auth, outside our control. weak tier → read + soft acts, never destructive, never registration
so voice/chat can never reach registration-enable — **not because auth "failed" but because the channel can't carry the proof.**
### key-provenance
**the theorem:** no unattended key source survives a powered-on stolen box. anything the daemon fetches with no human present, a thief who grabs the running laptop fetches too. so the question was never "find the secure source" — it's **pick the failure mode:** unattended-but-loses-to-running-theft, or theft-resistant-but-attended. structural, can't have both.
**the trap — TPM-alone:** TPM-sealing binds release to PCRs, defeats offline disk extraction (real, worth having). but against full-box theft it does nothing — PCRs still match, thief boots, TPM unseals on cue. a laptop is portable → full-box theft is the *likely* case. TPM-alone protects the scenario you're less exposed to.
**the resolution — cold-start IS a layer-3 act:** unlocking the db makes maven's entire memory readable — the single highest-authority op. so **daemon cold-start unlock IS layer-3 step-up.** "always-on" = doesn't need babysitting during *normal operation*; does NOT mean *survives a cold boot with nobody around*. unlock is **remote-attended**`systemd-ask-password` over ssh, or pushed through the passkey-authed page. theft = box reboots into a locked daemon and stays there.
concrete stack:
| layer | mechanism | buys |
|---|---|---|
| disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline-extraction dead (TPM), powered-theft needs PIN-in-head |
| sqlcipher key | not at rest. supplied at daemon start via `systemd` credentials / `ask-password`, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal |
TPM+PIN is the honest middle. yubikey challenge-response is the upgrade path *if carried on your body* — don't reach for it yet. **runtime:** key in daemon RAM → `mlock` the page (no swap-to-disk), swap off or encrypted. zero on shutdown. **the trade:** a cold reboot needs you (remotely) present; in exchange a stolen laptop — running or off — is a brick holding ciphertext. matches the fail-closed posture.
### core/module key-isolation
the convenience (reboot-attendance collapses to *core cold-start only*) is contingent on one thing: **the key lives in core's address space and nowhere else.**
- **core = the only key-holder.** daemon holding sqlcipher-unlocked db + trigger loop. unlocked once (remote-attended), runs weeks. reboots almost never *because it was deliberately given nothing that churns* — no tool code, no model weights, no router. just state + loop
- **modules = restart-free, key-free, fail-independent.** stt/tts, router/classifier, tool executors, delivery. update/crash/swap one → none touch the unlock. "update the tool module, no attendance" is correct *by construction* — the module never had the key
enforce it: **IPC boundary, not shared address space** (unix domain socket, local-only — a crashing tts can't read the key page). **core mediates, never hands back a db handle** — modules send requests *to* core ("write this fact" / "read presence"); core never returns raw db access. **module compromise ≤ module authority** — worst a popped tts does is send garbage audio.
the discipline: **nothing enters core's process unless it must read state under the lock.** loop qualifies. predicates qualify. phrasing, routing, transcription, tool execution, delivery all read *derived* data or send requests — none need the raw key. erode this and you buy back the attendance you just eliminated. **systemd topology:** core = one unit; each module its own unit, `After=core.socket`, socket-activated, `Restart=on-failure`.
### the through-line
network → box → process: same question at three radii.
- **network (wg):** who reaches the box
- **box (LUKS+TPM+PIN, sqlcipher):** what a dead/stolen box gives up
- **process (core/module socket):** what a compromised module reaches
every cut is the same instinct — *a boundary you can move from inside isn't one*, *compromised X can't forge Y*, *attribution is not auth*. auth didn't add a new principle; it applied the existing one at smaller and smaller scope.
---
## open questions
> four items the old `decisions` doc listed as open — **router + invocation**, **two-memory routing**, **presence (concretely)**, **auth** — are now resolved by their respective sections above and are NOT listed here.
### impactful — gate behaviour or the mvp surface
- **stage-3 confidence threshold** — the gate-or-clarify number for the router. unset. defines how often maven asks vs. guesses on free-form input; the whole reactive mvp feel rides on it
- **quiet-hours definition** — fixed clock vs derived from sleep facts. gates *all* proactive delivery; can't ship the loop's restraint without it
- **confirmation tier model** — only the forced pin is settled (**registration is out-of-band, human-only**). the rest is a sketch: confirmation scales with specification + reversibility (rough buckets: just-do / binary-confirm / clarify-then-confirm / out-of-band). don't anchor on the count or any `f()`. open sub-item: **destructive-act confirm — policy-level vs per-function flag**
- **ask-password transport** — `systemd-ask-password` over ssh vs passkey-authed page push for cold-start unlock. both work; unpicked. blocks remote-attended boot being real
### plumbing / deferred — won't block the core build
- **passkey enrollment bootstrap** — first credential on a fresh device before a passkey exists to authenticate with. the trust-on-first-use gap
- **mTLS in or out** — provisioning cost on mobile vs paranoia payoff. leaning optional
- **PIN vs yubikey for LUKS** — PIN-in-head pinned for now; yubikey deferred unless carry-on-body becomes the model
- **session lifetime / re-auth cadence** — how long a layer-2 session lives before forcing re-assertion. unset
- **core↔module wire format** — socket decided, the protocol on it isn't (length-prefixed json vs something tighter)
- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one note in one utterance. classifier picks one label/utterance → compound splits (second pass) or loses half
- **query read-path** — "when do i like backups?" is RAG-over-chroma, not the sqlite read the router table implies. query-answering is chroma-RAG OR sqlite-read depending on the ask
- **obsidian↔chroma mechanics** — canonical md, derived embedding index, chunking. implementation of `obsidian → chroma`, unbuilt
- **presence — away tap override** — explicit `away` tap as hard override (voids presence to away for a duration). clean extension, deferred; scoring stands without it
- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning once the loop runs against real signal traces
- **presence — wg home-vs-cellular** — distinguish home-wifi-via-wg vs cellular-via-wg to let wg carry more weight when clearly home. needs the signal to exist first
- **personality prompt** — the identity character spec. unwritten
- **custom tts voice — train on a hand-picked voice** — future: replace the piper ru floor (irina) with a voice kami picks himself, trained/fine-tuned. per the stt/tts note, *training effort is where the personality budget goes* — this is that budget. piper supports voice training; xtts/silero fine-tune too. unscoped: pick the voice + the trainer, dataset size, cpu-vs-gpu train. deferred until the reactive+proactive core is solid; the irina floor ships first.
- **daemon runtime** — deferred until the homelab tool-call surface is real
- **listening modes 23** — meeting-record + ambient-derive. post-mvp; ambient-derive needs the confidence model working first