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