initial commit
This commit is contained in:
+22
@@ -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/
|
||||||
@@ -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
@@ -0,0 +1,131 @@
|
|||||||
|
## Maven — current state (2026-07)
|
||||||
|
|
||||||
|
Consolidated status. The reactive↔proactive core is closed and testable through
|
||||||
|
the web PWA today. Big untouched capability: **acting on the homelab** (tool
|
||||||
|
executor). Big untouched infra: **at-rest security** (sqlcipher/passkey).
|
||||||
|
|
||||||
|
### Access model
|
||||||
|
|
||||||
|
- **Phone** → needs the wg tunnel to reach homesrv (no homesrv DNS otherwise;
|
||||||
|
raw IP or a DNS tweak can bypass, not the default).
|
||||||
|
- **PC** → uses homesrv DNS, resolves the domains over local-net, **no wg needed**.
|
||||||
|
- nginx + ufw both scope to `10.42.0.0/24` (wg) + `192.168.1.0/24` (LAN), deny all else.
|
||||||
|
- **Surface in use now: the web PWA (`mavweb`).** Voice PTT + in-app nudges both ride it.
|
||||||
|
|
||||||
|
### Works end-to-end (tested)
|
||||||
|
|
||||||
|
- **Reactive voice:** PWA record → Whisper STT (`mavsttd`) → ONNX classifier →
|
||||||
|
LFM 2.5-1.2B phraser (llama-server subprocess) → Piper TTS (`mavttsd`) → reply.
|
||||||
|
HTTP POST path (mobile-Chrome drops WS for the audio).
|
||||||
|
- **Capture:** `fact` (EN **and RU** — root-substring recognizers) + `reminder`
|
||||||
|
persist through CoreAPI (`source=tap:voice`). This is the substrate the care
|
||||||
|
rules read.
|
||||||
|
- **Notes / query (semantic recall, sqlite — no chroma):** `note` → embed (the
|
||||||
|
classifier's ONNX embedder) → `notes` table. `query` → embed → brute-force
|
||||||
|
cosine top-k → confidence-gated answer (below `queryMinScore` 0.55 ⇒ "no note",
|
||||||
|
not a guess). Verbatim top-hit reply; full top-k phraser-RAG is the follow-up.
|
||||||
|
- **Monitoring (`/dash`):** mavweb server-renders presence + recent nudges (by
|
||||||
|
outcome) + recent facts from the append-only store via CoreAPI. Read-only,
|
||||||
|
meta-refresh, no JS.
|
||||||
|
- **Proactive loop:** 60s dumb ticker, pure predicates over a State snapshot,
|
||||||
|
universal gate (quiet-hours/presence/cooldown/snooze/calendar), one-nudge-per-
|
||||||
|
tick max-severity, reminders (gate-bypassing), sev4 repeat-til-ack, feedback
|
||||||
|
auto-tuner (outcome ratio → bounded cooldown, persisted as `source=feedback`).
|
||||||
|
- **Rules:** water/meal/break (sev1–2 care), service_down (sev4, `poll:uptimekuma`),
|
||||||
|
netdata_critical (sev3, `poll:netdata`).
|
||||||
|
- **Env facts (`mavpoll`):** netdata alarms → `netdata_alarm` (fires immediately
|
||||||
|
on a real CRITICAL); kuma monitor_status → `service_down`. Writes only on
|
||||||
|
value-change (no append-only churn).
|
||||||
|
- **Presence:** noisy-OR decay + Schmitt hysteresis. Live via `page_heartbeat`
|
||||||
|
(PWA auto-pings `/api/signal` every 30s → present when a tab's open).
|
||||||
|
- **Delivery:** ntfy / telegram / voice by `f(severity, presence)`; minimal body
|
||||||
|
on away channels. PWA subscribes to ntfy over **WebSocket** for in-app nudges.
|
||||||
|
- **Stability:** llama-server orphan leak fixed (`Pdeathsig` kills the child on
|
||||||
|
any mavend death); `kill-maven.sh` reaps strays (matches the model, not a
|
||||||
|
bogus `llama-server.*maven` pattern); `start-maven.sh` wires `-core` + poller.
|
||||||
|
|
||||||
|
### Wired but needs a deploy action (not code)
|
||||||
|
|
||||||
|
- **`desk_active`** (strongest presence signal) — `scripts/desk-active.sh` runs
|
||||||
|
on the **desk PC** (hypridle-gated systemd timer), posts over wg to mavweb.
|
||||||
|
- **Kuma `service_down`** — needs an API key created in Kuma → Settings → API Keys,
|
||||||
|
passed to `mavpoll -kuma-key`.
|
||||||
|
|
||||||
|
Caveats / gotchas:
|
||||||
|
- **desk_active is a workstation deploy, not code** — 0 facts ever written; presence
|
||||||
|
runs on page_heartbeat alone (dash reads "away"/"never at desk"). `scripts/desk-active.sh`
|
||||||
|
+ a hypridle-gated `maven-desk` timer must be installed on the desk PC (not homesrv).
|
||||||
|
- **Notes recall needs the ONNX embedder** — under the HashEmbedder floor, cosine is
|
||||||
|
lexical (token overlap), not semantic; scores are low, so most RU commands sit under
|
||||||
|
the 0.35 route threshold and clarify. Configure `voice.embedder` for confident recall+routing.
|
||||||
|
(The floor now at least tokenizes Cyrillic — see below — so it ranks correctly, just weakly.)
|
||||||
|
- **Switching the embedder model silently breaks old notes** — different dim ⇒
|
||||||
|
cosine 0 ⇒ they stop matching; brute-force can't re-embed. Re-embed on a model change.
|
||||||
|
- **`wg_handshake` is OFF and should stay off** — in this topology the phone only
|
||||||
|
runs wg when *outside*, so a fresh handshake means AWAY, not here. The `mavpoll
|
||||||
|
-wg` flag exists (defaults `""`) and could later back the spec's "away override"
|
||||||
|
by flipping the sign; as a presence-*here* signal it's inverted. desk_active +
|
||||||
|
page_heartbeat cover home presence.
|
||||||
|
|
||||||
|
### Not built yet (ranked by ROI)
|
||||||
|
|
||||||
|
1. **Full note RAG** — today the `query` reply is the verbatim top-hit note.
|
||||||
|
Follow-up: feed gated top-k to the phraser to compose an answer.
|
||||||
|
2. **quiet-hours source** — gate reads a `quiet_hours` config fact nothing writes
|
||||||
|
(defaults not-quiet; restraint unenforced).
|
||||||
|
3. **Security layer** — sqlcipher at-rest, cold-start unlock, real passkey auth.
|
||||||
|
Today: plain sqlite, `FloorEnrollment` (any same-uid caller = full L3).
|
||||||
|
|
||||||
|
Done since last revision: **act tool executor, store-backed, full flow**
|
||||||
|
(`internal/tool` + `internal/store/tools.go` + `tools` CoreAPI methods).
|
||||||
|
- **Execution:** IntentAct runs the matched fn against the store's ENABLED
|
||||||
|
allowlist. argv, no shell → STT text can't inject. Live store read, so a
|
||||||
|
newly-enabled tool runs without a daemon restart.
|
||||||
|
- **proposed→enabled:** an act whose verb isn't enabled is scaffolded as a
|
||||||
|
`proposed` tool (maven suggests). A human enables it (fills argv + destructive)
|
||||||
|
on the authed **`mavweb /tools`** page — never voice. `EnableTool` sits at
|
||||||
|
`AuthStepUp` in the policy table (gate lands with the security layer).
|
||||||
|
- **Confirm turn:** a destructive enabled tool replies "выполнить X? да/нет" and
|
||||||
|
parks; the next utterance (ru/en yes-no) confirms or cancels (90s TTL).
|
||||||
|
- **Config:** `voice.tools` seeds enabled tools at boot (editing mavend.json =
|
||||||
|
the human enable act); mavweb enables ad-hoc ones on top.
|
||||||
|
- **Russian:** fixed grammar in reply strings + seed files; maven's self-
|
||||||
|
reference is feminine ("she") — [[maven-persona-gender]].
|
||||||
|
|
||||||
|
Also fixed:
|
||||||
|
- **HashEmbedder was blind to Cyrillic** (`tokenize` iterated bytes, kept only
|
||||||
|
`a-z0-9`) → every RU utterance embedded to the zero vector → cosine 0 across
|
||||||
|
all intents → misrouted to `act` (alphabetical tie-break). Now rune-based
|
||||||
|
(`unicode.IsLetter`). This was the real cause of "Найди заметку" (a query)
|
||||||
|
landing in `notes`; added note-retrieval query seeds too.
|
||||||
|
- **Notes are now browsable on `/dash`** — `RecentNotes` plumbed through the
|
||||||
|
store + CoreAPI; voice-captured notes were previously only reachable via
|
||||||
|
semantic `query`.
|
||||||
|
Earlier: notes/query recall, `/dash` monitoring, `wg_handshake` poller (NO-OP).
|
||||||
|
|
||||||
|
### Future / logged, not now
|
||||||
|
|
||||||
|
Personality prompt; custom TTS voice training (kami-picked voice, replaces irina
|
||||||
|
floor); listening modes 2–3 (meeting-record, ambient-derive).
|
||||||
|
|
||||||
|
### Services & layout
|
||||||
|
|
||||||
|
- `mavend` (core, IPC unix socket) — store + loop + phraser; the only key-holder.
|
||||||
|
- `mavsttd` / `mavttsd` — STT/TTS worker modules (unix sockets).
|
||||||
|
- `mavweb` — PWA bridge (HTTP), `/api/ptt` voice, `/api/signal` presence ingest,
|
||||||
|
`/api/ntfy` WS-subscribe config, `/dash` read-only monitoring.
|
||||||
|
- `mavpoll` — env poller (netdata/kuma → facts via CoreAPI).
|
||||||
|
- All behind wg + nginx deny-all; no phone-home. CGo only in `mavsttd`.
|
||||||
|
- Start/stop: `./start-maven.sh [build]`, `./kill-maven.sh`.
|
||||||
|
- Config: `~/.config/maven/mavend.json` (or `mavend.json` in repo root).
|
||||||
|
|
||||||
|
### Key files
|
||||||
|
|
||||||
|
- `cmd/mavend/{main,tick,voice}.go` — daemon wiring, loop driver, voice handler
|
||||||
|
- `internal/loop/{loop,rules,gather,feedback}.go` — proactive engine
|
||||||
|
- `internal/store/` — append-only facts/reminders/nudges/presence/notes
|
||||||
|
- `cmd/mavweb/{main.go,dash.html}` — PWA bridge + `/dash` monitoring
|
||||||
|
- `internal/router/{classifier,slots,stage0}.go` — reactive routing + slot parse
|
||||||
|
- `internal/delivery/` — dispatcher + ntfy/telegram/voice sinks
|
||||||
|
- `internal/auth/` — scope/gate/enrollment (floor today)
|
||||||
|
- `cmd/mavpoll/`, `scripts/desk-active.sh` — env + presence producers
|
||||||
@@ -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/
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Package delivery is maven's channel-routing + dispatch layer.
|
||||||
|
//
|
||||||
|
// Spec contract (from maven.md § delivery / channel routing):
|
||||||
|
//
|
||||||
|
// - routing = f(severity, presence). presence decides REACHABILITY; severity
|
||||||
|
// decides INSISTENCE. need both.
|
||||||
|
//
|
||||||
|
// | | present | away |
|
||||||
|
// | sev1–2 (care) | voice | drop |
|
||||||
|
// | sev3 (ops soft) | voice | ntfy, once |
|
||||||
|
// | sev4 (ops hard) | voice + ntfy | telegram, repeat til ack |
|
||||||
|
//
|
||||||
|
// - sev ≤ 2 drops on away, sev ≥ 3 holds. "a missed water nudge is noise;
|
||||||
|
// a missed backup-failure isn't."
|
||||||
|
// - away channels (ntfy/telegram) leave the box — the one path that crosses
|
||||||
|
// "never phones home," through your relay. MINIMAL BODY — "disk low on
|
||||||
|
// homesrv," not detail. don't make notifications a shoulder-surf exfil
|
||||||
|
// surface.
|
||||||
|
// - reminders are a SEPARATE class — two delivery paths. reminders bypass
|
||||||
|
// the restraint gate ("wake me 7" fires in quiet hours; that's the point).
|
||||||
|
// snooze still applies. voice when present, ntfy when away. fire once.
|
||||||
|
//
|
||||||
|
// Architecture mirrors the loop's gather/pure split: the routing table is a
|
||||||
|
// PURE function of (severity, presence); the Dispatcher holds the impure Sinks
|
||||||
|
// (one per channel transport) and the recorder seams. ShouldRepeat is pure —
|
||||||
|
// the daemon's tick loop calls it each cycle for un-acked sev4 telegram sends.
|
||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Channel — one delivery transport. Drop is an explicit no-op (the routing
|
||||||
|
// table chose to suppress, which is a decision, not a failure — "a missed
|
||||||
|
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
|
||||||
|
// gap, not a Channel value.
|
||||||
|
type Channel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChannelVoice Channel = "voice"
|
||||||
|
ChannelNtfy Channel = "ntfy"
|
||||||
|
ChannelTelegram Channel = "telegram"
|
||||||
|
ChannelDrop Channel = "drop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelsFor — the PURE routing table: f(severity, presence).
|
||||||
|
//
|
||||||
|
// presence decides reachability; severity decides insistence. the loop's gate
|
||||||
|
// already suppressed care nudges (sev ≤ 2) on away — this table is the
|
||||||
|
// delivery-side authority for ALL severities, including the ops nudges the
|
||||||
|
// gate lets through. double authority is intentional: the gate decides whether
|
||||||
|
// a rule EMITS; delivery decides where it LANDS. they agree on care-away
|
||||||
|
// (both drop) and diverge only where they must (ops survives away here, not
|
||||||
|
// because the gate let it through, but because delivery insists).
|
||||||
|
//
|
||||||
|
// sev4 present → voice + ntfy: the disk-fire alarm gets voice AND a push —
|
||||||
|
// you're here, but this is loud enough to also surface on the watch.
|
||||||
|
// sev4 away → telegram, repeat til ack: the one channel that crosses the relay
|
||||||
|
// and insists until you respond.
|
||||||
|
func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
|
||||||
|
if presence == store.Away {
|
||||||
|
switch {
|
||||||
|
case sev <= loop.Sev2:
|
||||||
|
return []Channel{ChannelDrop}
|
||||||
|
case sev == loop.Sev3:
|
||||||
|
return []Channel{ChannelNtfy}
|
||||||
|
case sev >= loop.Sev4:
|
||||||
|
return []Channel{ChannelTelegram}
|
||||||
|
}
|
||||||
|
return []Channel{ChannelDrop} // unknown sev → fail-closed
|
||||||
|
}
|
||||||
|
// present
|
||||||
|
if sev >= loop.Sev4 {
|
||||||
|
return []Channel{ChannelVoice, ChannelNtfy}
|
||||||
|
}
|
||||||
|
return []Channel{ChannelVoice}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelsForReminder — reminders are a SEPARATE class that bypasses the gate.
|
||||||
|
// "wake me 7" fires in quiet hours; that's the point. presence still routes
|
||||||
|
// reachability: voice when present, ntfy when away. fires once — no repeat
|
||||||
|
// (repeat-til-ack is a sev4 ops-hard behavior, not a reminder behavior).
|
||||||
|
//
|
||||||
|
// reminders don't carry a Severity — they're user-stated future intent, not
|
||||||
|
// loop-derived insistence. the routing is presence-only: reachability without
|
||||||
|
// the insistence axis. a reminder that must be louder (an alarm) is a future
|
||||||
|
// per-reminder override, not a table entry.
|
||||||
|
func ChannelsForReminder(presence store.Bucket) []Channel {
|
||||||
|
if presence == store.Away {
|
||||||
|
return []Channel{ChannelNtfy}
|
||||||
|
}
|
||||||
|
return []Channel{ChannelVoice}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Package ntfysink implements delivery.Sink for the ntfy push channel.
|
||||||
|
//
|
||||||
|
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
|
||||||
|
// nudges when present (alongside voice), and reminders when away. the
|
||||||
|
// message body is the Sendable's Summary — the minimal-body rule from the
|
||||||
|
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
||||||
|
// the relay). voice gets Body; away channels get Summary, enforced at the
|
||||||
|
// sink so a phraser bug can't exfil.
|
||||||
|
//
|
||||||
|
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
||||||
|
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
||||||
|
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
|
||||||
|
// your phone, nothing else. matches the module key-isolation invariant: the
|
||||||
|
// sink never holds the sqlcipher key.
|
||||||
|
package ntfysink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config — ntfy publish config. the daemon wires this from its config file;
|
||||||
|
// the credential lives in the daemon's config (or a systemd credential),
|
||||||
|
// never in the binary.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path)
|
||||||
|
Topic string // e.g. maven (all maven notifications land here)
|
||||||
|
Username string // basic auth; empty = anonymous (won't work with deny-all)
|
||||||
|
Password string // basic auth
|
||||||
|
Timeout time.Duration // per-request; 0 = DefaultTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Sink — implements delivery.Sink via ntfy HTTP publish. one POST per Send.
|
||||||
|
// no retry (the dispatcher + daemon decide retry policy); no streaming.
|
||||||
|
type Sink struct {
|
||||||
|
cfg Config
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates the config and builds the sink. BaseURL and Topic are
|
||||||
|
// required; auth is optional (but deny-all servers reject unauthed publishes).
|
||||||
|
func New(cfg Config) (*Sink, error) {
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("ntfysink: BaseURL is required")
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(cfg.BaseURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Topic == "" {
|
||||||
|
return nil, fmt.Errorf("ntfysink: Topic is required")
|
||||||
|
}
|
||||||
|
to := cfg.Timeout
|
||||||
|
if to == 0 {
|
||||||
|
to = DefaultTimeout
|
||||||
|
}
|
||||||
|
return &Sink{
|
||||||
|
cfg: cfg,
|
||||||
|
hc: &http.Client{Timeout: to},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send publishes one notification to ntfy. the body is the Sendable's Summary
|
||||||
|
// (minimal body); Title is "maven" (consistent sender identity on the lock
|
||||||
|
// screen — the content is in the body). Priority maps from severity/kind so
|
||||||
|
// the phone client can ring differently for an alarm vs a soft ops nudge.
|
||||||
|
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||||
|
body := d.Summary
|
||||||
|
if body == "" {
|
||||||
|
body = d.Body // terse full message beats no message
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return fmt.Errorf("ntfysink: empty message for %s", d.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntfysink: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Title", "maven")
|
||||||
|
req.Header.Set("Priority", priorityFor(d).String())
|
||||||
|
if s.cfg.Username != "" {
|
||||||
|
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntfysink: publish: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sink) topicURL() string {
|
||||||
|
return strings.TrimRight(s.cfg.BaseURL, "/") + "/" + s.cfg.Topic
|
||||||
|
}
|
||||||
|
|
||||||
|
// ntfyPriority — ntfy's 1–5 priority scale.
|
||||||
|
type ntfyPriority int
|
||||||
|
|
||||||
|
const (
|
||||||
|
prioMin ntfyPriority = 1
|
||||||
|
prioLow ntfyPriority = 2
|
||||||
|
prioDefault ntfyPriority = 3
|
||||||
|
prioHigh ntfyPriority = 4
|
||||||
|
prioMax ntfyPriority = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p ntfyPriority) String() string { return fmt.Sprintf("%d", int(p)) }
|
||||||
|
|
||||||
|
// priorityFor — maps maven's (kind, severity) to ntfy's 1–5.
|
||||||
|
//
|
||||||
|
// reminders are user-stated intent ("wake me 7") → high (4); they bypassed the
|
||||||
|
// gate to reach you, make them ring. sev4 (ops hard, present — away goes to
|
||||||
|
// telegram) → max (5): a disk-fire alarm that also pushes to your watch. sev3
|
||||||
|
// (ops soft, away) → high (4): it held through away for a reason. care nudges
|
||||||
|
// never reach ntfy (they drop on away), so no sev1–2 mapping here.
|
||||||
|
func priorityFor(d delivery.Sendable) ntfyPriority {
|
||||||
|
if d.Kind == delivery.KindReminder {
|
||||||
|
return prioHigh
|
||||||
|
}
|
||||||
|
if d.Severity >= loop.Sev4 {
|
||||||
|
return prioMax
|
||||||
|
}
|
||||||
|
if d.Severity == loop.Sev3 {
|
||||||
|
return prioHigh
|
||||||
|
}
|
||||||
|
return prioDefault
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 1–5;
|
||||||
|
// the repeat mechanism IS the insistence, re-sending on the dispatcher's tick
|
||||||
|
// until MarkAcked.
|
||||||
|
package telegramsink
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBaseURL — telegram bot API. overridable in Config (e.g. for a
|
||||||
|
// self-hosted bridge that mirrors the API shape and handles the region cut
|
||||||
|
// itself); the proxy is the normal path, the BaseURL override is the fallback.
|
||||||
|
const DefaultBaseURL = "https://api.telegram.org"
|
||||||
|
|
||||||
|
const DefaultTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Config — telegram bot API publish config. the daemon wires this from its
|
||||||
|
// config file; the bot token + chat id live in the daemon's config (or a
|
||||||
|
// systemd credential), never in the binary.
|
||||||
|
type Config struct {
|
||||||
|
// BotToken — the telegram bot token from BotFather. required. sent in the
|
||||||
|
// URL path (the only place telegram accepts it), not in the body.
|
||||||
|
BotToken string
|
||||||
|
|
||||||
|
// ChatID — destination chat. may be a numeric user/group id (sent as a
|
||||||
|
// JSON number) or @channelusername (sent as a JSON string). required.
|
||||||
|
ChatID string
|
||||||
|
|
||||||
|
// BaseURL — telegram API base. empty = DefaultBaseURL. override to point
|
||||||
|
// at a self-hosted API bridge if the proxy path isn't used.
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
// Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram
|
||||||
|
// API (region-restricted direct egress). empty = direct (won't work from
|
||||||
|
// the homesrv without a relay; kept configurable for tests + future
|
||||||
|
// topology change).
|
||||||
|
Proxy string
|
||||||
|
|
||||||
|
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
|
||||||
|
// tick loop.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sink — implements delivery.Sink via the telegram bot sendMessage API. one
|
||||||
|
// POST per Send; no retry (the dispatcher + daemon decide retry policy). the
|
||||||
|
// repeat-til-ack clock is driven by the dispatcher calling Send again each
|
||||||
|
// interval — the sink itself is stateless.
|
||||||
|
type Sink struct {
|
||||||
|
cfg Config
|
||||||
|
hc *http.Client
|
||||||
|
base string // resolved BaseURL, no trailing slash
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validates the config and builds the sink. BotToken and ChatID are
|
||||||
|
// required; Proxy and BaseURL are optional.
|
||||||
|
func New(cfg Config) (*Sink, error) {
|
||||||
|
if cfg.BotToken == "" {
|
||||||
|
return nil, fmt.Errorf("telegramsink: BotToken is required")
|
||||||
|
}
|
||||||
|
if cfg.ChatID == "" {
|
||||||
|
return nil, fmt.Errorf("telegramsink: ChatID is required")
|
||||||
|
}
|
||||||
|
base := cfg.BaseURL
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultBaseURL
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(base); err != nil {
|
||||||
|
return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Proxy != "" {
|
||||||
|
if _, err := url.Parse(cfg.Proxy); err != nil {
|
||||||
|
return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
to := cfg.Timeout
|
||||||
|
if to == 0 {
|
||||||
|
to = DefaultTimeout
|
||||||
|
}
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
if cfg.Proxy != "" {
|
||||||
|
pu, _ := url.Parse(cfg.Proxy) // parsed+checked above
|
||||||
|
transport.Proxy = http.ProxyURL(pu)
|
||||||
|
}
|
||||||
|
return &Sink{
|
||||||
|
cfg: cfg,
|
||||||
|
base: strings.TrimRight(base, "/"),
|
||||||
|
hc: &http.Client{Timeout: to, Transport: transport},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageReq — the subset of sendMessage params maven uses. JSON-encoded
|
||||||
|
// as the request body. chat_id accepts number or string; go json tags keep
|
||||||
|
// both shapes (channel usernames are strings, user ids are numbers — send
|
||||||
|
// whatever ChatID was configured as).
|
||||||
|
type sendMessageReq struct {
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
DisableNotification bool `json:"disable_notification"` // false = ring (always — these are alarms)
|
||||||
|
ProtectContent bool `json:"protect_content"` // true = no forwarding out of chat
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegramResp — the shape telegram returns. ok=false on logical error with
|
||||||
|
// error_code + description; ok=true with result on success (result contents
|
||||||
|
// not needed by the sink).
|
||||||
|
type telegramResp struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
ErrorCode int `json:"error_code,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send publishes one message to the configured telegram chat. the body is the
|
||||||
|
// Sendable's Summary (minimal body); empty Summary falls back to Body (terse
|
||||||
|
// full message beats no message). protect_content=true so a phraser bug (Body
|
||||||
|
// leaking detail through Summary) can't be forwarded onward by the user or a
|
||||||
|
// chat observer — locks the minimal-body rule at the channel's own last mile.
|
||||||
|
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||||
|
body := d.Summary
|
||||||
|
if body == "" {
|
||||||
|
body = d.Body
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return fmt.Errorf("telegramsink: empty message for %s", d.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := sendMessageReq{
|
||||||
|
ChatID: s.cfg.ChatID,
|
||||||
|
Text: body,
|
||||||
|
DisableNotification: false, // maven sends to telegram only on sev4/sev3/reminder — all want to ring
|
||||||
|
ProtectContent: true, // no forwarding out — locks minimal body at the channel last mile
|
||||||
|
}
|
||||||
|
pb, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("telegramsink: marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.sendMessageURL(), bytes.NewReader(pb))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("telegramsink: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("telegramsink: sendMessage: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
|
||||||
|
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
|
||||||
|
// error_code + description on failure. parse the body either way so a 200
|
||||||
|
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
|
||||||
|
var tr telegramResp
|
||||||
|
if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
|
||||||
|
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||||
|
}
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageURL — the bot API path. the token is in the URL path
|
||||||
|
// (https://api.telegram.org/bot<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package loop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gate — universal, applied by the loop, never per-rule.
|
||||||
|
//
|
||||||
|
// quiet-hours, presence, cooldown, snooze, calendar-busy all live in ONE
|
||||||
|
// fires(). Cross-cutting restraint in one place or it drifts.
|
||||||
|
//
|
||||||
|
// The gate does NOT itself decide "should this rule run" — the Rule.Predicate
|
||||||
|
// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot.
|
||||||
|
// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env
|
||||||
|
// predicate, not the LLM's job — same boundary as "rules decide, llm phrases."
|
||||||
|
//
|
||||||
|
// Gate is pure. no I/O. reads State + Rule only.
|
||||||
|
func Gate(s State, r Rule) bool {
|
||||||
|
now := s.Now
|
||||||
|
|
||||||
|
// snooze — per-rule "leave me alone until X". overrides everything below.
|
||||||
|
if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cooldown — most recent same-rule nudge + base/feedback-tuned duration.
|
||||||
|
// Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil.
|
||||||
|
if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// quiet hours — care nudges (sev1–2) shut up. ops (sev ≥3) still surface
|
||||||
|
// (a failed backup at 2am genuinely matters and maven routes to telegram).
|
||||||
|
if s.QuietHours && r.Severity.IsCare() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env
|
||||||
|
// predicate; not the LLM's call.
|
||||||
|
if s.CalendarBusy && r.Severity.IsCare() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// presence — sev1–2 DROP on away (missed water nudge is noise).
|
||||||
|
// sev ≥3 HOLDS — see the delivery channel-routing table in the spec.
|
||||||
|
// (the loop doesn't pick the channel; it just decides whether to emit.)
|
||||||
|
if s.Presence == store.Away && r.Severity.IsCare() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// no-data inertness — since(key)==null → don't fire. shut up when uncertain.
|
||||||
|
// The predicate MAY have encoded this itself; the gate enforces it for any
|
||||||
|
// rule that declared InertWhenNoData keys.
|
||||||
|
for _, k := range r.InertWhenNoData {
|
||||||
|
if _, ok := s.Fact(k); !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true).
|
||||||
|
// The loop picks one per tick (max severity).
|
||||||
|
type Candidate struct {
|
||||||
|
Rule Rule
|
||||||
|
Severity Severity
|
||||||
|
State State // snapshot at evaluation time — for the phraser's context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick — PURE. Evaluates the configured rules against the snapshot, returns
|
||||||
|
// AT MOST one proactive candidate (max severity, with a deterministic tie-break).
|
||||||
|
// Returns nil when nothing fires ("shuts up" is the default outcome of a tick).
|
||||||
|
//
|
||||||
|
// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to
|
||||||
|
// the phraser (LFM) and delivery module. The loop just decides.
|
||||||
|
//
|
||||||
|
// Reminders are NOT handled here — they're a separate, gate-bypassing class.
|
||||||
|
// See DueReminders (gathered separately) and RemindDecisions (the loop output
|
||||||
|
// flag for the daemon).
|
||||||
|
func Tick(s State, rules []Rule) *Candidate {
|
||||||
|
var fire *Candidate
|
||||||
|
for _, r := range rules {
|
||||||
|
if !r.Predicate(s) {
|
||||||
|
continue // rule doesn't want to fire — skip gate entirely (cheap path)
|
||||||
|
}
|
||||||
|
if !Gate(s, r) {
|
||||||
|
continue // wanted but suppressed this tick
|
||||||
|
}
|
||||||
|
c := Candidate{Rule: r, Severity: r.Severity, State: s}
|
||||||
|
if fire == nil {
|
||||||
|
fire = &c
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// max severity wins; tie-break: severity desc, then name asc for determinism.
|
||||||
|
if c.Severity > fire.Severity ||
|
||||||
|
(c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) {
|
||||||
|
fire = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fire
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderDecision — a due reminder the daemon should deliver now.
|
||||||
|
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
|
||||||
|
// that's the point). Snooze still applies — represented by a separate
|
||||||
|
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
|
||||||
|
// straight to MarkReminder(fired).
|
||||||
|
type ReminderDecision struct {
|
||||||
|
Reminder store.Reminder
|
||||||
|
State State
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemindDecisions — returns all due reminders (without gating their delivery
|
||||||
|
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
|
||||||
|
// produces that list from `fire_ts <= now AND pending`.
|
||||||
|
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
|
||||||
|
out := make([]ReminderDecision, 0, len(due))
|
||||||
|
for _, r := range due {
|
||||||
|
out = append(out, ReminderDecision{Reminder: r, State: s})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CooldownFor — helper for the Gatherer: given the active cooldown base
|
||||||
|
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
|
||||||
|
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
|
||||||
|
// Pure. The auto-tuner writes the base the gatherer reads as a feedback
|
||||||
|
// fact; this function just adds it to the last send.
|
||||||
|
func CooldownFor(base time.Duration, lastSend time.Time) time.Time {
|
||||||
|
if lastSend.IsZero() {
|
||||||
|
return time.Time{} // never sent → no cooldown active
|
||||||
|
}
|
||||||
|
return lastSend.Add(base)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Package loop is maven's proactive trigger engine.
|
||||||
|
//
|
||||||
|
// Loop contract (from spec):
|
||||||
|
//
|
||||||
|
// - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free.
|
||||||
|
// - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State.
|
||||||
|
// - since(key)==null → don't fire. silence on no-data = "shuts up when uncertain".
|
||||||
|
// - the GATE is universal, applied by the loop, never per-rule. quiet-hours,
|
||||||
|
// presence, cooldown, snooze, calendar-busy all live in ONE fires(). cross-
|
||||||
|
// cutting restraint in one place or it drifts.
|
||||||
|
// - ONE nudge per tick (max severity), never dogpile.
|
||||||
|
// - rules as code, not a DSL. revisit at ~30 rules.
|
||||||
|
//
|
||||||
|
// Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler:
|
||||||
|
// - predicate: `fire_ts <= now AND pending`
|
||||||
|
// - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point
|
||||||
|
// - snooze still applies; fires once (pending → fired)
|
||||||
|
//
|
||||||
|
// Architecture: the Gatherer is the only impure bit — it builds a State snapshot
|
||||||
|
// under the store lock. Everything from there on is PURE functions over State.
|
||||||
|
// Phrasing (the llm lane) and delivery are out of scope here — the loop emits
|
||||||
|
// Decisions; the daemon wires them to phrasing + delivery.
|
||||||
|
package loop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity — higher = more insistent (harder to suppress).
|
||||||
|
//
|
||||||
|
// Per the spec's delivery table:
|
||||||
|
//
|
||||||
|
// sev1–2: care nudges (water, meal, break). voice when present; DROP on away.
|
||||||
|
// sev3: ops soft (backup failed, cert soon). voice + once over ntfy when away.
|
||||||
|
// sev4: ops hard (disk critical, service down). voice + ntfy present;
|
||||||
|
// telegram, repeat til ack, when away.
|
||||||
|
//
|
||||||
|
// "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent
|
||||||
|
// candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1).
|
||||||
|
type Severity int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Sev1 Severity = 1 // care, lowest insistence — drops on away
|
||||||
|
Sev2 Severity = 2
|
||||||
|
Sev3 Severity = 3 // ops soft
|
||||||
|
Sev4 Severity = 4 // ops hard, highest insistence
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away
|
||||||
|
|
||||||
|
// State — the loop's view of the world, gathered under the store lock at the
|
||||||
|
// start of each tick. From here on everything is pure — predicates and the gate
|
||||||
|
// read ONLY this struct and never touch the store.
|
||||||
|
//
|
||||||
|
// Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups
|
||||||
|
// the rules + gate have pre-arranged. Presence/PresenceScore/Now are global.
|
||||||
|
// The Gatherer decides what to populate; rules see what's in the snapshot.
|
||||||
|
type State struct {
|
||||||
|
Now time.Time
|
||||||
|
Presence store.Bucket
|
||||||
|
PresenceScore float64
|
||||||
|
|
||||||
|
// Latest non-voided fact per key the loop requested at gather time.
|
||||||
|
// Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire.
|
||||||
|
Facts map[string]store.Fact
|
||||||
|
|
||||||
|
// Last nudge per rule, for cooldown enforcement (newest send).
|
||||||
|
LastNudge map[string]store.Nudge
|
||||||
|
|
||||||
|
// Per-rule snooze-until — explicit "don't bother me about this rule until X".
|
||||||
|
// Overlays on top of cooldown; both must be clear to fire.
|
||||||
|
SnoozeUntil map[string]time.Time
|
||||||
|
|
||||||
|
// Per-rule cooldown-until — derived from LastNudge + cooldown duration
|
||||||
|
// (the auto-tuner adjusts the duration via feedback outcomes).
|
||||||
|
CooldownUntil map[string]time.Time
|
||||||
|
|
||||||
|
// Cross-cutting env flags derived from facts at gather time:
|
||||||
|
// QuietHours — the care gate suppresses sev1–2 when true. ops still surface.
|
||||||
|
QuietHours bool
|
||||||
|
// CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate.
|
||||||
|
CalendarBusy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fact returns the latest non-voided fact for key, or
|
||||||
|
// (store.Fact{}, false) — the predicate's "no data" case.
|
||||||
|
// since(key)==null → don't fire is implemented by checking the bool.
|
||||||
|
func (s State) Fact(key string) (store.Fact, bool) {
|
||||||
|
f, ok := s.Facts[key]
|
||||||
|
if !ok || f.Ts.IsZero() {
|
||||||
|
return store.Fact{}, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since returns the duration since the latest fact for key, or (0,false).
|
||||||
|
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
||||||
|
func (s State) Since(key string) (time.Duration, bool) {
|
||||||
|
f, ok := s.Fact(key)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if s.Now.Before(f.Ts) {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
return s.Now.Sub(f.Ts), true
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note — a recall/preference item. No predicate reads it (facts are for that);
|
||||||
|
// query-answering ranks notes by embedding cosine. Score is set by QueryNotes.
|
||||||
|
type Note struct {
|
||||||
|
ID int64
|
||||||
|
Ts time.Time
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteNote appends a note with its embedding (stored as a little-endian
|
||||||
|
// float32 BLOB). Source is provenance (tap:voice, etc.).
|
||||||
|
func (s *Store) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO notes (ts, text, embedding, source) VALUES (?,?,?,?)`,
|
||||||
|
ts.UnixMilli(), text, floatsToBlob(embedding), source)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("write note: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryNotes returns the top-k notes by cosine similarity to embedding, highest
|
||||||
|
// first (ties broken newest-first). Fewer than k notes ⇒ returns what exists.
|
||||||
|
//
|
||||||
|
// ponytail: brute-force O(n) cosine over every note each query. Add sqlite-vec
|
||||||
|
// or an ANN index only when note count or latency actually bites — at personal
|
||||||
|
// scale (hundreds–thousands) a full scan is sub-millisecond.
|
||||||
|
func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT id, ts, text, embedding, source FROM notes`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query notes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Note
|
||||||
|
for rows.Next() {
|
||||||
|
var n Note
|
||||||
|
var tsMilli int64
|
||||||
|
var blob []byte
|
||||||
|
if err := rows.Scan(&n.ID, &tsMilli, &n.Text, &blob, &n.Source); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n.Ts = time.UnixMilli(tsMilli).UTC()
|
||||||
|
n.Score = cosine(embedding, blobToFloats(blob))
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Score != out[j].Score {
|
||||||
|
return out[i].Score > out[j].Score
|
||||||
|
}
|
||||||
|
return out[i].Ts.After(out[j].Ts) // newest breaks ties
|
||||||
|
})
|
||||||
|
if k > 0 && len(out) > k {
|
||||||
|
out = out[:k]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentNotes returns the newest n notes, newest first — a browse view (no
|
||||||
|
// embedding math; Score stays 0). This is the read surface for /dash: notes
|
||||||
|
// captured by voice are otherwise only reachable through semantic query.
|
||||||
|
func (s *Store) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, ts, text, source FROM notes ORDER BY ts DESC LIMIT ?`, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recent notes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Note
|
||||||
|
for rows.Next() {
|
||||||
|
var nt Note
|
||||||
|
var tsMilli int64
|
||||||
|
if err := rows.Scan(&nt.ID, &tsMilli, &nt.Text, &nt.Source); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nt.Ts = time.UnixMilli(tsMilli).UTC()
|
||||||
|
out = append(out, nt)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cosine similarity. Embedder vectors are L2-normalized, so this is just the
|
||||||
|
// dot product — but normalize defensively in case a caller passes a raw vector.
|
||||||
|
func cosine(a, b []float32) float64 {
|
||||||
|
if len(a) != len(b) || len(a) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var dot, na, nb float64
|
||||||
|
for i := range a {
|
||||||
|
dot += float64(a[i]) * float64(b[i])
|
||||||
|
na += float64(a[i]) * float64(a[i])
|
||||||
|
nb += float64(b[i]) * float64(b[i])
|
||||||
|
}
|
||||||
|
if na == 0 || nb == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatsToBlob(v []float32) []byte {
|
||||||
|
b := make([]byte, 4*len(v))
|
||||||
|
for i, f := range v {
|
||||||
|
binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(f))
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func blobToFloats(b []byte) []float32 {
|
||||||
|
v := make([]float32, len(b)/4)
|
||||||
|
for i := range v {
|
||||||
|
v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[4*i:]))
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reminder — user-stated future intent. fires once. relative→absolute happens
|
||||||
|
// at capture ("in 4h" → store now+4h, never the string).
|
||||||
|
type Reminder struct {
|
||||||
|
ID int64
|
||||||
|
CreatedTs time.Time
|
||||||
|
FireTs time.Time
|
||||||
|
Payload string // raw json
|
||||||
|
Status string // pending | fired | cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrReminderNotFound = errors.New("store: reminder not found")
|
||||||
|
ErrReminderState = errors.New("store: reminder not in a mutable state")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateReminder persists a reminder with a resolved absolute fire time.
|
||||||
|
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
|
||||||
|
// We do not accept strings here.
|
||||||
|
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`,
|
||||||
|
now.UnixMilli(), fire.UnixMilli(), payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create reminder: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueReminders returns pending reminders with fire_ts <= now, oldest first.
|
||||||
|
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`.
|
||||||
|
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, created_ts, fire_ts, payload, status
|
||||||
|
FROM reminders
|
||||||
|
WHERE status = 'pending' AND fire_ts <= ?
|
||||||
|
ORDER BY fire_ts ASC`, now.UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("due reminders: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Reminder
|
||||||
|
for rows.Next() {
|
||||||
|
var r Reminder
|
||||||
|
var created, fire int64
|
||||||
|
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.CreatedTs = time.UnixMilli(created).UTC()
|
||||||
|
r.FireTs = time.UnixMilli(fire).UTC()
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
|
||||||
|
// pending→cancelled. Anything else is a programming error.
|
||||||
|
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||||
|
if status != "fired" && status != "cancelled" {
|
||||||
|
return fmt.Errorf("%w: %s", ErrReminderState, status)
|
||||||
|
}
|
||||||
|
// pending → fired|cancelled only.
|
||||||
|
var current string
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrReminderNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current != "pending" {
|
||||||
|
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 "приняла."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 "."
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
# maven
|
||||||
|
|
||||||
|
a self-hosted personal assistant. manages your day, acts on your homelab. all software, fully local — never phones home.
|
||||||
|
|
||||||
|
> consolidated from: spec, decisions, router, two-memory, presence, auth, stt/tts. dated 2026-06-30.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## capabilities
|
||||||
|
|
||||||
|
### reactive
|
||||||
|
- **converse** — voice in → stt → router → llm → tts, and text
|
||||||
|
- **act** — function calls into the homelab ("do digital things")
|
||||||
|
|
||||||
|
### proactive
|
||||||
|
- **health nudges** — hydration, meals, breaks, shower, sleep, cleanup
|
||||||
|
- **user reminders** — your stated future intent ("remind me tuesday", "wake me 7"); scheduled, fires once
|
||||||
|
- **deliver** — voice when near, ntfy/telegram/matrix when away
|
||||||
|
- **restrain** — quiet hours, per-rule cooldowns, snooze-memory, self-quieting
|
||||||
|
|
||||||
|
### capture
|
||||||
|
- throw facts/notes/tasks at it mid-flow → feeds memory
|
||||||
|
|
||||||
|
### state
|
||||||
|
- **self** — timestamped facts about you (meals, water, sleep, desk time)
|
||||||
|
- **presence** — are you here (inferred, decaying confidence — never one signal)
|
||||||
|
- **activity** — what you're doing right now
|
||||||
|
- **environment** — world facts that gate or trigger: homelab health, calendar, weather
|
||||||
|
|
||||||
|
### memory
|
||||||
|
- long-term recall, personalization (obsidian → chroma)
|
||||||
|
|
||||||
|
### feedback
|
||||||
|
- nudge outcomes (acted / snoozed / ignored) tune the rules
|
||||||
|
- you correct a bad fact; it knows its own hit rate
|
||||||
|
- self-quieting falls out of this
|
||||||
|
|
||||||
|
### identity
|
||||||
|
- consistent character — tone, values, phrasing. pinned in prompt, makes restraint legible
|
||||||
|
|
||||||
|
### surface
|
||||||
|
- talk to it (phone page, pc client)
|
||||||
|
- it reaches you (the channels above)
|
||||||
|
- **prove it's you** — auth; this thing holds your life and can act
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## non-goals — what maven isn't
|
||||||
|
|
||||||
|
**outside boundary**
|
||||||
|
- not alexa/siri on steroids
|
||||||
|
- not a smart device
|
||||||
|
|
||||||
|
**inside boundary** (the ones that actually constrain the build)
|
||||||
|
- **not autonomous** — suggests and acts on command; proactive triggers never get unsandboxed action rights. "backup failed, rerun it?" — never reruns it herself. suggest≠act is the safety model
|
||||||
|
- **not a guesser-of-truth** — inference changes whether she asks, never what she records. a confident wrong fact is worse than a known gap
|
||||||
|
- **not a nag** — she'd rather miss a nudge than be mutable. shuts up when uncertain. load-bearing
|
||||||
|
- **not a stranger** — runs on your stuff, your model, your data. never phones home
|
||||||
|
- **not a relationship** — mom-tone is a function that makes nudges land, not emotional company. names the drift a warm small model falls into
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## architecture
|
||||||
|
|
||||||
|
- daemon lives on homesrv (always-on), not the workstation
|
||||||
|
- trigger loop is dumb: ticks ~1min, no llm, evaluates deterministic predicates against state
|
||||||
|
- llm wakes only when a predicate fires; job is narrow — phrase + (no) final veto, not drive the loop
|
||||||
|
- lfm2.5 / sub-1b for phrasing — prompted, not trained. training effort stays on tts/stt/personality
|
||||||
|
- presence = decaying confidence score over multiple weak signals, with hysteresis; never trust one source
|
||||||
|
- self-facts → care nudges. world-facts → ops + context. same engine
|
||||||
|
- rules as code, not a config DSL — until ~30 rules and you feel the pain
|
||||||
|
- proactive triggers: read + suggest only, never action rights
|
||||||
|
|
||||||
|
### build order
|
||||||
|
state layer is first. nothing proactive works without state to evaluate predicates against — it's the floor, built before the loop, phrasing, or delivery.
|
||||||
|
|
||||||
|
### storage — sqlite
|
||||||
|
- single-user, no concurrent writers, on a box already tight on ram → sqlite, not postgres. library not a process, no port to harden, backup is `cp`
|
||||||
|
- **sqlcipher** at-rest. key read at daemon start, not hardcoded
|
||||||
|
- give up postgres `LISTEN/NOTIFY` — loop polls anyway, non-loss
|
||||||
|
|
||||||
|
```sql
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA synchronous=NORMAL;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA busy_timeout=5000;
|
||||||
|
```
|
||||||
|
|
||||||
|
### schema — append-only, three shapes
|
||||||
|
never UPDATE a value. a wrong fact is superseded, not overwritten — keeps the audit trail. current value = latest non-voided row for a key.
|
||||||
|
|
||||||
|
**facts** — substrate, all observations (self + env)
|
||||||
|
```sql
|
||||||
|
facts (
|
||||||
|
id, ts, -- ts = valid-time (true-as-of), not insert-time
|
||||||
|
kind, -- 'self' | 'env' | 'config'
|
||||||
|
key, value, -- value json if structured
|
||||||
|
source, -- tap:* | infer:* | poll:* | ambient | promote | feedback
|
||||||
|
confidence, -- 1.0 taps only; <1 inferred
|
||||||
|
voids_id -- correction points at the fact it cancels
|
||||||
|
)
|
||||||
|
-- index (key, ts desc)
|
||||||
|
```
|
||||||
|
|
||||||
|
**reminders** — user intent, fires once
|
||||||
|
```sql
|
||||||
|
reminders ( id, created_ts, fire_ts, payload, status ) -- pending|fired|cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
**nudges** — every proactive send + outcome. this table IS the restraint memory
|
||||||
|
```sql
|
||||||
|
nudges ( id, ts, rule, channel, message, outcome, outcome_ts ) -- pending|acted|snoozed|ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
**presence is not a table** — pure function over recent facts, computed each tick. only stateful bit is hysteresis:
|
||||||
|
```sql
|
||||||
|
presence_state ( last_bucket, last_score, updated_ts )
|
||||||
|
```
|
||||||
|
|
||||||
|
### trigger model
|
||||||
|
- loop ticks ~60s, no llm. 99% of ticks evaluate a few predicates and die for free
|
||||||
|
- a predicate is `(State) -> Boolean`, **pure, no i/o** → unit-tests with a fake State, zero infra
|
||||||
|
- `since(key)==null` → don't fire. silence on no-data = "shuts up when uncertain"
|
||||||
|
- **the gate is universal, applied by the loop, never per-rule** — quiet-hours, presence, cooldown, snooze, calendar-busy all live in one `fires()`. cross-cutting restraint in one place or it drifts
|
||||||
|
- one nudge per tick (max severity), never dogpile
|
||||||
|
- rules as code, not a DSL. revisit at ~30 rules
|
||||||
|
|
||||||
|
### rules decide, llm phrases
|
||||||
|
the rule decides whether maven speaks — absolute, deterministic. the llm only words it: input `(rule, severity, context)`, output `message`. **no `send`/veto bool.** a nondeterministic sub-1b model never gets to silently kill a greenlit nudge. suppression-context ("don't nag mid-meeting") moves INTO the gate as an env predicate, not the llm's job.
|
||||||
|
|
||||||
|
### user reminders — separate class
|
||||||
|
- relative→absolute **at capture** ("in 4h" → store `now+4h`, never the string)
|
||||||
|
- reuses the loop, not a second scheduler — just a predicate: `fire_ts <= now AND pending`
|
||||||
|
- **bypasses the restraint gate** — "wake me 7" fires in quiet hours; that's the point. snooze still applies. two delivery paths
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## reactive path — router
|
||||||
|
|
||||||
|
### the decision: classifier, not slm
|
||||||
|
routing is a *decision*, and every decision in maven stays deterministic. an slm router is the one nondeterministic decider banned everywhere else. a classifier gives a confidence-scored distribution over a fixed label set, which is what routing actually wants. **classifier owns the route. slm stays in its phrasing lane.** same boundary as `rules decide, llm phrases`, extended to the reactive path.
|
||||||
|
|
||||||
|
### a cascade, not classifier-vs-deterministic
|
||||||
|
not alternatives — layers.
|
||||||
|
- **stage 0 — exact match (regex/grammar).** wake-word + known command grammar. "maven, restart nginx" hits the allowlist directly, skips the classifier. lowest latency — the vosk command path. boring high-frequency acts for free
|
||||||
|
- **stage 1 — intent classifier.** everything free-form. embed utterance, nearest-centroid over labeled intents. one forward pass, ~30ms cpu, yields a similarity score to threshold
|
||||||
|
- **stage 2 — slot extraction, per intent.** classification gives *what kind*, not *the args*. reminders need a datetime, acts need fn + params. you parse
|
||||||
|
- **stage 3 — confidence gate.** below threshold → clarify, don't guess. same pattern as `since(key)==null → don't fire`. a misrouted fact is a confident wrong write — worse than a gap
|
||||||
|
|
||||||
|
### save-where — the two-memory routing axis
|
||||||
|
one discriminator: **does the loop evaluate a predicate against it?**
|
||||||
|
|
||||||
|
| intent | example | lands in | why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| act | "restart the backup" | function call (allowlist) | command now, not stored |
|
||||||
|
| reminder | "wake me 7", "vet tuesday" | `reminders` (sqlite) | has a fire-time |
|
||||||
|
| fact | "drank water", "slept 6h" | `facts` (sqlite) | structured state the loop reasons over |
|
||||||
|
| note | "gpu driver fixed the flicker", "prefer backups at 3am" | chroma/obsidian | recall/preference, no predicate touches it |
|
||||||
|
| query | "is the backup up?", "when'd i last eat?" | slm (reads sqlite or chroma) | answer, don't store |
|
||||||
|
|
||||||
|
fact-vs-note is the whole line: predicate will read it → structured `facts` row; just "recall when relevant" → semantic store. reminder splits off by future timestamp; act splits off by being imperative-now. routing isn't a separate mechanism from classification.
|
||||||
|
|
||||||
|
### the preference seam — forced, not a choice
|
||||||
|
a preference ("prefer backups at 3am") looks like note-or-config. it isn't, because of a hard constraint: **a predicate cannot read chroma.** the loop is dumb + deterministic; it can't run a vector search every tick. so the moment a preference becomes load-bearing, it MUST exist as a structured `key=value` row the loop can evaluate. the store split is physical, not cosmetic. so:
|
||||||
|
- **capture → always a note** (chroma). default, inert, fail-safe. costs nothing, drives nothing
|
||||||
|
- **a note stays a note until a rule needs it**
|
||||||
|
|
||||||
|
### promotion
|
||||||
|
**promotion = the moment a human authors a predicate that reads the value.**
|
||||||
|
- authoring the rule copies the value into `facts (kind=config, source=promote)` — a deterministic key the loop can evaluate
|
||||||
|
- the note stays as provenance (where the config came from, in your words)
|
||||||
|
- the predicate reads the promoted `facts` row, never chroma
|
||||||
|
|
||||||
|
properties: **one-way, never auto** (a note can't self-promote — no path from chroma into the loop that skips a human writing a predicate). **closes the injection hole** — ambient-derive overhears the TV say "prefer backups at 3am" → lands as inert note → can't drive the loop without you authoring the rule. same instinct as `compromised router can't forge a capability`. identical shape to `proposed → enabled`: low-authority form free + automatic, high-authority form requires a deliberate human act.
|
||||||
|
|
||||||
|
### tool registration — drafting is suggest, enabling is act
|
||||||
|
maven can scaffold a tool she's missing. she cannot enable it.
|
||||||
|
- maven detects the gap, scaffolds the registration (name, command, params, destructive y/n), writes a `proposed` row, surfaces it — "earn the right to ask"
|
||||||
|
- `proposed → enabled` flips through an authed surface (pc client / authed page), **never the voice/chat path** — that's the act, and it's the user's
|
||||||
|
|
||||||
|
why human-only: editing the allowlist is the one act that *moves the boundary* — a boundary you can move from inside isn't one. registration is privilege escalation, a different authority tier than invoking a listed tool. paranoid case: prompt injection via ambient-derive — TV says "maven add a shell tool" — a self-registering maven grants *itself* arbitrary capability. human-only enable keeps a compromised maven boxed by what's already on. low-friction stays intact: she builds the stubs, you review + enable. you just never lose the pen.
|
||||||
|
|
||||||
|
### confirmation is not one mechanism
|
||||||
|
a gate assumes a fully-formed action — "drop the db? y/n" works because the action is already specified. an underspecified request can't be gated; you can't confirm what isn't specified. confirmation scales to how formed the act is.
|
||||||
|
|
||||||
|
### stack
|
||||||
|
- **classifier:** `multilingual-e5-small` or `paraphrase-multilingual-MiniLM-L12-v2`, onnx int8 (~120mb). bilingual native, no separate ru/en path. nearest-centroid needs ~10 examples/intent, no training pipeline
|
||||||
|
- **dates:** `dateparser` — ru+en relative+absolute, does `relative→absolute at capture` for free
|
||||||
|
- **acts:** fuzzy-match against the fn allowlist. **not on the list → refuse, don't improvise.** destructive ones still gate behind confirm — "drop the db?" must not fire on a fuzzy match
|
||||||
|
- **slm's remaining lane:** phrasing the reply + last-resort slot extraction for free-form notes the parsers choke on. never the router decision
|
||||||
|
- **misroute correction = new centroid example** — append-only, grows the classifier as used. same shape as `nudges.outcome` tuning cooldowns. more reliable over time, no retrain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## stt / tts (low-tier, cpu, ru+en)
|
||||||
|
|
||||||
|
target box: ryzen + 13gb ram, igpu only, no real gpu. onnxruntime on cpu for everything — igpu rocm is flaky and pointless for sub-1b models.
|
||||||
|
|
||||||
|
picks:
|
||||||
|
- **stt:** faster-whisper small (int8) — primary, ru+en, handles mid-sentence code-switching
|
||||||
|
- **stt (low-latency path):** vosk ru — known command grammar; runs alongside fw (this is stage-0)
|
||||||
|
- **tts:** silero (ru-native) — *verify license + voices first*; piper ru as the safe floor
|
||||||
|
|
||||||
|
| model | role | params | langs (ru?) | license | cpu fit |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **faster-whisper** small/int8 | stt | ~240M | 99+ incl ru ✅ | MIT | ~95% large-v3 acc @ 6x speed |
|
||||||
|
| **vosk** (ru model) | stt | tiny | 20+ incl ru ✅ | Apache 2.0 | real-time, native streaming, runs on a pi |
|
||||||
|
| moonshine | stt | 245M | english only ❌ | MIT | streaming, ~6x smaller than whisper-lg |
|
||||||
|
| **silero** | tts | small | russian-native ✅ | check it* | fast on cpu |
|
||||||
|
| **piper** (ru voices) | tts | tiny | 30+ incl ru ✅ | GPL-3.0 (fork) | real-time on a pi, audibly synthetic |
|
||||||
|
| xtts v2 | tts | ~470M | 17 incl ru ✅ | CPML (non-commercial) | cpu works, 5-10x slower, heavier |
|
||||||
|
| kokoro | tts | 82M | no russian ❌ | Apache 2.0 | fast on cpu, en-first |
|
||||||
|
|
||||||
|
bolded = the actual picks.
|
||||||
|
|
||||||
|
notes: faster-whisper = ctranslate2 int8, ~4x faster than vanilla whisper, lower memory. kokoro is in the table only as a "don't bother for ru" marker. ram math: fw ~0.5–1gb + tts ~1gb → ~10gb left for the router llm + os, comfortable, no swap. silero row is prior knowledge, no source pulled — **confirm license + current ru voices before wiring in.** bilingual / mid-sentence code-switch → lean on faster-whisper, not vosk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## state — signal sources
|
||||||
|
|
||||||
|
**explicit taps set truth. passive signals drive prompting.** a passive signal can never write a self-fact — it only makes maven ask. boundary lives in `source`; rules trust provenance.
|
||||||
|
|
||||||
|
- **self / taps (1.0):** water, meal, shower — phone / voice / telegram
|
||||||
|
- **self / passive (activity, not truth):** desk-idle (hyprland / GetLastInputInfo), voice_active, sleep
|
||||||
|
- **presence (weak, decaying, multi-source):** wg handshake, page heartbeat, hyprland-not-idle. *no LAN sweeps* — too invasive; wg+heartbeat get ~90%
|
||||||
|
- **env / polled:** healthcheck (disk/service/backup/cert), calendar (caldav/.ics), weather
|
||||||
|
- **provenance-scoping:** a rule on `service_down` trusts only `source=poll:healthcheck`. a compromised poller must not be able to forge a trigger
|
||||||
|
|
||||||
|
### presence — concrete scoring
|
||||||
|
|
||||||
|
pinned: pure function over recent facts, computed each tick; decaying confidence over multiple weak signals, never one authoritative source; hysteresis to stop flapping.
|
||||||
|
|
||||||
|
**combiner — noisy-OR, not weighted sum.** these are independent-ish positive indicators of one latent binary ("kami here?").
|
||||||
|
```
|
||||||
|
p_i = weight_i · exp(-Δt_i / τ_i) # one signal's decayed contribution
|
||||||
|
P = 1 − Π (1 − p_i) # combine
|
||||||
|
```
|
||||||
|
diminishing returns on stacking weak signals, never exceeds 1.0. "any one signal raises confidence, no single one owns it" falls straight out. weighted-sum rejected: heartbeat+wg fresh would peg identical to everything-fresh — overcounts.
|
||||||
|
|
||||||
|
**signals — weights + decay.** `Δt` = `now − latest fact ts` for that key. no fact → drops out of the product (not a zero).
|
||||||
|
|
||||||
|
| signal | source | fresh weight | τ (min) | rationale |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| desk active | `infer:hyprland` (not-idle) | 0.90 | 8 | input = human at keyboard. strongest. τ forgives read/think gaps |
|
||||||
|
| page heartbeat | `infer:heartbeat` | 0.60 | 4 | a surface you use is open + alive. pings ~30s; 4min gap = gone |
|
||||||
|
| wg handshake | `infer:wg` | 0.40 | 20 | device on tunnel. coarse — pocket or three rooms away. weakest, slowest |
|
||||||
|
|
||||||
|
**threshold + hysteresis (schmitt trigger):**
|
||||||
|
```
|
||||||
|
ENTER (away → present): P ≥ 0.55
|
||||||
|
EXIT (present → away): P < 0.30
|
||||||
|
cold start: away # fail-closed; same as since(key)==null → don't fire
|
||||||
|
```
|
||||||
|
wide band = stable. a lone fresh wg (0.40) can't *enter* present but *holds* it while decaying — phone-on-network alone never declares you here.
|
||||||
|
|
||||||
|
| scenario | P | bucket |
|
||||||
|
|---|---|---|
|
||||||
|
| at desk, typing | ~0.90 | present |
|
||||||
|
| at desk, 5min reading (client open) | ~0.79 | present |
|
||||||
|
| desk-only, ~9min zero input | <0.30 | → away |
|
||||||
|
| couch, phone page open, no desk | 0.60 | present |
|
||||||
|
| left house, only wg lingering | decays ~20min | → away |
|
||||||
|
| cold boot, nothing | 0 | away |
|
||||||
|
|
||||||
|
implementation (core — presence reads State under the lock, predicate input, not a module):
|
||||||
|
```kotlin
|
||||||
|
data class Signal(val key: String, val weight: Double, val tauMin: Double)
|
||||||
|
|
||||||
|
val SIGNALS = listOf(
|
||||||
|
Signal("desk_active", 0.90, 8.0),
|
||||||
|
Signal("page_heartbeat", 0.60, 4.0),
|
||||||
|
Signal("wg_handshake", 0.40, 20.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun presenceScore(now: Instant, state: State): Double {
|
||||||
|
var pAway = 1.0
|
||||||
|
for (s in SIGNALS) {
|
||||||
|
val last = state.lastTs(s.key) ?: continue // no data → drops out
|
||||||
|
val dt = Duration.between(last, now).toMillis() / 60000.0
|
||||||
|
pAway *= (1.0 - s.weight * exp(-dt / s.tauMin)) // noisy-OR
|
||||||
|
}
|
||||||
|
return 1.0 - pAway
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolve(score: Double, last: Bucket) = when (last) {
|
||||||
|
PRESENT -> if (score < 0.30) AWAY else PRESENT
|
||||||
|
AWAY -> if (score >= 0.55) PRESENT else AWAY
|
||||||
|
}
|
||||||
|
```
|
||||||
|
each tick: `score = presenceScore(now, state)` → `bucket = resolve(score, state.lastBucket)` → persist `presence_state`. decay uses wall-clock Δt, so 60s tick jitter causes no drift.
|
||||||
|
|
||||||
|
boundaries: **hand-tuned, NOT feedback-tuned** — keep presence numbers out of the auto-tuner or a weird week drifts you silently invisible. **presence = reachability, not wakefulness** — sleep/quiet-hours handled separately in the gate. feeds delivery routing directly. caveat (stated, not litigated): noisy-OR assumes independence; desk+heartbeat correlate (pc client open ⇒ both fire) — co-firing slightly overcounts, which is fine, genuinely co-firing IS stronger evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## proactive
|
||||||
|
|
||||||
|
### listening — three modes, not one
|
||||||
|
"ambient listening" was the wrong frame — three capabilities split by trigger + retention. threat model is local-only: the file at rest (sqlcipher, extend to any retained audio/transcript) and who can reach the box (auth). that's it.
|
||||||
|
|
||||||
|
1. **address-capture** — "maven, note this" out loud. hands-free voice path, no tap. raw audio dies after deriving the fact. **← mvp pick.** lowest retention, highest daily payoff, ~free (voice path minus the tap)
|
||||||
|
2. **meeting-record** — deliberate start/stop, verbatim transcript *kept*. retention is the point (overrides any "always delete" rule). post-mvp
|
||||||
|
3. **ambient-derive** — background overhearing, low-confidence candidate facts, raw ephemeral, only derived fact survives. needs the confidence model working first. last
|
||||||
|
|
||||||
|
only mode 3 is always-on. modes 1–2 fire heavy transcription on explicit trigger/record → ryzen idles. always-on = lightweight VAD (+ maybe owner-detect) only. **speaker-scoping = attribution metadata, not a kill-gate** — tag `source=ambient:self | ambient:other`. per-person retention becomes a `WHERE` clause — "delete what you have from gf" is a query, not a problem.
|
||||||
|
|
||||||
|
### delivery / channel routing
|
||||||
|
routing = `f(severity, presence)`. presence decides *reachability*; severity decides *insistence*. need both.
|
||||||
|
|
||||||
|
| | present | away |
|
||||||
|
|---|---|---|
|
||||||
|
| **sev1–2** (care) | voice | **drop** |
|
||||||
|
| **sev3** (ops, soft) | voice | ntfy, once |
|
||||||
|
| **sev4** (ops, hard) | voice + ntfy | telegram, repeat til ack |
|
||||||
|
|
||||||
|
sev ≤ 2 drops on away, sev ≥ 3 holds. a missed water nudge is noise; a missed backup-failure isn't. away-channels (ntfy/telegram) leave the box — the one path that crosses "never phones home," through your relay. **minimal body** — "disk low on homesrv," not detail. don't make notifications a shoulder-surf exfil surface.
|
||||||
|
|
||||||
|
### feedback loop (outcomes → tune cooldowns)
|
||||||
|
the `nudges.outcome` column IS the signal — no new storage. cooldown becomes a function of recent outcomes, not a constant.
|
||||||
|
- mostly `ignored` → nagging into the void → lengthen cooldown / raise threshold
|
||||||
|
- mostly `acted` → landing → leave, or cautiously shorten
|
||||||
|
- mostly `snoozed` → right nudge wrong *time* → shift the window, not the frequency
|
||||||
|
|
||||||
|
**tunes parameters, never logic.** can widen cooldown, nudge threshold, shift window. cannot rewrite a predicate or invent a rule. bounded knobs (`cooldown ∈ [min,max]`) so a weird week can't mutate maven silent or stalker. **dead simple at mvp:** a ratio over last N, not a learned model. `ignored_rate > 0.7 → cooldown *= 1.5, capped`. introspectable. **persist the adjusted cooldown as a fact** (`source=feedback`) — survives restart, stays visible; why maven went quiet should be a query, not a mystery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## auth
|
||||||
|
|
||||||
|
### a cascade, not a pick-one
|
||||||
|
same shape as `confirmation is not one mechanism` — each layer answers a different question.
|
||||||
|
|
||||||
|
| layer | question | mechanism | surface |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 — tunnel | on the network at all? | wireguard | everything. floor |
|
||||||
|
| 1 — device | enrolled box? | mTLS client cert, terminated at proxy | pc client, authed page |
|
||||||
|
| 2 — session | you, this session? | passkey / webauthn | pc client, authed page |
|
||||||
|
| 3 — step-up | you, *right now*, for this act? | passkey user-verification gesture | registration-enable, destructive acts, **core cold-start** |
|
||||||
|
|
||||||
|
wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is "authed" at layer 0 only — that gap is why the upper layers exist. **passkey over password/token** because step-up is load-bearing: webauthn gives per-assertion user-verification for free — the biometric/PIN gesture IS the human-in-the-loop. no shared secret on the box to steal; private key stays in enclave/TPM. spring security 6.4+ has native passkey support → in-stack for kotlin/spring. **mTLS (layer 1) optional** — if dropping one, drop mTLS, never the passkey.
|
||||||
|
|
||||||
|
### the invariant — surface caps authority
|
||||||
|
**auth tier is a property of the surface; the surface caps maximum authority.** you can't step up past what the channel structurally carries.
|
||||||
|
- **voice** presents layer 0 + speaker-attribution and STOPS. speaker verification is attribution, not auth. a room mic is reachable by gf, the TV, anyone present → voice is *structurally incapable* of layer 3
|
||||||
|
- **telegram inbound** = possession of a telegram account + chat-id allowlist. telegram's auth, outside our control. weak tier → read + soft acts, never destructive, never registration
|
||||||
|
|
||||||
|
so voice/chat can never reach registration-enable — **not because auth "failed" but because the channel can't carry the proof.**
|
||||||
|
|
||||||
|
### key-provenance
|
||||||
|
**the theorem:** no unattended key source survives a powered-on stolen box. anything the daemon fetches with no human present, a thief who grabs the running laptop fetches too. so the question was never "find the secure source" — it's **pick the failure mode:** unattended-but-loses-to-running-theft, or theft-resistant-but-attended. structural, can't have both.
|
||||||
|
|
||||||
|
**the trap — TPM-alone:** TPM-sealing binds release to PCRs, defeats offline disk extraction (real, worth having). but against full-box theft it does nothing — PCRs still match, thief boots, TPM unseals on cue. a laptop is portable → full-box theft is the *likely* case. TPM-alone protects the scenario you're less exposed to.
|
||||||
|
|
||||||
|
**the resolution — cold-start IS a layer-3 act:** unlocking the db makes maven's entire memory readable — the single highest-authority op. so **daemon cold-start unlock IS layer-3 step-up.** "always-on" = doesn't need babysitting during *normal operation*; does NOT mean *survives a cold boot with nobody around*. unlock is **remote-attended** — `systemd-ask-password` over ssh, or pushed through the passkey-authed page. theft = box reboots into a locked daemon and stays there.
|
||||||
|
|
||||||
|
concrete stack:
|
||||||
|
|
||||||
|
| layer | mechanism | buys |
|
||||||
|
|---|---|---|
|
||||||
|
| disk | LUKS2, `systemd-cryptenroll` **TPM2 + PIN** | offline-extraction dead (TPM), powered-theft needs PIN-in-head |
|
||||||
|
| sqlcipher key | not at rest. supplied at daemon start via `systemd` credentials / `ask-password`, sourced from the authed surface, held in process memory only | unlock is a deliberate gesture, never a file to steal |
|
||||||
|
|
||||||
|
TPM+PIN is the honest middle. yubikey challenge-response is the upgrade path *if carried on your body* — don't reach for it yet. **runtime:** key in daemon RAM → `mlock` the page (no swap-to-disk), swap off or encrypted. zero on shutdown. **the trade:** a cold reboot needs you (remotely) present; in exchange a stolen laptop — running or off — is a brick holding ciphertext. matches the fail-closed posture.
|
||||||
|
|
||||||
|
### core/module key-isolation
|
||||||
|
the convenience (reboot-attendance collapses to *core cold-start only*) is contingent on one thing: **the key lives in core's address space and nowhere else.**
|
||||||
|
|
||||||
|
- **core = the only key-holder.** daemon holding sqlcipher-unlocked db + trigger loop. unlocked once (remote-attended), runs weeks. reboots almost never *because it was deliberately given nothing that churns* — no tool code, no model weights, no router. just state + loop
|
||||||
|
- **modules = restart-free, key-free, fail-independent.** stt/tts, router/classifier, tool executors, delivery. update/crash/swap one → none touch the unlock. "update the tool module, no attendance" is correct *by construction* — the module never had the key
|
||||||
|
|
||||||
|
enforce it: **IPC boundary, not shared address space** (unix domain socket, local-only — a crashing tts can't read the key page). **core mediates, never hands back a db handle** — modules send requests *to* core ("write this fact" / "read presence"); core never returns raw db access. **module compromise ≤ module authority** — worst a popped tts does is send garbage audio.
|
||||||
|
|
||||||
|
the discipline: **nothing enters core's process unless it must read state under the lock.** loop qualifies. predicates qualify. phrasing, routing, transcription, tool execution, delivery all read *derived* data or send requests — none need the raw key. erode this and you buy back the attendance you just eliminated. **systemd topology:** core = one unit; each module its own unit, `After=core.socket`, socket-activated, `Restart=on-failure`.
|
||||||
|
|
||||||
|
### the through-line
|
||||||
|
network → box → process: same question at three radii.
|
||||||
|
- **network (wg):** who reaches the box
|
||||||
|
- **box (LUKS+TPM+PIN, sqlcipher):** what a dead/stolen box gives up
|
||||||
|
- **process (core/module socket):** what a compromised module reaches
|
||||||
|
|
||||||
|
every cut is the same instinct — *a boundary you can move from inside isn't one*, *compromised X can't forge Y*, *attribution is not auth*. auth didn't add a new principle; it applied the existing one at smaller and smaller scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## open questions
|
||||||
|
|
||||||
|
> four items the old `decisions` doc listed as open — **router + invocation**, **two-memory routing**, **presence (concretely)**, **auth** — are now resolved by their respective sections above and are NOT listed here.
|
||||||
|
|
||||||
|
### impactful — gate behaviour or the mvp surface
|
||||||
|
|
||||||
|
- **stage-3 confidence threshold** — the gate-or-clarify number for the router. unset. defines how often maven asks vs. guesses on free-form input; the whole reactive mvp feel rides on it
|
||||||
|
- **quiet-hours definition** — fixed clock vs derived from sleep facts. gates *all* proactive delivery; can't ship the loop's restraint without it
|
||||||
|
- **confirmation tier model** — only the forced pin is settled (**registration is out-of-band, human-only**). the rest is a sketch: confirmation scales with specification + reversibility (rough buckets: just-do / binary-confirm / clarify-then-confirm / out-of-band). don't anchor on the count or any `f()`. open sub-item: **destructive-act confirm — policy-level vs per-function flag**
|
||||||
|
- **ask-password transport** — `systemd-ask-password` over ssh vs passkey-authed page push for cold-start unlock. both work; unpicked. blocks remote-attended boot being real
|
||||||
|
|
||||||
|
### plumbing / deferred — won't block the core build
|
||||||
|
|
||||||
|
- **passkey enrollment bootstrap** — first credential on a fresh device before a passkey exists to authenticate with. the trust-on-first-use gap
|
||||||
|
- **mTLS in or out** — provisioning cost on mobile vs paranoia payoff. leaning optional
|
||||||
|
- **PIN vs yubikey for LUKS** — PIN-in-head pinned for now; yubikey deferred unless carry-on-body becomes the model
|
||||||
|
- **session lifetime / re-auth cadence** — how long a layer-2 session lives before forcing re-assertion. unset
|
||||||
|
- **core↔module wire format** — socket decided, the protocol on it isn't (length-prefixed json vs something tighter)
|
||||||
|
- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one note in one utterance. classifier picks one label/utterance → compound splits (second pass) or loses half
|
||||||
|
- **query read-path** — "when do i like backups?" is RAG-over-chroma, not the sqlite read the router table implies. query-answering is chroma-RAG OR sqlite-read depending on the ask
|
||||||
|
- **obsidian↔chroma mechanics** — canonical md, derived embedding index, chunking. implementation of `obsidian → chroma`, unbuilt
|
||||||
|
- **presence — away tap override** — explicit `away` tap as hard override (voids presence to away for a duration). clean extension, deferred; scoring stands without it
|
||||||
|
- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning once the loop runs against real signal traces
|
||||||
|
- **presence — wg home-vs-cellular** — distinguish home-wifi-via-wg vs cellular-via-wg to let wg carry more weight when clearly home. needs the signal to exist first
|
||||||
|
- **personality prompt** — the identity character spec. unwritten
|
||||||
|
- **custom tts voice — train on a hand-picked voice** — future: replace the piper ru floor (irina) with a voice kami picks himself, trained/fine-tuned. per the stt/tts note, *training effort is where the personality budget goes* — this is that budget. piper supports voice training; xtts/silero fine-tune too. unscoped: pick the voice + the trainer, dataset size, cpu-vs-gpu train. deferred until the reactive+proactive core is solid; the irina floor ships first.
|
||||||
|
- **daemon runtime** — deferred until the homelab tool-call surface is real
|
||||||
|
- **listening modes 2–3** — meeting-record + ambient-derive. post-mvp; ambient-derive needs the confidence model working first
|
||||||
Executable
+25
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user