414 lines
30 KiB
Markdown
414 lines
30 KiB
Markdown
# 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
|