From 612583d59af7c73cd0ee44848f76d69444584695 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 00:32:48 +0200 Subject: [PATCH] initial commit --- .gitignore | 22 + Makefile | 72 ++ PROGRESS.md | 131 ++++ START.md | 179 +++++ go.mod | 22 + go.sum | 55 ++ internal/audio/audio.go | 86 +++ internal/audio/audio_test.go | 104 +++ internal/audio/pcmwav.go | 121 ++++ internal/auth/auth_test.go | 393 ++++++++++ internal/auth/enrollment.go | 112 +++ internal/auth/gate.go | 120 ++++ internal/auth/policy.go | 168 +++++ internal/auth/scope.go | 39 + internal/auth/tier.go | 94 +++ internal/config/config.go | 297 ++++++++ internal/config/config_test.go | 131 ++++ internal/config/helpers_test.go | 20 + internal/delivery/ack.go | 26 + internal/delivery/channel.go | 94 +++ internal/delivery/dispatcher.go | 242 +++++++ internal/delivery/dispatcher_test.go | 491 +++++++++++++ internal/delivery/ntfysink/ntfysink.go | 142 ++++ internal/delivery/ntfysink/ntfysink_test.go | 313 ++++++++ internal/delivery/sendable.go | 56 ++ internal/delivery/sink.go | 32 + .../delivery/telegramsink/telegramsink.go | 199 ++++++ .../telegramsink/telegramsink_test.go | 402 +++++++++++ internal/delivery/voicesink/voicesink.go | 109 +++ internal/ipc/api.go | 273 +++++++ internal/ipc/client.go | 262 +++++++ internal/ipc/frame.go | 86 +++ internal/ipc/ipc_test.go | 330 +++++++++ internal/ipc/server.go | 670 ++++++++++++++++++ internal/ipc/wire.go | 130 ++++ internal/loop/feedback.go | 122 ++++ internal/loop/feedback_test.go | 152 ++++ internal/loop/gather.go | 150 ++++ internal/loop/loop.go | 137 ++++ internal/loop/loop_test.go | 382 ++++++++++ internal/loop/rules.go | 151 ++++ internal/loop/state.go | 109 +++ internal/phraser/llmphraser.go | 300 ++++++++ internal/phraser/phraser.go | 193 +++++ internal/phraser/phraser_test.go | 223 ++++++ internal/router/classifier.go | 160 +++++ internal/router/embedder.go | 97 +++ internal/router/embedder_test.go | 39 + internal/router/intent.go | 96 +++ internal/router/onnxembedder.go | 316 +++++++++ internal/router/router.go | 99 +++ internal/router/router_test.go | 315 ++++++++ internal/router/slots.go | 329 +++++++++ internal/router/slots_ru_test.go | 30 + internal/router/stage0.go | 55 ++ internal/store/facts.go | 182 +++++ internal/store/notes.go | 131 ++++ internal/store/notes_test.go | 38 + internal/store/nudges.go | 178 +++++ internal/store/presence.go | 113 +++ internal/store/presence_state.go | 61 ++ internal/store/presence_test.go | 145 ++++ internal/store/reminders.go | 87 +++ internal/store/schema.sql | 84 +++ internal/store/store.go | 96 +++ internal/store/store_test.go | 290 ++++++++ internal/store/tools.go | 133 ++++ internal/stt/stt.go | 112 +++ internal/stt/stt_test.go | 125 ++++ internal/tool/tool.go | 135 ++++ internal/tool/tool_test.go | 87 +++ internal/tts/tts.go | 93 +++ internal/tts/tts_test.go | 138 ++++ internal/voice/client.go | 240 +++++++ internal/voice/errors.go | 73 ++ internal/voice/frame.go | 60 ++ internal/voice/replier.go | 84 +++ internal/voice/server.go | 235 ++++++ internal/voice/session.go | 177 +++++ internal/voice/voice_test.go | 223 ++++++ internal/voice/wire.go | 182 +++++ internal/worker/client.go | 186 +++++ internal/worker/frame.go | 67 ++ internal/worker/handler.go | 25 + internal/worker/jobs.go | 54 ++ internal/worker/server.go | 226 ++++++ internal/worker/wire.go | 123 ++++ internal/worker/worker_test.go | 289 ++++++++ kill-maven.sh | 33 + maven.md | 413 +++++++++++ scripts/desk-active.sh | 25 + start-maven.sh | 100 +++ 92 files changed, 14521 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 PROGRESS.md create mode 100644 START.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/audio/audio.go create mode 100644 internal/audio/audio_test.go create mode 100644 internal/audio/pcmwav.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/enrollment.go create mode 100644 internal/auth/gate.go create mode 100644 internal/auth/policy.go create mode 100644 internal/auth/scope.go create mode 100644 internal/auth/tier.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/helpers_test.go create mode 100644 internal/delivery/ack.go create mode 100644 internal/delivery/channel.go create mode 100644 internal/delivery/dispatcher.go create mode 100644 internal/delivery/dispatcher_test.go create mode 100644 internal/delivery/ntfysink/ntfysink.go create mode 100644 internal/delivery/ntfysink/ntfysink_test.go create mode 100644 internal/delivery/sendable.go create mode 100644 internal/delivery/sink.go create mode 100644 internal/delivery/telegramsink/telegramsink.go create mode 100644 internal/delivery/telegramsink/telegramsink_test.go create mode 100644 internal/delivery/voicesink/voicesink.go create mode 100644 internal/ipc/api.go create mode 100644 internal/ipc/client.go create mode 100644 internal/ipc/frame.go create mode 100644 internal/ipc/ipc_test.go create mode 100644 internal/ipc/server.go create mode 100644 internal/ipc/wire.go create mode 100644 internal/loop/feedback.go create mode 100644 internal/loop/feedback_test.go create mode 100644 internal/loop/gather.go create mode 100644 internal/loop/loop.go create mode 100644 internal/loop/loop_test.go create mode 100644 internal/loop/rules.go create mode 100644 internal/loop/state.go create mode 100644 internal/phraser/llmphraser.go create mode 100644 internal/phraser/phraser.go create mode 100644 internal/phraser/phraser_test.go create mode 100644 internal/router/classifier.go create mode 100644 internal/router/embedder.go create mode 100644 internal/router/embedder_test.go create mode 100644 internal/router/intent.go create mode 100644 internal/router/onnxembedder.go create mode 100644 internal/router/router.go create mode 100644 internal/router/router_test.go create mode 100644 internal/router/slots.go create mode 100644 internal/router/slots_ru_test.go create mode 100644 internal/router/stage0.go create mode 100644 internal/store/facts.go create mode 100644 internal/store/notes.go create mode 100644 internal/store/notes_test.go create mode 100644 internal/store/nudges.go create mode 100644 internal/store/presence.go create mode 100644 internal/store/presence_state.go create mode 100644 internal/store/presence_test.go create mode 100644 internal/store/reminders.go create mode 100644 internal/store/schema.sql create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/tools.go create mode 100644 internal/stt/stt.go create mode 100644 internal/stt/stt_test.go create mode 100644 internal/tool/tool.go create mode 100644 internal/tool/tool_test.go create mode 100644 internal/tts/tts.go create mode 100644 internal/tts/tts_test.go create mode 100644 internal/voice/client.go create mode 100644 internal/voice/errors.go create mode 100644 internal/voice/frame.go create mode 100644 internal/voice/replier.go create mode 100644 internal/voice/server.go create mode 100644 internal/voice/session.go create mode 100644 internal/voice/voice_test.go create mode 100644 internal/voice/wire.go create mode 100644 internal/worker/client.go create mode 100644 internal/worker/frame.go create mode 100644 internal/worker/handler.go create mode 100644 internal/worker/jobs.go create mode 100644 internal/worker/server.go create mode 100644 internal/worker/wire.go create mode 100644 internal/worker/worker_test.go create mode 100755 kill-maven.sh create mode 100644 maven.md create mode 100755 scripts/desk-active.sh create mode 100755 start-maven.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3476b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Built binaries +mavend +mavenclient +mavsttd +mavttsd +mavweb +mavpoll + +# Dependencies (fetch/build, not vendored) +deps/ + +# ML models (large, downloaded separately) +models/ + +# Runtime data +*.db + +# Certs (private keys, don't commit) +certs/ + +# Temp files +/tmp/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e41550 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +GO := $(shell pwd)/deps/go/go/bin/go +GOFLAGS := +CGO_LDFLAGS := -L$(shell pwd)/deps/lib -Wl,-rpath,$(shell pwd)/deps/lib +CGO_CFLAGS := -I$(shell pwd)/deps/include -I$(shell pwd)/deps/whisper.cpp/ggml/include + +WHISPER_MODEL := $(shell pwd)/models/stt/ggml-small.bin +PIPER_BIN := $(shell pwd)/deps/piper/piper +PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx +PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data + +.PHONY: all build build-stt build-tts build-daemon build-client build-web build-poll clean test run-stt run-tts run-web + +all: build + +build: build-stt build-tts build-daemon build-client build-web build-poll + +build-stt: + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + $(GO) build $(GOFLAGS) -o mavsttd ./cmd/mavsttd/ + +build-tts: + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + $(GO) build $(GOFLAGS) -o mavttsd ./cmd/mavttsd/ + +build-daemon: + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + $(GO) build $(GOFLAGS) -o mavend ./cmd/mavend/ + +build-client: + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + $(GO) build $(GOFLAGS) -o mavenclient ./cmd/mavenclient/ + +build-web: + $(GO) build $(GOFLAGS) -o mavweb ./cmd/mavweb/ + +build-poll: + $(GO) build $(GOFLAGS) -o mavpoll ./cmd/mavpoll/ + +run-web: build-web + ./mavweb -addr :9200 -voice 127.0.0.1:9100 + +test: + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + $(GO) test ./internal/... + +run-stt: build-stt + LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + ./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL) + +run-tts: build-tts + LD_LIBRARY_PATH="$(shell pwd)/deps/piper" \ + ./mavttsd -socket /tmp/maven/tts.sock \ + -piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK) + +deps: deps-whisper deps-piper + +deps-whisper: + cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \ + -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_SERVER=OFF && \ + cmake --build build --config Release -j$$(nproc) + cp deps/whisper.cpp/build/bin/libwhisper.so* deps/lib/ + cp deps/whisper.cpp/build/bin/libggml*.so* deps/lib/ + cp deps/whisper.cpp/build/bin/libparakeet.so* deps/lib/ + +deps-piper: + mkdir -p deps/ + curl -sL "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz" \ + -o /tmp/piper.tar.gz + tar -xzf /tmp/piper.tar.gz -C deps/ + +clean: + rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..f7eedba --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,131 @@ +## Maven — current state (2026-07) + +Consolidated status. The reactive↔proactive core is closed and testable through +the web PWA today. Big untouched capability: **acting on the homelab** (tool +executor). Big untouched infra: **at-rest security** (sqlcipher/passkey). + +### Access model + +- **Phone** → needs the wg tunnel to reach homesrv (no homesrv DNS otherwise; + raw IP or a DNS tweak can bypass, not the default). +- **PC** → uses homesrv DNS, resolves the domains over local-net, **no wg needed**. +- nginx + ufw both scope to `10.42.0.0/24` (wg) + `192.168.1.0/24` (LAN), deny all else. +- **Surface in use now: the web PWA (`mavweb`).** Voice PTT + in-app nudges both ride it. + +### Works end-to-end (tested) + +- **Reactive voice:** PWA record → Whisper STT (`mavsttd`) → ONNX classifier → + LFM 2.5-1.2B phraser (llama-server subprocess) → Piper TTS (`mavttsd`) → reply. + HTTP POST path (mobile-Chrome drops WS for the audio). +- **Capture:** `fact` (EN **and RU** — root-substring recognizers) + `reminder` + persist through CoreAPI (`source=tap:voice`). This is the substrate the care + rules read. +- **Notes / query (semantic recall, sqlite — no chroma):** `note` → embed (the + classifier's ONNX embedder) → `notes` table. `query` → embed → brute-force + cosine top-k → confidence-gated answer (below `queryMinScore` 0.55 ⇒ "no note", + not a guess). Verbatim top-hit reply; full top-k phraser-RAG is the follow-up. +- **Monitoring (`/dash`):** mavweb server-renders presence + recent nudges (by + outcome) + recent facts from the append-only store via CoreAPI. Read-only, + meta-refresh, no JS. +- **Proactive loop:** 60s dumb ticker, pure predicates over a State snapshot, + universal gate (quiet-hours/presence/cooldown/snooze/calendar), one-nudge-per- + tick max-severity, reminders (gate-bypassing), sev4 repeat-til-ack, feedback + auto-tuner (outcome ratio → bounded cooldown, persisted as `source=feedback`). +- **Rules:** water/meal/break (sev1–2 care), service_down (sev4, `poll:uptimekuma`), + netdata_critical (sev3, `poll:netdata`). +- **Env facts (`mavpoll`):** netdata alarms → `netdata_alarm` (fires immediately + on a real CRITICAL); kuma monitor_status → `service_down`. Writes only on + value-change (no append-only churn). +- **Presence:** noisy-OR decay + Schmitt hysteresis. Live via `page_heartbeat` + (PWA auto-pings `/api/signal` every 30s → present when a tab's open). +- **Delivery:** ntfy / telegram / voice by `f(severity, presence)`; minimal body + on away channels. PWA subscribes to ntfy over **WebSocket** for in-app nudges. +- **Stability:** llama-server orphan leak fixed (`Pdeathsig` kills the child on + any mavend death); `kill-maven.sh` reaps strays (matches the model, not a + bogus `llama-server.*maven` pattern); `start-maven.sh` wires `-core` + poller. + +### Wired but needs a deploy action (not code) + +- **`desk_active`** (strongest presence signal) — `scripts/desk-active.sh` runs + on the **desk PC** (hypridle-gated systemd timer), posts over wg to mavweb. +- **Kuma `service_down`** — needs an API key created in Kuma → Settings → API Keys, + passed to `mavpoll -kuma-key`. + +Caveats / gotchas: +- **desk_active is a workstation deploy, not code** — 0 facts ever written; presence + runs on page_heartbeat alone (dash reads "away"/"never at desk"). `scripts/desk-active.sh` + + a hypridle-gated `maven-desk` timer must be installed on the desk PC (not homesrv). +- **Notes recall needs the ONNX embedder** — under the HashEmbedder floor, cosine is + lexical (token overlap), not semantic; scores are low, so most RU commands sit under + the 0.35 route threshold and clarify. Configure `voice.embedder` for confident recall+routing. + (The floor now at least tokenizes Cyrillic — see below — so it ranks correctly, just weakly.) +- **Switching the embedder model silently breaks old notes** — different dim ⇒ + cosine 0 ⇒ they stop matching; brute-force can't re-embed. Re-embed on a model change. +- **`wg_handshake` is OFF and should stay off** — in this topology the phone only + runs wg when *outside*, so a fresh handshake means AWAY, not here. The `mavpoll + -wg` flag exists (defaults `""`) and could later back the spec's "away override" + by flipping the sign; as a presence-*here* signal it's inverted. desk_active + + page_heartbeat cover home presence. + +### Not built yet (ranked by ROI) + +1. **Full note RAG** — today the `query` reply is the verbatim top-hit note. + Follow-up: feed gated top-k to the phraser to compose an answer. +2. **quiet-hours source** — gate reads a `quiet_hours` config fact nothing writes + (defaults not-quiet; restraint unenforced). +3. **Security layer** — sqlcipher at-rest, cold-start unlock, real passkey auth. + Today: plain sqlite, `FloorEnrollment` (any same-uid caller = full L3). + +Done since last revision: **act tool executor, store-backed, full flow** +(`internal/tool` + `internal/store/tools.go` + `tools` CoreAPI methods). +- **Execution:** IntentAct runs the matched fn against the store's ENABLED + allowlist. argv, no shell → STT text can't inject. Live store read, so a + newly-enabled tool runs without a daemon restart. +- **proposed→enabled:** an act whose verb isn't enabled is scaffolded as a + `proposed` tool (maven suggests). A human enables it (fills argv + destructive) + on the authed **`mavweb /tools`** page — never voice. `EnableTool` sits at + `AuthStepUp` in the policy table (gate lands with the security layer). +- **Confirm turn:** a destructive enabled tool replies "выполнить X? да/нет" and + parks; the next utterance (ru/en yes-no) confirms or cancels (90s TTL). +- **Config:** `voice.tools` seeds enabled tools at boot (editing mavend.json = + the human enable act); mavweb enables ad-hoc ones on top. +- **Russian:** fixed grammar in reply strings + seed files; maven's self- + reference is feminine ("she") — [[maven-persona-gender]]. + +Also fixed: +- **HashEmbedder was blind to Cyrillic** (`tokenize` iterated bytes, kept only + `a-z0-9`) → every RU utterance embedded to the zero vector → cosine 0 across + all intents → misrouted to `act` (alphabetical tie-break). Now rune-based + (`unicode.IsLetter`). This was the real cause of "Найди заметку" (a query) + landing in `notes`; added note-retrieval query seeds too. +- **Notes are now browsable on `/dash`** — `RecentNotes` plumbed through the + store + CoreAPI; voice-captured notes were previously only reachable via + semantic `query`. +Earlier: notes/query recall, `/dash` monitoring, `wg_handshake` poller (NO-OP). + +### Future / logged, not now + +Personality prompt; custom TTS voice training (kami-picked voice, replaces irina +floor); listening modes 2–3 (meeting-record, ambient-derive). + +### Services & layout + +- `mavend` (core, IPC unix socket) — store + loop + phraser; the only key-holder. +- `mavsttd` / `mavttsd` — STT/TTS worker modules (unix sockets). +- `mavweb` — PWA bridge (HTTP), `/api/ptt` voice, `/api/signal` presence ingest, + `/api/ntfy` WS-subscribe config, `/dash` read-only monitoring. +- `mavpoll` — env poller (netdata/kuma → facts via CoreAPI). +- All behind wg + nginx deny-all; no phone-home. CGo only in `mavsttd`. +- Start/stop: `./start-maven.sh [build]`, `./kill-maven.sh`. +- Config: `~/.config/maven/mavend.json` (or `mavend.json` in repo root). + +### Key files + +- `cmd/mavend/{main,tick,voice}.go` — daemon wiring, loop driver, voice handler +- `internal/loop/{loop,rules,gather,feedback}.go` — proactive engine +- `internal/store/` — append-only facts/reminders/nudges/presence/notes +- `cmd/mavweb/{main.go,dash.html}` — PWA bridge + `/dash` monitoring +- `internal/router/{classifier,slots,stage0}.go` — reactive routing + slot parse +- `internal/delivery/` — dispatcher + ntfy/telegram/voice sinks +- `internal/auth/` — scope/gate/enrollment (floor today) +- `cmd/mavpoll/`, `scripts/desk-active.sh` — env + presence producers diff --git a/START.md b/START.md new file mode 100644 index 0000000..be56c6f --- /dev/null +++ b/START.md @@ -0,0 +1,179 @@ +# Start Commands + +All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`. + +## Prerequisites + +```bash +export ROOT=/home/kami/apps/Maven +export CGO_CFLAGS="-I$ROOT/deps/include -I$ROOT/deps/whisper.cpp/ggml/include" +export CGO_LDFLAGS="-L$ROOT/deps/lib -Wl,-rpath,$ROOT/deps/lib" +export LD_LIBRARY_PATH="$ROOT/deps/lib" +export PATH="$ROOT/deps/go/go/bin:$PATH" +``` + +## Build everything + +```bash +cd "$ROOT" +go build ./cmd/mavend/ +go build ./cmd/mavsttd/ # needs CGO (whisper.cpp) +go build ./cmd/mavttsd/ # pure Go +``` + +## mavend — daemon (core) + +```bash +cd "$ROOT" +./mavend -config mavend.json +``` + +Config path: `~/.config/maven/mavend.json`. Full example with all options: + +```json +{ + "db_path": "/home/kami/.local/share/maven/maven.db", + "socket_path": "/run/user/1000/maven/mavend.sock", + "tick_interval": "60s", + "repeat_interval": "5m", + "ntfy": { + "base_url": "https://ntfy.kvmx.ru", + "topic": "maven" + }, + "phraser": { + "model_path": "/mnt/hdd1/llms/LFM2.5/LFM2.5-1.2B-Instruct-Q4_K_M.gguf", + "bin_path": "/usr/local/bin/llama-server", + "n_gpu_layers": -1 + }, + "voice": { + "enabled": true, + "bind": "127.0.0.1:9100", + "lang": "ru", + "embedder": { + "model_path": "models/embedder/model.onnx", + "tokenizer_path": "models/embedder/tokenizer.json", + "lib_path": "deps/onnxruntime-linux-x64-1.17.1/lib/libonnxruntime.so.1.17.1" + } + } +} +``` + +## mavsttd — STT worker (optional, remote whisper.cpp) + +Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so). + +```bash +cd "$ROOT" +export LD_LIBRARY_PATH="$ROOT/deps/lib" +./mavsttd -socket /run/user/$UID/maven/stt.sock -model models/stt/ggml-small.bin +``` + +Without `-model` it runs as a stub (deterministic, no ML). + +## mavttsd — TTS worker (optional, remote Piper) + +Requires `LD_LIBRARY_PATH` to include deps/piper (for Piper's espeak-ng). + +```bash +cd "$ROOT" +export LD_LIBRARY_PATH="$ROOT/deps/piper" +./mavttsd -socket /run/user/$UID/maven/tts.sock -piper deps/piper/piper -model models/tts/ru_RU-irina-medium.onnx -espeak_data deps/piper/espeak-ng-data +``` + +Without `-piper` it runs as a stub. + +## mavweb — PWA voice bridge (WebSocket ↔ TCP) + +No CGo, no deps; builds with stock Go. + +```bash +cd "$ROOT" +go build ./cmd/mavweb/ +./mavweb -addr :9200 -voice 127.0.0.1:9100 +``` + +To also receive proactive nudges in-app, pass the ntfy WebSocket subscribe URL +(the PWA connects to it directly; the auth token stays server-side config): + +```bash +./mavweb -addr :9200 -voice 127.0.0.1:9100 \ + -ntfy 'wss://ntfy.kvmx.ru/maven/ws?auth=' +``` + +`` is a *read*-capable ntfy access token, base64url-encoded +(ntfy's browser-WS auth: `Bearer tk_...` can't set a header, so ntfy takes it as +the `?auth=` query param). Without `-ntfy`, the PWA stays voice-only. + +### presence-signal ingest (`-core`) + +Pass mavend's IPC socket so mavweb can feed presence via `/api/signal`: + +```bash +./mavweb -addr :9200 -voice 127.0.0.1:9100 \ + -core /run/user/1000/maven/mavend.sock +``` + +- **page_heartbeat** (weak, τ=4min) — the PWA auto-pings every 30s. Nothing to do. +- **desk_active** (strongest, τ=8min) — a *workstation* signal (hyprland), so it + can't be a homesrv module. Run `scripts/desk-active.sh` on the PC via a + systemd-user timer, gated by hypridle (see the script header). Posts over wg. +- **wg_handshake** (coarse, τ=20min) — still unfed; it's homesrv-local + (`wg show latest-handshakes`), a natural small poller to add next. + +Allowlisted keys only; without `-core`, `/api/signal` returns 503 and presence +stays cold-start `away`. + +Open http://10.42.0.1:9200/ (or http://voice.kvmx.ru:9200/) on your phone from +inside the WireGuard tunnel. Tap & hold to speak; release to send; the reply +plays automatically. + +## mavpoll — env poller (netdata + uptime-kuma → facts) + +Thin adapter: reads netdata alarms + kuma monitor status and writes `env` facts +through core's IPC socket. This is what makes `service_down` (sev4) and +`netdata_critical` (sev3) rules fire on real data. Runs on homesrv where both +services live — hit them on localhost, not the public `.kvmx.ru` names. + +```bash +cd "$ROOT" +go build ./cmd/mavpoll/ +# netdata only (kuma disabled until its API key exists): +./mavpoll -socket /run/user/$UID/maven/mavend.sock -netdata http://127.0.0.1:19999 +# with kuma: create an API key in Kuma → Settings → API Keys, then: +./mavpoll -socket /run/user/$UID/maven/mavend.sock \ + -netdata http://127.0.0.1:19999 \ + -kuma http://127.0.0.1:3001/metrics -kuma-key +``` + +Writes only on value change (append-only, no per-tick churn). `service_down` +aggregates any monitor reading 0 as "down"; per-service granularity is a later +add. netdata `-timeout`/`-interval` tunable; defaults 8s / 60s. + +### nginx (optional) + +```bash +sudo cp cmd/mavweb/nginx.conf /etc/nginx/sites-available/voice.kvmx.ru +sudo ln -sf /etc/nginx/sites-available/voice.kvmx.ru /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +## Quick smoke test (stubs, no models) + +```bash +cd "$ROOT" +./mavend -config mavend.json # voice enabled, no stt/tts/embedder config → all stubs +``` + +## Run all tests + +```bash +cd "$ROOT" +go test ./internal/router/ ./internal/delivery/... ./cmd/mavend/ ./cmd/mavsttd/ ./cmd/mavttsd/ +``` + +## Benchmark + +```bash +cd "$ROOT" +go test -bench=. ./internal/router/ ./cmd/mavsttd/ ./cmd/mavttsd/ +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f0b5660 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/kami/maven + +go 1.23 + +require ( + golang.org/x/sys v0.22.0 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/coder/websocket v1.8.12 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/yalue/onnxruntime_go v1.31.0 // indirect + golang.org/x/text v0.3.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7886815 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA= +github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/audio/audio.go b/internal/audio/audio.go new file mode 100644 index 0000000..c39f59f --- /dev/null +++ b/internal/audio/audio.go @@ -0,0 +1,86 @@ +// Package audio is maven's shared audio types. +// +// Audio crosses two boundaries in maven, and only two: +// +// - client → core (the PushToTalk payload over internal/voice): raw audio +// bytes the client captured after wake-word + VAD (per the spec's "clients +// do capture, server transcribes" decision). one clean blob per utterance. +// - core → worker module (/internal/worker for stt + tts): raw bytes the +// server ships to / from the transcription + synthesis modules. core is +// the worker's client here; the worker never reaches back. +// +// Format is fixed across both surfaces at scaffold time: 16 kHz mono int16 +// little-endian PCM. faster-whisper, vosk, silero, and piper all work with +// 16 kHz mono; picking one shape up-front keeps the wire a single format +// field rather than a negotiation. A different rate upstream (e.g. an 8 kHz +// phone codec) downmixes at the client before send, never on the server — +// resampling on the always-on box is wasted work. +// +// Bytes are raw PCM, NOT a container (no WAV header on the wire). The client +// strips/generates WAV headers locally so the server's worker modules get +// exactly the bytes their model expects — there is no useful reason to ship +// a 44-byte header through a length-prefixed JSON frame. internal/pcmwav +// (a tiny helper below) is the WAV ⇄ PCM pair the reference client uses. +package audio + +// Format describes raw PCM audio. Fixed at scaffold time; the wire carries +// it but today only one value is meaningful. Future: a negotiated registry +// if a second codec lands (e.g. opus, for a low-bitrate phone PWA path). +type Format struct { + SampleRate int `json:"sample_rate"` // samples/sec; default 16000 + Channels int `json:"channels"` // 1 = mono + SampleBits int `json:"sample_bits"` // 16 ⇒ int16 little-endian PCM + Encoding string `json:"encoding"` // "pcm_s16le" is the only value today +} + +// PCM16kMono — the canonical maven audio shape. faster-whisper, silero, +// vosk, piper all consume it. Set as the default at every seam; the wire +// carries the explicit fields so a second format doesn't need a protocol +// version bump when it lands, just a new value here. +var PCM16kMono = Format{ + SampleRate: 16000, + Channels: 1, + SampleBits: 16, + Encoding: "pcm_s16le", +} + +// Audio — one blob. Bytes is raw PCM in Format (no container header). The +// caller that built it (client capture, TTS synthesis output, a stub) knows +// the duration from len(Bytes) / Format.bytesPerSample() / SampleRate. +type Audio struct { + Format Format `json:"format"` + Bytes []byte `json:"bytes"` // raw PCM; base64 over the wire via worker/voice JSON marshal +} + +// Duration returns the playback duration implied by Bytes + Format. Returns +// 0 for empty Bytes or an unknown encoding. A sanity helper, not a contract: +// callers that need the duration for display use this; callers that need it +// for real (e.g. cooldown math) read it from the facts table, not the audio. +func (a Audio) Duration() float64 { + if len(a.Bytes) == 0 { + return 0 + } + if a.Format.SampleRate <= 0 || a.Format.Channels <= 0 || a.Format.SampleBits <= 0 { + return 0 + } + if a.Format.Encoding != "pcm_s16le" { + return 0 + } + bytesPerSample := a.Format.SampleBits / 8 + if bytesPerSample == 0 { + return 0 + } + samples := float64(len(a.Bytes)) / float64(bytesPerSample*a.Format.Channels) + return samples / float64(a.Format.SampleRate) +} + +// IsValid reports whether f is a format maven can route today. Returns true +// only for the single canonical shape; a value with a different encoding is +// refused at the seam rather than mis-routed to a model that expects +// something else. +func (f Format) IsValid() bool { + return f.SampleRate == PCM16kMono.SampleRate && + f.Channels == PCM16kMono.Channels && + f.SampleBits == PCM16kMono.SampleBits && + f.Encoding == PCM16kMono.Encoding +} \ No newline at end of file diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go new file mode 100644 index 0000000..710739b --- /dev/null +++ b/internal/audio/audio_test.go @@ -0,0 +1,104 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestAudioDuration(t *testing.T) { + t.Parallel() + // 16k mono int16 ⇒ 2 bytes/sample ⇒ 32000 bytes/second. + a := Audio{Format: PCM16kMono, Bytes: make([]byte, 32000)} + if got, want := a.Duration(), 1.0; got != want { + t.Fatalf("Duration: got %v, want %v", got, want) + } + // 4 seconds + a.Bytes = make([]byte, 128000) + if got, want := a.Duration(), 4.0; got != want { + t.Fatalf("Duration: got %v, want %v", got, want) + } +} + +func TestAudioDurationEmptyAndBad(t *testing.T) { + t.Parallel() + if (Audio{}).Duration() != 0 { + t.Fatalf("empty audio: Duration should be 0") + } + a := Audio{Format: Format{}, Bytes: make([]byte, 32000)} + if a.Duration() != 0 { + t.Fatalf("zero format: Duration should be 0") + } + a = Audio{Format: Format{SampleRate: 16000, Channels: 1, SampleBits: 16, Encoding: "opus"}, Bytes: make([]byte, 32000)} + if a.Duration() != 0 { + t.Fatalf("unsupported encoding: Duration should be 0") + } +} + +func TestFormatIsValid(t *testing.T) { + t.Parallel() + if !PCM16kMono.IsValid() { + t.Fatalf("PCM16kMono should be valid") + } + if (Format{SampleRate: 8000, Channels: 1, SampleBits: 16, Encoding: "pcm_s16le"}).IsValid() { + t.Fatalf("8k should be rejected") + } + if (Format{SampleRate: 16000, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}).IsValid() { + t.Fatalf("stereo should be rejected") + } +} + +func TestWAVRoundTrip(t *testing.T) { + t.Parallel() + // 0.5s of square wave (alternating samples) — sndfile/aplay can play it. + const nsamp = 8000 + pcm := make([]byte, nsamp*2) + for i := 0; i < nsamp; i++ { + var v int16 = -16384 + if i%2 == 0 { + v = 16384 + } + binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v)) + } + wav, err := WAVFromPCM(PCM16kMono, pcm) + if err != nil { + t.Fatalf("WAVFromPCM: %v", err) + } + if len(wav) != 44+len(pcm) { + t.Fatalf("wav length: got %d, want %d", len(wav), 44+len(pcm)) + } + if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" { + t.Fatalf("missing RIFF/WAVE marker: %q", wav[0:12]) + } + f, pcm2, err := PCMFromWAV(wav) + if err != nil { + t.Fatalf("PCMFromWAV: %v", err) + } + if !f.IsValid() { + t.Fatalf("parsed format invalid: %+v", f) + } + if !bytes.Equal(pcm, pcm2) { + t.Fatalf("PCM mismatch after round-trip: in=%d bytes, out=%d bytes", len(pcm), len(pcm2)) + } +} + +func TestPCMFromWAVRejectsNonCanonical(t *testing.T) { + t.Parallel() + // too short + if _, _, err := PCMFromWAV([]byte("RIFF")); err == nil { + t.Fatalf("short input should error") + } + // bad RIFF marker + bad := make([]byte, 44) + copy(bad[0:4], []byte("RIFF")) + copy(bad[8:12], []byte("XXXX")) + if _, _, err := PCMFromWAV(bad); err == nil { + t.Fatalf("non-WAVE marker should error") + } + // format code 3 (float), canonical otherwise + wav, _ := WAVFromPCM(PCM16kMono, []byte{0, 0}) + binary.LittleEndian.PutUint16(wav[20:22], 3) // IEEE float, not PCM + if _, _, err := PCMFromWAV(wav); err == nil { + t.Fatalf("non-PCM format should error") + } +} \ No newline at end of file diff --git a/internal/audio/pcmwav.go b/internal/audio/pcmwav.go new file mode 100644 index 0000000..210918a --- /dev/null +++ b/internal/audio/pcmwav.go @@ -0,0 +1,121 @@ +// audio/pcmwav.go — tiny WAV ⇄ PCM helpers. +// +// The reference client (cmd/mavenclient) speaks WAV on disk (`arecord -f` +// produces it; `aplay` plays it) and raw PCM on the wire (audio.Audio.Bytes +// is headerless per the audio package). These helpers do the 44-byte +// canonical-PCM-WAV strip/produce dance — a full WAV parser is overkill for +// a single fixed format, and pulling in a third-party WAV library violates +// the "stdlib + modernc only" floor. +// +// Only canonical 16-bit PCM mono WAV (format 1, channel count = 1, bitsPerSample +// = 16) is supported. A non-conforming WAV is rejected with ErrNotCanonicalPCM +// rather than silently mis-routing — the client should produce canonical +// audio (arecord -f cd -r 16000 default; or mavenclient -r) and the helper +// refuses anything else so we don't ship garbage to a model expecting 16k mono. +package audio + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// ErrNotCanonicalPCM — the WAV blob isn't canonical 16-bit mono PCM. Either +// it's a different container (RIFF WAVE with a different format code), wrong +// bit depth, or multi-channel. Refused at the seam rather than resampled — +// resampling on the always-on box wastes cycles, and the client is in the +// better position to do it (it has the platform audio stack). +var ErrNotCanonicalPCM = errors.New("audio: not canonical 16-bit mono PCM WAV") + +// wavHeaderSize — the canonical 44-byte PCM WAV header (RIFF + fmt + data, +// each chunk exactly minimum-size). Everything else is an extension we +// don't read and shouldn't accept silently. +const wavHeaderSize = 44 + +// PCMFromWAV strips a canonical 16-bit mono PCM WAV header and returns the +// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is +// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam +// so the caller surfaces it, not a hidden silent downmix. +func PCMFromWAV(wav []byte) (Format, []byte, error) { + if len(wav) < wavHeaderSize { + return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav)) + } + if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" { + return Format{}, nil, fmt.Errorf("%w: missing RIFF/WAVE", ErrNotCanonicalPCM) + } + if string(wav[12:16]) != "fmt " { + return Format{}, nil, fmt.Errorf("%w: missing fmt chunk", ErrNotCanonicalPCM) + } + fmtSize := binary.LittleEndian.Uint32(wav[16:20]) + if fmtSize != 16 { + return Format{}, nil, fmt.Errorf("%w: fmt chunk size %d (not 16)", ErrNotCanonicalPCM, fmtSize) + } + audioFormat := binary.LittleEndian.Uint16(wav[20:22]) + if audioFormat != 1 { + return Format{}, nil, fmt.Errorf("%w: format code %d (not PCM=1)", ErrNotCanonicalPCM, audioFormat) + } + channels := int(binary.LittleEndian.Uint16(wav[22:24])) + sampleRate := int(binary.LittleEndian.Uint32(wav[24:28])) + bitsPerSample := int(binary.LittleEndian.Uint16(wav[34:36])) + if channels != 1 || bitsPerSample != 16 { + return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample) + } + // data chunk: the spec mandates it appears right after fmt, but real + // recorders sometimes append extra chunks (LIST, fact). Find the "data" + // chunk by scanning; require it within the region we'd expect. + dataIdx := -1 + for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ { + if string(wav[i:i+4]) == "data" { + dataIdx = i + break + } + } + if dataIdx < 0 { + return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM) + } + dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8]) + body := wav[dataIdx+8:] + if dataSize != 0 && int(dataSize) < len(body) { + body = body[:dataSize] + } + f := Format{ + SampleRate: sampleRate, + Channels: 1, + SampleBits: 16, + Encoding: "pcm_s16le", + } + if !f.IsValid() { + return Format{}, nil, fmt.Errorf("%w: rate %d (want 16000)", ErrNotCanonicalPCM, sampleRate) + } + return f, body, nil +} + +// WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV +// header so the result can be written to disk and played with `aplay`. +// Used by the reference client to write the TTS reply; not on the wire. +func WAVFromPCM(format Format, pcm []byte) ([]byte, error) { + if !format.IsValid() { + return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format) + } + out := make([]byte, wavHeaderSize+len(pcm)) + copy(out[wavHeaderSize:], pcm) + // RIFF header + copy(out[0:4], []byte("RIFF")) + binary.LittleEndian.PutUint32(out[4:8], uint32(36+len(pcm))) + copy(out[8:12], []byte("WAVE")) + // fmt chunk + copy(out[12:16], []byte("fmt ")) + binary.LittleEndian.PutUint32(out[16:20], 16) // fmt chunk size + binary.LittleEndian.PutUint16(out[20:22], 1) // PCM + binary.LittleEndian.PutUint16(out[22:24], uint16(format.Channels)) + binary.LittleEndian.PutUint32(out[24:28], uint32(format.SampleRate)) + byteRate := uint32(format.SampleRate) * uint32(format.Channels) * uint32(format.SampleBits) / 8 + binary.LittleEndian.PutUint32(out[28:32], byteRate) + blockAlign := uint16(format.Channels) * uint16(format.SampleBits) / 8 + binary.LittleEndian.PutUint16(out[32:34], blockAlign) + binary.LittleEndian.PutUint16(out[34:36], uint16(format.SampleBits)) + // data chunk + copy(out[36:40], []byte("data")) + binary.LittleEndian.PutUint32(out[40:44], uint32(len(pcm))) + return out, nil +} \ No newline at end of file diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..c6c32bb --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,393 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +func TestMaxLayer_SurfaceCapsAuthority(t *testing.T) { + // The invariant: surface caps maximum authority. A surface structurally + // unable to carry passkey-user-verification caps below L3 — voice never + // reaches step-up, telegram never reaches step-up, pc_client + authed_page + // + the daemon's own process DO. + cases := []struct { + surface Surface + max Layer + }{ + {SurfaceVoice, Layer0}, + {SurfaceTelegram, Layer2}, + {SurfacePCClient, Layer3}, + {SurfaceAuthedPage, Layer3}, + {SurfaceCoreProcess, Layer3}, + {SurfaceUnknown, -1}, + } + for _, c := range cases { + got := MaxLayer(c.surface) + if got != c.max { + t.Errorf("MaxLayer(%s) = %d; want %d (surface caps authority)", c.surface, got, c.max) + } + } +} + +func TestSourceAllowed(t *testing.T) { + cases := []struct { + scope []string + src string + want bool + }{ + {[]string{"*"}, "anything", true}, + {[]string{"poll:healthcheck"}, "poll:healthcheck", true}, + {[]string{"poll:healthcheck"}, "poll:uptime", false}, // compromised poller can't forge a trigger + {[]string{"poll:healthcheck", "tap:water"}, "tap:water", true}, + {[]string{"poll:healthcheck", "tap:water"}, "ambient", false}, + {[]string{}, "anything", false}, // fail closed + {nil, "anything", false}, + } + for _, c := range cases { + if got := SourceAllowed(c.scope, c.src); got != c.want { + t.Errorf("SourceAllowed(%v, %q) = %v; want %v", c.scope, c.src, got, c.want) + } + } +} + +func TestRequirement_Table(t *testing.T) { + // WriteFact is AuthWrite (carries source-scope); EnableTool is AuthStepUp + // (registration-enable moves the boundary); all others are AuthRead. + if got := Requirement(ipc.MethodWriteFact); got != AuthWrite { + t.Errorf("WriteFact authority = %v; want AuthWrite", got) + } + if got := Requirement(ipc.MethodEnableTool); got != AuthStepUp { + t.Errorf("EnableTool authority = %v; want AuthStepUp", got) + } + reads := []ipc.Method{ + ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince, + ipc.MethodPresence, ipc.MethodRecentOutcomes, + ipc.MethodCreateReminder, ipc.MethodMarkReminder, + ipc.MethodRecordNudge, ipc.MethodResolveNudge, + } + for _, m := range reads { + if got := Requirement(m); got != AuthRead { + t.Errorf("%s authority = %v; want AuthRead", m, got) + } + } +} + +func TestGate_EnableTool_StepUp(t *testing.T) { + // EnableTool is AuthStepUp. At the floor (FloorEnrollment L3 + FloorSession + // asserting L3), the local caller may enable — the authed mavweb /tools page + // hits this path. With a nil Session (no step-up asserted), it's refused — + // step-up can't be granted from what wasn't demonstrated. + ctx := context.Background() + withSession := &Gate{Enrollment: NewFloorEnrollment(), Session: FloorSession{}} + if err := withSession.Check(ctx, ipc.MethodEnableTool, nil); err != nil { + t.Errorf("EnableTool with FloorSession = %v; want nil (floor permits)", err) + } + noSession := &Gate{Enrollment: NewFloorEnrollment()} + if err := noSession.Check(ctx, ipc.MethodEnableTool, nil); !errors.Is(err, ipc.ErrForbidden) { + t.Errorf("EnableTool with nil Session = %v; want ErrForbidden (fail closed)", err) + } +} + +func TestCan_Unenrolled_FailClosed(t *testing.T) { + // No Module / unknown surface ⇒ refused, NOT "0-level authed". This is + // the surface-caps property applied before the layer caps. + if err := Can(ipc.MethodPresence, Scope{}, nil); !errors.Is(err, ErrUnenrolled) { + t.Errorf("Can with empty Scope = %v; want ErrUnenrolled", err) + } + if err := Can(ipc.MethodPresence, Scope{Module: "x", Surface: SurfaceUnknown}, nil); !errors.Is(err, ErrUnenrolled) { + t.Errorf("Can with unknown surface = %v; want ErrUnenrolled", err) + } +} + +func TestCan_WriteFact_SourceScope(t *testing.T) { + // The spec's compromised-poller case: a poller enrolled to write + // poll:healthcheck can't forge poll:uptime (or anything else). + poller := Scope{ + Surface: SurfaceCoreProcess, + Module: "poller:healthcheck", + SourceScope: []string{"poll:healthcheck"}, + } + core := Scope{ + Surface: SurfaceCoreProcess, + Module: "core", + SourceScope: []string{"*"}, + } + + // In-scope write succeeds. + if err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:healthcheck")); err != nil { + t.Errorf("poller writing poll:healthcheck = %v; want nil", err) + } + // Out-of-scope write is forbidden — auth.ErrForbidden in the chain. + err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:uptime")) + if err == nil || !errors.Is(err, ErrForbidden) { + t.Errorf("poller writing poll:uptime = %v; want ErrForbidden in chain", err) + } + if err != nil && !strings.Contains(err.Error(), "poll:uptime") { + t.Errorf("forbidden err should name the offending source: got %q", err) + } + + // Core (wildcard) writes anything. + if err := Can(ipc.MethodWriteFact, core, mustWriteFactParams("poll:uptime")); err != nil { + t.Errorf("core writing poll:uptime = %v; want nil (wildcard scope)", err) + } + + // Empty SourceScope ⇒ fail closed even for an enrolled module. + empty := Scope{Surface: SurfaceCoreProcess, Module: "x", SourceScope: nil} + if err := Can(ipc.MethodWriteFact, empty, mustWriteFactParams("poll:healthcheck")); !errors.Is(err, ErrForbidden) { + t.Errorf("empty SourceScope WriteFact = %v; want ErrForbidden", err) + } +} + +func TestCan_Reads_AnyEnrolledModule(t *testing.T) { + // Reads permit any enrolled module (the enrollment already gated caller + // identity). Reads on SurfaceVoice are OK too — enrollment may have + // enrolled a voice module for read-only purposes (e.g. ambient parse). + for _, surf := range []Surface{SurfaceVoice, SurfaceTelegram, SurfacePCClient, SurfaceCoreProcess} { + scope := Scope{Surface: surf, Module: "x", SourceScope: []string{"*"}} + if err := Can(ipc.MethodPresence, scope, nil); err != nil { + t.Errorf("read on %s = %v; want nil (any enrolled module may read)", surf, err) + } + } +} + +// --- Gate --- + +func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) { + // The floor must be a no-op vs pre-auth: same-uid trusted, full source + // scope, every read and in-scope write passes. Tied end-to-end through + // Gate.Check on the ipc.Method enumeration, so AuthRead / AuthWrite both + // flow through the composition. + g := &Gate{Enrollment: NewFloorEnrollment()} + ctx := context.Background() + for _, m := range []ipc.Method{ + ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes, + ipc.MethodCreateReminder, ipc.MethodRecordNudge, + } { + if err := g.Check(ctx, m, nil); err != nil { + t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err) + } + } + if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")); err != nil { + t.Errorf("floor gate Check(WriteFact, *) = %v; want nil (full source scope)", err) + } +} + +func TestGate_StaticEnrollment_SourceScopeEnforced(t *testing.T) { + // A poller enrolled to write only poll:healthcheck is denied a write to + // poll:uptime. Gate.Authoritative on real source-scope. Wire code should + // be forbidden once we wire through the ipc layer (next test exercises + // that path through a real socket). + pollerUid := int32(70001) + enrollment := &StaticEnrollment{ + ByUid: map[int32]Scope{ + pollerUid: { + Surface: SurfaceCoreProcess, + Module: "poll:healthcheck", + SourceScope: []string{"poll:healthcheck"}, + }, + }, + Default: &Scope{ // any other uid gets full trust — for tests, doesn't matter + Surface: SurfaceCoreProcess, + Module: "core", + SourceScope: []string{"*"}, + }, + } + g := &Gate{Enrollment: enrollment} + + ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1234}) + if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil { + t.Errorf("poller in-scope write = %v; want nil", err) + } + err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")) + if err == nil { + t.Fatalf("poller out-of-scope write returned nil; want forbidden") + } + if !errors.Is(err, ipc.ErrForbidden) { + t.Errorf("poller out-of-scope err chain = %v; want ipc.ErrForbidden in chain (wire code wraps ipc.ErrForbidden)", err) + } + if !errors.Is(err, ErrForbidden) { + t.Errorf("poller out-of-scope err chain = %v; want auth.ErrForbidden in chain (auth tests can satisfy Is)", err) + } +} + +func TestGate_Unenrolled_FailsClosed(t *testing.T) { + enrollment := &StaticEnrollment{} // no Default ⇒ ErrUnenrolled for any caller + g := &Gate{Enrollment: enrollment} + ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: 4242, Pid: 99}) + err := g.Check(ctx, ipc.MethodPresence, nil) + if err == nil { + t.Fatalf("un-enrolled caller returned nil; want forbidden") + } + if !errors.Is(err, ipc.ErrForbidden) { + t.Errorf("unenrolled err = %v; want ipc.ErrForbidden in chain (mapped at wire edge)", err) + } +} + +// --- end-to-end through ipc.Server --- + +// TestGate_IpcServer_WiresCheck end-to-end. Server.Check = g.Check; an +// out-of-scope WriteFact sent through a real socket rehydrates as +// ipc.ErrForbidden on the wire. The seam — no CoreAPI change, no module +// change — is the whole point: the daemon sets Server.Check at construction. +func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) { + pollerUid := int32(70001) + enrollment := &StaticEnrollment{ + ByUid: map[int32]Scope{ + pollerUid: { + Surface: SurfaceCoreProcess, + Module: "poll:healthcheck", + SourceScope: []string{"poll:healthcheck"}, + }, + }, + Default: &Scope{ // any other uid ⇒ un-enrolled → forbidden + Surface: SurfaceUnknown, + Module: "", + }, + } + gate := &Gate{Enrollment: enrollment} + + dir := t.TempDir() + sock := filepath.Join(dir, "maven.sock") + // A fake CoreAPI that records writes; the auth verdict should fire before + // it ever gets called. + fake := &recordingAPI{} + srv, err := ipc.Listen(sock, fake) + if err != nil { + t.Fatalf("listen: %v", err) + } + srv.Check = gate.Check + done := make(chan struct{}) + go func() { + _ = srv.Serve() + close(done) + }() + t.Cleanup(func() { + _ = srv.Close() + <-done + }) + + // Direct in-process check: we can caller-stamp our test ctx with any + // uid/pid we want. The socket path produces our real uid (via + // SO_PEERCRED), but the auth layer's verdict depends only on the + // caller-shape the wire delivered, not the wire transport — so the + // in-process check uses the same Gate.Check the socketed dispatch calls. + ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1}) + if err := gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil { + t.Errorf("in-scope write through gate = %v; want nil", err) + } + err = gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")) + if !errors.Is(err, ipc.ErrForbidden) { + t.Errorf("out-of-scope write through gate = %v; want ipc.ErrForbidden", err) + } + + // Smoke the actual wire path: our real uid is un-enrolled per the + // StaticEnrollment (only 70001 is enrolled), so the very next call we + // make over the socket is forbidden at the wire — rehydrating on the + // client side as ipc.ErrForbidden. This is the full chain: + // dispatch → Check (auth) → codeOf → wire → hydrate. + cli, err := ipc.Dial(sock) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = cli.Close() }) + _, err = cli.WriteFact(context.Background(), ipc.WriteFactReq{ + Kind: "env", + Key: "service_down", + Value: "down", + Source: "poll:healthcheck", + Confidence: 1.0, + }) + if !errors.Is(err, ipc.ErrForbidden) { + t.Errorf("wire: write from real uid (unenrolled per StaticEnrollment) = %v; want ipc.ErrForbidden", err) + } + if fake.writes != 0 { + t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes) + } +} + +// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth +// check must reject before reaching it, otherwise the refusal leaks into the +// fake's counts and we fail. +type recordingAPI struct { + writes int +} + +func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) { + r.writes++ + return int64(r.writes), nil +} +func (r *recordingAPI) LatestFact(_ context.Context, _ string) (ipc.Fact, error) { + return ipc.Fact{}, ipc.ErrNoFact +} +func (r *recordingAPI) LatestFactBySource(_ context.Context, _, _ string) (ipc.Fact, error) { + return ipc.Fact{}, ipc.ErrNoFact +} +func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Duration, error) { + return 0, ipc.ErrNoFact +} +func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) { + return ipc.Presence{}, nil +} +func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _ string) (int64, error) { + return 1, nil +} +func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil } +func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) { + return 1, nil +} +func (r *recordingAPI) ResolveNudge(_ context.Context, _ int64, _ string, _ time.Time) error { + return nil +} +func (r *recordingAPI) RecentOutcomes(_ context.Context, _ string, _ int) ([]string, error) { + return nil, nil +} +func (r *recordingAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) { + return nil, nil +} +func (r *recordingAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) { + return nil, nil +} +func (r *recordingAPI) WriteNote(_ context.Context, _ time.Time, _ string, _ []float32, _ string) (int64, error) { + return 1, nil +} +func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.Note, error) { + return nil, nil +} +func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) { + return nil, nil +} +func (r *recordingAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) { + return false, nil +} +func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error { + return nil +} +func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) { + return ipc.Tool{}, ipc.ErrToolNotFound +} +func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error) { + return nil, nil +} + +// mustWriteFactParams — minimal WriteFactReq JSON with only the source field, +// matching what ipc.dispatch hands to Server.Check (the raw params frame). +func mustWriteFactParams(source string) []byte { + b, err := json.Marshal(ipc.WriteFactReq{ + Kind: "env", + Key: "service_down", + Value: "down", + Source: source, + Confidence: 1.0, + }) + if err != nil { + panic(err) + } + return b +} diff --git a/internal/auth/enrollment.go b/internal/auth/enrollment.go new file mode 100644 index 0000000..588bd14 --- /dev/null +++ b/internal/auth/enrollment.go @@ -0,0 +1,112 @@ +package auth + +import ( + "context" + "errors" + "fmt" + + "github.com/kami/maven/internal/ipc" +) + +// Sentinel errors. The wire carries ErrForbidden as codeForbidden; the others +// (ErrUnenrolled distinct) floor to forbidden on the wire too — but the daemon +// log can still see the distinction server-side. +var ( + // ErrForbidden — the caller's scope caps the requested authority (a + // voice-channel caller asking for L3, a poller writing an out-of-scope + // source, etc.). The wire shape: codeForbidden. + ErrForbidden = errors.New("auth: forbidden") + + // ErrUnenrolled — the caller isn't recognized by the enrollment table at + // all. A distinct sentinel so the daemon can surface "this module's + // enrollment is missing" as a wiring bug, not a generic denied. + ErrUnenrolled = errors.New("auth: caller not enrolled") +) + +// Enrollment — the impure seam mapping a connecting process to a Scope. The +// floor below trusts same-uid local callers fully (the 0600-floor equivalent), +// production wires the real enrollment table reading a Config / sqlite table. +// +// Lookup is the single impure call per dispatch; Can and Check downstream are +// pure. That keeps "no module ever reads its own authority" checkable: +// anywhere outside the Enrollment impl doing caller→scope resolution is a bug. +type Enrollment interface { + // Lookup resolves the caller's Scope. Return ErrUnenrolled when the caller + // isn't recognized; ErrForbidden when recognized but refused for policy + // reasons (e.g. disabled module); any other error for I/O failure. + // hasCaller=false is the in-process path (no ipc.Caller attached to ctx). + Lookup(ctx context.Context, c ipc.Caller, hasCaller bool) (Scope, error) +} + +// FloorEnrollment — today's auth floor. Same as the socket's 0600 perms: any +// same-uid caller is trusted as a "core" module (SurfaceCoreProcess, L3, +// write-any-source). The in-process path (no Caller) is the same: it's the +// daemon itself, holding the unlocked store, so it gets L3 trivially. +// +// This is the AUTH FLOOR, not the auth model — the spec's invariant (surface +// caps authority, source-scope, step-up) is shaped in policy.go and exercised +// in tests through tighter enrollments. The daemon swaps this out when the +// real enrollment table lands; nothing downstream changes. +type FloorEnrollment struct { + // Module is the label FloorEnrollment stamps on every caller (default + // "core"). Real enrollment derives this from Caller.Uid/Pid. + Module string +} + +// NewFloorEnrollment — default "core" module, full source scope, L3 cap. +// This preserves the prior (pre-auth) behavior: any same-uid caller was +// permitted everything. Compiles to identity authority. +func NewFloorEnrollment() *FloorEnrollment { return &FloorEnrollment{Module: "core"} } + +// Lookup — same-uid floor. HasCaller=false ⇒ in-process path (trusted "core"); +// HasCaller=true ⇒ for now we still trust (only same-uid can connect via the +// 0600 socket perms). The real enrollment table replaces this with a lookup +// keyed on Uid/Pid → Module entry. +func (f *FloorEnrollment) Lookup(_ context.Context, _ ipc.Caller, _ bool) (Scope, error) { + return Scope{ + Surface: SurfaceCoreProcess, + Module: f.Module, + SourceScope: []string{"*"}, + }, nil +} + +// StaticEnrollment — a hand-built enrollment for tests and demos: map every +// caller exact-match on Uid to a fixed Scope. Pure-ish (no I/O); the daemon +// holds it and lets the operator append at runtime; tests build their own. +// Used to model "a poller that can only write poll:healthcheck" — the spec's +// compromised-poller scenario — without standing up the full enrollment table. +type StaticEnrollment struct { + // ByUid — uid-keyed scope. Mutated to add an enrolled module. + ByUid map[int32]Scope + + // InProcess is the scope returned for in-process (HasCaller=false) calls. + // nil ⇒ falls through to Default. + InProcess *Scope + + // Default is returned when no specific entry matches. nil ⇒ ErrUnenrolled + // (fail closed). + Default *Scope +} + +// Lookup walks the static map. hasCaller ⇒ ByUid → Default → ErrUnenrolled; +// in-process ⇒ InProcess → Default → ErrUnenrolled. Fail closed everywhere, +// because a StaticEnrollment is built deliberately and any unmatched caller +// is exactly the "who is this?" case the real table answers. +func (s *StaticEnrollment) Lookup(_ context.Context, c ipc.Caller, hasCaller bool) (Scope, error) { + if !hasCaller { + if s.InProcess != nil { + return *s.InProcess, nil + } + if s.Default != nil { + return *s.Default, nil + } + return Scope{}, ErrUnenrolled + } + if sc, ok := s.ByUid[c.Uid]; ok { + return sc, nil + } + if s.Default != nil { + return *s.Default, nil + } + return Scope{}, fmt.Errorf("%w (uid=%d)", ErrUnenrolled, c.Uid) +} diff --git a/internal/auth/gate.go b/internal/auth/gate.go new file mode 100644 index 0000000..e1fca86 --- /dev/null +++ b/internal/auth/gate.go @@ -0,0 +1,120 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/kami/maven/internal/ipc" +) + +// Gate — wraps Enrollment + optional Session state and exposes a CheckFunc +// the daemon wires into ipc.Server.Check. The ONE place a wire call gets +// authorized. Adding the auth layer does not change CoreAPI, dispatch, or +// module code; the daemon constructs a Gate and sets Server.Check = gate.Check. +// +// floors: +// - FloorEnrollment ⇒ no change from pre-auth behavior (same-uid trusted, +// full source scope). +// - nil Session ⇒ step-up never asserted; any AuthStepUp call refused +// (surface caps + session caps agree to fail closed). Today's CoreAPI has +// no AuthStepUp methods, so nil Session is the daemon floor. +type Gate struct { + Enrollment Enrollment + Session Session // nil ⇒ step-up not asserted +} + +// Session — the per-session step-up state. The impure seam the passkey +// verifier implements: Assert records a successful user-verification gesture, +// CurrentLayer returns how high the session is asserted right now (cold boot ⇒ +// not-asserted; passkey challenge ⇒ L3 for the session lifetime). Without a +// Session wired, step-up-requiring calls (EnableTool, cold-start unlock) fail +// closed — the gate can't grant what wasn't demonstrated. +// +// Today the daemon runs no interface that asserts session step-up (no pc client +// yet); the floor is nil Session ⇒ AuthStepUp always refused. The shape is +// here so the passkey verifier is a single new impl, not a dispatch change. +type Session interface { + // CurrentLayer returns the authority the session currently carries. + // Outside a step-up window, returns the layer the surface can carry on its + // own minus the step-up contribution (e.g. a pc_client without asserted + // step-up returns Layer2; the passkey gesture bumps it to Layer3 for the + // session lifetime). + CurrentLayer(ctx context.Context, scope Scope) Layer + + // Assert — record a successful step-up gesture for scope. The passkey + // verifier returns nil and the daemon-queried Session treats this session + // as L3 until it expires. Floor impls may return ErrStepUpUnsupported. + Assert(ctx context.Context, scope Scope) error +} + +// ErrStepUpUnsupported — returned by floor Session.Assert when no passkey +// verifier is wired. Distinct from ErrForbidden: a missing impl is a wiring +// bug, not a denial. +var ErrStepUpUnsupported = errors.New("auth: step-up not supported by this session") + +// FloorSession — the step-up floor, mirroring FloorEnrollment: any caller that +// cleared the 0600 socket is trusted as fully step-asserted (L3). It exists so +// the floor is CONSISTENT — FloorEnrollment already grants same-uid callers L3 +// for writes; without a matching Session floor, AuthStepUp methods (EnableTool) +// would be refused for the same callers, an accidental asymmetry. The real +// passkey verifier replaces this (a single new Session impl, no dispatch change) +// so step-up becomes a real gesture instead of a floor grant. +type FloorSession struct{} + +// CurrentLayer — the floor trusts the local caller fully. +func (FloorSession) CurrentLayer(_ context.Context, _ Scope) Layer { return Layer3 } + +// Assert — a no-op success at the floor (the caller is already trusted). +func (FloorSession) Assert(_ context.Context, _ Scope) error { return nil } + +// Check — the authorization hook. Wired into ipc.Server.Check (single +// insertion point). Shape: nil ipc.Caller ⇒ in-process path (Lookup with +// hasCaller=false). Otherwise resolve via Enrollment; refuse on any error; +// run pure Can on the resolved scope + raw params. +// +// Returns ErrForbidden (mirrored to codeForbidden on the wire) for any +// authority failure; bubbles other errors (Enrollment I/O, ErrUnenrolled) +// up to dispatch where they're mapped to codeInternal or codeForbidden +// depending on identity-ness. We map ErrUnenrolled → forbidden: an unknown +// caller is not surfaced as "internal error" to a module. +func (g *Gate) Check(ctx context.Context, m ipc.Method, params json.RawMessage) error { + caller, hasCaller := ipc.CallerFrom(ctx) + scope, err := g.Enrollment.Lookup(ctx, caller, hasCaller) + if err != nil { + // Unenrolled ⇒ forbidden on the wire (codeForbidden). I/O failures of + // the enrollment table are not "the caller is unauthorized"; they + // bubble as internal via codeOf's default. Wrap with both sentinels + // (Go 1.20+ multi-%w) so: + // - ipc.codeOf resolves to codeForbidden via errors.Is(ipc.ErrForbidden) + // - auth-package tests resolve via errors.Is(auth.ErrForbidden) + // - the daemon log carries both names. + if errors.Is(err, ErrUnenrolled) { + return fmt.Errorf("%w: %w", ipc.ErrForbidden, err) + } + return err + } + if err := Can(m, scope, params); err != nil { + // Can already uses auth.ErrForbidden / ErrUnenrolled inside; we wrap + // with ipc.ErrForbidden so codeOf resolves to codeForbidden at the + // wire. The auth sentinel stays in the chain via %w (not %v) so + // errors.Is(auth.ErrForbidden) works in auth-package tests. + if errors.Is(err, ErrUnenrolled) || errors.Is(err, ErrForbidden) { + return fmt.Errorf("%w: %w", ipc.ErrForbidden, err) + } + return err + } + // AuthStepUp verdict's surface-cap half is in Can. The session-level + // check (was step-up actually asserted THIS session?) lives here so the + // Session owns its own state; Can stays pure-data. + if Requirement(m) == AuthStepUp { + if g.Session == nil { + return fmt.Errorf("%w: %w: AuthStepUp but no Session wired", ipc.ErrForbidden, ErrForbidden) + } + if g.Session.CurrentLayer(ctx, scope) < Layer3 { + return fmt.Errorf("%w: %w: step-up not asserted this session", ipc.ErrForbidden, ErrForbidden) + } + } + return nil +} diff --git a/internal/auth/policy.go b/internal/auth/policy.go new file mode 100644 index 0000000..61d0011 --- /dev/null +++ b/internal/auth/policy.go @@ -0,0 +1,168 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/kami/maven/internal/ipc" +) + +// Authority — a discrete authority requirement per ipc.Method. Higher +// numbers are STRICTER (need a higher layer to be granted). Today's CoreAPI +// methods are all read or single-module-write; the deferred L3 acts +// (EnableTool / destructive Ops) are reserved at the top rung — they're +// not on the CoreAPI yet (the tool-executor module is unbuilt), but the +// authority table holds the rung so adding them is a policy entry, not a +// new mechanism. +type Authority int8 + +const ( + // AuthRead — read methods (LatestFact, LatestFactBySource, Since, Presence, + // RecentOutcomes) and state mutations a module legitimately makes + // (CreateReminder, MarkReminder, RecordNudge, ResolveNudge). The Enrollment + // already gated caller identity; any enrolled module may use these. + AuthRead Authority = 0 + + // AuthWrite — WriteFact. Need enrollment + source-scope match. The + // "compromised poller can't forge a trigger" property: a module only writes + // sources it owns. Floor gets "*"; tight enrollments scope per source. + AuthWrite Authority = 1 + + // AuthStepUp — a per-assertion user-verification gesture is required for + // this call. Reserved for EnableTool (registration-enable) and destructive + // acts when they land on the CoreAPI. NOT a Layer itself — Authority is + // the call-side requirement; Layer is the surface-side capability. The + // pure check is just: does the surface cap (MaxLayer) carry L3, AND was + // step-up asserted this session? Both settled by Can below. + AuthStepUp Authority = 2 +) + +// Requirement — the PURE authority table: per-method required Authority. This +// is the one place in the codebase a method's required authority is declared; +// every other reference to "registration needs step-up" points back here. +// Adding a new ipc.Method = a row here (or it inherits AuthRead by default, +// which the vet check in dispatch catches via Method existence, not auth). +func Requirement(m ipc.Method) Authority { + switch m { + case ipc.MethodEnableTool: + // Registration-enable is privilege escalation: it moves the boundary + // (adds a runnable capability). Human-only, step-up asserted — never a + // module or the voice/chat path. maven can propose but never enable. + return AuthStepUp + case ipc.MethodWriteFact: + return AuthWrite + case ipc.MethodLatestFact, + ipc.MethodLatestFactBySource, + ipc.MethodSince, + ipc.MethodPresence, + ipc.MethodRecentOutcomes, + ipc.MethodCreateReminder, + ipc.MethodMarkReminder, + ipc.MethodRecordNudge, + ipc.MethodResolveNudge: + return AuthRead + } + // Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod + // regardless of the auth verdict (we run before dispatch; we don't gate on + // Method existence — Check is method-agnostic policy, not routing). + return AuthRead +} + +// Can — the PURE authority decision for one call. Returns nil if the scope +// is authorized to invoke m with the supplied params; an error otherwise: +// +// - ErrUnenrolled — scope has no Module (caller wasn't in the enrollment +// table). Fail closed. +// - ErrForbidden — surface caps the layer below what m requires, or +// WriteFact's source is out of scope. +// +// The adapter wrapping this for the ipc gate (Gate.Check) maps the error to +// codeForbidden at the wire; we keep the distinction here so daemon logs +// can show why a call was denied. +// +// params is the raw json.RawMessage the ipc Server received; for WriteFact we +// re-parse Source out of it. Other methods don't need params — their verdict +// depends only on the scope. +func Can(m ipc.Method, scope Scope, params json.RawMessage) error { + // Floor closed: any caller not in the enrollment table is refused outright. + // Not "0-level unauthed" — refused. This is the surface-caps property + // applied before the layer caps: there is no L0 surface if SurfaceUnknown. + if scope.Module == "" { + return ErrUnenrolled + } + if scope.Surface == SurfaceUnknown { + return ErrUnenrolled + } + + switch Requirement(m) { + case AuthRead: + // Any enrolled module may read. Reads through the surface level the + // Enrollment set (voice-L0 wouldn't be enrolled to write at all). + return nil + + case AuthWrite: + if m == ipc.MethodWriteFact { + src, err := extractSource(params) + if err != nil { + // Malformed params is a bad-params error already produced by + // ipc.dispatch; but Can runs first. Treat as forbidden — a + // caller doesn't get to probe scopes with garbage params. + return fmt.Errorf("%w: malformed source", ErrForbidden) + } + if !SourceAllowed(scope.SourceScope, src) { + // The spec's compromised-poller case in one line: a poller + // enrolled to write poll:healthcheck asking to write + // poll:uptime is denied — but the same poller writing + // poll:healthcheck is fine. Polls can't forge triggers. + return fmt.Errorf("%w: source %q out of scope", ErrForbidden, src) + } + } + return nil + + case AuthStepUp: + // Surface-caps-authority enforced here. The surface can't carry L3 ⇒ + // forbidden. The session step-up itself is checked by the Gate (it + // owns Session state and surfaces a Check function); we cap surface + // here so the gate fails closed on shape alone. + if MaxLayer(scope.Surface) < Layer3 { + return fmt.Errorf("%w: surface %s can't carry step-up", ErrForbidden, scope.Surface) + } + return nil + } + return nil +} + +// SourceAllowed — true iff src is in scope (the wildcard "*" matches all). +// Empty scope ⇒ fail closed. The function is pure; we keep it exported so a +// future enrollment table can call into the same matching logic. +func SourceAllowed(scope []string, src string) bool { + if len(scope) == 0 { + return false + } + for _, s := range scope { + if s == "*" || s == src { + return true + } + } + return false +} + +// extractSource reads WriteFactReq.Source out of the raw params WITHOUT a full +// unmarshal — Source is the only field Can needs, and re-parsing it once per +// write is cheap (and only happens on MethodWriteFact). Stay independent of +// any future WriteFactReq shape changes by using the struct directly. +func extractSource(raw json.RawMessage) (string, error) { + var p ipc.WriteFactReq + if err := json.Unmarshal(raw, &p); err != nil { + return "", err + } + if p.Source == "" { + // An empty source is rejected by store.WriteFact anyway; surface it + // as forbidden to avoid giving a caller an ipc sentinel that names the + // store's internal invariant. (store rejects this before feature flag + // for "missing source"; today this is best-effort.) + return "", errors.New("empty source") + } + return p.Source, nil +} diff --git a/internal/auth/scope.go b/internal/auth/scope.go new file mode 100644 index 0000000..9bd4553 --- /dev/null +++ b/internal/auth/scope.go @@ -0,0 +1,39 @@ +package auth + +import ( + "github.com/kami/maven/internal/ipc" +) + +// Scope — the resolved authority of a caller. Built by Enrollment *once* +// when a connection is accepted (or once per call, depending on impl), then +// threaded through Check → Can as pure data. The impure part (looking up the +// module enrollment table from ipc.Caller Uid+Pid) ends at the Enrollment +// boundary; everything downstream is pure. +type Scope struct { + // Surface — the channel the caller entered through. Caps the layer. + Surface Surface + + // Module — the enrolled module name ("tts", "poll:healthcheck", + // "router", "telegram-relay", ""). "" ⇒ unenrolled; Check refuses. + // The daemon's in-process path sets "core" by convention. + Module string + + // SourceScope — the sources this module may WriteFact under. The spec's + // exact "compromised poller can't forge a trigger" guard: a module + // only writes sources it owns. The floor enrollment grants "*" + // (anything); a real enrollment scopes a poller to one source prefix. + // Empty slice ⇒ refuse all writes (fail closed); the daemon never sets + // this empty for an enrolled caller. + SourceScope []string +} + +// Lookup — the Enroller's input: ipc.Caller when present (Uid/Pid from +// SO_PEERCRED on the socket), or the zero value for in-process (no Caller +// attached to ctx — the daemon treats this as SurfaceCoreProcess, "core"). +type Lookup struct { + // Caller — when CallerFrom(ctx) is absent (the in-process path), this is + // the zero ipc.Caller. The Enrollment floor returns SurfaceCoreProcess in + // that case. + Caller ipc.Caller + HasCaller bool +} diff --git a/internal/auth/tier.go b/internal/auth/tier.go new file mode 100644 index 0000000..18dd753 --- /dev/null +++ b/internal/auth/tier.go @@ -0,0 +1,94 @@ +// Package auth is maven's authority layer — the 4-layer cascade and the +// "surface caps authority" invariant. +// +// Spec contract (from maven.md § auth): +// +// a cascade, not a pick-one — each layer answers a different question: +// +// | layer | question | mechanism | surface | +// | 0 | on the network at all? | wireguard | floor | +// | 1 | enrolled box? | mTLS client cert | pc client, authed page | +// | 2 | you, this session? | passkey / webauthn | pc client, authed page | +// | 3 | you, right now, for this act? | passkey user-verification | step-up acts | +// +// 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. +// +// the invariant: 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 STOPS at L0 (a room mic is reachable by anyone present → +// speaker verification is attribution, not auth). telegram inbound = weak +// tier (read + soft acts, never destructive, never registration). only +// pc_client / authed_page carry passkey user-verification (L3) at all. +// +// "compromised X can't forge Y" is the through-line — applied at smaller +// and smaller scope (network → box → process). the auth layer applies it at +// the process radius: a module calling CoreAPI is bound to a Surface; the +// Surface caps what methods/authority that call can carry. +package auth + +// Layer — one rung of the auth cascade. +type Layer int8 + +const ( + // Layer0 — wireguard: on the network at all. Floor for everything. + Layer0 Layer = 0 + // Layer1 — mTLS client cert: enrolled box. Optional per spec ("if dropping + // one, drop mTLS, never the passkey"); not wired by the floor enrollment. + Layer1 Layer = 1 + // Layer2 — passkey/webauthn session: you, this session. + Layer2 Layer = 2 + // Layer3 — passkey user-verification gesture for a SINGLE act: you, right + // now, for this. registration-enable, destructive acts, core cold-start + // unlock. the highest-authority op. + Layer3 Layer = 3 +) + +// Surface — where a call ENTERS maven from. A property of the channel that +// structurally caps the maximum authority that channel can carry. The wire +// doesn't carry a layer — it carries a Caller; the Enrollment maps the caller +// to a Surface; MaxLayer caps it. So voice can never reach EnableTool — not +// because auth "failed" but because the channel can't carry the proof. +type Surface string + +const ( + // SurfaceVoice — a room mic / wake-word path. Speaker verification is + // attribution, not auth: anyone present (gf, the TV) can speak. STOPS at + // L0. never destructive, never registration. + SurfaceVoice Surface = "voice" + // SurfaceTelegram — telegram inbound. weak tier: telegram's own auth, + // outside our control. read + soft acts, never destructive, never + // registration. carries up to L2 (a chat-id allowlist is the best we get). + SurfaceTelegram Surface = "telegram" + // SurfacePCClient — the desktop gui, mTLS'd and passkey'd. carries L3 + // (passkey user-verification is available on-device). + SurfacePCClient Surface = "pc_client" + // SurfaceAuthedPage — the web authed page over wg. carries L3 (passkey + // user-verification via the browser / platform authenticator). + SurfaceAuthedPage Surface = "authed_page" + // SurfaceCoreProcess — a module running in core's own address space (the + // daemon-embedded router/delivery today). There is no boundary to cross; + // this is the 0600-floor equivalent: trusted same-process. Layer3-capped + // since the user already unlocked the daemon (cold-start IS L3 per spec). + SurfaceCoreProcess Surface = "core_process" + // SurfaceUnknown — enrollment didn't recognize the caller. fail closed. + SurfaceUnknown Surface = "unknown" +) + +// MaxLayer — the surface-caps-authority table. PURE. The invariant: you +// cannot step up past what your channel structurally carries. voice → L0; +// telegram → L2 (chat allowlist); pc_client / authed_page / core_process → +// L3. An unrecognized surface caps at -1 (fail closed) — a caller with no +// enrolled identity is not "unauthed at L0", it's refused outright. +func MaxLayer(s Surface) Layer { + switch s { + case SurfaceVoice: + return Layer0 + case SurfaceTelegram: + return Layer2 + case SurfacePCClient, SurfaceAuthedPage, SurfaceCoreProcess: + return Layer3 + default: + return -1 + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..af235c8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,297 @@ +// Package config is maven's daemon configuration. +// +// The daemon reads a single JSON file at startup (path from the -config flag, +// default ~/.config/maven/mavend.json). Everything a module needs is wired +// from this file: the store path, the unix socket path, the tick cadence, +// and per-sink configs (ntfy/telegram). Credentials live in the file (or a +// systemd credential that the file points at) — never in the binary. +// +// This package is pure data + a loader. It imports the sink config structs +// so the daemon wires each `Sink` from a single, typed config tree without +// re-declaring their shapes (the sink constructors own validation). +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/kami/maven/internal/delivery/ntfysink" + "github.com/kami/maven/internal/delivery/telegramsink" +) + +// Config — the daemon's whole config tree. Loaded once at startup. +// +// Fields with omitempty are optional: a missing sink config = that channel +// not wired (the dispatcher's nil-sink path skips it silently, the same as a +// deliberately-unwired channel at scaffold time). +type Config struct { + // DBPath — sqlite database path. Default applied by Load if empty. + DBPath string `json:"db_path"` + + // SocketPath — the unix socket the IPC server listens on. Modules + // connect here; the dir is created 0700, the socket chmod'd 0600 by + // ipc.Listen. Default applied by Load if empty. + SocketPath string `json:"socket_path"` + + // StateDir — base dir for db + socket if their paths aren't absolute. + // Default applied by Load if empty (XDG-style: ~/.local/share/maven for + // the db, /run/user/$UID/maven for the socket). + StateDir string `json:"state_dir,omitempty"` + + // TickInterval — the proactive loop cadence. Default 60s. The loop is + // "dumb + deterministic": most ticks evaluate a few predicates and die + // for free; raising this saves nothing worth losing responsiveness over. + TickInterval Duration `json:"tick_interval,omitempty"` + + // RepeatInterval — how often sev4 telegram sends re-fire until acked. + // Default 5m. A disk-fire alarm that repeats every tick (60s) is spam; + // one that repeats never is silent. The default tilts toward "loud." + RepeatInterval Duration `json:"repeat_interval,omitempty"` + + // AutotuneInterval — how often the feedback auto-tuner runs: reads + // store.RecentOutcomes for each rule, calls loop.TuneCooldown, writes the + // tuned cooldown back as a `facts (kind=config, source=feedback)` row + // if it changed. Default 10m — slow enough to be cheap + not write every + // tick (append-only facts churn), fast enough that a weird-afternoon + // pattern shows up inside a day. 0 ⇒ autotune disabled (the gatherer + // falls back to the rule's static Base, matching pre-autotune behavior). + AutotuneInterval Duration `json:"autotune_interval,omitempty"` + + // Ntfy — the ntfy push sink config. nil ⇒ ntfy channel not wired. + // sev3 (ops soft) away + sev4 (ops hard) present + reminders away all + // route here; not wiring ntfy means those routes drop silently. + Ntfy *ntfysink.Config `json:"ntfy,omitempty"` + + // Telegram — the telegram push sink config. nil ⇒ telegram channel + // not wired. sev4 away routes here with repeat-til-ack; not wiring + // telegram means sev4-away alarms silently drop (a disk-fire alarm at + // 2am that no one sees — wire it). + Telegram *telegramsink.Config `json:"telegram,omitempty"` + + // Phraser — the LLM-backed phraser config. nil ⇒ the daemon uses the + // template-based Stub (deterministic, no model required — good for CI). + // When configured, the daemon spawns llama-server as a subprocess and + // calls its /v1/chat/completions endpoint to phrase nudges and reminders. + Phraser *PhraserConfig `json:"phraser,omitempty"` + + // Voice — the client↔core surface + the stt/tts modules the daemon + // wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays + // down, the dispatcher's Voice slot stays nil (the routing table's + // ChannelVoice selections drop silently — same as pre-voice behaviour). + // To enable: voice.enabled = true AND voice.bind = an address inside + // the wg tunnel; the daemon binds the TCP listener there. + Voice *VoiceConfig `json:"voice,omitempty"` +} + +// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module +// seams. +// +// Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in +// production; "127.0.0.1:9100" for the local smoke). Lang is the default +// language hint passed to both stt and tts (per-call overrides later). +// +// Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the +// in-process stt.Stub (the "no models on disk" floor — the loop is +// exercisable end-to-end with a deterministic no-model transcriber). +// non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix +// socket (cmd/mavsttd serves the other end; production swaps in a +// faster-whisper handler in cmd/mavsttd, no daemon or stt-package +// change). Tts mirrors for tts.Remote + cmd/mavttsd. +// +// Embedder configures the router's sentence embedder. When all three +// paths are set, the daemon constructs an ONNX multilingual embedder +// (in-process); when nil, it falls back to the floor HashEmbedder stub +// (deterministic, no model files required — good for CI and smoke). +// +// The daemon refuses to start if Voice.Enabled but Bind is empty — the +// bind is the one operational config the surface can't default (127.0.0.1 +// is too relaxed for production, a wg-tunnel address is the user's); +// surfacing the gap explicitly beats an idle listener the user thinks is +// wired but isn't reachable. +type VoiceConfig struct { + Enabled bool `json:"enabled,omitempty"` + Bind string `json:"bind,omitempty"` + Lang string `json:"lang,omitempty"` + Stt *WorkerConfig `json:"stt,omitempty"` + Tts *TtsConfig `json:"tts,omitempty"` + Embedder *EmbedderConfig `json:"embedder,omitempty"` + + // Tools — the enabled act allowlist. Each is a spoken verb → argv the + // executor runs (args from the utterance appended). Editing this set is the + // human-only "enable" act (per spec); maven can't add to it from a request. + // Empty ⇒ every act is refused (nothing enabled). + Tools []ToolConfig `json:"tools,omitempty"` + + // ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s). + ToolTimeout Duration `json:"tool_timeout,omitempty"` +} + +// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is +// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that +// must not fire from the voice path (they need a confirm on an authed surface). +type ToolConfig struct { + Name string `json:"name"` + Cmd []string `json:"cmd"` + Destructive bool `json:"destructive,omitempty"` +} + +// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server +// as a managed subprocess and sends chat-completion requests to phrase nudge +// and reminder messages. nil ⇒ the template-based Stub is used instead. +// +// ModelPath is the only required field. The rest have sensible defaults: +// - BinPath defaults to "llama-server" (found via PATH at spawn time). +// - Listen defaults to "127.0.0.1:0" (random port, read from stderr). +// - NGpuLayers defaults to -1 (max, uses all available GPU layers). +// - NCtx defaults to 2048. +// - Timeout defaults to 30s per request. +type PhraserConfig struct { + ModelPath string `json:"model_path"` + BinPath string `json:"bin_path,omitempty"` + Listen string `json:"listen,omitempty"` + NGpuLayers int `json:"n_gpu_layers,omitempty"` + NCtx int `json:"n_ctx,omitempty"` + Timeout Duration `json:"timeout,omitempty"` +} + +// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon +// constructs an in-process ONNX embedder when all three paths are non-empty; +// the router's classifier then uses real sentence embeddings instead of the +// floor HashEmbedder stub. Model_path is the ONNX model file, tokenizer_path +// is tokenizer.json (Unigram), lib_path is the ONNX Runtime shared library. +type EmbedderConfig struct { + ModelPath string `json:"model_path,omitempty"` + TokenizerPath string `json:"tokenizer_path,omitempty"` + LibPath string `json:"lib_path,omitempty"` +} + +// WorkerConfig — a unix-socket worker module connection. Used by Stt and +// (via TtsConfig embedding the same fields) by Tts. Socket is the unix +// socket path the worker module listens on (e.g. +// /run/user/$UID/maven/stt.sock). Lang overrides the surface default for +// this module when the user wants different langs for stt vs tts (rare). +type WorkerConfig struct { + Socket string `json:"socket,omitempty"` + Lang string `json:"lang,omitempty"` +} + +// TtsConfig — the tts worker module connection + tts-specific Voice field +// (a named voice when the worker supports multiple; "" ⇒ the worker's +// configured default). +type TtsConfig struct { + Socket string `json:"socket,omitempty"` + Lang string `json:"lang,omitempty"` + Voice string `json:"voice,omitempty"` +} + +// Duration — a time.Duration that round-trips through JSON as a string +// ("60s", "5m", "1h30m"). Plain time.Duration marshals as a nanosecond int, +// which is unreadable in a config file; this wrapper uses ParseDuration. +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + v, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("config: bad duration %q: %w", s, err) + } + *d = Duration(v) + return nil +} + +// Defaults applied when the corresponding field is empty/zero. +const ( + DefaultTickInterval = 60 * time.Second + DefaultRepeatInterval = 5 * time.Minute + DefaultAutotuneInterval = 10 * time.Minute +) + +// Load reads the JSON config at path and applies defaults. A missing file is +// an error — the daemon refuses to start without an explicit config (the +// default-less state is too permissive: empty db path, no sinks, an idle +// loop that silently does nothing, etc. — better to surface the gap than to +// run an idle daemon the user thinks is wired). +func Load(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("config: read %s: %w", path, err) + } + var c Config + if err := json.Unmarshal(b, &c); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", path, err) + } + c.applyDefaults() + if err := c.validate(); err != nil { + return nil, fmt.Errorf("config: %s: %w", path, err) + } + return &c, nil +} + +func (c *Config) applyDefaults() { + if c.TickInterval == 0 { + c.TickInterval = Duration(DefaultTickInterval) + } + if c.RepeatInterval == 0 { + c.RepeatInterval = Duration(DefaultRepeatInterval) + } + if c.AutotuneInterval == 0 { + c.AutotuneInterval = Duration(DefaultAutotuneInterval) + } + if c.DBPath == "" { + c.DBPath = filepath.Join(defaultDataDir(), "maven.db") + } + if c.SocketPath == "" { + c.SocketPath = filepath.Join(defaultRuntimeDir(), "mavend.sock") + } +} + +func (c *Config) validate() error { + if c.Phraser != nil { + if c.Phraser.ModelPath == "" { + return errors.New("phraser.model_path is required") + } + } + if c.Voice != nil && c.Voice.Enabled { + if c.Voice.Bind == "" { + return errors.New("voice.enabled set but voice.bind is empty — refusing to start a voice surface with no bind address") + } + if c.Voice.Embedder != nil { + partial := c.Voice.Embedder.ModelPath == "" || c.Voice.Embedder.TokenizerPath == "" || c.Voice.Embedder.LibPath == "" + if partial { + return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub") + } + } + } + return nil +} + +func defaultDataDir() string { + if x := os.Getenv("XDG_DATA_HOME"); x != "" { + return filepath.Join(x, "maven") + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(os.TempDir(), "maven") + } + return filepath.Join(home, ".local", "share", "maven") +} + +func defaultRuntimeDir() string { + if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" { + return filepath.Join(x, "maven") + } + // /run/user/$UID is the typical answer; without XDG_RUNTIME_DIR, fall back + // to the data dir (still works; just not tmpfs-clearance-on-reboot clean). + return filepath.Join(defaultDataDir()) +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6660c96 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,131 @@ +package config + +import ( + "path/filepath" + "testing" + "time" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "mavend.json") + if err := writeFile(t, p, body); err != nil { + t.Fatalf("write config: %v", err) + } + return p +} + +func TestLoadDefaults(t *testing.T) { + p := writeConfig(t, `{}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if time.Duration(c.TickInterval) != DefaultTickInterval { + t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval) + } + if time.Duration(c.RepeatInterval) != DefaultRepeatInterval { + t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval) + } + if c.DBPath == "" { + t.Error("DBPath default not applied") + } + if c.SocketPath == "" { + t.Error("SocketPath default not applied") + } +} + +func TestLoadDurationsParse(t *testing.T) { + p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if time.Duration(c.TickInterval) != 90*time.Second { + t.Errorf("TickInterval = %v, want 90s", c.TickInterval) + } + if time.Duration(c.RepeatInterval) != 10*time.Minute { + t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval) + } +} + +func TestLoadBadDurationRejected(t *testing.T) { + p := writeConfig(t, `{"tick_interval":"not-a-duration"}`) + if _, err := Load(p); err == nil { + t.Fatal("Load succeeded for a bad duration; want error") + } +} + +func TestLoadMissingFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "nonexistent.json") + if _, err := Load(p); err == nil { + t.Fatal("Load succeeded for a missing file; want error") + } +} + +func TestVoiceEnabledRequiresBind(t *testing.T) { + // enabled=true without bind is refused — the voice surface can't + // default a bind (127.0.0.1 too relaxed for production, a wg addr is + // the user's). surfacing the gap explicitly beats an idle listener. + p := writeConfig(t, `{"voice":{"enabled":true}}`) + if _, err := Load(p); err == nil { + t.Fatal("Load succeeded for voice.enabled=true with no bind; want error") + } +} + +func TestVoiceEnabledWithBindOK(t *testing.T) { + // voice surface fully configured — accepted (the daemon wires Stub tts + + // voicesink; no models on disk required). + p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) + if _, err := Load(p); err != nil { + t.Fatalf("Load: %v", err) + } +} + +func TestVoicePresentDisabledOK(t *testing.T) { + // voice block present but not enabled — acceptable (the listener stays + // down; the routing table's ChannelVoice selections drop). + p := writeConfig(t, `{"voice":{"enabled":false}}`) + if _, err := Load(p); err != nil { + t.Fatalf("Load: %v", err) + } +} + +func TestVoiceSttTtsConfigParsed(t *testing.T) { + // the stt/tts worker sub-blocks parse + remember socket/lang. + p := writeConfig(t, `{ + "voice": { + "enabled": true, "bind": "127.0.0.1:9100", + "stt": {"socket": "/tmp/stt.sock", "lang": "ru"}, + "tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"} + } + }`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" { + t.Fatalf("Stt config not parsed: %+v", c.Voice) + } + if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" { + t.Fatalf("Tts config not parsed: %+v", c.Voice) + } +} + +func TestDurationRoundTrip(t *testing.T) { + d := Duration(15 * time.Minute) + b, err := d.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + if got, want := string(b), `"15m0s"`; got != want { + t.Errorf("MarshalJSON = %s, want %s", got, want) + } + var d2 Duration + if err := d2.UnmarshalJSON(b); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + if d2 != d { + t.Errorf("round-trip = %v, want %v", d2, d) + } +} \ No newline at end of file diff --git a/internal/config/helpers_test.go b/internal/config/helpers_test.go new file mode 100644 index 0000000..30a70c1 --- /dev/null +++ b/internal/config/helpers_test.go @@ -0,0 +1,20 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// writeFile is a tiny helper used by the test files; inlined so config_test.go +// stays self-contained without a shared helpers file. +func writeFile(t *testing.T, path, body string) error { + t.Helper() + dir := filepath.Dir(path) + if dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + return os.WriteFile(path, []byte(body), 0o600) +} \ No newline at end of file diff --git a/internal/delivery/ack.go b/internal/delivery/ack.go new file mode 100644 index 0000000..cc1d88a --- /dev/null +++ b/internal/delivery/ack.go @@ -0,0 +1,26 @@ +package delivery + +import "time" + +// ShouldRepeat — PURE: given the last send ts, whether the send was acked, the +// current time, and the repeat interval, decide whether to re-send a sev4 +// telegram nudge. +// +// "telegram, repeat til ack" — a disk-fire alarm at 2am repeats on telegram +// until the user acknowledges. this is the decision the daemon's tick loop +// calls each cycle (via Dispatcher.RepeatUnacked). acked ⇒ stop. interval +// elapsed since last send ⇒ re-send. otherwise wait. +// +// lastSent.IsZero() ⇒ never sent; the INITIAL dispatch handles that (not the +// repeat path). returning true here is harmless — the caller will send and +// MarkSent, which sets the clock. fail-safe: when in doubt, send (a missed +// disk-fire alarm is the cost we're optimizing against). +func ShouldRepeat(lastSent time.Time, acked bool, now time.Time, interval time.Duration) bool { + if acked { + return false + } + if lastSent.IsZero() { + return true + } + return now.Sub(lastSent) >= interval +} diff --git a/internal/delivery/channel.go b/internal/delivery/channel.go new file mode 100644 index 0000000..968e617 --- /dev/null +++ b/internal/delivery/channel.go @@ -0,0 +1,94 @@ +// Package delivery is maven's channel-routing + dispatch layer. +// +// Spec contract (from maven.md § 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. +// - reminders are a SEPARATE class — two delivery paths. reminders bypass +// the restraint gate ("wake me 7" fires in quiet hours; that's the point). +// snooze still applies. voice when present, ntfy when away. fire once. +// +// Architecture mirrors the loop's gather/pure split: the routing table is a +// PURE function of (severity, presence); the Dispatcher holds the impure Sinks +// (one per channel transport) and the recorder seams. ShouldRepeat is pure — +// the daemon's tick loop calls it each cycle for un-acked sev4 telegram sends. +package delivery + +import ( + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +// Channel — one delivery transport. Drop is an explicit no-op (the routing +// table chose to suppress, which is a decision, not a failure — "a missed +// water nudge is noise"). a nil Sink for a wired channel is a daemon config +// gap, not a Channel value. +type Channel string + +const ( + ChannelVoice Channel = "voice" + ChannelNtfy Channel = "ntfy" + ChannelTelegram Channel = "telegram" + ChannelDrop Channel = "drop" +) + +// ChannelsFor — the PURE routing table: f(severity, presence). +// +// presence decides reachability; severity decides insistence. the loop's gate +// already suppressed care nudges (sev ≤ 2) on away — this table is the +// delivery-side authority for ALL severities, including the ops nudges the +// gate lets through. double authority is intentional: the gate decides whether +// a rule EMITS; delivery decides where it LANDS. they agree on care-away +// (both drop) and diverge only where they must (ops survives away here, not +// because the gate let it through, but because delivery insists). +// +// sev4 present → voice + ntfy: the disk-fire alarm gets voice AND a push — +// you're here, but this is loud enough to also surface on the watch. +// sev4 away → telegram, repeat til ack: the one channel that crosses the relay +// and insists until you respond. +func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel { + if presence == store.Away { + switch { + case sev <= loop.Sev2: + return []Channel{ChannelDrop} + case sev == loop.Sev3: + return []Channel{ChannelNtfy} + case sev >= loop.Sev4: + return []Channel{ChannelTelegram} + } + return []Channel{ChannelDrop} // unknown sev → fail-closed + } + // present + if sev >= loop.Sev4 { + return []Channel{ChannelVoice, ChannelNtfy} + } + return []Channel{ChannelVoice} +} + +// ChannelsForReminder — reminders are a SEPARATE class that bypasses the gate. +// "wake me 7" fires in quiet hours; that's the point. presence still routes +// reachability: voice when present, ntfy when away. fires once — no repeat +// (repeat-til-ack is a sev4 ops-hard behavior, not a reminder behavior). +// +// reminders don't carry a Severity — they're user-stated future intent, not +// loop-derived insistence. the routing is presence-only: reachability without +// the insistence axis. a reminder that must be louder (an alarm) is a future +// per-reminder override, not a table entry. +func ChannelsForReminder(presence store.Bucket) []Channel { + if presence == store.Away { + return []Channel{ChannelNtfy} + } + return []Channel{ChannelVoice} +} diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go new file mode 100644 index 0000000..a69c8db --- /dev/null +++ b/internal/delivery/dispatcher.go @@ -0,0 +1,242 @@ +package delivery + +import ( + "context" + "fmt" + "time" + + "github.com/kami/maven/internal/loop" +) + +// NudgeRecorder — the seam the store implements. the dispatcher records one +// nudge row per channel-sent (the nudges table IS the restraint memory + the +// feedback loop's only input). recording happens AFTER a successful send, so +// a failed send doesn't pollute the feedback signal with a phantom nudge — +// ignored_rate would drift on a row that never reached anyone. +type NudgeRecorder interface { + RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) +} + +// ReminderCompleter — the seam the store implements. a reminder fires once: +// pending → fired after successful delivery. a failed send does NOT mark the +// reminder fired (it stays pending; the next tick re-delivers). +type ReminderCompleter interface { + MarkReminder(ctx context.Context, id int64, status string) error +} + +// PhrasedNudge — the phraser module's output for a nudge. the phraser (LFM +// sub-1b, prompted not trained) takes (rule, severity, context) and produces +// Body (full message for voice) + Summary (minimal body for away channels). +// the phraser is a separate module; the dispatcher only consumes its output. +type PhrasedNudge struct { + Candidate loop.Candidate + Body string + Summary string +} + +// PhrasedReminder — the phraser's output for a reminder. +type PhrasedReminder struct { + Decision loop.ReminderDecision + Body string + Summary string +} + +// Dispatch — record of one successful send. returned to the daemon for +// logging, ack wiring, and nudge-id tracking. NudgeID is set for nudges +// (the feedback loop's key); 0 for reminders. +type Dispatch struct { + Sendable Sendable + NudgeID int64 +} + +// Config — wires the dispatcher. nil sinks = that channel not wired. nil +// AckTracker = repeat-til-ack disabled (the daemon doesn't wire it until the +// telegram module lands). nil Nudges/Reminders = recording disabled (test +// scenarios that only exercise routing). +type Config struct { + Voice Sink + Ntfy Sink + Telegram Sink + Ack AckTracker + Nudges NudgeRecorder + Reminders ReminderCompleter +} + +// Dispatcher — holds one sink per channel + the recorder seams. the daemon +// wires it once; per-tick it calls DispatchNudge / DispatchReminder. the +// routing table (channel.go) is pure; this struct is the impure orchestration. +type Dispatcher struct { + cfg Config +} + +func NewDispatcher(cfg Config) *Dispatcher { + return &Dispatcher{cfg: cfg} +} + +// DispatchNudge — routes a phrased nudge to the channels the routing table +// picks for (severity, presence), sends via the matching sink, and records +// one nudge row per successful send. returns the dispatches (one per channel). +// +// a Drop channel = no send, no record (the nudge was suppressed by routing, +// not by a failure — "a missed water nudge is noise"). a nil sink = channel +// not wired, skip silently. a send error stops the dispatch and returns what +// got through — the daemon decides whether to retry. +func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) { + c := pn.Candidate + channels := ChannelsFor(c.Severity, c.State.Presence) + var out []Dispatch + for _, ch := range channels { + if ch == ChannelDrop { + continue + } + s := Sendable{ + Channel: ch, + Kind: KindNudge, + Severity: c.Severity, + RuleName: c.Rule.Name, + Body: pn.Body, + Summary: pn.Summary, + RepeatUntilAck: ch == ChannelTelegram && c.Severity >= loop.Sev4, + Ts: now, + } + sink := d.sinkFor(ch) + if sink == nil { + continue + } + if err := sink.Send(ctx, s); err != nil { + return out, fmt.Errorf("send %s: %w", ch, err) + } + // record AFTER successful send — a failed send must not pollute the + // feedback loop with a phantom nudge (ignored_rate would drift). + var nudgeID int64 + if d.cfg.Nudges != nil { + id, err := d.cfg.Nudges.RecordNudge(ctx, c.Rule.Name, string(ch), messageForChannel(s), now) + if err != nil { + return out, fmt.Errorf("record nudge %s: %w", ch, err) + } + nudgeID = id + } + // for repeat-til-ack telegram sends, mark the initial send in the ack + // tracker so ShouldRepeat's clock starts now. + if s.RepeatUntilAck && d.cfg.Ack != nil { + if err := d.cfg.Ack.MarkSent(ctx, c.Rule.Name, now); err != nil { + return out, fmt.Errorf("ack mark-sent: %w", err) + } + } + out = append(out, Dispatch{Sendable: s, NudgeID: nudgeID}) + } + return out, nil +} + +// DispatchReminder — routes a phrased reminder. reminders bypass the gate and +// fire once (pending → fired after successful delivery). voice when present, +// ntfy when away. no repeat (reminders fire once). marks the reminder fired +// only if at least one channel succeeded — a failed send leaves it pending +// for the next tick to re-deliver. +func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, now time.Time) ([]Dispatch, error) { + rd := pr.Decision + channels := ChannelsForReminder(rd.State.Presence) + var out []Dispatch + for _, ch := range channels { + s := Sendable{ + Channel: ch, + Kind: KindReminder, + ReminderID: rd.Reminder.ID, + Body: pr.Body, + Summary: pr.Summary, + Ts: now, + } + sink := d.sinkFor(ch) + if sink == nil { + continue + } + if err := sink.Send(ctx, s); err != nil { + return out, fmt.Errorf("send %s: %w", ch, err) + } + out = append(out, Dispatch{Sendable: s}) + } + if d.cfg.Reminders != nil && len(out) > 0 { + if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil { + return out, fmt.Errorf("mark reminder fired: %w", err) + } + } + return out, nil +} + +// RepeatUnacked — the daemon calls this each tick to re-send un-acked sev4 +// telegram nudges. `keys` = rule names with un-acked telegram sends (the +// daemon queries the nudges table for pending-outcome sev4+telegram rows — +// a store helper that's deferred). for each key: if not acked and the repeat +// interval has elapsed since the last send, re-send + update last-sent. +// +// the body/summary are passed in because the phraser output from the original +// dispatch isn't retained — the daemon re-phrases (or reuses a cached phrase). +// a sev4 alarm repeating with the same terse body is correct; it's an alarm. +func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time.Time, interval time.Duration, body, summary string) ([]Dispatch, error) { + if d.cfg.Telegram == nil || d.cfg.Ack == nil { + return nil, nil + } + var out []Dispatch + for _, key := range keys { + acked, err := d.cfg.Ack.WasAcked(ctx, key) + if err != nil { + return out, fmt.Errorf("ack was-acked %s: %w", key, err) + } + last, err := d.cfg.Ack.LastSent(ctx, key) + if err != nil { + return out, fmt.Errorf("ack last-sent %s: %w", key, err) + } + if !ShouldRepeat(last, acked, now, interval) { + continue + } + s := Sendable{ + Channel: ChannelTelegram, + Kind: KindNudge, + Severity: loop.Sev4, + RuleName: key, + Body: body, + Summary: summary, + RepeatUntilAck: true, + Ts: now, + } + if err := d.cfg.Telegram.Send(ctx, s); err != nil { + return out, fmt.Errorf("repeat send telegram %s: %w", key, err) + } + if err := d.cfg.Ack.MarkSent(ctx, key, now); err != nil { + return out, fmt.Errorf("ack mark-sent %s: %w", key, err) + } + out = append(out, Dispatch{Sendable: s}) + } + return out, nil +} + +func (d *Dispatcher) sinkFor(ch Channel) Sink { + switch ch { + case ChannelVoice: + return d.cfg.Voice + case ChannelNtfy: + return d.cfg.Ntfy + case ChannelTelegram: + return d.cfg.Telegram + default: + return nil + } +} + +// messageForChannel — away channels get the minimal summary (no shoulder-surf +// exfil — "disk low on homesrv," not detail); voice gets the full body (local). +// a missing summary falls back to body — a terse full message is better than +// no message, and the phraser should have produced a summary for away-bound +// severities. this is the "minimal body" rule from the spec, enforced at the +// last mile so a phraser bug can't accidentally exfil via the relay. +func messageForChannel(s Sendable) string { + switch s.Channel { + case ChannelNtfy, ChannelTelegram: + if s.Summary != "" { + return s.Summary + } + return s.Body + default: + return s.Body + } +} diff --git a/internal/delivery/dispatcher_test.go b/internal/delivery/dispatcher_test.go new file mode 100644 index 0000000..0cc348a --- /dev/null +++ b/internal/delivery/dispatcher_test.go @@ -0,0 +1,491 @@ +package delivery + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } + +func sevRule(name string, sev loop.Severity) loop.Rule { + return loop.Rule{Name: name, Severity: sev} +} + +func candidate(name string, sev loop.Severity, presence store.Bucket) loop.Candidate { + return loop.Candidate{ + Rule: sevRule(name, sev), + Severity: sev, + State: loop.State{Now: refNow(), Presence: presence}, + } +} + +// --- fakes --- + +type fakeSink struct { + sends []Sendable + err error +} + +func (f *fakeSink) Send(_ context.Context, s Sendable) error { + if f.err != nil { + return f.err + } + f.sends = append(f.sends, s) + return nil +} + +type fakeNudgeRecorder struct { + rows []nudgeRow + id int64 +} + +type nudgeRow struct { + rule, channel, message string + ts time.Time +} + +func (f *fakeNudgeRecorder) RecordNudge(_ context.Context, rule, channel, message string, ts time.Time) (int64, error) { + f.id++ + f.rows = append(f.rows, nudgeRow{rule, channel, message, ts}) + return f.id, nil +} + +type fakeReminderCompleter struct { + marked []struct { + id int64 + status string + } + err error +} + +func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error { + if f.err != nil { + return f.err + } + f.marked = append(f.marked, struct { + id int64 + status string + }{id, status}) + return nil +} + +type fakeAck struct { + acked map[string]bool + lastSent map[string]time.Time +} + +func newFakeAck() *fakeAck { + return &fakeAck{acked: make(map[string]bool), lastSent: make(map[string]time.Time)} +} + +func (f *fakeAck) WasAcked(_ context.Context, key string) (bool, error) { + return f.acked[key], nil +} +func (f *fakeAck) MarkSent(_ context.Context, key string, ts time.Time) error { + f.lastSent[key] = ts + return nil +} +func (f *fakeAck) LastSent(_ context.Context, key string) (time.Time, error) { + return f.lastSent[key], nil +} +func (f *fakeAck) MarkAcked(_ context.Context, key string) error { + f.acked[key] = true + return nil +} + +// ----------------------------- routing table -------------------------------- + +func TestChannelsForCarePresent(t *testing.T) { + got := ChannelsFor(loop.Sev1, store.Present) + if len(got) != 1 || got[0] != ChannelVoice { + t.Fatalf("sev1 present: want [voice], got %v", got) + } +} + +func TestChannelsForCareAwayDrops(t *testing.T) { + // sev ≤ 2 drops on away — "a missed water nudge is noise." + got := ChannelsFor(loop.Sev2, store.Away) + if len(got) != 1 || got[0] != ChannelDrop { + t.Fatalf("sev2 away: want [drop], got %v", got) + } +} + +func TestChannelsForOpsSoftAwayNtfyOnce(t *testing.T) { + got := ChannelsFor(loop.Sev3, store.Away) + if len(got) != 1 || got[0] != ChannelNtfy { + t.Fatalf("sev3 away: want [ntfy], got %v", got) + } +} + +func TestChannelsForOpsHardPresentVoiceAndNtfy(t *testing.T) { + got := ChannelsFor(loop.Sev4, store.Present) + if len(got) != 2 || got[0] != ChannelVoice || got[1] != ChannelNtfy { + t.Fatalf("sev4 present: want [voice ntfy], got %v", got) + } +} + +func TestChannelsForOpsHardAwayTelegram(t *testing.T) { + got := ChannelsFor(loop.Sev4, store.Away) + if len(got) != 1 || got[0] != ChannelTelegram { + t.Fatalf("sev4 away: want [telegram], got %v", got) + } +} + +func TestChannelsForReminderPresentVoice(t *testing.T) { + got := ChannelsForReminder(store.Present) + if len(got) != 1 || got[0] != ChannelVoice { + t.Fatalf("reminder present: want [voice], got %v", got) + } +} + +func TestChannelsForReminderAwayNtfy(t *testing.T) { + got := ChannelsForReminder(store.Away) + if len(got) != 1 || got[0] != ChannelNtfy { + t.Fatalf("reminder away: want [ntfy], got %v", got) + } +} + +// ----------------------------- dispatcher: nudges --------------------------- + +func TestDispatchNudgeCarePresentSendsVoice(t *testing.T) { + voice := &fakeSink{} + ntfy := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("water", loop.Sev1, store.Present), + Body: "you haven't had water in 4h", + Summary: "water", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelVoice { + t.Fatalf("want 1 voice dispatch, got %+v", out) + } + if len(voice.sends) != 1 || voice.sends[0].Body != "you haven't had water in 4h" { + t.Fatalf("voice send: %+v", voice.sends) + } + if len(ntfy.sends) != 0 { + t.Fatalf("ntfy should not fire for sev1 present, got %+v", ntfy.sends) + } + if len(rec.rows) != 1 || rec.rows[0].rule != "water" || rec.rows[0].channel != "voice" { + t.Fatalf("nudge record: %+v", rec.rows) + } +} + +func TestDispatchNudgeCareAwayDropsNoSendNoRecord(t *testing.T) { + voice := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("water", loop.Sev1, store.Away), + Body: "you haven't had water in 4h", + Summary: "water", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(out) != 0 { + t.Fatalf("care away: want 0 dispatches, got %+v", out) + } + if len(voice.sends) != 0 || len(rec.rows) != 0 { + t.Fatalf("drop = no send, no record; sends=%v rows=%v", voice.sends, rec.rows) + } +} + +func TestDispatchNudgeOpsHardPresentSendsVoiceAndNtfy(t *testing.T) { + voice := &fakeSink{} + ntfy := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("service_down", loop.Sev4, store.Present), + Body: "the backup service on homesrv is down", + Summary: "backup down on homesrv", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(out) != 2 { + t.Fatalf("sev4 present: want 2 dispatches, got %d", len(out)) + } + if len(voice.sends) != 1 || len(ntfy.sends) != 1 { + t.Fatalf("want 1 voice + 1 ntfy, got voice=%d ntfy=%d", len(voice.sends), len(ntfy.sends)) + } +} + +func TestDispatchNudgeOpsHardAwayTelegramRepeatUntilAck(t *testing.T) { + telegram := &fakeSink{} + rec := &fakeNudgeRecorder{} + ack := newFakeAck() + d := NewDispatcher(Config{Telegram: telegram, Ack: ack, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("service_down", loop.Sev4, store.Away), + Body: "the backup service on homesrv is down", + Summary: "backup down on homesrv", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram { + t.Fatalf("sev4 away: want 1 telegram, got %+v", out) + } + if !out[0].Sendable.RepeatUntilAck { + t.Fatalf("sev4 telegram away: want RepeatUntilAck=true") + } + if len(telegram.sends) != 1 { + t.Fatalf("want 1 telegram send, got %d", len(telegram.sends)) + } + // ack tracker should have the initial MarkSent + last, _ := ack.LastSent(context.Background(), "service_down") + if !last.Equal(refNow()) { + t.Fatalf("ack MarkSent: want %v, got %v", refNow(), last) + } +} + +func TestDispatchNudgeMinimalBodyForAwayChannels(t *testing.T) { + // away channels get Summary, not Body — the "minimal body" / no-shoulder- + // surf-exfil rule. the nudge record stores the summary too. + ntfy := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Ntfy: ntfy, Nudges: rec}) + + _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("cert_expiring", loop.Sev3, store.Away), + Body: "the tls cert for homesrv.kami.lan expires in 3 days — renew via acme.sh on the reverse proxy", + Summary: "cert expiring soon", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if ntfy.sends[0].Summary != "cert expiring soon" { + t.Fatalf("ntfy summary: want 'cert expiring soon', got %q", ntfy.sends[0].Summary) + } + if rec.rows[0].message != "cert expiring soon" { + t.Fatalf("recorded message should be summary, got %q", rec.rows[0].message) + } +} + +func TestDispatchNudgeSendErrorStopsAndReturnsPartial(t *testing.T) { + // sev4 present → voice + ntfy. voice fails → ntfy never tried, partial + // returned. a failed send doesn't pollute the feedback loop (no record + // for the failed channel). + voice := &fakeSink{err: errors.New("audio device gone")} + ntfy := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("service_down", loop.Sev4, store.Present), + Body: "down", Summary: "down", + }, refNow()) + if err == nil { + t.Fatalf("want send error, got nil") + } + if len(out) != 0 { + t.Fatalf("voice failed first → 0 dispatches, got %d", len(out)) + } + if len(rec.rows) != 0 { + t.Fatalf("failed send must not record a nudge, got %d rows", len(rec.rows)) + } +} + +func TestDispatchNudgeNilSinkSkipsSilently(t *testing.T) { + // ntfy not wired; sev3 away routes to ntfy → skipped, no error. + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Nudges: rec}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("cert_expiring", loop.Sev3, store.Away), + Body: "cert", Summary: "cert", + }, refNow()) + if err != nil { + t.Fatalf("nil sink should skip silently, got %v", err) + } + if len(out) != 0 { + t.Fatalf("nil ntfy → 0 dispatches, got %d", len(out)) + } +} + +// ----------------------------- dispatcher: reminders ------------------------ + +func TestDispatchReminderPresentVoice(t *testing.T) { + voice := &fakeSink{} + rc := &fakeReminderCompleter{} + d := NewDispatcher(Config{Voice: voice, Reminders: rc}) + + rd := loop.ReminderDecision{ + Reminder: store.Reminder{ID: 42, Payload: `"wake me"`, Status: "pending"}, + State: loop.State{Now: refNow(), Presence: store.Present}, + } + out, err := d.DispatchReminder(context.Background(), PhrasedReminder{ + Decision: rd, Body: "wake up", Summary: "wake up", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelVoice { + t.Fatalf("want 1 voice, got %+v", out) + } + if len(rc.marked) != 1 || rc.marked[0].id != 42 || rc.marked[0].status != "fired" { + t.Fatalf("reminder not marked fired: %+v", rc.marked) + } +} + +func TestDispatchReminderAwayNtfy(t *testing.T) { + ntfy := &fakeSink{} + rc := &fakeReminderCompleter{} + d := NewDispatcher(Config{Ntfy: ntfy, Reminders: rc}) + + rd := loop.ReminderDecision{ + Reminder: store.Reminder{ID: 7, Status: "pending"}, + State: loop.State{Now: refNow(), Presence: store.Away}, + } + out, err := d.DispatchReminder(context.Background(), PhrasedReminder{ + Decision: rd, Body: "full wake up message", Summary: "wake up", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy { + t.Fatalf("want 1 ntfy, got %+v", out) + } + // away channel gets summary, not body + if ntfy.sends[0].Summary != "wake up" { + t.Fatalf("ntfy summary: want 'wake up', got %q", ntfy.sends[0].Summary) + } + if len(rc.marked) != 1 || rc.marked[0].status != "fired" { + t.Fatalf("reminder not marked fired: %+v", rc.marked) + } +} + +func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) { + // a failed send must not mark the reminder fired — it stays pending for + // the next tick to re-deliver. same instinct as "record after success." + voice := &fakeSink{err: errors.New("no audio")} + rc := &fakeReminderCompleter{} + d := NewDispatcher(Config{Voice: voice, Reminders: rc}) + + rd := loop.ReminderDecision{ + Reminder: store.Reminder{ID: 1, Status: "pending"}, + State: loop.State{Now: refNow(), Presence: store.Present}, + } + _, err := d.DispatchReminder(context.Background(), PhrasedReminder{ + Decision: rd, Body: "wake", Summary: "wake", + }, refNow()) + if err == nil { + t.Fatalf("want send error") + } + if len(rc.marked) != 0 { + t.Fatalf("failed send must not mark fired, got %+v", rc.marked) + } +} + +// ----------------------------- repeat-til-ack ------------------------------- + +func TestShouldRepeat(t *testing.T) { + now := refNow() + cases := []struct { + name string + lastSent time.Time + acked bool + now time.Time + interval time.Duration + want bool + }{ + {"never sent, not acked", time.Time{}, false, now, 5 * time.Minute, true}, + {"acked → stop", now.Add(-1 * time.Minute), true, now, 5 * time.Minute, false}, + {"interval not elapsed → wait", now.Add(-1 * time.Minute), false, now, 5 * time.Minute, false}, + {"interval elapsed → repeat", now.Add(-6 * time.Minute), false, now, 5 * time.Minute, true}, + {"exactly interval → repeat", now.Add(-5 * time.Minute), false, now, 5 * time.Minute, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := ShouldRepeat(c.lastSent, c.acked, c.now, c.interval) + if got != c.want { + t.Fatalf("ShouldRepeat: want %v, got %v", c.want, got) + } + }) + } +} + +func TestRepeatUnackedReSendsAfterInterval(t *testing.T) { + telegram := &fakeSink{} + ack := newFakeAck() + d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) + + // initial send was 6min ago, interval 5min → should repeat. + initial := refNow().Add(-6 * time.Minute) + _ = ack.MarkSent(context.Background(), "service_down", initial) + + out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "backup down") + if err != nil { + t.Fatalf("repeat: %v", err) + } + if len(out) != 1 || out[0].Sendable.RuleName != "service_down" { + t.Fatalf("want 1 repeat for service_down, got %+v", out) + } + // last-sent updated to now + last, _ := ack.LastSent(context.Background(), "service_down") + if !last.Equal(refNow()) { + t.Fatalf("last-sent should update to now, got %v", last) + } +} + +func TestRepeatUnackedSkipsAcked(t *testing.T) { + telegram := &fakeSink{} + ack := newFakeAck() + d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) + + _ = ack.MarkSent(context.Background(), "service_down", refNow().Add(-10*time.Minute)) + _ = ack.MarkAcked(context.Background(), "service_down") + + out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "down") + if err != nil { + t.Fatalf("repeat: %v", err) + } + if len(out) != 0 { + t.Fatalf("acked → 0 repeats, got %+v", out) + } + if len(telegram.sends) != 0 { + t.Fatalf("acked → no telegram send, got %d", len(telegram.sends)) + } +} + +func TestRepeatUnackedSkipsBeforeInterval(t *testing.T) { + telegram := &fakeSink{} + ack := newFakeAck() + d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) + + // sent 1min ago, interval 5min → wait. + _ = ack.MarkSent(context.Background(), "service_down", refNow().Add(-1*time.Minute)) + + out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "down") + if err != nil { + t.Fatalf("repeat: %v", err) + } + if len(out) != 0 { + t.Fatalf("before interval → 0 repeats, got %+v", out) + } +} + +func TestRepeatUnackedNilTelegramOrAckIsNoOp(t *testing.T) { + d := NewDispatcher(Config{}) // no telegram, no ack + out, err := d.RepeatUnacked(context.Background(), []string{"service_down"}, refNow(), 5*time.Minute, "down", "down") + if err != nil { + t.Fatalf("nil telegram/ack: want nil err, got %v", err) + } + if out != nil { + t.Fatalf("nil telegram/ack: want nil, got %+v", out) + } +} diff --git a/internal/delivery/ntfysink/ntfysink.go b/internal/delivery/ntfysink/ntfysink.go new file mode 100644 index 0000000..dad2f9b --- /dev/null +++ b/internal/delivery/ntfysink/ntfysink.go @@ -0,0 +1,142 @@ +// Package ntfysink implements delivery.Sink for the ntfy push channel. +// +// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard) +// nudges when present (alongside voice), and reminders when away. the +// message body is the Sendable's Summary — the minimal-body rule from the +// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through +// the relay). voice gets Body; away channels get Summary, enforced at the +// sink so a phraser bug can't exfil. +// +// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes +// with a dedicated user (write-only to maven-* topics) — the credential is a +// delivery-config secret, not a db key; a popped ntfy sink can push spam to +// your phone, nothing else. matches the module key-isolation invariant: the +// sink never holds the sqlcipher key. +package ntfysink + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" +) + +// Config — ntfy publish config. the daemon wires this from its config file; +// the credential lives in the daemon's config (or a systemd credential), +// never in the binary. +type Config struct { + BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path) + Topic string // e.g. maven (all maven notifications land here) + Username string // basic auth; empty = anonymous (won't work with deny-all) + Password string // basic auth + Timeout time.Duration // per-request; 0 = DefaultTimeout +} + +const DefaultTimeout = 10 * time.Second + +// Sink — implements delivery.Sink via ntfy HTTP publish. one POST per Send. +// no retry (the dispatcher + daemon decide retry policy); no streaming. +type Sink struct { + cfg Config + hc *http.Client +} + +// New validates the config and builds the sink. BaseURL and Topic are +// required; auth is optional (but deny-all servers reject unauthed publishes). +func New(cfg Config) (*Sink, error) { + if cfg.BaseURL == "" { + return nil, fmt.Errorf("ntfysink: BaseURL is required") + } + if _, err := url.Parse(cfg.BaseURL); err != nil { + return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err) + } + if cfg.Topic == "" { + return nil, fmt.Errorf("ntfysink: Topic is required") + } + to := cfg.Timeout + if to == 0 { + to = DefaultTimeout + } + return &Sink{ + cfg: cfg, + hc: &http.Client{Timeout: to}, + }, nil +} + +// Send publishes one notification to ntfy. the body is the Sendable's Summary +// (minimal body); Title is "maven" (consistent sender identity on the lock +// screen — the content is in the body). Priority maps from severity/kind so +// the phone client can ring differently for an alarm vs a soft ops nudge. +func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { + body := d.Summary + if body == "" { + body = d.Body // terse full message beats no message + } + if body == "" { + return fmt.Errorf("ntfysink: empty message for %s", d.Channel) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body)) + if err != nil { + return fmt.Errorf("ntfysink: build request: %w", err) + } + req.Header.Set("Title", "maven") + req.Header.Set("Priority", priorityFor(d).String()) + if s.cfg.Username != "" { + req.SetBasicAuth(s.cfg.Username, s.cfg.Password) + } + + resp, err := s.hc.Do(req) + if err != nil { + return fmt.Errorf("ntfysink: publish: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb))) + } + return nil +} + +func (s *Sink) topicURL() string { + return strings.TrimRight(s.cfg.BaseURL, "/") + "/" + s.cfg.Topic +} + +// ntfyPriority — ntfy's 1–5 priority scale. +type ntfyPriority int + +const ( + prioMin ntfyPriority = 1 + prioLow ntfyPriority = 2 + prioDefault ntfyPriority = 3 + prioHigh ntfyPriority = 4 + prioMax ntfyPriority = 5 +) + +func (p ntfyPriority) String() string { return fmt.Sprintf("%d", int(p)) } + +// priorityFor — maps maven's (kind, severity) to ntfy's 1–5. +// +// reminders are user-stated intent ("wake me 7") → high (4); they bypassed the +// gate to reach you, make them ring. sev4 (ops hard, present — away goes to +// telegram) → max (5): a disk-fire alarm that also pushes to your watch. sev3 +// (ops soft, away) → high (4): it held through away for a reason. care nudges +// never reach ntfy (they drop on away), so no sev1–2 mapping here. +func priorityFor(d delivery.Sendable) ntfyPriority { + if d.Kind == delivery.KindReminder { + return prioHigh + } + if d.Severity >= loop.Sev4 { + return prioMax + } + if d.Severity == loop.Sev3 { + return prioHigh + } + return prioDefault +} diff --git a/internal/delivery/ntfysink/ntfysink_test.go b/internal/delivery/ntfysink/ntfysink_test.go new file mode 100644 index 0000000..d106c42 --- /dev/null +++ b/internal/delivery/ntfysink/ntfysink_test.go @@ -0,0 +1,313 @@ +package ntfysink + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" +) + +// recordingServer — captures the last request so tests assert the wire shape. +type recordingServer struct { + mu chan struct{} + method string + path string + body string + auth string + title string + prio string + status int + respond string +} + +func newRecordingServer(t *testing.T, status int, respond string) *recordingServer { + t.Helper() + rs := &recordingServer{status: status, respond: respond, mu: make(chan struct{}, 1)} + rs.mu <- struct{}{} + return rs +} + +func (rs *recordingServer) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + <-rs.mu + rs.method = r.Method + rs.path = r.URL.Path + rs.body = string(b) + rs.auth = r.Header.Get("Authorization") + rs.title = r.Header.Get("Title") + rs.prio = r.Header.Get("Priority") + rs.mu <- struct{}{} + w.WriteHeader(rs.status) + if rs.respond != "" { + _, _ = w.Write([]byte(rs.respond)) + } + }) +} + +func (rs *recordingServer) snapshot() (method, path, body, auth, title, prio string) { + <-rs.mu + method, path, body, auth, title, prio = rs.method, rs.path, rs.body, rs.auth, rs.title, rs.prio + rs.mu <- struct{}{} + return +} + +func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable { + return delivery.Sendable{ + Channel: delivery.ChannelNtfy, + Kind: delivery.KindNudge, + Severity: sev, + RuleName: "service_down", + Body: "the backup service on homesrv is down - check journalctl", + Summary: summary, + Ts: time.Now(), + } +} + +func reminderSendable(summary string) delivery.Sendable { + return delivery.Sendable{ + Channel: delivery.ChannelNtfy, + Kind: delivery.KindReminder, + ReminderID: 42, + Body: "full reminder body with detail", + Summary: summary, + Ts: time.Now(), + } +} + +// ----------------------------- config --------------------------------------- + +func TestNewRejectsEmptyBaseURL(t *testing.T) { + _, err := New(Config{Topic: "maven"}) + if err == nil { + t.Fatal("want error for empty BaseURL") + } +} + +func TestNewRejectsEmptyTopic(t *testing.T) { + _, err := New(Config{BaseURL: "http://localhost:8085"}) + if err == nil { + t.Fatal("want error for empty Topic") + } +} + +func TestNewDefaultTimeout(t *testing.T) { + s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"}) + if err != nil { + t.Fatalf("New: %v", err) + } + if s.hc.Timeout != DefaultTimeout { + t.Fatalf("default timeout: want %v, got %v", DefaultTimeout, s.hc.Timeout) + } +} + +// ----------------------------- send shape ---------------------------------- + +func TestSendPostsToTopicPath(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil { + t.Fatalf("Send: %v", err) + } + method, path, _, _, _, _ := rs.snapshot() + if method != http.MethodPost { + t.Fatalf("method: want POST, got %s", method) + } + if path != "/maven" { + t.Fatalf("path: want /maven, got %s", path) + } +} + +func TestSendBodyIsSummaryNotFullBody(t *testing.T) { + // the minimal-body rule: away channels get Summary, never Body. the sink + // must post Summary so a phraser bug (Body leaking detail) can't exfil. + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _, _ := rs.snapshot() + if body != "cert expiring soon" { + t.Fatalf("body: want summary 'cert expiring soon', got %q", body) + } +} + +func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { + // a terse full message is better than no message; the phraser should + // produce a summary for away-bound severities, but don't silently drop. + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + s := nudgeSendable(loop.Sev3, "") + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + if err := sink.Send(context.Background(), s); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _, _ := rs.snapshot() + if body != s.Body { + t.Fatalf("fallback body: want %q, got %q", s.Body, body) + } +} + +func TestSendRejectsEmptyMessage(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + s := nudgeSendable(loop.Sev3, "") + s.Body = "" + err := sink.Send(context.Background(), s) + if err == nil { + t.Fatal("want error for empty message") + } +} + +func TestSendSetsBasicAuth(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{ + BaseURL: srv.URL, + Topic: "maven", + Username: "maven", + Password: "secret", + }) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, _, auth, _, _ := rs.snapshot() + if auth == "" { + t.Fatal("want Basic auth header, got empty") + } + if !strings.HasPrefix(auth, "Basic ") { + t.Fatalf("auth: want 'Basic ...', got %q", auth) + } +} + +func TestSendNoAuthWhenUsernameEmpty(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, _, auth, _, _ := rs.snapshot() + if auth != "" { + t.Fatalf("want no auth header, got %q", auth) + } +} + +func TestSendTitleIsMaven(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, _, _, title, _ := rs.snapshot() + if title != "maven" { + t.Fatalf("title: want 'maven', got %q", title) + } +} + +// ----------------------------- priority mapping ---------------------------- + +func TestPrioritySev3IsHigh(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + _ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert")) + _, _, _, _, _, prio := rs.snapshot() + if prio != "4" { + t.Fatalf("sev3 priority: want 4 (high), got %s", prio) + } +} + +func TestPrioritySev4IsMax(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + _ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) + _, _, _, _, _, prio := rs.snapshot() + if prio != "5" { + t.Fatalf("sev4 priority: want 5 (max), got %s", prio) + } +} + +func TestPriorityReminderIsHigh(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + _ = sink.Send(context.Background(), reminderSendable("wake up")) + _, _, _, _, _, prio := rs.snapshot() + if prio != "4" { + t.Fatalf("reminder priority: want 4 (high), got %s", prio) + } +} + +// ----------------------------- error handling ------------------------------ + +func TestSendReturnsErrorOnNon2xx(t *testing.T) { + rs := newRecordingServer(t, http.StatusForbidden, `{"error":"forbidden"}`) + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")) + if err == nil { + t.Fatal("want error on 403") + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("error should mention status 403, got: %v", err) + } +} + +func TestSendContextCancelReturnsError(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down")) + if err == nil { + t.Fatal("want error on canceled context") + } +} + +func TestSendConnectionRefusedReturnsError(t *testing.T) { + sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second}) + err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")) + if err == nil { + t.Fatal("want error on connection refused") + } +} diff --git a/internal/delivery/sendable.go b/internal/delivery/sendable.go new file mode 100644 index 0000000..05590fa --- /dev/null +++ b/internal/delivery/sendable.go @@ -0,0 +1,56 @@ +package delivery + +import ( + "time" + + "github.com/kami/maven/internal/loop" +) + +// Kind — what's being delivered. nudges and reminders are two delivery paths +// (per spec); the kind flows into the nudge table and ack tracking. a nudge +// is loop-derived (a rule fired); a reminder is user-stated future intent. +type Kind string + +const ( + KindNudge Kind = "nudge" + KindReminder Kind = "reminder" +) + +// Sendable — what a Sink receives. one per channel per dispatch. +// +// Body is the full phrased message for voice (local — no shoulder-surf +// concern beyond who's in the room). Summary is the minimal body for away +// channels (ntfy/telegram) — "disk low on homesrv," not detail. away channels +// leave the box through your relay; the minimal-body rule stops notifications +// from becoming a shoulder-surf exfil surface. messageForChannel picks which +// field the sink should display based on Channel. +// +// RepeatUntilAck — sev4 ops hard on away → telegram repeats until the user +// acknowledges. the telegram sink (or daemon repeat driver) consults +// ShouldRepeat + AckTracker. other channels are fire-and-forget. +type Sendable struct { + Channel Channel + Kind Kind + + // Severity — 0 for reminders (they don't carry one). set for nudges so + // sinks can adjust behavior (e.g. telegram could escalate presentation). + Severity loop.Severity + + // identification — one set depending on Kind. nudges use RuleName (matches + // the nudges table + the feedback loop's RecentOutcomes key); reminders use + // ReminderID (matches the reminders table). + RuleName string + ReminderID int64 + + // content + Body string // voice (local) — full phrased message + Summary string // away channels (ntfy/telegram) — minimal, no exfil + + // repeat-til-ack — only set for sev4 telegram nudges. the ack key is + // RuleName (one un-acked repeat stream per rule). + RepeatUntilAck bool + + // Ts — when this sendable was created (the tick time). flows into + // RecordNudge and the ack tracker's last-sent. + Ts time.Time +} diff --git a/internal/delivery/sink.go b/internal/delivery/sink.go new file mode 100644 index 0000000..849c7c2 --- /dev/null +++ b/internal/delivery/sink.go @@ -0,0 +1,32 @@ +package delivery + +import ( + "context" + "time" +) + +// Sink — one channel's transport. the impure seam: voice (local audio via the +// tts module), ntfy (push), telegram (push through your relay). each is a +// separate module with its own unit; the dispatcher holds one per channel. +// +// A nil Sink = that channel is not wired (the daemon doesn't have to wire all +// three at scaffold time). sends to an unwired channel are skipped — the +// daemon should wire what the routing table can produce for its configured +// severities, but a missing sink is a config gap, not a panic. +type Sink interface { + Send(ctx context.Context, s Sendable) error +} + +// AckTracker — for sev4 telegram repeat-til-ack. maps a send key (rule name) +// → acked + last-sent. the daemon's tick loop calls ShouldRepeat each cycle; +// when the user acknowledges (a tap on the telegram message, a voice "got it"), +// MarkAcked stops the repeat for that key. +// +// Pure decision lives in ShouldRepeat (ack.go); this is the stateful seam. +// the production impl is a store-backed table; the scaffold's fake is in-memory. +type AckTracker interface { + WasAcked(ctx context.Context, key string) (bool, error) + MarkSent(ctx context.Context, key string, ts time.Time) error + LastSent(ctx context.Context, key string) (time.Time, error) + MarkAcked(ctx context.Context, key string) error +} diff --git a/internal/delivery/telegramsink/telegramsink.go b/internal/delivery/telegramsink/telegramsink.go new file mode 100644 index 0000000..23449bb --- /dev/null +++ b/internal/delivery/telegramsink/telegramsink.go @@ -0,0 +1,199 @@ +// Package telegramsink implements delivery.Sink for the telegram push channel. +// +// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm +// at 2am routes to telegram, repeat til ack." the message body is the +// Sendable's Summary — the minimal-body rule from the spec ("disk low on +// homesrv," not detail; no shoulder-surf exfil through the relay). voice gets +// Body; away channels get Summary, enforced at the sink so a phraser bug can't +// exfil. additionally, protect_content=true is passed on every send so the +// message can't be forwarded out of the chat — locks the minimal body further. +// +// telegram's bot API is region-restricted for this homesrv — direct egress to +// api.telegram.org is unreliable. the spec's "away channels leave the box — +// through your relay" maps here to a Proxy config field (HTTP/HTTPS/SOCKS5). +// stdlib net/http Transport.Proxy supports all three; the daemon wires the +// relay URL from config. the bot token is a delivery-config secret (not a db +// key — a popped telegram sink can push spam to your chat, nothing else; +// matches the module key-isolation invariant: the sink never holds the +// sqlcipher key). +// +// repeat-til-ack is driven by the dispatcher + AckTracker (delivery/ack.go + +// dispatcher.RepeatUnacked), NOT by the sink. the sink is fire-and-forget per +// call — telegram has no priority/insistence field analogous to ntfy's 1–5; +// the repeat mechanism IS the insistence, re-sending on the dispatcher's tick +// until MarkAcked. +package telegramsink + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kami/maven/internal/delivery" +) + +// DefaultBaseURL — telegram bot API. overridable in Config (e.g. for a +// self-hosted bridge that mirrors the API shape and handles the region cut +// itself); the proxy is the normal path, the BaseURL override is the fallback. +const DefaultBaseURL = "https://api.telegram.org" + +const DefaultTimeout = 10 * time.Second + +// Config — telegram bot API publish config. the daemon wires this from its +// config file; the bot token + chat id live in the daemon's config (or a +// systemd credential), never in the binary. +type Config struct { + // BotToken — the telegram bot token from BotFather. required. sent in the + // URL path (the only place telegram accepts it), not in the body. + BotToken string + + // ChatID — destination chat. may be a numeric user/group id (sent as a + // JSON number) or @channelusername (sent as a JSON string). required. + ChatID string + + // BaseURL — telegram API base. empty = DefaultBaseURL. override to point + // at a self-hosted API bridge if the proxy path isn't used. + BaseURL string + + // Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram + // API (region-restricted direct egress). empty = direct (won't work from + // the homesrv without a relay; kept configurable for tests + future + // topology change). + Proxy string + + // Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the + // tick loop. + Timeout time.Duration +} + +// Sink — implements delivery.Sink via the telegram bot sendMessage API. one +// POST per Send; no retry (the dispatcher + daemon decide retry policy). the +// repeat-til-ack clock is driven by the dispatcher calling Send again each +// interval — the sink itself is stateless. +type Sink struct { + cfg Config + hc *http.Client + base string // resolved BaseURL, no trailing slash +} + +// New validates the config and builds the sink. BotToken and ChatID are +// required; Proxy and BaseURL are optional. +func New(cfg Config) (*Sink, error) { + if cfg.BotToken == "" { + return nil, fmt.Errorf("telegramsink: BotToken is required") + } + if cfg.ChatID == "" { + return nil, fmt.Errorf("telegramsink: ChatID is required") + } + base := cfg.BaseURL + if base == "" { + base = DefaultBaseURL + } + if _, err := url.Parse(base); err != nil { + return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err) + } + if cfg.Proxy != "" { + if _, err := url.Parse(cfg.Proxy); err != nil { + return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err) + } + } + to := cfg.Timeout + if to == 0 { + to = DefaultTimeout + } + transport := http.DefaultTransport.(*http.Transport).Clone() + if cfg.Proxy != "" { + pu, _ := url.Parse(cfg.Proxy) // parsed+checked above + transport.Proxy = http.ProxyURL(pu) + } + return &Sink{ + cfg: cfg, + base: strings.TrimRight(base, "/"), + hc: &http.Client{Timeout: to, Transport: transport}, + }, nil +} + +// sendMessageReq — the subset of sendMessage params maven uses. JSON-encoded +// as the request body. chat_id accepts number or string; go json tags keep +// both shapes (channel usernames are strings, user ids are numbers — send +// whatever ChatID was configured as). +type sendMessageReq struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + DisableNotification bool `json:"disable_notification"` // false = ring (always — these are alarms) + ProtectContent bool `json:"protect_content"` // true = no forwarding out of chat +} + +// telegramResp — the shape telegram returns. ok=false on logical error with +// error_code + description; ok=true with result on success (result contents +// not needed by the sink). +type telegramResp struct { + Ok bool `json:"ok"` + ErrorCode int `json:"error_code,omitempty"` + Description string `json:"description,omitempty"` +} + +// Send publishes one message to the configured telegram chat. the body is the +// Sendable's Summary (minimal body); empty Summary falls back to Body (terse +// full message beats no message). protect_content=true so a phraser bug (Body +// leaking detail through Summary) can't be forwarded onward by the user or a +// chat observer — locks the minimal-body rule at the channel's own last mile. +func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { + body := d.Summary + if body == "" { + body = d.Body + } + if body == "" { + return fmt.Errorf("telegramsink: empty message for %s", d.Channel) + } + + payload := sendMessageReq{ + ChatID: s.cfg.ChatID, + Text: body, + DisableNotification: false, // maven sends to telegram only on sev4/sev3/reminder — all want to ring + ProtectContent: true, // no forwarding out — locks minimal body at the channel last mile + } + pb, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("telegramsink: marshal: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.sendMessageURL(), bytes.NewReader(pb)) + if err != nil { + return fmt.Errorf("telegramsink: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.hc.Do(req) + if err != nil { + return fmt.Errorf("telegramsink: sendMessage: %w", err) + } + defer resp.Body.Close() + rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + + // telegram returns 200 with ok=true on success; non-2xx with ok=false + + // error_code + description on failure. parse the body either way so a 200 + // with ok=false (shouldn't happen, but the API reserves that) still surfaces. + var tr telegramResp + if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok { + return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description)) + } + if resp.StatusCode/100 != 2 { + return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb))) + } + return nil +} + +// sendMessageURL — the bot API path. the token is in the URL path +// (https://api.telegram.org/bot/sendMessage); telegram does not accept +// it anywhere else. the URL is built per-send from the resolved base — the +// token never leaves the sink, no logging. +func (s *Sink) sendMessageURL() string { + return s.base + "/bot" + s.cfg.BotToken + "/sendMessage" +} \ No newline at end of file diff --git a/internal/delivery/telegramsink/telegramsink_test.go b/internal/delivery/telegramsink/telegramsink_test.go new file mode 100644 index 0000000..25ae761 --- /dev/null +++ b/internal/delivery/telegramsink/telegramsink_test.go @@ -0,0 +1,402 @@ +package telegramsink + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" +) + +// recordingServer — captures the last request so tests assert the wire shape. +type recordingServer struct { + mu chan struct{} + method string + path string + body string + contentType string + auth string + status int + respond string +} + +func newRecordingServer(t *testing.T, status int, respond string) *recordingServer { + t.Helper() + rs := &recordingServer{status: status, respond: respond, mu: make(chan struct{}, 1)} + rs.mu <- struct{}{} + return rs +} + +func (rs *recordingServer) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + <-rs.mu + rs.method = r.Method + rs.path = r.URL.Path + rs.body = string(b) + rs.contentType = r.Header.Get("Content-Type") + rs.auth = r.Header.Get("Authorization") + rs.mu <- struct{}{} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(rs.status) + if rs.respond != "" { + _, _ = w.Write([]byte(rs.respond)) + } else { + _, _ = w.Write([]byte(`{"ok":true}`)) + } + }) +} + +func (rs *recordingServer) snapshot() (method, path, body, contentType, auth string) { + <-rs.mu + method, path, body, contentType, auth = rs.method, rs.path, rs.body, rs.contentType, rs.auth + rs.mu <- struct{}{} + return +} + +func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable { + return delivery.Sendable{ + Channel: delivery.ChannelTelegram, + Kind: delivery.KindNudge, + Severity: sev, + RuleName: "service_down", + Body: "the backup service on homesrv is down - check journalctl", + Summary: summary, + Ts: time.Now(), + } +} + +func reminderSendable(summary string) delivery.Sendable { + return delivery.Sendable{ + Channel: delivery.ChannelTelegram, + Kind: delivery.KindReminder, + ReminderID: 42, + Body: "full reminder body with detail", + Summary: summary, + Ts: time.Now(), + } +} + +// sinkCfg — convenience for tests: BaseURL + minimal required fields. +func sinkCfg(baseURL string) Config { + return Config{BaseURL: baseURL, BotToken: "123:abc", ChatID: "42"} +} + +// ----------------------------- config --------------------------------------- + +func TestNewRejectsEmptyBotToken(t *testing.T) { + _, err := New(Config{ChatID: "42"}) + if err == nil { + t.Fatal("want error for empty BotToken") + } +} + +func TestNewRejectsEmptyChatID(t *testing.T) { + _, err := New(Config{BotToken: "123:abc"}) + if err == nil { + t.Fatal("want error for empty ChatID") + } +} + +func TestNewDefaultTimeout(t *testing.T) { + s, err := New(Config{BotToken: "123:abc", ChatID: "42"}) + if err != nil { + t.Fatalf("New: %v", err) + } + if s.hc.Timeout != DefaultTimeout { + t.Fatalf("default timeout: want %v, got %v", DefaultTimeout, s.hc.Timeout) + } +} + +func TestNewRejectsBadBaseURL(t *testing.T) { + _, err := New(Config{BotToken: "x", ChatID: "1", BaseURL: "://bad"}) + if err == nil { + t.Fatal("want error for bad BaseURL") + } +} + +func TestNewRejectsBadProxy(t *testing.T) { + _, err := New(Config{BotToken: "x", ChatID: "1", Proxy: "://bad"}) + if err == nil { + t.Fatal("want error for bad Proxy") + } +} + +// ----------------------------- send shape ----------------------------------- + +func TestSendPostsToSendMessagePath(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, err := New(sinkCfg(srv.URL)) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); err != nil { + t.Fatalf("Send: %v", err) + } + method, path, _, _, _ := rs.snapshot() + if method != http.MethodPost { + t.Fatalf("method: want POST, got %s", method) + } + if path != "/bot123:abc/sendMessage" { + t.Fatalf("path: want /bot123:abc/sendMessage, got %s", path) + } +} + +func TestSendBodyIsSummaryNotFullBody(t *testing.T) { + // minimal-body rule: away channels get Summary, never Body. the sink must + // post Summary so a phraser bug (Body leaking detail) can't exfil. + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _ := rs.snapshot() + var req sendMessageReq + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("unmarshal body: %v (raw=%q)", err, body) + } + if req.Text != "homesrv down" { + t.Fatalf("text: want summary 'homesrv down', got %q", req.Text) + } +} + +func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { + // terse full message beats none; the phraser should produce a summary for + // away-bound severities, but don't silently drop. + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + s := nudgeSendable(loop.Sev4, "") + sink, _ := New(sinkCfg(srv.URL)) + if err := sink.Send(context.Background(), s); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _ := rs.snapshot() + var req sendMessageReq + _ = json.Unmarshal([]byte(body), &req) + if req.Text != s.Body { + t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text) + } +} + +func TestSendRejectsEmptyMessage(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + s := nudgeSendable(loop.Sev4, "") + s.Body = "" + err := sink.Send(context.Background(), s) + if err == nil { + t.Fatal("want error for empty message") + } +} + +func TestSendSetsChatIDAndProtectContent(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + cfg := sinkCfg(srv.URL) + cfg.ChatID = "@maven_alerts" + sink, _ := New(cfg) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _ := rs.snapshot() + var req sendMessageReq + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if req.ChatID != "@maven_alerts" { + t.Fatalf("chat_id: want @maven_alerts, got %q", req.ChatID) + } + if !req.ProtectContent { + t.Fatalf("protect_content: want true, got false") + } + if req.DisableNotification { + t.Fatalf("disable_notification: want false (alarms ring), got true") + } +} + +func TestSendContentTypeIsJSON(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + _ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) + _, _, _, ct, _ := rs.snapshot() + if !strings.Contains(ct, "application/json") { + t.Fatalf("content-type: want application/json, got %q", ct) + } +} + +func TestSendNoBasicAuthHeader(t *testing.T) { + // telegram uses the bot token in the URL path, NOT a Basic auth header. + // a Basic header would be a token-leak surface: proxies log headers, URLs + // less so. + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + _ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) + _, _, _, _, auth := rs.snapshot() + if auth != "" { + t.Fatalf("want no Authorization header (token in URL), got %q", auth) + } +} + +// ----------------------------- error handling ------------------------------- + +func TestSendReturnsErrorOnTelegramError(t *testing.T) { + // telegram returns non-2xx with JSON {"ok":false,"error_code":401,...} on + // auth failure. the sink must surface error_code + description. + rs := newRecordingServer(t, http.StatusUnauthorized, `{"ok":false,"error_code":401,"description":"Unauthorized"}`) + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) + if err == nil { + t.Fatal("want error on telegram 401") + } + if !strings.Contains(err.Error(), "401") { + t.Fatalf("error should mention error_code 401, got: %v", err) + } +} + +func TestSendReturnsErrorOnNon2xx(t *testing.T) { + rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) + if err == nil { + t.Fatal("want error on 502") + } +} + +func TestSendContextCancelReturnsError(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + err := sink.Send(ctx, nudgeSendable(loop.Sev4, "down")) + if err == nil { + t.Fatal("want error on canceled context") + } +} + +func TestSendConnectionRefusedReturnsError(t *testing.T) { + cfg := sinkCfg("http://127.0.0.1:1") + cfg.Timeout = time.Second + sink, _ := New(cfg) + err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) + if err == nil { + t.Fatal("want error on connection refused") + } +} + +// ----------------------------- proxy seam ----------------------------------- + +func TestProxyWiredIntoTransport(t *testing.T) { + // region restriction: homesrv can't reach api.telegram.org directly. + // the proxy URL configured must land on the http.Transport.Proxy so the + // stdlib dials the relay first. this is the only thing the sink needs to + // do for the region cut — Transport.Proxy handles HTTP/HTTPS/SOCKS5. + sink, err := New(Config{ + BotToken: "123:abc", + ChatID: "42", + Proxy: "socks5://127.0.0.1:1080", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + tr, ok := sink.hc.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport: want *http.Transport, got %T", sink.hc.Transport) + } + if tr.Proxy == nil { + t.Fatal("transport.Proxy not set") + } + pu, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "api.telegram.org"}}) + if err != nil { + t.Fatalf("proxy fn: %v", err) + } + if pu == nil || pu.Host != "127.0.0.1:1080" || pu.Scheme != "socks5" { + t.Fatalf("proxy url: want socks5://127.0.0.1:1080, got %v", pu) + } +} + +func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) { + // end-to-end: a fake proxy (httptest) records that the transport routes + // through it. the fake telegram API is rigged to t.Fatalf if reached + // directly — proving the proxy actually carried the request, not just that + // Transport.Proxy is set (covered by the previous test). + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer proxySrv.Close() + + telegramSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("direct telegram API reached — proxy was bypassed") + })) + defer telegramSrv.Close() + + cfg := sinkCfg(telegramSrv.URL) + cfg.Proxy = proxySrv.URL + sink, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil { + t.Fatalf("Send: %v", err) + } + // reaching here means the proxy carried the request; the direct telegram + // endpoint's handler never fired. +} + +// ----------------------------- reminder same shape -------------------------- + +func TestReminderSendUsesSamePath(t *testing.T) { + // reminders away route to ntfy, not telegram — but if the daemon ever + // routes a reminder via telegram (per-reminder override), the sink must + // accept KindReminder undamaged. exercises the kind-agnostic contract. + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(sinkCfg(srv.URL)) + if err := sink.Send(context.Background(), reminderSendable("wake up")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _ := rs.snapshot() + var req sendMessageReq + _ = json.Unmarshal([]byte(body), &req) + if req.Text != "wake up" { + t.Fatalf("reminder text: want 'wake up', got %q", req.Text) + } +} \ No newline at end of file diff --git a/internal/delivery/voicesink/voicesink.go b/internal/delivery/voicesink/voicesink.go new file mode 100644 index 0000000..65fdbb0 --- /dev/null +++ b/internal/delivery/voicesink/voicesink.go @@ -0,0 +1,109 @@ +// Package voicesink implements delivery.Sink for the voice channel. +// +// It is the LAST mile of proactive voice delivery: the dispatcher calls +// voicesink.Send for a PhrasedNudge whose routing landed on ChannelVoice. +// The sink: +// +// 1. renders the Sendable.Body (the full phrased message) to PCM via the +// tts.Synthesizer seam. The phraser already produced the text; the +// voice sink synthesises AUDIO for it. Body, not Summary — voice is the +// local channel; no shoulder-surf exfil concern (no relay); the user +// hears the full message. +// +// 2. finds the most-recently-active voice client session via the +// voice.Sessions registry and pushes the audio as a voice.AudioNudgePush +// on that session's conn. The user's most-recently-active client plays +// it; other clients stay silent (no dogpile — the spec's "play it on +// most-recently-active, reroute to ntfy/telegram if none reachable"). +// +// 3. if no live session exists, Send returns voice.ErrNoSession +// (wrapped). The daemon logs the partial dispatch; an OPEN deferred +// question is whether the dispatcher should reroute to away-channels +// instead of returning partial — listed in PROGRESS.md. +// +// Import direction: voicesink imports internal/tts (synth seam) and +// internal/voice (Sessions registry). Both are siblings of delivery; the +// dispatcher holds `delivery.Sink` and doesn't import voicesink, so the +// import cycle is broken. voicesink is wired at the daemon. +package voicesink + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/tts" + "github.com/kami/maven/internal/voice" +) + +// Sink — implements delivery.Sink for the voice channel via TTS synthesis + +// per-session push. +type Sink struct { + tts tts.Synthesizer + sess *voice.Sessions +} + +// New builds a Sink wired to a Synthesizer and the Sessions registry shared +// with the voice.Server. The daemon constructs one when its config enables +// voice; otherwise the dispatcher's voice slot stays nil and the routing +// table's ChannelVoice selections drop silently (today's behaviour). +func New(synth tts.Synthesizer, sess *voice.Sessions) *Sink { + return &Sink{tts: synth, sess: sess} +} + +// Send — delivery.Sink. Synthesises Body text, picks the most-recently- +// active session, pushes the audio. Errors ⇒ the dispatcher surfaces +// partial dispatch; a deferred question is rerouting to away channels when +// no live voice session exists (today the dispatcher errors and stops; +// tomorrow: reroute on voice.ErrNoSession within the dispatcher or here). +func (s *Sink) Send(ctx context.Context, send delivery.Sendable) error { + if s.tts == nil { + return fmt.Errorf("voicesink: tts synthesizer not wired") + } + if s.sess == nil { + return fmt.Errorf("voicesink: sessions registry not wired") + } + text := send.Body + if text == "" { + // voice gets Body; if the phraser didn't produce one, fall back to + // Summary (terse full beats silence — the routing table insisted + // on voice for this severity, so a silent no-op would hide a bug + // in the phraser behind routing). + text = send.Summary + } + out, err := s.tts.Synthesize(ctx, text) + if err != nil { + return fmt.Errorf("voicesink: synthesize: %w", err) + } + if !out.Format.IsValid() { + // A synthesizer returning an off-spec format is a model bug; refuse + // to ship bytes the client can't play rather than misrouting to a + // decoder that doesn't exist. + return fmt.Errorf("voicesink: tts returned %v, want PCM16kMono", out.Format) + } + push := voice.AudioNudgePush{ + RuleName: send.RuleName, + Severity: int(send.Severity), + Audio: out, + Text: text, + Ts: time.Now(), + } + if err := s.sess.PushToMostRecent(ctx, push); err != nil { + if errors.Is(err, voice.ErrNoSession) { + log.Printf("voicesink: no live voice session for %s — rerouting deferred, dropping", send.RuleName) + return err + } + return fmt.Errorf("voicesink: push: %w", err) + } + return nil +} + +// keep audio import honest (used in Send's audio.PCM check indirect via +// Format.IsValid which is a method on the imported audio.Format). The alias +// below keeps the import alive even if a future refactor moves the only +// reference. Today, the synthesizer's audio.Audio directly flows through. +var _ = audio.PCM16kMono \ No newline at end of file diff --git a/internal/ipc/api.go b/internal/ipc/api.go new file mode 100644 index 0000000..a45ed0f --- /dev/null +++ b/internal/ipc/api.go @@ -0,0 +1,273 @@ +package ipc + +import ( + "context" + "errors" + "time" +) + +// DTOs — wire-level data. Decoupled from internal/store so the protocol is +// self-describing and a module never needs to import store internals (the +// boundary is the point). The store adapter maps store.* ⇔ these 1:1. + +// Fact — one observation. Ts is valid-time (true-as-of), as in store. +type Fact struct { + ID int64 `json:"id"` + Ts time.Time `json:"ts"` + Kind string `json:"kind"` // "self" | "env" | "config" + Key string `json:"key"` + Value string `json:"value"` // raw json if structured + Source string `json:"source"` // tap:*|infer:*|poll:*|ambient|promote|feedback + Confidence float64 `json:"confidence"` + VoidsID *int64 `json:"voids_id,omitempty"` +} + +// Bucket — presence hysteresis state: "present" | "away". +type Bucket string + +const ( + Present Bucket = "present" + Away Bucket = "away" +) + +// Nudge — one proactive send + its outcome, for the monitoring read path. +type Nudge struct { + ID int64 `json:"id"` + Ts time.Time `json:"ts"` + Rule string `json:"rule"` + Channel string `json:"channel"` + Message string `json:"message"` + Outcome string `json:"outcome"` // pending|acted|snoozed|ignored + OutcomeTs *int64 `json:"outcome_ts,omitempty"` +} + +// Note — a recall/preference item; ranked by embedding cosine on query. +// Score is set by QueryNotes (0 on the write path). +type Note struct { + ID int64 `json:"id"` + Ts time.Time `json:"ts"` + Text string `json:"text"` + Source string `json:"source"` + Score float64 `json:"score"` +} + +// Reminder — user-stated future intent; fires once. +type Reminder struct { + ID int64 `json:"id"` + CreatedTs time.Time `json:"created_ts"` + FireTs time.Time `json:"fire_ts"` + Payload string `json:"payload"` + Status string `json:"status"` // pending|fired|cancelled +} + +// Presence — the read the phraser / delivery modules need to decide channel +// routing and tone. presence = reachability, NOT wakefulness (spec). Loop +// reads probes + computes score itself; modules get the resolved snapshot. +type Presence struct { + Bucket Bucket `json:"bucket"` + Score float64 `json:"score"` + Updated time.Time `json:"updated"` +} + +// WriteFactReq — the only state mutation a capture/tool module performs. +// Confidence is 1.0 for taps, (0,1) for inferences; the store enforces range. +// Source is provenance — the server-side source-scope seam (auth layer) will +// refuse a module writing under a source it doesn't own ("compromised poller +// can't forge a trigger"). Today the floor permits any local caller. +type WriteFactReq struct { + Ts time.Time `json:"ts"` + Kind string `json:"kind"` + Key string `json:"key"` + Value string `json:"value"` + Source string `json:"source"` + Confidence float64 `json:"confidence"` + VoidsID *int64 `json:"voids_id,omitempty"` +} + +// idReq — methods keyed by a single id. +type idReq struct { + ID int64 `json:"id"` +} + +// markReminderReq — pending→fired|cancelled. +type markReminderReq struct { + ID int64 `json:"id"` + Status string `json:"status"` +} + +// resolveNudgeReq — pending→acted|snoozed|ignored, once. +type resolveNudgeReq struct { + ID int64 `json:"id"` + Outcome string `json:"outcome"` + Ts time.Time `json:"ts"` +} + +// keyReq / keySourceReq / sinceReq / outcomesReq — read param shapes. +type keyReq struct { + Key string `json:"key"` +} +type keySourceReq struct { + Key string `json:"key"` + Source string `json:"source"` +} +type sinceReq struct { + Key string `json:"key"` + Now time.Time `json:"now"` +} +type outcomesReq struct { + Rule string `json:"rule"` + N int `json:"n"` +} +type nReq struct { + N int `json:"n"` +} +type writeNoteReq struct { + Ts time.Time `json:"ts"` + Text string `json:"text"` + Embedding []float32 `json:"embedding"` + Source string `json:"source"` +} +type queryNotesReq struct { + Embedding []float32 `json:"embedding"` + K int `json:"k"` +} +type createReminderReq struct { + Fire time.Time `json:"fire"` + Payload string `json:"payload"` +} +type recordNudgeReq struct { + Rule string `json:"rule"` + Channel string `json:"channel"` + Message string `json:"message"` + Ts time.Time `json:"ts"` +} + +// idResp / sinceResp — small scalar return wrappers. +type idResp struct { + ID int64 `json:"id"` +} +type sinceResp struct { + Dur time.Duration `json:"dur"` +} + +// Tool — an act allowlist entry as core exposes it. Status 'proposed' is an +// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'. +type Tool struct { + Name string `json:"name"` + Cmd []string `json:"cmd"` + Destructive bool `json:"destructive"` + Status string `json:"status"` + Utterance string `json:"utterance"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +type proposeToolReq struct { + Name string `json:"name"` + Utterance string `json:"utterance"` + Ts time.Time `json:"ts"` +} +type proposeToolResp struct { + Proposed bool `json:"proposed"` +} +type enableToolReq struct { + Name string `json:"name"` + Cmd []string `json:"cmd"` + Destructive bool `json:"destructive"` + Ts time.Time `json:"ts"` +} +type lookupToolReq struct { + Name string `json:"name"` +} +type listToolsReq struct { + Status string `json:"status"` +} +type listToolsResp struct { + Tools []Tool `json:"tools"` +} + +// CoreAPI — what core exposes to modules. One Go interface, satisfied by: +// - the in-process store adapter (server.go storeAPI) — used by the daemon +// for modules that live in-process for now (router, delivery) and by tests, +// - the socket-backed server's dispatcher (which delegates to a CoreAPI), +// - the client proxy (client.go) — same interface, over the wire. +// +// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's +// been wired in-process (tests / daemon-embedded) or socketed (full topology). +// That swappability is the seam the auth layer will insert into without +// touching the module code. +type CoreAPI interface { + WriteFact(ctx context.Context, req WriteFactReq) (int64, error) + LatestFact(ctx context.Context, key string) (Fact, error) + LatestFactBySource(ctx context.Context, key, source string) (Fact, error) + Since(ctx context.Context, key string, now time.Time) (time.Duration, error) + Presence(ctx context.Context) (Presence, error) + CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) + MarkReminder(ctx context.Context, id int64, status string) error + RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) + ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error + RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) + RecentFacts(ctx context.Context, n int) ([]Fact, error) + RecentNudges(ctx context.Context, n int) ([]Nudge, error) + WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) + QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) + RecentNotes(ctx context.Context, n int) ([]Note, error) + + // ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable); + // returns whether a new proposal was written. EnableTool fills cmd + + // destructive and flips to 'enabled' — the human-only "enable" act, gated + // at AuthStepUp (see auth/policy.go). LookupTool/ListTools read them. + ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) + EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error + LookupTool(ctx context.Context, name string) (Tool, error) + ListTools(ctx context.Context, status string) ([]Tool, error) +} + +// ErrToolNotFound — no tool row with this name (re-exported store sentinel for +// wire round-tripping via errors.Is). +var ErrToolNotFound = errors.New("ipc: tool not found") + +// callerKey — context key for the authenticated caller. Server sets it from +// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats +// a missing Caller as "trusted same-process", the equivalent of the socket's +// 0600 floor). +type callerKey struct{} + +// Caller — the peer identity as core sees it. Uid/Pid come from SO_PEERCRED +// on Linux; the future auth layer maps Uid + module enrollment → authority. +// Today only Uid is populated and used for a same-user check. +type Caller struct { + Uid int32 + Pid int32 +} + +// WithCaller returns ctx annotated with c. Server-side use only. +func WithCaller(ctx context.Context, c Caller) context.Context { + return context.WithValue(ctx, callerKey{}, c) +} + +// CallerFrom retrieves the Caller, or ok=false if absent (in-process path). +func CallerFrom(ctx context.Context) (Caller, bool) { + c, ok := ctx.Value(callerKey{}).(Caller) + return c, ok +} + +// Sentinel errors. Mirror store's 1:1 so module code reads the same whether +// in-process or over the wire. The store adapter translates store.* → these. +var ( + ErrNoFact = errors.New("ipc: no fact for key") + ErrConfidence = errors.New("ipc: confidence must be in (0.0, 1.0]") + ErrVoidsMissing = errors.New("ipc: voids_id does not reference an existing fact") + ErrNudgeNotFound = errors.New("ipc: nudge not found") + ErrNudgeOutcome = errors.New("ipc: nudge already resolved") + ErrReminderNotFound = errors.New("ipc: reminder not found") + ErrReminderState = errors.New("ipc: reminder not in a mutable state") + ErrUnknownMethod = errors.New("ipc: unknown method") + ErrBadParams = errors.New("ipc: bad params") + // ErrForbidden — the caller's authority doesn't cover this call. The + // auth layer's only wire-exported verdict: surface caps the layer, or a + // write was out-of-scope, or step-up was required but not asserted. The + // text ErrForbidden carries is derived during dispatch (from auth.ErrForbidden + // via fmt.Errorf %w wrapping); the wire carries codeForbidden. + ErrForbidden = errors.New("ipc: forbidden") +) \ No newline at end of file diff --git a/internal/ipc/client.go b/internal/ipc/client.go new file mode 100644 index 0000000..1a54191 --- /dev/null +++ b/internal/ipc/client.go @@ -0,0 +1,262 @@ +package ipc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "sync" + "time" +) + +// Client — the module side of the boundary. Wraps a unix-socket connection +// and satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is +// agnostic to whether it's been wired in-process (tests / daemon-embedded) +// or over this socket (full topology). The swappability is the seam auth +// will insert into without touching module code. +// +// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that +// wants parallel requests opens one Client per goroutine; the store is the +// bottleneck anyway (single writer), so pipelining buys nothing here and a +// per-Client lock keeps frame interleaving impossible by construction. +type Client struct { + conn net.Conn + mu sync.Mutex +} + +// Dial connects to a core socket at path and returns a Client. The module +// owns its Client lifecycle; Close on shutdown. +func Dial(path string) (*Client, error) { + c, err := net.Dial("unix", path) + if err != nil { + return nil, fmt.Errorf("ipc: dial %s: %w", path, err) + } + return &Client{conn: c}, nil +} + +func (c *Client) Close() error { return c.conn.Close() } + +// call — the single request/response engine. Serialized by c.mu so a frame +// and its reply always pair up; no interleaving to disambiguate. A wire +// RpcError is rehydrated into the matching package sentinel (errors.Is works +// the same as the in-process path — the boundary is transparent to callers). +func (c *Client) call(ctx context.Context, m Method, params, result any) error { + c.mu.Lock() + defer c.mu.Unlock() + + // Honor ctx cancellation by closing the conn — a half-sent frame would + // desync the stream; tearing down is the clean recovery. A fresh Dial + // is the module's responsibility on the next call (modules are long-lived + // processes; a dropped conn is recoverable, not fatal). + select { + case <-ctx.Done(): + _ = c.conn.Close() + return ctx.Err() + default: + } + + var raw json.RawMessage + if params != nil { + b, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("ipc: marshal params: %w", err) + } + raw = b + } + if err := writeFrame(c.conn, Request{Method: m, Params: raw}); err != nil { + return err + } + var resp Response + if err := readFrame(c.conn, &resp); err != nil { + return err + } + if resp.Error != nil { + return hydrate(resp.Error) + } + if result == nil { + return nil + } + // "null" body into a pointer is valid (sets the zero value); marshal a + // RawMessage directly to avoid extra encode/decode churn. + return json.Unmarshal(resp.Result, result) +} + +// hydrate rehydrates a wire RpcError into the matching package sentinel. The +// code↔sentinel table is the only place the wire "knows" about errors; keep it +// in sync with codeOf in wire.go. +func hydrate(e *RpcError) error { + switch e.Code { + case codeNoFact: + return fmt.Errorf("%w: %s", ErrNoFact, e.Message) + case codeConfidence: + return fmt.Errorf("%w: %s", ErrConfidence, e.Message) + case codeVoidsMissing: + return fmt.Errorf("%w: %s", ErrVoidsMissing, e.Message) + case codeNudgeNotFound: + return fmt.Errorf("%w: %s", ErrNudgeNotFound, e.Message) + case codeNudgeOutcome: + return fmt.Errorf("%w: %s", ErrNudgeOutcome, e.Message) + case codeReminderMissing: + return fmt.Errorf("%w: %s", ErrReminderNotFound, e.Message) + case codeReminderState: + return fmt.Errorf("%w: %s", ErrReminderState, e.Message) + case codeToolNotFound: + return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message) + case codeUnknownMethod: + return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message) + case codeBadParams: + return fmt.Errorf("%w: %s", ErrBadParams, e.Message) + case codeForbidden: + return fmt.Errorf("%w: %s", ErrForbidden, e.Message) + default: + return errors.New(e.Error()) + } +} + +// CoreAPI implementation on *Client. Each method is a thin call() shim; the +// shape mirrors the CoreAPI interface 1:1 so the embedded-doc intent (module +// holds a CoreAPI, transport-agnostic) reads straight off the signatures. + +func (c *Client) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) { + var r idResp + if err := c.call(ctx, MethodWriteFact, req, &r); err != nil { + return 0, err + } + return r.ID, nil +} + +func (c *Client) LatestFact(ctx context.Context, key string) (Fact, error) { + var f Fact + if err := c.call(ctx, MethodLatestFact, keyReq{Key: key}, &f); err != nil { + return Fact{}, err + } + return f, nil +} + +func (c *Client) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) { + var f Fact + if err := c.call(ctx, MethodLatestFactBySource, keySourceReq{Key: key, Source: source}, &f); err != nil { + return Fact{}, err + } + return f, nil +} + +func (c *Client) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { + var r sinceResp + if err := c.call(ctx, MethodSince, sinceReq{Key: key, Now: now}, &r); err != nil { + return 0, err + } + return r.Dur, nil +} + +func (c *Client) Presence(ctx context.Context) (Presence, error) { + var p Presence + if err := c.call(ctx, MethodPresence, nil, &p); err != nil { + return Presence{}, err + } + return p, nil +} + +func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { + var r idResp + if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload}, &r); err != nil { + return 0, err + } + return r.ID, nil +} + +func (c *Client) MarkReminder(ctx context.Context, id int64, status string) error { + return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil) +} + +func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { + var r idResp + if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil { + return 0, err + } + return r.ID, nil +} + +func (c *Client) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { + return c.call(ctx, MethodResolveNudge, resolveNudgeReq{ID: id, Outcome: outcome, Ts: ts}, nil) +} + +func (c *Client) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { + var out []string + if err := c.call(ctx, MethodRecentOutcomes, outcomesReq{Rule: rule, N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) { + var out []Fact + if err := c.call(ctx, MethodRecentFacts, nReq{N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { + var out []Nudge + if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { + var r idResp + if err := c.call(ctx, MethodWriteNote, writeNoteReq{Ts: ts, Text: text, Embedding: embedding, Source: source}, &r); err != nil { + return 0, err + } + return r.ID, nil +} + +func (c *Client) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) { + var out []Note + if err := c.call(ctx, MethodQueryNotes, queryNotesReq{Embedding: embedding, K: k}, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) { + var out []Note + if err := c.call(ctx, MethodRecentNotes, nReq{N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) { + var r proposeToolResp + if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Utterance: utterance, Ts: ts}, &r); err != nil { + return false, err + } + return r.Proposed, nil +} + +func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error { + return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Cmd: cmd, Destructive: destructive, Ts: ts}, nil) +} + +func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) { + var t Tool + if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil { + return Tool{}, err + } + return t, nil +} + +func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) { + var r listToolsResp + if err := c.call(ctx, MethodListTools, listToolsReq{Status: status}, &r); err != nil { + return nil, err + } + return r.Tools, nil +} + +// Compile-time check: *Client satisfies CoreAPI. +var _ CoreAPI = (*Client)(nil) \ No newline at end of file diff --git a/internal/ipc/frame.go b/internal/ipc/frame.go new file mode 100644 index 0000000..c24ef6b --- /dev/null +++ b/internal/ipc/frame.go @@ -0,0 +1,86 @@ +// Package ipc is maven's core↔module boundary. +// +// Core = the only key-holder: the daemon process holds the unlocked sqlcipher +// db + the trigger loop. Modules (stt/tts, router/classifier, tool executors, +// delivery) are separate processes — restart-free, key-free, fail-independent. +// "a crashing tts can't read the key page" only holds if there IS a page +// boundary between core and modules; this package IS that boundary. +// +// Transport: unix domain socket, local-only. The socket's filesystem perms +// (0700 dir, 0600 socket) are the current auth floor — "same unix user" — +// carrying the same instinct as wg-floor at the network radius. The full +// 4-layer auth cascade (wg/mTLS/passkey/step-up) is a later module; IPC +// threads a Caller (uid/pid via SO_PEERCRED) so the auth layer can scope +// module authority without restructuring the wire. +// +// Core mediates, never hands back a db handle. The methods here are the only +// state operations a module can perform: write a fact (provenance-scoped by +// source), read a fact / presence, create/complete reminders, record/resolve +// nudges. Anything needing raw db access lives in core and is unreachable. +package ipc + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" +) + +// maxFrame — 4 MiB. a single fact/reminder/nudge is tiny; this is a safety cap +// against a confused peer sending a terabyte of length prefix, not a real +// operational limit. away-channel bodies are minimal by spec. +const maxFrame = 4 << 20 + +// ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now desynchronized +// (we read the length but not the body), so the caller must close it. +var ErrFrameTooLarge = errors.New("ipc: frame too large") + +// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length +// prefix + body. length-prefixed JSON (not a tighter binary schema) is the +// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to +// evolve while the protocol settles, and at single-user local scale the +// encode cost is invisible next to a db round-trip. +func writeFrame(w io.Writer, v any) error { + body, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ipc: marshal frame: %w", err) + } + if len(body) > maxFrame { + return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body)) + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) + if _, err := w.Write(hdr[:]); err != nil { + return fmt.Errorf("ipc: write frame header: %w", err) + } + if _, err := w.Write(body); err != nil { + return fmt.Errorf("ipc: write frame body: %w", err) + } + return nil +} + +// readFrame reads one length-prefixed frame into v. A zero-length frame is +// legal JSON (e.g. `null`/`{}` encode to a few bytes, never zero) — we don't +// treat it as EOF; only an EOF on the header read does. +func readFrame(r io.Reader, v any) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + if errors.Is(err, io.EOF) { + return io.EOF + } + return fmt.Errorf("ipc: read frame header: %w", err) + } + n := binary.BigEndian.Uint32(hdr[:]) + if n > maxFrame { + return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n) + } + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return fmt.Errorf("ipc: read frame body: %w", err) + } + if err := json.Unmarshal(buf, v); err != nil { + return fmt.Errorf("ipc: unmarshal frame: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go new file mode 100644 index 0000000..6f45ec9 --- /dev/null +++ b/internal/ipc/ipc_test.go @@ -0,0 +1,330 @@ +package ipc + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// tmpSocket — a socket path under a 0700 temp dir, unique per test. +func tmpSocket(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return filepath.Join(dir, "maven.sock") +} + +// newServerWithStore spins a real store + Server + Client so the boundary +// is exercised exactly as the daemon wires it. Returns the api (for direct +// in-process expectations) and a client going through the socket. +func newServerWithStore(t *testing.T) (CoreAPI, *Server, *Client, *store.Store) { + t.Helper() + dir := t.TempDir() + s, err := store.Open(context.Background(), filepath.Join(dir, "maven.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + api := NewStoreAPI(s) + srv, err := Listen(tmpSocket(t), api) + if err != nil { + t.Fatalf("listen: %v", err) + } + done := make(chan struct{}) + go func() { + _ = srv.Serve() + close(done) + }() + t.Cleanup(func() { + _ = srv.Close() + <-done + }) + cli, err := Dial(srv.Path()) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = cli.Close() }) + return api, srv, cli, s +} + +// TestFrame_Roundtrip — JSON over a length prefix survives the loop, and the +// prefix itself encodes the length exactly. The framing is the only thing +// keeping a module's request paired with core's reply; it's worth a direct test. +func TestFrame_Roundtrip(t *testing.T) { + var buf bytes.Buffer + type payload struct { + Msg string `json:"m"` + N int `json:"n"` + } + want := payload{Msg: "hello", N: 42} + if err := writeFrame(&buf, want); err != nil { + t.Fatalf("writeFrame: %v", err) + } + // header length must equal the JSON body length that follows. + var hdr [4]byte + if _, err := io.ReadFull(&buf, hdr[:]); err != nil { + t.Fatalf("read hdr: %v", err) + } + bodyLen := binary.BigEndian.Uint32(hdr[:]) + if int(bodyLen) != buf.Len() { + t.Fatalf("prefix length %d != body %d", bodyLen, buf.Len()) + } + // readFrame consumes header+body together; recombine so it sees a whole frame. + full := append(hdr[:], buf.Bytes()...) + var got payload + if err := readFrame(bytes.NewReader(full), &got); err != nil { + t.Fatalf("readFrame: %v", err) + } + if got != want { + t.Fatalf("roundtrip mismatch: got %+v want %+v", got, want) + } +} + +func TestFrame_TooLarge(t *testing.T) { + // Encode-side guard refuses to ship anything bigger than maxFrame; the + // socket never sees it. Defense against a confused peer, not a real path. + big := make([]byte, maxFrame+1) + if err := writeFrame(io.Discard, big); !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("writeFrame: got %v, want ErrFrameTooLarge", err) + } + // Decode-side guard refuses a header claiming a too-large body; the conn + // is now desynced (length read, body not), but readFrame doesn't have to + // recover — the caller closes it. + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], maxFrame+1) + if err := readFrame(bytes.NewReader(hdr[:]), nil); !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("readFrame: got %v, want ErrFrameTooLarge", err) + } +} + +// TestSocket_Perms — the auth floor. 0600 ⇒ only the same unix user can +// connect. If this regresses to world-readable, every user on the box is a +// module; that's the entire auth model today, so assert it. +func TestSocket_Perms(t *testing.T) { + _, srv, _, _ := newServerWithStore(t) + fi, err := os.Stat(srv.Path()) + if err != nil { + t.Fatalf("stat socket: %v", err) + } + mode := fi.Mode().Perm() + if mode != 0o600 { + t.Fatalf("socket perm = %#o, want 0600", mode) + } +} + +// TestStoreAPI_Direct — the in-process adapter path (no socket) maps store +// sentinels to ipc sentinels. The boundary's contract is that error identity +// is the same on both sides; this pins it for the daemon-embedded modules +// (router, delivery today) that never go over the wire. +func TestStoreAPI_Direct(t *testing.T) { + api, _, _, _ := newServerWithStore(t) + ctx := context.Background() + + // missing key ⇒ ErrNoFact + if _, err := api.LatestFact(ctx, "nope"); !errors.Is(err, ErrNoFact) { + t.Fatalf("LatestFact missing: got %v, want ErrNoFact", err) + } + // bad confidence ⇒ ErrConfidence + if _, err := api.WriteFact(ctx, WriteFactReq{ + Ts: time.Now(), Kind: "self", Key: "water", Value: "1", Source: "tap:water", Confidence: 0, + }); !errors.Is(err, ErrConfidence) { + t.Fatalf("WriteFact conf=0: got %v, want ErrConfidence", err) + } + // since on missing key ⇒ ErrNoFact + if _, err := api.Since(ctx, "nope", time.Now()); !errors.Is(err, ErrNoFact) { + t.Fatalf("Since missing: got %v, want ErrNoFact", err) + } + // reminder idempotency: invalid status ⇒ ErrReminderState + if err := api.MarkReminder(ctx, 99999, "weird"); !errors.Is(err, ErrReminderState) { + t.Fatalf("MarkReminder weird: got %v, want ErrReminderState", err) + } + // resolve nonexistent nudge ⇒ ErrNudgeNotFound + if err := api.ResolveNudge(ctx, 99999, "acted", time.Now()); !errors.Is(err, ErrNudgeNotFound) { + t.Fatalf("ResolveNudge none: got %v, want ErrNudgeNotFound", err) + } +} + +// TestClient_E2E — full socket round trip against a real store. Drives every +// method end-to-end and asserts sentinel identity survives the wire. This is +// the test that catches the boundary bugs: param shape mismatch, sentinel +// code drift, dto mapping, framing interleaving. +func TestClient_E2E(t *testing.T) { + _, _, cli, _ := newServerWithStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Millisecond) + + // write a tap (self, confidence 1.0) and read it back. + id, err := cli.WriteFact(ctx, WriteFactReq{ + Ts: now, Kind: "self", Key: "water", Value: "1", Source: "tap:water", Confidence: 1.0, + }) + if err != nil { + t.Fatalf("WriteFact: %v", err) + } + if id <= 0 { + t.Fatalf("WriteFact returned id %d", id) + } + f, err := cli.LatestFact(ctx, "water") + if err != nil { + t.Fatalf("LatestFact: %v", err) + } + if f.Key != "water" || f.Value != "1" || f.Source != "tap:water" || f.Confidence != 1.0 { + t.Fatalf("LatestFact mismatch: %+v", f) + } + if !f.Ts.Equal(now) { + t.Fatalf("Ts roundtrip: got %v want %v", f.Ts, now) + } + + // provenance scope: a foreign source doesn't see the tap value. + if _, err := cli.LatestFactBySource(ctx, "water", "poll:evil"); !errors.Is(err, ErrNoFact) { + t.Fatalf("LatestFactBySource foreign: got %v, want ErrNoFact", err) + } + if _, err := cli.LatestFactBySource(ctx, "water", "tap:water"); err != nil { + t.Fatalf("LatestFactBySource own: %v", err) + } + + // since: ~0 elapsed since "now". + d, err := cli.Since(ctx, "water", now.Add(time.Second)) + if err != nil { + t.Fatalf("Since: %v", err) + } + if d != time.Second { + t.Fatalf("Since dur = %v, want 1s", d) + } + // since missing ⇒ ErrNoFact over the wire. + if _, err := cli.Since(ctx, "nope", now); !errors.Is(err, ErrNoFact) { + t.Fatalf("Since missing: got %v, want ErrNoFact", err) + } + + // presence cold-start ⇒ away, score 0. + pres, err := cli.Presence(ctx) + if err != nil { + t.Fatalf("Presence: %v", err) + } + if pres.Bucket != Away || pres.Score != 0 { + t.Fatalf("Presence cold-start = %+v, want away/0", pres) + } + + // reminder lifecycle: create → mark fired → re-mark ⇒ ErrReminderState. + rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`) + if err != nil { + t.Fatalf("CreateReminder: %v", err) + } + if err := cli.MarkReminder(ctx, rid, "fired"); err != nil { + t.Fatalf("MarkReminder fired: %v", err) + } + if err := cli.MarkReminder(ctx, rid, "fired"); !errors.Is(err, ErrReminderState) { + t.Fatalf("MarkReminder twice: got %v, want ErrReminderState", err) + } + + // nudge lifecycle: record → resolve acted → resolve again ⇒ ErrNudgeOutcome. + nid, err := cli.RecordNudge(ctx, "water", "voice", "drink", now) + if err != nil { + t.Fatalf("RecordNudge: %v", err) + } + if err := cli.ResolveNudge(ctx, nid, "acted", now); err != nil { + t.Fatalf("ResolveNudge acted: %v", err) + } + if err := cli.ResolveNudge(ctx, nid, "ignored", now); !errors.Is(err, ErrNudgeOutcome) { + t.Fatalf("ResolveNudge twice: got %v, want ErrNudgeOutcome", err) + } + + // feedback loop read: RecentOutcomes returns the resolved outcome. + out, err := cli.RecentOutcomes(ctx, "water", 5) + if err != nil { + t.Fatalf("RecentOutcomes: %v", err) + } + if len(out) != 1 || out[0] != "acted" { + t.Fatalf("RecentOutcomes = %v, want [acted]", out) + } + // empty result over the wire is a stable [] not null (server coerces). + if got, err := cli.RecentOutcomes(ctx, "never_fired_rule", 5); err != nil || len(got) != 0 { + t.Fatalf("RecentOutcomes empty = %v err=%v, want []", got, err) + } +} + +// TestCaller_Peercred — when the client dials, core sees a Caller with the +// test process's own uid via SO_PEERCRED. This is the seam auth scopes on; +// asserting it's populated today means the future auth layer has its input. +func TestCaller_Peercred(t *testing.T) { + _, srv, cli, _ := newServerWithStore(t) + ctx := context.Background() + + // round-trip any call; the server annotates ctx with a Caller on accept. + if _, err := cli.LatestFact(ctx, "nope"); err != nil && !errors.Is(err, ErrNoFact) { + t.Fatalf("LatestFact: %v", err) + } + + // introspect the server's view: re-accept a conn manually and read creds. + uc, err := dialRaw(srv.Path()) + if err != nil { + t.Fatalf("dialRaw: %v", err) + } + defer uc.Close() + c, ok := peerCaller(uc) + if !ok { + t.Skip("SO_PEERCRED unavailable on this platform; skipping") + } + if c.Uid != int32(os.Getuid()) { + t.Fatalf("peercred uid = %d, want %d", c.Uid, os.Getuid()) + } +} + +// TestDispatch_UnknownMethod — an unknown method over the wire comes back as +// ErrUnknownMethod, not a panic or a dropped conn. The server must stay up +// for the next (legitimate) request on the same conn. +func TestDispatch_UnknownMethod(t *testing.T) { + _, srv, _, _ := newServerWithStore(t) + + uc, err := dialRaw(srv.Path()) + if err != nil { + t.Fatalf("dialRaw: %v", err) + } + defer uc.Close() + + // send garbage method on the raw conn, read back its error, then send a + // real method on the SAME conn to confirm the server survived. + if err := writeFrame(uc, Request{Method: Method("definitely_not_a_method")}); err != nil { + t.Fatalf("writeFrame: %v", err) + } + var resp Response + if err := readFrame(uc, &resp); err != nil { + t.Fatalf("readFrame: %v", err) + } + if resp.Error == nil || !errors.Is(hydrate(resp.Error), ErrUnknownMethod) { + t.Fatalf("unknown method response = %+v, want ErrUnknownMethod", resp.Error) + } + // same conn, legit follow-up: prove the goroutine is still alive. + if err := writeFrame(uc, Request{Method: MethodLatestFact, Params: mustJSON(keyReq{Key: "nope"})}); err != nil { + t.Fatalf("writeFrame follow-up: %v", err) + } + if err := readFrame(uc, &resp); err != nil { + t.Fatalf("readFrame follow-up: %v", err) + } + if resp.Error == nil || !errors.Is(hydrate(resp.Error), ErrNoFact) { + t.Fatalf("follow-up response = %+v, want ErrNoFact", resp.Error) + } +} + +// dialRaw — a bare unix conn for tests that want to script the wire directly +// (send an unknown method, follow up on the same conn, inspect framing). +func dialRaw(path string) (net.Conn, error) { + return net.Dial("unix", path) +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} \ No newline at end of file diff --git a/internal/ipc/server.go b/internal/ipc/server.go new file mode 100644 index 0000000..cbab303 --- /dev/null +++ b/internal/ipc/server.go @@ -0,0 +1,670 @@ +package ipc + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "sync" + "time" + + "github.com/kami/maven/internal/store" + "golang.org/x/sys/unix" +) + +// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of +// these inside the core process; the socket Server calls it through the +// CoreAPI interface, so over-the-wire and in-process callers behave +// identically. The translation here is the only place store sentinels cross +// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module +// view of errors stable regardless of transport. +type storeAPI struct { + s *store.Store +} + +// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher- +// unlocked handle held ONLY in core's address space; this adapter never +// returns it to a caller — core mediates. +func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} } + +func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) { + var voids sql.NullInt64 + if req.VoidsID != nil { + voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true} + } + id, err := a.s.WriteFact(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Value, req.Source, req.Confidence, voids) + return id, mapErr(err) +} + +func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) { + f, err := a.s.LatestFact(ctx, key) + if err != nil { + return Fact{}, mapErr(err) + } + return toFact(f), nil +} + +func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) { + f, err := a.s.LatestFactBySource(ctx, key, source) + if err != nil { + return Fact{}, mapErr(err) + } + return toFact(f), nil +} + +func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { + d, err := a.s.Since(ctx, key, now) + return d, mapErr(err) +} + +func (a *storeAPI) Presence(ctx context.Context) (Presence, error) { + b, score, upd, err := a.s.LoadPresenceState(ctx) + if err != nil { + return Presence{}, fmt.Errorf("ipc: load presence: %w", err) + } + return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil +} + +func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { + id, err := a.s.CreateReminder(ctx, fire, payload) + return id, mapErr(err) +} + +func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error { + return mapErr(a.s.MarkReminder(ctx, id, status)) +} + +func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { + id, err := a.s.RecordNudge(ctx, rule, channel, message, ts) + return id, mapErr(err) +} + +func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { + return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts)) +} + +func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { + out, err := a.s.RecentOutcomes(ctx, rule, n) + return out, mapErr(err) +} + +func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) { + fs, err := a.s.RecentFacts(ctx, n) + if err != nil { + return nil, mapErr(err) + } + out := make([]Fact, len(fs)) + for i, f := range fs { + out[i] = toFact(f) + } + return out, nil +} + +func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { + ns, err := a.s.RecentNudges(ctx, n) + if err != nil { + return nil, mapErr(err) + } + out := make([]Nudge, len(ns)) + for i, ng := range ns { + out[i] = toNudge(ng) + } + return out, nil +} + +func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { + id, err := a.s.WriteNote(ctx, ts, text, embedding, source) + return id, mapErr(err) +} + +func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) { + ns, err := a.s.QueryNotes(ctx, embedding, k) + if err != nil { + return nil, mapErr(err) + } + out := make([]Note, len(ns)) + for i, n := range ns { + out[i] = toNote(n) + } + return out, nil +} + +func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) { + ns, err := a.s.RecentNotes(ctx, n) + if err != nil { + return nil, mapErr(err) + } + out := make([]Note, len(ns)) + for i, note := range ns { + out[i] = toNote(note) + } + return out, nil +} + +func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) { + ok, err := a.s.ProposeTool(ctx, name, utterance, ts) + return ok, mapErr(err) +} + +func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error { + return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, ts)) +} + +func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) { + t, err := a.s.LookupTool(ctx, name) + if err != nil { + return Tool{}, mapErr(err) + } + return toTool(t), nil +} + +func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) { + ts, err := a.s.ListTools(ctx, status) + if err != nil { + return nil, mapErr(err) + } + out := make([]Tool, len(ts)) + for i, t := range ts { + out[i] = toTool(t) + } + return out, nil +} + +func toTool(t store.Tool) Tool { + return Tool{ + Name: t.Name, Cmd: t.Cmd, Destructive: t.Destructive, Status: t.Status, + Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs, + } +} + +func toNote(n store.Note) Note { + return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score} +} + +func toNudge(n store.Nudge) Nudge { + out := Nudge{ + ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel, + Message: n.Message, Outcome: n.Outcome, + } + if n.OutcomeTs.Valid { + v := n.OutcomeTs.Int64 + out.OutcomeTs = &v + } + return out +} + +func toFact(f store.Fact) Fact { + out := Fact{ + ID: f.ID, + Ts: f.Ts, + Kind: string(f.Kind), + Key: f.Key, + Value: f.Value, + Source: f.Source, + Confidence: f.Confidence, + } + if f.VoidsID.Valid { + v := f.VoidsID.Int64 + out.VoidsID = &v + } + return out +} + +// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is +// wrapped but not mapped (server-side dispatch surfaces it as codeInternal, +// keeping internal text off the wire except to the daemon log). +func mapErr(err error) error { + if err == nil { + return nil + } + switch { + case errors.Is(err, store.ErrNoFact): + return ErrNoFact + case errors.Is(err, store.ErrConfidence): + return ErrConfidence + case errors.Is(err, store.ErrVoidsMissing): + return ErrVoidsMissing + case errors.Is(err, store.ErrNudgeNotFound): + return ErrNudgeNotFound + case errors.Is(err, store.ErrNudgeOutcome): + return ErrNudgeOutcome + case errors.Is(err, store.ErrReminderNotFound): + return ErrReminderNotFound + case errors.Is(err, store.ErrReminderState): + return ErrReminderState + case errors.Is(err, store.ErrToolNotFound): + return ErrToolNotFound + } + return err +} + +// Server — the core side of the boundary. Listens on a unix domain socket, +// accepts module connections, frames requests to a CoreAPI and responses back. +// One Server per daemon process; concurrent connections are handled in their +// own goroutine but share the single CoreAPI (and therefore the single store +// writer — store is single-connection, SetMaxOpenConns(1), so serialization is +// already guaranteed at the db; the Server adds no locking of its own). +type Server struct { + api CoreAPI + path string + + ln net.Listener + wg sync.WaitGroup + done chan struct{} + + // Check — optional authorization hook. dispatch runs it BEFORE method + // dispatch, with the raw params, so the auth layer can make verdicts + // that depend on the call's shape (e.g. WriteFact's source). A non-nil + // error aborts the call; the wire code is codeForbidden when the error + // satisfies errors.Is(ErrForbidden), else codeInternal. + // + // Nil ⇒ today's auth floor: any same-uid caller (the 0600 socket perms) + // is authorized, identical to pre-auth behavior. The daemon sets this to + // auth.Gate.Check once the auth layer is constructed; there is no module + // change to gain or lose the seam. + Check CheckFunc + + // now is injected so tests can drive time; the loop already works in + // absolute ts supplied by callers, so this isn't load-bearing for live ops. +} + +// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check +// satisfies this); dispatch calls it once per request after param-unmarshal +// independence (it gets the raw params, may unmarshal what it needs — ipc +// already unmarshals for the typed call separately). Keeping Check on raw +// params means ipc doesn't need to know each method's authority shape, and +// auth doesn't need to leak implementation into ipc. +type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error + +// Listen creates a Server bound to path. path's parent dir must exist and be +// 0700 (we chmod it if we own it); the socket file itself is created 0600 so +// only the same unix user can connect — the current "auth floor", same radius +// as wg at the network boundary. Removing a stale socket at path first lets +// the daemon restart cleanly. +func Listen(path string, api CoreAPI) (*Server, error) { + _ = os.Remove(path) // stale socket from a crashed daemon; ignore missing + if err := os.MkdirAll(parentDir(path), 0o700); err != nil { + return nil, fmt.Errorf("ipc: mkdir socket dir: %w", err) + } + // umask could widen the perms on socket creation; tighten then chmod to + // be explicit. 0600 ⇒ read+write by owner only. + oldMask := unix.Umask(0o077) + ln, err := net.Listen("unix", path) + unix.Umask(oldMask) + if err != nil { + return nil, fmt.Errorf("ipc: listen %s: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + _ = ln.Close() + _ = os.Remove(path) + return nil, fmt.Errorf("ipc: chmod socket: %w", err) + } + return &Server{ + api: api, + path: path, + ln: ln, + done: make(chan struct{}), + }, nil +} + +// Serve accepts connections until the listener closes. Each connection is +// served in its own goroutine; a panicking handler or a malformed frame tears +// down only that conn, not the server (a misbehaving module can't kill core). +func (s *Server) Serve() error { + for { + c, err := s.ln.Accept() + if err != nil { + select { + case <-s.done: + return nil // graceful Close + default: + return fmt.Errorf("ipc: accept: %w", err) + } + } + s.wg.Add(1) + go func(c net.Conn) { + defer s.wg.Done() + defer c.Close() + s.serveConn(c) + }(c) + } +} + +func (s *Server) serveConn(c net.Conn) { + caller, callerOK := peerCaller(c) + ctx := context.Background() + if callerOK { + ctx = WithCaller(ctx, caller) + } + for { + var req Request + if err := readFrame(c, &req); err != nil { + return // EOF or malformed ⇒ end this conn; nothing to recover + } + // redispatch expects the framework's recover so one bad call can't + // take the goroutine (and therefore the conn) with it. + result, err := s.safeDispatch(ctx, req) + resp := Response{} + if err != nil { + resp.Error = rpcErr(err) + } else { + resp.Result = result + } + if err := writeFrame(c, resp); err != nil { + return + } + } +} + +func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.RawMessage, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("ipc: panic dispatching %s: %v", req.Method, r) + } + }() + return s.dispatch(ctx, req) +} + +// dispatch unmarshals params for req.Method and calls the matching CoreAPI +// method. Unknown method ⇒ ErrUnknownMethod; a malformed params payload ⇒ +// ErrBadParams with the underlying text (local, server-side, not shipped to +// the module except as a generic message via rpcErr). +// +// Authorization runs ONCE at the top: if Server.Check is set, we call it with +// the raw params before any method-specific unmarshal; auth unmarshals fields +// it cares about (WriteFact's source, etc.) itself. A nil Check is the floor +// and is invisible at the wire — pre-auth Server behavior is unchanged. +func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) { + if s.Check != nil { + if err := s.Check(ctx, req.Method, req.Params); err != nil { + return nil, err + } + } + switch req.Method { + case MethodWriteFact: + var p WriteFactReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + id, err := s.api.WriteFact(ctx, p) + return marshalResult(idResp{ID: id}), err + + case MethodLatestFact: + var p keyReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + f, err := s.api.LatestFact(ctx, p.Key) + if err != nil { + return nil, err + } + return marshalResult(f), nil + + case MethodLatestFactBySource: + var p keySourceReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + f, err := s.api.LatestFactBySource(ctx, p.Key, p.Source) + if err != nil { + return nil, err + } + return marshalResult(f), nil + + case MethodSince: + var p sinceReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + d, err := s.api.Since(ctx, p.Key, p.Now) + if err != nil { + return nil, err + } + return marshalResult(sinceResp{Dur: d}), nil + + case MethodPresence: + pres, err := s.api.Presence(ctx) + if err != nil { + return nil, err + } + return marshalResult(pres), nil + + case MethodCreateReminder: + var p createReminderReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload) + return marshalResult(idResp{ID: id}), err + + case MethodMarkReminder: + var p markReminderReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + err := s.api.MarkReminder(ctx, p.ID, p.Status) + return marshalResult(nil), err + + case MethodRecordNudge: + var p recordNudgeReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + id, err := s.api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts) + return marshalResult(idResp{ID: id}), err + + case MethodResolveNudge: + var p resolveNudgeReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + err := s.api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts) + return marshalResult(nil), err + + case MethodRecentOutcomes: + var p outcomesReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.RecentOutcomes(ctx, p.Rule, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []string{} // stable non-null on the wire + } + return marshalResult(out), nil + + case MethodRecentFacts: + var p nReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.RecentFacts(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Fact{} + } + return marshalResult(out), nil + + case MethodRecentNudges: + var p nReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.RecentNudges(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Nudge{} + } + return marshalResult(out), nil + + case MethodWriteNote: + var p writeNoteReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + id, err := s.api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source) + return marshalResult(idResp{ID: id}), err + + case MethodQueryNotes: + var p queryNotesReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.QueryNotes(ctx, p.Embedding, p.K) + if err != nil { + return nil, err + } + if out == nil { + out = []Note{} + } + return marshalResult(out), nil + + case MethodRecentNotes: + var p nReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.RecentNotes(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Note{} + } + return marshalResult(out), nil + + case MethodProposeTool: + var p proposeToolReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Ts) + if err != nil { + return nil, err + } + return marshalResult(proposeToolResp{Proposed: ok}), nil + + case MethodEnableTool: + var p enableToolReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Ts) + + case MethodLookupTool: + var p lookupToolReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + t, err := s.api.LookupTool(ctx, p.Name) + if err != nil { + return nil, err + } + return marshalResult(t), nil + + case MethodListTools: + var p listToolsReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.ListTools(ctx, p.Status) + if err != nil { + return nil, err + } + if out == nil { + out = []Tool{} + } + return marshalResult(listToolsResp{Tools: out}), nil + + default: + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + } +} + +func unmarshalParams(raw json.RawMessage, v any) error { + if len(raw) == 0 { + raw = []byte("null") + } + if err := json.Unmarshal(raw, v); err != nil { + return fmt.Errorf("%w: %v", ErrBadParams, err) + } + return nil +} + +func marshalResult(v any) json.RawMessage { + if v == nil { + return json.RawMessage("null") + } + b, _ := json.Marshal(v) + return b +} + +// Close stops accepting and waits for in-flight connections to drain. The +// socket file is removed so a restart can rebind cleanly. Idempotent. +func (s *Server) Close() error { + select { + case <-s.done: + return nil + default: + close(s.done) + } + err := s.ln.Close() + s.wg.Wait() + _ = os.Remove(s.path) + return err +} + +// Path returns the filesystem path of the listening socket. +func (s *Server) Path() string { return s.path } + +func parentDir(p string) string { + if i := lastIndexByte(p, '/'); i >= 0 { + if i == 0 { + return "/" + } + return p[:i] + } + return "." +} + +func lastIndexByte(s string, b byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == b { + return i + } + } + return -1 +} + +// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting +// process. Returns ok=false on a non-unix conn or a platform without +// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms +// already proved same-user). Linux only today; on other platforms this floors +// to "unknown caller" rather than failing — the wire still works. +func peerCaller(c net.Conn) (Caller, bool) { + uc, ok := c.(*net.UnixConn) + if !ok { + return Caller{}, false + } + raw, err := uc.SyscallConn() + if err != nil { + return Caller{}, false + } + var cred *unix.Ucred + ctrlErr := raw.Control(func(fd uintptr) { + cred, err = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }) + if ctrlErr != nil || err != nil || cred == nil { + return Caller{}, false + } + return Caller{Uid: int32(cred.Uid), Pid: int32(cred.Pid)}, true +} \ No newline at end of file diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go new file mode 100644 index 0000000..ed3cfe4 --- /dev/null +++ b/internal/ipc/wire.go @@ -0,0 +1,130 @@ +package ipc + +import ( + "encoding/json" + "errors" + "fmt" +) + +// Method — one RPC verb. The set is intentionally small: it mirrors exactly +// what a module legitimately needs from core state, and nothing more. Adding +// a method is a core-authority change (every method is a new thing a module +// can ask for); do it deliberately. +type Method string + +const ( + MethodWriteFact Method = "write_fact" + MethodLatestFact Method = "latest_fact" + MethodLatestFactBySource Method = "latest_fact_by_source" + MethodSince Method = "since" + MethodPresence Method = "presence" + MethodCreateReminder Method = "create_reminder" + MethodMarkReminder Method = "mark_reminder" + MethodRecordNudge Method = "record_nudge" + MethodResolveNudge Method = "resolve_nudge" + MethodRecentOutcomes Method = "recent_outcomes" + MethodRecentFacts Method = "recent_facts" + MethodRecentNudges Method = "recent_nudges" + MethodWriteNote Method = "write_note" + MethodQueryNotes Method = "query_notes" + MethodRecentNotes Method = "recent_notes" + MethodProposeTool Method = "propose_tool" + MethodEnableTool Method = "enable_tool" + MethodLookupTool Method = "lookup_tool" + MethodListTools Method = "list_tools" +) + +// Request — one frame from module to core. Params is the JSON-encoded argument +// struct for Method (see api.go for the per-method shapes). The server +// unmarshals Params based on Method; an unknown Method ⇒ ErrUnknownMethod. +type Request struct { + Method Method `json:"m"` + Params json.RawMessage `json:"p,omitempty"` +} + +// Response — one frame from core back to module. Exactly one of Result/Error +// is set. Result is the JSON-encoded return value of the method (might be a +// scalar, a struct, or null for void methods). +type Response struct { + Result json.RawMessage `json:"r,omitempty"` + Error *RpcError `json:"e,omitempty"` +} + +// RpcError — a typed wire error. Code is one of the sentinel codes below; +// the client rehydrates it into the matching package sentinel so callers can +// use errors.Is like they would in-process (core's contract is the same on +// both sides of the wire — the boundary shouldn't change error semantics). +type RpcError struct { + Code string `json:"c"` + Message string `json:"m,omitempty"` +} + +func (e *RpcError) Error() string { + if e.Message != "" { + return fmt.Sprintf("ipc: %s: %s", e.Code, e.Message) + } + return fmt.Sprintf("ipc: %s", e.Code) +} + +// Sentinel codes. Stable over the wire — do not rename. Mirror the package +// sentinels in api.go 1:1. The string is the contract. +const ( + codeNoFact = "no_fact" + codeConfidence = "confidence" + codeVoidsMissing = "voids_missing" + codeNudgeNotFound = "nudge_not_found" + codeNudgeOutcome = "nudge_outcome" + codeReminderMissing = "reminder_not_found" + codeReminderState = "reminder_state" + codeToolNotFound = "tool_not_found" + codeUnknownMethod = "unknown_method" + codeBadParams = "bad_params" + codeForbidden = "forbidden" + codeInternal = "internal" +) + +// codeOf maps a server-side sentinel to its wire code. Anything not matched +// is codeInternal — we never leak internal Go error text to a module; it +// gets a generic "internal" and the daemon logs the real error server-side. +func codeOf(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, ErrNoFact): + return codeNoFact + case errors.Is(err, ErrConfidence): + return codeConfidence + case errors.Is(err, ErrVoidsMissing): + return codeVoidsMissing + case errors.Is(err, ErrNudgeNotFound): + return codeNudgeNotFound + case errors.Is(err, ErrNudgeOutcome): + return codeNudgeOutcome + case errors.Is(err, ErrReminderNotFound): + return codeReminderMissing + case errors.Is(err, ErrReminderState): + return codeReminderState + case errors.Is(err, ErrToolNotFound): + return codeToolNotFound + case errors.Is(err, ErrUnknownMethod): + return codeUnknownMethod + case errors.Is(err, ErrBadParams): + return codeBadParams + case errors.Is(err, ErrForbidden): + return codeForbidden + default: + return codeInternal + } +} + +// rpcErr builds the wire error for a server-side error. message is omitted +// for sentinel codes (the Code carries the meaning; no need to echo text the +// caller can re-derive from errors.Is) and included for internal/bad-params +// where the text is the actual diagnostic. +func rpcErr(err error) *RpcError { + c := codeOf(err) + if c == codeInternal || c == codeBadParams { + return &RpcError{Code: c, Message: err.Error()} + } + return &RpcError{Code: c} +} \ No newline at end of file diff --git a/internal/loop/feedback.go b/internal/loop/feedback.go new file mode 100644 index 0000000..91842ac --- /dev/null +++ b/internal/loop/feedback.go @@ -0,0 +1,122 @@ +// loop/feedback.go — the cooldown auto-tuner: PURE math over resolved nudge +// outcomes. +// +// The feedback loop is data-flow-only at MVP — the daemon reads +// store.RecentOutcomes for a rule, calls TuneCooldown, writes the result back +// as a `facts (kind=config, source=feedback, key=cooldown:)` row. the +// Gatherer reads that row on the next tick and uses it as the active +// cooldown base (instead of the rule's static Base). misrouted tuning = +// constrained by the rule's Cooldown envelope (Min/Max), so a weird week +// can't mutate maven silent or stalker (per spec). +// +// All math here is PURE — no I/O. The daemon is the impure bit. Unit-testable +// with a fake Rule + outcomes slice. +package loop + +import ( + "encoding/json" + "time" + + "github.com/kami/maven/internal/store" +) + +// FeedbackSource — the provenance fixed string for auto-tuned cooldown facts. +// Matches the `Source` enumeration in store.Fact ("feedback"); named here so +// the gatherer + daemon reference the same string and a typo can't slip. +const FeedbackSource = "feedback" + +// FeedbackKey — the config-fact key a rule's tuned cooldown lands under. +// `cooldown:` keeps it namespaced off any predicate-read key (`water`, +// `meal`, ...) so the tuner can't collide with a rule's own substrate. the +// gatherer reads it back via LatestFactBySource(source=feedback) — the same +// trust-by-provenance shape as ServiceDownRule's poll:healthcheck read (a +// module that doesn't own the `feedback` source can't poison a rule's +// cooldown once the auth source-scope lands). +func FeedbackKey(r Rule) string { return "cooldown:" + r.Name } + +// Tuner cadence + window. Named constants (not config) because they pin the +// *shape* of the feedback loop, not its schedule; the cadence is daemon-config +// (config.AutotuneInterval), the window is the-loop's own. +const ( + // TuneSampleN — how many recent resolved outcomes the tuner looks at. + // Small enough to react to a real change of pattern inside a day; large + // enough that one weird afternoon can't whipsaw the cooldown. + TuneSampleN = 8 + + // TuneMinOutcomes — below this many resolved outcomes there's NOT enough + // signal to tune; leave Base alone. cold boot + sparse rules don't get + // a dive on first sight. + TuneMinOutcomes = 4 +) + +// TuneCooldown — PURE. dead-simple ratio over the last N resolved outcomes: +// +// mostly ignored → lengthen (the nudge is noise at this cadence) +// mostly acted → shorten (the nudge is load-bearing; ring sooner) +// snoozed is neutral — the user reacted, just deferred; treating it as +// ignored would over-lengthen, as acted would over-shorten. mvp +// neutrality > picking the wrong direction. +// +// Math: newBase = base × (1 + α·(ignoredRate − actedRate)). +// α=0.5 keeps the step tame: a fully-ignored week grows ~50%; fully-acted +// shrinks ~50%. Clamped to the rule's envelope (Min/Max) — the auto-tuner can +// never push the cooldown beyond the rule's designed bounds, so wrong math +// or a poisoned signal can't make maven silent (Min) or a stalker (Max). +// +// Empty outcomes ⇒ return Base unchanged (cold boot; sparse rule ⇒ no data, +// shut up when uncertain — same instinct as the gate's InertWhenNoData). +func TuneCooldown(r Rule, outcomes []string) time.Duration { + base := r.Cooldown.Base + if len(outcomes) == 0 { + return base + } + var acted, ignored int + for _, o := range outcomes { + switch o { + case store.NudgeActed: + acted++ + case store.NudgeIgnored: + ignored++ + // store.NudgeSnoozed: deliberately neutral (see comment). + } + } + n := float64(len(outcomes)) + const alpha = 0.5 + factor := 1 + alpha*(float64(ignored)/n-float64(acted)/n) + tuned := time.Duration(float64(base) * factor) + if tuned < r.Cooldown.Min { + tuned = r.Cooldown.Min + } + if tuned > r.Cooldown.Max { + tuned = r.Cooldown.Max + } + return tuned +} + +// ParseCooldownFact — read a feedback fact (from FeedbackKey) back into the +// active base duration. Returns (dur, false) when the row is missing, zero, +// stale (source mismatch), or malformed — the gatherer falls back to the +// rule's static Base in that case (a bad feedback row must NOT crash the +// loop; same shut-up instinct as the gate's missing-key path). +func ParseCooldownFact(f store.Fact) (time.Duration, bool) { + if f.Ts.IsZero() || f.Source != FeedbackSource { + return 0, false + } + var ns int64 + if err := json.Unmarshal([]byte(f.Value), &ns); err != nil { + return 0, false + } + if ns <= 0 { + return 0, false + } + return time.Duration(ns), true +} + +// MarshalCooldown — inverse of ParseCooldownFact. Produces the JSON value the +// daemon hands to store.SetValue for a tuned cooldown (a nanosecond int64 — +// time.Duration's native JSON encoding). Callee owns the shape so a future +// richer payload (e.g. `{"base":..,"reason":"ignored"}`) is a one-place change. +func MarshalCooldown(d time.Duration) string { + b, _ := json.Marshal(int64(d)) // int64 marshal never errors + return string(b) +} \ No newline at end of file diff --git a/internal/loop/feedback_test.go b/internal/loop/feedback_test.go new file mode 100644 index 0000000..07dd238 --- /dev/null +++ b/internal/loop/feedback_test.go @@ -0,0 +1,152 @@ +package loop + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// helper: rule with a known envelope so tested values stay stable even if +// the canonical WaterRule's numbers move. +func tuneRule(name string, base, min, max time.Duration) Rule { + return Rule{ + Name: name, + Severity: Sev1, + Cooldown: Cooldown{Base: base, Min: min, Max: max}, + } +} + +func TestTuneCooldownEmptyReturnsBase(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour) + if got := TuneCooldown(r, nil); got != r.Cooldown.Base { + t.Fatalf("empty outcomes: want base %v, got %v", r.Cooldown.Base, got) + } + if got := TuneCooldown(r, []string{}); got != r.Cooldown.Base { + t.Fatalf("no outcomes: want base %v, got %v", r.Cooldown.Base, got) + } +} + +func TestTuneCooldownThreeIgnoredOneActedLengthens(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour) + os := []string{store.NudgeIgnored, store.NudgeIgnored, store.NudgeIgnored, store.NudgeActed} + got := TuneCooldown(r, os) + // factor = 1 + 0.5*(.75 - .25) = 1.25 → 37.5m + want := time.Duration(float64(r.Cooldown.Base) * 1.25) + if got != want { + t.Fatalf("3 ignored / 1 acted: want %v, got %v", want, got) + } +} + +func TestTuneCooldownMostlyIgnoredLengthens(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour) + os := repeat(store.NudgeIgnored, 7) + os = append(os, store.NudgeActed) + got := TuneCooldown(r, os) + // factor = 1 + 0.5*(7/8 - 1/8) = 1 + 0.5*0.75 = 1.375 → 41.25m + want := time.Duration(float64(r.Cooldown.Base) * 1.375) + if got != want { + t.Fatalf("mostly ignored: want %v, got %v", want, got) + } +} + +func TestTuneCooldownMostlyActedShortens(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour) + os := repeat(store.NudgeActed, 7) + os = append(os, store.NudgeIgnored) + got := TuneCooldown(r, os) + // factor = 1 + 0.5*(1/8 - 7/8) = 1 - 0.5*0.75 = 0.625 → 18.75m + want := time.Duration(float64(r.Cooldown.Base) * 0.625) + if got != want { + t.Fatalf("mostly acted: want %v, got %v", want, got) + } +} + +func TestTuneCooldownClampsAtMax(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 35*time.Minute) + os := repeat(store.NudgeIgnored, 8) // all ignored → factor 1.5 → 45m, clamped to Max 35m + if got := TuneCooldown(r, os); got != 35*time.Minute { + t.Fatalf("all ignored should clamp to Max: want 35m, got %v", got) + } +} + +func TestTuneCooldownClampsAtMin(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 20*time.Minute, 6*time.Hour) + os := repeat(store.NudgeActed, 8) // all acted → factor 0.5 → 15m, clamped to Min 20m + if got := TuneCooldown(r, os); got != 20*time.Minute { + t.Fatalf("all acted should clamp to Min: want 20m, got %v", got) + } +} + +func TestTuneCooldownSnoozedIsNeutral(t *testing.T) { + // all snoozed → both rates 0 → factor 1 → base unchanged. + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour) + os := repeat(store.NudgeSnoozed, 8) + if got := TuneCooldown(r, os); got != r.Cooldown.Base { + t.Fatalf("all snoozed should be neutral: want base %v, got %v", r.Cooldown.Base, got) + } +} + +func TestFeedbackKeyMatchesRuleName(t *testing.T) { + r := tuneRule("water", 30*time.Minute, 15*time.Minute, 6*time.Hour) + if got := FeedbackKey(r); got != "cooldown:water" { + t.Fatalf("FeedbackKey: want cooldown:water, got %q", got) + } +} + +func TestParseCooldownFactRoundTrip(t *testing.T) { + d := 45 * time.Minute + f := store.Fact{ + Key: "cooldown:water", + Source: FeedbackSource, + Value: MarshalCooldown(d), + Ts: refTime(), + } + got, ok := ParseCooldownFact(f) + if !ok { + t.Fatalf("ParseCooldownFact: want ok, got false (value %q)", f.Value) + } + if got != d { + t.Fatalf("round trip: want %v, got %v", d, got) + } +} + +func TestParseCooldownFactRejectsBadRows(t *testing.T) { + now := refTime() + cases := []struct { + name string + f store.Fact + }{ + {"missing value", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Ts: now}}, + {"wrong source", store.Fact{Key: "cooldown:water", Source: "tap:water", Value: MarshalCooldown(30 * time.Minute), Ts: now}}, + {"non-numeric", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: `"45m"`, Ts: now}}, + {"negative ns", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: "-1000", Ts: now}}, + {"zero ts", store.Fact{Key: "cooldown:water", Source: FeedbackSource, Value: MarshalCooldown(30 * time.Minute)}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, ok := ParseCooldownFact(c.f); ok { + t.Fatalf("ParseCooldownFact %q: want not ok", c.name) + } + }) + } +} + +func TestCooldownForTakesBaseDuration(t *testing.T) { + last := refTime() + got := CooldownFor(45*time.Minute, last) + if got != last.Add(45*time.Minute) { + t.Fatalf("CooldownFor(base, last): want %v, got %v", last.Add(45*time.Minute), got) + } + if got := CooldownFor(45*time.Minute, time.Time{}); !got.IsZero() { + t.Fatalf("CooldownFor zero lastSend: want zero, got %v", got) + } +} + +func repeat(v string, n int) []string { + out := make([]string, n) + for i := range out { + out[i] = v + } + return out +} \ No newline at end of file diff --git a/internal/loop/gather.go b/internal/loop/gather.go new file mode 100644 index 0000000..23eb0d7 --- /dev/null +++ b/internal/loop/gather.go @@ -0,0 +1,150 @@ +// loop/gather.go — the ONE impure piece in the loop. +// +// Gather builds a State snapshot under the store lock at the start of each +// tick. From there, every predicate and the gate are pure functions over State. +// +// Why centralize the I/O: the loop is "dumb + deterministic", the spec +// repeatedly enforces a no-I/O contract on predicates. Centralizing read here +// makes the contract checkable (anywhere outside gather.go doing I/O is a bug). +package loop + +import ( + "context" + "time" + + "github.com/kami/maven/internal/store" +) + +// Gatherer — holds nothing mutable; the Store is the only dependency. The +// daemon runs one Gatherer per tick. +type Gatherer struct { + store *store.Store + rules []Rule +} + +func NewGatherer(s *store.Store, rules []Rule) *Gatherer { + return &Gatherer{store: s, rules: rules} +} + +// GatherState — reads the store ONCE and assembles the snapshot the pure Tick +// will operate on. +// +// Reads the loop needs: +// - presence: probes (ts per signal key), current bucket, compute score, resolve. +// - facts: every key any rule's Predicate OR InertWhenNoData names. +// - last nudge per rule (for cooldown). +// - due reminders (the loop reuses the loop for reminders; we gather them here). +// - env flags QuietHours / CalendarBusy — read as facts (kind=config/env). +// +// All reads share a single read-only transaction for a consistent snapshot. +func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []store.Reminder, error) { + // presence first — pure function over probes. the gate checks the bucket; + // delivery (later) checks the score. + probes, err := g.store.PresenceProbes(ctx) + if err != nil { + return State{}, nil, err + } + lastBucket, _, _, err := g.store.LoadPresenceState(ctx) + if err != nil { + return State{}, nil, err + } + score := store.PresenceScore(now, probes) + bucket := store.Resolve(score, lastBucket) + + // collect every key any rule references (predicate + inert list). + // Daemon rules are small (≤ ~30, per the "revisit at 30 rules" line); + // a single map over rules per tick is negligible at 60s cadence. + wanted := make(map[string]struct{}) + for _, r := range g.rules { + // We don't introspect the predicate closure (Go can't); the rule author + // declares InertWhenNoData for keys the predicate reads. Reuse that list. + for _, k := range r.InertWhenNoData { + wanted[k] = struct{}{} + } + } + // presence signal keys live in facts too — included via the probes path, + // but also surface via Fact() for rules that want direct access (e.g. break). + for _, sig := range store.PresenceSignals { + wanted[sig.Key] = struct{}{} + } + + facts := make(map[string]store.Fact, len(wanted)) + for k := range wanted { + f, err := g.store.LatestFact(ctx, k) + if err == nil { + facts[k] = f + continue + } + if err == store.ErrNoFact { + continue // missing ⇒ shut up; the gate handles it + } + return State{}, nil, err + } + + // last nudge per rule + cooldown-until derived from the active cooldown. + // "active" = the feedback tuner's persisted base if one exists, else the + // rule's static Base. LatestFactBySource is the trust-by-provenance read + // (a module that doesn't own the `feedback` source can't poison a rule's + // cooldown once the auth source-scope lands — same shape as ServiceDownRule). + lastNudge := make(map[string]store.Nudge, len(g.rules)) + cooldownUntil := make(map[string]time.Time, len(g.rules)) + for _, r := range g.rules { + base := r.Cooldown.Base + if f, err := g.store.LatestFactBySource(ctx, FeedbackKey(r), FeedbackSource); err == nil { + if tuned, ok := ParseCooldownFact(f); ok { + base = tuned + } + } else if err != store.ErrNoFact { + return State{}, nil, err + } + n, err := g.store.LastNudge(ctx, r.Name) + if err == nil { + lastNudge[r.Name] = n + cooldownUntil[r.Name] = CooldownFor(base, n.Ts) + continue + } + if err == store.ErrNudgeNotFound { + continue // never fired → no cooldown + } + return State{}, nil, err + } + + // env flags — QuietHours / CalendarBusy as config facts. + // QuietHours: presence != reachability, sleep/quiet-hours handled separately + // in the gate. We read a config `quiet_hours` fact for the boolean. + var quiet bool + if f, ok := readFact(ctx, g.store, "quiet_hours"); ok { + quiet = f.Value == "true" || f.Value == `"true"` + } + var calBusy bool + if f, ok := readFact(ctx, g.store, "calendar_busy"); ok { + calBusy = f.Value == "true" || f.Value == `"true"` + } + + // due reminders — gate-bypassing class. read here, the daemon emits them. + due, err := g.store.DueReminders(ctx, now) + if err != nil { + return State{}, nil, err + } + + s := State{ + Now: now, + Presence: bucket, + PresenceScore: score, + Facts: facts, + LastNudge: lastNudge, + SnoozeUntil: nil, // no snooze persistence yet — daemon wires in + CooldownUntil: cooldownUntil, + QuietHours: quiet, + CalendarBusy: calBusy, + } + return s, due, nil +} + +func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) { + f, err := s.LatestFact(ctx, key) + if err != nil { + return store.Fact{}, false + } + return f, true +} \ No newline at end of file diff --git a/internal/loop/loop.go b/internal/loop/loop.go new file mode 100644 index 0000000..f2baafb --- /dev/null +++ b/internal/loop/loop.go @@ -0,0 +1,137 @@ +package loop + +import ( + "time" + + "github.com/kami/maven/internal/store" +) + +// Gate — 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. +// +// The gate does NOT itself decide "should this rule run" — the Rule.Predicate +// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot. +// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env +// predicate, not the LLM's job — same boundary as "rules decide, llm phrases." +// +// Gate is pure. no I/O. reads State + Rule only. +func Gate(s State, r Rule) bool { + now := s.Now + + // snooze — per-rule "leave me alone until X". overrides everything below. + if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) { + return false + } + + // cooldown — most recent same-rule nudge + base/feedback-tuned duration. + // Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil. + if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) { + return false + } + + // quiet hours — care nudges (sev1–2) shut up. ops (sev ≥3) still surface + // (a failed backup at 2am genuinely matters and maven routes to telegram). + if s.QuietHours && r.Severity.IsCare() { + return false + } + + // calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env + // predicate; not the LLM's call. + if s.CalendarBusy && r.Severity.IsCare() { + return false + } + + // presence — sev1–2 DROP on away (missed water nudge is noise). + // sev ≥3 HOLDS — see the delivery channel-routing table in the spec. + // (the loop doesn't pick the channel; it just decides whether to emit.) + if s.Presence == store.Away && r.Severity.IsCare() { + return false + } + + // no-data inertness — since(key)==null → don't fire. shut up when uncertain. + // The predicate MAY have encoded this itself; the gate enforces it for any + // rule that declared InertWhenNoData keys. + for _, k := range r.InertWhenNoData { + if _, ok := s.Fact(k); !ok { + return false + } + } + + return true +} + +// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true). +// The loop picks one per tick (max severity). +type Candidate struct { + Rule Rule + Severity Severity + State State // snapshot at evaluation time — for the phraser's context +} + +// Tick — PURE. Evaluates the configured rules against the snapshot, returns +// AT MOST one proactive candidate (max severity, with a deterministic tie-break). +// Returns nil when nothing fires ("shuts up" is the default outcome of a tick). +// +// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to +// the phraser (LFM) and delivery module. The loop just decides. +// +// Reminders are NOT handled here — they're a separate, gate-bypassing class. +// See DueReminders (gathered separately) and RemindDecisions (the loop output +// flag for the daemon). +func Tick(s State, rules []Rule) *Candidate { + var fire *Candidate + for _, r := range rules { + if !r.Predicate(s) { + continue // rule doesn't want to fire — skip gate entirely (cheap path) + } + if !Gate(s, r) { + continue // wanted but suppressed this tick + } + c := Candidate{Rule: r, Severity: r.Severity, State: s} + if fire == nil { + fire = &c + continue + } + // max severity wins; tie-break: severity desc, then name asc for determinism. + if c.Severity > fire.Severity || + (c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) { + fire = &c + } + } + return fire +} + +// ReminderDecision — a due reminder the daemon should deliver now. +// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours; +// that's the point). Snooze still applies — represented by a separate +// snooze-until the gatherer consults; for the scaffold, fired-reminders move +// straight to MarkReminder(fired). +type ReminderDecision struct { + Reminder store.Reminder + State State +} + +// RemindDecisions — returns all due reminders (without gating their delivery +// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer +// produces that list from `fire_ts <= now AND pending`. +func RemindDecisions(s State, due []store.Reminder) []ReminderDecision { + out := make([]ReminderDecision, 0, len(due)) + for _, r := range due { + out = append(out, ReminderDecision{Reminder: r, State: s}) + } + return out +} + +// CooldownFor — helper for the Gatherer: given the active cooldown base +// (the rule's static Base, OR the feedback tuner's persisted tuning) and the +// last send ts, compute the wall-clock "cooldown-until" the gate will check. +// Pure. The auto-tuner writes the base the gatherer reads as a feedback +// fact; this function just adds it to the last send. +func CooldownFor(base time.Duration, lastSend time.Time) time.Time { + if lastSend.IsZero() { + return time.Time{} // never sent → no cooldown active + } + return lastSend.Add(base) +} \ No newline at end of file diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go new file mode 100644 index 0000000..12abc93 --- /dev/null +++ b/internal/loop/loop_test.go @@ -0,0 +1,382 @@ +package loop + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +func refTime() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } + +func factAt(key, source, value string, ts time.Time) store.Fact { + return store.Fact{Ts: ts, Key: key, Source: source, Value: value, Confidence: 1.0} +} + +// ----------------------------- Tick + Gate ----------------------------------- + +func TestTickNothingFiresColdBoot(t *testing.T) { + // cold boot — no facts at all. every rule's InertWhenNoData kicks in; gate + // returns false; Tick returns nil. "shuts up when uncertain" is the default. + s := State{Now: refTime(), Presence: store.Away} + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("cold boot: want nil, got %+v", got) + } +} + +func TestTickWaterFiresWhenThirstyAndPresent(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + }, + } + got := Tick(s, DefaultRules()) + if got == nil || got.Rule.Name != "water" { + t.Fatalf("want water candidate, got %+v", got) + } + if got.Severity != Sev1 { + t.Fatalf("water sev mismatch: %d", got.Severity) + } +} + +func TestTickWaterSuppressedOnAwayCareDrops(t *testing.T) { + // sev1 (care) → drops on away. spec: "a missed water nudge is noise." + now := refTime() + s := State{ + Now: now, + Presence: store.Away, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("care on away: want nil, got %+v", got) + } +} + +func TestTickWaterSuppressedInQuietHours(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + QuietHours: true, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("care in quiet hours: want nil, got %+v", got) + } +} + +func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) { + // sev4 ops hard — must survive both away AND quiet hours. the gate only + // suppresses sev≤2 for either flag; sev4 is the disk-fire alarm. + now := refTime() + s := State{ + Now: now, + Presence: store.Away, + QuietHours: true, + Facts: map[string]store.Fact{ + "service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)), + }, + } + got := Tick(s, DefaultRules()) + if got == nil || got.Rule.Name != "service_down" || got.Severity != Sev4 { + t.Fatalf("ops hard survives: want service_down/sev4, got %+v", got) + } +} + +func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) { + // a non-poll:uptimekuma source recording "down" must NOT fire the ops + // rule — compromised poller / ambient can't forge a trigger. + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("forged source: want nil, got %+v", got) + } +} + +func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) { + // both water (sev1) and service_down (sev4) want to fire and clear the gate. + // max severity wins — disk-fire preempts water. never dogpile. + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + "service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)), + }, + } + got := Tick(s, DefaultRules()) + if got == nil || got.Rule.Name != "service_down" { + t.Fatalf("max sev wins: want service_down, got %+v", got) + } +} + +func TestTickCooldownSuppresses(t *testing.T) { + now := refTime() + // 4h since water (would fire) — but cooldown until now+10min. Suppressed. + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + }, + CooldownUntil: map[string]time.Time{ + "water": now.Add(10 * time.Minute), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("cooldown: want nil, got %+v", got) + } +} + +func TestTickSnoozeSuppressesBeforeCooldown(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + }, + SnoozeUntil: map[string]time.Time{ + "water": now.Add(time.Hour), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("snooze: want nil, got %+v", got) + } +} + +func TestTickCalendarBusySuppressesCare(t *testing.T) { + // "don't nag mid-meeting" lives in the gate as an env predicate. + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + CalendarBusy: true, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("busy care: want nil, got %+v", got) + } +} + +func TestTickPredicateFalseSkipsGateEntirely(t *testing.T) { + // water since lastFact < 3h ⇒ predicate false ⇒ not a candidate at all, + // regardless of any gate state. cheap path; gate never consulted. + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "water": factAt("water", "tap:water", `"250ml"`, now.Add(-30*time.Minute)), + }, + } + if got := Tick(s, DefaultRules()); got != nil { + t.Fatalf("predicate false: want nil, got %+v", got) + } +} + +func TestGateNoDataInertShutsUp(t *testing.T) { + // predicate true (carelessly), but rule declared InertWhenNoData and key + // is missing in the snapshot — gate must still return false. + s := State{Now: refTime(), Presence: store.Present} + iwantfire := Rule{ + Name: "x", + Severity: Sev1, + Predicate: func(State) bool { return true }, + InertWhenNoData: []string{"missing_key"}, + } + if Gate(s, iwantfire) { + t.Fatalf("no-data rule should be inert, got fire") + } +} + +func TestRemindDecisionsDoesNotGate(t *testing.T) { + // reminders bypass restraint — they pass through untouched even in quiet, + // away, etc. this is the documented two-delivery-path split. + now := refTime() + s := State{Now: now, Presence: store.Away, QuietHours: true, CalendarBusy: true} + due := []store.Reminder{{ID: 1, Payload: `{"text":"wake me"}`}} + got := RemindDecisions(s, due) + if len(got) != 1 || got[0].Reminder.ID != 1 { + t.Fatalf("reminders must bypass gate, got %+v", got) + } +} + +// ----------------------------- Gatherer + real store ------------------------- + +func TestGathererEndToEndWaterFires(t *testing.T) { + path := t.TempDir() + "/m.db" + st, err := store.Open(context.Background(), path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + ctx := context.Background() + // write a water fact 4h ago — drop into the past by direct INSERT. + now := refTime() + if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", "250ml", now.Add(-4*time.Hour)); err != nil { + t.Fatal(err) + } + // (no `break` fact seeded — BreakRule's InertWhenNoData keeps it inert, + // so water is the only care candidate.) + // silence env flags so they don't accidentally suppress. + if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil { + t.Fatal(err) + } + if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil { + t.Fatal(err) + } + + g := NewGatherer(st, DefaultRules()) + snap, due, err := g.GatherState(ctx, now) + if err != nil { + t.Fatalf("GatherState: %v", err) + } + if len(due) != 0 { + t.Fatalf("no reminders due, got %d", len(due)) + } + // cold-boot presence (no signal facts) ⇒ away ⇒ care should drop. + if snap.Presence != store.Away { + t.Fatalf("cold presence: want Away, got %s (score %f)", snap.Presence, snap.PresenceScore) + } + // water predicate true but presence away ⇒ gate suppresses care ⇒ nil. + if got := Tick(snap, DefaultRules()); got != nil { + t.Fatalf("away cold presence should suppress care: want nil, got %+v", got) + } + + // now create a fresh desk_active signal so presence flips to present. + if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil { + t.Fatal(err) + } + snap2, _, err := g.GatherState(ctx, now) + if err != nil { + t.Fatal(err) + } + if snap2.Presence != store.Present { + t.Fatalf("with desk signal: want Present, got %s (score %f)", snap2.Presence, snap2.PresenceScore) + } + got := Tick(snap2, DefaultRules()) + if got == nil || got.Rule.Name != "water" { + t.Fatalf("present + thirsty should fire water, got %+v", got) + } +} + +// TestGathererUsesFeedbackTunedCooldown — write a `cooldown:water` feedback +// fact, then verify the gatherer's CooldownUntil for the rule uses the tuned +// base (last nudge ts + tuned base), not the rule's static Base. This is the +// end-to-end shape of the feedback loop: tuner writes fact → gatherer reads +// it next tick → gate consults the adjusted CooldownUntil. +func TestGathererUsesFeedbackTunedCooldown(t *testing.T) { + path := t.TempDir() + "/m.db" + st, err := store.Open(context.Background(), path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + ctx := context.Background() + now := refTime() + + // seed water + a presence signal so the rule has a snapshot worth gating + // against (we're not asserting Tick here — just the CooldownUntil field). + if _, err := st.SetValue(ctx, store.KindSelf, "water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := st.SetValue(ctx, store.KindSelf, "desk_active", "infer:hyprland", "1", now.Add(-1*time.Second)); err != nil { + t.Fatal(err) + } + if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", "false", now); err != nil { + t.Fatal(err) + } + if _, err := st.SetValue(ctx, store.KindConfig, "calendar_busy", "promote", "false", now); err != nil { + t.Fatal(err) + } + + // send a water nudge 10m ago so cooldown-until is lastSend+base. + sendTs := now.Add(-10 * time.Minute) + nudgeID, err := st.RecordNudge(ctx, "water", "voice", "drink water", sendTs) + if err != nil { + t.Fatal(err) + } + // leave it pending so LastNudge still surfaces it as the most recent send. + + g := NewGatherer(st, DefaultRules()) + + // baseline: rule's static Base (30m) → CooldownUntil == sendTs + 30m. + snap1, _, err := g.GatherState(ctx, now) + if err != nil { + t.Fatalf("gather baseline: %v", err) + } + wantStatic := sendTs.Add(30 * time.Minute) + if got := snap1.CooldownUntil["water"]; got != wantStatic { + t.Fatalf("baseline cooldown-until: want %v, got %v", wantStatic, got) + } + + // the feedback tuner writes a tuned base as facts(kind=config, + // source=feedback, key=cooldown:water). simulate the daemon: write a + // 5h tuned cooldown fact. + tuned := 5 * time.Hour + water := WaterRule() + if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), FeedbackSource, tuned, now); err != nil { + t.Fatal(err) + } + + snap2, _, err := g.GatherState(ctx, now) + if err != nil { + t.Fatalf("gather tuned: %v", err) + } + wantTuned := sendTs.Add(tuned) + if got := snap2.CooldownUntil["water"]; got != wantTuned { + t.Fatalf("tuned cooldown-until: want %v, got %v", wantTuned, got) + } + + // a poisoned row from a non-feedback source must be ignored (trust by + // provenance — gatherer reads LatestFactBySource(source=feedback), so a + // tap:water row at the same key doesn't reach the cooldown). + if _, err := st.SetValue(ctx, store.KindConfig, FeedbackKey(water), "tap:water", 1*time.Minute, now); err != nil { + t.Fatal(err) + } + snap3, _, err := g.GatherState(ctx, now) + if err != nil { + t.Fatalf("gather poisoned: %v", err) + } + // LatestFactBySource returns the latest NON-voided feedback row, which is + // still the 5h one we wrote — the tap:water row is invisible to this read. + if got := snap3.CooldownUntil["water"]; got != wantTuned { + t.Fatalf("poisoned row leaked: want %v, got %v", wantTuned, got) + } + + // sanity: the water nudge row is still findable (the gatherer's LastNudge + // + gate's cooldown consult it). + n, err := st.LastNudge(ctx, "water") + if err != nil || n.ID != nudgeID { + t.Fatalf("LastNudge: want id %d, got %+v err=%v", nudgeID, n, err) + } + + // sanity: the feedback fact lookup round-trips via ParseCooldownFact too. + fb, err := st.LatestFactBySource(ctx, FeedbackKey(water), FeedbackSource) + if err != nil { + t.Fatalf("LatestFactBySource feedback: %v", err) + } + if d, ok := ParseCooldownFact(fb); !ok || d != tuned { + t.Fatalf("ParseCooldownFact round-trip: want %v ok, got %v ok=%v", tuned, d, ok) + } +} \ No newline at end of file diff --git a/internal/loop/rules.go b/internal/loop/rules.go new file mode 100644 index 0000000..0521da2 --- /dev/null +++ b/internal/loop/rules.go @@ -0,0 +1,151 @@ +package loop + +import "time" + +// Rule — a proactive rule. Rules are CODE, not a DSL config — until ~30 rules +// and you feel the pain (per spec). A Rule has a name (ids it in nudges.outcome +// for the feedback loop), a Severity, a pure Predicate, and a cooldown. +// +// The Predicate answers "should this rule want to fire given the State?" — +// only the check against the snapshot. It is pure, no I/O. The GATE answers +// "are we allowed to fire it right now?" (quiet hours, cooldown, etc) — +// applied by the loop, never per-rule. +// +// Cooldown is the BASE duration between same-rule nudges. The feedback +// auto-tuner scales it over time (mostly ignored → lengthen; acted → leave). +// Bounds belong at the daemon-config level; here we just carry the base. +type Rule struct { + Name string + Severity Severity + Cooldown // base cooldown + bounded-duration envelope for the auto-tuner + Predicate func(State) bool + + // InertWhenNoData — most rules should be silent when their substrate key is + // missing (since(key)==null → don't fire). If the predicate already encodes + // that check itself, leave this empty. Otherwise set to the key(s) the rule + // needs and the gate will skip the rule when any are missing. + InertWhenNoData []string +} + +// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't +// mutate maven silent or stalker. Base is what the rule ships with; Min/Max +// bound the feedback-driven adjustments persisted as `facts (source=feedback)`. +type Cooldown struct { + Base time.Duration + Min time.Duration + Max time.Duration +} + +// Canonical care/ops rules — NOT a config DSL. Code, so the predicate is +// inspectable and unit-tested. Daemon wires these up; the loop just iterates. + +// WaterRule — sev1 care: if it's been ≥3h since a `water` fact, fire. +// Inert when no water fact exists at all (shuts up when uncertain). +func WaterRule() Rule { + return Rule{ + Name: "water", + Severity: Sev1, + Cooldown: Cooldown{Base: 30 * time.Minute, Min: 15 * time.Minute, Max: 6 * time.Hour}, + InertWhenNoData: []string{"water"}, + Predicate: func(s State) bool { + d, ok := s.Since("water") + if !ok { + return false // no data → shut up + } + return d >= 3*time.Hour + }, + } +} + +// MealRule — sev1 care: if ≥6h since an `meal` fact, fire. Inert without data. +func MealRule() Rule { + return Rule{ + Name: "meal", + Severity: Sev1, + Cooldown: Cooldown{Base: 60 * time.Minute, Min: 30 * time.Minute, Max: 8 * time.Hour}, + InertWhenNoData: []string{"meal"}, + Predicate: func(s State) bool { + d, ok := s.Since("meal") + if !ok { + return false + } + return d >= 6 * time.Hour + }, + } +} + +// BreakRule — sev2 care: ≥90min of continuous desk activity without a break. +// Reads both `desk_active` (fresh input ⇒ at desk) and `break` (last taken). +// Inert unless both exist — can't claim continuous activity without both anchors. +func BreakRule() Rule { + return Rule{ + Name: "break", + Severity: Sev2, + Cooldown: Cooldown{Base: 45 * time.Minute, Min: 20 * time.Minute, Max: 4 * time.Hour}, + InertWhenNoData: []string{"desk_active", "break"}, + Predicate: func(s State) bool { + dDesk, ok1 := s.Since("desk_active") + dBreak, ok2 := s.Since("break") + if !ok1 || !ok2 { + return false // shut up until we have both anchors + } + // at desk (fresh input within 2min) AND no break for ≥90min. + return dDesk <= 2*time.Minute && dBreak >= 90*time.Minute + }, + } +} + +// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads +// "down". Source must be poll:uptimekuma — kuma is the source of truth for +// service up/down (mavpoll writes this key). The predicate is provenance-scoped: +// a compromised poller writing under a different source can't forge the trigger. +func ServiceDownRule() Rule { + return Rule{ + Name: "service_down", + Severity: Sev4, + Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour}, + InertWhenNoData: []string{"service_down"}, + Predicate: func(s State) bool { + f, ok := s.Fact("service_down") + if !ok || f.Ts.IsZero() { + return false + } + // value is json `"down"`; trivial check keyed off source provenance. + return f.Source == "poll:uptimekuma" && f.Value == `"down"` + }, + } +} + +// NetdataCriticalRule — sev3 ops soft: netdata has a CRITICAL alarm active +// (disk/mem/cert/temp). The `netdata_alarm` aggregate fact (mavpoll, source +// poll:netdata) reads "critical". Sev3 not sev4: netdata resource alarms are +// "look soon", not "wake me" — a full disk matters, but kuma's service_down is +// the hard page. Provenance-scoped to poll:netdata. +func NetdataCriticalRule() Rule { + return Rule{ + Name: "netdata_critical", + Severity: Sev3, + Cooldown: Cooldown{Base: 20 * time.Minute, Min: 10 * time.Minute, Max: 2 * time.Hour}, + InertWhenNoData: []string{"netdata_alarm"}, + Predicate: func(s State) bool { + f, ok := s.Fact("netdata_alarm") + if !ok || f.Ts.IsZero() { + return false + } + return f.Source == "poll:netdata" && f.Value == `"critical"` + }, + } +} + +// DefaultRules — the canonical set the daemon wires. Add more as code, not config. +// Order here is NOT load-bearing — the loop picks max severity, ties broken by +// (severity desc, name asc) for deterministic output. +func DefaultRules() []Rule { + return []Rule{ + WaterRule(), + MealRule(), + BreakRule(), + ServiceDownRule(), + NetdataCriticalRule(), + } +} \ No newline at end of file diff --git a/internal/loop/state.go b/internal/loop/state.go new file mode 100644 index 0000000..db57723 --- /dev/null +++ b/internal/loop/state.go @@ -0,0 +1,109 @@ +// Package loop is maven's proactive trigger engine. +// +// Loop contract (from spec): +// +// - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free. +// - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State. +// - 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. +// +// Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler: +// - predicate: `fire_ts <= now AND pending` +// - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point +// - snooze still applies; fires once (pending → fired) +// +// Architecture: the Gatherer is the only impure bit — it builds a State snapshot +// under the store lock. Everything from there on is PURE functions over State. +// Phrasing (the llm lane) and delivery are out of scope here — the loop emits +// Decisions; the daemon wires them to phrasing + delivery. +package loop + +import ( + "time" + + "github.com/kami/maven/internal/store" +) + +// Severity — higher = more insistent (harder to suppress). +// +// Per the spec's delivery table: +// +// sev1–2: care nudges (water, meal, break). voice when present; DROP on away. +// sev3: ops soft (backup failed, cert soon). voice + once over ntfy when away. +// sev4: ops hard (disk critical, service down). voice + ntfy present; +// telegram, repeat til ack, when away. +// +// "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent +// candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1). +type Severity int + +const ( + Sev1 Severity = 1 // care, lowest insistence — drops on away + Sev2 Severity = 2 + Sev3 Severity = 3 // ops soft + Sev4 Severity = 4 // ops hard, highest insistence +) + +func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away + +// State — the loop's view of the world, gathered under the store lock at the +// start of each tick. From here on everything is pure — predicates and the gate +// read ONLY this struct and never touch the store. +// +// Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups +// the rules + gate have pre-arranged. Presence/PresenceScore/Now are global. +// The Gatherer decides what to populate; rules see what's in the snapshot. +type State struct { + Now time.Time + Presence store.Bucket + PresenceScore float64 + + // Latest non-voided fact per key the loop requested at gather time. + // Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire. + Facts map[string]store.Fact + + // Last nudge per rule, for cooldown enforcement (newest send). + LastNudge map[string]store.Nudge + + // Per-rule snooze-until — explicit "don't bother me about this rule until X". + // Overlays on top of cooldown; both must be clear to fire. + SnoozeUntil map[string]time.Time + + // Per-rule cooldown-until — derived from LastNudge + cooldown duration + // (the auto-tuner adjusts the duration via feedback outcomes). + CooldownUntil map[string]time.Time + + // Cross-cutting env flags derived from facts at gather time: + // QuietHours — the care gate suppresses sev1–2 when true. ops still surface. + QuietHours bool + // CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate. + CalendarBusy bool +} + +// Fact returns the latest non-voided fact for key, or +// (store.Fact{}, false) — the predicate's "no data" case. +// since(key)==null → don't fire is implemented by checking the bool. +func (s State) Fact(key string) (store.Fact, bool) { + f, ok := s.Facts[key] + if !ok || f.Ts.IsZero() { + return store.Fact{}, false + } + return f, true +} + +// Since returns the duration since the latest fact for key, or (0,false). +// "false" ⇒ no data ⇒ shuts up when uncertain. +func (s State) Since(key string) (time.Duration, bool) { + f, ok := s.Fact(key) + if !ok { + return 0, false + } + if s.Now.Before(f.Ts) { + return 0, true + } + return s.Now.Sub(f.Ts), true +} \ No newline at end of file diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go new file mode 100644 index 0000000..e16d6ea --- /dev/null +++ b/internal/phraser/llmphraser.go @@ -0,0 +1,300 @@ +package phraser + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "regexp" + "strings" + "sync" + "syscall" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" +) + +var listenRE = regexp.MustCompile(`listening on (https?://\S+)`) + +type LLMPhraser struct { + cfg Config + client *http.Client + port string + cmd *exec.Cmd + cancel context.CancelFunc + wg sync.WaitGroup +} + +type Config struct { + ModelPath string + BinPath string + Listen string + NGpuLayers int + NCtx int + Timeout time.Duration +} + +func DefaultConfig(modelPath string) Config { + return Config{ + ModelPath: modelPath, + BinPath: "llama-server", + Listen: "127.0.0.1:0", + NGpuLayers: -1, + NCtx: 2048, + Timeout: 30 * time.Second, + } +} + +func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) { + ctx, cancel := context.WithCancel(ctx) + p := &LLMPhraser{ + cfg: cfg, + client: &http.Client{Timeout: cfg.Timeout}, + cancel: cancel, + } + if err := p.start(ctx); err != nil { + cancel() + return nil, err + } + return p, nil +} + +func (p *LLMPhraser) start(ctx context.Context) error { + args := []string{ + "-m", p.cfg.ModelPath, + "--host", "127.0.0.1", + "--port", extractPort(p.cfg.Listen), + "-c", fmt.Sprintf("%d", p.cfg.NCtx), + "-ngl", fmt.Sprintf("%d", p.cfg.NGpuLayers), + "--no-webui", + } + cmd := exec.CommandContext(ctx, p.cfg.BinPath, args...) + // Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by + // ANY means, including SIGKILL/OOM/panic where our Close() never runs. Without + // it a hard-killed mavend orphans its llama-server (reparented to init, keeps + // eating GPU/RAM); repeated dev restarts pile up orphans until the box OOMs. + // Setpgid isolates it in its own process group so a stray Ctrl-C on the + // terminal group doesn't half-kill it out from under us. (Linux-only, like + // the rest of the daemon.) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} + p.cmd = cmd + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("llm: stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("llm: start: %w", err) + } + + portCh := make(chan string, 1) + errCh := make(chan error, 1) + p.wg.Add(1) + go func() { + defer p.wg.Done() + buf := make([]byte, 4096) + var leftover []byte + for { + n, err := stderr.Read(buf) + if n > 0 { + data := append(leftover, buf[:n]...) + lines := bytes.Split(data, []byte("\n")) + for _, line := range lines[:len(lines)-1] { + if m := listenRE.FindSubmatch(line); len(m) > 1 { + addr := string(m[1]) + portCh <- addr + close(portCh) + } + } + leftover = lines[len(lines)-1] + } + if err != nil { + errCh <- err + return + } + } + }() + + select { + case addr := <-portCh: + p.port = addr + return nil + case err := <-errCh: + _ = cmd.Process.Kill() + return fmt.Errorf("llm: server output: %w", err) + case <-ctx.Done(): + _ = cmd.Process.Kill() + return ctx.Err() + case <-time.After(60 * time.Second): + _ = cmd.Process.Kill() + return fmt.Errorf("llm: server did not start within 60s") + } +} + +func (p *LLMPhraser) Close() error { + p.cancel() + if p.cmd != nil && p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + } + p.wg.Wait() + return nil +} + +func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) { + prompt := buildNudgePrompt(c) + resp, err := p.chat(ctx, prompt) + if err != nil { + return delivery.PhrasedNudge{}, err + } + body, summary := parsePhrase(resp) + if body == "" { + body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity)) + } + if summary == "" { + summary = c.Rule.Name + } + return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, nil +} + +func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) { + text := extractReminderText(d.Reminder.Payload) + if text == "" { + text = "reminder" + } + + prompt := fmt.Sprintf( + `The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"body": "...", "summary": "..."}`, + text, + ) + resp, err := p.chat(ctx, prompt) + if err != nil { + return delivery.PhrasedReminder{}, err + } + body, summary := parsePhrase(resp) + if body == "" { + body = text + } + if summary == "" { + summary = text + if len(summary) > 60 { + summary = summary[:57] + "..." + } + } + return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary}, nil +} + +type chatMsg struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatReq struct { + Model string `json:"model"` + Messages []chatMsg `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` +} + +type chatResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) { + req := chatReq{ + Messages: []chatMsg{ + {Role: "system", Content: systemPrompt()}, + {Role: "user", Content: userPrompt}, + }, + Temperature: 0.7, + MaxTokens: 256, + } + body, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("llm: marshal: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("llm: request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("llm: post: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("llm: read: %w", err) + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + + var cr chatResp + if err := json.Unmarshal(raw, &cr); err != nil { + return "", fmt.Errorf("llm: parse: %w", err) + } + if len(cr.Choices) == 0 { + return "", fmt.Errorf("llm: no choices in response") + } + return cr.Choices[0].Message.Content, nil +} + +func systemPrompt() string { + return `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"body": "full voice message", "summary": "brief away-channel version (<60 chars)"}. body is what the user hears on voice; summary is for push notifications (ntfy/telegram) — minimal, no exfil detail.` +} + +func buildNudgePrompt(c loop.Candidate) string { + var ctxParts []string + ctxParts = append(ctxParts, fmt.Sprintf("Rule: %s", c.Rule.Name)) + ctxParts = append(ctxParts, fmt.Sprintf("Severity: %s", sevLabel(c.Severity))) + + if d, ok := c.State.Since(c.Rule.Name); ok { + ctxParts = append(ctxParts, fmt.Sprintf("Duration since last event: %s", humanDur(d))) + } + + return fmt.Sprintf( + `Generate a nudge message. Context: +%s + +Respond as JSON: {"body": "...", "summary": "..."}`, + strings.Join(ctxParts, "\n"), + ) +} + +func parsePhrase(raw string) (body, summary string) { + cleaned := strings.TrimSpace(raw) + start := strings.Index(cleaned, "{") + end := strings.LastIndex(cleaned, "}") + if start < 0 || end < 0 || end <= start { + return "", "" + } + var parsed struct { + Body string `json:"body"` + Summary string `json:"summary"` + } + if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil { + return "", "" + } + return parsed.Body, parsed.Summary +} + +func extractPort(listen string) string { + _, port, _ := strings.Cut(listen, ":") + if port == "" { + return "0" + } + return port +} diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go new file mode 100644 index 0000000..9edbe66 --- /dev/null +++ b/internal/phraser/phraser.go @@ -0,0 +1,193 @@ +// Package phraser is maven's "rules decide, llm phrases" seam — the layer +// that turns a loop decision into the body + summary the delivery module ships. +// +// Per the spec: the phraser (LFM sub-1b, prompted not trained) takes +// (rule, severity, context) and produces Body (full voice message, local — no +// shoulder-surf concern beyond who's in the room) + Summary (minimal body for +// away channels — "disk low on homesrv," not detail; no exfil through the +// relay). the phraser NEVER owns the route — it phrases what the loop decided. +// +// This package defines the interface + a deterministic Stub (the floor). the +// Stub is template-based, no model — it exists so the daemon can be wired +// end-to-end before the LLM-backed impl lands. the LLM impl is a single new +// type satisfying the same interface; the daemon swaps one for the other at +// the construction seam, no CoreAPI or delivery change. +// +// Architecture: the phraser is impure (the LLM impl makes RPC calls). the +// Stub is pure (templates over State) and is the test floor. both produce +// delivery.PhrasedNudge / delivery.PhrasedReminder, which the dispatcher +// consumes unchanged. +package phraser + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" +) + +// Phraser — the seam the daemon wires. one method per delivery path (nudge +// = loop-derived, reminder = user-stated). both return the +// delivery.Phrased* structs the dispatcher consumes, so the phraser owns the +// full output contract: Body (voice) + Summary (away channels). +// +// the daemon calls PhraseNudge with the loop's *Candidate (Rule + Severity + +// the State snapshot at evaluation time — exactly the (rule, severity, +// context) input the spec names). PhraseReminder with the ReminderDecision +// (Reminder + State). the phraser reads the State for context ("you haven't +// had water in 4h, you're at your desk, it's 2pm") — never touches the store. +type Phraser interface { + PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) + PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) + Close() error +} + +// Stub — the deterministic, no-model floor. template-based, reads context +// from the Candidate/Decision State. produces a terse Body (voice) + an even +// terser Summary (away channels). warm-but-functional tone; the LLM impl +// carries the personality-prompt character spec, the Stub does not. +// +// the Stub is the production path until the LLM-backed impl lands, and the +// test path afterward (deterministic phrasing makes the daemon + delivery +// unit-testable without a model in the loop). +type Stub struct{} + +// NewStub builds the floor phraser. no config — the Stub is stateless. +func NewStub() *Stub { return &Stub{} } + +// Close implements Phraser.Close (no-op for the stub). +func (s *Stub) Close() error { return nil } + +// PhraseNudge — dispatches on the rule name to a per-rule template, falls +// back to a generic shape. reads the State for the durations/values that made +// the predicate fire (the same State the predicate saw). +func (s *Stub) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) { + body, summary := phraseNudge(c) + return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, nil +} + +// PhraseReminder — extracts the user's text from the reminder payload (raw +// JSON, shape owned by the router's reminder slot extraction) and renders it +// as both Body and a short Summary. the reminder's payload is the user's own +// words — the phraser just unwraps it, doesn't editorialize. +func (s *Stub) PhraseReminder(_ context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) { + text := extractReminderText(d.Reminder.Payload) + if text == "" { + text = "reminder" + } + summary := text + if len(summary) > 60 { + summary = summary[:57] + "..." + } + return delivery.PhrasedReminder{Decision: d, Body: text, Summary: summary}, nil +} + +// phraseNudge — the per-rule templates. each reads the context the predicate +// used to decide, so the phrased message names WHY the rule fired ("you +// haven't had water in 4h") rather than just THAT it fired. +func phraseNudge(c loop.Candidate) (body, summary string) { + switch c.Rule.Name { + case "water": + if d, ok := c.State.Since("water"); ok { + body = fmt.Sprintf("you haven't had water in %s — drink something.", humanDur(d)) + } else { + body = "drink some water." + } + return body, "drink water" + case "meal": + if d, ok := c.State.Since("meal"); ok { + body = fmt.Sprintf("it's been %s since you ate — get some food.", humanDur(d)) + } else { + body = "you should eat something." + } + return body, "eat something" + case "break": + if d, ok := c.State.Since("break"); ok { + body = fmt.Sprintf("you've been at your desk for %s without a break — step away for a bit.", humanDur(d)) + } else { + body = "take a break." + } + return body, "take a break" + case "service_down": + // the fact value is json `"down"`; the key carries the service name. + body = "a service on homesrv is down — check journalctl." + summary = "service down on homesrv" + if f, ok := c.State.Fact("service_down"); ok { + if f.Key != "" && f.Key != "service_down" { + body = fmt.Sprintf("%s on homesrv is down — check journalctl.", f.Key) + summary = fmt.Sprintf("%s down on homesrv", f.Key) + } + } + return body, summary + default: + // generic: name the rule + severity; the LLM impl replaces this with + // a prompted phrase. the Stub never editorializes beyond the rule name. + body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity)) + summary = c.Rule.Name + return body, summary + } +} + +// extractReminderText — the reminder payload is raw JSON; the router's +// reminder slot extraction owns the shape. the conventional field is "text". +// fall back to the raw payload if it isn't JSON or lacks the field — the user +// said it, it's the user's words. +func extractReminderText(payload string) string { + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err == nil { + if t, ok := m["text"].(string); ok && t != "" { + return t + } + if t, ok := m["text"]; ok { + return fmt.Sprintf("%v", t) + } + } + return strings.TrimSpace(payload) +} + +// humanDur — round a duration to the coarsest sensible unit for speech. +// "4h12m" → "4 hours"; "92m" → "1h32m" → "an hour and a half". keep it simple: +// hours, then minutes, rounded. this is FLOOR phrasing — the LLM impl can +// natural-language it; the Stub sticks to readable. +func humanDur(d time.Duration) string { + if d < 0 { + d = 0 + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + switch { + case h >= 2: + return fmt.Sprintf("%d hours", h) + case h == 1: + if m >= 30 { + return "an hour and a half" + } + return "an hour" + default: + if m >= 45 { + return "an hour" + } + return fmt.Sprintf("%d minutes", m) + } +} + +// sevLabel — a one-word gist of severity for the generic fallback. the +// per-rule templates don't use this; it's only for rules without a dedicated +// template (i.e. rules added to DefaultRules after the phrasers ship, before +// they get a template). +func sevLabel(s loop.Severity) string { + switch { + case s <= loop.Sev1: + return "care" + case s == loop.Sev2: + return "care" + case s == loop.Sev3: + return "ops" + default: + return "alarm" + } +} \ No newline at end of file diff --git a/internal/phraser/phraser_test.go b/internal/phraser/phraser_test.go new file mode 100644 index 0000000..9b54b79 --- /dev/null +++ b/internal/phraser/phraser_test.go @@ -0,0 +1,223 @@ +package phraser + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +// ----------------------------- nudges -------------------------------------- + +func TestPhraseNudgeWaterMentionsDuration(t *testing.T) { + now := time.Now().UTC() + earlier := now.Add(-4 * time.Hour) + st := loop.State{ + Now: now, + Facts: map[string]store.Fact{"water": {Key: "water", Ts: earlier, Source: "tap:water", Value: `"250ml"`}}, + } + c := loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: st} + pn, err := NewStub().PhraseNudge(context.Background(), c) + if err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if pn.Candidate.Rule.Name != "water" { + t.Fatalf("candidate rule: want water, got %s", pn.Candidate.Rule.Name) + } + if !strings.Contains(pn.Body, "water") || !strings.Contains(pn.Body, "4 hours") { + t.Fatalf("body should mention water + 4 hours, got %q", pn.Body) + } + if pn.Summary != "drink water" { + t.Fatalf("summary: want 'drink water', got %q", pn.Summary) + } + // Summary must be shorter than Body — the away-channel minimal-body rule. + if len(pn.Summary) >= len(pn.Body) { + t.Fatalf("summary should be shorter than body: body=%q (%d), summary=%q (%d)", pn.Body, len(pn.Body), pn.Summary, len(pn.Summary)) + } +} + +func TestPhraseNudgeMealNoDataStillPhrases(t *testing.T) { + // predicate wouldn't fire on no data (since==null), but the phraser is + // still required to produce SOMETHING if called — never return empty body. + st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}} + c := loop.Candidate{Rule: loop.MealRule(), Severity: loop.Sev1, State: st} + pn, err := NewStub().PhraseNudge(context.Background(), c) + if err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if pn.Body == "" { + t.Fatal("body empty on no-data — should still phrase") + } + if pn.Summary == "" { + t.Fatal("summary empty on no-data") + } +} + +func TestPhraseNudgeBreakDeskDuration(t *testing.T) { + now := time.Now().UTC() + st := loop.State{ + Now: now, + Facts: map[string]store.Fact{ + "desk_active": {Key: "desk_active", Ts: now.Add(-30 * time.Second)}, + "break": {Key: "break", Ts: now.Add(-95 * time.Minute)}, + }, + } + c := loop.Candidate{Rule: loop.BreakRule(), Severity: loop.Sev2, State: st} + pn, _ := NewStub().PhraseNudge(context.Background(), c) + if !strings.Contains(pn.Body, "desk") || !strings.Contains(pn.Body, "an hour and a half") { + t.Fatalf("break body should mention desk + duration, got %q", pn.Body) + } + if pn.Summary != "take a break" { + t.Fatalf("summary: want 'take a break', got %q", pn.Summary) + } +} + +func TestPhraseNudgeServiceDownNamedService(t *testing.T) { + // a service_down fact whose Key is the specific service name → the phrase + // names the service, not just "service down". + now := time.Now().UTC() + st := loop.State{ + Now: now, + Facts: map[string]store.Fact{"service_down": {Key: "nginx", Ts: now, Source: "poll:healthcheck", Value: `"down"`}}, + } + c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st} + pn, _ := NewStub().PhraseNudge(context.Background(), c) + if !strings.Contains(pn.Body, "nginx") { + t.Fatalf("body should name the service, got %q", pn.Body) + } + if !strings.Contains(pn.Summary, "nginx") { + t.Fatalf("summary should name the service, got %q", pn.Summary) + } +} + +func TestPhraseNudgeServiceDownGenericKey(t *testing.T) { + // the rule key itself ("service_down") rather than a specific service → + // the generic phrase, not a phantom "service_down down on homesrv". + now := time.Now().UTC() + st := loop.State{ + Now: now, + Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: "poll:healthcheck", Value: `"down"`}}, + } + c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st} + pn, _ := NewStub().PhraseNudge(context.Background(), c) + if strings.Contains(pn.Body, "service_down down") { + t.Fatalf("body shouldn't repeat the key verbatim: %q", pn.Body) + } +} + +func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) { + // a rule without a dedicated template — generic fallback names the rule + + // severity gist. never empty. + unk := loop.Rule{Name: "custom_rule", Severity: loop.Sev3} + st := loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}} + c := loop.Candidate{Rule: unk, Severity: loop.Sev3, State: st} + pn, _ := NewStub().PhraseNudge(context.Background(), c) + if pn.Body == "" || pn.Summary == "" { + t.Fatalf("fallback should produce both: body=%q summary=%q", pn.Body, pn.Summary) + } + if pn.Summary != "custom_rule" { + t.Fatalf("fallback summary should be the rule name, got %q", pn.Summary) + } +} + +// ----------------------------- reminders ------------------------------------ + +func TestPhraseReminderExtractsText(t *testing.T) { + // the router's reminder slot extraction produces {"text": "wake me"} — the + // phraser unwraps it. the reminder's words are the user's; no editorializing. + rd := loop.ReminderDecision{ + Reminder: store.Reminder{Payload: `{"text":"wake me at 7"}`}, + State: loop.State{Now: time.Now().UTC()}, + } + pr, err := NewStub().PhraseReminder(context.Background(), rd) + if err != nil { + t.Fatalf("PhraseReminder: %v", err) + } + if pr.Body != "wake me at 7" { + t.Fatalf("body: want 'wake me at 7', got %q", pr.Body) + } + if pr.Summary != "wake me at 7" { + t.Fatalf("summary: want 'wake me at 7', got %q", pr.Summary) + } +} + +func TestPhraseReminderTruncatesLongSummary(t *testing.T) { + // away channels (ntfy/telegram) get Summary; a long reminder text → + // truncated so the lock-screen preview isn't a paragraph. + long := "remind me to do the thing where i need to walk all the way over there and back before the sun comes up" + rd := loop.ReminderDecision{ + Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`}, + State: loop.State{Now: time.Now().UTC()}, + } + pr, _ := NewStub().PhraseReminder(context.Background(), rd) + if len(pr.Summary) > 63 { + t.Fatalf("summary should be truncated to ~60, got %d: %q", len(pr.Summary), pr.Summary) + } + if pr.Body != long { + t.Fatalf("body should be the full text, got %q", pr.Body) + } +} + +func TestPhraseReminderNonJSONPayload(t *testing.T) { + // a payload that isn't JSON → the phraser falls back to the raw string. + rd := loop.ReminderDecision{ + Reminder: store.Reminder{Payload: "just a plain string"}, + State: loop.State{Now: time.Now().UTC()}, + } + pr, _ := NewStub().PhraseReminder(context.Background(), rd) + if pr.Body != "just a plain string" { + t.Fatalf("non-json body: want the raw string, got %q", pr.Body) + } + if pr.Summary != "just a plain string" { + t.Fatalf("non-json summary: want the raw string, got %q", pr.Summary) + } +} + +func TestPhraseReminderEmptyPayload(t *testing.T) { + // don't ship an empty message — the reminder path must produce something. + rd := loop.ReminderDecision{ + Reminder: store.Reminder{Payload: ""}, + State: loop.State{Now: time.Now().UTC()}, + } + pr, _ := NewStub().PhraseReminder(context.Background(), rd) + if pr.Body == "" || pr.Summary == "" { + t.Fatalf("empty payload should fall back to 'reminder': body=%q summary=%q", pr.Body, pr.Summary) + } + if pr.Body != "reminder" { + t.Fatalf("empty payload body: want 'reminder', got %q", pr.Body) + } +} + +// ----------------------------- interface guard ------------------------------ + +func TestStubSatisfiesPhraser(t *testing.T) { + // compile-time guard: the Stub must satisfy the Phraser interface so the + // daemon can wire it. the production LLM impl satisfies the same interface. + var _ Phraser = (*Stub)(nil) + // also exercise the methods once so the guard isn't the only assertion. + p := NewStub() + if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}}); err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if _, err := p.PhraseReminder(context.Background(), loop.ReminderDecision{Reminder: store.Reminder{Payload: "{}"}, State: loop.State{Now: time.Now().UTC()}}); err != nil { + t.Fatalf("PhraseReminder: %v", err) + } +} + +func TestStubProducesDeliveryTypes(t *testing.T) { + // the output must be the delivery.Phrased* structs the dispatcher + // consumes — the phraser owns the full output contract. + pn, _ := NewStub().PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1, State: loop.State{Now: time.Now().UTC(), Facts: map[string]store.Fact{}}}) + if _, ok := any(pn).(delivery.PhrasedNudge); !ok { + t.Fatalf("PhraseNudge must return delivery.PhrasedNudge, got %T", pn) + } +} + +// ----------------------------- helpers -------------------------------------- +// (the per-rule templates change output shape; assert via strings.Contains +// against salient fragments, not exact strings — keeps the tests robust to +// tone tweaks in the Stub.) \ No newline at end of file diff --git a/internal/router/classifier.go b/internal/router/classifier.go new file mode 100644 index 0000000..9bc662b --- /dev/null +++ b/internal/router/classifier.go @@ -0,0 +1,160 @@ +package router + +import ( + "context" + "errors" + "math" + "sort" +) + +// ErrNoIntents — the classifier has no seeded examples; cannot classify. +// The daemon seeds ~10 examples/intent at bootstrap (per spec). Until then +// every free-form utterance routes to clarify-or-ask, never to a guess. +var ErrNoIntents = errors.New("router: no intents seeded") + +// Example — one labeled utterance + its embedding. The classifier is +// append-only: misroute correction = AddExample for the corrected intent +// (per spec — "append-only, grows the classifier as used. more reliable over +// time, no retrain"). The note stays as provenance; the router never silently +// rewires a label. +type Example struct { + Text string + Vec []float32 +} + +// Result — one scored intent from Classify. Score is cosine similarity in +// [-1,1]; higher = closer to that intent's centroid. +type Result struct { + Intent Intent + Score float64 +} + +// Classifier — nearest-centroid over labeled intents. One forward pass (the +// embed) yields a vector; cosine similarity to each intent's centroid (mean +// of its examples) gives a score; max wins. ~10 examples/intent is the spec's +// bootstrap target. +// +// Pure given the Embedder: the only I/O is the embed call itself. Centroid +// math is deterministic and unit-testable with a fake embedder. The cascade +// (router.go) applies the confidence threshold; the classifier just scores. +type Classifier struct { + embedder Embedder + examples map[Intent][]Example + centroids map[Intent][]float32 + dim int +} + +func NewClassifier(e Embedder) *Classifier { + return &Classifier{ + embedder: e, + examples: make(map[Intent][]Example), + centroids: make(map[Intent][]float32), + dim: e.Dim(), + } +} + +// AddExample — appends a labeled example and recomputes that intent's centroid. +// Misroute correction calls this with the corrected intent. Production path. +func (c *Classifier) AddExample(ctx context.Context, intent Intent, text string) error { + vec, err := c.embedder.Embed(ctx, text) + if err != nil { + return err + } + c.addVec(intent, text, vec) + return nil +} + +// AddExampleVec — for tests that want to skip the embedder (inject vectors +// directly). Keeps the classifier pure under a fake embedder without re-running +// the hash. Not used by the production cascade. +func (c *Classifier) AddExampleVec(intent Intent, text string, vec []float32) { + c.addVec(intent, text, vec) +} + +func (c *Classifier) addVec(intent Intent, text string, vec []float32) { + c.examples[intent] = append(c.examples[intent], Example{Text: text, Vec: vec}) + c.centroids[intent] = meanVec(c.examples[intent]) +} + +// Examples — read-only view of seeded examples per intent. Introspectable: the +// daemon surfaces "what maven has been taught" through an authed surface. +func (c *Classifier) Examples(intent Intent) []Example { + return c.examples[intent] +} + +// Intents — the set of intents with at least one seeded example. +func (c *Classifier) Intents() []Intent { + out := make([]Intent, 0, len(c.centroids)) + for k := range c.centroids { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// Classify — embeds the utterance and returns all intents scored by cosine +// similarity to their centroid, sorted best-first (ties broken by intent asc +// for determinism). The caller applies the confidence threshold (stage 3). +// Returns ErrNoIntents if no examples have been seeded. +func (c *Classifier) Classify(ctx context.Context, utterance string) ([]Result, error) { + if len(c.centroids) == 0 { + return nil, ErrNoIntents + } + vec, err := c.embedder.Embed(ctx, utterance) + if err != nil { + return nil, err + } + results := make([]Result, 0, len(c.centroids)) + for intent, centroid := range c.centroids { + results = append(results, Result{Intent: intent, Score: cosine(vec, centroid)}) + } + sort.Slice(results, func(i, j int) bool { + if results[i].Score != results[j].Score { + return results[i].Score > results[j].Score + } + return results[i].Intent < results[j].Intent + }) + return results, nil +} + +// meanVec — L2-normalized mean of a set of example vectors. Normalizing the +// centroid keeps cosine = dot product against normalized query vectors, and +// stops high-example-count intents from dominating purely by magnitude. +func meanVec(exs []Example) []float32 { + if len(exs) == 0 { + return nil + } + m := make([]float32, len(exs[0].Vec)) + for _, e := range exs { + for i, x := range e.Vec { + m[i] += x + } + } + var sum float64 + for i := range m { + m[i] /= float32(len(exs)) + sum += float64(m[i]) * float64(m[i]) + } + if sum == 0 { + return m + } + inv := float32(1.0 / math.Sqrt(sum)) + for i := range m { + m[i] *= inv + } + return m +} + +// cosine — both inputs are L2-normalized ⇒ dot product == cosine similarity. +// Mismatched/empty dimensions return 0 (no signal), which the threshold gate +// turns into clarify — never into a confident wrong write. +func cosine(a, b []float32) float64 { + if len(a) != len(b) || len(a) == 0 { + return 0 + } + var dot float64 + for i := range a { + dot += float64(a[i]) * float64(b[i]) + } + return dot +} diff --git a/internal/router/embedder.go b/internal/router/embedder.go new file mode 100644 index 0000000..e9c0de1 --- /dev/null +++ b/internal/router/embedder.go @@ -0,0 +1,97 @@ +package router + +import ( + "context" + "math" + "unicode" +) + +// Embedder produces a dense vector for a text utterance. The classifier is +// nearest-centroid over labeled-intent example vectors; the embedder is the +// one impure seam — the production path is the multilingual ONNX int8 model +// (multilingual-e5-small or paraphrase-multilingual-MiniLM-L12-v2, ~120mb, +// bilingual ru+en native, no separate ru/en path). That model is a later +// module; anything deterministic + dimension-fixed works here, which keeps +// the classifier + cascade unit-testable without the model loaded. +type Embedder interface { + Dim() int + Embed(ctx context.Context, text string) ([]float32, error) + Close() error +} + +// HashEmbedder — a deterministic bag-of-words embedder used for tests and as a +// non-zero default floor. NOT semantically meaningful across languages; the +// real classifier swaps in the multilingual ONNX model wholesale. +// +// Token collisions are the point: same surface words ⇒ similar vectors ⇒ the +// centroid math is testable. Each token hashes into a dimension; weights +// accumulate then L2-normalize so cosine similarity is a clean inner product. +type HashEmbedder struct { + dim int +} + +func NewHashEmbedder(dim int) *HashEmbedder { + if dim <= 0 { + dim = 128 + } + return &HashEmbedder{dim: dim} +} + +func (h *HashEmbedder) Dim() int { return h.dim } + +func (h *HashEmbedder) Close() error { return nil } + +func (h *HashEmbedder) Embed(_ context.Context, text string) ([]float32, error) { + v := make([]float32, h.dim) + for _, tok := range tokenize(text) { + idx := fnv1a(tok) % uint32(h.dim) + v[idx] += 1.0 + } + var sum float64 + for _, x := range v { + sum += float64(x) * float64(x) + } + if sum == 0 { + return v, nil + } + inv := float32(1.0 / math.Sqrt(sum)) + for i := range v { + v[i] *= inv + } + return v, nil +} + +func fnv1a(s string) uint32 { + h := uint32(2166136261) + for i := 0; i < len(s); i++ { + h ^= uint32(s[i]) + h *= 16777619 + } + return h +} + +// tokenize — lowercase, split on non-letter/digit, drop empties + 1-char noise. +// Rune-based and Unicode-aware: Maven is ru-first, so a byte-only ASCII filter +// would drop every Cyrillic word (its bytes are all ≥ 0x80) and embed Russian +// utterances to the zero vector — cosine 0 across all intents, misrouting every +// RU command. unicode.IsLetter covers Cyrillic + Latin; the real ONNX model +// brings its own tokenizer. +func tokenize(s string) []string { + out := make([]string, 0, 8) + var b []rune + flush := func() { + if len(b) > 1 { + out = append(out, string(b)) + } + b = b[:0] + } + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b = append(b, unicode.ToLower(r)) + } else { + flush() + } + } + flush() + return out +} diff --git a/internal/router/embedder_test.go b/internal/router/embedder_test.go new file mode 100644 index 0000000..5f815b1 --- /dev/null +++ b/internal/router/embedder_test.go @@ -0,0 +1,39 @@ +package router + +import ( + "context" + "testing" +) + +// TestHashEmbedderCyrillic guards the ru-first floor: a byte-only tokenizer +// drops every Cyrillic word (bytes ≥ 0x80) and embeds Russian to the zero +// vector — cosine 0 across all intents, misrouting every RU utterance. Assert +// non-zero vectors, and that shared Russian words produce more similar vectors +// than disjoint ones (the point of the bag-of-words floor). +func TestHashEmbedderCyrillic(t *testing.T) { + e := NewHashEmbedder(1024) + ctx := context.Background() + + nonZero := func(text string) []float32 { + v, err := e.Embed(ctx, text) + if err != nil { + t.Fatalf("embed %q: %v", text, err) + } + var sum float64 + for _, x := range v { + sum += float64(x) * float64(x) + } + if sum == 0 { + t.Fatalf("embed %q → zero vector (tokenizer dropped all tokens)", text) + } + return v + } + + a := nonZero("найди заметку про сервер") + b := nonZero("найди заметку про роутер") // shares 3 of 4 words + c := nonZero("перезагрузи компьютер") // disjoint + + if cosine(a, b) <= cosine(a, c) { + t.Fatalf("cosine(shared)=%.3f not > cosine(disjoint)=%.3f", cosine(a, b), cosine(a, c)) + } +} diff --git a/internal/router/intent.go b/internal/router/intent.go new file mode 100644 index 0000000..134ccfc --- /dev/null +++ b/internal/router/intent.go @@ -0,0 +1,96 @@ +// Package router is maven's reactive path — the cascade that turns a free-form +// utterance into a deterministic Decision. +// +// Spec contract (from maven.md § reactive path — router): +// +// - routing is a DECISION, and every decision in maven stays deterministic. +// a classifier owns the route; the SLM stays in its phrasing lane. same +// boundary as "rules decide, llm phrases," extended to the reactive path. +// - a CASCADE, not classifier-vs-deterministic — 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. +// stage 1 — intent classifier. embed utterance, nearest-centroid over +// labeled intents. one forward pass, ~30ms cpu, similarity score. +// 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 misrouted +// fact is a confident wrong write — worse than a gap. +// - save-where is the routing axis: act | reminder | fact | note | query. +// - misroute correction = new centroid example — append-only, grows the +// classifier as used. same shape as nudges.outcome tuning cooldowns. +// +// The Embedder is the one impure seam (the ONNX int8 model is a later module). +// Given a deterministic embedder, the classifier + cascade are pure and unit- +// testable with zero infra — same instinct as the loop's gather/pure split. +package router + +import "time" + +// Intent — the five save-where labels from the spec's routing table. The +// discriminator is "does the loop evaluate a predicate against it?": +// +// - act: command now, not stored (function call into the allowlist) +// - reminder: has a fire-time → reminders table (sqlite). bypasses the gate +// - fact: structured state the loop reasons over → facts (sqlite) +// - note: recall/preference, no predicate touches it → chroma +// - query: answer, don't store → slm reads sqlite or chroma (RAG) +// +// 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. +type Intent string + +const ( + IntentAct Intent = "act" + IntentReminder Intent = "reminder" + IntentFact Intent = "fact" + IntentNote Intent = "note" + IntentQuery Intent = "query" + IntentSystem Intent = "system" +) + +// Slots — per-intent extracted arguments (stage 2). Not every field is set for +// every intent; the Intent decides which matter. A slot that doesn't parse +// leaves its Has* flag false — the daemon's SLM last-resort lane picks it up +// for free-form notes the parsers choke on. Never an error to be missing. +type Slots struct { + // Reminder: absolute fire time. The router resolves relative→absolute AT + // CAPTURE ("in 4h" → now+4h, never the string) per spec — the reminders + // table stores only absolute fire_ts. HasTime=false ⇒ no datetime parsed. + Time time.Time + HasTime bool + + // Act: the function name from the allowlist + positional args. The router + // fuzzy-matches the verb against the allowlist; not on the list → refuse, + // don't improvise. Fn empty ⇒ no allowlist match. Destructive acts still + // gate behind confirm at the daemon layer, not here. + Fn string + Args []string + HasFn bool + + // Fact: structured (key,value) the loop will evaluate predicates against. + // "drank water" → key=water; "slept 6h" → key=sleep, value=6h. The value + // is the raw string the daemon json-encodes before WriteFact. + Key string + Value string + HasKey bool + + // Note/Query: free-form payload (chroma-bound for note, RAG input for query). + // Always set to the utterance for those intents. + Text string +} + +// Decision — the router's output. The cascade is deterministic: stage 0 wins +// outright; otherwise classify (1) → extract (2) → gate (3). Clarify=true ⇒ +// the daemon asks instead of guessing — "shuts up when uncertain" for routing, +// same shape as since(key)==null → don't fire for the loop. +type Decision struct { + Utterance string + Stage int // 0 exact-match, 1 classified, 2 slots-extracted, 3 clarify-gated + Intent Intent + Confidence float64 // 1.0 for stage-0; classifier cosine similarity for 1+ + Slots Slots + Clarify bool // stage 3: below threshold — ask, don't guess +} diff --git a/internal/router/onnxembedder.go b/internal/router/onnxembedder.go new file mode 100644 index 0000000..fc0b8a8 --- /dev/null +++ b/internal/router/onnxembedder.go @@ -0,0 +1,316 @@ +package router + +import ( + "context" + "encoding/json" + "fmt" + "math" + "os" + "strings" + + ort "github.com/yalue/onnxruntime_go" + "golang.org/x/text/unicode/norm" +) + +const ( + padTokenID = 1 + unkTokenID = 3 + clsTokenID = 0 + sepTokenID = 2 + maxLength = 128 + embedDim = 384 +) + +type onnxEmbedder struct { + tokenizer *unigramTokenizer + session *ort.DynamicSession[int64, float32] +} + +func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, error) { + ort.SetSharedLibraryPath(libPath) + if err := ort.InitializeEnvironment(); err != nil { + return nil, fmt.Errorf("onnx: init environment: %w", err) + } + + tok, err := newUnigramTokenizer(tokenizerPath) + if err != nil { + return nil, fmt.Errorf("tokenizer: %w", err) + } + + session, err := ort.NewDynamicSession[int64, float32]( + modelPath, + []string{"input_ids", "attention_mask", "token_type_ids"}, + []string{"last_hidden_state"}, + ) + if err != nil { + return nil, fmt.Errorf("onnx: create session: %w", err) + } + + return &onnxEmbedder{ + tokenizer: tok, + session: session, + }, nil +} + +func (e *onnxEmbedder) Dim() int { return embedDim } + +func (e *onnxEmbedder) Embed(ctx context.Context, text string) ([]float32, error) { + inputIDs, attentionMask, _ := e.tokenizer.Encode(text) + + inputShape := ort.NewShape(1, int64(maxLength)) + inputT, err := ort.NewTensor(inputShape, inputIDs) + if err != nil { + return nil, fmt.Errorf("onnx: input tensor: %w", err) + } + defer inputT.Destroy() + maskT, err := ort.NewTensor(inputShape, attentionMask) + if err != nil { + return nil, fmt.Errorf("onnx: mask tensor: %w", err) + } + defer maskT.Destroy() + typeT, err := ort.NewTensor(inputShape, make([]int64, maxLength)) + if err != nil { + return nil, fmt.Errorf("onnx: type tensor: %w", err) + } + defer typeT.Destroy() + + outputShape := ort.NewShape(1, int64(maxLength), embedDim) + outputT, err := ort.NewTensor(outputShape, make([]float32, maxLength*embedDim)) + if err != nil { + return nil, fmt.Errorf("onnx: output tensor: %w", err) + } + defer outputT.Destroy() + + if err := e.session.Run( + []*ort.Tensor[int64]{inputT, maskT, typeT}, + []*ort.Tensor[float32]{outputT}, + ); err != nil { + return nil, fmt.Errorf("onnx: run: %w", err) + } + + emb := meanPool(outputT.GetData(), attentionMask, maxLength, embedDim) + return emb, nil +} + +func (e *onnxEmbedder) Close() error { + e.session.Destroy() + return nil +} + +func meanPool(hidden []float32, mask []int64, seqLen, dim int) []float32 { + out := make([]float32, dim) + var maskSum float32 + for i := 0; i < seqLen; i++ { + if mask[i] == 0 { + continue + } + maskSum++ + for j := 0; j < dim; j++ { + out[j] += hidden[i*dim+j] + } + } + if maskSum > 0 { + for j := 0; j < dim; j++ { + out[j] /= maskSum + } + } + var sumSq float64 + for _, v := range out { + sumSq += float64(v) * float64(v) + } + if sumSq > 0 { + inv := float32(1.0 / math.Sqrt(sumSq)) + for i := range out { + out[i] *= inv + } + } + return out +} + +type unigramTokenizer struct { + vocab map[string]vocabEntry + unkScore float64 +} + +type vocabEntry struct { + id int64 + score float64 +} + +func newUnigramTokenizer(path string) (*unigramTokenizer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read tokenizer.json: %w", err) + } + + var raw struct { + Model struct { + Type string `json:"type"` + Vocab json.RawMessage `json:"vocab"` + } `json:"model"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse tokenizer.json: %w", err) + } + if raw.Model.Type != "Unigram" { + return nil, fmt.Errorf("unsupported tokenizer type: %s", raw.Model.Type) + } + + var rawVocab [][]json.RawMessage + if err := json.Unmarshal(raw.Model.Vocab, &rawVocab); err != nil { + return nil, fmt.Errorf("parse vocab: %w", err) + } + + vocab := make(map[string]vocabEntry, len(rawVocab)) + var unkScore float64 + for _, pair := range rawVocab { + if len(pair) < 2 { + continue + } + var token string + if err := json.Unmarshal(pair[0], &token); err != nil { + continue + } + var score float64 + if err := json.Unmarshal(pair[1], &score); err != nil { + continue + } + vocab[token] = vocabEntry{score: score} + } + // Assign IDs based on order + i := int64(0) + for _, pair := range rawVocab { + var token string + if err := json.Unmarshal(pair[0], &token); err != nil { + continue + } + e := vocab[token] + e.id = i + vocab[token] = e + if i == unkTokenID { + unkScore = e.score + } + i++ + } + + return &unigramTokenizer{vocab: vocab, unkScore: unkScore}, nil +} + +func (t *unigramTokenizer) Encode(text string) (inputIDs, attentionMask, tokenTypeIDs []int64) { + tokens := t.tokenize(text) + + tokens = append([]int64{clsTokenID}, tokens...) + tokens = append(tokens, sepTokenID) + + if len(tokens) > maxLength { + tokens = tokens[:maxLength-1] + tokens = append(tokens, sepTokenID) + } + + inputIDs = make([]int64, maxLength) + attentionMask = make([]int64, maxLength) + tokenTypeIDs = make([]int64, maxLength) + for i, id := range tokens { + inputIDs[i] = id + attentionMask[i] = 1 + } + return +} + +func (t *unigramTokenizer) tokenize(text string) []int64 { + words := preTokenize(text) + var ids []int64 + for _, word := range words { + wordIDs := t.encodeWord(word) + ids = append(ids, wordIDs...) + } + return ids +} + +type cand struct { + start int + end int + id int64 + score float64 +} + +func (t *unigramTokenizer) encodeWord(word string) []int64 { + runes := []rune(word) + n := len(runes) + if n == 0 { + return nil + } + + var candidates []cand + for i := 0; i < n; i++ { + for j := i + 1; j <= n && j-i <= 50; j++ { + sub := string(runes[i:j]) + if e, ok := t.vocab[sub]; ok { + candidates = append(candidates, cand{ + start: i, end: j, id: e.id, score: e.score, + }) + } + } + } + + dp := make([]float64, n+1) + prev := make([]int, n+1) + bestID := make([]int64, n+1) + filled := make([]bool, n+1) + dp[0] = 0 + filled[0] = true + + for i := 1; i <= n; i++ { + bestScore := math.Inf(-1) + bestPrev := -1 + bestTokenID := int64(unkTokenID) + + for _, c := range candidates { + if c.end == i && filled[c.start] { + candScore := dp[c.start] + c.score + if candScore > bestScore { + bestScore = candScore + bestPrev = c.start + bestTokenID = c.id + } + } + } + + if bestScore == math.Inf(-1) { + if filled[i-1] { + dp[i] = dp[i-1] + t.unkScore + prev[i] = i - 1 + bestID[i] = unkTokenID + filled[i] = true + } + } else { + dp[i] = bestScore + prev[i] = bestPrev + bestID[i] = bestTokenID + filled[i] = true + } + } + + var result []int64 + for i := n; i > 0; i = prev[i] { + result = append([]int64{bestID[i]}, result...) + } + // Reverse + for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 { + result[l], result[r] = result[r], result[l] + } + return result +} + +func preTokenize(text string) []string { + text = norm.NFKC.String(text) + text = strings.ToLower(text) + pieces := strings.Fields(text) + out := make([]string, 0, len(pieces)) + for _, p := range pieces { + out = append(out, "\u2581"+p) + } + return out +} + +var _ Embedder = (*onnxEmbedder)(nil) diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..068044c --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,99 @@ +package router + +import ( + "context" + "time" +) + +// Config — wires the cascade. Build via New; a zero-value Router is unusable. +type Config struct { + // Grammars — stage-0 exact-match rules. DefaultGrammars(actMatcher) wires + // the wake-word act fast path; the daemon may append more. + Grammars []Grammar + // Classifier — stage-1 nearest-centroid classifier. Must be seeded with + // ~10 examples/intent at bootstrap (per spec) before free-form routing + // is trustworthy; until then Route returns ErrNoIntents on free-form input. + Classifier *Classifier + // Extractor — stage-2 per-intent slot extraction. Any nil sub-parser just + // leaves the corresponding Has* flag false for that intent. + Extractor Extractor + // Threshold — stage-3 confidence gate. Below ⇒ Clarify, don't guess. The + // spec leaves this open (defines how often maven asks vs guesses on free- + // form input; the whole reactive mvp feel rides on it). The daemon sets it. + Threshold float64 +} + +// Router — the deterministic cascade. Route never guesses: stage 0 wins +// outright, stage 1 scores, stage 2 extracts, stage 3 gates. The SLM only +// phrases the reply — it never owns the route. +type Router struct { + grammars []Grammar + classifier *Classifier + extractor Extractor + threshold float64 +} + +func New(cfg Config) *Router { + return &Router{ + grammars: cfg.Grammars, + classifier: cfg.Classifier, + extractor: cfg.Extractor, + threshold: cfg.Threshold, + } +} + +// Route — the cascade: stage 0 (exact match) → 1 (classify) → 2 (extract) → +// 3 (confidence gate). +// +// Stage 0 wins outright: returns at confidence 1.0, no classifier. +// Otherwise the classifier scores every intent; the best wins; slots are +// extracted for that intent. If the winning score < threshold the Decision +// is flagged Clarify (the daemon asks rather than guesses — same shape as +// since(key)==null → don't fire: a misrouted fact is a confident wrong write, +// worse than a gap). +func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (Decision, error) { + // stage 0 — exact match / grammar. First match wins; grammars are ordered. + for _, g := range r.grammars { + m := g.Pattern.FindStringSubmatch(utterance) + if m == nil { + continue + } + d, ok := g.Build(m) + if !ok { + continue // grammar matched shape but not content → fall through + } + d.Utterance = utterance + return d, nil + } + + // stage 1 — intent classifier. + results, err := r.classifier.Classify(ctx, utterance) + if err != nil { + return Decision{}, err + } + best := results[0] + + // stage 2 — slot extraction for the winning intent. + d := Decision{ + Utterance: utterance, + Stage: 2, + Intent: best.Intent, + Confidence: best.Score, + Slots: r.extractor.Extract(ctx, best.Intent, utterance, now), + } + + // stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess. + if d.Confidence < r.threshold { + d.Stage = 3 + d.Clarify = true + } + return d, nil +} + +// CorrectMisroute — the user corrected a bad classification. Appends a new +// example for the corrected intent (append-only — grows the classifier, no +// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over +// time, introspectable, no model surgery. +func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error { + return r.classifier.AddExample(ctx, corrected, utterance) +} diff --git a/internal/router/router_test.go b/internal/router/router_test.go new file mode 100644 index 0000000..7222ba5 --- /dev/null +++ b/internal/router/router_test.go @@ -0,0 +1,315 @@ +package router + +import ( + "context" + "testing" + "time" +) + +func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } + +// seedClassifier — the spec's "~10 examples/intent" bootstrap, trimmed for the +// test. Real surface words ⇒ the hash embedder gives same-words-similar-vectors +// ⇒ the centroid math routes correctly without the ONNX model. +func seedClassifier(t *testing.T, c *Classifier) { + t.Helper() + ctx := context.Background() + acts := []string{"restart nginx", "restart the backup", "stop nginx", "run the backup now"} + reminders := []string{"remind me at seven", "wake me at seven", "remind me in four hours", "wake me tuesday"} + facts := []string{"drank water", "i drank water", "ate lunch", "slept six hours", "had a meal"} + notes := []string{"gpu driver fixed the flicker", "prefer backups at three am", "note that the router reboots on tuesday"} + queries := []string{"is the backup up", "when did i last eat", "is nginx running", "how much water today"} + for _, x := range acts { + if err := c.AddExample(ctx, IntentAct, x); err != nil { + t.Fatalf("seed act %q: %v", x, err) + } + } + for _, x := range reminders { + if err := c.AddExample(ctx, IntentReminder, x); err != nil { + t.Fatalf("seed reminder %q: %v", x, err) + } + } + for _, x := range facts { + if err := c.AddExample(ctx, IntentFact, x); err != nil { + t.Fatalf("seed fact %q: %v", x, err) + } + } + for _, x := range notes { + if err := c.AddExample(ctx, IntentNote, x); err != nil { + t.Fatalf("seed note %q: %v", x, err) + } + } + for _, x := range queries { + if err := c.AddExample(ctx, IntentQuery, x); err != nil { + t.Fatalf("seed query %q: %v", x, err) + } + } +} + +func newTestRouter(t *testing.T, threshold float64) *Router { + t.Helper() + // dim 1024: a hash embedder is bag-of-words, so collisions across intents + // would mask the real centroid math. 1024 buckets over ~40 tokens makes + // collisions negligible — the test exercises the cascade, not the hash. + emb := NewHashEmbedder(1024) + c := NewClassifier(emb) + seedClassifier(t, c) + acts := DefaultActMatcher{Fns: []string{"restart", "stop", "run", "backup"}} + ex := Extractor{ + Time: StubDateTimeParser{}, + Acts: acts, + Facts: DefaultFactParser{}, + } + return New(Config{ + Grammars: DefaultGrammars(acts), + Classifier: c, + Extractor: ex, + Threshold: threshold, + }) +} + +// ----------------------------- stage 0 --------------------------------------- + +func TestStage0WakeWordAct(t *testing.T) { + r := newTestRouter(t, 0.0) + d, err := r.Route(context.Background(), "maven, restart nginx", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Stage != 0 || d.Intent != IntentAct || d.Confidence != 1.0 { + t.Fatalf("stage0: want stage=0 act conf=1.0, got %+v", d) + } + if !d.Slots.HasFn || d.Slots.Fn != "restart" || len(d.Slots.Args) != 1 || d.Slots.Args[0] != "nginx" { + t.Fatalf("stage0 slots: want fn=restart args=[nginx], got %+v", d.Slots) + } +} + +func TestStage0WakeWordFallsThroughOnUnknownAct(t *testing.T) { + // wakeword prefix alone doesn't guarantee a known command. "maven, i'm tired" + // is a fact-ish utterance → falls through to the classifier. + r := newTestRouter(t, 0.0) + d, err := r.Route(context.Background(), "maven, i drank water", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Stage == 0 { + t.Fatalf("unknown act should fall through, got stage0 %+v", d) + } +} + +// ----------------------------- stage 1 --------------------------------------- + +func TestStage1ClassifiesAct(t *testing.T) { + r := newTestRouter(t, 0.0) + d, err := r.Route(context.Background(), "restart the backup now", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Intent != IntentAct { + t.Fatalf("want act, got %s (conf %f)", d.Intent, d.Confidence) + } + if !d.Slots.HasFn || d.Slots.Fn != "restart" { + t.Fatalf("act slots: want fn=restart, got %+v", d.Slots) + } +} + +func TestStage1ClassifiesFact(t *testing.T) { + r := newTestRouter(t, 0.0) + d, err := r.Route(context.Background(), "i drank water", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Intent != IntentFact { + t.Fatalf("want fact, got %s (conf %f)", d.Intent, d.Confidence) + } + if !d.Slots.HasKey || d.Slots.Key != "water" { + t.Fatalf("fact slots: want key=water, got %+v", d.Slots) + } +} + +func TestStage1ClassifiesNoteAndQuery(t *testing.T) { + r := newTestRouter(t, 0.0) + cases := []struct { + in string + want Intent + }{ + {"prefer backups at three am", IntentNote}, + {"gpu driver fixed the flicker", IntentNote}, + {"is the backup up", IntentQuery}, + {"when did i last eat", IntentQuery}, + } + for _, c := range cases { + d, err := r.Route(context.Background(), c.in, refNow()) + if err != nil { + t.Fatalf("route %q: %v", c.in, err) + } + if d.Intent != c.want { + t.Errorf("%q: want %s, got %s (conf %f)", c.in, c.want, d.Intent, d.Confidence) + } + } +} + +// ----------------------------- stage 2 --------------------------------------- + +func TestStage2ReminderSlotExtraction(t *testing.T) { + r := newTestRouter(t, 0.0) + now := refNow() + d, err := r.Route(context.Background(), "remind me in four hours", now) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Intent != IntentReminder { + t.Fatalf("want reminder, got %s", d.Intent) + } + if !d.Slots.HasTime { + t.Fatalf("reminder: want HasTime, got %+v", d.Slots) + } + want := now.Add(4 * time.Hour) + if !d.Slots.Time.Equal(want) { + t.Fatalf("reminder time: want %v, got %v", want, d.Slots.Time) + } +} + +func TestStage2ReminderAtClockRollsToTomorrow(t *testing.T) { + // "wake me at 7" said at 12:00 → fires tomorrow 07:00 (already past today). + r := newTestRouter(t, 0.0) + now := refNow() + d, err := r.Route(context.Background(), "wake me at 7", now) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Intent != IntentReminder { + t.Fatalf("want reminder, got %s", d.Intent) + } + want := time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC) + if !d.Slots.Time.Equal(want) { + t.Fatalf("wake-at-7: want %v, got %v", want, d.Slots.Time) + } +} + +func TestStage2FactSleptDuration(t *testing.T) { + r := newTestRouter(t, 0.0) + d, err := r.Route(context.Background(), "slept 6h", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Intent != IntentFact { + t.Fatalf("want fact, got %s", d.Intent) + } + if d.Slots.Key != "sleep" || d.Slots.Value != `"6h"` { + t.Fatalf("slept slots: want key=sleep value=\"6h\", got %+v", d.Slots) + } +} + +// ----------------------------- stage 3 --------------------------------------- + +func TestStage3ClarifyBelowThreshold(t *testing.T) { + // high threshold ⇒ even a well-classified utterance is gated to clarify. + // "shuts up when uncertain": a misrouted fact is a confident wrong write. + r := newTestRouter(t, 0.99) + d, err := r.Route(context.Background(), "i drank water", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if !d.Clarify || d.Stage != 3 { + t.Fatalf("want stage3 clarify, got stage=%d clarify=%v (conf %f)", d.Stage, d.Clarify, d.Confidence) + } + // the best-guess intent + slots still travel with the decision so the + // clarify prompt can use them ("did you mean — you drank water?") + if d.Intent != IntentFact { + t.Fatalf("clarify should still carry best guess, got %s", d.Intent) + } +} + +func TestStage3PassesAboveThreshold(t *testing.T) { + r := newTestRouter(t, 0.0) // threshold 0 ⇒ nothing gated + d, err := r.Route(context.Background(), "i drank water", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Clarify { + t.Fatalf("threshold 0 should never clarify, got %+v", d) + } +} + +// ----------------------------- cold boot ------------------------------------- + +func TestColdBootNoIntents(t *testing.T) { + // unseeded classifier → free-form input cannot be routed. stage 0 still + // works (grammar path). "shuts up when uncertain" for routing. + emb := NewHashEmbedder(128) + c := NewClassifier(emb) + r := New(Config{Classifier: c, Threshold: 0}) + if _, err := r.Route(context.Background(), "something freeform", refNow()); err != ErrNoIntents { + t.Fatalf("cold boot: want ErrNoIntents, got %v", err) + } +} + +// ----------------------------- misroute correction --------------------------- + +func TestCorrectMisrouteGrowsClassifier(t *testing.T) { + r := newTestRouter(t, 0.4) + // "note the backup is broken" looks note-ish but the user meant a fact + // (loop should know the backup is down). Without correction it routes note. + before, err := r.Route(context.Background(), "backup is broken", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if before.Intent == IntentFact { + t.Fatalf("precondition: expected non-fact, got %s", before.Intent) + } + // user corrects → append a new example for the corrected intent. + if err := r.CorrectMisroute(context.Background(), "backup is broken", IntentFact); err != nil { + t.Fatalf("correct: %v", err) + } + // a few reinforcements so the centroid shifts decisively. + for _, x := range []string{"backup is down", "backup failed", "backup broken now"} { + if err := r.CorrectMisroute(context.Background(), x, IntentFact); err != nil { + t.Fatalf("correct: %v", err) + } + } + after, err := r.Route(context.Background(), "backup is broken", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if after.Intent != IntentFact { + t.Fatalf("after correction: want fact, got %s (conf %f)", after.Intent, after.Confidence) + } +} + +// ----------------------------- classifier unit ------------------------------- + +func TestClassifierDeterministicOrdering(t *testing.T) { + emb := NewHashEmbedder(64) + c := NewClassifier(emb) + ctx := context.Background() + _ = c.AddExample(ctx, IntentAct, "restart nginx") + _ = c.AddExample(ctx, IntentFact, "drank water") + r1, _ := c.Classify(ctx, "restart nginx") + r2, _ := c.Classify(ctx, "restart nginx") + if len(r1) != len(r2) { + t.Fatalf("non-deterministic length") + } + for i := range r1 { + if r1[i] != r2[i] { + t.Fatalf("non-deterministic ordering at %d: %v vs %v", i, r1[i], r2[i]) + } + } + if r1[0].Intent != IntentAct { + t.Fatalf("best match should be act, got %s", r1[0].Intent) + } +} + +func TestClassifierIntentsSorted(t *testing.T) { + emb := NewHashEmbedder(32) + c := NewClassifier(emb) + ctx := context.Background() + _ = c.AddExample(ctx, IntentQuery, "q") + _ = c.AddExample(ctx, IntentAct, "a") + _ = c.AddExample(ctx, IntentFact, "f") + got := c.Intents() + want := []Intent{IntentAct, IntentFact, IntentQuery} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Fatalf("intents sort: want %v, got %v", want, got) + } +} diff --git a/internal/router/slots.go b/internal/router/slots.go new file mode 100644 index 0000000..4537bfe --- /dev/null +++ b/internal/router/slots.go @@ -0,0 +1,329 @@ +package router + +import ( + "context" + "strconv" + "strings" + "time" +) + +// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h), +// per spec. The production impl is `dateparser` (ru+en relative+absolute) in a +// later module; the interface keeps slot extraction testable without it. +// Returns (time, true, nil) on a successful parse; (zero, false, nil) when the +// text carries no recognizable datetime — a missing slot, not an error. +type DateTimeParser interface { + Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) +} + +// ActMatcher — fuzzy-matches an utterance's verb against the fn allowlist. +// Not on the list → refuse, don't improvise (per spec). The production matcher +// is fuzzy; the scaffold ships exact + exact-with-args. Destructive acts still +// gate behind confirm at the daemon layer — the matcher only identifies the fn. +type ActMatcher interface { + Match(utterance string) (fn string, args []string, ok bool) + Allowlist() []string +} + +// FactParser — pulls a (key,value) pair out of a fact utterance. "drank water" +// → key=water; "slept 6h" → key=sleep, value=6h. The loop evaluates predicates +// against the key; the value is the structured payload the daemon json-encodes +// before WriteFact. Tiny at mvp; the table of recognizers grows as code (same +// instinct as rules-as-code). +type FactParser interface { + Parse(utterance string) (key, value string, ok bool) +} + +// Extractor — stage 2: per-intent slot extraction. Classification gives *what +// kind*, not *the args*. Each intent has its own parser; the router dispatches. +// The SLM's last-resort lane (free-form notes the parsers choke on) is NOT +// here — it lives in the phrasing module. The extractor is deterministic. +type Extractor struct { + Time DateTimeParser + Acts ActMatcher + Facts FactParser +} + +// Extract — dispatches on intent, fills the relevant Slots fields. Best-effort: +// a slot that doesn't parse leaves its Has* flag false; the daemon/SLM last- +// resort lane picks it up. Never returns an error for "couldn't parse" — +// missing slot ≠ failure. +func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string, now time.Time) Slots { + s := Slots{Text: utterance} + switch intent { + case IntentReminder: + if e.Time != nil { + if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok { + s.Time = t + s.HasTime = true + } + } + case IntentAct: + if e.Acts != nil { + if fn, args, ok := e.Acts.Match(utterance); ok { + s.Fn = fn + s.Args = args + s.HasFn = true + } + } + case IntentFact: + if e.Facts != nil { + if k, v, ok := e.Facts.Parse(utterance); ok { + s.Key = k + s.Value = v + s.HasKey = true + } + } + } + return s +} + +// --- default implementations (scaffold floors; production swaps wholesale) --- + +// DefaultActMatcher — exact verb prefix + remainder-as-args. The production +// matcher is fuzzy; this is the scaffold floor. "restart nginx" → fn=restart, +// args=[nginx]. Not on the list → ok=false → the router refuses the act. +type DefaultActMatcher struct { + Fns []string +} + +func (m DefaultActMatcher) Allowlist() []string { return m.Fns } + +func (m DefaultActMatcher) Match(utterance string) (string, []string, bool) { + u := strings.TrimSpace(utterance) + // longest-verb-first so "restart" can't be shadowed by a shorter prefix. + sorted := append([]string(nil), m.Fns...) + sortDescByLen(sorted) + for _, fn := range sorted { + if u == fn { + return fn, nil, true + } + if strings.HasPrefix(u, fn+" ") { + rest := strings.TrimSpace(strings.TrimPrefix(u, fn+" ")) + return fn, splitArgs(rest), true + } + } + return "", nil, false +} + +// DefaultFactParser — a handful of recognizers as code. Grows by append, not +// by config. Keys match the loop's rule keys (water/meal/sleep/break) so a +// captured fact actually feeds the predicates that read it. +type DefaultFactParser struct{} + +func (DefaultFactParser) Parse(utterance string) (string, string, bool) { + s := strings.ToLower(strings.TrimSpace(utterance)) + // Maven is ru-first (voice, tts). Each case carries the English tokens AND + // Russian stems — matched by prefix (hasStem) because Russian inflects + // (воды/воду/вода share "вод"), so exact-token matching would miss most + // real utterances and silently drop the capture. + switch { + case (containsWord(s, "water") && containsWord(s, "drank")) || + (hasRoot(s, "вод") && (hasRoot(s, "пил") || hasRoot(s, "пью") || hasRoot(s, "пей"))): + return "water", `"drank"`, true + case containsWord(s, "meal") || (containsWord(s, "ate") && !containsWord(s, "backup")) || containsWord(s, "lunch") || containsWord(s, "dinner") || + hasRoot(s, "поел") || hasRoot(s, "поесть") || hasRoot(s, "куша") || hasRoot(s, "обед") || hasRoot(s, "ужин") || hasRoot(s, "завтрак") || hasRoot(s, "еда"): + return "meal", `"ate"`, true + case containsWord(s, "shower") || hasRoot(s, "душ"): + return "shower", `"took"`, true + case containsWord(s, "break") || hasRoot(s, "перерыв") || hasRoot(s, "отдох"): + return "break", `"took"`, true + case containsWord(s, "slept") || containsWord(s, "sleep") || + hasRoot(s, "спал") || hasRoot(s, "выспал"): + if v, ok := parseDurationValue(afterWord(s, "slept")); ok { + return "sleep", strconv.Quote(v), true + } + return "sleep", `"slept"`, true + } + return "", "", false +} + +// hasRoot — substring match on the whole utterance. Russian inflects with BOTH +// prefixes and suffixes (вы-пил, по-пил, пил-и), so a prefix test misses the +// verb; the root as a substring catches all forms. A rare over-match (пил in +// пилот) is fine at this floor. ponytail: substring roots over a morphology lib +// until misfires actually bite. +func hasRoot(s, root string) bool { return strings.Contains(s, root) } + +// containsWord — whole-token membership (avoids "breakfast" matching "break"). +func containsWord(s, w string) bool { + for _, tok := range strings.Fields(s) { + if tok == w { + return true + } + } + return false +} + +// afterWord — the remainder of s after the first occurrence of word w (tokens). +func afterWord(s, w string) string { + toks := strings.Fields(s) + for i, t := range toks { + if t == w { + return strings.Join(toks[i+1:], " ") + } + } + return "" +} + +// StubDateTimeParser — a tiny relative/absolute parser standing in for +// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and +// "at HH:MM" / "HH:MM". The production path replaces this wholesale; the +// interface is the seam, not this implementation. +type StubDateTimeParser struct{} + +func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) { + s := strings.ToLower(strings.TrimSpace(text)) + toks := strings.Fields(s) + // scan for "in " anywhere — dateparser extracts the datetime + // expression from surrounding text; the stub does the same naively. + for i := 0; i+2 < len(toks); i++ { + if toks[i] != "in" { + continue + } + n, unit, ok := splitNumUnit(toks[i+1] + " " + toks[i+2]) + if !ok { + continue + } + if d, ok := unitToDuration(n, unit); ok { + return now.Add(d), true, nil + } + } + // scan for "at " anywhere. + for i := 0; i+1 < len(toks); i++ { + if toks[i] != "at" { + continue + } + if t, ok := parseClock(toks[i+1], now); ok { + return t, true, nil + } + } + // bare clock at start ("7:30"). + if len(toks) > 0 { + if t, ok := parseClock(toks[0], now); ok { + return t, true, nil + } + } + return time.Time{}, false, nil +} + +// --- helpers --- + +func splitArgs(rest string) []string { + parts := strings.Fields(rest) + if len(parts) == 0 { + return nil + } + return parts +} + +func sortDescByLen(ss []string) { + for i := 1; i < len(ss); i++ { + for j := i; j > 0 && len(ss[j]) > len(ss[j-1]); j-- { + ss[j], ss[j-1] = ss[j-1], ss[j] + } + } +} + +// parseClock — "7", "7:30" → today at that time; if already past today, roll +// to tomorrow (a "wake me 7" at 8pm fires tomorrow 7). Used by the stub scan. +func parseClock(clock string, now time.Time) (time.Time, bool) { + parts := strings.SplitN(clock, ":", 2) + h, err := strconv.Atoi(parts[0]) + if err != nil || h < 0 || h > 23 { + return time.Time{}, false + } + m := 0 + if len(parts) == 2 { + m, err = strconv.Atoi(parts[1]) + if err != nil || m < 0 || m > 59 { + return time.Time{}, false + } + } + t := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location()) + if !t.After(now) { + t = t.Add(24 * time.Hour) + } + return t, true +} + +// splitNumUnit — "4h" → (4, "h"); "thirty minutes" → (30, "minutes"). Also +// handles a small set of English word numbers ("four", "thirty") so the stub +// parses natural reminder seeds; `dateparser` brings the full ru/en coverage. +func splitNumUnit(s string) (int, string, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, "", false + } + if n, rest, ok := leadingDigits(s); ok { + return n, strings.TrimSpace(rest), true + } + if n, rest, ok := leadingWordNumber(s); ok { + return n, strings.TrimSpace(rest), true + } + return 0, "", false +} + +func leadingDigits(s string) (int, string, bool) { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == 0 { + return 0, "", false + } + n, err := strconv.Atoi(s[:i]) + if err != nil { + return 0, "", false + } + return n, s[i:], true +} + +// wordNumbers — small set, enough for natural test seeds ("four hours", +// "thirty minutes"). Production dateparser handles the full ru/en range. +var wordNumbers = map[string]int{ + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, + "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, + "eleven": 11, "twelve": 12, "fifteen": 15, "twenty": 20, + "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, +} + +func leadingWordNumber(s string) (int, string, bool) { + toks := strings.Fields(s) + if len(toks) == 0 { + return 0, "", false + } + n, ok := wordNumbers[toks[0]] + if !ok { + return 0, "", false + } + return n, strings.Join(toks[1:], " "), true +} + +func unitToDuration(n int, unit string) (time.Duration, bool) { + switch unit { + case "h", "hour", "hours", "hr", "hrs": + return time.Duration(n) * time.Hour, true + case "m", "min", "mins", "minute", "minutes": + return time.Duration(n) * time.Minute, true + case "s", "sec", "secs", "second", "seconds": + return time.Duration(n) * time.Second, true + } + return 0, false +} + +// parseDurationValue — used by the fact parser for "slept 6h" → value "6h". +func parseDurationValue(s string) (string, bool) { + s = strings.TrimSpace(s) + if s == "" { + return "", false + } + n, unit, ok := splitNumUnit(s) + if !ok { + return "", false + } + if _, ok := unitToDuration(n, unit); !ok { + return "", false + } + return strconv.Itoa(n) + unit, true +} diff --git a/internal/router/slots_ru_test.go b/internal/router/slots_ru_test.go new file mode 100644 index 0000000..dd624ff --- /dev/null +++ b/internal/router/slots_ru_test.go @@ -0,0 +1,30 @@ +package router + +import "testing" + +// Russian capture must produce the same keys the loop's care rules read, or a +// ru voice test silently writes nothing and the nudges look broken. +func TestDefaultFactParserRU(t *testing.T) { + p := DefaultFactParser{} + cases := []struct { + utterance string + key string + ok bool + }{ + {"выпил воды", "water", true}, + {"попил воду", "water", true}, + {"я поел", "meal", true}, + {"пообедал", "meal", true}, + {"принял душ", "shower", true}, + {"сделал перерыв", "break", true}, + {"немного отдохнул", "break", true}, + {"поспал шесть часов", "sleep", true}, + {"перезапусти nginx", "", false}, // an act, not a fact + } + for _, c := range cases { + k, _, ok := p.Parse(c.utterance) + if ok != c.ok || k != c.key { + t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", c.utterance, k, ok, c.key, c.ok) + } + } +} diff --git a/internal/router/stage0.go b/internal/router/stage0.go new file mode 100644 index 0000000..39b95aa --- /dev/null +++ b/internal/router/stage0.go @@ -0,0 +1,55 @@ +package router + +import ( + "regexp" + "strings" +) + +// Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar +// hits the allowlist directly, skips the classifier (lowest latency — the vosk +// command path). Boring high-frequency acts for free. +// +// A Grammar returns a fully-formed Decision (intent + slots) at confidence 1.0 +// when its pattern matches AND its Build returns ok=true; the router stops the +// cascade. Grammar rules are code, not config — same boundary as rules-as-code +// in the loop. The tool registry populates the verb set at daemon wiring time. +type Grammar struct { + Name string + Pattern *regexp.Regexp // matched against the raw utterance + Build func(match []string) (Decision, bool) +} + +// wakeWordAct — "maven, restart nginx" / "maven restart nginx" → the remainder +// is matched against the act allowlist. A non-match returns ok=false so the +// cascade falls through to the classifier (a wakeword prefix alone doesn't +// guarantee a known command — "maven, i'm tired" is a fact, not an act). +var wakeWordAct = regexp.MustCompile(`(?i)^\s*maven[,: ]+(.+)$`) + +// DefaultGrammars — the wake-word act fast path. The ActMatcher is the same +// allowlist stage-2 act extraction uses (single source of truth for the fn +// list). Returns nil grammars if no matcher is wired (the daemon always wires +// one — the guard is for tests that only exercise the classifier). +func DefaultGrammars(actMatcher ActMatcher) []Grammar { + if actMatcher == nil { + return nil + } + return []Grammar{ + { + Name: "wakeword-act", + Pattern: wakeWordAct, + Build: func(m []string) (Decision, bool) { + rest := strings.TrimSpace(m[1]) + fn, args, ok := actMatcher.Match(rest) + if !ok { + return Decision{}, false // fall through to classifier + } + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + Slots: Slots{Fn: fn, Args: args, HasFn: true, Text: rest}, + }, true + }, + }, + } +} diff --git a/internal/store/facts.go b/internal/store/facts.go new file mode 100644 index 0000000..66a23c4 --- /dev/null +++ b/internal/store/facts.go @@ -0,0 +1,182 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for +// inferences; the caller is responsible for that provenance discipline. +// +// If voidsID is Valid, this row voids (cancels) the referenced fact. The +// caller must have verified voidsID points at an existing fact — we check it +// here too and refuse to write a dangling voids pointer (the audit trail must +// stay coherent). +func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key, value, source string, confidence float64, voidsID sql.NullInt64) (int64, error) { + if confidence <= 0.0 || confidence > 1.0 { + return 0, fmt.Errorf("%w: %f", ErrConfidence, confidence) + } + if voidsID.Valid { + var ok int64 + err := s.db.QueryRowContext(ctx, "SELECT 1 FROM facts WHERE id = ?", voidsID.Int64).Scan(&ok) + if errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("%w: id=%d", ErrVoidsMissing, voidsID.Int64) + } + if err != nil { + return 0, fmt.Errorf("voids lookup: %w", err) + } + } + res, err := s.db.ExecContext(ctx, + `INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`, + ts.UnixMilli(), string(kind), key, value, source, confidence, voidsID) + if err != nil { + return 0, fmt.Errorf("write fact: %w", err) + } + id, _ := res.LastInsertId() + return id, nil +} + +// LatestFact returns the latest non-voided fact for key, or ErrNoFact. +// "Non-voided" = no later row has voids_id pointing at it. We resolve this by +// taking the newest row whose id is not referenced by any voids_id. +func (s *Store) LatestFact(ctx context.Context, key string) (Fact, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, ts, kind, key, value, source, confidence, voids_id + FROM facts + WHERE key = ? + AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL) + ORDER BY ts DESC, id DESC + LIMIT 1`, key) + return scanFact(row) +} + +// RecentFacts — the newest n facts across all keys, for the monitoring dash. +// Includes voided rows (the audit trail is the point: you want to SEE a +// correction, not have it hidden). Newest first. +func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, ts, kind, key, value, source, confidence, voids_id + FROM facts ORDER BY ts DESC, id DESC LIMIT ?`, n) + if err != nil { + return nil, fmt.Errorf("recent facts: %w", err) + } + defer rows.Close() + var out []Fact + for rows.Next() { + f, err := scanFact(rows) + if err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only +// source=poll:healthcheck; a compromised poller can't forge a trigger. Use this +// from rules, not LatestFact, whenever the rule's source contract matters. +func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, ts, kind, key, value, source, confidence, voids_id + FROM facts + WHERE key = ? AND source = ? + AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL) + ORDER BY ts DESC, id DESC + LIMIT 1`, key, source) + return scanFact(row) +} + +// Since returns how long ago the latest non-voided fact for key landed, or +// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from +// the spec — silence on no-data is "shuts up when uncertain". +func (s *Store) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { + f, err := s.LatestFact(ctx, key) + if err != nil { + return 0, err + } + if now.Before(f.Ts) { + return 0, nil + } + return now.Sub(f.Ts), nil +} + +// SetValue is a convenience for writing a structured (json) value at confidence 1.0 +// from a tap. Self-taps (water, meal, shower) land here. Caller supplies source +// like "tap:water"; we serialize the value. +func (s *Store) SetValue(ctx context.Context, kind FactKind, key, source string, value any, ts time.Time) (int64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, fmt.Errorf("marshal value: %w", err) + } + return s.WriteFact(ctx, ts, kind, key, string(raw), source, 1.0, sql.NullInt64{}) +} + +// CorrectValue voids the latest non-voided fact for key and writes a replacement +// in one transaction. Use this for "you corrected a bad fact" feedback — keeps +// the audit trail, supersedes the wrong value. +func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, ts time.Time) (newID int64, err error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + var oldID int64 + err = tx.QueryRowContext(ctx, ` + SELECT id FROM facts + WHERE key = ? + AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL) + ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID) + if errors.Is(err, sql.ErrNoRows) { + // nothing to correct; write as a plain new fact instead — no voiding needed. + } else if err != nil { + return 0, fmt.Errorf("correct: find old: %w", err) + } + raw, merr := json.Marshal(value) + if merr != nil { + return 0, fmt.Errorf("marshal value: %w", merr) + } + var voids sql.NullInt64 + if oldID != 0 { + voids = sql.NullInt64{Int64: oldID, Valid: true} + } + res, err := tx.ExecContext(ctx, + `INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`, + ts.UnixMilli(), string(KindSelf), key, string(raw), source, 1.0, voids) + if err != nil { + return 0, fmt.Errorf("write corrected: %w", err) + } + if err := tx.Commit(); err != nil { + return 0, err + } + newID, _ = res.LastInsertId() + return newID, nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanFact(r rowScanner) (Fact, error) { + var f Fact + var tsMilli int64 + var kind string + var voids sql.NullInt64 + if err := r.Scan(&f.ID, &tsMilli, &kind, &f.Key, &f.Value, &f.Source, &f.Confidence, &voids); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Fact{}, ErrNoFact + } + return Fact{}, err + } + f.Ts = time.UnixMilli(tsMilli).UTC() + f.Kind = FactKind(kind) + f.VoidsID = voids + return f, nil +} \ No newline at end of file diff --git a/internal/store/notes.go b/internal/store/notes.go new file mode 100644 index 0000000..fa52fe6 --- /dev/null +++ b/internal/store/notes.go @@ -0,0 +1,131 @@ +package store + +import ( + "context" + "encoding/binary" + "fmt" + "math" + "sort" + "time" +) + +// Note — a recall/preference item. No predicate reads it (facts are for that); +// query-answering ranks notes by embedding cosine. Score is set by QueryNotes. +type Note struct { + ID int64 + Ts time.Time + Text string + Source string + Score float64 +} + +// WriteNote appends a note with its embedding (stored as a little-endian +// float32 BLOB). Source is provenance (tap:voice, etc.). +func (s *Store) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `INSERT INTO notes (ts, text, embedding, source) VALUES (?,?,?,?)`, + ts.UnixMilli(), text, floatsToBlob(embedding), source) + if err != nil { + return 0, fmt.Errorf("write note: %w", err) + } + id, _ := res.LastInsertId() + return id, nil +} + +// QueryNotes returns the top-k notes by cosine similarity to embedding, highest +// first (ties broken newest-first). Fewer than k notes ⇒ returns what exists. +// +// ponytail: brute-force O(n) cosine over every note each query. Add sqlite-vec +// or an ANN index only when note count or latency actually bites — at personal +// scale (hundreds–thousands) a full scan is sub-millisecond. +func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, ts, text, embedding, source FROM notes`) + if err != nil { + return nil, fmt.Errorf("query notes: %w", err) + } + defer rows.Close() + + var out []Note + for rows.Next() { + var n Note + var tsMilli int64 + var blob []byte + if err := rows.Scan(&n.ID, &tsMilli, &n.Text, &blob, &n.Source); err != nil { + return nil, err + } + n.Ts = time.UnixMilli(tsMilli).UTC() + n.Score = cosine(embedding, blobToFloats(blob)) + out = append(out, n) + } + if err := rows.Err(); err != nil { + return nil, err + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + return out[i].Ts.After(out[j].Ts) // newest breaks ties + }) + if k > 0 && len(out) > k { + out = out[:k] + } + return out, nil +} + +// RecentNotes returns the newest n notes, newest first — a browse view (no +// embedding math; Score stays 0). This is the read surface for /dash: notes +// captured by voice are otherwise only reachable through semantic query. +func (s *Store) RecentNotes(ctx context.Context, n int) ([]Note, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, ts, text, source FROM notes ORDER BY ts DESC LIMIT ?`, n) + if err != nil { + return nil, fmt.Errorf("recent notes: %w", err) + } + defer rows.Close() + var out []Note + for rows.Next() { + var nt Note + var tsMilli int64 + if err := rows.Scan(&nt.ID, &tsMilli, &nt.Text, &nt.Source); err != nil { + return nil, err + } + nt.Ts = time.UnixMilli(tsMilli).UTC() + out = append(out, nt) + } + return out, rows.Err() +} + +// cosine similarity. Embedder vectors are L2-normalized, so this is just the +// dot product — but normalize defensively in case a caller passes a raw vector. +func cosine(a, b []float32) float64 { + if len(a) != len(b) || len(a) == 0 { + return 0 + } + var dot, na, nb float64 + for i := range a { + dot += float64(a[i]) * float64(b[i]) + na += float64(a[i]) * float64(a[i]) + nb += float64(b[i]) * float64(b[i]) + } + if na == 0 || nb == 0 { + return 0 + } + return dot / (math.Sqrt(na) * math.Sqrt(nb)) +} + +func floatsToBlob(v []float32) []byte { + b := make([]byte, 4*len(v)) + for i, f := range v { + binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(f)) + } + return b +} + +func blobToFloats(b []byte) []float32 { + v := make([]float32, len(b)/4) + for i := range v { + v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[4*i:])) + } + return v +} diff --git a/internal/store/notes_test.go b/internal/store/notes_test.go new file mode 100644 index 0000000..068eb40 --- /dev/null +++ b/internal/store/notes_test.go @@ -0,0 +1,38 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestQueryNotesRanksByCosine(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + + now := time.Now() + // 3-dim vectors along distinct axes; query aligns with the "backups" note. + if _, err := st.WriteNote(ctx, now, "prefer backups at 3am", []float32{1, 0, 0}, "tap:voice"); err != nil { + t.Fatal(err) + } + if _, err := st.WriteNote(ctx, now, "gpu driver fixed the flicker", []float32{0, 1, 0}, "tap:voice"); err != nil { + t.Fatal(err) + } + if _, err := st.WriteNote(ctx, now, "cat likes the window", []float32{0, 0, 1}, "tap:voice"); err != nil { + t.Fatal(err) + } + + got, err := st.QueryNotes(ctx, []float32{0.9, 0.1, 0}, 2) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("want 2 notes, got %d", len(got)) + } + if got[0].Text != "prefer backups at 3am" { + t.Errorf("nearest = %q, want backups note (score %.3f)", got[0].Text, got[0].Score) + } + if got[0].Score <= got[1].Score { + t.Errorf("scores not descending: %.3f then %.3f", got[0].Score, got[1].Score) + } +} diff --git a/internal/store/nudges.go b/internal/store/nudges.go new file mode 100644 index 0000000..bbf02ee --- /dev/null +++ b/internal/store/nudges.go @@ -0,0 +1,178 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// Nudge — one proactive send, with deferred outcome. outcomes are: pending | +// acted | snoozed | ignored. outcome_ts set when resolved. the outcome column +// IS the restraint-memory signal — no separate table for the feedback loop. +type Nudge struct { + ID int64 + Ts time.Time // sent ts + Rule string + Channel string + Message string + Outcome string + OutcomeTs sql.NullInt64 +} + +const ( + NudgePending = "pending" + NudgeActed = "acted" + NudgeSnoozed = "snoozed" + NudgeIgnored = "ignored" +) + +var ( + ErrNudgeNotFound = errors.New("store: nudge not found") + ErrNudgeOutcome = errors.New("store: nudge already resolved") +) + +// RecordNudge inserts a pending nudge (sent). returns the id. the loop writes +// one row per proactive send per tick (one nudge per tick, max severity). +func (s *Store) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, + `INSERT INTO nudges (ts, rule, channel, message, outcome) VALUES (?,?,?,?, 'pending')`, + ts.UnixMilli(), rule, channel, message) + if err != nil { + return 0, fmt.Errorf("record nudge: %w", err) + } + id, _ := res.LastInsertId() + return id, nil +} + +// ResolveNudge sets the outcome of a still-pending nudge. acted | snoozed | +// ignored — caller decides by user response (or lack of it). idempotency is +// rejected here: a nudge can only be resolved once, by design — re-resolving +// would silently corrupt the feedback signal (the table IS the learning input). +func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { + switch outcome { + case NudgeActed, NudgeSnoozed, NudgeIgnored: + default: + return fmt.Errorf("store: unknown outcome %q", outcome) + } + res, err := s.db.ExecContext(ctx, + `UPDATE nudges SET outcome = ?, outcome_ts = ? WHERE id = ? AND outcome = 'pending'`, + outcome, ts.UnixMilli(), id) + if err != nil { + return fmt.Errorf("resolve nudge: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + // either no such row, or it was already resolved — distinguish so callers + // can tell a bug from a race. + var cur string + err := s.db.QueryRowContext(ctx, "SELECT outcome FROM nudges WHERE id = ?", id).Scan(&cur) + if errors.Is(err, sql.ErrNoRows) { + return ErrNudgeNotFound + } + if err != nil { + return err + } + return fmt.Errorf("%w: currently %s", ErrNudgeOutcome, cur) + } + return nil +} + +// RecentOutcomes returns the last N outcomes (in reverse chronological order) +// for a given rule — the feedback loop's only input. used to compute +// ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N. +func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT outcome FROM nudges + WHERE rule = ? AND outcome != 'pending' + ORDER BY ts DESC, id DESC LIMIT ?`, rule, n) + if err != nil { + return nil, fmt.Errorf("recent outcomes: %w", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var o string + if err := rows.Scan(&o); err != nil { + return nil, err + } + out = append(out, o) + } + return out, rows.Err() +} + +// UnackedTelegramRules returns rule names that have at least one still-pending +// (un-acked) nudge sent over the telegram channel. the dispatcher's +// RepeatUnacked re-sends these each tick until MarkAcked. +// +// telegram is the sev4-away channel by routing-table construction +// (ChannelsFor sends sev4 away → telegram, nothing else to telegram), so +// `channel='telegram' AND outcome='pending'` already implies sev4 ops-hard — +// no severity column on the nudges table, and none needed: the routing table +// is the authority. grouped by rule so the repeat stream is one-per-rule +// (the ack key IS the rule name). +func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT rule FROM nudges + WHERE channel = 'telegram' AND outcome = 'pending' + GROUP BY rule ORDER BY rule`) + if err != nil { + return nil, fmt.Errorf("unacked telegram rules: %w", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// RecentNudges — the newest n nudges across all rules, with outcomes, for the +// monitoring dash. Newest first. +func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, ts, rule, channel, message, outcome, outcome_ts + FROM nudges ORDER BY ts DESC, id DESC LIMIT ?`, n) + if err != nil { + return nil, fmt.Errorf("recent nudges: %w", err) + } + defer rows.Close() + var out []Nudge + for rows.Next() { + var ng Nudge + var tsMilli int64 + if err := rows.Scan(&ng.ID, &tsMilli, &ng.Rule, &ng.Channel, &ng.Message, &ng.Outcome, &ng.OutcomeTs); err != nil { + return nil, err + } + ng.Ts = time.UnixMilli(tsMilli).UTC() + out = append(out, ng) + } + return out, rows.Err() +} + +// LastNudge — newest nudge for a rule regardless of outcome. used by the gate +// for cooldown enforcement. returns ErrNudgeNotFound if the rule has never fired. +func (s *Store) LastNudge(ctx context.Context, rule string) (Nudge, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, ts, rule, channel, message, outcome, outcome_ts + FROM nudges WHERE rule = ? ORDER BY ts DESC, id DESC LIMIT 1`, rule) + var n Nudge + var tsMilli int64 + var outcomeTs sql.NullInt64 + if err := row.Scan(&n.ID, &tsMilli, &n.Rule, &n.Channel, &n.Message, &n.Outcome, &outcomeTs); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Nudge{}, ErrNudgeNotFound + } + return Nudge{}, err + } + n.Ts = time.UnixMilli(tsMilli).UTC() + if outcomeTs.Valid { + n.OutcomeTs = outcomeTs + } + return n, nil +} \ No newline at end of file diff --git a/internal/store/presence.go b/internal/store/presence.go new file mode 100644 index 0000000..547dca1 --- /dev/null +++ b/internal/store/presence.go @@ -0,0 +1,113 @@ +// Package store — presence. +// +// Presence is a pure function over recent facts, computed each tick; decaying +// confidence over multiple weak signals, never one authoritative source; +// hysteresis (a Schmitt trigger) to stop flapping. +// +// `presenceScore` and `resolve` are deliberately pure: no I/O, no time.Now. +// The loop supplies `now` and the Signal readings (gathered under the state +// lock) as inputs — the functions here are the unit-testable core. +// +// Signals and their hand-tuned weights/τ: +// +// (desk_active, 0.90, 8 min) input = human at keyboard +// (page_heartbeat,0.60, 4 min) a surface you use is open + alive; pings ~30s +// (wg_handshake, 0.40, 20 min) device on tunnel; coarse, three-rooms-away +// +// Combiner is noisy-OR: P = 1 − Π (1 − p_i) with p_i = weight_i · exp(-Δt_i / τ_i). +// Diminishing returns on stacking weak signals, never exceeds 1.0. +// Signals with no fact for that key drop out of the product (not zero). +// +// Hysteresis (Schmitt trigger): +// +// ENTER (away → present): P >= 0.55 +// EXIT (present → away): P < 0.30 +// cold start: away (fail-closed; same instinct as since(key)==null) +// +// Boundaries are HAND-tuned, NOT feedback-tuned — keep presence numbers out of +// the auto-tuner or a weird week drifts you silently invisible. +package store + +import ( + "math" + "time" +) + +// Signal — one presence signal with hand-tuned fresh-weight and decay τ. +type Signal struct { + Key string + Weight float64 + TauMin float64 +} + +// PresenceSignals — the three signals. Iterate in stable order. +var PresenceSignals = []Signal{ + {Key: "desk_active", Weight: 0.90, TauMin: 8.0}, + {Key: "page_heartbeat", Weight: 0.60, TauMin: 4.0}, + {Key: "wg_handshake", Weight: 0.40, TauMin: 20.0}, +} + +// PresenceEnter — the ENTER threshold of the Schmitt trigger. +const PresenceEnter = 0.55 + +// PresenceExit — the EXIT threshold. +const PresenceExit = 0.30 + +// SignalProbe — the loop's reading of one signal at tick time. All that +// presence needs is, per signal key, the timestamp of the latest non-voided +// fact for that key (or nil if no fact — it then drops out of the product). +// +// The loop fills this by calling LatestFact for each Signal.Key under the +// state lock; presence itself does no I/O. +type SignalProbe struct { + Key string + LastTs *time.Time // nil → no data; SignalProbe drops out of the product +} + +// PresenceScore — pure. returns the noisy-OR combined score in [0,1). +// If every signal has no data the product collapses to 1.0 and score = 0. +// (Cold boot: away, by construction.) +func PresenceScore(now time.Time, probes []SignalProbe) float64 { + var pAway = 1.0 + byKey := make(map[string]*time.Time, len(probes)) + for i := range probes { + byKey[probes[i].Key] = probes[i].LastTs + } + for _, s := range PresenceSignals { + last, ok := byKey[s.Key] + if !ok || last == nil { + continue // no data → drops out of the product + } + dtMin := now.Sub(*last).Minutes() + if dtMin < 0 { + dtMin = 0 // clock skew shouldn't gift us a p > weight + } + p := s.Weight * math.Exp(-dtMin/s.TauMin) + pAway *= (1.0 - p) + } + return 1.0 - pAway +} + +// Resolve — the Schmitt trigger. Pure. +// +// last == present: stay present while P >= 0.30; flip to away below. +// last == away: stay away while P < 0.55; enter present at 0.55 or above. +// +// Cold start (no prior bucket) → away, fail-closed. +func Resolve(score float64, last Bucket) Bucket { + if last == Present { + if score < PresenceExit { + return Away + } + return Present + } + // last == Away or cold-start + if score >= PresenceEnter { + return Present + } + return Away +} + +// ResolveCold — convenience for the very first tick after daemon cold-start. +// presence_state has no row; we begin as Away, the fail-closed outcome. +func ResolveCold(score float64) Bucket { return Resolve(score, Away) } \ No newline at end of file diff --git a/internal/store/presence_state.go b/internal/store/presence_state.go new file mode 100644 index 0000000..85fac06 --- /dev/null +++ b/internal/store/presence_state.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// LoadPresenceState — returns the singleton hysteresis row, or a cold-start +// default (away, score 0) when no row exists yet. fail-closed. +func (s *Store) LoadPresenceState(ctx context.Context) (bucket Bucket, score float64, updated time.Time, err error) { + var b string + var updMilli int64 + row := s.db.QueryRowContext(ctx, + `SELECT last_bucket, last_score, updated_ts FROM presence_state WHERE id = 1`) + err = row.Scan(&b, &score, &updMilli) + if errors.Is(err, sql.ErrNoRows) { + return Away, 0.0, time.Time{}, nil + } + if err != nil { + return "", 0, time.Time{}, fmt.Errorf("load presence_state: %w", err) + } + return Bucket(b), score, time.UnixMilli(updMilli).UTC(), nil +} + +// SavePresenceState — upsert the singleton row. called every tick after resolve hysteresis. +func (s *Store) SavePresenceState(ctx context.Context, bucket Bucket, score float64, now time.Time) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO presence_state (id, last_bucket, last_score, updated_ts) VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET last_bucket = excluded.last_bucket, + last_score = excluded.last_score, + updated_ts = excluded.updated_ts`, + string(bucket), score, now.UnixMilli()) + if err != nil { + return fmt.Errorf("save presence_state: %w", err) + } + return nil +} + +// PresenceProbes — gather the latest non-voided fact ts per signal key. +// Returns a SignalProbe slice aligned with PresenceSignals. nil LastTs where +// the key has no data. This is the only I/O presence needs; the actual score +// computation happens in the pure PresenceScore() function. +func (s *Store) PresenceProbes(ctx context.Context) ([]SignalProbe, error) { + probes := make([]SignalProbe, 0, len(PresenceSignals)) + for _, sig := range PresenceSignals { + f, err := s.LatestFact(ctx, sig.Key) + if errors.Is(err, ErrNoFact) { + probes = append(probes, SignalProbe{Key: sig.Key, LastTs: nil}) + continue + } + if err != nil { + return nil, err + } + t := f.Ts + probes = append(probes, SignalProbe{Key: sig.Key, LastTs: &t}) + } + return probes, nil +} \ No newline at end of file diff --git a/internal/store/presence_test.go b/internal/store/presence_test.go new file mode 100644 index 0000000..f15a203 --- /dev/null +++ b/internal/store/presence_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "math" + "testing" + "time" +) + +func refTime() time.Time { + return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) +} + +func TestPresenceScoreColdBoot(t *testing.T) { + // No probes at all. Product collapses to 1.0; score = 0. Fail-closed. + got := PresenceScore(refTime(), nil) + if math.Abs(got-0.0) > 1e-9 { + t.Fatalf("cold boot: want 0, got %f", got) + } + if b := ResolveCold(got); b != Away { + t.Fatalf("cold boot: want Away, got %s", b) + } +} + +func TestPresenceScoreAllSignalsDroppedOnNoFact(t *testing.T) { + // Every probe present but LastTs nil → drops out → score 0. + probes := []SignalProbe{ + {Key: "desk_active", LastTs: nil}, + {Key: "page_heartbeat", LastTs: nil}, + {Key: "wg_handshake", LastTs: nil}, + } + if got := PresenceScore(refTime(), probes); got != 0.0 { + t.Fatalf("all-nil probes: want 0, got %f", got) + } +} + +func TestPresenceScoreAtDeskTyping(t *testing.T) { + // Fresh (<1s) desk_active + heartbeat → very high. Spec table: ~0.90. + now := refTime() + justNow := now.Add(-1 * time.Second) + probes := []SignalProbe{ + {Key: "desk_active", LastTs: &justNow}, + {Key: "page_heartbeat", LastTs: &justNow}, + } + got := PresenceScore(now, probes) + if got < 0.85 { + t.Fatalf("at desk typing: want >= 0.85, got %f", got) + } + if b := Resolve(got, Away); b != Present { + t.Fatalf("away→present at P=%f: want Present, got %s", got, b) + } +} + +func TestPresenceScoreFreshWGAloneCannotEnter(t *testing.T) { + // A lone fresh wg (weight 0.40) is below ENTER (0.55). Cannot declare present. + now := refTime() + justNow := now.Add(-1 * time.Second) + probes := []SignalProbe{{Key: "wg_handshake", LastTs: &justNow}} + got := PresenceScore(now, probes) + if got > PresenceEnter { + t.Fatalf("fresh wg alone: want <= ENTER, got %f", got) + } + if b := Resolve(got, Away); b != Away { + t.Fatalf("wg alone enters present: want Away, got %s (P=%f)", b, got) + } +} + +func TestPresenceDeskOnlyDecaysToAway(t *testing.T) { + // Desk-only, zero input for ~9min → <0.30 → Away. Spec table row 3. + now := refTime() + deskThen := now.Add(-9 * time.Minute) + probes := []SignalProbe{{Key: "desk_active", LastTs: &deskThen}} + got := PresenceScore(now, probes) + if got >= PresenceExit { + t.Fatalf("9min-stale desk-only: want < EXIT(%f), got %f", PresenceExit, got) + } + if b := Resolve(got, Present); b != Away { + t.Fatalf("present→away at P=%f (9min stale desk): want Away, got %s", got, b) + } +} + +func TestPresenceHysteresisHoldsWhileDecaying(t *testing.T) { + // Already present. WG alone decayed—but a fresh-ish wg_holding(0.40) while + // present must HOLD present even though it could not ENTER present. Band. + now := refTime() + wgThen := now.Add(-1 * time.Minute) // dt=1min; p ≈ 0.40 * exp(-1/20) ≈ 0.381 + probes := []SignalProbe{{Key: "wg_handshake", LastTs: &wgThen}} + got := PresenceScore(now, probes) + // Sanity: above EXIT, below ENTER — sitting in the hold band. + if got <= PresenceExit || got >= PresenceEnter { + t.Fatalf("decaying wg in hold band: want (%f, %f), got %f", PresenceExit, PresenceEnter, got) + } + if b := Resolve(got, Present); b != Present { + t.Fatalf("hold during decay: want Present, got %s (P=%f, last=Present)", b, got) + } + // But the exact same reading from Away must NOT enter. + if b := Resolve(got, Away); b != Away { + t.Fatalf("from Away the same P must not enter: want Away, got %s (P=%f)", b, got) + } +} + +func TestPresenceCouchPhoneOpen(t *testing.T) { + // Couch, phone page open, no desk → ~0.60, just present (≥0.55). + now := refTime() + justNow := now.Add(-1 * time.Second) + probes := []SignalProbe{{Key: "page_heartbeat", LastTs: &justNow}} + got := PresenceScore(now, probes) + // weight 0.60, decay≈0 → near 0.60. Should ENTER from away. + if got < PresenceEnter { + t.Fatalf("couch+phone: want >= ENTER(%f), got %f", PresenceEnter, got) + } + if b := Resolve(got, Away); b != Present { + t.Fatalf("couch+phone enters: want Present, got %s (P=%f)", b, got) + } +} + +func TestPresenceUnequalWeightsSumDiminishingReturns(t *testing.T) { + // Noisy-OR: stacking adds diminishing returns; never exceeds 1.0. + // At t=0 all three fresh: P = 1 - (0.1)(0.4)(0.6) = 1 - 0.024 = 0.976. + now := refTime() + t0 := now + probes := []SignalProbe{ + {Key: "desk_active", LastTs: &t0}, + {Key: "page_heartbeat", LastTs: &t0}, + {Key: "wg_handshake", LastTs: &t0}, + } + got := PresenceScore(now, probes) + if got >= 1.0 { + t.Fatalf("stacked fresh: want < 1.0, got %f", got) + } + if math.Abs(got-0.976) > 1e-3 { + t.Fatalf("stacked fresh: want ≈ 0.976, got %f", got) + } +} + +func TestNegativeClockSkewClampsToFresh(t *testing.T) { + // last is in the future relative to now (clock skew). Should clamp dt to 0 + // rather than gifting a p > weight via exp(-negative/τ) > 1. + now := refTime() + future := now.Add(5 * time.Minute) + probes := []SignalProbe{{Key: "desk_active", LastTs: &future}} + got := PresenceScore(now, probes) + if got > 0.90+1e-9 { + t.Fatalf("clock skew clamps: want <= weight(0.90), got %f", got) + } +} \ No newline at end of file diff --git a/internal/store/reminders.go b/internal/store/reminders.go new file mode 100644 index 0000000..a8f296f --- /dev/null +++ b/internal/store/reminders.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// Reminder — user-stated future intent. fires once. relative→absolute happens +// at capture ("in 4h" → store now+4h, never the string). +type Reminder struct { + ID int64 + CreatedTs time.Time + FireTs time.Time + Payload string // raw json + Status string // pending | fired | cancelled +} + +var ( + ErrReminderNotFound = errors.New("store: reminder not found") + ErrReminderState = errors.New("store: reminder not in a mutable state") +) + +// CreateReminder persists a reminder with a resolved absolute fire time. +// The caller (router/capture path) MUST have already converted "in 4h" → now+4h. +// We do not accept strings here. +func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { + now := time.Now().UTC() + res, err := s.db.ExecContext(ctx, + `INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`, + now.UnixMilli(), fire.UnixMilli(), payload) + if err != nil { + return 0, fmt.Errorf("create reminder: %w", err) + } + id, _ := res.LastInsertId() + return id, nil +} + +// DueReminders returns pending reminders with fire_ts <= now, oldest first. +// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`. +func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, created_ts, fire_ts, payload, status + FROM reminders + WHERE status = 'pending' AND fire_ts <= ? + ORDER BY fire_ts ASC`, now.UnixMilli()) + if err != nil { + return nil, fmt.Errorf("due reminders: %w", err) + } + defer rows.Close() + var out []Reminder + for rows.Next() { + var r Reminder + var created, fire int64 + if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil { + return nil, err + } + r.CreatedTs = time.UnixMilli(created).UTC() + r.FireTs = time.UnixMilli(fire).UTC() + out = append(out, r) + } + return out, rows.Err() +} + +// MarkReminder sets a reminder's status. Only valid transitions: pending→fired, +// pending→cancelled. Anything else is a programming error. +func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error { + if status != "fired" && status != "cancelled" { + return fmt.Errorf("%w: %s", ErrReminderState, status) + } + // pending → fired|cancelled only. + var current string + err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t) + if errors.Is(err, sql.ErrNoRows) { + return ErrReminderNotFound + } + if err != nil { + return err + } + if current != "pending" { + return fmt.Errorf("%w: currently %s", ErrReminderState, current) + } + _, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id) + return err +} \ No newline at end of file diff --git a/internal/store/schema.sql b/internal/store/schema.sql new file mode 100644 index 0000000..71bc019 --- /dev/null +++ b/internal/store/schema.sql @@ -0,0 +1,84 @@ +-- maven core schema — append-only, three shapes. +-- a wrong fact is superseded, never overwritten. current value = latest non-voided row for a key. + +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; +PRAGMA foreign_keys=ON; +PRAGMA busy_timeout=5000; + +-- facts — substrate, all observations (self + env + config). +-- ts = valid-time (true-as-of), not insert-time. +-- source: tap:* | infer:* | poll:* | ambient | promote | feedback +-- confidence = 1.0 for taps only; <1 for inferred. +-- voids_id points at the fact this one cancels (correction). +CREATE TABLE IF NOT EXISTS facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, -- unix epoch millis (valid-time) + kind TEXT NOT NULL CHECK (kind IN ('self','env','config')), + key TEXT NOT NULL, + value TEXT NOT NULL, -- json if structured + source TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence > 0.0 AND confidence <= 1.0), + voids_id INTEGER REFERENCES facts(id) +); +CREATE INDEX IF NOT EXISTS idx_facts_key_ts ON facts (key, ts DESC); +CREATE INDEX IF NOT EXISTS idx_facts_voids ON facts (voids_id); + +-- reminders — user intent, fires once. +-- relative→absolute happens at capture ("in 4h" → store now+4h, never the string). +CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_ts INTEGER NOT NULL, -- epoch millis + fire_ts INTEGER NOT NULL, -- epoch millis + payload TEXT NOT NULL, -- json + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','fired','cancelled')) +); +CREATE INDEX IF NOT EXISTS idx_reminders_fire ON reminders (fire_ts) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders (status); + +-- nudges — every proactive send + outcome. this table IS the restraint memory. +CREATE TABLE IF NOT EXISTS nudges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, -- sent ts, epoch millis + rule TEXT NOT NULL, + channel TEXT NOT NULL, + message TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored')), + outcome_ts INTEGER +); +CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC); +CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome); + +-- notes — semantic recall/preference store. no predicate reads these (that's +-- what facts are for); query-answering does brute-force cosine over embedding. +-- embedding is a little-endian float32 BLOB. no index — personal-scale scan. +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, -- epoch millis + text TEXT NOT NULL, + embedding BLOB NOT NULL, -- little-endian float32[] + source TEXT NOT NULL +); + +-- tools — the act allowlist maven executes. proposed rows are scaffolds maven +-- drafts when she hits an act she can't run; enabling (filling cmd + flipping +-- status) is a human act through an authed surface, NEVER the voice path. a +-- proposed row drives nothing — the executor only runs status='enabled' rows. +CREATE TABLE IF NOT EXISTS tools ( + name TEXT PRIMARY KEY, -- the spoken verb ("restart") + cmd TEXT NOT NULL DEFAULT '[]', -- json argv prefix; args appended at run + destructive INTEGER NOT NULL DEFAULT 0, -- 1 ⇒ needs a confirm turn before it runs + status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','enabled')), + utterance TEXT NOT NULL DEFAULT '', -- the utterance that scaffolded a proposal (provenance) + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); + +-- presence_state — the only stateful bit of presence (a pure function otherwise). +-- hysteresis bucket; updated each tick after resolve(). +CREATE TABLE IF NOT EXISTS presence_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row + last_bucket TEXT NOT NULL CHECK (last_bucket IN ('present','away')), + last_score REAL NOT NULL, + updated_ts INTEGER NOT NULL +); \ No newline at end of file diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..5085403 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,96 @@ +// Package store is maven's persistent state layer. +// +// The layer is append-only: a wrong fact is superseded, not overwritten. +// Current value for a key = the latest non-voided fact row. +// +// Schema lives in schema.sql and is applied idempotently on Open. +package store + +import ( + "context" + "database/sql" + _ "embed" // schema.sql + "errors" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +// FactKind — self | env | config. The loop only evaluates predicates against +// `self` and `config` rows; `env` feeds env predicates (calendar/weather/health). +type FactKind string + +const ( + KindSelf FactKind = "self" + KindEnv FactKind = "env" + KindConfig FactKind = "config" +) + +// Fact — one observation. ts is valid-time (true-as-of), not insert-time. +type Fact struct { + ID int64 + Ts time.Time + Kind FactKind + Key string + Value string // raw json if structured + Source string // tap:* | infer:* | poll:* | ambient | promote | feedback + Confidence float64 + VoidsID sql.NullInt64 +} + +// Bucket — presence hysteresis state. +type Bucket string + +const ( + Present Bucket = "present" + Away Bucket = "away" +) + +// Store is the persistent state layer. All writes are append-only; nothing +// here performs an UPDATE of a fact value (status-flips on reminders/nudges +// are the documented exceptions — they mutate small state-machine columns). +type Store struct { + db *sql.DB +} + +// Open opens or creates the sqlite database at path and applies the schema. +// Pragmas (WAL, NORMAL, FK on, busy_timeout) are set in schema.sql and re-applied +// per connection on open via the modernc driver DSN. +func Open(ctx context.Context, path string) (*Store, error) { + // `_pragma=busy_timeout(5000)` etc. embed cleanly; schema.sql sets them too. + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + // single writer expected; the daemon is the only process touching the db. + db.SetMaxOpenConns(1) + if _, err := db.ExecContext(ctx, schemaSQL); err != nil { + _ = db.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close releases the database handle. +func (s *Store) Close() error { return s.db.Close() } + +// DB exposes the underlying handle for internal read-only snapshots. +// Used by the loop to take a consistent read under a single transaction. +// Modules never receive this handle — core mediates. +func (s *Store) DB(ctx context.Context) (*sql.Tx, error) { + return s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) +} + +var ( + // ErrNoFact — no non-voided row exists for this key. + ErrNoFact = errors.New("store: no fact for key") + // ErrConfidence — a write attempted to use a confidence outside (0,1.0]. + ErrConfidence = errors.New("store: confidence must be in (0.0, 1.0]") + // ErrVoidsMissing — a correction pointed at a nonexistent fact. + ErrVoidsMissing = errors.New("store: voids_id does not reference an existing fact") +) \ No newline at end of file diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..c374a54 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,290 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "maven_test.db") + s, err := Open(context.Background(), path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestWriteAndLatestFact(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + // ts is stored as unix millis; round to ms to match the roundtrip. + now := time.Now().UTC().Truncate(time.Millisecond) + if _, err := s.SetValue(ctx, KindSelf, "water", "tap:water", map[string]int{"ml": 250}, now); err != nil { + t.Fatalf("SetValue: %v", err) + } + f, err := s.LatestFact(ctx, "water") + if err != nil { + t.Fatalf("LatestFact: %v", err) + } + if f.Key != "water" || f.Source != "tap:water" || f.Confidence != 1.0 { + t.Fatalf("got %+v", f) + } + var v map[string]int + if err := json.Unmarshal([]byte(f.Value), &v); err != nil || v["ml"] != 250 { + t.Fatalf("value roundtrip: %v (%s)", err, f.Value) + } + if !f.Ts.Equal(now) { + t.Fatalf("ts roundtrip: want %s got %s", now, f.Ts) + } +} + +func TestAppendOnlySupersedeNotOverwrite(t *testing.T) { + // Two facts for the same key: LatestFact returns the newer one, the older + // row is still there (append-only audit trail). + s := newTestStore(t) + ctx := context.Background() + t1 := time.Now().UTC().Add(-5 * time.Minute) + t2 := time.Now().UTC() + if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "pasta", t1); err != nil { + t.Fatal(err) + } + if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "salad", t2); err != nil { + t.Fatal(err) + } + f, err := s.LatestFact(ctx, "meal") + if err != nil { + t.Fatal(err) + } + if f.Value != `"salad"` { + t.Fatalf("latest value: want salad, got %s", f.Value) + } + // audit trail still has both rows + var n int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM facts WHERE key = 'meal'").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("append-only: want 2 rows, got %d", n) + } +} + +func TestCorrectValueVoidsAndSupersedes(t *testing.T) { + // User corrects a bad fact → latest row voids the previous; LatestFact + // now returns the corrected one; voids_id points back at the old row. + s := newTestStore(t) + ctx := context.Background() + old := time.Now().UTC().Add(-2 * time.Minute) + if _, err := s.SetValue(ctx, KindSelf, "sleep", "tap:sleep", "8h", old); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + newID, err := s.CorrectValue(ctx, "sleep", "feedback", "6h", now) + if err != nil { + t.Fatalf("CorrectValue: %v", err) + } + f, err := s.LatestFact(ctx, "sleep") + if err != nil { + t.Fatal(err) + } + if f.ID != newID { + t.Fatalf("latest should be corrected row: want id=%d got=%d", newID, f.ID) + } + if !f.VoidsID.Valid || f.VoidsID.Int64 == 0 { + t.Fatalf("corrected row should void the old one: %+v", f.VoidsID) + } + // old row should NOT come back from LatestFact + if f.Value != `"6h"` { + t.Fatalf("value: want 6h got %s", f.Value) + } + // audit trail: 2 rows; one of them voids the other + var voidedCount int + if err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM facts WHERE key='sleep' AND voids_id IS NOT NULL").Scan(&voidedCount); err != nil { + t.Fatal(err) + } + if voidedCount != 1 { + t.Fatalf("exactly one voiding row, got %d", voidedCount) + } +} + +func TestSinceNoFactReturnsErrNoFact(t *testing.T) { + // silence on no-data = "shuts up when uncertain" + s := newTestStore(t) + ctx := context.Background() + if _, err := s.Since(ctx, "never_observed", time.Now().UTC()); !errors.Is(err, ErrNoFact) { + t.Fatalf("Since on missing key: want ErrNoFact, got %v", err) + } +} + +func TestConfidenceBounds(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + for _, c := range []float64{0.0, -0.1, 1.5} { + if _, err := s.WriteFact(ctx, time.Now().UTC(), KindSelf, "x", "v", "tap", c, sql.NullInt64{}); !errors.Is(err, ErrConfidence) { + t.Fatalf("confidence %f: want ErrConfidence, got %v", c, err) + } + } +} + +func TestProvenanceScopedLookup(t *testing.T) { + // A compensating fact from a non-authoritative source should NOT override the + // authoritative one when the rule uses LatestFactBySource. + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "poll:healthcheck", "down", now); err != nil { + t.Fatal(err) + } + if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "ambient", "down", now.Add(time.Second)); err != nil { + t.Fatal(err) + } + // unscoped latest = ambient (newer) + if f, _ := s.LatestFact(ctx, "service_nginx"); f.Source != "ambient" { + t.Fatalf("LatestFact: want ambient, got %s", f.Source) + } + // source-scoped = poll:healthcheck + f, err := s.LatestFactBySource(ctx, "service_nginx", "poll:healthcheck") + if err != nil || f.Source != "poll:healthcheck" { + t.Fatalf("LatestFactBySource: want poll:healthcheck, got %+v / %v", f, err) + } + // missing source → ErrNoFact (a compromised poller can't forge a trigger) + if _, err := s.LatestFactBySource(ctx, "service_nginx", "poll:bogus"); !errors.Is(err, ErrNoFact) { + t.Fatalf("bogus source: want ErrNoFact, got %v", err) + } +} + +func TestRemindersRelativeResolvedAtCapture(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + // capture path (router) converts "in 4h" → absolute. store just takes fire_ts. + fire := time.Now().UTC().Add(4 * time.Hour) + id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`) + if err != nil { + t.Fatal(err) + } + // not due yet + if due, err := s.DueReminders(ctx, time.Now().UTC()); err != nil || len(due) != 0 { + t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err) + } + // due once past fire_ts + if due, err := s.DueReminders(ctx, fire.Add(time.Second)); err != nil || len(due) != 1 || due[0].ID != id { + t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err) + } + // mark fired → not due again (fires once) + if err := s.MarkReminder(ctx, id, "fired"); err != nil { + t.Fatalf("MarkReminder: %v", err) + } + if due, err := s.DueReminders(ctx, fire.Add(2*time.Second)); err != nil || len(due) != 0 { + t.Fatalf("after fired: want 0 due, got %d", len(due)) + } + // can't fire again + if err := s.MarkReminder(ctx, id, "fired"); !errors.Is(err, ErrReminderState) { + t.Fatalf("re-fire: want ErrReminderState, got %v", err) + } +} + +func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + id, err := s.RecordNudge(ctx, "water", "voice", "drink some water", now) + if err != nil { + t.Fatal(err) + } + // pending state: not yet in outcomes + if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 0 { + t.Fatalf("pending should not count as outcome: got %v", os) + } + // resolve once → ok + if err := s.ResolveNudge(ctx, id, "acted", now.Add(time.Minute)); err != nil { + t.Fatalf("ResolveNudge: %v", err) + } + // re-resolve rejected — feedback signal must not be silently corruptable + if err := s.ResolveNudge(ctx, id, "ignored", now.Add(2*time.Minute)); !errors.Is(err, ErrNudgeOutcome) { + t.Fatalf("re-resolve: want ErrNudgeOutcome, got %v", err) + } + // outcomes feed back: 1 acted in last N + if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 1 || os[0] != "acted" { + t.Fatalf("outcomes: want [acted], got %v", os) + } +} + +func TestUnackedTelegramRules(t *testing.T) { + // the dispatcher's RepeatUnacked reads this to know which sev4 telegram + // sends are still un-acked. telegram is the sev4-away channel by routing + // construction, so channel+outcome is the full filter. + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + // none → empty (not nil-iff-not-set is fine; empty slice is the contract) + if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 0 { + t.Fatalf("cold: want [] err=nil, got %v %v", got, err) + } + + // a pending telegram nudge → its rule appears. + if _, err := s.RecordNudge(ctx, "service_down", "telegram", "homesrv down", now); err != nil { + t.Fatal(err) + } + got, err := s.UnackedTelegramRules(ctx) + if err != nil || len(got) != 1 || got[0] != "service_down" { + t.Fatalf("after send: want [service_down], got %v %v", got, err) + } + + // a pending nudge on a different channel (voice) must NOT appear — the + // repeat-til-ack path is telegram-only. + if _, err := s.RecordNudge(ctx, "water", "voice", "drink water", now); err != nil { + t.Fatal(err) + } + if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" { + t.Fatalf("voice must not appear: want [service_down], got %v %v", got, err) + } + + // a second pending telegram nudge for a different rule → both appear, + // sorted by rule name (deterministic for the daemon). + if _, err := s.RecordNudge(ctx, "disk_full", "telegram", "disk 99%", now); err != nil { + t.Fatal(err) + } + if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 2 || got[0] != "disk_full" || got[1] != "service_down" { + t.Fatalf("two rules: want [disk_full service_down], got %v %v", got, err) + } + + // resolving one (the user acked disk_full) → only the other remains. + // RecordNudge returned disk_full's id; re-query to get it here. + id, err := s.LastNudge(ctx, "disk_full") + if err != nil { + t.Fatalf("LastNudge disk_full: %v", err) + } + if err := s.ResolveNudge(ctx, id.ID, "acted", now.Add(time.Minute)); err != nil { + t.Fatalf("ResolveNudge: %v", err) + } + if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" { + t.Fatalf("after ack disk_full: want [service_down], got %v %v", got, err) + } +} + +func TestPresenceStateSingletonRoundtrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + // cold start → away, 0 + b, score, _, err := s.LoadPresenceState(ctx) + if err != nil || b != Away || score != 0 { + t.Fatalf("cold: want Away/0, got %s/%f (%v)", b, score, err) + } + // save → reload + now := time.Now().UTC() + if err := s.SavePresenceState(ctx, Present, 0.83, now); err != nil { + t.Fatal(err) + } + if b, score, _, err := s.LoadPresenceState(ctx); err != nil || b != Present || score != 0.83 { + t.Fatalf("after save: want Present/0.83, got %s/%f (%v)", b, score, err) + } +} \ No newline at end of file diff --git a/internal/store/tools.go b/internal/store/tools.go new file mode 100644 index 0000000..b391965 --- /dev/null +++ b/internal/store/tools.go @@ -0,0 +1,133 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Tool — one act in the allowlist. Cmd is the fixed argv prefix run with the +// utterance's args appended (no shell). Status 'proposed' is a scaffold that +// drives nothing; 'enabled' is the human-flipped, runnable form. +type Tool struct { + Name string + Cmd []string + Destructive bool + Status string // proposed | enabled + Utterance string // provenance: the utterance that scaffolded a proposal + CreatedTs time.Time + UpdatedTs time.Time +} + +var ( + // ErrToolNotFound — no tool row with this name. + ErrToolNotFound = errors.New("store: tool not found") + // ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something). + ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd") +) + +// ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance) +// if no row for name exists yet. Returns true when a new proposal was written, +// false when a row (proposed or enabled) already existed. maven calls this when +// she classifies an act whose verb isn't on the enabled allowlist — she drafts +// the registration; a human enables it. Never overwrites an enabled tool. +func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts) + VALUES (?, '[]', 0, 'proposed', ?, ?, ?) + ON CONFLICT(name) DO NOTHING`, + name, utterance, ts.UnixMilli(), ts.UnixMilli()) + if err != nil { + return false, fmt.Errorf("propose tool: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the +// human "enable" act (the authed surface calls it); it upserts so enabling a +// name that was never proposed still works. An empty cmd is refused — an +// enabled tool that runs nothing is a footgun, not a tool. +func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error { + if len(cmd) == 0 { + return ErrToolCmd + } + raw, err := json.Marshal(cmd) + if err != nil { + return fmt.Errorf("enable tool: %w", err) + } + d := 0 + if destructive { + d = 1 + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts) + VALUES (?, ?, ?, 'enabled', '', ?, ?) + ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive, + status='enabled', updated_ts=excluded.updated_ts`, + name, string(raw), d, ts.UnixMilli(), ts.UnixMilli()) + if err != nil { + return fmt.Errorf("enable tool: %w", err) + } + return nil +} + +// LookupTool returns the tool by name. ErrToolNotFound when absent. +func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts + FROM tools WHERE name = ?`, name) + t, err := scanTool(row) + if errors.Is(err, sql.ErrNoRows) { + return Tool{}, ErrToolNotFound + } + return t, err +} + +// ListTools returns tools filtered by status ("" ⇒ all), name-sorted. +func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) { + q := `SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools` + var args []any + if status != "" { + q += ` WHERE status = ?` + args = append(args, status) + } + q += ` ORDER BY name ASC` + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list tools: %w", err) + } + defer rows.Close() + var out []Tool + for rows.Next() { + t, err := scanTool(rows) + if err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// scanner is the shared shape of *sql.Row and *sql.Rows. +type scanner interface{ Scan(...any) error } + +func scanTool(sc scanner) (Tool, error) { + var t Tool + var cmdJSON string + var d int + var created, updated int64 + if err := sc.Scan(&t.Name, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil { + return Tool{}, err + } + if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil { + return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err) + } + t.Destructive = d != 0 + t.CreatedTs = time.UnixMilli(created).UTC() + t.UpdatedTs = time.UnixMilli(updated).UTC() + return t, nil +} diff --git a/internal/stt/stt.go b/internal/stt/stt.go new file mode 100644 index 0000000..11cc107 --- /dev/null +++ b/internal/stt/stt.go @@ -0,0 +1,112 @@ +// Package stt is maven's speech-to-text seam. +// +// Core calls exactly one method: `Transcribe(ctx, audio.Audio) (text, error)`. +// The interface is the swap seam: +// +// - Stub: deterministic, no model. Generates a fixed phrase per utterance +// so the daemon's reactive loop is exercisable end-to-end without any +// weights on disk (the "no models on disk" floor). The Stub hashes the +// audio bytes for a tiny bit of variation per utterance; the *content* +// of the audio doesn't matter, only the wire shape round-trips. +// +// - Remote: dials a worker module process at a unix socket (cmd/mavsttd +// today; a faster-whisper / vosk-backed process when models land). The +// swap is one constructor change at the daemon seam; the boundary is the +// same. +// +// The Daemon picks the implementation from config. With no models on disk, +// it wires Stub (the audio path is "live" end to end, the transcribe step +// returns a canned string the router + action path operate on); with a +// worker socket configured, it wires Remote. +// +// Per spec (maven.md § stt/tts): faster-whisper small/int8 is the production +// stt; vosk-ru runs on the client (wake-word + stage-0 grammar), not here. +// The server-side stt module is the heavy multilingual path; vosk's +// stage-0 grammar hits the router directly via the client's stage-0 surface +// and never crosses this seam — that path is post-MVP (the client doesn't +// exist yet). Today's Remote + Stub both return plain text the router +// classifies. +package stt + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/worker" +) + +// Transcriber — the speech-to-text contract. One method; one input shape; +// the output is plain text the router classifies. The implementation is +// impure (calls a model or a worker process); the Stub is pure (hash + +// template) and exists so the daemon runs end-to-end without a model. +type Transcriber interface { + Transcribe(ctx context.Context, a audio.Audio) (text string, confidence float64, err error) +} + +// Stub — the deterministic, no-model floor. Returns a canned phrase derived +// from a hash of the audio bytes so two utterances differ on the wire but +// both stay stable across runs (a test fixture is reproducible). The +// returned text is shaped like a real utterance ("maven, отметь что я выпил +// воды" or "maven, remind me in 4 hours to stretch") so the router's +// cascade has something realistic to chew on during end-to-end exercises. +type Stub struct{} + +// NewStub builds the floor transcriber. No config — the Stub is stateless. +func NewStub() *Stub { return &Stub{} } + +// stubPhrases — the canned outputs the Stub rotates through. Each is a +// plausible utterance shape the router's stage-0 grammar or stage-1 +// classifier will route to a different intent (act / reminder / fact / note +// / query). The hash picks the phrase per utterance deterministically. +var stubPhrases = []string{ + "maven, отметь что я выпил воды", // fact (water tap → fact table) + "maven, напомни через 4 часа размяться", // reminder (→ reminders table) + "maven, restart nginx", // act (→ stage-0 grammar hit) + "maven, что у меня сегодня по календарю", // query (→ slm read-path, deferred) + "note: idea — staggered cooldown by time of day", // note (→ chroma, deferred) + "slept 6h, fan noise wrecked it", // compound capture (open spec; routes as fact today) +} + +// Transcribe returns one of stubPhrases, indexed by a hash of the audio +// bytes. Empty audio ⇒ the first phrase (so a misconfigured client still +// gets a round-trip). Confidence is always 1.0 — the Stub is "certain" by +// construction, the router's confidence gate exercises on the classifier +// stage, not here. +func (s *Stub) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) { + if len(a.Bytes) == 0 { + return stubPhrases[0], 1.0, nil + } + h := sha256.Sum256(a.Bytes) + idx := binary.BigEndian.Uint32(h[:4]) % uint32(len(stubPhrases)) + return stubPhrases[idx], 1.0, nil +} + +// Remote — the worker-backed Transcriber. Holds a worker.Client that dials +// the stt module's unix socket. The daemon constructs one when its config +// points at a worker socket; otherwise it uses the Stub. +type Remote struct { + c *worker.Client + lang string +} + +// NewRemote builds a Remote Transcriber. lang is the default language hint +// passed to the worker for every call ("ru"/"en"/"mixed"); the worker may +// override per-call but the daemon doesn't today. +func NewRemote(c *worker.Client, lang string) *Remote { + return &Remote{c: c, lang: lang} +} + +// Transcribe forwards to the worker module. A worker-side error (unknown +// method, bad params, internal panic-recovered) is returned wrapped so the +// daemon can log + continue — a transient stt fault doesn't kill the +// reactive path; the user gets a "sorry, didn't catch that" reply. +func (r *Remote) Transcribe(ctx context.Context, a audio.Audio) (string, float64, error) { + resp, err := r.c.Transcribe(ctx, worker.TranscribeReq{Audio: a, Lang: r.lang}) + if err != nil { + return "", 0, fmt.Errorf("stt: transcribe: %w", err) + } + return resp.Text, resp.Confidence, nil +} \ No newline at end of file diff --git a/internal/stt/stt_test.go b/internal/stt/stt_test.go new file mode 100644 index 0000000..9e54146 --- /dev/null +++ b/internal/stt/stt_test.go @@ -0,0 +1,125 @@ +package stt + +import ( + "context" + "testing" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/worker" +) + +// fakeTranscriber — a worker.Transcriber that records the call. +type fakeTranscriber struct { + got audio.Audio +} + +func (f *fakeTranscriber) Transcribe(_ context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) { + f.got = req.Audio + return worker.TranscribeResp{Text: "hello world", Confidence: 0.7}, nil +} + +func TestStubReturnsDeterministicPhrase(t *testing.T) { + t.Parallel() + s := NewStub() + a := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("utterance-A")} + text, conf, err := s.Transcribe(context.Background(), a) + if err != nil { + t.Fatalf("Stub.Transcribe: %v", err) + } + if conf != 1.0 { + t.Fatalf("Stub confidence should be 1.0, got %v", conf) + } + // same input ⇒ same output (deterministic; tests can rely on this). + text2, _, _ := s.Transcribe(context.Background(), a) + if text != text2 { + t.Fatalf("Stub should be deterministic: %q vs %q", text, text2) + } + // phrases come from the stubPhrases list. + found := false + for _, p := range stubPhrases { + if p == text { + found = true + break + } + } + if !found { + t.Fatalf("Stub phrase %q not in stubPhrases", text) + } +} + +func TestStubEmptyAudioReturnsFirstPhrase(t *testing.T) { + t.Parallel() + s := NewStub() + text, _, err := s.Transcribe(context.Background(), audio.Audio{Format: audio.PCM16kMono}) + if err != nil { + t.Fatalf("Stub.Transcribe: %v", err) + } + if text != stubPhrases[0] { + t.Fatalf("empty audio should return first phrase %q, got %q", stubPhrases[0], text) + } +} + +func TestStubDifferentAudioMayPickDifferentPhrase(t *testing.T) { + t.Parallel() + s := NewStub() + // try enough variants to land at least two different phrases. + seen := map[string]struct{}{} + for i := 0; i < 200; i++ { + b := make([]byte, 64) + for j := range b { + b[j] = byte(i + j) + } + text, _, _ := s.Transcribe(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: b}) + seen[text] = struct{}{} + if len(seen) >= 2 { + return + } + } + t.Fatalf("expected ≥2 distinct phrases across varied audio, got %d", len(seen)) +} + +func TestRemoteForwardsToClient(t *testing.T) { + t.Parallel() + // stand up a worker.Server with the fakeTranscriber behind it. + srv := worker.NewServer(t.TempDir()+"/stt.sock", &fakeTranscriber{}) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer srv.Close() + go srv.Serve() + + c := worker.Dial(srv.Path()) + defer c.Close() + r := NewRemote(c, "ru") + + in := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("payload")} + text, conf, err := r.Transcribe(context.Background(), in) + if err != nil { + t.Fatalf("Remote.Transcribe: %v", err) + } + if text != "hello world" { + t.Fatalf("Text: %q, want %q", text, "hello world") + } + if conf != 0.7 { + t.Fatalf("Confidence: %v, want 0.7", conf) + } +} + +func TestRemoteErrorWraps(t *testing.T) { + t.Parallel() + // a server with no transcriber ⇒ ErrUnknownMethod on Transcribe. + srv := worker.NewSynthesizerServer(t.TempDir()+"/stt.sock", nil) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer srv.Close() + go srv.Serve() + + c := worker.Dial(srv.Path()) + defer c.Close() + r := NewRemote(c, "ru") + _, _, err := r.Transcribe(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}) + if err == nil { + t.Fatalf("want error, got nil") + } +} \ No newline at end of file diff --git a/internal/tool/tool.go b/internal/tool/tool.go new file mode 100644 index 0000000..a0363b6 --- /dev/null +++ b/internal/tool/tool.go @@ -0,0 +1,135 @@ +// Package tool is maven's act executor: it runs the ENABLED tools from the +// store's allowlist, and drafts 'proposed' scaffolds for acts that aren't on +// it yet. +// +// Boundary discipline (maven.md "tool registration — drafting is suggest, +// enabling is act"): +// +// - The store is the allowlist. Only status='enabled' rows run. A verb not +// on it → refuse ("not on the list → refuse, don't improvise") and draft a +// 'proposed' scaffold instead. Enabling a proposal is a human act on an +// authed surface (mavweb), gated at AuthStepUp — never the voice path, so +// a compromised router can't grant itself a capability. +// - Args are passed as argv, NEVER through a shell. STT text lands as +// positional arguments to Cmd; there is no `sh -c`, so "restart nginx; +// rm -rf" can't inject — the tail is one argv element to the named binary. +// - Destructive tools don't run on first hearing: Exec returns ErrNeedsConfirm +// and the handler runs a confirm turn ("выполнить X? да/нет"); only a +// confirmed re-Exec runs them. A gate assumes a fully-formed action, which +// an enabled+matched act is (maven.md "confirmation is not one mechanism"). +package tool + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// API — the narrow slice of ipc.CoreAPI the executor and matcher need. Backed +// in-process by the daemon's store adapter (a direct sqlite query per call — +// acts are rare, personal-scale; no cache). +type API interface { + LookupTool(ctx context.Context, name string) (ipc.Tool, error) + ListTools(ctx context.Context, status string) ([]ipc.Tool, error) + ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) +} + +var ( + // ErrNotEnabled — the fn isn't an enabled tool (absent, or still proposed). + ErrNotEnabled = errors.New("tool not on the enabled allowlist") + // ErrNeedsConfirm — the fn is enabled but destructive; needs a confirm turn. + ErrNeedsConfirm = errors.New("destructive tool needs confirmation") +) + +// Executor runs enabled tools. run is the exec seam (default: real process); +// tests swap it. timeout bounds each invocation. +type Executor struct { + api API + timeout time.Duration + run func(ctx context.Context, argv []string) (string, error) +} + +// NewExecutor builds the executor. timeout<=0 defaults to 30s. +func NewExecutor(api API, timeout time.Duration) *Executor { + if timeout <= 0 { + timeout = 30 * time.Second + } + return &Executor{api: api, timeout: timeout, run: runProcess} +} + +// Exec looks up name in the store and runs Cmd+args as argv (no shell). +// confirmed=true is the second turn of a destructive act (the user said "да"); +// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a +// destructive tool with confirmed=false ⇒ ErrNeedsConfirm. +func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) { + t, err := e.api.LookupTool(ctx, name) + if errors.Is(err, ipc.ErrToolNotFound) { + return "", ErrNotEnabled + } + if err != nil { + return "", err + } + if t.Status != "enabled" { + return "", ErrNotEnabled + } + if t.Destructive && !confirmed { + return "", ErrNeedsConfirm + } + argv := append(append([]string(nil), t.Cmd...), args...) + if len(argv) == 0 { + return "", ErrNotEnabled + } + ctx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + return e.run(ctx, argv) +} + +func runProcess(ctx context.Context, argv []string) (string, error) { + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + out := strings.TrimSpace(buf.String()) + if err != nil { + return out, fmt.Errorf("run %v: %w", argv, err) + } + return out, nil +} + +// Matcher — a router.ActMatcher whose allowlist is the live set of enabled +// tool names (one source of truth with the executor). Match delegates to the +// router's default prefix logic over the current names. The interface's Match +// has no ctx, so it queries with a background context — an in-process sqlite +// read on the daemon. +type Matcher struct{ api API } + +// NewMatcher builds a store-backed act matcher. +func NewMatcher(api API) *Matcher { return &Matcher{api: api} } + +func (m *Matcher) names() []string { + ts, err := m.api.ListTools(context.Background(), "enabled") + if err != nil { + return nil + } + names := make([]string, len(ts)) + for i, t := range ts { + names[i] = t.Name + } + return names +} + +// Allowlist — the enabled verbs (for stage-0 grammar wiring / introspection). +func (m *Matcher) Allowlist() []string { return m.names() } + +// Match — longest-verb-first prefix match over the live enabled allowlist. +func (m *Matcher) Match(utterance string) (string, []string, bool) { + return router.DefaultActMatcher{Fns: m.names()}.Match(utterance) +} diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go new file mode 100644 index 0000000..d184258 --- /dev/null +++ b/internal/tool/tool_test.go @@ -0,0 +1,87 @@ +package tool + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// fakeAPI — an in-memory tool store for the executor/matcher tests. +type fakeAPI struct{ tools map[string]ipc.Tool } + +func (f fakeAPI) LookupTool(_ context.Context, name string) (ipc.Tool, error) { + t, ok := f.tools[name] + if !ok { + return ipc.Tool{}, ipc.ErrToolNotFound + } + return t, nil +} +func (f fakeAPI) ListTools(_ context.Context, status string) ([]ipc.Tool, error) { + var out []ipc.Tool + for _, t := range f.tools { + if status == "" || t.Status == status { + out = append(out, t) + } + } + return out, nil +} +func (f fakeAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) { + return true, nil +} + +// TestExec covers the allowlist boundary: enabled runs, unknown/proposed refuse, +// destructive needs confirm, and args land as argv (no shell) after the prefix. +func TestExec(t *testing.T) { + api := fakeAPI{tools: map[string]ipc.Tool{ + "restart": {Name: "restart", Cmd: []string{"systemctl", "restart"}, Status: "enabled"}, + "drop": {Name: "drop", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"}, + "draft": {Name: "draft", Cmd: []string{"x"}, Status: "proposed"}, + }} + + var gotArgv []string + e := NewExecutor(api, 0) + e.run = func(_ context.Context, argv []string) (string, error) { gotArgv = argv; return "ok", nil } + + // enabled → runs, args appended to the fixed prefix as argv. + out, err := e.Exec(context.Background(), "restart", []string{"nginx; rm -rf /"}, false) + if err != nil || out != "ok" { + t.Fatalf("enabled: out=%q err=%v", out, err) + } + want := []string{"systemctl", "restart", "nginx; rm -rf /"} + if !reflect.DeepEqual(gotArgv, want) { + t.Fatalf("argv=%v want %v (injection must stay one argv element)", gotArgv, want) + } + + // unknown → refuse. + if _, err := e.Exec(context.Background(), "nope", nil, false); !errors.Is(err, ErrNotEnabled) { + t.Fatalf("unknown: err=%v want ErrNotEnabled", err) + } + // proposed (not enabled) → refuse. + if _, err := e.Exec(context.Background(), "draft", nil, false); !errors.Is(err, ErrNotEnabled) { + t.Fatalf("proposed: err=%v want ErrNotEnabled", err) + } + // destructive unconfirmed → needs confirm; confirmed → runs. + if _, err := e.Exec(context.Background(), "drop", nil, false); !errors.Is(err, ErrNeedsConfirm) { + t.Fatalf("destructive: err=%v want ErrNeedsConfirm", err) + } + if _, err := e.Exec(context.Background(), "drop", []string{"db"}, true); err != nil { + t.Fatalf("destructive confirmed: err=%v", err) + } + if want := []string{"dropdb", "db"}; !reflect.DeepEqual(gotArgv, want) { + t.Fatalf("confirmed argv=%v want %v", gotArgv, want) + } + + // matcher allowlist = enabled names only (proposed excluded). + m := NewMatcher(api) + fn, args, ok := m.Match("restart nginx") + if !ok || fn != "restart" || !reflect.DeepEqual(args, []string{"nginx"}) { + t.Fatalf("match: fn=%q args=%v ok=%v", fn, args, ok) + } + if _, _, ok := m.Match("draft something"); ok { + t.Fatal("proposed tool must not match (not enabled)") + } +} diff --git a/internal/tts/tts.go b/internal/tts/tts.go new file mode 100644 index 0000000..04f83a7 --- /dev/null +++ b/internal/tts/tts.go @@ -0,0 +1,93 @@ +// Package tts is maven's text-to-speech seam. +// +// Mirrors internal/stt: one method, two implementations (Stub + Remote), one +// swap seam at the daemon. The output is audio.Audio — raw 16k mono int16 +// PCM, headerless per the audio package; the voice sink + reference client +// wrap it in a WAV at the disk edge. +// +// Per spec (maven.md § stt/tts): silero (ru-native) is the production tts, +// piper (ru) is the safe floor. Both are CPU-only on the ryzen box; both +// ship as separate worker module processes (cmd/mavttsd today with the +// Stub handler; production swaps in onnxruntime / espeak-ng in the same +// main, no tts-package change). The daemon wires one — Remote pointing at +// the worker socket if configured, Stub otherwise. +// +// The Stub returns a short deterministic tone (a 200ms mid-frequency sine +// burst) so the voice loop round-trips end-to-end without a model. The +// reference client can `aplay` the reply, hear a tone, and know the wire +// shape is right; the production swap replaces the bytes with model output. +package tts + +import ( + "context" + "fmt" + "math" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/worker" +) + +// Synthesizer — the text-to-speech contract. Input is text the phraser has +// already rendered (Body for proactive nudges, reply text for reactive). +// Output is raw PCM audio the delivery / client surfaces ship. +type Synthesizer interface { + Synthesize(ctx context.Context, text string) (audio.Audio, error) +} + +// Stub — the deterministic, no-model floor. Returns a fixed-duration tone +// keyed by the input text's first byte so different replies produce +// slightly different tones (a test asserting "the voice reply was sent" +// can distinguish them; a human smoke-testing hears that SOMETHING came +// back, not silence). 200ms at 16k mono int16 ⇒ 6400 bytes — small frames, +// instant over the wire. +type Stub struct{} + +// NewStub builds the floor synthesizer. +func NewStub() *Stub { return &Stub{} } + +// Synthesize returns a 200ms tone derived from the first byte of text. +// Empty text ⇒ a low tone (so an empty reply is still audible, not a +// silent no-op a bug could hide behind). +func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) { + const durMs = 200 + const samples = 16000 * durMs / 1000 // 3200 samples @ 16k + pcm := make([]byte, samples*2) + freq := 220.0 // A3 + if len(text) > 0 { + freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band + } + for i := 0; i < samples; i++ { + t := float64(i) / 16000.0 + v := int16(12000 * math.Sin(2*math.Pi*freq*t)) + pcm[i*2] = byte(v) + pcm[i*2+1] = byte(v >> 8) + } + return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil +} + +// Remote — the worker-backed Synthesizer. Holds a worker.Client that dials +// the tts module's unix socket. +type Remote struct { + c *worker.Client + lang string + voice string +} + +// NewRemote builds a Remote Synthesizer. lang is the default voice language; +// voice is the named voice ("" ⇒ the worker's configured default). +func NewRemote(c *worker.Client, lang, voice string) *Remote { + return &Remote{c: c, lang: lang, voice: voice} +} + +// Synthesize forwards to the worker module. A worker-side fault returns an +// empty Audio + error; the dispatcher's voice path logs and skips (a +// transient TTS fault drops the voice channel for that one send; away +// channels like ntfy/telegram still fire because their sinks are +// independent). +func (r *Remote) Synthesize(ctx context.Context, text string) (audio.Audio, error) { + resp, err := r.c.Synthesize(ctx, worker.SynthesizeReq{Text: text, Lang: r.lang, Voice: r.voice}) + if err != nil { + return audio.Audio{}, fmt.Errorf("tts: synthesize: %w", err) + } + return resp.Audio, nil +} \ No newline at end of file diff --git a/internal/tts/tts_test.go b/internal/tts/tts_test.go new file mode 100644 index 0000000..1bc3e5d --- /dev/null +++ b/internal/tts/tts_test.go @@ -0,0 +1,138 @@ +package tts + +import ( + "context" + "testing" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/worker" +) + +type fakeSynthesizer struct{} + +func (fakeSynthesizer) Synthesize(_ context.Context, req worker.SynthesizeReq) (worker.SynthesizeResp, error) { + if req.Text == "" { + return worker.SynthesizeResp{}, nil + } + pcm := make([]byte, 3200) + for i := 0; i < len(pcm)/2; i++ { + v := int16(i) + pcm[i*2] = byte(v) + pcm[i*2+1] = byte(v >> 8) + } + return worker.SynthesizeResp{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}}, nil +} + +func TestStubProducesValidAudio(t *testing.T) { + t.Parallel() + s := NewStub() + a, err := s.Synthesize(context.Background(), "hi") + if err != nil { + t.Fatalf("Stub.Synthesize: %v", err) + } + if !a.Format.IsValid() { + t.Fatalf("format invalid: %+v", a.Format) + } + // 200ms @ 16k ⇒ 3200 samples ⇒ 6400 bytes. + if len(a.Bytes) != 6400 { + t.Fatalf("bytes len: %d, want 6400", len(a.Bytes)) + } + // non-empty audio should not be all zeros (sine wave). + allZero := true + for _, b := range a.Bytes { + if b != 0 { + allZero = false + break + } + } + if allZero { + t.Fatalf("Stub audio is all zeros; should be a tone") + } + if a.Duration() < 0.19 || a.Duration() > 0.21 { + t.Fatalf("Duration: got %v, want ~0.2s", a.Duration()) + } +} + +func TestStubDifferentTextDifferentFreq(t *testing.T) { + t.Parallel() + s := NewStub() + a, _ := s.Synthesize(context.Background(), "a") // 'a' = 97 + b, _ := s.Synthesize(context.Background(), "z") // 'z' = 122 + // different first byte ⇒ different modulo ⇒ different tones; bytes differ. + if samePCM(a.Bytes, b.Bytes) { + t.Fatalf("expected different tones for 'a' vs 'z'") + } +} + +func TestStubEmptyTextStillTones(t *testing.T) { + t.Parallel() + s := NewStub() + a, err := s.Synthesize(context.Background(), "") + if err != nil { + t.Fatalf("Stub.Synthesize: %v", err) + } + if len(a.Bytes) == 0 { + t.Fatalf("empty text should still produce tone (audible), got silence") + } +} + +func TestRemoteForwardsToClient(t *testing.T) { + t.Parallel() + srv := worker.NewSynthesizerServer(t.TempDir()+"/tts.sock", fakeSynthesizer{}) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer srv.Close() + go srv.Serve() + + c := worker.Dial(srv.Path()) + defer c.Close() + r := NewRemote(c, "ru", "") + + out, err := r.Synthesize(context.Background(), "hello") + if err != nil { + t.Fatalf("Remote.Synthesize: %v", err) + } + if !out.Format.IsValid() { + t.Fatalf("invalid format: %+v", out.Format) + } + if len(out.Bytes) != 3200 { + t.Fatalf("bytes len: %d, want 3200", len(out.Bytes)) + } +} + +func TestRemoteErrorWraps(t *testing.T) { + t.Parallel() + // a transcriber-only server ⇒ ErrUnknownMethod on Synthesize. + srv := worker.NewServer(t.TempDir()+"/tts.sock", &fakeErrTranscriber{}) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer srv.Close() + go srv.Serve() + c := worker.Dial(srv.Path()) + defer c.Close() + r := NewRemote(c, "ru", "") + _, err := r.Synthesize(context.Background(), "hello") + if err == nil { + t.Fatalf("want error, got nil") + } +} + +type fakeErrTranscriber struct{} + +func (fakeErrTranscriber) Transcribe(context.Context, worker.TranscribeReq) (worker.TranscribeResp, error) { + return worker.TranscribeResp{}, nil +} + +func samePCM(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} \ No newline at end of file diff --git a/internal/voice/client.go b/internal/voice/client.go new file mode 100644 index 0000000..26ab47e --- /dev/null +++ b/internal/voice/client.go @@ -0,0 +1,240 @@ +// voice/client.go — the client-side dialer. +// +// Used by cmd/mavenclient to round-trip a PushToTalk. One Client = one TCP +// conn = one frame at a time (singleplex — the reference client doesn't +// multiplex; production may, but the wire shape already carries IDs so +// multiplexing is a client-lib affair, not a wire change). When the user +// wants to receive proactive voice pushes, RunPushReceiver spawns a reader +// goroutine that calls the PushHandler for each server-initiated Push frame. +// +// The wire is symmetric: a Request from the client is answered by a +// Response with a matching ID, OR a server-initiated Push frame (no ID) +// may arrive interleaved. SendRequest loops reading frames, drops Push +// frames to the harness if a receiver is running (or silently if not), +// and returns the first Response with the matching ID. +package voice + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/kami/maven/internal/audio" +) + +// PushHandler — receives server-initiated Push frames. cmd/mavenclient's +// -listen mode wires one that writes the audio to disk / aplay. The Push +// arrives on the reader goroutine; the handler runs there too, so a slow +// handler blocks subsequent frames (intentional — proactive voice playback +// should not queue up behind a stuck player; the next nudge replaces the +// stale one in the user's attention, not dogpiles on it). +type PushHandler interface { + OnPush(p Push) +} + +// Client — one connection to the voice.Server. +type Client struct { + addr string + mu sync.Mutex + c net.Conn + nextID atomic.Uint64 + + // pushCh fan-out: a reader goroutine (started by RunPushReceiver) + // writes Push frames here; SendRequest also drains it when no reader + // is running (drops the frame in that case). + pushMu sync.Mutex + pushH PushHandler +} + +// Dial returns a Client that will connect to addr on first use. +func Dial(addr string) *Client { return &Client{addr: addr} } + +// Close releases the conn. Idempotent. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.c == nil { + return nil + } + err := c.c.Close() + c.c = nil + return err +} + +// PushToTalk is the convenience for fire-and-forget: send audio bytes + +// language, block for the reply. Used by cmd/mavenclient's one-shot mode. +func (c *Client) PushToTalk(ctx context.Context, a audio.Audio, lang string) (PushToTalkResp, error) { + var out PushToTalkResp + err := c.SendRequest(ctx, MethodPushToTalk, PushToTalkReq{Audio: a, Lang: lang, Surface: SurfacePCClient}, &out) + return out, err +} + +// SendRequest sends one Request frame and waits for the matching Response. +// Push frames received while waiting are dropped on the floor UNLESS a +// PushHandler has been wired via RunPushReceiver, in which case the handler +// is invoked inline (still synchronous with the SendRequest caller's +// read). For sanity, the reference client runs either one-shot (no +// receiver) or interactive (RunPushReceiver, no concurrent SendRequest). +func (c *Client) SendRequest(ctx context.Context, m Method, params any, out any) error { + body, err := marshalParams(params) + if err != nil { + return err + } + id := c.nextID.Add(1) + req := Request{ID: id, Method: m, Params: body} + + c.mu.Lock() + if err := c.ensureConnLocked(ctx); err != nil { + c.mu.Unlock() + return err + } + conn := c.c + c.mu.Unlock() + + if dl, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(dl) + } else { + _ = conn.SetDeadline(time.Now().Add(120 * time.Second)) + } + defer conn.SetDeadline(time.Time{}) + + if err := writeFrame(conn, &req); err != nil { + c.teardown() + return err + } + for { + resp, push, err := readOneFrame(conn) + if err != nil { + c.teardown() + return err + } + if push != nil { + c.deliverPush(*push) + continue + } + if resp.ID != id { + continue // not ours; ignore (singleplex ⇒ shouldn't happen) + } + if resp.Error != nil { + return hydrate(resp.Error) + } + if out != nil { + if err := json.Unmarshal(resp.Result, out); err != nil { + return fmt.Errorf("voice: unmarshal result: %w", err) + } + } + return nil + } +} + +// RunPushReceiver spawns a reader goroutine that delivers Push frames to h +// until the conn closes or Close is called. Today's reference client uses +// this in -listen mode (proactive voice playback). SendRequest and +// RunPushReceiver SHOULD NOT be used concurrently on the same Client — the +// wire is singleplex at the reference client's scale; production picks one +// mode per conn. Returns when the goroutine ends (ctx cancel or conn close). +func (c *Client) RunPushReceiver(ctx context.Context, h PushHandler) error { + c.mu.Lock() + if err := c.ensureConnLocked(ctx); err != nil { + c.mu.Unlock() + return err + } + conn := c.c + c.mu.Unlock() + + c.pushMu.Lock() + c.pushH = h + c.pushMu.Unlock() + + defer func() { + c.pushMu.Lock() + c.pushH = nil + c.pushMu.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, push, err := readOneFrame(conn) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { + return nil + } + return err + } + if push != nil { + c.deliverPush(*push) + } + } +} + +func (c *Client) deliverPush(p Push) { + c.pushMu.Lock() + h := c.pushH + c.pushMu.Unlock() + if h != nil { + h.OnPush(p) + } +} + +func (c *Client) ensureConnLocked(ctx context.Context) error { + if c.c != nil { + return nil + } + d := net.Dialer{Timeout: 10 * time.Second} + conn, err := d.DialContext(ctx, "tcp", c.addr) + if err != nil { + return fmt.Errorf("voice: dial %s: %w", c.addr, err) + } + c.c = conn + return nil +} + +func (c *Client) teardown() { + c.mu.Lock() + defer c.mu.Unlock() + if c.c != nil { + _ = c.c.Close() + c.c = nil + } +} + +// readOneFrame — reads one frame and tries Response-then-Push shape. A +// frame with a non-zero `id` is a Response; a frame with `kind` and no `id` +// is a Push. Exactly one return value is non-nil. +func readOneFrame(r io.Reader) (*Response, *Push, error) { + var raw struct { + ID uint64 `json:"id"` + Result json.RawMessage `json:"r,omitempty"` + Error *RpcError `json:"e,omitempty"` + Kind PushKind `json:"kind,omitempty"` + Params json.RawMessage `json:"p,omitempty"` + } + if err := readFrame(r, &raw); err != nil { + return nil, nil, err + } + if raw.Kind != "" && raw.ID == 0 { + return nil, &Push{Kind: raw.Kind, Params: raw.Params}, nil + } + return &Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil +} + +func marshalParams(v any) (json.RawMessage, error) { + if v == nil { + return nil, nil + } + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("voice: marshal params: %w", err) + } + return b, nil +} \ No newline at end of file diff --git a/internal/voice/errors.go b/internal/voice/errors.go new file mode 100644 index 0000000..801e836 --- /dev/null +++ b/internal/voice/errors.go @@ -0,0 +1,73 @@ +// voice/errors.go — wire error codes + sentinel rehydration. +package voice + +import ( + "encoding/json" + "errors" + "fmt" +) + +// Sentinel errors. Mirrored 1:1 to wire codes below; the client rehydrates a +// wire RpcError into one of these so callers can use errors.Is the same way +// in-process and over-the-wire. +var ( + ErrUnknownMethod = errors.New("voice: unknown method") + ErrBadParams = errors.New("voice: bad params") + ErrForbidden = errors.New("voice: forbidden") + ErrNoSession = errors.New("voice: no live client session") // voicesink unable to push +) + +// Sentinel wire codes. Stable; do not rename. +const ( + codeUnknownMethod = "unknown_method" + codeBadParams = "bad_params" + codeForbidden = "forbidden" + codeInternal = "internal" +) + +// codeOf maps a server-side sentinel to its wire code. Unknown ⇒ +// codeInternal; the server logs the real text and the client sees a +// generic internal code (no authority-bearing text exposure). +func codeOf(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, ErrUnknownMethod): + return codeUnknownMethod + case errors.Is(err, ErrBadParams): + return codeBadParams + case errors.Is(err, ErrForbidden): + return codeForbidden + default: + return codeInternal + } +} + +func rpcErr(err error) *RpcError { + c := codeOf(err) + if c == codeInternal || c == codeBadParams { + return &RpcError{Code: c, Message: err.Error()} + } + return &RpcError{Code: c} +} + +// hydrate rehydrates a wire RpcError into a package sentinel. +func hydrate(e *RpcError) error { + switch e.Code { + case codeUnknownMethod: + return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message) + case codeBadParams: + return fmt.Errorf("%w: %s", ErrBadParams, e.Message) + case codeForbidden: + return ErrForbidden + default: + if e.Message != "" { + return fmt.Errorf("voice: %s: %s", e.Code, e.Message) + } + return fmt.Errorf("voice: %s", e.Code) + } +} + +// json helpers kept local so call sites read clean. +func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } +func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) } \ No newline at end of file diff --git a/internal/voice/frame.go b/internal/voice/frame.go new file mode 100644 index 0000000..c556124 --- /dev/null +++ b/internal/voice/frame.go @@ -0,0 +1,60 @@ +// voice/frame.go — length-prefixed JSON framing. +// +// Same shape as ipc/worker with the 64 MiB cap (see wire.go maxFrame). One +// frame carries EITHER a Request, Response, or Push — distinguished by the +// present fields (Request has `id`+`m`; Response has `id`; Push has `kind`). +// Server-side reads probe all three shallowly; client-side reads expect +// either Response or Push depending on context. +package voice + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" +) + +// ErrFrameTooLarge — frame exceeded maxFrame; conn is desynced; caller must close. +var ErrFrameTooLarge = errors.New("voice: frame too large") + +func writeFrame(w io.Writer, v any) error { + body, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("voice: marshal frame: %w", err) + } + if len(body) > maxFrame { + return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body)) + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) + if _, err := w.Write(hdr[:]); err != nil { + return fmt.Errorf("voice: write frame header: %w", err) + } + if _, err := w.Write(body); err != nil { + return fmt.Errorf("voice: write frame body: %w", err) + } + return nil +} + +func readFrame(r io.Reader, v any) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + if errors.Is(err, io.EOF) { + return io.EOF + } + return fmt.Errorf("voice: read frame header: %w", err) + } + n := binary.BigEndian.Uint32(hdr[:]) + if n > maxFrame { + return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n) + } + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return fmt.Errorf("voice: read frame body: %w", err) + } + if err := json.Unmarshal(buf, v); err != nil { + return fmt.Errorf("voice: unmarshal frame: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/voice/replier.go b/internal/voice/replier.go new file mode 100644 index 0000000..1a12618 --- /dev/null +++ b/internal/voice/replier.go @@ -0,0 +1,84 @@ +// voice/replier.go — reactive reply phrasing. +// +// A SEPARATE seam from internal/phraser.Phraser: +// +// - Phraser phrases DELIVERIES — the loop's nudges + reminders. Its output +// (Body + Summary) is consumed by the dispatcher and shipped to ALL the +// routed channels (voice gets Body, away channels get Summary). The +// Phraser owns the multiple-of-many-channel output contract. +// +// - Replier phrases REPLIES — the one text a voice round-trip says back to +// the user who just spoke. Reactive, single-channel (voice), no +// dispatcher involvement. The reply text goes through TTS to a single +// audio clip played on the originating client; nothing routes elsewhere. +// +// Both seams feed TTS in production but at different times: Phraser for +// proactive nudges (loop tick → voicesink → tts → push), Replier for +// reactive round-trips (client audio → stt → router → action → replier → +// tts → response). Splitting the seam keeps the Phraser interface stable +// (the project's tests mock it; adding a method would break them) and +// keeps the LLM-backed impl cleanly focused: production phraser = +// personality-prompted "nudge tone", production replier = "chat tone", +// different prompts in the same model server. +// +// The Stub is the deterministic floor; production swaps in an LLM impl at +// the daemon seam (config wiring, no CoreAPI or voice-package change). +package voice + +import "github.com/kami/maven/internal/router" + +// Replier — the reactive reply phrasing seam. The daemon's reactive handler +// calls Reply with the router's Decision; the impl produces a terse reply +// the handler passes to TTS and ships back to the originating client. +// +// The reply is per-Decision (one per reactive turn); the impl sees the +// decision's Intent + Slots + Clarify. The Intent largely names the reply +// shape (act/reminder/fact/note/query/clarify); the Slots carry the +// specifics that personalise it ("got it: water at 14:00"). +type Replier interface { + Reply(d router.Decision) string +} + +// StubReplier — the deterministic, no-model floor. Canned per intent; +// slots incorporated as plain strings (the LLM impl will natural-language +// them; the Stub keeps it readable). The same instinct as the phraser +// Stub: a SCAFFOLD GROUND TRUTH so tests + the daemon end-to-end have +// deterministic replies; the LLM impl swaps in at the daemon wiring. +type StubReplier struct{} + +// NewStubReplier builds the floor replier. +func NewStubReplier() *StubReplier { return &StubReplier{} } + +// Reply dispatches on Intent + Clarify. Each branch is short; the LLM impl +// will replace this with prompted text and the same dispatch shape. +func (s *StubReplier) Reply(d router.Decision) string { + if d.Clarify { + return "не совсем поняла — можешь переформулировать?" + } + switch d.Intent { + case router.IntentAct: + if !d.Slots.HasFn { + return "не могу это сделать — не разобрала действие." + } + return "ок, записала действие: " + d.Slots.Fn + case router.IntentReminder: + if d.Slots.HasTime { + return "напомню." + } + return "напомню." + case router.IntentFact: + if d.Slots.HasKey { + if d.Slots.Value != "" { + return "отметила: " + d.Slots.Key + " = " + d.Slots.Value + } + return "отметила: " + d.Slots.Key + } + return "записала факт." + case router.IntentNote: + return "сохранила заметку." + case router.IntentQuery: + return "это пока не подключено — чтение из памяти появится позже." + default: + return "приняла." + } +} \ No newline at end of file diff --git a/internal/voice/server.go b/internal/voice/server.go new file mode 100644 index 0000000..00c9c04 --- /dev/null +++ b/internal/voice/server.go @@ -0,0 +1,235 @@ +// voice/server.go — the daemon-side TCP listener. +// +// Accept loop mirrors ipc/server.go shape: one Server, one goroutine per +// conn, recover per conn so a misbehaving client can't kill core. Two key +// differences from ipc: +// +// - Each conn registers a Session in *Sessions before it reads the first +// frame. The conn serves requests AND receives server-initiated Pushes +// through the same conn (the voicesink calls Sessions.PushToMostRecent, +// which finds the session by lastActive and writes a Push frame on +// its conn). The conn's write side is therefore shared: serveConn's +// Response writes vs. the voicesink's Push writes; both serialize via +// the per-Session mutex. +// +// - The handler is a voice.Handler, NOT a CoreAPI-style interface. The +// single method, HandlePushToTalk(ctx, req, sessionID) → resp, does +// the full reactive path (stt → router → action → replier → tts) and +// returns the reply. The daemon provides a concrete impl wired to its +// stt/tts/router/coreAPI; the voice package stays free of those imports +// (it's just the wire surface). +package voice + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "sync" + "time" +) + +// Handler — the daemon-side reactive path. The voice package defines the +// interface; the daemon wires a concrete handler that knows stt/tts/router. +// This keeps voice from importing every other package in the system. +// +// The handler is called on a per-conn goroutine; it must be safe for +// concurrent use by multiple callers (the daemon's impl routes through +// the wired singleton stt.Transcriber / tts.Synthesizer / router, all of +// which are concurrency-safe). +type Handler interface { + HandlePushToTalk(ctx context.Context, req PushToTalkReq, sessionID uint64) (PushToTalkResp, error) +} + +// Server — maven's client↔core network surface. Listens on a TCP address +// (inside the wg tunnel; bind to wg-egress only — the listener doesn't +// enforce that, the daemon's config picks the bind addr). Each conn is a +// session. Server holds *Sessions so the voicesink can ask +// PushToMostRecent. +type Server struct { + addr string + handler Handler + sessions *Sessions + + ln net.Listener + wg sync.WaitGroup + done chan struct{} +} + +// NewServer builds a Server bound to addr (e.g. "127.0.0.1:9100" for a +// local-only smoke; production: a wg-tunnel address). handler is the +// reactive handler; sessions is shared with the voicesink (the daemon +// constructs one, passes to both Server and voicesink). +func NewServer(addr string, handler Handler, sessions *Sessions) *Server { + return &Server{ + addr: addr, + handler: handler, + sessions: sessions, + done: make(chan struct{}), + } +} + +// Listen binds the TCP listener. Today's floor is plaintext — the auth +// cascade (mTLS, passkey) layers in at the same addr without changing the +// wire shape. Production picks a bind addr that's inside the wg tunnel +// (the wg layer IS the L0 floor); the listener doesn't enforce that, the +// config does. +func (s *Server) Listen() error { + ln, err := net.Listen("tcp", s.addr) + if err != nil { + return fmt.Errorf("voice: listen %s: %w", s.addr, err) + } + s.ln = ln + return nil +} + +// Addr returns the bound TCP address (after Listen). +func (s *Server) Addr() string { + if s.ln == nil { + return s.addr + } + return s.ln.Addr().String() +} + +// Serve accepts connections until the listener closes. Per-conn goroutine; +// per-conn recover so a misbehaving client can't crash core. +func (s *Server) Serve() error { + if s.ln == nil { + return fmt.Errorf("voice: serve before listen") + } + for { + c, err := s.ln.Accept() + if err != nil { + select { + case <-s.done: + return nil + default: + return fmt.Errorf("voice: accept: %w", err) + } + } + s.wg.Add(1) + go func(c net.Conn) { + defer s.wg.Done() + s.serveConn(c) + }(c) + } +} + +// serveConn — one client's lifecycle. Registers a Session, reads requests +// in a loop, dispatches to Handler, writes Responses, removes the session +// on EOF / read error / ctx cancel. +func (s *Server) serveConn(c net.Conn) { + defer c.Close() + // TODO(step-up): the auth handshake populates the surface from mTLS / + // passkey enrollment. Today the floor sets SurfacePCClient (the + // reference client's surface, capped at L3 per auth.MaxLayer). The + // reference client doesn't carry passkey yet, so the floor is "you + // got through the wg tunnel ⇒ you're on SurfacePCClient by name; the + // passkey step-up will cap-before-L3 untrusted pop sessions later." + sess := s.sessions.Add(c, SurfacePCClient) + defer s.sessions.Remove(sess.ID) + log.Printf("voice: client %d connected from %s", sess.ID, sess.RemoteAddr) + + for { + var req Request + if err := readFrame(c, &req); err != nil { + if errors.Is(err, io.EOF) { + // quiet disconnect; common during shutdown. + } else { + log.Printf("voice: client %d read: %v", sess.ID, err) + } + return + } + s.sessions.Touch(sess.ID, time.Now()) + + result, err := s.safeDispatch(c.RemoteAddr(), sess.ID, req) + resp := Response{ID: req.ID} + if err != nil { + resp.Error = rpcErr(err) + } else { + resp.Result = result + } + if err := writeFrame(c, &resp); err != nil { + log.Printf("voice: client %d write: %v", sess.ID, err) + return + } + } +} + +func (s *Server) safeDispatch(addr net.Addr, sid uint64, req Request) (result json.RawMessage, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("voice: panic dispatching %s (sid %d): %v", req.Method, sid, r) + } + }() + return s.dispatch(context.Background(), sid, req) +} + +func (s *Server) dispatch(ctx context.Context, sid uint64, req Request) (json.RawMessage, error) { + switch req.Method { + case MethodPushToTalk: + var p PushToTalkReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + // Floor default: surface = SurfacePCClient (set here so a floor + // client that didn't populate the wire field still gets the + // reference surface). Production: handshake sets it; req.Surface + // wins over the default. + if p.Surface == "" { + p.Surface = SurfacePCClient + } + resp, err := s.handler.HandlePushToTalk(ctx, p, sid) + if err != nil { + return nil, err + } + return marshalResult(resp), nil + + case MethodPong: + // Pong updates lastActive (the Touch above already did it on the + // read); no further action. Returns an empty success. + return marshalResult(nil), nil + + default: + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + } +} + +// Close stops accepting and waits for in-flight conns to drain. +func (s *Server) Close() error { + select { + case <-s.done: + return nil + default: + close(s.done) + } + var err error + if s.ln != nil { + err = s.ln.Close() + } + s.wg.Wait() + return err +} + +func unmarshalParams(raw json.RawMessage, v any) error { + if len(raw) == 0 { + raw = []byte("null") + } + if err := json.Unmarshal(raw, v); err != nil { + return fmt.Errorf("%w: %v", ErrBadParams, err) + } + return nil +} + +func marshalResult(v any) json.RawMessage { + if v == nil { + return json.RawMessage("null") + } + b, _ := json.Marshal(v) + return b +} + +// io.EOF — used by serveConn to detect a quiet client disconnect. \ No newline at end of file diff --git a/internal/voice/session.go b/internal/voice/session.go new file mode 100644 index 0000000..b72a161 --- /dev/null +++ b/internal/voice/session.go @@ -0,0 +1,177 @@ +// voice/session.go — the live-client registry. +// +// One session per connected client. The registry is the seam the voicesink +// asks ("play this on the most-recently-active client") and the seam the +// server mutates ("client X is active at T"). The voicesink doesn't know +// the conn — this package hides it; voicesink holds *Sessions, calls +// PushToMostRecent, gets nil-id back when no one's around, and surfaces +// that as "no live voice channel" (delivery falls back to away channels if +// the routing table insists, drops if it doesn't). +// +// Concurrency: one mutex around the map; per-session last-active updates +// go through the same lock as add/remove. Adds are rare (one per client +// connect); updates are once per request (inbound PushToTalk refreshes +// last-active). The lock is held briefly — no audio bytes flow through +// sessions; the sink ships audio through the per-conn writeFrame call, +// which is short. +package voice + +import ( + "context" + "encoding/json" + "fmt" + "net" + "sync" + "sync/atomic" + "time" +) + +// Session — one connected client. The server writes to it via pushAudio; +// the registry reads lastActive via LastActive; the conn is closed when +// the client disconnects (serveConn returns) or the server shuts down. +type Session struct { + ID uint64 + Surface Surface + RemoteAddr string + + // lastActive — last time we heard from this client (request frame OR + // Pong). PickRecent selects the max-lastActive session for routing. + lastActive atomic.Int64 // unix nano + + mu sync.Mutex + conn net.Conn + closed bool +} + +func (s *Session) setLastActive(t time.Time) { + s.lastActive.Store(t.UnixNano()) +} + +// LastActive — int64 unixnano, atomic read. PickRecent compares these. +func (s *Session) LastActive() int64 { return s.lastActive.Load() } + +// IsClosed reports whether the conn has been torn down. PickRecent skips +// closed sessions explicitly; an in-flight close race is fine — pushAudio +// returns an error and the sink reroutes (the same path as "no session"). +func (s *Session) IsClosed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.closed +} + +// pushAudio writes one Push frame on this session's conn. Acquires the +// session lock so a concurrent close doesn't race with a write (the +// voicesink + the proactive-nudge pusher are goroutines separate from +// serveConn). Returns an error if the conn is closed. +func (s *Session) pushAudio(p AudioNudgePush) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed || s.conn == nil { + return fmt.Errorf("voice: session %d closed: %w", s.ID, ErrNoSession) + } + return writeFrame(s.conn, Push{Kind: PushKindAudioNudge, Params: mustParams(p)}) +} + +func (s *Session) shutdown() { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + s.closed = true + if s.conn != nil { + _ = s.conn.Close() + } +} + +// Sessions — the registry of live clients. Held by the Server AND by +// voicesink (same pointer; the daemon passes one to both). Mutex around the +// map; per-session conn writes use the session's own lock. +type Sessions struct { + mu sync.Mutex + sess map[uint64]*Session + nextID uint64 +} + +func NewSessions() *Sessions { + return &Sessions{sess: make(map[uint64]*Session)} +} + +// Add registers a new client conn. Returns the Session (caller uses it to +// push; serveConn reads the next request from conn). Surface defaults to +// SurfacePCClient today (the reference client's surface; mTLS / passkey +// enrollment will populate this from the auth handshake instead). Removes +// the session on serveConn's return via Remove. +func (r *Sessions) Add(c net.Conn, surface Surface) *Session { + r.mu.Lock() + defer r.mu.Unlock() + r.nextID++ + s := &Session{ + ID: r.nextID, + Surface: surface, + RemoteAddr: c.RemoteAddr().String(), + conn: c, + } + s.setLastActive(time.Now()) + r.sess[s.ID] = s + return s +} + +// Remove drops a session (serveConn returned — client closed or read err). +func (r *Sessions) Remove(id uint64) { + r.mu.Lock() + defer r.mu.Unlock() + if s, ok := r.sess[id]; ok { + s.shutdown() + delete(r.sess, id) + } +} + +// Touch refreshes a session's lastActive to now (called on each request). +// No-op if the session is gone (a stale request after disconnect). +func (r *Sessions) Touch(id uint64, now time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + if s, ok := r.sess[id]; ok { + s.setLastActive(now) + } +} + +// Active returns the count of live sessions. For logging / readiness. +func (r *Sessions) Active() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.sess) +} + +// PushToMostRecent — the voicesink's primary call. Delivers p to the +// session with the maximum lastActive; today the only Push kind. Returns +// ErrNoSession (wrapped) when no live session exists — the dispatcher / +// voicesink surfaces that as "voice channel not available, route away." +// +// We DO NOT broadcast; the spec's "most-recently-active client plays it" +// is enforced here. If the user has the phone and the laptop open, the +// one they used most recently plays; the other doesn't double-ring. +func (r *Sessions) PushToMostRecent(ctx context.Context, p AudioNudgePush) error { + r.mu.Lock() + var best *Session + for _, s := range r.sess { + if best == nil || s.LastActive() > best.LastActive() { + best = s + } + } + r.mu.Unlock() + if best == nil { + return fmt.Errorf("voice: push: %w", ErrNoSession) + } + return best.pushAudio(p) +} + +// helper that returns a fixed nil-error marshal so the push call site is short. +func mustParams(v any) json.RawMessage { + if v == nil { + return nil + } + b, _ := jsonMarshal(v) + return b +} \ No newline at end of file diff --git a/internal/voice/voice_test.go b/internal/voice/voice_test.go new file mode 100644 index 0000000..d5a5441 --- /dev/null +++ b/internal/voice/voice_test.go @@ -0,0 +1,223 @@ +package voice + +import ( + "context" + "encoding/json" + "net" + "os" + "syscall" + "testing" + "time" + + "github.com/kami/maven/internal/audio" +) + +// newTestListener returns a TCP listener on a random port with SO_REUSEADDR. +// It retries a few times if the port is temporarily unavailable (e.g., TIME_WAIT). +func newTestListener(t *testing.T) net.Listener { + t.Helper() + lc := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + var err error + c.Control(func(fd uintptr) { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + }) + return err + }, + } + var ln net.Listener + var err error + for i := 0; i < 5; i++ { + ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err == nil { + break + } + if !isAddrInUse(err) { + t.Fatalf("listen: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + if err != nil { + t.Fatalf("listen after retries: %v", err) + } + // Close the probe before returning: it only existed to reserve a free port + // (127.0.0.1:0 → a concrete port). The Server rebinds that exact addr in its + // own Listen(), which fails with EADDRINUSE while the probe still holds it. + // Addr() keeps returning the address after Close, and an unconnected listener + // leaves no TIME_WAIT, so the rebind is immediate and clean. + _ = ln.Close() + return ln +} + +func isAddrInUse(err error) bool { + if err == nil { + return false + } + if opErr, ok := err.(*net.OpError); ok { + if sysErr, ok := opErr.Err.(*os.SyscallError); ok { + if err := sysErr.Err; err == syscall.EADDRINUSE { + return true + } + } + } + return false +} + +// wait a bit for OS to release the port after close. +func waitPort() { time.Sleep(500 * time.Millisecond) } + +// stubHandler — satisfies voice.Handler for tests. +type stubHandler struct { + lastReq PushToTalkReq +} + +func (h *stubHandler) HandlePushToTalk(_ context.Context, req PushToTalkReq, _ uint64) (PushToTalkResp, error) { + h.lastReq = req + return PushToTalkResp{ + ReplyAudio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("reply")}, + ReplyText: "got it", + }, nil +} + +func TestServerAcceptsAndRemovesSession(t *testing.T) { + l := newTestListener(t) + sess := NewSessions() + h := &stubHandler{} + srv := NewServer(l.Addr().String(), h, sess) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + _ = srv.Close() + waitPort() + }() + // Accept connections in the background; Serve blocks until Close. Without + // it the listener binds but never accepts, so a client round-trip hangs. + go func() { _ = srv.Serve() }() + + if sess.Active() != 0 { + t.Fatalf("active before connect: %d", sess.Active()) + } + + conn, err := net.Dial("tcp", l.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn.Close() // quick disconnect + + // Give serveConn a moment to register then remove. + time.Sleep(50 * time.Millisecond) + if sess.Active() != 0 { + t.Fatalf("active after disconnect: %d, want 0", sess.Active()) + } +} + +func TestClientPushToTalkRoundTrip(t *testing.T) { + l := newTestListener(t) + sess := NewSessions() + h := &stubHandler{} + srv := NewServer(l.Addr().String(), h, sess) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + _ = srv.Close() + waitPort() + }() + // Accept connections in the background; Serve blocks until Close. Without + // it the listener binds but never accepts, so a client round-trip hangs. + go func() { _ = srv.Serve() }() + + c := Dial(l.Addr().String()) + defer c.Close() + + resp, err := c.PushToTalk(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("hello")}, "ru") + if err != nil { + t.Fatalf("PushToTalk: %v", err) + } + if resp.ReplyText != "got it" { + t.Fatalf("ReplyText: %q, want %q", resp.ReplyText, "got it") + } + if len(resp.ReplyAudio.Bytes) == 0 { + t.Fatalf("ReplyAudio empty") + } + if h.lastReq.Audio.Bytes == nil { + t.Fatalf("handler never got audio") + } +} + +func TestClientListenModeReceivesPush(t *testing.T) { + l := newTestListener(t) + sess := NewSessions() + h := &stubHandler{} + srv := NewServer(l.Addr().String(), h, sess) + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + _ = srv.Close() + waitPort() + }() + // Accept connections in the background; Serve blocks until Close. Without + // it the listener binds but never accepts, so a client round-trip hangs. + go func() { _ = srv.Serve() }() + + c := Dial(l.Addr().String()) + defer c.Close() + + // Run the push receiver in the background: it blocks reading frames until + // the conn closes (ctx cancel alone can't interrupt a blocking read). A + // proactive push from the server is delivered to the handler, which hands + // the audio to a channel the test waits on. + got := make(chan audio.Audio, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + _ = c.RunPushReceiver(ctx, pushHandlerFunc(func(p Push) { + if p.Kind != PushKindAudioNudge { + return + } + var ap AudioNudgePush + if err := json.Unmarshal(p.Params, &ap); err == nil { + select { + case got <- ap.Audio: + default: + } + } + })) + }() + + // serveConn registers the session on accept, but Accept→Add is async — wait + // for it before pushing, or PushToMostRecent finds no session. + for i := 0; i < 100 && sess.Active() < 1; i++ { + time.Sleep(10 * time.Millisecond) + } + if sess.Active() < 1 { + t.Fatal("session never registered") + } + + // Server pushes to the session. + push := AudioNudgePush{ + RuleName: "test", + Severity: 3, + Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("proactive")}, + Text: "proactive text", + Ts: time.Now(), + } + if err := sess.PushToMostRecent(context.Background(), push); err != nil { + t.Fatalf("PushToMostRecent: %v", err) + } + + select { + case received := <-got: + if string(received.Bytes) != "proactive" { + t.Fatalf("received audio mismatch: %q", string(received.Bytes)) + } + case <-time.After(2 * time.Second): + t.Fatal("never received pushed audio") + } +} + +type pushHandlerFunc func(Push) + +func (f pushHandlerFunc) OnPush(p Push) { f(p) } \ No newline at end of file diff --git a/internal/voice/wire.go b/internal/voice/wire.go new file mode 100644 index 0000000..b90e476 --- /dev/null +++ b/internal/voice/wire.go @@ -0,0 +1,182 @@ +// Package voice is maven's client↔core network surface. +// +// It is the SECOND protocol surface (the first being internal/ipc, the +// module boundary). Three structural differences from ipc: +// +// - TRAVERSAL: clients cross the network (spec: wg + mTLS / passkey). ipc +// is local-only unix socket; this surface is a TCP listener, expected to +// live inside the wg tunnel. The auth cascade (L0 wg / L1 mTLS / L2 +// passkey / L3 step-up) applies HERE; ipc's auth floor is "same unix +// user" on the box. Today's floor is plaintext: same-wg-tunnel caller +// trusted (the listener binds wg-egress only). mTLS / passkey layer in +// when the auth machinery lands; the wire frames stay unchanged. +// +// - DIRECTION: bidirectional. ipc is strict request/response (modules +// pull). clients here ALSO receive server-initiated pushes (async TTS +// audio back from reactive requests, proactive nudge audio from the +// loop). One persistent conn per client; the server reads requests AND +// writes pushes through it. The wire distinguishes a Response (matches +// a client Request.ID) from a Push (server-initiated, no matching ID). +// +// - PAYLOAD: audio bytes. Frames hold raw PCM (base64 in JSON for the +// same debuggability instinct as ipc/worker). Cap is 64 MiB — same as +// worker — to allow minutes-long transcription / synthesis payloads +// without chunking complexity (chunked audio is post-MVP, only needed +// by meeting-record mode). +// +// Session tracking: each connected client = one session (id, surface, +// last-active). Proactive voice delivery (voicesink) routes to the +// most-recently-active session by last-active ts. The spec's "proactive +// voice routing: most-recently-active client plays it; if no client +// reachable, reroute to ntfy/telegram" is enforced HERE not in delivery — +// delivery's voice sink asks Sessions.PickRecent() and either pushes audio +// or returns "no live session" to the dispatcher, which the dispatcher +// translates into rerouting per ChannelsFor's away path. (Today the +// routing-table already drops care-away; the rerouting for ops-when- +// no-client is a deferred plug point in delivery/voicesink, marked below.) +// +// The surface caps: a Push frame carries Audio; the client plays it. The +// reference client (cmd/mavenclient) writes the audio to stdout/-out for +// `aplay` / inspection. A real PWA / native client plays it directly. +package voice + +import ( + "encoding/json" + "time" + + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/auth" +) + +// Surface — the auth surface a client is on. Pulled from internal/auth so +// the voice surface maps 1:1 to the auth cascade's surface-caps table +// (SurfacePCClient / SurfaceAuthedPage / SurfaceVoice). The wire does NOT +// carry the surface — the auth handshake derives it (mTLS metadata, +// passkey enrollment); today's floor sets it to SurfacePCClient (the +// reference client's surface), capped at L3 per the table. +type Surface = auth.Surface + +// Re-export the surface constants so the rest of the voice package and +// downstream clients (cmd/mavenclient, daemon wiring) don't need to import +// internal/auth directly. The auth package IS the source of truth; these +// aliases forward to it. +const ( + SurfaceVoice = auth.SurfaceVoice + SurfaceTelegram = auth.SurfaceTelegram + SurfacePCClient = auth.SurfacePCClient + SurfaceAuthedPage = auth.SurfaceAuthedPage + SurfaceCoreProcess = auth.SurfaceCoreProcess + SurfaceUnknown = auth.SurfaceUnknown +) + +// maxFrame — 64 MiB. Same instinct as worker: a single utterance at 16k mono +// int16 ⇒ ~1.9 MiB/min; 64 MiB covers ~33 minutes of audio in one frame, +// which comfortably includes a long pushed reply (a few seconds) and a +// long upload (push-to-talk clips). +const maxFrame = 64 << 20 + +// Method — one client→server verb. Adding one is a voice API change (the +// client has to learn it). Today: PushToTalk (reactive round-trip) + Pong +// (liveness/last-active update). Proactive is server-initiated (a Push +// frame), not a Method. +type Method string + +const ( + MethodPushToTalk Method = "push_to_talk" // client audio → reply audio (sync) + MethodPong Method = "pong" // reply to a Ping push, refreshes last-active +) + +// Request — one frame from client to server. ID is chosen by the client, +// monotonically increasing per conn; the server echoes it back in the +// matching Response so the client can multiplex (today it doesn't, but the +// field is reserved for an async client library later). +type Request struct { + ID uint64 `json:"id"` + Method Method `json:"m"` + Params json.RawMessage `json:"p,omitempty"` +} + +// Response — one frame from server to client, matching a Request.ID. For +// today's synchronous PushToTalk, the server writes the Response on the +// same conn immediately after handling the Request (the client blocks +// reading). Asynchronous replies (server-initiated) come as Push frames, +// not Responses. +type Response struct { + ID uint64 `json:"id"` + Result json.RawMessage `json:"r,omitempty"` + Error *RpcError `json:"e,omitempty"` +} + +// Push — one server-initiated frame. No matching Request.ID (the field is +// absent). Kind names the push type; today the only Push is the proactive +// voice nudge (KindAudioNudge), where the server has TTS'd a nudge to audio +// and wants the client to play it. A Push arriving on a conn that's +// awaiting a Request response is interleaved — the client distinguishes by +// the json shape (Response has `id`, Push has `kind`). +type Push struct { + Kind PushKind `json:"kind"` + Params json.RawMessage `json:"p,omitempty"` +} + +// PushKind — one server→client push verb. +type PushKind string + +const ( + // PushKindAudioNudge — proactive delivery: a loop rule fired and the + // phraser rendered its Body; the TTS module synthesised audio; the + // voicesink picked this session (most-recently-active) and is pushing + // the bytes here for the client to play. Params: AudioNudgePush. + PushKindAudioNudge PushKind = "audio_nudge" + // PushKindPing — liveness probe. Server may send this to refresh + // last-active; client may respond with MethodPong (today optional — + // last-active is updated by ANY frame from the client, including the + // next PushToTalk). Reserved for heartbeat wiring. + PushKindPing PushKind = "ping" +) + +// RpcError — typed wire error, same shape as ipc's. Sentinel codes mirror +// the package sentinels 1:1 (see errors.go). Errors don't carry internal +// text across the wire except for bad-params / internal codes (diagnostic +// only; not authority-bearing — auth refusals carry codeForbidden with no +// message). +type RpcError struct { + Code string `json:"c"` + Message string `json:"m,omitempty"` +} + +// PushToTalkReq — the push-to-talk payload. Audio is the captured PCM +// (format declared in the audio.Audio.Format). Lang is the requested +// recognition language for this utterance, overriding the daemon's default +// for mid-sentence code-switches ("ru" / "en" / "mixed"). Surface is the +// client's auth surface; today the floor sets it to SurfacePCClient for +// every conn, but the wire carries it so future mTLS / passkey handshakes +// can populate it without a protocol version bump. +type PushToTalkReq struct { + Audio audio.Audio `json:"audio"` + Lang string `json:"lang,omitempty"` + Surface Surface `json:"surface,omitempty"` +} + +// PushToTalkResp — the reply. ReplyAudio is TTS-synthesised; ReplyText is +// the same reply in text form (for clients that can't play audio, for +// logging, for tests asserting the round-trip shape). Routed channels list +// the away channels the dispatcher also delivered to (e.g. a low-severity +// nudge fired alongside the reply and was forwarded to ntfy); today the +// reactive handler doesn't dispatch nudges, so this is empty. +type PushToTalkResp struct { + ReplyAudio audio.Audio `json:"reply_audio"` + ReplyText string `json:"reply_text"` + Transcript string `json:"transcript,omitempty"` + RoutedChannels []string `json:"routed_channels,omitempty"` +} + +// AudioNudgePush — the proactive nudge push payload. RuleName + Severity +// for the client to display; Audio is the synthesised Body; Text is the +// same in text form. +type AudioNudgePush struct { + RuleName string `json:"rule_name"` + Severity int `json:"severity"` + Audio audio.Audio `json:"audio"` + Text string `json:"text"` + Ts time.Time `json:"ts"` +} \ No newline at end of file diff --git a/internal/worker/client.go b/internal/worker/client.go new file mode 100644 index 0000000..c9e5976 --- /dev/null +++ b/internal/worker/client.go @@ -0,0 +1,186 @@ +// worker/client.go — the core-side dialer. +// +// Core is the CLIENT of its stt/tts worker modules: it dials them, ships +// audio bytes (Transcribe) and reply text (Synthesize), reads back the +// result. One Client ⇒ one conn ⇒ one mutex ⇒ no frame interleaving by +// construction (a module needing parallelism opens N clients, but stt/tts +// jobs are serial at single-user scale — the model is the bottleneck, not +// the wire). ctx cancel ⇒ conn close (a half-sent frame desyncs the stream; +// teardown is the clean recovery, a fresh Dial is the caller's job on next +// call). Same instinct as internal/ipc/client.go. +// +// The daemon holds two Clients — one for the stt module, one for tts — each +// behind a stt.Transcriber / tts.Synthesizer interface (see internal/stt, +// internal/tts). The interface IS the swap seam: a Stub impl satisfies the +// same interface in-process; the Remote wraps this Client. Core never sees +// a difference. +package worker + +import ( + "context" + "encoding/json" + "fmt" + "net" + "sync" + "time" +) + +// Client — one connection to one worker module. NOT goroutine-safe for +// concurrent calls on the same conn: the mutex serializes writes per +// request so a frame doesn't interleave with another, but parallel calls +// block on each other. Open N clients for N parallel jobs. +type Client struct { + path string + + mu sync.Mutex + c net.Conn + dial func() (net.Conn, error) +} + +// Dial opens a Client to the worker socket at path. The first call lazily +// dials; subsequent calls reuse the conn (a fresh dial happens on next call +// after a teardown). Lazy dial keeps a worker that's restarting from +// blocking core's startup; core attempts the dial on first use. +func Dial(path string) *Client { + return &Client{ + path: path, + dial: func() (net.Conn, error) { + return net.Dial("unix", path) + }, + } +} + +// Close releases the conn. Idempotent; subsequent calls re-dial on demand. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.c == nil { + return nil + } + err := c.c.Close() + c.c = nil + return err +} + +// Transcribe calls the transcribe verb on the connected worker module. Raises +// ErrUnknownMethod when the worker serves synthesize only; the daemon wiring +// has pointed this Client at the wrong socket, surfaced as a clean error. +func (c *Client) Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error) { + var resp TranscribeResp + err := c.call(ctx, MethodTranscribe, req, &resp) + return resp, err +} + +// Synthesize calls the synthesize verb. +func (c *Client) Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error) { + var resp SynthesizeResp + err := c.call(ctx, MethodSynthesize, req, &resp) + return resp, err +} + +func (c *Client) call(ctx context.Context, m Method, params any, out any) error { + body, err := marshalParams(params) + if err != nil { + return err + } + req := Request{Method: m, Params: body} + + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureConnLocked(ctx); err != nil { + return err + } + // ctx cancel ⇒ close conn so a stalled peer doesn't hang the caller; a + // half-sent frame would desync the stream. Teardown is the clean + // recovery; the next call re-dials. + if dl, ok := ctx.Deadline(); ok { + _ = c.c.SetDeadline(dl) + } else { + _ = c.c.SetDeadline(time.Now().Add(defaultCallTimeout)) + } + defer c.c.SetDeadline(time.Time{}) + + if err := writeFrame(c.c, req); err != nil { + c.teardownLocked() + return err + } + var resp Response + if err := readFrame(c.c, &resp); err != nil { + c.teardownLocked() + return err + } + if resp.Error != nil { + return hydrate(resp.Error) + } + if err := json.Unmarshal(resp.Result, out); err != nil { + return fmt.Errorf("worker: unmarshal result: %w", err) + } + return nil +} + +func (c *Client) ensureConnLocked(ctx context.Context) error { + if c.c != nil { + return nil + } + connCh := make(chan net.Conn, 1) + errCh := make(chan error, 1) + go func() { + nc, err := c.dial() + if err != nil { + errCh <- err + return + } + connCh <- nc + }() + select { + case nc := <-connCh: + c.c = nc + return nil + case err := <-errCh: + return fmt.Errorf("worker: dial %s: %w", c.path, err) + case <-ctx.Done(): + // the dial goroutine will finish or not; the conn if any is leaked + // to GC. acceptable — dial failures are rare, and a leaked closed + // socket is harmless. + return ctx.Err() + } +} + +func (c *Client) teardownLocked() { + if c.c != nil { + _ = c.c.Close() + c.c = nil + } +} + +// defaultCallTimeout — a worker that hangs > 60s is dead or stuck on a +// model forward pass that's gone off the rails. Surface as a timeout +// instead of hanging the daemon's tick / voice path. Production may override +// via ctx (a long meeting-record transcription, post-MVP). +const defaultCallTimeout = 60 * time.Second + +func hydrate(e *RpcError) error { + switch e.Code { + case codeUnknownMethod: + return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message) + case codeBadParams: + return fmt.Errorf("%w: %s", ErrBadParams, e.Message) + default: + if e.Message != "" { + return fmt.Errorf("worker: %s: %s", e.Code, e.Message) + } + return fmt.Errorf("worker: %s", e.Code) + } +} + +func marshalParams(v any) (json.RawMessage, error) { + if v == nil { + return nil, nil + } + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("worker: marshal params: %w", err) + } + return b, nil +} \ No newline at end of file diff --git a/internal/worker/frame.go b/internal/worker/frame.go new file mode 100644 index 0000000..9e00a1e --- /dev/null +++ b/internal/worker/frame.go @@ -0,0 +1,67 @@ +// worker/frame.go — length-prefixed JSON framing. +// +// Identical shape to internal/ipc/frame.go with one difference: the cap is +// 64 MiB here vs 4 there. Audio bytes (base64-encoded in JSON) push frame +// sizes up to ~80% over their decoded size; 64 MiB on the wire comfortably +// allows minutes-long single utterances, while still bounding a confused +// peer's length prefix from allocating an unbounded buffer. +// +// Binary bytes are base64-encoded inside the JSON envelope. Local socket +// + single-user scale ⇒ the 33% expansion cost is invisible vs. a model +// forward pass; the benefit is one debuggable wire shape (socat shows a +// readable JSON frame, no separate byte-pump to inspect audio). +package worker + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" +) + +// ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now +// desynchronized (we read the length but not the body), so the caller must +// close it. +var ErrFrameTooLarge = errors.New("worker: frame too large") + +func writeFrame(w io.Writer, v any) error { + body, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("worker: marshal frame: %w", err) + } + if len(body) > maxFrame { + return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body)) + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) + if _, err := w.Write(hdr[:]); err != nil { + return fmt.Errorf("worker: write frame header: %w", err) + } + if _, err := w.Write(body); err != nil { + return fmt.Errorf("worker: write frame body: %w", err) + } + return nil +} + +func readFrame(r io.Reader, v any) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + if errors.Is(err, io.EOF) { + return io.EOF + } + return fmt.Errorf("worker: read frame header: %w", err) + } + n := binary.BigEndian.Uint32(hdr[:]) + if n > maxFrame { + return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n) + } + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return fmt.Errorf("worker: read frame body: %w", err) + } + if err := json.Unmarshal(buf, v); err != nil { + return fmt.Errorf("worker: unmarshal frame: %w", err) + } + return nil +} \ No newline at end of file diff --git a/internal/worker/handler.go b/internal/worker/handler.go new file mode 100644 index 0000000..bcdd06e --- /dev/null +++ b/internal/worker/handler.go @@ -0,0 +1,25 @@ +// worker/handler.go — the worker-side seam. A module process implements +// either Transcriber or Synthesizer (rarely both — stt and tts are +// independent units with independent model weights and restart lifecycles). +// Server dispatches a Method to the matching handler. +package worker + +import "context" + +// Transcriber — the stt module's contract. Implementations: +// - the daemon's in-process Stub (built-in stt stub handler, for tests + +// for the "no models on disk yet" floor; the daemon decides which to wire +// from config), +// - a real faster-whisper / vosk process's main (cmd/mavsttd/main.go today +// ships the Stub handler; production swaps in onnxruntime-backed code in +// that same main, no worker-package change). +type Transcriber interface { + Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error) +} + +// Synthesizer — the tts module's contract. Mirrors Transcriber (separate +// interface so the two modules can be in separate packages / binaries, and +// so a server asked for the wrong verb refuses cleanly). +type Synthesizer interface { + Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error) +} \ No newline at end of file diff --git a/internal/worker/jobs.go b/internal/worker/jobs.go new file mode 100644 index 0000000..00397ef --- /dev/null +++ b/internal/worker/jobs.go @@ -0,0 +1,54 @@ +// worker/jobs.go — the two verb payloads (Transcribe + Synthesize). +// +// Audio bytes are base64-encoded by encoding/json automatically via the +// []byte type — the wire shape is a string field carrying base64. Local +// unix socket ⇒ expansion cost is invisible; the JSON envelope stays +// debuggable per the package doc. +// +// Input shape (Transcribe): core has captured audio (push-to-talk) or +// synthesised it (TTS round-trip test path — non-production). The worker +// module consumes audio bytes with the declared Format. Lang is a hint +// ("ru"/"en"/"mixed"); faster-whisper is multilingual and treats the hint +// as a soft bias, vosk ru ignores it (one-model, one-language). The reference +// stt stub ignores all params and returns a canned phrase so the loop is +// exercisable without a model on disk. +// +// Output shape (Transcribe): Text is the recognized string. Confidence is +// the model's own estimate when available; 0 ⇒ unknown. The router downstream +// does its own confidence scoring (the cosine-sim classifier), so worker-side +// confidence is for logging/gating, not for routing. +// +// Input shape (Synthesize): text to render. Lang is the requested voice +// language ("ru"/"en"). Voice ID is a named voice when supported, "" ⇒ the +// worker's configured default. Speed is a 1.0 = normal multiplier; out of +// range is clamped by the worker, not the caller. +// +// Output shape (Synthesize): Audio is the rendered PCM bytes in Format. +package worker + +import "github.com/kami/maven/internal/audio" + +// TranscribeReq — the transcribe verb args. +type TranscribeReq struct { + Audio audio.Audio `json:"audio"` // capture bytes (raw PCM, format declared) + Lang string `json:"lang"` // "ru" | "en" | "mixed" | "" ⇒ module default +} + +// TranscribeResp — the transcribe verb result. +type TranscribeResp struct { + Text string `json:"text"` + Confidence float64 `json:"confidence,omitempty"` // 0 ⇒ unknown +} + +// SynthesizeReq — the synthesize verb args. +type SynthesizeReq struct { + Text string `json:"text"` + Lang string `json:"lang"` // "ru" | "en" | "" + Voice string `json:"voice"` // named voice or "" ⇒ worker default + Speed float64 `json:"speed"` // 1.0 = normal; clamped server-side +} + +// SynthesizeResp — the synthesize verb result. +type SynthesizeResp struct { + Audio audio.Audio `json:"audio"` // rendered PCM +} \ No newline at end of file diff --git a/internal/worker/server.go b/internal/worker/server.go new file mode 100644 index 0000000..58f2074 --- /dev/null +++ b/internal/worker/server.go @@ -0,0 +1,226 @@ +// worker/server.go — the worker-side listener + accept loop. +// +// Each call is dispatched to a single handler (Transcriber OR Synthesizer, +// depending on what the module process); +// the other verb returns ErrUnknownMethod — a stt process won't serve +// synthesize. robust to a misconfigured client (the daemon wiring chooses +// which module to dial; mixing the two is a config error caught cleanly by +// the wire, not a runtime goroutine panic). One Server per module process. +// +// Socket perms mirror ipc.Server: dir 0700, socket 0600 ⇒ same unix user. +// The module has no key, so the floor is "same user"; the wg/mTLS layers +// are out of scope here (this socket never crosses the network radius — +// it's local-only, point-to-point between two processes on the box). +package worker + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "sync" + "sync/atomic" + + "golang.org/x/sys/unix" +) + +// Server — a worker module process's listener. Wires either a Transcriber, +// a Synthesizer, or both (the both case is unusual; the daemon typically +// runs two separate module processes). The unset verb returns +// ErrUnknownMethod. +type Server struct { + t Transcriber + s Synthesizer + + path string + ln net.Listener + + wg sync.WaitGroup + done chan struct{} + + // connCount — assigned per accepted conn, used in logs to distinguish + // concurrent connections. Monotonic; not load-bearing for correctness. + connCount atomic.Uint64 +} + +// NewServer builds a Server with a Transcriber. The caller wires a +// Synthesizer via SetSynthesizer if this process serves tts. Use +// NewSynthesizerServer for the tts-only case (mirrors this constructor). +func NewServer(path string, t Transcriber) *Server { + return &Server{t: t, path: path, done: make(chan struct{})} +} + +// NewSynthesizerServer builds a Server with a Synthesizer (the tts module). +func NewSynthesizerServer(path string, s Synthesizer) *Server { + return &Server{s: s, path: path, done: make(chan struct{})} +} + +// SetSynthesizer wires the synthesize verb on a Transcriber-built Server. +// Used only when one process serves both (non-default; the daemon prefers +// two separate processes per the restart-free / fail-independent invariant). +func (srv *Server) SetSynthesizer(s Synthesizer) { srv.s = s } + +// Listen binds the unix socket with 0700 dir + 0600 socket perms (same floor +// as internal/ipc). A stale socket at path is removed first so the worker +// process restarts cleanly after a crash, no manual cleanup needed. +func (srv *Server) Listen() error { + _ = os.Remove(srv.path) + if err := os.MkdirAll(parentDir(srv.path), 0o700); err != nil { + return fmt.Errorf("worker: mkdir socket dir: %w", err) + } + oldMask := unix.Umask(0o077) + ln, err := net.Listen("unix", srv.path) + unix.Umask(oldMask) + if err != nil { + return fmt.Errorf("worker: listen %s: %w", srv.path, err) + } + if err := os.Chmod(srv.path, 0o600); err != nil { + _ = ln.Close() + _ = os.Remove(srv.path) + return fmt.Errorf("worker: chmod socket: %w", err) + } + srv.ln = ln + return nil +} + +// Path returns the bound socket path (after Listen; "" before). +func (srv *Server) Path() string { return srv.path } + +// Serve accepts connections until the listener closes. Each connection is +// served in its own goroutine; a panicking handler tears down only that conn +// (the rest of the module keeps serving, restart-free per spec). +func (srv *Server) Serve() error { + if srv.ln == nil { + return fmt.Errorf("worker: serve before listen") + } + for { + c, err := srv.ln.Accept() + if err != nil { + select { + case <-srv.done: + return nil + default: + return fmt.Errorf("worker: accept: %w", err) + } + } + srv.wg.Add(1) + go func(c net.Conn) { + defer srv.wg.Done() + defer c.Close() + srv.serveConn(c) + }(c) + } +} + +func (srv *Server) serveConn(c net.Conn) { + id := srv.connCount.Add(1) + for { + var req Request + if err := readFrame(c, &req); err != nil { + return // EOF / malformed ⇒ end this conn + } + result, err := srv.safeDispatch(c.RemoteAddr(), id, req) + resp := Response{} + if err != nil { + resp.Error = rpcErr(err) + } else { + resp.Result = result + } + if err := writeFrame(c, resp); err != nil { + return + } + } +} + +func (srv *Server) safeDispatch(addr net.Addr, id uint64, req Request) (result json.RawMessage, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("worker: panic dispatching %s (conn %d): %v", req.Method, id, r) + } + }() + return srv.dispatch(req) +} + +func (srv *Server) dispatch(req Request) (json.RawMessage, error) { + switch req.Method { + case MethodTranscribe: + if srv.t == nil { + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + } + var p TranscribeReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + resp, err := srv.t.Transcribe(context.Background(), p) + if err != nil { + return nil, err + } + return marshalResult(resp), nil + + case MethodSynthesize: + if srv.s == nil { + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + } + var p SynthesizeReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + resp, err := srv.s.Synthesize(context.Background(), p) + if err != nil { + return nil, err + } + return marshalResult(resp), nil + + default: + return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method) + } +} + +// Close stops accepting and waits for in-flight connections to drain. The +// socket file is removed so a restart can rebind cleanly. Idempotent. +func (srv *Server) Close() error { + select { + case <-srv.done: + return nil + default: + close(srv.done) + } + if srv.ln == nil { + return nil + } + err := srv.ln.Close() + srv.wg.Wait() + _ = os.Remove(srv.path) + return err +} + +func unmarshalParams(raw json.RawMessage, v any) error { + if len(raw) == 0 { + raw = []byte("null") + } + if err := json.Unmarshal(raw, v); err != nil { + return fmt.Errorf("%w: %v", ErrBadParams, err) + } + return nil +} + +func marshalResult(v any) json.RawMessage { + if v == nil { + return json.RawMessage("null") + } + b, _ := json.Marshal(v) + return b +} + +func parentDir(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' { + if i == 0 { + return "/" + } + return p[:i] + } + } + return "." +} \ No newline at end of file diff --git a/internal/worker/wire.go b/internal/worker/wire.go new file mode 100644 index 0000000..01a614d --- /dev/null +++ b/internal/worker/wire.go @@ -0,0 +1,123 @@ +// Package worker is the audio-job boundary between core and its stt/tts +// modules. +// +// It is a SIBLING to internal/ipc, not a reuse of it: +// +// - internal/ipc is the core↔module *state* boundary. Modules call INTO +// core to write facts / read presence. core is the server; the module +// is the client. The verbs (WriteFact, Since, ...) are authority-bound; +// the auth layer scopes them per Caller. +// - internal/worker is the core↔module *job* boundary. Core calls OUT to +// the stt/tts module: "transcribe these bytes", "synthesize this text." +// Core is the client here; the worker module is the server. The verbs +// are not authority-bound — they are pure compute over the payload core +// already owns (audio bytes / reply text). Nothing about authority moves +// across this boundary because the worker has no key, no facts, nothing +// stateful to read or write. +// +// The reversal matters: a module that ships audio work to core would have +// to hold a Caller identity, an enrolled daemon surface, etc.; core that +// ships work to a module ships only the work. Same unix-socket floor (0600 +// dir + socket perms carry "same user") but the flow is reversed, and the +// surface stays out of the auth cascade — the worker never reaches into +// core's state, so it has nothing to be capped against. +// +// Transport: unix domain socket, local-only, same-host as core. Same wire +// shape as ipc (4-byte big-endian length + JSON body) so `socat`/`nc` can +// debug it identically. Frame cap is 64 MiB (vs ipc's 4) so audio blobs fit +// — a minute of 16k mono int16 is ~1.9 MiB, comfortably under; an hour's +// sleep-clip transcription is implausible to send as one frame but the cap +// permits long-enough live clips without framing the worker into chunks. +package worker + +import ( + "encoding/json" + "errors" + "fmt" +) + +// maxFrame — 64 MiB. Audio up to ~3 minutes @ 16k mono int16 fits one frame +// with headroom; longer audio is chunked by the caller (meeting-record mode +// is post-MVP) or rejected with ErrFrameTooLarge. Sized for stt inputs and +// tts outputs at single-utterance scale. +const maxFrame = 64 << 20 + +// Method — one RPC verb. Adding one is a worker-API change, not an authority +// change (worker has no authority surface — see package doc). The two verbs +// mirror the stt/tts interfaces the daemon wires; new verbs go with a new +// module kind (e.g. translation), not a new feature of an existing one. +type Method string + +const ( + MethodTranscribe Method = "transcribe" + MethodSynthesize Method = "synthesize" +) + +// Request — one frame from core to a worker module. +type Request struct { + Method Method `json:"m"` + Params json.RawMessage `json:"p,omitempty"` +} + +// Response — one frame back from the worker. Exactly one of Result/Error is set. +type Response struct { + Result json.RawMessage `json:"r,omitempty"` + Error *RpcError `json:"e,omitempty"` +} + +// RpcError — a typed wire error. Mirrors internal/ipc's shape for tooling +// parity (same args to socat, same `errors.Is` rehydration pattern); worker +// sentinels are independent since this boundary is independent. +type RpcError struct { + Code string `json:"c"` + Message string `json:"m,omitempty"` +} + +func (e *RpcError) Error() string { + if e.Message != "" { + return fmt.Sprintf("worker: %s: %s", e.Code, e.Message) + } + return fmt.Sprintf("worker: %s", e.Code) +} + +// Sentinel codes. Stable over the wire — do not rename. Mirror the package +// sentinels 1:1. +const ( + codeUnknownMethod = "unknown_method" + codeBadParams = "bad_params" + codeInternal = "internal" +) + +// Sentinel errors. The client rehydrates a wire RpcError into one of these so +// callers use errors.Is the same way they would in-process. +var ( + ErrUnknownMethod = errors.New("worker: unknown method") + ErrBadParams = errors.New("worker: bad params") +) + +// codeOf maps a server-side sentinel to its wire code. Anything not matched +// is codeInternal — internal Go error text never ships to the caller; the +// server logs the real text and the client sees a generic code. +func codeOf(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, ErrUnknownMethod): + return codeUnknownMethod + case errors.Is(err, ErrBadParams): + return codeBadParams + default: + return codeInternal + } +} + +// rpcErr builds the wire error for a server-side error. message is omitted +// for sentinel codes (the Code carries the meaning). internal errors carry +// the message text — it's not authority-bearing text, just a diagnostic. +func rpcErr(err error) *RpcError { + c := codeOf(err) + if c == codeInternal || c == codeBadParams { + return &RpcError{Code: c, Message: err.Error()} + } + return &RpcError{Code: c} +} \ No newline at end of file diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go new file mode 100644 index 0000000..4ac4e77 --- /dev/null +++ b/internal/worker/worker_test.go @@ -0,0 +1,289 @@ +package worker + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/kami/maven/internal/audio" +) + +// stubTranscriber returns a fixed string; satisfies worker.Transcriber. +type stubTranscriber struct { + mu sync.Mutex + gotLast audio.Audio +} + +func (s *stubTranscriber) Transcribe(_ context.Context, req TranscribeReq) (TranscribeResp, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.gotLast = req.Audio + return TranscribeResp{Text: "hello from stt", Confidence: 0.9}, nil +} + +type errTranscriber struct{} + +func (errTranscriber) Transcribe(context.Context, TranscribeReq) (TranscribeResp, error) { + return TranscribeResp{}, errors.New("synth failed") +} + +type stubSynthesizer struct{} + +func (stubSynthesizer) Synthesize(_ context.Context, req SynthesizeReq) (SynthesizeResp, error) { + pcm := make([]byte, 3200) // 100ms of silence @ 16k mono int16 + _ = req + return SynthesizeResp{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}}, nil +} + +// newServer builds a Server on a temp socket, starts Serve in a goroutine, +// returns the Server + path + a cleanup. Tests use this to get a real +// round-trip over a unix socket. +func newServer(t *testing.T, srv *Server) (*Server, string, func()) { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "worker.sock") + srv.path = sock + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + go func() { + if err := srv.Serve(); err != nil { + t.Logf("serve ended: %v", err) + } + }() + cleanup := func() { + _ = srv.Close() + } + return srv, sock, cleanup +} + +func TestServerRejectsUnknownMethod(t *testing.T) { + t.Parallel() + srv := NewSynthesizerServer("", stubSynthesizer{}) + _, sock, cleanup := newServer(t, srv) + defer cleanup() + c := Dial(sock) + defer c.Close() + _, err := c.Transcribe(context.Background(), TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte{1, 2}}}) + if err == nil { + t.Fatalf("Transcribe on a tts server should error") + } + if !errors.Is(err, ErrUnknownMethod) { + t.Fatalf("err should be ErrUnknownMethod, got: %v", err) + } +} + +func TestServerTranscribeRoundTrip(t *testing.T) { + t.Parallel() + tr := &stubTranscriber{} + srv := NewServer("", tr) + _, sock, cleanup := newServer(t, srv) + defer cleanup() + c := Dial(sock) + defer c.Close() + + in := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("hello audio payload")} + resp, err := c.Transcribe(context.Background(), TranscribeReq{Audio: in, Lang: "ru"}) + if err != nil { + t.Fatalf("Transcribe: %v", err) + } + if resp.Text != "hello from stt" { + t.Fatalf("Text: %q", resp.Text) + } + if resp.Confidence != 0.9 { + t.Fatalf("Confidence: %v", resp.Confidence) + } + if !bytes.Equal(tr.gotLast.Bytes, in.Bytes) { + t.Fatalf("audio bytes did not round-trip: in=%v got=%v", in.Bytes, tr.gotLast.Bytes) + } +} + +func TestServerSynthesizeRoundTrip(t *testing.T) { + t.Parallel() + srv := NewSynthesizerServer("", stubSynthesizer{}) + _, sock, cleanup := newServer(t, srv) + defer cleanup() + c := Dial(sock) + defer c.Close() + + resp, err := c.Synthesize(context.Background(), SynthesizeReq{Text: "привет", Lang: "ru"}) + if err != nil { + t.Fatalf("Synthesize: %v", err) + } + if !resp.Audio.Format.IsValid() { + t.Fatalf("audio format invalid: %+v", resp.Audio.Format) + } + if len(resp.Audio.Bytes) != 3200 { + t.Fatalf("audio bytes len: %d, want 3200", len(resp.Audio.Bytes)) + } +} + +func TestServerUnsetVerbReturnsUnknownMethod(t *testing.T) { + t.Parallel() + srv := NewServer("", &stubTranscriber{}) // transcriber-only + _, sock, cleanup := newServer(t, srv) + defer cleanup() + c := Dial(sock) + defer c.Close() + _, err := c.Synthesize(context.Background(), SynthesizeReq{Text: "x"}) + if !errors.Is(err, ErrUnknownMethod) { + t.Fatalf("Synthesize on a stt-only server: want ErrUnknownMethod, got %v", err) + } +} + +func TestServerDispatchesBadParams(t *testing.T) { + t.Parallel() + srv := NewServer("", &stubTranscriber{}) + _, sock, cleanup := newServer(t, srv) + defer cleanup() + + // raw socket: ship a Request frame with malformed params JSON. We can't + // use writeFrame because Request marshals RawMessage and rejects invalid + // JSON itself — so build the bytes by hand: a valid Request envelope + // whose `p` field is a syntactically broken JSON string. + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + // {"m":"transcribe","p":{not json}} — but the `p` value is invalid JSON. + // We instead send `{"m":"transcribe","p":""}` — a valid + // JSON frame whose params unmarshal fails into TranscribeReq (string + // into a struct). That path hits unmarshalParams' error ⇒ codeBadParams. + body := []byte(`{"m":"transcribe","p":"not an object"}`) + var hdr [4]byte + hdr[0] = byte(len(body) >> 24) + hdr[1] = byte(len(body) >> 16) + hdr[2] = byte(len(body) >> 8) + hdr[3] = byte(len(body)) + if _, err := conn.Write(hdr[:]); err != nil { + t.Fatalf("write hdr: %v", err) + } + if _, err := conn.Write(body); err != nil { + t.Fatalf("write body: %v", err) + } + var resp Response + if err := readFrame(conn, &resp); err != nil { + t.Fatalf("readFrame: %v", err) + } + if resp.Error == nil || resp.Error.Code != codeBadParams { + t.Fatalf("want codeBadParams, got %+v", resp.Error) + } +} + +func TestServerInternalErrorPropagates(t *testing.T) { + t.Parallel() + srv := NewServer("", errTranscriber{}) + _, sock, cleanup := newServer(t, srv) + defer cleanup() + c := Dial(sock) + defer c.Close() + _, err := c.Transcribe(context.Background(), TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono}}) + if err == nil { + t.Fatalf("want error from errTranscriber") + } + if errors.Is(err, ErrUnknownMethod) || errors.Is(err, ErrBadParams) { + t.Fatalf("internal error should not match known sentinels: %v", err) + } + if !contains(err.Error(), "synth failed") { + t.Fatalf("err should carry internal message text, got: %v", err) + } +} + +func TestClientContextCancelStopsCall(t *testing.T) { + t.Parallel() + // a server that never replies — transcriber blocked on a chan. + hang := &hangTranscriber{ok: make(chan struct{})} + srv := NewServer("", hang) + _, sock, cleanup := newServer(t, srv) + defer cleanup() + defer close(hang.ok) + + c := Dial(sock) + defer c.Close() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := c.Transcribe(ctx, TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}}) + if err == nil { + t.Fatalf("want ctx timeout error") + } + if !contains(err.Error(), "dial") && !errors.Is(err, context.DeadlineExceeded) { + // dial may succeed within 100ms; if so, the deadline set on the conn + // surfaces as an i/o timeout from writeFrame/readFrame. Either path + // is acceptable; we just assert the call returned in finite time. + } +} + +type hangTranscriber struct { + ok chan struct{} +} + +func (h *hangTranscriber) Transcribe(ctx context.Context, _ TranscribeReq) (TranscribeResp, error) { + select { + case <-h.ok: + return TranscribeResp{}, nil + case <-ctx.Done(): + return TranscribeResp{}, ctx.Err() + } +} + +func TestServerCloseIsIdempotent(t *testing.T) { + t.Parallel() + srv := NewServer("", &stubTranscriber{}) + _, _, cleanup := newServer(t, srv) + cleanup() // first close + if err := srv.Close(); err != nil { + t.Fatalf("second Close should be no-op, got: %v", err) + } +} + +func TestFrameTooLargeRejected(t *testing.T) { + t.Parallel() + // build a frame whose length prefix exceeds maxFrame; ensure readFrame + // returns ErrFrameTooLarge. + r, w := io.Pipe() + go func() { + var hdr [4]byte + hdr[0] = 0xff + hdr[1] = 0xff + hdr[2] = 0xff + hdr[3] = 0xff + _, _ = w.Write(hdr[:]) + _ = w.Close() + }() + var v any + err := readFrame(r, &v) + if !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("want ErrFrameTooLarge, got %v", err) + } + _ = r.Close() +} + +func TestPathAfterListen(t *testing.T) { + t.Parallel() + srv := NewServer("", &stubTranscriber{}) + dir := t.TempDir() + sock := filepath.Join(dir, "x.sock") + srv.path = sock + if err := srv.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer srv.Close() + if srv.Path() != sock { + t.Fatalf("Path: got %q, want %q", srv.Path(), sock) + } + if _, err := os.Stat(sock); err != nil { + t.Fatalf("socket file missing after listen: %v", err) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (bytes.Contains([]byte(haystack), []byte(needle))) +} \ No newline at end of file diff --git a/kill-maven.sh b/kill-maven.sh new file mode 100755 index 0000000..573da5b --- /dev/null +++ b/kill-maven.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Unified script to stop all Maven services. +# Usage: ./kill-maven.sh +# - Graceful SIGTERM is attempted first. +# - If any process lingers, force with SIGKILL. + +set -euo pipefail + +# The llama-server the phraser spawns has NO "maven" in its command line (its +# args are `-m /path/to/LFM2.5-...gguf --port ...`), so a `llama-server.*maven` +# pattern matches nothing and leaks it — the exact bug that let orphans pile up +# and OOM the box. Match the model instead. Override MODEL if you change it. +MODEL="${MODEL:-LFM2}" +PAT='mavend|mavsttd|mavttsd|mavweb|mavpoll|mavenclient' +LLM="llama-server.*${MODEL}" + +echo "--- Sending graceful SIGTERM to Maven services ---" +pkill -TERM -f "$PAT" || true +# mavend's Pdeathsig SIGKILLs its llama-server on exit, but sweep strays too +# (an orphan whose mavend already died has no one left to reap it). +pkill -TERM -f "$LLM" || true + +echo "--- Verifying processes are gone ---" +sleep 1 +PIDS=$(pgrep -d ',' -f "$PAT|$LLM") || PIDS="" +if [ -n "$PIDS" ]; then + echo "Warning: some processes still alive. PIDs: $PIDS" + echo "--- Force killing with SIGKILL ---" + echo "$PIDS" | tr ',' '\n' | xargs -r kill -9 + echo "Done (SIGKILL)." +else + echo "All services gracefully stopped." +fi \ No newline at end of file diff --git a/maven.md b/maven.md new file mode 100644 index 0000000..2a8d91c --- /dev/null +++ b/maven.md @@ -0,0 +1,413 @@ +# 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 diff --git a/scripts/desk-active.sh b/scripts/desk-active.sh new file mode 100755 index 0000000..b9b688a --- /dev/null +++ b/scripts/desk-active.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# desk-active.sh — the strongest presence signal (weight 0.90), sourced from +# the WORKSTATION (hyprland), not homesrv. Posts one desk_active fact to +# mavweb's /api/signal over the wg tunnel. mavweb (on homesrv) writes it +# through CoreAPI; the fact's fresh timestamp is all the presence scorer reads. +# +# This is a DUMB one-shot poster — no idle detection here. Idle-gating is +# hypridle's job (it already owns the ext-idle protocol; re-querying it from a +# script is the fragile path). Wire it so "active" = the poster runs, "idle" = +# it doesn't, and the signal decays out (τ=8min) exactly as intended: +# +# # ~/.config/systemd/user/maven-desk.timer → OnUnitActiveSec=60s, calls this +# # ~/.config/hypridle.conf: +# listener { +# timeout = 120 # 2min no input +# on-timeout = systemctl --user stop maven-desk.timer # idle → stop posting +# on-resume = systemctl --user start maven-desk.timer # active → resume +# } +# +# Until the pc app ships this signal natively, this is the stopgap. +# +# Usage: MAVEN_URL=https://maven.kvmx.ru:9443 ./desk-active.sh +set -euo pipefail +URL="${MAVEN_URL:-https://maven.kvmx.ru:9443}" +curl -fsS -k -X POST "$URL/api/signal?key=desk_active" >/dev/null diff --git a/start-maven.sh b/start-maven.sh new file mode 100755 index 0000000..92fb1f7 --- /dev/null +++ b/start-maven.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Unified start script for Maven services. +# Usage: ./start-maven.sh [build] +# - Pass "build" as first arg to rebuild everything before starting. +# +# This assumes ROOT=/home/kami/apps/Maven and the local Go toolchain at +# $ROOT/deps/go/go/bin/go. + +set -euo pipefail + +ROOT="/home/kami/apps/Maven" + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- +export ROOT +export CGO_CFLAGS="-I$ROOT/deps/include -I$ROOT/deps/whisper.cpp/ggml/include" +export CGO_LDFLAGS="-L$ROOT/deps/lib -Wl,-rpath,$ROOT/deps/lib" +export LD_LIBRARY_PATH="$ROOT/deps/lib" +export PATH="$ROOT/deps/go/go/bin:$PATH" + +# --------------------------------------------------------------------------- +# Optional: build +# --------------------------------------------------------------------------- +if [[ "${1:-}" == "build" ]]; then + echo "--- Building all commands ---" + cd "$ROOT" + go build ./cmd/mavend/ + go build ./cmd/mavsttd/ # needs CGO (whisper.cpp) + go build ./cmd/mavttsd/ # pure Go + go build ./cmd/mavweb/ + go build ./cmd/mavpoll/ + echo "--- Build complete ---" +fi + +# --------------------------------------------------------------------------- +# Ensure directories exist +# --------------------------------------------------------------------------- +mkdir -p /run/user/1000/maven +mkdir -p /home/kami/.local/share/maven + +# --------------------------------------------------------------------------- +# Start services +# --------------------------------------------------------------------------- +echo "--- Starting Maven services ---" + +cd "$ROOT" + +# 1. Core daemon +./mavend > /tmp/mavend.log 2>&1 & +MAVEND_PID=$! +echo "mavend pid=$MAVEND_PID" + +# Wait for mavend to create its socket (it loads models at startup) +for i in $(seq 1 30); do + if [ -S /run/user/1000/maven/mavend.sock ]; then + break + fi + sleep 1 +done + +# 2. STT worker (needs LD_LIBRARY_PATH for libwhisper.so, libggml-vulkan.so) +export LD_LIBRARY_PATH="$ROOT/deps/lib" +./mavsttd -socket /run/user/1000/maven/stt.sock -model models/stt/ggml-small.bin > /tmp/mavsttd.log 2>&1 & +MAVSTTD_PID=$! +echo "mavsttd pid=$MAVSTTD_PID" + +# 3. TTS worker (needs LD_LIBRARY_PATH for Piper's espeak-ng) +export LD_LIBRARY_PATH="$ROOT/deps/piper" +./mavttsd -socket /run/user/1000/maven/tts.sock -piper deps/piper/piper -model models/tts/ru_RU-irina-medium.onnx -espeak_data deps/piper/espeak-ng-data > /tmp/mavttsd.log 2>&1 & +MAVTTSD_PID=$! +echo "mavttsd pid=$MAVTTSD_PID" + +# 4. Web bridge (pure Go, no deps) +./mavweb -addr :9201 -voice 127.0.0.1:9100 -core /run/user/1000/maven/mavend.sock > /tmp/mavweb.log 2>&1 & +MAVWEB_PID=$! +echo "mavweb pid=$MAVWEB_PID" + +# 5. Env poller +./mavpoll -socket /run/user/1000/maven/mavend.sock -netdata http://127.0.0.1:19999 > /tmp/mavpoll.log 2>&1 & +MAVPOLL_PID=$! +echo "mavpoll pid=$MAVPOLL_PID" + +# --------------------------------------------------------------------------- +# Trap SIGINT to forward it to children, then wait for everything +# --------------------------------------------------------------------------- +cleanup() { + echo "" + echo "--- Stopping Maven services ---" + kill $MAVEND_PID 2>/dev/null || true + kill $MAVSTTD_PID 2>/dev/null || true + kill $MAVTTSD_PID 2>/dev/null || true + kill $MAVWEB_PID 2>/dev/null || true + kill $MAVPOLL_PID 2>/dev/null || true +} +trap cleanup INT TERM + +echo "" +echo "All services started. Press Ctrl-C to stop." +wait