initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+22
View File
@@ -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/
+72
View File
@@ -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
+131
View File
@@ -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 (sev12 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 23 (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
+179
View File
@@ -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=<base64-token>'
```
`<base64-token>` 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 <API_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/
```
+22
View File
@@ -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
)
+55
View File
@@ -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=
+86
View File
@@ -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
}
+104
View File
@@ -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")
}
}
+121
View File
@@ -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
}
+393
View File
@@ -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
}
+112
View File
@@ -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)
}
+120
View File
@@ -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
}
+168
View File
@@ -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
}
+39
View File
@@ -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
}
+94
View File
@@ -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
}
}
+297
View File
@@ -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())
}
+131
View File
@@ -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)
}
}
+20
View File
@@ -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)
}
+26
View File
@@ -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
}
+94
View File
@@ -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 |
// | sev12 (care) | voice | drop |
// | sev3 (ops soft) | voice | ntfy, once |
// | sev4 (ops hard) | voice + ntfy | telegram, repeat til ack |
//
// - sev ≤ 2 drops on away, sev ≥ 3 holds. "a missed water nudge is noise;
// a missed backup-failure isn't."
// - away channels (ntfy/telegram) leave the box — the one path that crosses
// "never phones home," through your relay. MINIMAL BODY — "disk low on
// homesrv," not detail. don't make notifications a shoulder-surf exfil
// surface.
// - 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}
}
+242
View File
@@ -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
}
}
+491
View File
@@ -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)
}
}
+142
View File
@@ -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 15 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 15.
//
// 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 sev12 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
}
+313
View File
@@ -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")
}
}
+56
View File
@@ -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
}
+32
View File
@@ -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
}
@@ -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 15;
// 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<token>/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"
}
@@ -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)
}
}
+109
View File
@@ -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
+273
View File
@@ -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")
)
+262
View File
@@ -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)
+86
View File
@@ -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
}
+330
View File
@@ -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
}
+670
View File
@@ -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
}
+130
View File
@@ -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}
}
+122
View File
@@ -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:<rule>)` 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:<rule>` 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)
}
+152
View File
@@ -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
}
+150
View File
@@ -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
}
+137
View File
@@ -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 (sev12) 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 — sev12 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)
}
+382
View File
@@ -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)
}
}
+151
View File
@@ -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(),
}
}
+109
View File
@@ -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:
//
// sev12: 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 sev12 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
}
+300
View File
@@ -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
}
+193
View File
@@ -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"
}
}
+223
View File
@@ -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.)
+160
View File
@@ -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
}
+97
View File
@@ -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
}
+39
View File
@@ -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))
}
}
+96
View File
@@ -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
}
+316
View File
@@ -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)
+99
View File
@@ -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)
}
+315
View File
@@ -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)
}
}
+329
View File
@@ -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 <num> <unit>" 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 <clock>" 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
}
+30
View File
@@ -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)
}
}
}
+55
View File
@@ -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
},
},
}
}
+182
View File
@@ -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
}
+131
View File
@@ -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 (hundredsthousands) 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
}
+38
View File
@@ -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)
}
}
+178
View File
@@ -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
}
+113
View File
@@ -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) }
+61
View File
@@ -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
}
+145
View File
@@ -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)
}
}
+87
View File
@@ -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(&current)
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
}
+84
View File
@@ -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
);
+96
View File
@@ -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")
)
+290
View File
@@ -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)
}
}
+133
View File
@@ -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
}
+112
View File
@@ -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
}
+125
View File
@@ -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")
}
}
+135
View File
@@ -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)
}
+87
View File
@@ -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)")
}
}
+93
View File
@@ -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
}
+138
View File
@@ -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
}
+240
View File
@@ -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
}
+73
View File
@@ -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) }
+60
View File
@@ -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
}
+84
View File
@@ -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 "приняла."
}
}
+235
View File
@@ -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.
+177
View File
@@ -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
}
+223
View File
@@ -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) }
+182
View File
@@ -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"`
}
+186
View File
@@ -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
}
+67
View File
@@ -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
}
+25
View File
@@ -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)
}
+54
View File
@@ -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
}
+226
View File
@@ -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 "."
}
+123
View File
@@ -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}
}
+289
View File
@@ -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":"<not an object>"}` — 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)))
}
Executable
+33
View File
@@ -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
+413
View File
@@ -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.51gb + tts ~1gb → ~10gb left for the router llm + os, comfortable, no swap. silero row is prior knowledge, no source pulled — **confirm license + current ru voices before wiring in.** bilingual / mid-sentence code-switch → lean on faster-whisper, not vosk.
---
## state — signal sources
**explicit taps set truth. passive signals drive prompting.** a passive signal can never write a self-fact — it only makes maven ask. boundary lives in `source`; rules trust provenance.
- **self / taps (1.0):** water, meal, shower — phone / voice / telegram
- **self / passive (activity, not truth):** desk-idle (hyprland / GetLastInputInfo), voice_active, sleep
- **presence (weak, decaying, multi-source):** wg handshake, page heartbeat, hyprland-not-idle. *no LAN sweeps* — too invasive; wg+heartbeat get ~90%
- **env / polled:** healthcheck (disk/service/backup/cert), calendar (caldav/.ics), weather
- **provenance-scoping:** a rule on `service_down` trusts only `source=poll:healthcheck`. a compromised poller must not be able to forge a trigger
### presence — concrete scoring
pinned: pure function over recent facts, computed each tick; decaying confidence over multiple weak signals, never one authoritative source; hysteresis to stop flapping.
**combiner — noisy-OR, not weighted sum.** these are independent-ish positive indicators of one latent binary ("kami here?").
```
p_i = weight_i · exp(-Δt_i / τ_i) # one signal's decayed contribution
P = 1 Π (1 p_i) # combine
```
diminishing returns on stacking weak signals, never exceeds 1.0. "any one signal raises confidence, no single one owns it" falls straight out. weighted-sum rejected: heartbeat+wg fresh would peg identical to everything-fresh — overcounts.
**signals — weights + decay.** `Δt` = `now latest fact ts` for that key. no fact → drops out of the product (not a zero).
| signal | source | fresh weight | τ (min) | rationale |
|---|---|---|---|---|
| desk active | `infer:hyprland` (not-idle) | 0.90 | 8 | input = human at keyboard. strongest. τ forgives read/think gaps |
| page heartbeat | `infer:heartbeat` | 0.60 | 4 | a surface you use is open + alive. pings ~30s; 4min gap = gone |
| wg handshake | `infer:wg` | 0.40 | 20 | device on tunnel. coarse — pocket or three rooms away. weakest, slowest |
**threshold + hysteresis (schmitt trigger):**
```
ENTER (away → present): P ≥ 0.55
EXIT (present → away): P < 0.30
cold start: away # fail-closed; same as since(key)==null → don't fire
```
wide band = stable. a lone fresh wg (0.40) can't *enter* present but *holds* it while decaying — phone-on-network alone never declares you here.
| scenario | P | bucket |
|---|---|---|
| at desk, typing | ~0.90 | present |
| at desk, 5min reading (client open) | ~0.79 | present |
| desk-only, ~9min zero input | <0.30 | → away |
| couch, phone page open, no desk | 0.60 | present |
| left house, only wg lingering | decays ~20min | → away |
| cold boot, nothing | 0 | away |
implementation (core — presence reads State under the lock, predicate input, not a module):
```kotlin
data class Signal(val key: String, val weight: Double, val tauMin: Double)
val SIGNALS = listOf(
Signal("desk_active", 0.90, 8.0),
Signal("page_heartbeat", 0.60, 4.0),
Signal("wg_handshake", 0.40, 20.0),
)
fun presenceScore(now: Instant, state: State): Double {
var pAway = 1.0
for (s in SIGNALS) {
val last = state.lastTs(s.key) ?: continue // no data → drops out
val dt = Duration.between(last, now).toMillis() / 60000.0
pAway *= (1.0 - s.weight * exp(-dt / s.tauMin)) // noisy-OR
}
return 1.0 - pAway
}
fun resolve(score: Double, last: Bucket) = when (last) {
PRESENT -> if (score < 0.30) AWAY else PRESENT
AWAY -> if (score >= 0.55) PRESENT else AWAY
}
```
each tick: `score = presenceScore(now, state)``bucket = resolve(score, state.lastBucket)` → persist `presence_state`. decay uses wall-clock Δt, so 60s tick jitter causes no drift.
boundaries: **hand-tuned, NOT feedback-tuned** — keep presence numbers out of the auto-tuner or a weird week drifts you silently invisible. **presence = reachability, not wakefulness** — sleep/quiet-hours handled separately in the gate. feeds delivery routing directly. caveat (stated, not litigated): noisy-OR assumes independence; desk+heartbeat correlate (pc client open ⇒ both fire) — co-firing slightly overcounts, which is fine, genuinely co-firing IS stronger evidence.
---
## proactive
### listening — three modes, not one
"ambient listening" was the wrong frame — three capabilities split by trigger + retention. threat model is local-only: the file at rest (sqlcipher, extend to any retained audio/transcript) and who can reach the box (auth). that's it.
1. **address-capture** — "maven, note this" out loud. hands-free voice path, no tap. raw audio dies after deriving the fact. **← mvp pick.** lowest retention, highest daily payoff, ~free (voice path minus the tap)
2. **meeting-record** — deliberate start/stop, verbatim transcript *kept*. retention is the point (overrides any "always delete" rule). post-mvp
3. **ambient-derive** — background overhearing, low-confidence candidate facts, raw ephemeral, only derived fact survives. needs the confidence model working first. last
only mode 3 is always-on. modes 12 fire heavy transcription on explicit trigger/record → ryzen idles. always-on = lightweight VAD (+ maybe owner-detect) only. **speaker-scoping = attribution metadata, not a kill-gate** — tag `source=ambient:self | ambient:other`. per-person retention becomes a `WHERE` clause — "delete what you have from gf" is a query, not a problem.
### delivery / channel routing
routing = `f(severity, presence)`. presence decides *reachability*; severity decides *insistence*. need both.
| | present | away |
|---|---|---|
| **sev12** (care) | voice | **drop** |
| **sev3** (ops, soft) | voice | ntfy, once |
| **sev4** (ops, hard) | voice + ntfy | telegram, repeat til ack |
sev ≤ 2 drops on away, sev ≥ 3 holds. a missed water nudge is noise; a missed backup-failure isn't. away-channels (ntfy/telegram) leave the box — the one path that crosses "never phones home," through your relay. **minimal body** — "disk low on homesrv," not detail. don't make notifications a shoulder-surf exfil surface.
### feedback loop (outcomes → tune cooldowns)
the `nudges.outcome` column IS the signal — no new storage. cooldown becomes a function of recent outcomes, not a constant.
- mostly `ignored` → nagging into the void → lengthen cooldown / raise threshold
- mostly `acted` → landing → leave, or cautiously shorten
- mostly `snoozed` → right nudge wrong *time* → shift the window, not the frequency
**tunes parameters, never logic.** can widen cooldown, nudge threshold, shift window. cannot rewrite a predicate or invent a rule. bounded knobs (`cooldown ∈ [min,max]`) so a weird week can't mutate maven silent or stalker. **dead simple at mvp:** a ratio over last N, not a learned model. `ignored_rate > 0.7 → cooldown *= 1.5, capped`. introspectable. **persist the adjusted cooldown as a fact** (`source=feedback`) — survives restart, stays visible; why maven went quiet should be a query, not a mystery.
---
## auth
### a cascade, not a pick-one
same shape as `confirmation is not one mechanism` — each layer answers a different question.
| layer | question | mechanism | surface |
|---|---|---|---|
| 0 — tunnel | on the network at all? | wireguard | everything. floor |
| 1 — device | enrolled box? | mTLS client cert, terminated at proxy | pc client, authed page |
| 2 — session | you, this session? | passkey / webauthn | pc client, authed page |
| 3 — step-up | you, *right now*, for this act? | passkey user-verification gesture | registration-enable, destructive acts, **core cold-start** |
wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is "authed" at layer 0 only — that gap is why the upper layers exist. **passkey over password/token** because step-up is load-bearing: webauthn gives per-assertion user-verification for free — the biometric/PIN gesture IS the human-in-the-loop. no shared secret on the box to steal; private key stays in enclave/TPM. spring security 6.4+ has native passkey support → in-stack for kotlin/spring. **mTLS (layer 1) optional** — if dropping one, drop mTLS, never the passkey.
### the invariant — surface caps authority
**auth tier is a property of the surface; the surface caps maximum authority.** you can't step up past what the channel structurally carries.
- **voice** presents layer 0 + speaker-attribution and STOPS. speaker verification is attribution, not auth. a room mic is reachable by gf, the TV, anyone present → voice is *structurally incapable* of layer 3
- **telegram inbound** = possession of a telegram account + chat-id allowlist. telegram's auth, outside our control. weak tier → read + soft acts, never destructive, never registration
so voice/chat can never reach registration-enable — **not because auth "failed" but because the channel can't carry the proof.**
### key-provenance
**the theorem:** no unattended key source survives a powered-on stolen box. anything the daemon fetches with no human present, a thief who grabs the running laptop fetches too. so the question was never "find the secure source" — it's **pick the failure mode:** unattended-but-loses-to-running-theft, or theft-resistant-but-attended. structural, can't have both.
**the trap — TPM-alone:** TPM-sealing binds release to PCRs, defeats offline disk extraction (real, worth having). but against full-box theft it does nothing — PCRs still match, thief boots, TPM unseals on cue. a laptop is portable → full-box theft is the *likely* case. TPM-alone protects the scenario you're less exposed to.
**the resolution — cold-start IS a layer-3 act:** unlocking the db makes maven's entire memory readable — the single highest-authority op. so **daemon cold-start unlock IS layer-3 step-up.** "always-on" = doesn't need babysitting during *normal operation*; does NOT mean *survives a cold boot with nobody around*. unlock is **remote-attended**`systemd-ask-password` over ssh, or pushed through the passkey-authed page. theft = box reboots into a locked daemon and stays there.
concrete stack:
| layer | mechanism | buys |
|---|---|---|
| disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline-extraction dead (TPM), powered-theft needs PIN-in-head |
| sqlcipher key | not at rest. supplied at daemon start via `systemd` credentials / `ask-password`, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal |
TPM+PIN is the honest middle. yubikey challenge-response is the upgrade path *if carried on your body* — don't reach for it yet. **runtime:** key in daemon RAM → `mlock` the page (no swap-to-disk), swap off or encrypted. zero on shutdown. **the trade:** a cold reboot needs you (remotely) present; in exchange a stolen laptop — running or off — is a brick holding ciphertext. matches the fail-closed posture.
### core/module key-isolation
the convenience (reboot-attendance collapses to *core cold-start only*) is contingent on one thing: **the key lives in core's address space and nowhere else.**
- **core = the only key-holder.** daemon holding sqlcipher-unlocked db + trigger loop. unlocked once (remote-attended), runs weeks. reboots almost never *because it was deliberately given nothing that churns* — no tool code, no model weights, no router. just state + loop
- **modules = restart-free, key-free, fail-independent.** stt/tts, router/classifier, tool executors, delivery. update/crash/swap one → none touch the unlock. "update the tool module, no attendance" is correct *by construction* — the module never had the key
enforce it: **IPC boundary, not shared address space** (unix domain socket, local-only — a crashing tts can't read the key page). **core mediates, never hands back a db handle** — modules send requests *to* core ("write this fact" / "read presence"); core never returns raw db access. **module compromise ≤ module authority** — worst a popped tts does is send garbage audio.
the discipline: **nothing enters core's process unless it must read state under the lock.** loop qualifies. predicates qualify. phrasing, routing, transcription, tool execution, delivery all read *derived* data or send requests — none need the raw key. erode this and you buy back the attendance you just eliminated. **systemd topology:** core = one unit; each module its own unit, `After=core.socket`, socket-activated, `Restart=on-failure`.
### the through-line
network → box → process: same question at three radii.
- **network (wg):** who reaches the box
- **box (LUKS+TPM+PIN, sqlcipher):** what a dead/stolen box gives up
- **process (core/module socket):** what a compromised module reaches
every cut is the same instinct — *a boundary you can move from inside isn't one*, *compromised X can't forge Y*, *attribution is not auth*. auth didn't add a new principle; it applied the existing one at smaller and smaller scope.
---
## open questions
> four items the old `decisions` doc listed as open — **router + invocation**, **two-memory routing**, **presence (concretely)**, **auth** — are now resolved by their respective sections above and are NOT listed here.
### impactful — gate behaviour or the mvp surface
- **stage-3 confidence threshold** — the gate-or-clarify number for the router. unset. defines how often maven asks vs. guesses on free-form input; the whole reactive mvp feel rides on it
- **quiet-hours definition** — fixed clock vs derived from sleep facts. gates *all* proactive delivery; can't ship the loop's restraint without it
- **confirmation tier model** — only the forced pin is settled (**registration is out-of-band, human-only**). the rest is a sketch: confirmation scales with specification + reversibility (rough buckets: just-do / binary-confirm / clarify-then-confirm / out-of-band). don't anchor on the count or any `f()`. open sub-item: **destructive-act confirm — policy-level vs per-function flag**
- **ask-password transport** — `systemd-ask-password` over ssh vs passkey-authed page push for cold-start unlock. both work; unpicked. blocks remote-attended boot being real
### plumbing / deferred — won't block the core build
- **passkey enrollment bootstrap** — first credential on a fresh device before a passkey exists to authenticate with. the trust-on-first-use gap
- **mTLS in or out** — provisioning cost on mobile vs paranoia payoff. leaning optional
- **PIN vs yubikey for LUKS** — PIN-in-head pinned for now; yubikey deferred unless carry-on-body becomes the model
- **session lifetime / re-auth cadence** — how long a layer-2 session lives before forcing re-assertion. unset
- **core↔module wire format** — socket decided, the protocol on it isn't (length-prefixed json vs something tighter)
- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one note in one utterance. classifier picks one label/utterance → compound splits (second pass) or loses half
- **query read-path** — "when do i like backups?" is RAG-over-chroma, not the sqlite read the router table implies. query-answering is chroma-RAG OR sqlite-read depending on the ask
- **obsidian↔chroma mechanics** — canonical md, derived embedding index, chunking. implementation of `obsidian → chroma`, unbuilt
- **presence — away tap override** — explicit `away` tap as hard override (voids presence to away for a duration). clean extension, deferred; scoring stands without it
- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning once the loop runs against real signal traces
- **presence — wg home-vs-cellular** — distinguish home-wifi-via-wg vs cellular-via-wg to let wg carry more weight when clearly home. needs the signal to exist first
- **personality prompt** — the identity character spec. unwritten
- **custom tts voice — train on a hand-picked voice** — future: replace the piper ru floor (irina) with a voice kami picks himself, trained/fine-tuned. per the stt/tts note, *training effort is where the personality budget goes* — this is that budget. piper supports voice training; xtts/silero fine-tune too. unscoped: pick the voice + the trainer, dataset size, cpu-vs-gpu train. deferred until the reactive+proactive core is solid; the irina floor ships first.
- **daemon runtime** — deferred until the homelab tool-call surface is real
- **listening modes 23** — meeting-record + ambient-derive. post-mvp; ambient-derive needs the confidence model working first
+25
View File
@@ -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
Executable
+100
View File
@@ -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