Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8fcb404be | |||
| dc4c5b7841 | |||
| 33e53ee897 | |||
| 45b5e16eff | |||
| 4eca20bd94 | |||
| fed33a4e16 | |||
| 62cc072f8c | |||
| 7c7bd8ceeb | |||
| aa1a26532c | |||
| d92349ca6e | |||
| 8d5e357b57 | |||
| 95ae900a58 | |||
| be066a4b04 | |||
| ad074cea31 | |||
| 2c1b0eede0 | |||
| cb3641e7bb | |||
| ee7bec11e3 | |||
| f42d1594ef | |||
| b4646155b4 | |||
| da647e87d0 | |||
| bf6ccf9aea | |||
| 7b2b96b957 | |||
| c8444813e2 | |||
| ed9bdd5e09 | |||
| 49f089d8a6 | |||
| 3af290152c | |||
| dc7c72a3d7 | |||
| 766ca091a7 | |||
| c5317eb2b4 | |||
| 9190f897a3 | |||
| d29e7ba813 |
@@ -7,6 +7,8 @@
|
|||||||
/mavpoll
|
/mavpoll
|
||||||
/mavcaldav
|
/mavcaldav
|
||||||
/mavwaked
|
/mavwaked
|
||||||
|
/mavmaild
|
||||||
|
/mavupdate
|
||||||
|
|
||||||
# Certs (private keys, don't commit)
|
# Certs (private keys, don't commit)
|
||||||
certs/
|
certs/
|
||||||
@@ -34,6 +36,10 @@ deps
|
|||||||
deploy/db_key.env
|
deploy/db_key.env
|
||||||
# Deploy secret (telegram bot token + chat id) — never commit
|
# Deploy secret (telegram bot token + chat id) — never commit
|
||||||
deploy/telegram.env
|
deploy/telegram.env
|
||||||
|
# zenmoney API token, read by mavpoll (never in argv, never committed)
|
||||||
|
deploy/zenmoney.token
|
||||||
|
# IMAP password, read by mavmaild (never in argv, never committed)
|
||||||
|
deploy/imap.password
|
||||||
|
|
||||||
# Temp files
|
# Temp files
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
|
|||||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # all 8 binaries
|
make build # all 9 binaries
|
||||||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||||
```
|
```
|
||||||
@@ -62,6 +62,7 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
|||||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||||
| `mavpoll` | Telegram long-poll reach. |
|
| `mavpoll` | Telegram long-poll reach. |
|
||||||
| `mavcaldav` | CalDAV calendar sync. |
|
| `mavcaldav` | CalDAV calendar sync. |
|
||||||
|
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||||
|
|
||||||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ RUN go build -o /out/mavend ./cmd/mavend && \
|
|||||||
go build -o /out/mavttsd ./cmd/mavttsd && \
|
go build -o /out/mavttsd ./cmd/mavttsd && \
|
||||||
go build -o /out/mavweb ./cmd/mavweb && \
|
go build -o /out/mavweb ./cmd/mavweb && \
|
||||||
go build -o /out/mavpoll ./cmd/mavpoll && \
|
go build -o /out/mavpoll ./cmd/mavpoll && \
|
||||||
go build -o /out/mavcaldav ./cmd/mavcaldav
|
go build -o /out/mavcaldav ./cmd/mavcaldav && \
|
||||||
|
go build -o /out/mavmaild ./cmd/mavmaild
|
||||||
|
|
||||||
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
|
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
|
||||||
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
|
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||||
|
|
||||||
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav
|
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
|
||||||
|
|
||||||
build-stt:
|
build-stt:
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
@@ -50,6 +50,15 @@ build-poll:
|
|||||||
build-caldav:
|
build-caldav:
|
||||||
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
||||||
|
|
||||||
|
build-mail:
|
||||||
|
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||||
|
|
||||||
|
# mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the
|
||||||
|
# box. It is built with the rest so a broken update path is caught by `make
|
||||||
|
# build` rather than the first time it is needed.
|
||||||
|
build-update:
|
||||||
|
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
|
||||||
|
|
||||||
run-web: build-web
|
run-web: build-web
|
||||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||||
|
|
||||||
@@ -82,6 +91,14 @@ vet:
|
|||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
$(GO) vet ./internal/... ./cmd/...
|
$(GO) vet ./internal/... ./cmd/...
|
||||||
|
|
||||||
|
# simulate — replay every scripted day under cmd/mavend/testdata/scenarios
|
||||||
|
# through the real router, store, tick loop and intake journal, on a fake clock
|
||||||
|
# (Vikunja #284). Verbose so the transcript of each scenario lands in the
|
||||||
|
# terminal. Also runs as part of `make test`; this target is for reading it.
|
||||||
|
simulate:
|
||||||
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
|
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
|
||||||
|
|
||||||
test: fmt-check vet
|
test: fmt-check vet
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||||
@@ -130,6 +147,17 @@ eval-models:
|
|||||||
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
||||||
-run TestLLMRouterBaseline ./internal/router/eval/
|
-run TestLLMRouterBaseline ./internal/router/eval/
|
||||||
|
|
||||||
|
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
|
||||||
|
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
|
||||||
|
# so this is the only way they should ever change. TestGoldenAudioTranscription
|
||||||
|
# then scores them against ggml-small; it self-skips when the model is absent.
|
||||||
|
stt-fixtures:
|
||||||
|
./scripts/gen-stt-fixtures.sh
|
||||||
|
|
||||||
|
test-stt-golden:
|
||||||
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
|
$(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/
|
||||||
|
|
||||||
run-stt: build-stt
|
run-stt: build-stt
|
||||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
||||||
@@ -185,4 +213,4 @@ download-embedder:
|
|||||||
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked
|
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked mavmaild
|
||||||
|
|||||||
+75
-144
@@ -1,17 +1,24 @@
|
|||||||
// mavcaldav — the CalDAV poller module.
|
// mavcaldav — the CalDAV module: reads calendars into facts, and renders
|
||||||
|
// maven's own reminders back out to a calendar she owns.
|
||||||
//
|
//
|
||||||
// Polls a Radicale (or any CalDAV) server for today's events and writes
|
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
// touch the store key, worst case a stale calendar_busy fact until the next
|
||||||
|
// poll. Two facts:
|
||||||
//
|
//
|
||||||
// Two facts written:
|
|
||||||
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||||
// nudges during meetings
|
// nudges during meetings
|
||||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||||
//
|
//
|
||||||
// Append-only discipline: a fact is written only when its value CHANGED
|
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||||
// vs the latest for that key+source.
|
// latest for that key+source.
|
||||||
|
//
|
||||||
|
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
|
||||||
|
// pending reminder as a single-event iCal resource in a collection maven owns.
|
||||||
|
// The calendar is a view, sqlite is the store — see render.go. The render URL
|
||||||
|
// must differ from the read URL, checked at startup, so the render target can
|
||||||
|
// never be a calendar maven is only supposed to read.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,6 +34,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +51,10 @@ func run(args []string) error {
|
|||||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||||
|
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||||
|
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||||
|
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||||
|
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
|||||||
if *url == "" || *user == "" || *pass == "" {
|
if *url == "" || *user == "" || *pass == "" {
|
||||||
return fmt.Errorf("-url, -user, -pass are required")
|
return fmt.Errorf("-url, -user, -pass are required")
|
||||||
}
|
}
|
||||||
|
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
defer core.Close()
|
defer core.Close()
|
||||||
|
|
||||||
|
hc := &http.Client{Timeout: *timeout}
|
||||||
p := &poller{
|
p := &poller{
|
||||||
core: core,
|
core: core,
|
||||||
http: &http.Client{Timeout: *timeout},
|
http: hc,
|
||||||
url: strings.TrimRight(*url, "/"),
|
url: strings.TrimRight(*url, "/"),
|
||||||
user: *user,
|
user: *user,
|
||||||
pass: *pass,
|
pass: *pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var rend *renderer
|
||||||
|
if *renderURL != "" {
|
||||||
|
ru, rp := *renderUser, *renderPass
|
||||||
|
if ru == "" {
|
||||||
|
ru = *user
|
||||||
|
}
|
||||||
|
if rp == "" {
|
||||||
|
rp = *pass
|
||||||
|
}
|
||||||
|
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||||
|
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
||||||
p.pollOnce(ctx) // fire immediately
|
tick := func() {
|
||||||
|
p.pollOnce(ctx)
|
||||||
|
if rend != nil {
|
||||||
|
rend.renderOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick() // fire immediately
|
||||||
t := time.NewTicker(*interval)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -82,11 +117,30 @@ func run(args []string) error {
|
|||||||
log.Printf("mavcaldav: bye")
|
log.Printf("mavcaldav: bye")
|
||||||
return nil
|
return nil
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
p.pollOnce(ctx)
|
tick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
||||||
|
// structural half of #127's "cannot write to your work calendar": the write
|
||||||
|
// credential and the write URL are separate flags, and the one calendar maven
|
||||||
|
// is known to only read is rejected as a target at startup rather than trusted
|
||||||
|
// at runtime.
|
||||||
|
func checkRenderTarget(readURL, renderURL string) error {
|
||||||
|
if renderURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sameCollection(readURL, renderURL) {
|
||||||
|
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCollection(a, b string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
type poller struct {
|
type poller struct {
|
||||||
core ipc.CoreAPI
|
core ipc.CoreAPI
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -95,12 +149,6 @@ type poller struct {
|
|||||||
pass string
|
pass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type icalEvent struct {
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
summary string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *poller) pollOnce(ctx context.Context) {
|
func (p *poller) pollOnce(ctx context.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
events, err := p.fetchEvents(ctx, now)
|
events, err := p.fetchEvents(ctx, now)
|
||||||
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy := false
|
|
||||||
for _, e := range events {
|
|
||||||
if !now.Before(e.start) && now.Before(e.end) {
|
|
||||||
busy = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
busyVal := "false"
|
busyVal := "false"
|
||||||
if busy {
|
if calendar.Busy(events, now) {
|
||||||
busyVal = "true"
|
busyVal = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write calendar_busy on change.
|
// Write calendar_busy on change.
|
||||||
if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil {
|
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write per-event facts (one per event, keyed by event summary + start).
|
// Write per-event facts (one per event, keyed by day + event summary).
|
||||||
// This lets the note RAG path answer "what's on my calendar" without
|
// This lets the note RAG path answer "what's on my calendar" without
|
||||||
// reaching back to Radicale.
|
// reaching back to Radicale.
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
key := calendar.FactKey(e)
|
||||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) {
|
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -162,120 +202,11 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e
|
|||||||
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseICal(body, now), nil
|
return calendar.ParseICalDay(body, now), nil
|
||||||
}
|
|
||||||
|
|
||||||
// parseICal scans iCal text for VEVENT components. Returns events that overlap
|
|
||||||
// with today (UTC day boundaries) to keep the response manageable.
|
|
||||||
func parseICal(body []byte, now time.Time) []icalEvent {
|
|
||||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
todayEnd := todayStart.AddDate(0, 0, 1)
|
|
||||||
|
|
||||||
var events []icalEvent
|
|
||||||
text := string(body)
|
|
||||||
for {
|
|
||||||
veventStart := strings.Index(text, "BEGIN:VEVENT")
|
|
||||||
if veventStart < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
text = text[veventStart+len("BEGIN:VEVENT"):]
|
|
||||||
veventEnd := strings.Index(text, "END:VEVENT")
|
|
||||||
if veventEnd < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
block := text[:veventEnd]
|
|
||||||
text = text[veventEnd+len("END:VEVENT"):]
|
|
||||||
|
|
||||||
e := parseVEVENT(block)
|
|
||||||
if e == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only keep events overlapping today.
|
|
||||||
if e.end.After(todayStart) && e.start.Before(todayEnd) {
|
|
||||||
events = append(events, *e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseVEVENT extracts start, end, summary from a VEVENT block.
|
|
||||||
// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...)
|
|
||||||
// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or
|
|
||||||
// parse failures.
|
|
||||||
func parseVEVENT(block string) *icalEvent {
|
|
||||||
var e icalEvent
|
|
||||||
lines := strings.Split(block, "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "DTSTART"):
|
|
||||||
if t, ok := parseDT(line); ok {
|
|
||||||
e.start = t
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(line, "DTEND"):
|
|
||||||
if t, ok := parseDT(line); ok {
|
|
||||||
e.end = t
|
|
||||||
}
|
|
||||||
case strings.HasPrefix(line, "SUMMARY"):
|
|
||||||
if idx := strings.Index(line, ":"); idx >= 0 {
|
|
||||||
e.summary = strings.TrimSpace(line[idx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e.start.IsZero() || e.end.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &e
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDT parses a DTSTART/DTEND value. Supports:
|
|
||||||
// - UTC: DTEND:20260703T100000Z
|
|
||||||
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
|
||||||
// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time)
|
|
||||||
func parseDT(line string) (time.Time, bool) {
|
|
||||||
if strings.Contains(line, "VALUE=DATE:") {
|
|
||||||
return time.Time{}, false // all-day, skip
|
|
||||||
}
|
|
||||||
idx := strings.LastIndex(line, ":")
|
|
||||||
if idx < 0 {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
val := line[idx+1:]
|
|
||||||
val = strings.TrimSuffix(val, "Z")
|
|
||||||
|
|
||||||
// Try UTC first (has Z suffix, or ended in Z before TrimSuffix).
|
|
||||||
if strings.HasSuffix(line, "Z") {
|
|
||||||
t, err := time.Parse("20060102T150405", val)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return t.UTC(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local time — treat as UTC for simplicity (CalDAV server and poller
|
|
||||||
// run in the same timezone; the gate only needs busy/not-busy accuracy).
|
|
||||||
t, err := time.Parse("20060102T150405", val)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
return t.UTC(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash).
|
|
||||||
func safeKey(s string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
|
||||||
b.WriteRune(r)
|
|
||||||
} else if r == ' ' || r == '_' {
|
|
||||||
b.WriteRune('-')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && prev.Value == val:
|
case err == nil && prev.Value == val:
|
||||||
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
|||||||
Key: key,
|
Key: key,
|
||||||
Value: val,
|
Value: val,
|
||||||
Source: source,
|
Source: source,
|
||||||
Confidence: 1.0,
|
Confidence: confidence,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write %s: %w", key, err)
|
return fmt.Errorf("write %s: %w", key, err)
|
||||||
|
|||||||
+5
-164
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
|||||||
return int64(len(f.writeLog)), nil
|
return int64(len(f.writeLog)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Parsing tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestParseICal(t *testing.T) {
|
|
||||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
body := []byte(`BEGIN:VCALENDAR
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260703T090000Z
|
|
||||||
DTEND:20260703T100000Z
|
|
||||||
SUMMARY:Morning standup
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260703T140000Z
|
|
||||||
DTEND:20260703T150000Z
|
|
||||||
SUMMARY:Team sync
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260702T140000Z
|
|
||||||
DTEND:20260702T150000Z
|
|
||||||
SUMMARY:Yesterday retro
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART:20260704T090000Z
|
|
||||||
DTEND:20260704T100000Z
|
|
||||||
SUMMARY:Tomorrow standup
|
|
||||||
END:VEVENT
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART;VALUE=DATE:20260704
|
|
||||||
DTEND;VALUE=DATE:20260705
|
|
||||||
SUMMARY:All-day event
|
|
||||||
END:VEVENT
|
|
||||||
END:VCALENDAR`)
|
|
||||||
|
|
||||||
events := parseICal(body, now)
|
|
||||||
|
|
||||||
if len(events) != 2 {
|
|
||||||
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Morning standup — overlaps today.
|
|
||||||
if events[0].summary != "Morning standup" {
|
|
||||||
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
|
|
||||||
}
|
|
||||||
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
if !events[0].start.Equal(wantStart0) {
|
|
||||||
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
|
|
||||||
}
|
|
||||||
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
|
||||||
if !events[0].end.Equal(wantEnd0) {
|
|
||||||
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team sync — overlaps today.
|
|
||||||
if events[1].summary != "Team sync" {
|
|
||||||
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
|
|
||||||
}
|
|
||||||
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
|
||||||
if !events[1].start.Equal(wantStart1) {
|
|
||||||
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
|
|
||||||
}
|
|
||||||
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
|
|
||||||
if !events[1].end.Equal(wantEnd1) {
|
|
||||||
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseVEVENT(t *testing.T) {
|
|
||||||
// Normal event with TZID in DTSTART and UTC DTEND.
|
|
||||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
|
||||||
e := parseVEVENT(block)
|
|
||||||
if e == nil {
|
|
||||||
t.Fatal("expected non-nil icalEvent")
|
|
||||||
}
|
|
||||||
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
|
||||||
if !e.start.Equal(wantStart) {
|
|
||||||
t.Errorf("start = %v, want %v", e.start, wantStart)
|
|
||||||
}
|
|
||||||
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
|
||||||
if !e.end.Equal(wantEnd) {
|
|
||||||
t.Errorf("end = %v, want %v", e.end, wantEnd)
|
|
||||||
}
|
|
||||||
if e.summary != "Stand up meeting" {
|
|
||||||
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
|
|
||||||
}
|
|
||||||
|
|
||||||
// All-day event (VALUE=DATE) → nil.
|
|
||||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
|
||||||
if e2 := parseVEVENT(allDay); e2 != nil {
|
|
||||||
t.Error("expected nil for all-day event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDT(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
line string
|
|
||||||
want time.Time
|
|
||||||
wantOK bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "UTC",
|
|
||||||
line: "DTEND:20260703T100000Z",
|
|
||||||
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
|
|
||||||
wantOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "local time",
|
|
||||||
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
|
|
||||||
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
|
|
||||||
wantOK: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all-day",
|
|
||||||
line: "DTSTART;VALUE=DATE:20260703",
|
|
||||||
want: time.Time{},
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid",
|
|
||||||
line: "DTSTART:garbage",
|
|
||||||
want: time.Time{},
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, ok := parseDT(tt.line)
|
|
||||||
if ok != tt.wantOK {
|
|
||||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
|
||||||
}
|
|
||||||
if !got.Equal(tt.want) {
|
|
||||||
t.Errorf("got = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSafeKey(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"Stand up meeting", "Stand-up-meeting"},
|
|
||||||
{"Hello_World", "Hello-World"},
|
|
||||||
{"special@#$chars!!", "specialchars"},
|
|
||||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := safeKey(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core logic tests
|
// Core logic tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("no previous fact writes", func(t *testing.T) {
|
t.Run("no previous fact writes", func(t *testing.T) {
|
||||||
fc := &fakeCore{}
|
fc := &fakeCore{}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
writeErr: fmt.Errorf("disk full"),
|
writeErr: fmt.Errorf("disk full"),
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||||
|
//
|
||||||
|
// It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the
|
||||||
|
// renderer reads the pending reminders out of core and publishes each one as a
|
||||||
|
// single-event iCal resource in a CalDAV collection maven owns. Nothing is ever
|
||||||
|
// read back from that collection, and losing it costs nothing — the next tick
|
||||||
|
// rebuilds it.
|
||||||
|
//
|
||||||
|
// It structurally cannot write to a calendar maven only reads. The URL comes
|
||||||
|
// from its own flag, checked at startup against every read URL (see
|
||||||
|
// run in main.go), and the only paths it ever addresses carry
|
||||||
|
// calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can
|
||||||
|
// only touch resources it created.
|
||||||
|
type renderer struct {
|
||||||
|
core ipc.CoreAPI
|
||||||
|
http *http.Client
|
||||||
|
url string
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
dur time.Duration
|
||||||
|
|
||||||
|
// published maps reminder id → the body last successfully PUT, so an
|
||||||
|
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||||
|
// re-publishes every reminder once, which is idempotent.
|
||||||
|
published map[int64]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||||
|
return &renderer{
|
||||||
|
core: core,
|
||||||
|
http: hc,
|
||||||
|
url: strings.TrimRight(url, "/"),
|
||||||
|
user: user,
|
||||||
|
pass: pass,
|
||||||
|
dur: dur,
|
||||||
|
published: make(map[int64]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOnce publishes every pending reminder and withdraws the ones that are
|
||||||
|
// no longer pending. Errors are logged and skipped: a calendar maven cannot
|
||||||
|
// reach must never break the reminder itself, which lives in sqlite.
|
||||||
|
func (r *renderer) renderOnce(ctx context.Context) {
|
||||||
|
reminders, err := r.core.ListReminders(ctx, renderMaxReminders)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavcaldav: list reminders: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
live := make(map[int64]bool, len(reminders))
|
||||||
|
for _, rem := range reminders {
|
||||||
|
if rem.Status != "pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
live[rem.ID] = true
|
||||||
|
e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur)
|
||||||
|
body := calendar.RenderICal([]calendar.Event{e})
|
||||||
|
if r.published[rem.ID] == body {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil {
|
||||||
|
log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.published[rem.ID] = body
|
||||||
|
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range r.published {
|
||||||
|
if live[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||||
|
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(r.published, id)
|
||||||
|
log.Printf("mavcaldav: withdrew reminder %d", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||||
|
// anything a calendar view is useful for.
|
||||||
|
const renderMaxReminders = 200
|
||||||
|
|
||||||
|
// fireTime prefers NextFireTs — for a recurring reminder that is the occurrence
|
||||||
|
// worth showing; FireTs is the original statement.
|
||||||
|
func fireTime(rem ipc.Reminder) time.Time {
|
||||||
|
if !rem.NextFireTs.IsZero() {
|
||||||
|
return rem.NextFireTs
|
||||||
|
}
|
||||||
|
return rem.FireTs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *renderer) put(ctx context.Context, name, body string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(r.user, r.pass)
|
||||||
|
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
return r.do(req, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *renderer) delete(ctx context.Context, name string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(r.user, r.pass)
|
||||||
|
return r.do(req, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do runs the request and treats any 2xx, plus 404 on a DELETE, as success —
|
||||||
|
// a resource that is already gone is the state the caller wanted.
|
||||||
|
func (r *renderer) do(req *http.Request, name string) error {
|
||||||
|
resp, err := r.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||||
|
return nil
|
||||||
|
case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reminderCore is a fakeCore that also answers ListReminders.
|
||||||
|
type reminderCore struct {
|
||||||
|
fakeCore
|
||||||
|
reminders []ipc.Reminder
|
||||||
|
listErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) {
|
||||||
|
if c.listErr != nil {
|
||||||
|
return nil, c.listErr
|
||||||
|
}
|
||||||
|
return c.reminders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calSrv records what a CalDAV collection received.
|
||||||
|
type calSrv struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
puts map[string]string
|
||||||
|
dels []string
|
||||||
|
status int
|
||||||
|
*httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCalSrv() *calSrv {
|
||||||
|
s := &calSrv{puts: map[string]string{}, status: http.StatusCreated}
|
||||||
|
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||||
|
case http.MethodDelete:
|
||||||
|
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(s.status)
|
||||||
|
}))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *calSrv) putCount() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.puts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOncePublishesPendingReminders(t *testing.T) {
|
||||||
|
fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC)
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"},
|
||||||
|
{ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"},
|
||||||
|
{ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
srv.mu.Lock()
|
||||||
|
body, ok := srv.puts["maven-reminder-7.ics"]
|
||||||
|
n := len(srv.puts)
|
||||||
|
srv.mu.Unlock()
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("pending reminder 7 was not published")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "SUMMARY:позвонить маме") {
|
||||||
|
t.Errorf("payload missing from rendered body:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "UID:maven-reminder-7") {
|
||||||
|
t.Errorf("UID missing from rendered body:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOnceSkipsUnchanged(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
if got := srv.putCount(); got != 1 {
|
||||||
|
t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
core.reminders[0].Status = "fired"
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
srv.mu.Lock()
|
||||||
|
dels := append([]string(nil), srv.dels...)
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||||
|
t.Fatalf("resolved reminder was not withdrawn: %v", dels)
|
||||||
|
}
|
||||||
|
if len(r.published) != 0 {
|
||||||
|
t.Errorf("published map still holds %v", r.published)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A calendar maven cannot reach must never break anything: sqlite is canonical.
|
||||||
|
func TestRenderOnceSurvivesServerErrors(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
srv.status = http.StatusInternalServerError
|
||||||
|
defer srv.Close()
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{
|
||||||
|
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"},
|
||||||
|
}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
if len(r.published) != 0 {
|
||||||
|
t.Error("a failed PUT must not be recorded as published, or it never retries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||||
|
srv := newCalSrv()
|
||||||
|
defer srv.Close()
|
||||||
|
next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC)
|
||||||
|
core := &reminderCore{reminders: []ipc.Reminder{{
|
||||||
|
ID: 3,
|
||||||
|
FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||||
|
NextFireTs: next,
|
||||||
|
Payload: "зарядка",
|
||||||
|
Status: "pending",
|
||||||
|
Cron: "0 7 * * *",
|
||||||
|
}}}
|
||||||
|
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||||
|
r.renderOnce(context.Background())
|
||||||
|
|
||||||
|
srv.mu.Lock()
|
||||||
|
body := srv.puts["maven-reminder-3.ics"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if !strings.Contains(body, "DTSTART:20260802T070000Z") {
|
||||||
|
t.Errorf("recurring reminder should render its next occurrence:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||||
|
read := "http://localhost:5232/kami/personal"
|
||||||
|
if err := checkRenderTarget(read, ""); err != nil {
|
||||||
|
t.Fatalf("rendering off must be fine: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||||
|
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, read); err == nil {
|
||||||
|
t.Error("rendering into the read calendar must be refused")
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||||
|
t.Error("a trailing slash must not defeat the check")
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||||
|
t.Error("case must not defeat the check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/mcp"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||||
case errors.Is(err, tool.ErrNotEnabled):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
|
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||||
|
// An MCP tool that wants named arguments a spoken verb cannot
|
||||||
|
// supply. Guessing them would be a wrong act, so she says so
|
||||||
|
// instead — the tool is still runnable from the authed surface,
|
||||||
|
// where a human types them.
|
||||||
|
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||||
}
|
}
|
||||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||||
if out != "" {
|
if out != "" {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Money questions (Vikunja #125).
|
||||||
|
//
|
||||||
|
// This is the whole read side: mavpoll holds the zenmoney token and writes
|
||||||
|
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
|
||||||
|
// Core never sees the token, never calls zenmoney, and has no rule on these
|
||||||
|
// keys — a total is never a reason for Maven to speak first. Maven is not a
|
||||||
|
// nag, least of all about his money.
|
||||||
|
//
|
||||||
|
// Nothing here can reach the external search capability: the figures are read
|
||||||
|
// from the store and rendered locally, and his financial data is never search
|
||||||
|
// input.
|
||||||
|
|
||||||
|
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
|
||||||
|
//
|
||||||
|
// Answers only from the latest fact the poller wrote. Three honest outcomes and
|
||||||
|
// no fourth: the figure, "the fact is old and here is its date", or "money
|
||||||
|
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||||
|
// its own — an invented number about his money is the worst thing this could do.
|
||||||
|
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||||
|
if window == router.MoneyToday {
|
||||||
|
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||||
|
}
|
||||||
|
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||||
|
if err != nil {
|
||||||
|
// No fact at all is the normal state when the capability is off. Claim
|
||||||
|
// the turn anyway: falling through to recall would answer a question
|
||||||
|
// about money with whatever note happens to be nearest.
|
||||||
|
if !isNoFactErr(err) {
|
||||||
|
log.Printf("voice: money fact: %v", err)
|
||||||
|
}
|
||||||
|
return "я не отслеживаю траты — не подключено.", true
|
||||||
|
}
|
||||||
|
val, err := zenmoney.ParseFactValue(fact.Value)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: money fact: decode: %v", err)
|
||||||
|
return "не получилось прочитать траты.", true
|
||||||
|
}
|
||||||
|
reply := val.FormatRU(phrase)
|
||||||
|
if reply == "" {
|
||||||
|
return "по тратам пока нечего сказать.", true
|
||||||
|
}
|
||||||
|
// A stale fact is reported as stale rather than spoken as today's number.
|
||||||
|
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
||||||
|
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
||||||
|
func isNoFactErr(err error) bool {
|
||||||
|
for e := err; e != nil; {
|
||||||
|
if e == ipc.ErrNoFact {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, ok := e.(interface{ Unwrap() error })
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e = u.Unwrap()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
|
||||||
|
// which is the assertion that answering a money question costs no model call
|
||||||
|
// and reaches no network.
|
||||||
|
type moneyAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
fact ipc.Fact
|
||||||
|
err error
|
||||||
|
gotKey string
|
||||||
|
gotSrc string
|
||||||
|
callCnt int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
a.gotKey, a.gotSrc = key, source
|
||||||
|
a.callCnt++
|
||||||
|
return a.fact, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func moneyFact(ts time.Time, val string) ipc.Fact {
|
||||||
|
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
|
||||||
|
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the money source must claim a money question")
|
||||||
|
}
|
||||||
|
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
|
||||||
|
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "1749.5") {
|
||||||
|
t.Errorf("reply = %q, want the exact figure", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "в этом месяце") {
|
||||||
|
t.Errorf("reply = %q, want the window named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
|
||||||
|
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
if _, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||||
|
}); !ok {
|
||||||
|
t.Fatal("expected the source to claim it")
|
||||||
|
}
|
||||||
|
if api.gotKey != zenmoney.KeySpentToday {
|
||||||
|
t.Errorf("key = %q, want today's", api.gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capability is off unless configured, and then there is no fact. She says
|
||||||
|
// so instead of letting the recall pass answer a money question from a note.
|
||||||
|
func TestQueryMoneySaysNotConnected(t *testing.T) {
|
||||||
|
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
|
||||||
|
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the source to claim it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не подключено") {
|
||||||
|
t.Errorf("reply = %q, want an honest 'not connected'", reply)
|
||||||
|
}
|
||||||
|
// No number of any kind in that answer.
|
||||||
|
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
|
||||||
|
if strings.Contains(reply, d) {
|
||||||
|
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fact older than the staleness bound is dated rather than spoken as if it
|
||||||
|
// were current: the poller can be down, and last week's total presented as
|
||||||
|
// today's is a lie by omission.
|
||||||
|
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
|
||||||
|
old := moneyNow().Add(-72 * time.Hour)
|
||||||
|
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||||
|
})
|
||||||
|
if !strings.Contains(reply, "данные от") {
|
||||||
|
t.Errorf("reply = %q, want the stale fact dated", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
|
||||||
|
api := &moneyAPI{}
|
||||||
|
h := &reactiveHandler{api: api, now: moneyNow}
|
||||||
|
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
|
||||||
|
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||||
|
t.Errorf("the money source claimed %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.callCnt != 0 {
|
||||||
|
t.Error("a non-money question must not read the money facts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money must be answered before the recall sources, or a question about
|
||||||
|
// spending gets answered by the nearest note.
|
||||||
|
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||||
|
moneyAt, notesAt := -1, -1
|
||||||
|
for i, src := range querySources {
|
||||||
|
switch src.name {
|
||||||
|
case "money":
|
||||||
|
moneyAt = i
|
||||||
|
case "notes":
|
||||||
|
notesAt = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moneyAt < 0 || notesAt < 0 {
|
||||||
|
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
|
||||||
|
}
|
||||||
|
if moneyAt > notesAt {
|
||||||
|
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ import (
|
|||||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||||
// index it for recall.
|
// index it for recall.
|
||||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||||
|
// An utterance that explicitly files a task is work, not recall, and
|
||||||
|
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||||
|
// is paid for. Everything else is a note, exactly as before.
|
||||||
|
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
// embed the note text with the same model the classifier uses, persist
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// facts — no predicate reads it (spec's two-memory split).
|
||||||
|
|||||||
+264
-3
@@ -5,11 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/rss"
|
||||||
"github.com/kami/maven/internal/weather"
|
"github.com/kami/maven/internal/weather"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,11 +49,55 @@ type querySource struct {
|
|||||||
// line here plus its method; where you put the line is the whole decision.
|
// line here plus its method; where you put the line is the whole decision.
|
||||||
var querySources = []querySource{
|
var querySources = []querySource{
|
||||||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||||||
|
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||||
|
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||||
|
// listing would otherwise swallow it.
|
||||||
|
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||||||
|
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||||
|
// and the habit question is the more specific one. Its matcher requires a
|
||||||
|
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||||
|
// Wednesday still reaches the calendar.
|
||||||
|
{"habits", (*reactiveHandler).queryHabits},
|
||||||
|
// Before "calendar" and before the recall sources: "что мне нужно
|
||||||
|
// сделать?" is a question about the task list, and the notes pass would
|
||||||
|
// otherwise answer it with whatever note happens to be nearest. Its
|
||||||
|
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||||
|
// date-bearing question still reaches the calendar.
|
||||||
|
{"tasks", (*reactiveHandler).queryTasks},
|
||||||
|
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||||
|
// the money facts the poller wrote, and the notes pass would otherwise
|
||||||
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
|
{"money", (*reactiveHandler).queryMoney},
|
||||||
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
|
// a question about the feeds she reads, and general knowledge would answer
|
||||||
|
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||||
|
// "у меня новая лента в инстаграме" is untouched.
|
||||||
|
{"feeds", (*reactiveHandler).queryFeeds},
|
||||||
|
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||||
|
// a question about the house, and the notes pass would otherwise answer it
|
||||||
|
// from whatever he once said about the lights. Its matcher needs a house
|
||||||
|
// marker plus an ask plus a device word, and it bails out on weather
|
||||||
|
// wording, so "какая температура на улице?" still reaches the weather
|
||||||
|
// source.
|
||||||
|
{"home", (*reactiveHandler).queryHome},
|
||||||
|
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||||
|
// question about the LAN, and the recall pass would otherwise answer it
|
||||||
|
// from an old note about the router. Its matcher needs a network word plus
|
||||||
|
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||||
|
{"network", (*reactiveHandler).queryNetwork},
|
||||||
{"calendar", (*reactiveHandler).queryCalendar},
|
{"calendar", (*reactiveHandler).queryCalendar},
|
||||||
{"weather", (*reactiveHandler).queryWeather},
|
{"weather", (*reactiveHandler).queryWeather},
|
||||||
{"embed", (*reactiveHandler).queryEmbed},
|
{"embed", (*reactiveHandler).queryEmbed},
|
||||||
{"memory", (*reactiveHandler).queryMemory},
|
{"memory", (*reactiveHandler).queryMemory},
|
||||||
{"notes", (*reactiveHandler).queryNotes},
|
{"notes", (*reactiveHandler).queryNotes},
|
||||||
|
// LAST before the model answers from memory, and that position is the whole
|
||||||
|
// design (Vikunja #259): local sources first. The model, his own notes and
|
||||||
|
// facts, and — once internal/kiwix is wired into this chain — the offline
|
||||||
|
// ZIMs all get their turn before anything touches the network. This source
|
||||||
|
// only claims a turn where he named a URL out loud, so it never competes
|
||||||
|
// with a local answer.
|
||||||
|
{"web", (*reactiveHandler).queryWeb},
|
||||||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +137,132 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||||
|
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||||
|
// reminders, and any morning checklist still outstanding.
|
||||||
|
//
|
||||||
|
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||||
|
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||||
|
// day, so that phrasing trims what has already passed.
|
||||||
|
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
plan, err := h.api.DayPlan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: day plan: %v", err)
|
||||||
|
return "не получилось собрать план.", true
|
||||||
|
}
|
||||||
|
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||||
|
return plan.Spoken, true
|
||||||
|
}
|
||||||
|
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||||
|
// rendered the whole day — one formatter, one persona.
|
||||||
|
p := morning.Plan{Date: plan.Date}
|
||||||
|
for _, it := range plan.Items {
|
||||||
|
p.Items = append(p.Items, morning.PlanEntry{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: morning.PlanKind(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return p.After(h.now()).FormatRU(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||||
|
// that means "from now on" rather than "the whole day".
|
||||||
|
func isRestOfDayQuery(text string) bool {
|
||||||
|
s := strings.ToLower(text)
|
||||||
|
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||||||
|
// over. Enough for a season of habits without scanning the whole store on every
|
||||||
|
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||||
|
const habitFactWindow = 2000
|
||||||
|
|
||||||
|
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||||
|
// answer out of the fact log rather than asking the model to summarise a life:
|
||||||
|
// see internal/memory/behavior.go for why nothing here is generated.
|
||||||
|
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: habits: recent facts: %v", err)
|
||||||
|
return "не получилось посмотреть записи.", true
|
||||||
|
}
|
||||||
|
obs := make([]memory.Observation, 0, len(facts))
|
||||||
|
for _, f := range facts {
|
||||||
|
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||||||
|
}
|
||||||
|
profile := memory.BuildProfile(obs, h.now())
|
||||||
|
if q.HasWeekday {
|
||||||
|
return profile.FormatWeekdayRU(q.Weekday), true
|
||||||
|
}
|
||||||
|
return profile.FormatOverallRU(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedNoteWindow — how many recent notes are scanned for feed items, and
|
||||||
|
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||||||
|
// top of the pile, she does not recite a river.
|
||||||
|
const (
|
||||||
|
feedNoteWindow = 200
|
||||||
|
feedReadOut = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||||
|
// (Vikunja #258).
|
||||||
|
//
|
||||||
|
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||||||
|
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||||
|
// changed is "Maven is not a nag", not a detail of this file.
|
||||||
|
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !h.feedsOn {
|
||||||
|
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||||
|
// letting general knowledge answer "что нового?" would be an invented
|
||||||
|
// news bulletin.
|
||||||
|
return "я пока не читаю ленты — они не настроены.", true
|
||||||
|
}
|
||||||
|
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: feeds: recent notes: %v", err)
|
||||||
|
return "не получилось посмотреть ленты.", true
|
||||||
|
}
|
||||||
|
var picked []string
|
||||||
|
for _, n := range notes {
|
||||||
|
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !router.CategoryMatches(n.Text, q.Category) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The note carries title, summary and link; she reads the title.
|
||||||
|
title := n.Text
|
||||||
|
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||||||
|
title = title[:i]
|
||||||
|
}
|
||||||
|
picked = append(picked, strings.TrimSpace(title))
|
||||||
|
if len(picked) == feedReadOut {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(picked) == 0 {
|
||||||
|
if q.Category != "" {
|
||||||
|
return "по этой теме в лентах пока ничего.", true
|
||||||
|
}
|
||||||
|
return "в лентах пока ничего нового.", true
|
||||||
|
}
|
||||||
|
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||||
|
}
|
||||||
|
|
||||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||||
// source can be tested at a fixed time like the rest.
|
// source can be tested at a fixed time like the rest.
|
||||||
@@ -102,12 +276,48 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
return "не получилось проверить календарь.", true
|
return "не получилось проверить календарь.", true
|
||||||
}
|
}
|
||||||
values := make([]string, len(events))
|
// Provenance travels with each event. A work meeting relayed off a phone
|
||||||
|
// notification (source ambient:notif, #126) is stored below full confidence
|
||||||
|
// and gets hedged; a CalDAV read is recited plainly.
|
||||||
|
entries := make([]router.CalendarEntry, len(events))
|
||||||
for i, e := range events {
|
for i, e := range events {
|
||||||
values[i] = e.Value
|
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
|
||||||
}
|
}
|
||||||
var f router.CalendarEventFormatter
|
var f router.CalendarEventFormatter
|
||||||
return f.Format(values, date), true
|
return f.FormatEntries(entries, date), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryHome answers a question about the house. Read-only by construction: it
|
||||||
|
// calls States and nothing else, so there is no confirm turn here — the only
|
||||||
|
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||||
|
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isHomeQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.home == nil {
|
||||||
|
// Claim the turn rather than fall through: "дом не подключён" is true,
|
||||||
|
// and letting general knowledge answer would be an invented house.
|
||||||
|
return "дом не подключён — я его не вижу.", true
|
||||||
|
}
|
||||||
|
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return h.home.homeSummary(ctxH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||||||
|
// no confirm turn because nothing is changed, and no way to widen the range
|
||||||
|
// because Scan takes no target — the utterance selects the question, never the
|
||||||
|
// subnet.
|
||||||
|
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isNetworkQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.netscan == nil {
|
||||||
|
// Claim the turn: "сканирование не настроено" is true, and general
|
||||||
|
// knowledge would answer with an invented list of devices.
|
||||||
|
return "сканирование сети не настроено.", true
|
||||||
|
}
|
||||||
|
return h.netscan.scanSummary(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
@@ -214,6 +424,57 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||||||
|
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||||||
|
// prompt, the persona block and the reply.
|
||||||
|
const webPageContextRunes = 1500
|
||||||
|
|
||||||
|
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||||
|
//
|
||||||
|
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||||
|
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||||||
|
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||||||
|
// travels with it.
|
||||||
|
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
link, ok := router.FirstURL(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.crawler == nil {
|
||||||
|
// Claim rather than fall through: he asked about a specific page, and
|
||||||
|
// letting the model answer from the URL's spelling alone is how a small
|
||||||
|
// model invents a page's contents.
|
||||||
|
return "я не читаю страницы — это не настроено.", true
|
||||||
|
}
|
||||||
|
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
page, err := h.crawler.Page(ctxFetch, link)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, crawl.ErrRobots) {
|
||||||
|
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||||
|
}
|
||||||
|
log.Printf("voice: web: %v", err)
|
||||||
|
return "не получилось прочитать страницу.", true
|
||||||
|
}
|
||||||
|
if page.Text == "" {
|
||||||
|
return "страница открылась, но читать там нечего.", true
|
||||||
|
}
|
||||||
|
// The page is handed to the phraser the same way a note is: as context for
|
||||||
|
// the question he actually asked. She answers the question, she does not
|
||||||
|
// recite the page.
|
||||||
|
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||||
|
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||||
|
if perr != nil {
|
||||||
|
log.Printf("voice: web: phrase: %v", perr)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
// No phraser (or it failed): read back the top of the page rather than
|
||||||
|
// pretend the fetch did not happen.
|
||||||
|
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
// queryGeneral — general knowledge from the phraser, the last source before
|
// queryGeneral — general knowledge from the phraser, the last source before
|
||||||
// giving up. It always claims: either the model answers or Maven says she
|
// giving up. It always claims: either the model answers or Maven says she
|
||||||
// doesn't know.
|
// doesn't know.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task capture on the voice/chat path (Vikunja #130).
|
||||||
|
//
|
||||||
|
// Two halves, both deliberately small:
|
||||||
|
//
|
||||||
|
// - captureTaskFromNote runs at the top of actionNote. An utterance that
|
||||||
|
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
|
||||||
|
// store instead of the note store. Anything without an explicit marker is
|
||||||
|
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
|
||||||
|
// not become a task.
|
||||||
|
// - queryTasks is a query source that reads the list back.
|
||||||
|
//
|
||||||
|
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
|
||||||
|
// rule reads the table.
|
||||||
|
|
||||||
|
// captureTaskFromNote claims the turn when the utterance explicitly files a
|
||||||
|
// task, returning the reply. ("", false) hands the turn back to the note path.
|
||||||
|
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
|
cap, ok := router.ParseTaskCapture(dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||||
|
Text: cap.Text,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||||||
|
Weight: cap.Weight, // 0 unless he said "срочно" / "важно"
|
||||||
|
Ts: h.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: capture task: %v", err)
|
||||||
|
return "не получилось записать задачу.", true
|
||||||
|
}
|
||||||
|
if !resp.Created {
|
||||||
|
return "это уже в списке.", true
|
||||||
|
}
|
||||||
|
return "записала: " + cap.Text, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
|
//
|
||||||
|
// Reads the live set and recites it in priority order (Vikunja #129). The order
|
||||||
|
// is computed by internal/tasks from what he told her — deadlines, the urgency
|
||||||
|
// he stated, how long a task has been sitting — never asked of the model. The
|
||||||
|
// rendering is the package's too, so the spoken list and the /tasks page can
|
||||||
|
// never disagree about what comes first.
|
||||||
|
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
live, err := h.api.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list tasks: %v", err)
|
||||||
|
return "не получилось посмотреть задачи.", true
|
||||||
|
}
|
||||||
|
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||||
|
// internal/tasks so the ranker stays a pure package with no ipc (and therefore
|
||||||
|
// no store, and therefore no cgo) dependency — the same posture as
|
||||||
|
// internal/morning and internal/memory.
|
||||||
|
func taskItems(ts []ipc.Task) []tasks.Item {
|
||||||
|
out := make([]tasks.Item, len(ts))
|
||||||
|
for i, t := range ts {
|
||||||
|
out[i] = tasks.Item{
|
||||||
|
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||||
|
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// taskAPI answers only the three task methods; every other call is
|
||||||
|
// unimplemented, which is the assertion that capture needs nothing else — in
|
||||||
|
// particular no embedder, so a filed task costs no model call.
|
||||||
|
type taskAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
captured []ipc.CaptureTaskReq
|
||||||
|
created bool
|
||||||
|
capErr error
|
||||||
|
|
||||||
|
tasks []ipc.Task
|
||||||
|
listArg string
|
||||||
|
listErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
a.captured = append(a.captured, req)
|
||||||
|
if a.capErr != nil {
|
||||||
|
return ipc.CaptureTaskResp{}, a.capErr
|
||||||
|
}
|
||||||
|
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||||
|
a.listArg = status
|
||||||
|
return a.tasks, a.listErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||||
|
return &reactiveHandler{api: api, now: taskNow}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
||||||
|
api := &taskAPI{created: true}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||||
|
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("an explicit capture must claim the turn")
|
||||||
|
}
|
||||||
|
if len(api.captured) != 1 {
|
||||||
|
t.Fatalf("captured %d, want 1", len(api.captured))
|
||||||
|
}
|
||||||
|
got := api.captured[0]
|
||||||
|
if got.Text != "купить молоко" {
|
||||||
|
t.Errorf("text = %q, want the marker stripped", got.Text)
|
||||||
|
}
|
||||||
|
if got.Source != "tap:voice" {
|
||||||
|
t.Errorf("source = %q, want tap:voice", got.Source)
|
||||||
|
}
|
||||||
|
if got.Status != "open" {
|
||||||
|
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
||||||
|
}
|
||||||
|
if !got.Ts.Equal(taskNow()) {
|
||||||
|
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Errorf("reply = %q, want it to read the task back", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note is still a note: capture only fires on an explicit marker, so
|
||||||
|
// ordinary recall is untouched.
|
||||||
|
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
||||||
|
api := &taskAPI{}
|
||||||
|
h := taskHandler(api)
|
||||||
|
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
||||||
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
||||||
|
t.Errorf("%q was captured as a task", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(api.captured) != 0 {
|
||||||
|
t.Errorf("captured %d requests, want none", len(api.captured))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
||||||
|
h := taskHandler(&taskAPI{created: false})
|
||||||
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the capture path to claim it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "уже") {
|
||||||
|
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||||
|
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
||||||
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не получилось") {
|
||||||
|
t.Errorf("reply = %q, want an honest failure", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||||
|
api := &taskAPI{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open"},
|
||||||
|
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
||||||
|
}}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the task source must claim a task-list question")
|
||||||
|
}
|
||||||
|
if api.listArg != "live" {
|
||||||
|
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
||||||
|
t.Errorf("reply = %q, want both tasks", reply)
|
||||||
|
}
|
||||||
|
// The candidate must be named as unconfirmed, not recited as his work.
|
||||||
|
openIdx := strings.Index(reply, "купить молоко")
|
||||||
|
candIdx := strings.Index(reply, "продлить страховку")
|
||||||
|
if !(openIdx < candIdx) {
|
||||||
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не подтвердил") {
|
||||||
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stated urgency rides through capture as a weight, so the ranker can use
|
||||||
|
// it later (Vikunja #129). "срочно" is not part of the task text.
|
||||||
|
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
||||||
|
api := &taskAPI{created: true}
|
||||||
|
h := taskHandler(api)
|
||||||
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||||
|
Utterance: "добавь в задачи срочно оплатить интернет",
|
||||||
|
}); !ok {
|
||||||
|
t.Fatal("expected a capture")
|
||||||
|
}
|
||||||
|
got := api.captured[0]
|
||||||
|
if got.Text != "оплатить интернет" {
|
||||||
|
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
||||||
|
}
|
||||||
|
if got.Weight == 0 {
|
||||||
|
t.Error("weight = 0 — he said срочно and it was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
||||||
|
// comes before undated work.
|
||||||
|
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
||||||
|
due := taskNow()
|
||||||
|
api := &taskAPI{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
||||||
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
||||||
|
}}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
||||||
|
})
|
||||||
|
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
||||||
|
t.Errorf("reply = %q, want the dated task first", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "сегодня") {
|
||||||
|
t.Errorf("reply = %q, want the reason named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTasksEmptyList(t *testing.T) {
|
||||||
|
h := taskHandler(&taskAPI{})
|
||||||
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the task source to claim it")
|
||||||
|
}
|
||||||
|
if reply != "задач нет." {
|
||||||
|
t.Errorf("reply = %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
||||||
|
api := &taskAPI{}
|
||||||
|
h := taskHandler(api)
|
||||||
|
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
||||||
|
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||||
|
t.Errorf("the task source claimed %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.listArg != "" {
|
||||||
|
t.Error("a non-task question must not read the task list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chain must reach the task source before the recall sources, or "что мне
|
||||||
|
// нужно сделать?" gets answered by whatever note is nearest.
|
||||||
|
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||||
|
var tasksAt, notesAt = -1, -1
|
||||||
|
for i, src := range querySources {
|
||||||
|
switch src.name {
|
||||||
|
case "tasks":
|
||||||
|
tasksAt = i
|
||||||
|
case "notes":
|
||||||
|
notesAt = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tasksAt < 0 || notesAt < 0 {
|
||||||
|
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
||||||
|
}
|
||||||
|
if tasksAt > notesAt {
|
||||||
|
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
|
||||||
|
// docs/plans/08-hearing.md).
|
||||||
|
//
|
||||||
|
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
|
||||||
|
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
|
||||||
|
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
|
||||||
|
// and the same retention as images, transcribes it through the ONE STT Maven has
|
||||||
|
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
|
||||||
|
// transcript on the resident model in windows that fit n_ctx 4096.
|
||||||
|
//
|
||||||
|
// # Off unless configured, twice over
|
||||||
|
//
|
||||||
|
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
|
||||||
|
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
|
||||||
|
// unconfigured box there is no wire path that starts a recording, which is the
|
||||||
|
// only guarantee worth making about a capability like this one.
|
||||||
|
//
|
||||||
|
// # What this file refuses to do
|
||||||
|
//
|
||||||
|
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
|
||||||
|
// "start when you hear a meeting". The plan document's keyword-triggered
|
||||||
|
// recorder is refused in internal/capture's package comment for the reason
|
||||||
|
// that applies here too: noticing a keyword requires listening, which is
|
||||||
|
// the behaviour this capability must not have.
|
||||||
|
// - No transcript note by default. The summary is written where he will read
|
||||||
|
// it; the verbatim record of what other people said takes a deliberate
|
||||||
|
// capture.save_transcript.
|
||||||
|
// - The transcript is never search input beyond this box, and the audio never
|
||||||
|
// leaves it at all.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/capture"
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureSummaryTimeout — the budget for one Stop, which is a map-reduce over
|
||||||
|
// the whole meeting: one model call per transcript window plus a reduce, each of
|
||||||
|
// which is seconds on this box. Forty windows is the configured ceiling, so the
|
||||||
|
// budget has to be minutes, not the 60s the reply path uses.
|
||||||
|
const captureSummaryTimeout = 20 * time.Minute
|
||||||
|
|
||||||
|
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
|
||||||
|
// the two strings it needs and stays free of the llm request struct; the client
|
||||||
|
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
|
||||||
|
type llmCompleter struct {
|
||||||
|
c *llm.Client
|
||||||
|
maxTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
||||||
|
return l.c.Complete(ctx, llm.Req{System: system, User: user, MaxTokens: l.maxTokens})
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureWiring — the recorder plus what it needs to write the result down.
|
||||||
|
type captureWiring struct {
|
||||||
|
rec *capture.Recorder
|
||||||
|
st *store.Store
|
||||||
|
emb router.Embedder
|
||||||
|
cfg *config.CaptureConfig
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCaptureWiring returns nil when the recorder should not exist: no media
|
||||||
|
// store, no capture block, capture disabled, or no STT to transcribe with.
|
||||||
|
//
|
||||||
|
// A missing llama-server is NOT a reason to return nil. Without one the
|
||||||
|
// recording is still made, stored and transcribed, and the summary is simply
|
||||||
|
// absent — the honest degradation, and much better than refusing to record a
|
||||||
|
// meeting that is happening now.
|
||||||
|
func newCaptureWiring(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
|
||||||
|
if keeper == nil || !cfg.Capture.Records() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tr := transcriberOf(voiceW)
|
||||||
|
if tr == nil {
|
||||||
|
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
|
||||||
|
// Storing hours of unreadable audio of other people is worse than not
|
||||||
|
// recording, so this is a refusal, not a degradation.
|
||||||
|
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cc := cfg.Capture
|
||||||
|
var sum *capture.Summarizer
|
||||||
|
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||||
|
client := llmClientFor(lp, captureSummaryTimeout)
|
||||||
|
sum = capture.NewSummarizer(
|
||||||
|
llmCompleter{c: client, maxTokens: 512},
|
||||||
|
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
|
||||||
|
MaxDuration: cc.MaxDuration(),
|
||||||
|
STTWindow: time.Duration(cc.STTWindow),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("capture: %v — meeting capture disabled", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
|
||||||
|
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start handles ipc.MethodCaptureStart.
|
||||||
|
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
|
||||||
|
s, err := c.rec.Start(req.Label)
|
||||||
|
if err != nil {
|
||||||
|
return ipc.CaptureStartResp{}, err
|
||||||
|
}
|
||||||
|
// The label is logged; nothing that was said ever is.
|
||||||
|
log.Printf("capture: started %q", s.Label)
|
||||||
|
return ipc.CaptureStartResp{
|
||||||
|
Label: s.Label,
|
||||||
|
Started: s.Started,
|
||||||
|
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
|
||||||
|
// response with Expired set rather than an error: the cap firing is the designed
|
||||||
|
// behaviour, and the client needs the flag to stop sending and call stop.
|
||||||
|
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
|
||||||
|
err := c.rec.Append(req.Audio)
|
||||||
|
st := c.rec.Status()
|
||||||
|
if errors.Is(err, capture.ErrExpired) {
|
||||||
|
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
|
||||||
|
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ipc.CaptureAppendResp{}, err
|
||||||
|
}
|
||||||
|
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop handles ipc.MethodCaptureStop.
|
||||||
|
//
|
||||||
|
// The error handling here mirrors vision's, and for the same reason: the audio is
|
||||||
|
// stored first, so a transcription or summary failure returns what exists rather
|
||||||
|
// than nothing. A response can carry a blob id with no transcript (STT failed,
|
||||||
|
// re-runnable), or a transcript with no summary (the model failed, the words are
|
||||||
|
// kept) — both are degraded successes and neither is an error to the caller.
|
||||||
|
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
|
||||||
|
if req.Discard {
|
||||||
|
// "забудь, не записывай" — nothing is stored, transcribed or noted.
|
||||||
|
if !c.rec.Abort() {
|
||||||
|
return ipc.CaptureStopResp{}, capture.ErrNoSession
|
||||||
|
}
|
||||||
|
log.Printf("capture: session discarded on request")
|
||||||
|
return ipc.CaptureStopResp{Discarded: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := c.rec.Stop(ctx)
|
||||||
|
resp := ipc.CaptureStopResp{
|
||||||
|
BlobID: res.BlobID,
|
||||||
|
Label: res.Label,
|
||||||
|
Started: res.Started,
|
||||||
|
Seconds: res.Duration.Seconds(),
|
||||||
|
Transcript: res.Transcript,
|
||||||
|
Summary: res.Summary,
|
||||||
|
Chunks: res.Chunks,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if res.BlobID == "" && res.Transcript == "" {
|
||||||
|
// Nothing survived: no session, or an empty recording. There is
|
||||||
|
// nothing to hand back, so this is a real error.
|
||||||
|
return ipc.CaptureStopResp{}, err
|
||||||
|
}
|
||||||
|
log.Printf("capture: %q partially finished: %v", res.Label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id, werr := c.writeNotes(ctx, res); werr != nil {
|
||||||
|
log.Printf("capture: note write for %q failed: %v", res.Label, werr)
|
||||||
|
} else {
|
||||||
|
resp.NoteID = id
|
||||||
|
}
|
||||||
|
log.Printf("capture: finished %q — %s of audio, %d summary chunk(s)",
|
||||||
|
res.Label, res.Duration.Round(time.Second), res.Chunks)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeNotes stores the summary as a note, and the transcript too when
|
||||||
|
// capture.save_transcript is set. Returns the summary note's id, or 0 when there
|
||||||
|
// was no summary to write.
|
||||||
|
//
|
||||||
|
// The note source carries the blob id, which is the only link back to the audio.
|
||||||
|
// When retention prunes the blob the note remains — words about a meeting are a
|
||||||
|
// far lighter thing to keep than a recording of it.
|
||||||
|
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
|
||||||
|
source := "capture:meeting"
|
||||||
|
if res.BlobID != "" {
|
||||||
|
source = "capture:meeting:" + res.BlobID[:12]
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if text := res.Summary; text != "" {
|
||||||
|
var err error
|
||||||
|
id, err = c.writeNote(ctx, text, source)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("summary note: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.cfg.SaveTranscript && res.Transcript != "" {
|
||||||
|
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
|
||||||
|
return id, fmt.Errorf("transcript note: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
|
||||||
|
var vec []float32
|
||||||
|
if c.emb != nil {
|
||||||
|
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
|
||||||
|
// embedder is asymmetric. Backwards here makes the meeting unfindable by
|
||||||
|
// the question that should have matched it.
|
||||||
|
var err error
|
||||||
|
vec, err = router.EmbedPassage(ctx, c.emb, text)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("embed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.st.WriteNote(ctx, c.now(), text, vec, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// status handles ipc.MethodCaptureStatus.
|
||||||
|
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
|
||||||
|
st := c.rec.Status()
|
||||||
|
return ipc.CaptureStatusResp{
|
||||||
|
Running: st.Running,
|
||||||
|
Label: st.Label,
|
||||||
|
Started: st.Started,
|
||||||
|
Seconds: st.Duration.Seconds(),
|
||||||
|
Bytes: st.Bytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
|
||||||
|
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
|
||||||
|
// opened: one blob store, one retention loop, images and audio side by side.
|
||||||
|
func wireCapture(srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
|
||||||
|
cw := newCaptureWiring(keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
|
||||||
|
if cw == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv.CaptureStartFn = cw.start
|
||||||
|
srv.CaptureAppendFn = cw.append
|
||||||
|
srv.CaptureStopFn = cw.stop
|
||||||
|
srv.CaptureStatusFn = cw.status
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func randBytes(t *testing.T, n int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||||
|
t.Fatalf("rand: %v", err)
|
||||||
|
}
|
||||||
|
b[0] |= 1
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
|
||||||
|
dl := newDaemonLock(true)
|
||||||
|
if !dl.isLocked() {
|
||||||
|
t.Fatal("newDaemonLock(true) is not locked")
|
||||||
|
}
|
||||||
|
dl.unlock(nil)
|
||||||
|
if dl.isLocked() {
|
||||||
|
t.Fatal("still locked after unlock")
|
||||||
|
}
|
||||||
|
if newDaemonLock(false).isLocked() {
|
||||||
|
t.Fatal("newDaemonLock(false) reports locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeStore must be safe on a daemon that never unlocked and safe twice —
|
||||||
|
// shutdown runs it unconditionally.
|
||||||
|
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
|
||||||
|
dl := newDaemonLock(true)
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("closeStore with no store: %v", err)
|
||||||
|
}
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("second closeStore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
|
||||||
|
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
|
||||||
|
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
|
||||||
|
// the ciphertext file — so every write of a cold-started session vanished.
|
||||||
|
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "maven.db")
|
||||||
|
tmpfs := filepath.Join(dir, "work")
|
||||||
|
key := randBytes(t, 32)
|
||||||
|
// Store.Close zeroes the key slice it was handed (encState.key is the
|
||||||
|
// caller's backing array), so the next boot needs its own copy — exactly
|
||||||
|
// as mavend keeps envKeyBytes separate from the config's key.
|
||||||
|
nextBoot := bytes.Clone(key)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Cold start: locked, no store.
|
||||||
|
dl := newDaemonLock(true)
|
||||||
|
|
||||||
|
// ... unlock arrives, opens the store and hands it over.
|
||||||
|
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
dl.unlock(st)
|
||||||
|
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown.
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("closeStore: %v", err)
|
||||||
|
}
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
t.Fatalf("second closeStore after a real store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next boot with the same key must see the write.
|
||||||
|
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
defer st2.Close()
|
||||||
|
notes, err := st2.RecentNotes(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 {
|
||||||
|
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the wrapped blob: what sits in the state dir must not let
|
||||||
|
// anyone open the database. Nothing written there may contain the key, and the
|
||||||
|
// ciphertext must not be readable with a wrong one.
|
||||||
|
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "maven.db")
|
||||||
|
tmpfs := filepath.Join(dir, "work")
|
||||||
|
wrappedPath := filepath.Join(dir, "db_key.wrapped")
|
||||||
|
key := randBytes(t, 32)
|
||||||
|
secret := randBytes(t, 32)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
blob, err := webauthn.WrapKey(key, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WrapKey: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
|
||||||
|
t.Fatalf("write wrapped key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenEncrypted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk everything in the state dir; none of it may contain the key.
|
||||||
|
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, rerr := os.ReadFile(p)
|
||||||
|
if rerr != nil {
|
||||||
|
return nil // unreadable is not a leak
|
||||||
|
}
|
||||||
|
if bytes.Contains(b, key) {
|
||||||
|
t.Errorf("%s contains the plaintext encryption key", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wrapped file must have owner-only permissions.
|
||||||
|
fi, err := os.Stat(wrappedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat: %v", err)
|
||||||
|
}
|
||||||
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Errorf("wrapped key file mode = %o, want 600", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wrong passkey must not open the store.
|
||||||
|
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
|
||||||
|
t.Fatal("a wrong PRF secret unwrapped the key")
|
||||||
|
}
|
||||||
|
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
|
||||||
|
t.Fatal("the encrypted store opened under a wrong key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the right one round-trips back to a readable database.
|
||||||
|
got, version, err := webauthn.UnwrapKey(blob, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnwrapKey: %v", err)
|
||||||
|
}
|
||||||
|
if version != webauthn.BlobV2 {
|
||||||
|
t.Errorf("blob version = %v, want v2", version)
|
||||||
|
}
|
||||||
|
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen with the unwrapped key: %v", err)
|
||||||
|
}
|
||||||
|
defer st2.Close()
|
||||||
|
notes, err := st2.RecentNotes(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentNotes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 {
|
||||||
|
t.Fatalf("got %d notes, want 1", len(notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// mavend/crawls.go — the driver for reading web pages (Vikunja #259,
|
||||||
|
// docs/plans/14-web-crawler.md). The crawler is pure and lives in
|
||||||
|
// internal/crawl; this is the impure half: the guarded fetcher, a ticker for the
|
||||||
|
// scheduled watches, and the fact-backed dedup hashes.
|
||||||
|
//
|
||||||
|
// Two paths, one config block, both off unless configured:
|
||||||
|
//
|
||||||
|
// - ON DEMAND — he names a URL out loud and she reads it. That is the
|
||||||
|
// `queryWeb` source in actions_query.go, LAST in the chain: after his
|
||||||
|
// memory, after the notes, and (once Kiwix is wired into the chain) after
|
||||||
|
// the local ZIMs. A local read costs nothing and leaks nothing; a fetch puts
|
||||||
|
// a URL in someone's log, so it goes last.
|
||||||
|
// - SCHEDULED — a watched page is re-read on its interval, and a page whose
|
||||||
|
// text changed is written as a note. It does NOT announce itself. Same rule
|
||||||
|
// as the feed poller: notes, never nudges.
|
||||||
|
//
|
||||||
|
// Only the URL goes out. Nothing here reads a note, a fact, the persona block or
|
||||||
|
// the history, and internal/crawl has no access to the store at all.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/webfetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCrawler builds the crawler from the `crawl` block, or returns nil when
|
||||||
|
// there is none. Every caller checks for nil, and nil means no page is ever
|
||||||
|
// fetched.
|
||||||
|
func newCrawler(cfg *config.Config) *crawl.Crawler {
|
||||||
|
if cfg.Crawl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cc := cfg.Crawl
|
||||||
|
|
||||||
|
hosts := append([]string(nil), cc.AllowHosts...)
|
||||||
|
// A watched page's own host is always reachable; otherwise an allowlist and
|
||||||
|
// a watch list would have to be kept in sync by hand.
|
||||||
|
for _, w := range cc.Watches {
|
||||||
|
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
|
||||||
|
hosts = append(hosts, u.Hostname())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An allowlist plus on-demand is a contradiction worth logging rather than
|
||||||
|
// silently resolving: he asked for arbitrary pages AND for a fixed list.
|
||||||
|
// The allowlist wins, because it is the narrower instruction.
|
||||||
|
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
|
||||||
|
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
|
||||||
|
}
|
||||||
|
ua := cc.UserAgent
|
||||||
|
if ua == "" {
|
||||||
|
ua = webfetch.DefaultUserAgent
|
||||||
|
}
|
||||||
|
fetcher := webfetch.New(webfetch.Config{
|
||||||
|
AllowHosts: hosts,
|
||||||
|
DenyHosts: cc.DenyHosts,
|
||||||
|
Timeout: time.Duration(cc.Timeout),
|
||||||
|
MaxBytes: cc.MaxBytes,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
// The user-agent handed to the crawler is the one the fetcher sends: obeying
|
||||||
|
// robots rules written for a different name would be a lie.
|
||||||
|
return crawl.New(&crawlFetcher{f: fetcher}, crawl.Config{
|
||||||
|
UserAgent: ua,
|
||||||
|
MaxRunes: cc.MaxRunes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// onDemandCrawler returns a crawler for the answer path, or nil when on-demand
|
||||||
|
// reading is off. The scheduled watches can be on while this is off: reading a
|
||||||
|
// fixed list of pages on a timer and reading whatever URL is in an utterance are
|
||||||
|
// different permissions, and the config keeps them separate.
|
||||||
|
func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
|
||||||
|
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return newCrawler(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlWorker — ticker + watcher for the scheduled half.
|
||||||
|
type crawlWorker struct {
|
||||||
|
watcher *crawl.Watcher
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlTickInterval — how often the worker asks what is due. Per-watch cadence
|
||||||
|
// is the watcher's business.
|
||||||
|
const crawlTickInterval = 15 * time.Minute
|
||||||
|
|
||||||
|
// newCrawlWorker wires the scheduled crawls, or nil when nothing is watched.
|
||||||
|
func newCrawlWorker(c *crawl.Crawler, api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *crawlWorker {
|
||||||
|
if c == nil || cfg.Crawl == nil || len(cfg.Crawl.Watches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
watches := make([]crawl.WatchConfig, 0, len(cfg.Crawl.Watches))
|
||||||
|
for _, w := range cfg.Crawl.Watches {
|
||||||
|
watches = append(watches, crawl.WatchConfig{
|
||||||
|
Name: w.Name,
|
||||||
|
URL: w.URL,
|
||||||
|
Interval: time.Duration(w.Interval),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watcher := crawl.NewWatcher(c, watches, api, &factHashes{api: api},
|
||||||
|
crawlEmbedder(emb), time.Duration(cfg.Crawl.Interval))
|
||||||
|
if watcher == nil {
|
||||||
|
log.Printf("crawl: configured but nothing watchable — scheduled crawls disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("crawl: watching %d page(s), checking what is due every %s", len(watches), crawlTickInterval)
|
||||||
|
return &crawlWorker{watcher: watcher, interval: crawlTickInterval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run checks what is due until ctx is canceled. The first round runs
|
||||||
|
// immediately; it writes notes only, so an early round startles nobody.
|
||||||
|
func (w *crawlWorker) run(ctx context.Context) {
|
||||||
|
w.watcher.CheckDue(ctx, time.Now())
|
||||||
|
t := time.NewTicker(w.interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
w.watcher.CheckDue(ctx, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlFetcher adapts webfetch to crawl.Fetcher, which is the seam that keeps
|
||||||
|
// net/http out of the crawler package.
|
||||||
|
type crawlFetcher struct{ f *webfetch.Fetcher }
|
||||||
|
|
||||||
|
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
|
||||||
|
resp, err := a.f.Get(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// factHashes stores each watch's last content hash as a config fact, so a
|
||||||
|
// restart does not re-note an unchanged page. Same mechanism the feed reader
|
||||||
|
// uses for its marks, and inspectable on /dash.
|
||||||
|
type factHashes struct{ api ipc.CoreAPI }
|
||||||
|
|
||||||
|
func hashKey(name string) string { return "crawl:hash:" + name }
|
||||||
|
|
||||||
|
func (h *factHashes) LastHash(ctx context.Context, name string) (string, error) {
|
||||||
|
f, err := h.api.LatestFact(ctx, hashKey(name))
|
||||||
|
if err != nil {
|
||||||
|
// No hash yet is not an error: the watcher treats "" as "never read".
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return f.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *factHashes) SetHash(ctx context.Context, name, hash string) error {
|
||||||
|
_, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: time.Now(),
|
||||||
|
Kind: "config",
|
||||||
|
Key: hashKey(name),
|
||||||
|
Value: hash,
|
||||||
|
Source: "poll:crawl",
|
||||||
|
Confidence: 1.0,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// crawlEmbedder adapts router.Embedder for the watcher, embedding with
|
||||||
|
// EmbedPassage (a page is text being searched FOR, and the e5 embedder is
|
||||||
|
// asymmetric).
|
||||||
|
func crawlEmbedder(emb router.Embedder) crawl.Embedder {
|
||||||
|
if emb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return passageEmbedder{emb}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The default config reads nothing. This is the whole "off unless configured"
|
||||||
|
// contract for the crawler, asserted at the wiring level rather than trusted.
|
||||||
|
func TestCrawlOffByDefault(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
if c := newCrawler(cfg); c != nil {
|
||||||
|
t.Error("newCrawler with no crawl block returned a crawler")
|
||||||
|
}
|
||||||
|
if c := onDemandCrawler(cfg); c != nil {
|
||||||
|
t.Error("onDemandCrawler with no crawl block returned a crawler")
|
||||||
|
}
|
||||||
|
if w := newCrawlWorker(nil, nil, nil, cfg); w != nil {
|
||||||
|
t.Error("newCrawlWorker with no crawl block returned a worker")
|
||||||
|
}
|
||||||
|
// Watches configured but on_demand off ⇒ the answer path still reads
|
||||||
|
// nothing: a timer over a fixed list is not permission for arbitrary URLs.
|
||||||
|
withWatch := &config.Config{Crawl: &config.CrawlConfig{
|
||||||
|
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://example.org/p"}},
|
||||||
|
}}
|
||||||
|
if c := onDemandCrawler(withWatch); c != nil {
|
||||||
|
t.Error("onDemandCrawler honoured a watch list as on-demand permission")
|
||||||
|
}
|
||||||
|
if c := newCrawler(withWatch); c == nil {
|
||||||
|
t.Error("newCrawler returned nil for a configured watch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wired fetcher must refuse a private address, because the crawler on this
|
||||||
|
// box sits one hop from the whole homelab. Same guard the webfetch tests cover;
|
||||||
|
// this asserts the daemon actually wires it.
|
||||||
|
func TestCrawlerRefusesPrivateAddress(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte("<html><body>secret</body></html>"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := newCrawler(&config.Config{Crawl: &config.CrawlConfig{OnDemand: true}})
|
||||||
|
if c == nil {
|
||||||
|
t.Fatal("newCrawler returned nil for an on-demand config")
|
||||||
|
}
|
||||||
|
if _, err := c.Page(context.Background(), srv.URL); err == nil {
|
||||||
|
t.Fatalf("reading %s succeeded; a loopback address must be refused", srv.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactHashesRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
h := &factHashes{api: ipc.NewStoreAPI(st)}
|
||||||
|
|
||||||
|
got, err := h.LastHash(ctx, "page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LastHash on a fresh store: %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("LastHash = %q, want empty for a never-read page", got)
|
||||||
|
}
|
||||||
|
if err := h.SetHash(ctx, "page", "deadbeef"); err != nil {
|
||||||
|
t.Fatalf("SetHash: %v", err)
|
||||||
|
}
|
||||||
|
got, err = h.LastHash(ctx, "page")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LastHash: %v", err)
|
||||||
|
}
|
||||||
|
if got != "deadbeef" {
|
||||||
|
t.Errorf("LastHash = %q, want deadbeef", got)
|
||||||
|
}
|
||||||
|
if key := hashKey("page"); key != "crawl:hash:page" {
|
||||||
|
t.Errorf("hashKey = %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubCrawlFetcher serves one fixed page to every URL, so queryWeb can be
|
||||||
|
// exercised without a network or an allowlist.
|
||||||
|
type stubCrawlFetcher struct{ body, ctype string }
|
||||||
|
|
||||||
|
func (s *stubCrawlFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||||
|
ct := s.ctype
|
||||||
|
if ct == "" {
|
||||||
|
ct = "text/html"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(u, "/robots.txt") {
|
||||||
|
return &crawl.Response{URL: u, ContentType: "text/plain", Body: []byte("")}, nil
|
||||||
|
}
|
||||||
|
return &crawl.Response{URL: u, ContentType: ct, Body: []byte(s.body)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWebHandler(c *crawl.Crawler) *reactiveHandler {
|
||||||
|
return &reactiveHandler{
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
phraser: phraser.NewStub(),
|
||||||
|
crawler: c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askWeb(h *reactiveHandler, q string) (string, bool) {
|
||||||
|
return h.queryWeb(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&stubCrawlFetcher{body: "<html><body>x</body></html>"}, crawl.Config{}))
|
||||||
|
if reply, ok := askWeb(h, "почему небо синее?"); ok {
|
||||||
|
t.Errorf("the web source claimed a question with no URL: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not configured is said out loud rather than falling through, so a small model
|
||||||
|
// never invents a page's contents from its URL.
|
||||||
|
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
|
||||||
|
h := buildWebHandler(nil)
|
||||||
|
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не настроено") {
|
||||||
|
t.Errorf("reply = %q, want the not-configured answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebReadsThePage(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||||
|
body: "<html><head><title>Заголовок</title></head><body><p>текст страницы</p></body></html>",
|
||||||
|
}, crawl.Config{}))
|
||||||
|
reply, ok := askWeb(h, "посмотри https://example.org/page — что там?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "текст страницы") {
|
||||||
|
t.Errorf("reply = %q, want the page text read back", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||||
|
body: "\x00\x01binary", ctype: "application/octet-stream",
|
||||||
|
}, crawl.Config{}))
|
||||||
|
reply, ok := askWeb(h, "почитай https://example.org/blob.bin")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не получилось") {
|
||||||
|
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||||
|
// reporting a generic failure.
|
||||||
|
func TestQueryWebObeysRobots(t *testing.T) {
|
||||||
|
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||||
|
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "robots.txt") {
|
||||||
|
t.Errorf("reply = %q, want the robots answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type robotsDenyFetcher struct{}
|
||||||
|
|
||||||
|
func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||||
|
if strings.HasSuffix(u, "/robots.txt") {
|
||||||
|
return &crawl.Response{URL: u, ContentType: "text/plain",
|
||||||
|
Body: []byte("User-agent: *\nDisallow: /private\n")}, nil
|
||||||
|
}
|
||||||
|
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||||
|
// exactly the assertion that the plan source needs nothing else.
|
||||||
|
type planAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
plan ipc.DayPlan
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
|
||||||
|
a.calls++
|
||||||
|
if a.err != nil {
|
||||||
|
return ipc.DayPlan{}, a.err
|
||||||
|
}
|
||||||
|
return a.plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func samplePlan() ipc.DayPlan {
|
||||||
|
day := planDay()
|
||||||
|
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
return ipc.DayPlan{
|
||||||
|
Date: mid,
|
||||||
|
Items: []ipc.DayPlanItem{
|
||||||
|
{At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"},
|
||||||
|
{At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true},
|
||||||
|
{At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"},
|
||||||
|
},
|
||||||
|
Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||||
|
"похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||||
|
return &reactiveHandler{api: api, now: planDay}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryDayPlanRecitesTheDay(t *testing.T) {
|
||||||
|
api := &planAPI{plan: samplePlan()}
|
||||||
|
h := planHandler(api)
|
||||||
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the plan source must claim a plan question")
|
||||||
|
}
|
||||||
|
if reply != api.plan.Spoken {
|
||||||
|
t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "что дальше?" is the rest of the day, not the whole day: what has already
|
||||||
|
// happened is not a plan.
|
||||||
|
func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||||
|
h := planHandler(&planAPI{plan: samplePlan()})
|
||||||
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the plan source to claim it")
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "Standup") {
|
||||||
|
t.Errorf("a passed item must not be read back: %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") {
|
||||||
|
t.Errorf("the rest of the day is missing: %q", reply)
|
||||||
|
}
|
||||||
|
// Provenance survives the trim.
|
||||||
|
if !strings.Contains(reply, "похоже,") {
|
||||||
|
t.Errorf("a relayed event must stay hedged: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A question that is not about the plan must fall through, or the plan buries
|
||||||
|
// the calendar listing and the weather behind it.
|
||||||
|
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||||
|
for _, q := range []string{
|
||||||
|
"что у меня сегодня?",
|
||||||
|
"какие планы на завтра?",
|
||||||
|
"когда планёрка?",
|
||||||
|
"какая погода?",
|
||||||
|
"",
|
||||||
|
} {
|
||||||
|
api := &planAPI{plan: samplePlan()}
|
||||||
|
reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
})
|
||||||
|
if ok {
|
||||||
|
t.Errorf("%q was claimed by the plan source (reply %q)", q, reply)
|
||||||
|
}
|
||||||
|
if api.calls != 0 {
|
||||||
|
t.Errorf("%q hit the core for a plan it does not want", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||||
|
h := planHandler(&planAPI{err: errors.New("socket closed")})
|
||||||
|
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||||
|
}
|
||||||
|
if reply != "не получилось собрать план." {
|
||||||
|
t.Errorf("reply = %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day plan must sit before the calendar listing: both match "…на сегодня",
|
||||||
|
// and the more specific matcher has to get first refusal (see #373 for what
|
||||||
|
// happens when the order is wrong).
|
||||||
|
func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||||
|
plan, cal := -1, -1
|
||||||
|
for i, s := range querySources {
|
||||||
|
switch s.name {
|
||||||
|
case "day-plan":
|
||||||
|
plan = i
|
||||||
|
case "calendar":
|
||||||
|
cal = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plan < 0 || cal < 0 {
|
||||||
|
t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal)
|
||||||
|
}
|
||||||
|
if plan > cal {
|
||||||
|
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// habitAPI answers only RecentFacts — the whole input the behaviour profile
|
||||||
|
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||||
|
type habitAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
facts []ipc.Fact
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||||
|
a.calls++
|
||||||
|
return a.facts, a.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
||||||
|
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
||||||
|
d := now
|
||||||
|
for d.Weekday() != time.Tuesday {
|
||||||
|
d = d.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
var out []ipc.Fact
|
||||||
|
for i := 0; i < weeks; i++ {
|
||||||
|
day := d.AddDate(0, 0, -7*i)
|
||||||
|
out = append(out, ipc.Fact{
|
||||||
|
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||||
|
Kind: "self",
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
||||||
|
now := planDay() // a Monday
|
||||||
|
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||||
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the habit source must claim a habit question")
|
||||||
|
}
|
||||||
|
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||||
|
t.Errorf("reply = %q, want %q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
||||||
|
now := planDay()
|
||||||
|
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
||||||
|
api := &habitAPI{}
|
||||||
|
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||||
|
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
}); ok {
|
||||||
|
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
||||||
|
}
|
||||||
|
if api.calls != 0 {
|
||||||
|
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both specific sources must precede the calendar listing, which matches any
|
||||||
|
// utterance naming a day.
|
||||||
|
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||||
|
habits, cal := -1, -1
|
||||||
|
for i, s := range querySources {
|
||||||
|
switch s.name {
|
||||||
|
case "habits":
|
||||||
|
habits = i
|
||||||
|
case "calendar":
|
||||||
|
cal = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if habits < 0 || cal < 0 {
|
||||||
|
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
||||||
|
}
|
||||||
|
if habits > cal {
|
||||||
|
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// mavend/feeds.go — the driver for RSS/Atom reading (Vikunja #258,
|
||||||
|
// docs/plans/13-rss-news-feeds.md). The reader itself is pure and lives in
|
||||||
|
// internal/rss; this is the impure half: a ticker, the guarded fetcher, and the
|
||||||
|
// two adapters that let a pure package talk to the store.
|
||||||
|
//
|
||||||
|
// Why in-core rather than its own daemon like mavmaild and mavpoll: those two
|
||||||
|
// hold a CREDENTIAL (an IMAP password, a zenmoney token), and the reason they
|
||||||
|
// are separate processes is that core must never see it. A feed URL is public,
|
||||||
|
// there is no secret to isolate, and a whole extra binary and compose service
|
||||||
|
// would buy nothing. The other half of the mavpoll precedent — off unless
|
||||||
|
// configured — is kept: no `feeds` block, no poller, no outbound request.
|
||||||
|
//
|
||||||
|
// It is its own goroutine, not a step on the tick: the tick has a delivery
|
||||||
|
// deadline behind it, and a feed read is a network round-trip that nobody is
|
||||||
|
// waiting on.
|
||||||
|
//
|
||||||
|
// Nothing here dispatches. A feed that announced itself would be a nag, so the
|
||||||
|
// only output is notes with source "rss:<feed>", which the answer path reads
|
||||||
|
// when he asks ("что нового в лентах?" — see queryFeeds in actions_query.go).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/rss"
|
||||||
|
"github.com/kami/maven/internal/stt"
|
||||||
|
"github.com/kami/maven/internal/webfetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedWorker — ticker + poller.
|
||||||
|
type feedWorker struct {
|
||||||
|
poller *rss.Poller
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedTickInterval — how often the worker asks the poller what is due. Per-feed
|
||||||
|
// cadence is the poller's business; this is just the granularity.
|
||||||
|
const feedTickInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// newFeedWorker wires feed reading, or returns nil when it must not run:
|
||||||
|
// no `feeds` block (the normal case), or nothing valid in it. Every caller
|
||||||
|
// checks for nil.
|
||||||
|
func newFeedWorker(api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *feedWorker {
|
||||||
|
if cfg.Feeds == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fc := cfg.Feeds
|
||||||
|
|
||||||
|
feeds := make([]rss.FeedConfig, 0, len(fc.Sources))
|
||||||
|
hosts := append([]string(nil), fc.AllowHosts...)
|
||||||
|
for _, s := range fc.Sources {
|
||||||
|
feeds = append(feeds, rss.FeedConfig{
|
||||||
|
Name: s.Name,
|
||||||
|
URL: s.URL,
|
||||||
|
Category: s.Category,
|
||||||
|
Interval: time.Duration(s.Interval),
|
||||||
|
Include: s.Include,
|
||||||
|
Exclude: s.Exclude,
|
||||||
|
})
|
||||||
|
// Each configured feed's own host is allowed. The allowlist is then
|
||||||
|
// exactly "the feeds he asked for", so a redirect off to somewhere else
|
||||||
|
// is refused by the fetcher rather than followed.
|
||||||
|
if u, err := url.Parse(s.URL); err == nil && u.Hostname() != "" {
|
||||||
|
hosts = append(hosts, u.Hostname())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher := webfetch.New(webfetch.Config{
|
||||||
|
AllowHosts: hosts,
|
||||||
|
Timeout: time.Duration(fc.Timeout),
|
||||||
|
MaxBytes: fc.MaxBytes,
|
||||||
|
})
|
||||||
|
poller := rss.NewPoller(feeds, &feedFetcher{f: fetcher}, api, &factMarks{api: api},
|
||||||
|
embedderFor(emb), nil, rss.Config{
|
||||||
|
DefaultInterval: time.Duration(fc.PollInterval),
|
||||||
|
MaxItems: fc.MaxItems,
|
||||||
|
MaxAge: time.Duration(fc.MaxAge),
|
||||||
|
})
|
||||||
|
if poller == nil {
|
||||||
|
log.Printf("feeds: configured but nothing pollable — feed reading disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("feeds: reading %d feed(s), checking what is due every %s", len(feeds), feedTickInterval)
|
||||||
|
return &feedWorker{poller: poller, interval: feedTickInterval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run polls what is due until ctx is canceled. The first round runs immediately
|
||||||
|
// so a restart does not blind her for the first interval; it writes notes only,
|
||||||
|
// so an early round cannot startle anyone.
|
||||||
|
func (w *feedWorker) run(ctx context.Context) {
|
||||||
|
w.poller.PollDue(ctx, time.Now())
|
||||||
|
t := time.NewTicker(w.interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
w.poller.PollDue(ctx, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedderOf — the voice wiring's embedder, or nil when voice is not wired.
|
||||||
|
// Feed notes are embedded with the SAME model the rest of the store uses, or not
|
||||||
|
// at all; a second embedder would write vectors nothing can search.
|
||||||
|
func embedderOf(w *voiceWiring) router.Embedder {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.embedder
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcriberOf — the STT the voice path is using, or nil when voice is off.
|
||||||
|
// The meeting recorder reuses it rather than dialling mavsttd a second time:
|
||||||
|
// Maven has one speech-to-text engine and adding a second would mean two
|
||||||
|
// whisper contexts competing for the same iGPU.
|
||||||
|
func transcriberOf(w *voiceWiring) stt.Transcriber {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.transcriber
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||||
|
// fields it needs and stays free of net/http.
|
||||||
|
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||||
|
|
||||||
|
func (a *feedFetcher) Get(ctx context.Context, u string) (*rss.Body, error) {
|
||||||
|
resp, err := a.f.Get(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rss.Body{Bytes: resp.Body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// factMarks stores "how far this feed was read" as a config fact, the same
|
||||||
|
// mechanism the plan named and the same one the pattern tick uses for its own
|
||||||
|
// bookkeeping. Durable, inspectable on /dash, and cheap.
|
||||||
|
type factMarks struct{ api ipc.CoreAPI }
|
||||||
|
|
||||||
|
func markKey(feed string) string { return "rss:latest:" + feed }
|
||||||
|
|
||||||
|
func (m *factMarks) LastMark(ctx context.Context, feed string) (time.Time, error) {
|
||||||
|
f, err := m.api.LatestFact(ctx, markKey(feed))
|
||||||
|
if err != nil {
|
||||||
|
// No mark yet is not an error worth propagating: the poller treats a
|
||||||
|
// zero time as a cold start.
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, f.Value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *factMarks) SetMark(ctx context.Context, feed string, at time.Time) error {
|
||||||
|
_, err := m.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: time.Now(),
|
||||||
|
Kind: "config",
|
||||||
|
Key: markKey(feed),
|
||||||
|
Value: at.UTC().Format(time.RFC3339),
|
||||||
|
Source: "poll:rss",
|
||||||
|
Confidence: 1.0,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedderFor adapts router.Embedder to rss.Embedder, and returns nil when
|
||||||
|
// there is none — a note without a vector is still a note the recent-notes path
|
||||||
|
// can read.
|
||||||
|
//
|
||||||
|
// EmbedPassage, not Embed: a feed item is text being searched FOR, and the e5
|
||||||
|
// embedder is asymmetric. Getting this backwards makes the item unfindable by
|
||||||
|
// the question that should have matched it.
|
||||||
|
func embedderFor(emb router.Embedder) rss.Embedder {
|
||||||
|
if emb == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return passageEmbedder{emb}
|
||||||
|
}
|
||||||
|
|
||||||
|
type passageEmbedder struct{ e router.Embedder }
|
||||||
|
|
||||||
|
func (p passageEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||||
|
return router.EmbedPassage(ctx, p.e, text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/rss"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildFeedHandler — a handler with the given feed notes already stored. No
|
||||||
|
// embedder: the feed source answers from recent notes by source, which is what
|
||||||
|
// makes it work for notes written before an embedder existed.
|
||||||
|
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Now()
|
||||||
|
for i, n := range notes {
|
||||||
|
ts := now.Add(time.Duration(i) * time.Minute)
|
||||||
|
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &reactiveHandler{
|
||||||
|
api: ipc.NewStoreAPI(st),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
phraser: phraser.NewStub(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
feedsOn: feedsOn,
|
||||||
|
embedder: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
return h.queryFeeds(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
||||||
|
h := buildFeedHandler(t, true,
|
||||||
|
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
||||||
|
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
||||||
|
)
|
||||||
|
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the feed source did not claim the question")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "уязвимость") {
|
||||||
|
t.Errorf("reply = %q, want the headline", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "он сам сказал") {
|
||||||
|
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
||||||
|
}
|
||||||
|
// She reads the headline, not the summary and not the URL.
|
||||||
|
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
||||||
|
t.Errorf("reply = %q, want the title line only", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFeedsByCategory(t *testing.T) {
|
||||||
|
h := buildFeedHandler(t, true,
|
||||||
|
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
||||||
|
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
||||||
|
)
|
||||||
|
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
||||||
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||||
|
}
|
||||||
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||||
|
if !strings.Contains(reply, "ничего") {
|
||||||
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||||
|
// answered by the model inventing a bulletin.
|
||||||
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||||
|
off := buildFeedHandler(t, false)
|
||||||
|
reply, ok := askFeeds(t, off, "что нового?")
|
||||||
|
if !ok || !strings.Contains(reply, "не настроены") {
|
||||||
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
on := buildFeedHandler(t, true)
|
||||||
|
reply, ok = askFeeds(t, on, "что нового?")
|
||||||
|
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||||
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||||
|
h := buildFeedHandler(t, true)
|
||||||
|
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
||||||
|
t.Fatalf("claimed an unrelated question with %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
||||||
|
// fact round-trip is worth a test of its own.
|
||||||
|
func TestFactMarksRoundTrip(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
at, err := m.LastMark(ctx, "habr")
|
||||||
|
if err != nil || !at.IsZero() {
|
||||||
|
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
||||||
|
if err := m.SetMark(ctx, "habr", want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := m.LastMark(ctx, "habr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("mark = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
||||||
|
// worker ⇒ no outbound request is possible.
|
||||||
|
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
api := ipc.NewStoreAPI(st)
|
||||||
|
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
||||||
|
t.Fatal("a config with no feeds block wired a feed worker")
|
||||||
|
}
|
||||||
|
// An empty sources list is normalised to "off" by config.Load; the worker
|
||||||
|
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
||||||
|
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
||||||
|
t.Fatal("an empty sources list wired a feed worker")
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||||
|
{Name: "habr", URL: "https://example.org/rss"},
|
||||||
|
}}}
|
||||||
|
w := newFeedWorker(api, nil, cfg)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("a configured feed did not wire a worker")
|
||||||
|
}
|
||||||
|
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
||||||
|
t.Fatalf("feeds = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
||||||
|
// nothing else — the crawler's SSRF guards are only worth as much as the
|
||||||
|
// allowlist handed to them.
|
||||||
|
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||||
|
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||||
|
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
||||||
|
}}}
|
||||||
|
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("no worker")
|
||||||
|
}
|
||||||
|
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
||||||
|
// itself must fail rather than be read.
|
||||||
|
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
||||||
|
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
||||||
|
}, time.Now())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the poller fetched a private address")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// mavend/intake.go — the unified event intake envelope, wired (Vikunja #283).
|
||||||
|
//
|
||||||
|
// internal/event defines the envelope and the bounded in-memory journal. This
|
||||||
|
// file is the one place that FILLS it, and the reason it is one place is worth
|
||||||
|
// stating, because the alternative was eight patches:
|
||||||
|
//
|
||||||
|
// Every intake path in Maven already converges on three writes, and all three
|
||||||
|
// are ipc.CoreAPI methods —
|
||||||
|
//
|
||||||
|
// WriteFact ← POST /api/ambient, mavcaldav, mavpoll's zenmoney + wg reads,
|
||||||
|
// /api/signal presence probes, the RSS/crawl watermarks
|
||||||
|
// WriteNote ← the RSS poller, the page crawler, meeting transcripts,
|
||||||
|
// image descriptions
|
||||||
|
// CaptureTask ← the voice path, the web form, and the mail reader
|
||||||
|
//
|
||||||
|
// — so decorating that ONE interface with a publish covers the lot without a
|
||||||
|
// caller knowing about events at all. cmd/mavmaild, cmd/mavcaldav, cmd/mavpoll,
|
||||||
|
// cmd/mavweb and the in-core feed/crawl/capture/vision workers are unchanged:
|
||||||
|
// they call the same interface they always called, and it now also narrates.
|
||||||
|
//
|
||||||
|
// The exception is cmd/mavend/mail.go, which reaches past the interface to
|
||||||
|
// st.CaptureTask directly. It publishes explicitly; see mailIntake.ingest.
|
||||||
|
//
|
||||||
|
// # Production behaviour when nobody is watching
|
||||||
|
//
|
||||||
|
// A nil *event.Bus makes Publish a no-op, and newIntakeAPI with a nil bus
|
||||||
|
// returns the wrapped API unchanged, so there is not even a decorator on the
|
||||||
|
// call path. The journal is memory-only and is never consulted by the tick
|
||||||
|
// loop, the router, or delivery — nothing Maven says depends on it. It is a
|
||||||
|
// read surface (`/events`, `recent_events`) and an observation seam for the
|
||||||
|
// simulator.
|
||||||
|
//
|
||||||
|
// # What is deliberately NOT here
|
||||||
|
//
|
||||||
|
// No dispatch. An event is a report that something arrived, never an
|
||||||
|
// instruction to speak: "a feed item appeared" becoming a notification is the
|
||||||
|
// nag this repo refuses. Digestion may one day read the journal; it will still
|
||||||
|
// go through internal/loop's rules and the severity/presence routing table.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEventBus builds the journal, or returns nil when the operator turned it
|
||||||
|
// off (a negative config.intake_journal). nil is the "behave exactly as before"
|
||||||
|
// value all the way down: no decorator, no ring, no /events rows.
|
||||||
|
func newEventBus(cfg *config.Config) *event.Bus {
|
||||||
|
if cfg == nil || cfg.IntakeJournal < 0 {
|
||||||
|
log.Printf("intake journal: off (intake_journal < 0)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := cfg.IntakeJournal
|
||||||
|
if n == 0 {
|
||||||
|
n = config.DefaultIntakeJournal
|
||||||
|
}
|
||||||
|
log.Printf("intake journal: keeping the last %d intake events in memory", n)
|
||||||
|
return event.NewBus(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intakeEventsFn is the daemonAPI.getEvents closure: the bus's ring rendered as
|
||||||
|
// the wire type. Returns nil for a nil bus, which the daemonAPI reports as an
|
||||||
|
// empty journal rather than an error.
|
||||||
|
func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
|
||||||
|
if bus == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(n int) []ipc.IntakeEvent {
|
||||||
|
evs := bus.Recent(n)
|
||||||
|
out := make([]ipc.IntakeEvent, 0, len(evs))
|
||||||
|
for _, e := range evs {
|
||||||
|
out = append(out, ipc.IntakeEvent{
|
||||||
|
Source: e.Source,
|
||||||
|
Kind: e.Kind,
|
||||||
|
EntityIDs: e.EntityIDs,
|
||||||
|
Title: e.Title,
|
||||||
|
Body: e.Body,
|
||||||
|
Priority: e.Priority,
|
||||||
|
OccurredAt: e.OccurredAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// intakeAPI decorates a CoreAPI, publishing one envelope per successful
|
||||||
|
// intake write. Embedding the interface means every other method passes
|
||||||
|
// through untouched, and a new CoreAPI method is inherited rather than
|
||||||
|
// silently dropped.
|
||||||
|
type intakeAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
bus *event.Bus
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newIntakeAPI wraps api so its intake writes are journalled. A nil bus
|
||||||
|
// returns api itself — no decorator, no allocation, no behaviour change.
|
||||||
|
func newIntakeAPI(api ipc.CoreAPI, bus *event.Bus, now func() time.Time) ipc.CoreAPI {
|
||||||
|
if bus == nil || api == nil {
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
return &intakeAPI{CoreAPI: api, bus: bus, now: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFact journals the fact after it lands. Order matters: an event is a
|
||||||
|
// report of something that HAPPENED, so a failed write publishes nothing.
|
||||||
|
func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
|
id, err := a.CoreAPI.WriteFact(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
|
||||||
|
// instant and the ambient path carries the meeting's start. Flattening
|
||||||
|
// those to notice-time would make the journal lie about when things
|
||||||
|
// happened, which is the one thing it is for.
|
||||||
|
a.bus.Publish(event.Event{
|
||||||
|
Source: req.Source,
|
||||||
|
Kind: event.SourceKind(req.Source, event.KindFact),
|
||||||
|
Title: req.Key,
|
||||||
|
Body: req.Value,
|
||||||
|
Priority: factPriority(req),
|
||||||
|
OccurredAt: req.Ts,
|
||||||
|
EntityIDs: entityIDs(req.Subject),
|
||||||
|
}, a.now())
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteNote journals a note. This is the RSS and crawler path, and also the
|
||||||
|
// meeting transcript and image description paths, which write their derived
|
||||||
|
// text as ordinary notes.
|
||||||
|
func (a *intakeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||||
|
id, err := a.CoreAPI.WriteNote(ctx, ts, text, embedding, source)
|
||||||
|
if err != nil {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
title, body := splitFirstLine(text)
|
||||||
|
a.bus.Publish(event.Event{
|
||||||
|
Source: source,
|
||||||
|
Kind: event.SourceKind(source, event.KindNote),
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
Priority: event.PriorityLow,
|
||||||
|
OccurredAt: ts,
|
||||||
|
}, a.now())
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureTask journals a captured task, but only when a row was actually
|
||||||
|
// created. CaptureTask dedupes on normalised text among live rows, so a
|
||||||
|
// mailbox re-read after a restart must not refill the journal with tasks that
|
||||||
|
// were already there.
|
||||||
|
func (a *intakeAPI) CaptureTask(ctx context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
resp, err := a.CoreAPI.CaptureTask(ctx, req)
|
||||||
|
if err != nil || !resp.Created {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
a.bus.Publish(publishableTask(store.Task{
|
||||||
|
CreatedTs: req.Ts,
|
||||||
|
Text: req.Text,
|
||||||
|
Source: req.Source,
|
||||||
|
Evidence: req.Evidence,
|
||||||
|
Status: req.Status,
|
||||||
|
Due: req.Due,
|
||||||
|
}, a.now()), a.now())
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishableTask is the task→envelope shape, shared with mail.go, which
|
||||||
|
// captures through the store directly rather than through the interface.
|
||||||
|
//
|
||||||
|
// Priority is high for a candidate with a due date and normal otherwise. That
|
||||||
|
// is the only place this file makes a judgement, and it is a display hint on a
|
||||||
|
// review page — nothing routes on it.
|
||||||
|
func publishableTask(t store.Task, now time.Time) event.Event {
|
||||||
|
occurred := t.CreatedTs
|
||||||
|
if occurred.IsZero() {
|
||||||
|
occurred = now
|
||||||
|
}
|
||||||
|
prio := event.PriorityNormal
|
||||||
|
if t.Due != nil {
|
||||||
|
prio = event.PriorityHigh
|
||||||
|
}
|
||||||
|
return event.Event{
|
||||||
|
Source: t.Source,
|
||||||
|
Kind: event.KindTask,
|
||||||
|
Title: t.Text,
|
||||||
|
Body: t.Evidence,
|
||||||
|
Priority: prio,
|
||||||
|
OccurredAt: occurred,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// factPriority is the attention hint for a fact write. Deliberately crude:
|
||||||
|
// a low-confidence inference (the ambient notification path writes below 1.0)
|
||||||
|
// is worth less attention than a read he or a credentialled poller made, and
|
||||||
|
// nothing else is distinguishable from here.
|
||||||
|
func factPriority(req ipc.WriteFactReq) string {
|
||||||
|
if req.Confidence > 0 && req.Confidence < 1.0 {
|
||||||
|
return event.PriorityLow
|
||||||
|
}
|
||||||
|
return event.PriorityNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
|
||||||
|
// already looks resolved. Intake runs BEFORE the fact enrichment worker
|
||||||
|
// resolves a subject against Nexus, so this is almost always empty — the slot
|
||||||
|
// exists for the paths that do know (the ecosystem acts), not for guessing.
|
||||||
|
func entityIDs(subject string) []string {
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
if subject == "" || !strings.HasPrefix(subject, "entity:") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{strings.TrimPrefix(subject, "entity:")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFirstLine renders a note as title + body. Feed and crawl notes are
|
||||||
|
// written "headline\nsummary\nlink", so the first line is already the title.
|
||||||
|
func splitFirstLine(text string) (title, body string) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
||||||
|
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
|
||||||
|
}
|
||||||
|
return text, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func intakeClock() time.Time { return intakeNow }
|
||||||
|
|
||||||
|
// failingAPI wraps the store adapter, failing the three intake writes on
|
||||||
|
// demand, so the "a failed write publishes nothing" invariant is testable.
|
||||||
|
type failingAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
fail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
|
if f.fail {
|
||||||
|
return 0, errors.New("injected")
|
||||||
|
}
|
||||||
|
return f.CoreAPI.WriteFact(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
bus := event.NewBus(32)
|
||||||
|
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
|
||||||
|
// The adoption invariant: with the journal off there is not even a
|
||||||
|
// decorator on the intake path, so production behaves exactly as before.
|
||||||
|
st := newTestStore(t)
|
||||||
|
bare := ipc.NewStoreAPI(st)
|
||||||
|
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
|
||||||
|
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEventBusOffWhenNegative(t *testing.T) {
|
||||||
|
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
|
||||||
|
t.Error("intake_journal = -1 still built a bus")
|
||||||
|
}
|
||||||
|
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
|
||||||
|
t.Error("intake_journal = 4 built no bus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeJournalsAFactWrite(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
// The ambient path's shape: an env fact below full confidence, timestamped
|
||||||
|
// at the meeting's start rather than at notice time.
|
||||||
|
start := intakeNow.Add(2 * time.Hour)
|
||||||
|
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
|
||||||
|
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("WriteFact: %v", err)
|
||||||
|
}
|
||||||
|
got := bus.Recent(0)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||||
|
}
|
||||||
|
e := got[0]
|
||||||
|
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
|
||||||
|
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
|
||||||
|
}
|
||||||
|
if e.Title != "calendar_event_20260801_планёрка" {
|
||||||
|
t.Errorf("title = %q, want the fact key", e.Title)
|
||||||
|
}
|
||||||
|
if !e.OccurredAt.Equal(start) {
|
||||||
|
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
|
||||||
|
}
|
||||||
|
if e.Priority != event.PriorityLow {
|
||||||
|
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
bus := event.NewBus(8)
|
||||||
|
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
|
||||||
|
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
|
||||||
|
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected the injected error")
|
||||||
|
}
|
||||||
|
if bus.Len() != 0 {
|
||||||
|
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
// The RSS shape: "headline\nsummary\nlink".
|
||||||
|
if _, err := api.WriteNote(context.Background(), intakeNow,
|
||||||
|
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
|
||||||
|
t.Fatalf("WriteNote: %v", err)
|
||||||
|
}
|
||||||
|
got := bus.Recent(1)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Title != "Вышло ядро 6.19" {
|
||||||
|
t.Errorf("title = %q, want the headline", got[0].Title)
|
||||||
|
}
|
||||||
|
if got[0].Kind != event.KindNote {
|
||||||
|
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
|
||||||
|
}
|
||||||
|
if got[0].Body == "" {
|
||||||
|
t.Error("body is empty, want the rest of the note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
|
||||||
|
if _, err := api.CaptureTask(ctx, req); err != nil {
|
||||||
|
t.Fatalf("CaptureTask: %v", err)
|
||||||
|
}
|
||||||
|
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
|
||||||
|
// mailbox must not refill the journal.
|
||||||
|
resp, err := api.CaptureTask(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CaptureTask (repeat): %v", err)
|
||||||
|
}
|
||||||
|
if resp.Created {
|
||||||
|
t.Fatal("store did not dedupe; the test cannot check what it means to")
|
||||||
|
}
|
||||||
|
if bus.Len() != 1 {
|
||||||
|
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
|
||||||
|
}
|
||||||
|
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
|
||||||
|
t.Errorf("entry = %+v, want the captured task", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
|
||||||
|
api, bus := newIntakeTestAPI(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, key := range []string{"a", "b", "c"} {
|
||||||
|
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("WriteFact %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn := intakeEventsFn(bus)
|
||||||
|
got := fn(2)
|
||||||
|
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
|
||||||
|
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
|
||||||
|
}
|
||||||
|
if intakeEventsFn(nil) != nil {
|
||||||
|
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||||
|
got, err := d.RecentEvents(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecentEvents with no journal errored: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("got %d events, want none", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// mavend/mail.go — core's half of the email reader (Vikunja #246,
|
||||||
|
// docs/plans/01-email-reader.md).
|
||||||
|
//
|
||||||
|
// The split: cmd/mavmaild holds the IMAP credential, connects to the mailbox
|
||||||
|
// and converts messages to plaintext; it hands each message to core over
|
||||||
|
// ipc.MethodIngestMail. Core runs the extraction on the resident model —
|
||||||
|
// llama-server lives in this process, spawned by the phraser — and writes what
|
||||||
|
// comes back through the one task intake seam.
|
||||||
|
//
|
||||||
|
// What this file may produce is exactly one thing: rows in `tasks` with status
|
||||||
|
// "candidate". No fact, no reminder, no note, no nudge, no calendar event. A
|
||||||
|
// 1.7B misreading a mail can therefore put a wrong line on a review page and
|
||||||
|
// nothing else; it can never make Maven speak, and it can never make her
|
||||||
|
// recite something out of an advert as true.
|
||||||
|
//
|
||||||
|
// Off unless configured twice over: no `email` block in mavend.json ⇒ the IPC
|
||||||
|
// method does not exist; no llama-server phraser ⇒ same. A reader pointed at a
|
||||||
|
// core that is not set up for mail gets ErrUnknownMethod rather than silence.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/email"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evidenceMaxChars — how much of the subject line is kept as a candidate's
|
||||||
|
// evidence. Enough to recognise the mail on /tasks, not enough to turn the task
|
||||||
|
// list into a copy of his mailbox.
|
||||||
|
const evidenceMaxChars = 160
|
||||||
|
|
||||||
|
// mailIntake — extraction + capture for one message at a time.
|
||||||
|
type mailIntake struct {
|
||||||
|
st *store.Store
|
||||||
|
ex *email.Extractor
|
||||||
|
timeout time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
// bus — the unified intake journal (Vikunja #283). This path captures
|
||||||
|
// through the store directly rather than through ipc.CoreAPI, so the
|
||||||
|
// decorator in intake.go does not see it and the publish is explicit here.
|
||||||
|
// nil is a working no-op.
|
||||||
|
bus *event.Bus
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMailIntake returns nil when mail ingestion must not be available, which is
|
||||||
|
// the default. Both preconditions are real:
|
||||||
|
//
|
||||||
|
// - no cfg.Email ⇒ not configured, and a capability is off unless configured;
|
||||||
|
// - no llama-server phraser ⇒ nothing to extract with. There is deliberately
|
||||||
|
// no keyword fallback: "the subject line became a task" is not extraction,
|
||||||
|
// it is a mailbox rendered as a to-do list, and it would fill the review
|
||||||
|
// page faster than he could clear it.
|
||||||
|
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake {
|
||||||
|
if cfg.Email == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lp, ok := phr.(*phraser.LLMPhraser)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timeout := time.Duration(cfg.Email.Timeout)
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = config.DefaultEmailTimeout
|
||||||
|
}
|
||||||
|
ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||||
|
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
|
||||||
|
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingest handles one ipc.MethodIngestMail call.
|
||||||
|
//
|
||||||
|
// Junk and empty messages are answered Skipped without touching the model — the
|
||||||
|
// reader's header filter is what keeps the resident model off newsletters.
|
||||||
|
//
|
||||||
|
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
|
||||||
|
// and the subject as Evidence. CaptureTask dedupes on normalised text among
|
||||||
|
// live rows, so a mailbox re-read after a restart produces Created=0 rather
|
||||||
|
// than a second copy of every task.
|
||||||
|
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||||
|
msg := email.Message{
|
||||||
|
UID: req.UID,
|
||||||
|
From: req.From,
|
||||||
|
Subject: req.Subject,
|
||||||
|
Date: req.Date,
|
||||||
|
Body: req.Body,
|
||||||
|
Junk: req.Junk,
|
||||||
|
}
|
||||||
|
if msg.Junk || (msg.Subject == "" && msg.Body == "") {
|
||||||
|
return ipc.IngestMailResp{Skipped: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||||
|
defer cancel()
|
||||||
|
cands, err := m.ex.Extract(ctx, msg)
|
||||||
|
if err != nil {
|
||||||
|
// The error from internal/email never carries mail text; keep it that way
|
||||||
|
// by not adding the subject here.
|
||||||
|
return ipc.IngestMailResp{}, fmt.Errorf("mail intake: uid %d: %w", req.UID, err)
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return ipc.IngestMailResp{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
source := email.SourcePrefix + req.Mailbox
|
||||||
|
evidence := truncateRunes(req.Subject, evidenceMaxChars)
|
||||||
|
now := m.now()
|
||||||
|
var resp ipc.IngestMailResp
|
||||||
|
for _, c := range cands {
|
||||||
|
t := store.Task{
|
||||||
|
CreatedTs: now,
|
||||||
|
Text: c.Text,
|
||||||
|
Source: source,
|
||||||
|
Evidence: evidence,
|
||||||
|
// The one status this path may ever write. Anything Maven derived from
|
||||||
|
// something she read is a suggestion until he confirms it on /tasks.
|
||||||
|
Status: store.TaskCandidate,
|
||||||
|
}
|
||||||
|
if due, ok := email.ParseDue(c.Due); ok {
|
||||||
|
t.Due = &due
|
||||||
|
}
|
||||||
|
id, created, err := m.st.CaptureTask(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("mail intake: capture: %w", err)
|
||||||
|
}
|
||||||
|
resp.TaskIDs = append(resp.TaskIDs, id)
|
||||||
|
if created {
|
||||||
|
resp.Created++
|
||||||
|
// Only a row that was actually created. CaptureTask dedupes on
|
||||||
|
// normalised text among live rows, so a mailbox re-read after a
|
||||||
|
// restart must not refill the journal with tasks already in it.
|
||||||
|
m.bus.Publish(publishableTask(t, now), now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Counts only: the log line names the mailbox and the UID, never the subject,
|
||||||
|
// the sender or the task text. Reviewing a candidate is what /tasks is for.
|
||||||
|
log.Printf("mail intake: %s uid %d → %d candidate(s), %d new", source, req.UID, len(cands), resp.Created)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
||||||
|
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
||||||
|
// unlock) so mail behaves the same either way.
|
||||||
|
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) {
|
||||||
|
mi := newMailIntake(st, phr, cfg, bus)
|
||||||
|
if mi == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv.IngestMailFn = mi.ingest
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes cuts a string to n runes, marking the cut.
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/email"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailLLM — a canned extraction reply.
|
||||||
|
type mailLLM struct {
|
||||||
|
reply string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||||
|
m.calls++
|
||||||
|
return m.reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
fake := &mailLLM{reply: reply}
|
||||||
|
return &mailIntake{
|
||||||
|
st: st,
|
||||||
|
ex: email.NewExtractor(fake, 0, nil),
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||||
|
}, st, fake
|
||||||
|
}
|
||||||
|
|
||||||
|
func ingestReq() ipc.IngestMailReq {
|
||||||
|
return ipc.IngestMailReq{
|
||||||
|
Mailbox: "INBOX", UID: 42,
|
||||||
|
From: "billing@isp.example",
|
||||||
|
Subject: "Счёт за интернет",
|
||||||
|
Body: "Оплатите счёт до 5 августа.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one property that matters: a mail-derived task is a candidate, attributed
|
||||||
|
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
||||||
|
// written.
|
||||||
|
func TestIngestCapturesCandidates(t *testing.T) {
|
||||||
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
||||||
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
||||||
|
t.Fatalf("resp = %+v, want one created task", resp)
|
||||||
|
}
|
||||||
|
tasks, err := st.ListTasks(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
got := tasks[0]
|
||||||
|
if got.Status != store.TaskCandidate {
|
||||||
|
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
||||||
|
}
|
||||||
|
if got.Source != "email:INBOX" {
|
||||||
|
t.Errorf("source = %q, want email:INBOX", got.Source)
|
||||||
|
}
|
||||||
|
if got.Evidence != "Счёт за интернет" {
|
||||||
|
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
||||||
|
}
|
||||||
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||||
|
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
||||||
|
}
|
||||||
|
// Nothing else may have been written: no reminder, no fact.
|
||||||
|
rem, err := st.ListReminders(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list reminders: %v", err)
|
||||||
|
}
|
||||||
|
if len(rem) != 0 {
|
||||||
|
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
||||||
|
// rows, and the intake relies on exactly that.
|
||||||
|
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
||||||
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
||||||
|
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
||||||
|
t.Fatalf("first ingest: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second ingest: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
||||||
|
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
||||||
|
}
|
||||||
|
tasks, _ := st.ListTasks(context.Background(), "")
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
||||||
|
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
||||||
|
req := ingestReq()
|
||||||
|
req.Junk = true
|
||||||
|
resp, err := mi.ingest(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Skipped || resp.Created != 0 {
|
||||||
|
t.Errorf("resp = %+v, want skipped", resp)
|
||||||
|
}
|
||||||
|
if fake.calls != 0 {
|
||||||
|
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
||||||
|
}
|
||||||
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||||
|
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
||||||
|
mi, _, fake := newTestIntake(t, "[]")
|
||||||
|
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
||||||
|
if err != nil || !resp.Skipped {
|
||||||
|
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
||||||
|
}
|
||||||
|
if fake.calls != 0 {
|
||||||
|
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
||||||
|
mi, st, _ := newTestIntake(t, "[]")
|
||||||
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
||||||
|
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
||||||
|
}
|
||||||
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||||
|
t.Errorf("got %d tasks, want 0", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestTruncatesEvidence(t *testing.T) {
|
||||||
|
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||||
|
req := ingestReq()
|
||||||
|
req.Subject = strings.Repeat("щ", 400)
|
||||||
|
if _, err := mi.ingest(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("ingest: %v", err)
|
||||||
|
}
|
||||||
|
tasks, _ := st.ListTasks(context.Background(), "")
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
||||||
|
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
||||||
|
// exist at all.
|
||||||
|
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
||||||
|
t.Error("no email block must mean no mail intake")
|
||||||
|
}
|
||||||
|
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||||
|
// extraction, by design.
|
||||||
|
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
||||||
|
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
-20
@@ -66,12 +66,19 @@ import (
|
|||||||
|
|
||||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||||
|
|
||||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||||
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
// owns the store handle the unlock path creates.
|
||||||
// replaces the CoreAPI with the real store adapter and flips the flag.
|
//
|
||||||
|
// The store matters here because of who runs when. In locked mode there is no
|
||||||
|
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
|
||||||
|
// or days later. Shutdown runs on the main goroutine. Without a handoff the
|
||||||
|
// main goroutine has nothing to close, and store.Close is what re-encrypts
|
||||||
|
// the tmpfs working copy back over the ciphertext file — so a daemon that
|
||||||
|
// cold-started lost every write of that session, silently, on the next boot.
|
||||||
type daemonLock struct {
|
type daemonLock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
locked bool
|
locked bool
|
||||||
|
st *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDaemonLock(locked bool) *daemonLock {
|
func newDaemonLock(locked bool) *daemonLock {
|
||||||
@@ -84,10 +91,25 @@ func (l *daemonLock) isLocked() bool {
|
|||||||
return l.locked
|
return l.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *daemonLock) unlock() {
|
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
|
||||||
|
func (l *daemonLock) unlock(st *store.Store) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
l.locked = false
|
l.locked = false
|
||||||
|
l.st = st
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeStore seals the store the unlock path opened, if any. Safe to call
|
||||||
|
// when the daemon never unlocked, and safe to call twice.
|
||||||
|
func (l *daemonLock) closeStore() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
st := l.st
|
||||||
|
l.st = nil
|
||||||
|
l.mu.Unlock()
|
||||||
|
if st == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return st.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -155,6 +177,14 @@ func run(args []string) error {
|
|||||||
return fmt.Errorf("open store: %w", err)
|
return fmt.Errorf("open store: %w", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
} else {
|
||||||
|
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
|
||||||
|
// opened, at shutdown, on this goroutine.
|
||||||
|
defer func() {
|
||||||
|
if err := dl.closeStore(); err != nil {
|
||||||
|
log.Printf("mavend: seal store on shutdown: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- daemon components (only wired when unlocked) -----
|
// ----- daemon components (only wired when unlocked) -----
|
||||||
@@ -169,8 +199,21 @@ func run(args []string) error {
|
|||||||
coreAPI ipc.CoreAPI
|
coreAPI ipc.CoreAPI
|
||||||
eco *ecosystemWiring
|
eco *ecosystemWiring
|
||||||
factWorker *factEnrichmentWorker
|
factWorker *factEnrichmentWorker
|
||||||
|
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||||
|
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||||
|
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The unified intake journal (Vikunja #283). Built before anything else
|
||||||
|
// that holds a CoreAPI, because intakeAPI wraps that one interface and
|
||||||
|
// every intake path in the daemon reaches its sink through it. nil (the
|
||||||
|
// operator set intake_journal negative) means no decorator at all.
|
||||||
|
evBus := newEventBus(cfg)
|
||||||
|
// coreFor is what every in-process holder of a CoreAPI now takes, instead
|
||||||
|
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
|
||||||
|
// envelope per successful intake write.
|
||||||
|
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||||
|
|
||||||
if !locked {
|
if !locked {
|
||||||
rules = loop.DefaultRules()
|
rules = loop.DefaultRules()
|
||||||
gatherer = loop.NewGatherer(st, rules)
|
gatherer = loop.NewGatherer(st, rules)
|
||||||
@@ -214,7 +257,7 @@ func run(args []string) error {
|
|||||||
eco = wireEcosystem(cfg)
|
eco = wireEcosystem(cfg)
|
||||||
|
|
||||||
// voice
|
// voice
|
||||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -261,18 +304,26 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
|
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: coreFor(),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
|
getEvents: intakeEventsFn(evBus),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
api.chatFn = voiceW.handler.handleText
|
api.chatFn = voiceW.handler.handleText
|
||||||
}
|
}
|
||||||
|
if voiceW != nil && voiceW.mcp != nil {
|
||||||
|
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||||
@@ -317,12 +368,35 @@ func run(args []string) error {
|
|||||||
|
|
||||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||||
|
|
||||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
||||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
// configured and there is a llama-server to extract with, in which case
|
||||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||||
|
if !locked {
|
||||||
|
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||||
|
wireModelSwap(srv, phr, cfg)
|
||||||
|
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||||
|
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||||
|
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
|
||||||
|
// The meeting recorder (Vikunja #253) shares that blob store and its
|
||||||
|
// retention loop. Off unless a capture block enables it, in which case
|
||||||
|
// all four capture methods answer ErrUnknownMethod.
|
||||||
|
wireCapture(srv, keeper, st, voiceW, phr, cfg)
|
||||||
|
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||||
|
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||||
|
// block, so no wire path takes a voiceprint on a default box.
|
||||||
|
wireSpeaker(srv, st, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapKeyFn — wraps the env key under the passkey PRF secret and persists
|
||||||
|
// the wrapped blob. Only wired when the daemon has the key in memory (env
|
||||||
|
// key mode). Called by mavweb after passkey enrollment.
|
||||||
|
//
|
||||||
|
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||||
|
// an authenticator without PRF support produces no wrapped file at all
|
||||||
|
// rather than a file that looks protected and is not.
|
||||||
if envKeyBytes != nil {
|
if envKeyBytes != nil {
|
||||||
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
srv.WrapKeyFn = func(ctx context.Context, secret []byte) error {
|
||||||
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
blob, err := webauthn.WrapKey(envKeyBytes, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wrap encryption key: %w", err)
|
return fmt.Errorf("wrap encryption key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -338,20 +412,42 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
// UnlockFn — cold-start unlock: unwraps the encryption key from the
|
||||||
// blob using the passkey credential public key, opens the store, wires all
|
// wrapped blob using the passkey PRF secret, opens the store, wires all
|
||||||
// daemon components, and replaces the locked API.
|
// daemon components, and replaces the locked API.
|
||||||
if locked {
|
if locked {
|
||||||
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
var unlockMu sync.Mutex
|
||||||
|
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
|
||||||
|
// One unlock at a time, and never a second one. Without this a
|
||||||
|
// concurrent pair of Unlock calls would each open a store and
|
||||||
|
// wire a full daemon, and the loser's goroutines would run
|
||||||
|
// against a store nobody closes.
|
||||||
|
unlockMu.Lock()
|
||||||
|
defer unlockMu.Unlock()
|
||||||
|
if !dl.isLocked() {
|
||||||
|
return nil // already unlocked; the caller does not need to know
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wire cannot authenticate its caller — the socket is
|
||||||
|
// same-uid — so the unlock path requires a passkey assertion
|
||||||
|
// that mavweb verified cryptographically first. Without this,
|
||||||
|
// MethodUnlock is reachable by anything on the box.
|
||||||
|
if !passkeySess.IsStepUp() {
|
||||||
|
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||||
|
}
|
||||||
|
|
||||||
wp := *wrappedKeyPath
|
wp := *wrappedKeyPath
|
||||||
blob, err := os.ReadFile(wp)
|
blob, err := os.ReadFile(wp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read wrapped key: %w", err)
|
return fmt.Errorf("read wrapped key: %w", err)
|
||||||
}
|
}
|
||||||
key, err := webauthn.UnwrapKey(blob, publicKey)
|
key, version, err := webauthn.UnwrapKey(blob, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unwrap key: %w", err)
|
return fmt.Errorf("unwrap key: %w", err)
|
||||||
}
|
}
|
||||||
|
if version == webauthn.BlobV1 {
|
||||||
|
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Re-enroll the passkey on an authenticator that supports the PRF extension to rewrite it as v2.", wp, version)
|
||||||
|
}
|
||||||
// Open the store with the unwrapped key.
|
// Open the store with the unwrapped key.
|
||||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -397,7 +493,7 @@ func run(args []string) error {
|
|||||||
|
|
||||||
eco = wireEcosystem(cfg)
|
eco = wireEcosystem(cfg)
|
||||||
|
|
||||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
@@ -438,20 +534,33 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
|
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||||
newAPI := &daemonAPI{
|
newAPI := &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: coreFor(),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
|
getEvents: intakeEventsFn(evBus),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
}
|
}
|
||||||
srv.SetAPI(newAPI)
|
srv.SetAPI(newAPI)
|
||||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||||
|
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||||
|
wireModelSwap(srv, phr, cfg)
|
||||||
|
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
|
||||||
|
wireCapture(srv, keeper, st, voiceW, phr, cfg)
|
||||||
|
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||||
|
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||||
|
// block, so no wire path takes a voiceprint on a default box.
|
||||||
|
wireSpeaker(srv, st, cfg)
|
||||||
|
|
||||||
// Start voice server.
|
// Start voice server.
|
||||||
if voiceW != nil {
|
if voiceW != nil {
|
||||||
@@ -476,7 +585,38 @@ func run(args []string) error {
|
|||||||
factWorker.run(ctx)
|
factWorker.run(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
dl.unlock()
|
// Start background memory evaluation (nil unless configured).
|
||||||
|
if evalWorker != nil {
|
||||||
|
go func() {
|
||||||
|
evalWorker.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start feed reading (nil unless configured).
|
||||||
|
if feedWkr != nil {
|
||||||
|
go func() {
|
||||||
|
feedWkr.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the watched-page crawls (nil unless configured).
|
||||||
|
if crawlWkr != nil {
|
||||||
|
go func() {
|
||||||
|
crawlWkr.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep MCP connections alive (nil unless configured).
|
||||||
|
if voiceW != nil && voiceW.mcp != nil {
|
||||||
|
go voiceW.mcp.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enumerate the house for new devices (nil unless configured).
|
||||||
|
if voiceW != nil && voiceW.home != nil {
|
||||||
|
go voiceW.home.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.unlock(st)
|
||||||
log.Printf("mavend: unlocked via passkey assertion")
|
log.Printf("mavend: unlocked via passkey assertion")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -514,6 +654,41 @@ func run(args []string) error {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
factWorker.run(ctx)
|
factWorker.run(ctx)
|
||||||
}()
|
}()
|
||||||
|
if evalWorker != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
evalWorker.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if feedWkr != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
feedWkr.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if crawlWkr != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
crawlWkr.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if voiceW != nil && voiceW.mcp != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
voiceW.mcp.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if voiceW != nil && voiceW.home != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
voiceW.home.run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/mcp"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/webfetch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpRefreshInterval — how often the manager re-dials a server that is down.
|
||||||
|
// The manager applies its own backoff on top, so this being short is cheap.
|
||||||
|
const mcpRefreshInterval = time.Minute
|
||||||
|
|
||||||
|
// mcpWiring — the MCP client, when the `mcp` block configures at least one
|
||||||
|
// enabled server. nil ⇒ nothing was configured, nothing is connected, and an
|
||||||
|
// allowlist row that happens to look like an MCP row refuses to run.
|
||||||
|
//
|
||||||
|
// It lives on the voice wiring because MCP tools ARE acts: they run through
|
||||||
|
// tool.Executor, the enabled allowlist and the confirm turn, which only exist
|
||||||
|
// on the voice/chat path. No voice surface ⇒ nothing that could call a tool.
|
||||||
|
type mcpWiring struct {
|
||||||
|
mgr *mcp.Manager
|
||||||
|
st *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireMCP builds the manager, connects, and proposes what it found. It never
|
||||||
|
// fails the daemon: a server that is unreachable at boot is logged and retried,
|
||||||
|
// because Maven starting is not contingent on someone else's process.
|
||||||
|
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
|
||||||
|
servers := cfg.MCPServers()
|
||||||
|
if len(servers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
limits := webfetch.Config{}
|
||||||
|
if cfg.MCP != nil {
|
||||||
|
limits.AllowHosts = cfg.MCP.AllowHosts
|
||||||
|
limits.DenyHosts = cfg.MCP.DenyHosts
|
||||||
|
limits.MaxBytes = cfg.MCP.MaxBytes
|
||||||
|
limits.Timeout = time.Duration(cfg.MCP.Timeout)
|
||||||
|
}
|
||||||
|
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
|
||||||
|
if err != nil {
|
||||||
|
// Validation already ran in config.validate, so this is a programming
|
||||||
|
// error rather than a config one. Still not fatal: MCP off is a working
|
||||||
|
// Maven.
|
||||||
|
log.Printf("mcp: not wired: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := &mcpWiring{mgr: mgr, st: st}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
mgr.Connect(ctx)
|
||||||
|
w.propose(ctx)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// propose writes a 'proposed' allowlist row for every discovered tool. It does
|
||||||
|
// NOT enable anything: a configured server is a place Maven may look, not a
|
||||||
|
// capability she has. Kami enables what he wants on /tools, behind step-up,
|
||||||
|
// which is the same gate a shell tool goes through.
|
||||||
|
//
|
||||||
|
// Re-running on every boot is idempotent — ProposeMCPTool never touches an
|
||||||
|
// existing row, so a tool he disabled stays disabled and one he enabled keeps
|
||||||
|
// the cmd he enabled it with.
|
||||||
|
func (w *mcpWiring) propose(ctx context.Context) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fresh := 0
|
||||||
|
for _, t := range w.mgr.Tools() {
|
||||||
|
name := mcp.LocalName(t.Server, t.Name)
|
||||||
|
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
|
||||||
|
// in this direction only costs a question.
|
||||||
|
destructive := !t.ReadOnly
|
||||||
|
provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name)
|
||||||
|
if t.Description != "" {
|
||||||
|
provenance += ": " + t.Description
|
||||||
|
}
|
||||||
|
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
|
||||||
|
mcp.Cmd(t.Server, t.Name), destructive, provenance, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mcp: propose %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
fresh++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fresh > 0 {
|
||||||
|
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run re-dials downed servers and picks up tools that appeared, until ctx is
|
||||||
|
// canceled.
|
||||||
|
func (w *mcpWiring) run(ctx context.Context) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := time.NewTicker(mcpRefreshInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.mgr.Refresh(ctx)
|
||||||
|
w.propose(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// status maps the manager's view onto the wire type the web surface reads.
|
||||||
|
func (w *mcpWiring) status() []ipc.MCPServerStatus {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
in := w.mgr.Status()
|
||||||
|
out := make([]ipc.MCPServerStatus, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
out = append(out, ipc.MCPServerStatus{
|
||||||
|
Name: s.Name,
|
||||||
|
Transport: s.Transport,
|
||||||
|
Target: s.Target,
|
||||||
|
Connected: s.Connected,
|
||||||
|
Server: s.Server,
|
||||||
|
Tools: s.Tools,
|
||||||
|
Err: s.Err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mcpWiring) close() {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = w.mgr.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// caller is the tool.MCPCaller the executor gets, or nil when MCP is off.
|
||||||
|
func (w *mcpWiring) caller() *mcp.Manager {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.mgr
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
for name, cfg := range map[string]*config.Config{
|
||||||
|
"no block": {},
|
||||||
|
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
|
||||||
|
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
|
||||||
|
}}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if w := wireMCP(cfg, st); w != nil {
|
||||||
|
t.Fatal("MCP must be off unless a server is configured AND enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// nil wiring must be safe to use everywhere it is reachable.
|
||||||
|
var w *mcpWiring
|
||||||
|
w.close()
|
||||||
|
w.propose(context.Background())
|
||||||
|
if w.status() != nil || w.caller() != nil {
|
||||||
|
t.Fatal("a nil wiring must report nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unreachable server must not stop the daemon, must be reported as down, and
|
||||||
|
// must propose nothing.
|
||||||
|
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||||
|
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||||
|
}}}}, st)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("a configured server should still wire")
|
||||||
|
}
|
||||||
|
defer w.close()
|
||||||
|
st2 := w.status()
|
||||||
|
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
|
||||||
|
t.Fatalf("status = %+v", st2)
|
||||||
|
}
|
||||||
|
tools, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tools) != 0 {
|
||||||
|
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A url server whose address is private is refused by webfetch unless that
|
||||||
|
// server sets allow_private. This is the guard the whole MCP path rides on, so
|
||||||
|
// it is asserted here too, at the wiring level.
|
||||||
|
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||||
|
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
|
||||||
|
}}}}, st)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("should wire")
|
||||||
|
}
|
||||||
|
defer w.close()
|
||||||
|
s := w.status()[0]
|
||||||
|
if s.Connected {
|
||||||
|
t.Fatal("a loopback server must not connect without allow_private")
|
||||||
|
}
|
||||||
|
if !strings.Contains(s.Err, "private address") {
|
||||||
|
t.Fatalf("err = %q, want the private-address refusal", s.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// mavend/memoryeval.go — the driver for background memory evaluation
|
||||||
|
// (Vikunja #248). The evaluator itself is pure-ish and lives in
|
||||||
|
// internal/memeval; this is the one impure part: a ticker, the store, and the
|
||||||
|
// resident model's base URL.
|
||||||
|
//
|
||||||
|
// It is its own goroutine and NOT a step on the main tick, deliberately. The
|
||||||
|
// tick runs every 60s and has a delivery deadline behind it; an evaluation is
|
||||||
|
// a multi-second LLM round-trip on the same llama-server that answers voice
|
||||||
|
// turns, and it happens hourly at most. Bolting it onto the tick would make
|
||||||
|
// every hour's tick the slow one for no benefit.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/memeval"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memoryEvalWorker — ticker + evaluator.
|
||||||
|
type memoryEvalWorker struct {
|
||||||
|
eval *memeval.Evaluator
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMemoryEvalWorker wires the evaluation loop, or returns nil when it should
|
||||||
|
// not run at all. nil is the normal case and every caller must handle it:
|
||||||
|
//
|
||||||
|
// - no memory_eval config block ⇒ off (a capability is off unless configured);
|
||||||
|
// - no LLM phraser ⇒ nothing to evaluate with. There is no template fallback
|
||||||
|
// here on purpose: a "memory evaluation" assembled from string templates
|
||||||
|
// would be a fixed sentence pretending to be an observation.
|
||||||
|
func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Config) *memoryEvalWorker {
|
||||||
|
if cfg.MemoryEval == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lp, ok := phr.(*phraser.LLMPhraser)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("memory eval: configured but no llama-server phraser — evaluation disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
interval := time.Duration(cfg.MemoryEval.Interval)
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = config.DefaultMemoryEvalInterval
|
||||||
|
}
|
||||||
|
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||||
|
// and nobody is waiting on the answer.
|
||||||
|
client := llmClientFor(lp, 5*time.Minute)
|
||||||
|
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||||
|
MaxItems: cfg.MemoryEval.MaxItems,
|
||||||
|
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||||
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
|
})
|
||||||
|
log.Printf("memory eval: enabled, every %s", interval)
|
||||||
|
return &memoryEvalWorker{eval: ev, interval: interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run evaluates every interval until ctx is canceled.
|
||||||
|
//
|
||||||
|
// The first evaluation waits a full interval rather than firing at startup, the
|
||||||
|
// opposite of the tick loop's cold-start behaviour. A tick that fires late is a
|
||||||
|
// nudge that arrives late; an evaluation that fires late is nothing at all, and
|
||||||
|
// the alternative is a heavy LLM call competing with startup — including with
|
||||||
|
// the first voice turn after a restart.
|
||||||
|
func (w *memoryEvalWorker) run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(w.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
obs, err := w.eval.Evaluate(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("memory eval: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range obs {
|
||||||
|
log.Printf("memory eval: noted (%.2f, %s): %s", o.Conf, o.Action, o.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Swapping the resident model while the daemon runs (Vikunja #250).
|
||||||
|
//
|
||||||
|
// Off unless configured: with no phraser.swap_models allowlist the two IPC
|
||||||
|
// methods are never wired, so they answer ErrUnknownMethod. When it is wired the
|
||||||
|
// swap method is AuthStepUp (internal/auth), which means an authed human surface
|
||||||
|
// only — there is no act, no intent and no timer that reaches it. The daemon
|
||||||
|
// never decides to change its own brain.
|
||||||
|
//
|
||||||
|
// The allowlist is exact-match against paths a human wrote in mavend.json. The
|
||||||
|
// request carries a path and llama-server is started with it as `-m`, so
|
||||||
|
// anything looser would turn "swap the model" into "load any file on my disk".
|
||||||
|
func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||||
|
if cfg.Phraser == nil || len(cfg.Phraser.SwapModels) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lp, ok := phr.(*phraser.LLMPhraser)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("model swap: phraser.swap_models is set but there is no llama-server phraser — swap disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, m := range cfg.Phraser.SwapModels {
|
||||||
|
allowed[filepath.Clean(m)] = true
|
||||||
|
}
|
||||||
|
// The configured model is always swappable back to, listed or not: the way
|
||||||
|
// out of a bad swap must not depend on remembering to allowlist the model
|
||||||
|
// you are already running.
|
||||||
|
allowed[filepath.Clean(cfg.Phraser.ModelPath)] = true
|
||||||
|
|
||||||
|
srv.SwapModelFn = func(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||||
|
path := filepath.Clean(req.ModelPath)
|
||||||
|
if !allowed[path] {
|
||||||
|
log.Printf("model swap: REFUSED %q — not in phraser.swap_models", req.ModelPath)
|
||||||
|
return ipc.SwapModelResp{}, fmt.Errorf("%w: %q is not in phraser.swap_models", ipc.ErrForbidden, req.ModelPath)
|
||||||
|
}
|
||||||
|
res, err := lp.Swap(ctx, phraser.SwapSpec{
|
||||||
|
ModelPath: path,
|
||||||
|
NGpuLayers: req.NGpuLayers,
|
||||||
|
NCtx: req.NCtx,
|
||||||
|
})
|
||||||
|
resp := ipc.SwapModelResp{
|
||||||
|
Model: res.Model,
|
||||||
|
ModelPath: res.ModelPath,
|
||||||
|
BaseURL: res.BaseURL,
|
||||||
|
RolledBack: res.RolledBack,
|
||||||
|
TookMs: res.Took.Milliseconds(),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// A rolled-back swap is a failure that left a working daemon behind.
|
||||||
|
// Both halves matter to the caller, so the response is filled in even
|
||||||
|
// though the error is returned.
|
||||||
|
log.Printf("model swap: %v", err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.ModelStatusFn = func(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||||
|
path, ngl, nctx := lp.LiveModel()
|
||||||
|
base := lp.BaseURL()
|
||||||
|
resp := ipc.ModelStatusResp{
|
||||||
|
ModelPath: path,
|
||||||
|
BaseURL: base,
|
||||||
|
NGpuLayers: ngl,
|
||||||
|
NCtx: nctx,
|
||||||
|
Swappable: cfg.Phraser.SwapModels,
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
resp.Model = llm.UnknownModel
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
id, err := llm.ModelID(ctx, base)
|
||||||
|
if err != nil {
|
||||||
|
// Report the honest "I could not confirm it" rather than echoing the
|
||||||
|
// configured filename as if the server had said it.
|
||||||
|
resp.Model = llm.UnknownModel
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
resp.Model = id
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("model swap: enabled, %d allowlisted model(s) — step-up required", len(cfg.Phraser.SwapModels))
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmClientFor builds a completion client on the phraser's llama-server and
|
||||||
|
// keeps it pointed at the right one across a model swap.
|
||||||
|
//
|
||||||
|
// Without the OnSwap registration every holder of a base URL — the LLM router,
|
||||||
|
// the replier, the mail extractor, the memory evaluator — would keep talking to
|
||||||
|
// the port of a server that no longer exists, and the daemon would degrade to
|
||||||
|
// the classifier permanently after the first swap. The client is re-pointed, not
|
||||||
|
// rebuilt, so nothing that holds it has to know a swap happened.
|
||||||
|
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||||
|
c := llm.New(lp.BaseURL(), timeout)
|
||||||
|
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||||
|
return c
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/netscan"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||||
|
// longer than this has already failed as a turn, so the scan returns whatever
|
||||||
|
// it found rather than keeping him waiting.
|
||||||
|
const scanBudget = 20 * time.Second
|
||||||
|
|
||||||
|
// scanReadOut — how many hosts she names out loud. The rest are a count: a
|
||||||
|
// spoken list of twenty IP addresses is not an answer.
|
||||||
|
const scanReadOut = 6
|
||||||
|
|
||||||
|
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||||
|
// never puts a discovery packet on the network.
|
||||||
|
//
|
||||||
|
// Unlike the house, a scan is a READ, so it is a query source rather than an
|
||||||
|
// act: there is no allowlist row and no confirm turn, because nothing changes.
|
||||||
|
// What makes that safe is that the range is not an argument — see
|
||||||
|
// internal/netscan's package comment.
|
||||||
|
type netWiring struct {
|
||||||
|
scanner *netscan.Scanner
|
||||||
|
subnets []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||||
|
func wireNetScan(cfg *config.Config) *netWiring {
|
||||||
|
nc, ok := cfg.NetScanner()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := netscan.Validate(nc); err != nil {
|
||||||
|
// config.validate already ran this, so reaching here is a programming
|
||||||
|
// error rather than a config one. Not fatal: the scanner off is a
|
||||||
|
// working Maven.
|
||||||
|
log.Printf("netscan: not wired: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanSummary answers "какие устройства в сети?" in one line.
|
||||||
|
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||||
|
if w == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||||
|
defer cancel()
|
||||||
|
hosts, err := w.scanner.Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("netscan: scan: %v", err)
|
||||||
|
return "не получилось просканировать сеть.", true
|
||||||
|
}
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return "в сети никого не нашла.", true
|
||||||
|
}
|
||||||
|
shown := hosts
|
||||||
|
if len(shown) > scanReadOut {
|
||||||
|
shown = shown[:scanReadOut]
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(shown))
|
||||||
|
for _, h := range shown {
|
||||||
|
s := h.Addr
|
||||||
|
if len(h.Ports) > 0 {
|
||||||
|
ps := make([]string, 0, len(h.Ports))
|
||||||
|
for _, p := range h.Ports {
|
||||||
|
ps = append(ps, fmt.Sprintf("%d", p))
|
||||||
|
}
|
||||||
|
s += " (" + strings.Join(ps, ", ") + ")"
|
||||||
|
}
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("нашла %d %s: %s", len(hosts), hostWord(len(hosts)), strings.Join(parts, "; "))
|
||||||
|
if len(hosts) > len(shown) {
|
||||||
|
out += fmt.Sprintf(" и ещё %d", len(hosts)-len(shown))
|
||||||
|
}
|
||||||
|
return out + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||||
|
// 5+ устройств, and the teens are all the last form.
|
||||||
|
func hostWord(n int) string {
|
||||||
|
if n%100 >= 11 && n%100 <= 14 {
|
||||||
|
return "устройств"
|
||||||
|
}
|
||||||
|
switch n % 10 {
|
||||||
|
case 1:
|
||||||
|
return "устройство"
|
||||||
|
case 2, 3, 4:
|
||||||
|
return "устройства"
|
||||||
|
default:
|
||||||
|
return "устройств"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||||
|
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||||
|
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||||
|
// bounds exist to prevent.
|
||||||
|
func isNetworkQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
network := false
|
||||||
|
for _, w := range []string{"в сети", "в сетке", "сеть", "сети", "локальн", "wifi", "wi-fi", "вайфай"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
network = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !network {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// An explicit ask to scan, or a phrase that can only be about the LAN.
|
||||||
|
// "кто в сети" carries no device noun but means nothing else.
|
||||||
|
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
|
||||||
|
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
|
||||||
|
if !ask {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
|
for name, cfg := range map[string]*config.Config{
|
||||||
|
"no block": {},
|
||||||
|
"written but dark": {NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"192.168.1.0/24"},
|
||||||
|
}},
|
||||||
|
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
||||||
|
"enabled but public": {NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
||||||
|
}},
|
||||||
|
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if w := wireNetScan(cfg); w != nil {
|
||||||
|
t.Fatal("the scanner must not wire for this config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var w *netWiring
|
||||||
|
if _, ok := w.scanSummary(context.Background()); ok {
|
||||||
|
t.Fatal("a nil wiring must not claim a query")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||||
|
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||||
|
}})
|
||||||
|
if ok == nil {
|
||||||
|
t.Fatal("a valid enabled block should wire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A loopback /32 with nothing listening on the scanned port: the summary must
|
||||||
|
// come back honest rather than inventing a host. This also exercises the real
|
||||||
|
// dialer end to end without touching anything outside this box.
|
||||||
|
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||||
|
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||||
|
// Port 1 on loopback: nothing listens and the connection is refused
|
||||||
|
// immediately, so the scan is fast and touches only this machine.
|
||||||
|
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||||
|
}})
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("wireNetScan returned nil")
|
||||||
|
}
|
||||||
|
out, claimed := w.scanSummary(context.Background())
|
||||||
|
if !claimed {
|
||||||
|
t.Fatal("the summary did not claim the turn")
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
t.Fatal("empty summary")
|
||||||
|
}
|
||||||
|
// Persona: feminine self-reference, informal address, no pet names.
|
||||||
|
low := strings.ToLower(out)
|
||||||
|
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
||||||
|
if strings.Contains(low, bad) {
|
||||||
|
t.Errorf("persona violation %q in %q", bad, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||||
|
for n, want := range map[int]string{
|
||||||
|
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||||
|
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||||
|
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||||
|
} {
|
||||||
|
if got := hostWord(n); got != want {
|
||||||
|
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNetworkQuery(t *testing.T) {
|
||||||
|
yes := []string{
|
||||||
|
"какие устройства в сети?",
|
||||||
|
"кто в сети?",
|
||||||
|
"просканируй сеть",
|
||||||
|
"покажи устройства в локальной сети",
|
||||||
|
"сколько машин в сети",
|
||||||
|
}
|
||||||
|
no := []string{
|
||||||
|
"",
|
||||||
|
"интернет не работает",
|
||||||
|
"сеть какая-то медленная",
|
||||||
|
"я в сети инстаграма",
|
||||||
|
"что включено дома?",
|
||||||
|
"напомни оплатить интернет",
|
||||||
|
}
|
||||||
|
for _, u := range yes {
|
||||||
|
if !isNetworkQuery(u) {
|
||||||
|
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range no {
|
||||||
|
if isNetworkQuery(u) {
|
||||||
|
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-1
@@ -1,6 +1,6 @@
|
|||||||
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
||||||
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
||||||
// write time in voice.go's detectPattern, tied to whichever channel wrote the
|
// write time in detectPattern below, tied to whichever channel wrote the
|
||||||
// fact. Detection — turning a run of events into a proposed routine — is
|
// fact. Detection — turning a run of events into a proposed routine — is
|
||||||
// channel-agnostic: it only needs what's already in the events table, so it
|
// channel-agnostic: it only needs what's already in the events table, so it
|
||||||
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
@@ -72,3 +73,48 @@ func detectAndPropose(ctx context.Context, ds *store.Store, action, object strin
|
|||||||
}
|
}
|
||||||
return r, id, nil
|
return r, id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectPattern extracts an event from the written fact and runs the pattern
|
||||||
|
// detector. If a stable recurring pattern is found and no proposed routine
|
||||||
|
// exists for this action+object yet, one is created and the user is prompted
|
||||||
|
// to confirm via the park() mechanism. Returns the suggestion phrase when a
|
||||||
|
// new proposal was created and parked; "" otherwise.
|
||||||
|
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
|
||||||
|
ev := pattern.Extract(factID, key, value, ts)
|
||||||
|
if ev == nil {
|
||||||
|
return "" // not an actionable event
|
||||||
|
}
|
||||||
|
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
|
||||||
|
log.Printf("voice: create event: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Detect+propose (Vikunja #43) is shared with the digestion tick's
|
||||||
|
// proactive scan — see detectAndPropose above. Event *extraction* stays
|
||||||
|
// here, tied to this fact write; detection over the accumulated history does
|
||||||
|
// not need to happen right now for the voice path to have already done
|
||||||
|
// its job — it's dedupe-safe to also let the next tick find the same
|
||||||
|
// pattern independently.
|
||||||
|
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return "" // not enough data, too irregular, or already proposed/decided
|
||||||
|
}
|
||||||
|
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||||
|
|
||||||
|
// Park the proposal for voice confirmation.
|
||||||
|
phrase := pattern.PhraseRoutine(r)
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pendingRoutine = &pendingRoutineConfirm{
|
||||||
|
routineID: id,
|
||||||
|
action: r.Action,
|
||||||
|
object: r.Object,
|
||||||
|
interval: r.IntervalDays,
|
||||||
|
phrase: phrase,
|
||||||
|
expiry: ts.Add(confirmTTL),
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return phrase
|
||||||
|
}
|
||||||
|
|||||||
+174
-11
@@ -3,9 +3,14 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,16 +32,16 @@ func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base t
|
|||||||
|
|
||||||
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
||||||
// its own, reading straight from the store — not as a side effect of a live
|
// its own, reading straight from the store — not as a side effect of a live
|
||||||
// utterance (Vikunja #43). Three weekly events with no voice turn in sight
|
// utterance (Vikunja #43). MinEvents weekly events with no voice turn in
|
||||||
// must produce exactly one proposed routine.
|
// sight must produce exactly one proposed routine.
|
||||||
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,11 +63,11 @@ func TestTickPatternDetectionIsIdempotent(t *testing.T) {
|
|||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
tl.detectPatterns(ctx, now.Add(time.Hour))
|
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,10 +86,10 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
seedRefillEvents(t, st, ctx, now, 3)
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
|
||||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||||
tl.detectPatterns(ctx, now)
|
tl.detectPatterns(ctx, now, loop.State{})
|
||||||
|
|
||||||
rows, err := st.ListProposedRoutines(ctx)
|
rows, err := st.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,8 +104,8 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
|
|
||||||
// More events for the same pair arrive, and the tick runs again — a
|
// More events for the same pair arrive, and the tick runs again — a
|
||||||
// dismissed pattern must not resurface.
|
// dismissed pattern must not resurface.
|
||||||
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), 3)
|
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
|
||||||
tl.detectPatterns(ctx, now.Add(60*24*time.Hour))
|
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
|
||||||
|
|
||||||
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,3 +125,161 @@ func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
|||||||
t.Errorf("status = %s, want dismissed", all[0].Status)
|
t.Errorf("status = %s, want dismissed", all[0].Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proposalRule — the rule name announceProposal uses for the seeded pair.
|
||||||
|
const proposalRule = "proposal:refill cat_water"
|
||||||
|
|
||||||
|
// TestTickProposalSilentByDefault — detection is always on, announcing is not.
|
||||||
|
// With no pattern_proposals block the tick still records the proposal, and says
|
||||||
|
// nothing about it: Maven is not autonomous, so a behaviour that speaks without
|
||||||
|
// being asked stays off until it is configured.
|
||||||
|
func TestTickProposalSilentByDefault(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
markPresent(t, st, ctx, now)
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
if n := countSends(sink, proposalRule); n != 0 {
|
||||||
|
t.Fatalf("announced %d proposals with no config, want 0", n)
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes
|
||||||
|
// out once through the ordinary delivery path, worded by the detector itself.
|
||||||
|
// Later ticks stay quiet because the pair is already proposed: one pattern is
|
||||||
|
// one announcement, ever.
|
||||||
|
func TestTickAnnouncesProposalWhenConfigured(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
markPresent(t, st, ctx, now)
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
var got *delivery.Sendable
|
||||||
|
for i := range sink.sends {
|
||||||
|
if sink.sends[i].RuleName == proposalRule {
|
||||||
|
got = &sink.sends[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("proposal was not announced; sends=%+v", sink.sends)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Body, "напоминать?") {
|
||||||
|
t.Errorf("body = %q, want the detector's own question", got.Body)
|
||||||
|
}
|
||||||
|
if got.Channel != delivery.ChannelVoice {
|
||||||
|
t.Errorf("channel = %v, want voice (sev1, present)", got.Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A month of further ticks: the pair already has a row, so there is
|
||||||
|
// nothing new to detect and nothing more to say.
|
||||||
|
sink.sends = nil
|
||||||
|
later := now.Add(40 * 24 * time.Hour)
|
||||||
|
markPresent(t, st, ctx, later)
|
||||||
|
tl.tick(ctx, later)
|
||||||
|
if n := countSends(sink, proposalRule); n != 0 {
|
||||||
|
t.Fatalf("re-announced an existing proposal %d times, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can
|
||||||
|
// say, so it is sev1 and the restraint gate suppresses it. Away presence means
|
||||||
|
// it is not announced at all: it is not held, not retried, it just lives on
|
||||||
|
// /routines. The proposal row is still written — noticing is never gated.
|
||||||
|
func TestTickProposalRespectsGate(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
// no presence probes ⇒ away ⇒ care-class gate blocks.
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
if n := countSends(sink, proposalRule); n != 0 {
|
||||||
|
t.Fatalf("away: announced %d proposals, want 0", n)
|
||||||
|
}
|
||||||
|
if !tl.lastProposalAt.IsZero() {
|
||||||
|
t.Error("cooldown clock advanced on a suppressed announcement")
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the
|
||||||
|
// same tick must not become two interruptions. The second one waits for the
|
||||||
|
// cooldown, and is on /routines meanwhile.
|
||||||
|
func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||||
|
for i := 0; i < pattern.MinEvents; i++ {
|
||||||
|
ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour)
|
||||||
|
factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write fact: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil {
|
||||||
|
t.Fatalf("create event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markPresent(t, st, ctx, now)
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)}
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
announced := 0
|
||||||
|
for _, s := range sink.sends {
|
||||||
|
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||||
|
announced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if announced != 1 {
|
||||||
|
t.Fatalf("announced %d proposals on one tick, want exactly 1", announced)
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still inside the cooldown: silence, even though a proposal is pending.
|
||||||
|
sink.sends = nil
|
||||||
|
soon := now.Add(time.Hour)
|
||||||
|
markPresent(t, st, ctx, soon)
|
||||||
|
tl.tick(ctx, soon)
|
||||||
|
for _, s := range sink.sends {
|
||||||
|
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||||
|
t.Fatalf("announced %q inside the cooldown", s.RuleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Quiet-mode toggle recognition — the pre-route keyword check that lets
|
||||||
|
// "тихий режим" flip the daemon-wide quiet_hours config without going through
|
||||||
|
// the router. Moved out of voice.go unchanged (Vikunja #321); the tests live in
|
||||||
|
// quiet_toggle_test.go.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||||
|
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
|
||||||
|
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
|
||||||
|
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||||
|
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||||
|
// setting. See classifyQuietToggle for the matching rule.
|
||||||
|
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
||||||
|
on, off := classifyQuietToggle(text)
|
||||||
|
if !on && !off {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
val := "false"
|
||||||
|
reply := "тихий режим выключен."
|
||||||
|
if on {
|
||||||
|
val = "true"
|
||||||
|
reply = "тихий режим включён. буду реже напоминать."
|
||||||
|
}
|
||||||
|
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: h.now(),
|
||||||
|
Kind: "config",
|
||||||
|
Key: "quiet_hours",
|
||||||
|
Value: val,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Confidence: 1.0,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("voice: write quiet_hours: %v", err)
|
||||||
|
return "не получилось переключить тихий режим.", true
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietInflections — the inflectional endings a stem may carry and still be
|
||||||
|
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
||||||
|
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
||||||
|
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
||||||
|
// ending, and "потихоньку" doesn't start with the stem at all.
|
||||||
|
var quietInflections = []string{
|
||||||
|
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
||||||
|
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
||||||
|
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietStem reports whether tok is the given stem carrying at most one
|
||||||
|
// inflectional ending. Word boundaries come from tokenisation (see
|
||||||
|
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
||||||
|
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
||||||
|
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
||||||
|
func quietStem(tok, stem string) bool {
|
||||||
|
if !strings.HasPrefix(tok, stem) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suffix := tok[len(stem):]
|
||||||
|
for _, e := range quietInflections {
|
||||||
|
if suffix == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietTokens splits an utterance into lowercase word tokens, dropping
|
||||||
|
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
|
||||||
|
// way ASCII ones do.
|
||||||
|
func quietTokens(text string) []string {
|
||||||
|
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietPhrase matches a pattern (a sequence of stems) against the token list.
|
||||||
|
// Multi-word patterns match any contiguous run of tokens — "включи тихий
|
||||||
|
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
|
||||||
|
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
|
||||||
|
// remark about the room and must not flip a daemon-wide setting.
|
||||||
|
func quietPhrase(tokens, pattern []string) bool {
|
||||||
|
if len(pattern) == 0 || len(tokens) < len(pattern) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(pattern) == 1 {
|
||||||
|
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
|
||||||
|
}
|
||||||
|
for i := 0; i+len(pattern) <= len(tokens); i++ {
|
||||||
|
hit := true
|
||||||
|
for j, stem := range pattern {
|
||||||
|
if !quietStem(tokens[i+j], stem) {
|
||||||
|
hit = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||||
|
var (
|
||||||
|
quietOffPhrases = [][]string{
|
||||||
|
{"quiet", "off"}, {"quiet", "end"},
|
||||||
|
{"громк", "режим"}, {"шумн", "режим"},
|
||||||
|
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||||
|
}
|
||||||
|
quietOnPhrases = [][]string{
|
||||||
|
{"quiet", "on"}, {"quiet", "mode"},
|
||||||
|
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||||
|
{"тих"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||||
|
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||||
|
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||||
|
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||||
|
// тихий режим" would turn quiet mode on. Negation wins.
|
||||||
|
func classifyQuietToggle(text string) (on, off bool) {
|
||||||
|
tokens := quietTokens(text)
|
||||||
|
for _, p := range quietOffPhrases {
|
||||||
|
if quietPhrase(tokens, p) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range quietOnPhrases {
|
||||||
|
if quietPhrase(tokens, p) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
// mavend/simulator_test.go — the replayable full-system simulator
|
||||||
|
// (Vikunja #284, 20-07-2026-BACKLOG.md item 7).
|
||||||
|
//
|
||||||
|
// # What it is
|
||||||
|
//
|
||||||
|
// A scripted day, replayed through the real mavend code paths, with every
|
||||||
|
// boundary faked and the clock under the scenario's control. A scenario is a
|
||||||
|
// JSON file in testdata/scenarios; the harness reads it, builds a world, walks
|
||||||
|
// the steps in order, and asserts on what actually happened:
|
||||||
|
//
|
||||||
|
// what Maven SAID — the reply text of every utterance
|
||||||
|
// what was SENT — every delivery.Sendable the dispatcher emitted
|
||||||
|
// what ARRIVED — the unified intake journal from #283
|
||||||
|
// what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis
|
||||||
|
// what did NOT happen — expect_no_send / expect_no_call, first-class
|
||||||
|
//
|
||||||
|
// The last one is the point. Maven's hard constraints are mostly negative —
|
||||||
|
// not a nag, not autonomous, nothing executed without confirmation — and a
|
||||||
|
// harness that can only assert on things that happened cannot test any of
|
||||||
|
// them. "Nothing was sent" is an assertion here, not an absence of one.
|
||||||
|
//
|
||||||
|
// # Determinism
|
||||||
|
//
|
||||||
|
// No time.Now() runs inside a replay. The scenario names a start instant, each
|
||||||
|
// step names a wall-clock offset from it, and the harness advances a fakeClock
|
||||||
|
// to that offset before running the step. Every clock reader in the world —
|
||||||
|
// the handler's `now`, the tick loop's `tick(ctx, now)`, the intake journal's
|
||||||
|
// publish stamp — is wired to that clock. Two runs of the same file produce
|
||||||
|
// the same transcript, and a scenario about 08:35 does not behave differently
|
||||||
|
// at 03:00 in CI.
|
||||||
|
//
|
||||||
|
// The tick is driven by the scenario, not by a ticker: tick() already takes
|
||||||
|
// `now` as an argument, so the only thing the daemon's ticker contributed was
|
||||||
|
// wall-clock timing, which is exactly what a replay must not have.
|
||||||
|
//
|
||||||
|
// # Why this shape and not a binary
|
||||||
|
//
|
||||||
|
// Vikunja #288 (golden-audio STT) deferred its tier-2 "audio → STT → router →
|
||||||
|
// phraser" scenarios to this task, and asked that they reuse a fixture format
|
||||||
|
// rather than inventing a third. A scenario here can name a WAV from
|
||||||
|
// cmd/mavsttd/testdata and the harness will feed it through the STT seam. As a
|
||||||
|
// test it runs under `make test` on every change, which a separate binary
|
||||||
|
// would not.
|
||||||
|
//
|
||||||
|
// # Production is untouched
|
||||||
|
//
|
||||||
|
// Every file this task adds is a _test.go file or testdata. There is no
|
||||||
|
// simulator in the daemon, no flag, no config key, and no code path that
|
||||||
|
// checks whether a simulation is running. The seams it uses — stt.Transcriber,
|
||||||
|
// tts.Synthesizer, router.Completer, delivery.Sink, ipc.CoreAPI, the
|
||||||
|
// event.Bus from #283 — all already existed for the production wiring.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario format
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// scenario — one scripted day. schema_version matches the convention already
|
||||||
|
// set by testdata/system_safety_scenarios.json.
|
||||||
|
type scenario struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
|
||||||
|
// Start — the instant the day begins, RFC3339. Every step offset is
|
||||||
|
// relative to it, and nothing in the run reads a real clock.
|
||||||
|
Start string `json:"start"`
|
||||||
|
|
||||||
|
// Script — what the resident model answers. The world has no llama-server;
|
||||||
|
// see scriptedLLM for how an entry is chosen.
|
||||||
|
Script []scriptEntry `json:"script,omitempty"`
|
||||||
|
|
||||||
|
// Praxis / Nexus / Hexis — canned bodies for the ecosystem fakes. Absent ⇒
|
||||||
|
// that service is not wired at all, which is the default box.
|
||||||
|
Praxis string `json:"praxis_attention,omitempty"`
|
||||||
|
Nexus string `json:"nexus_resolve,omitempty"`
|
||||||
|
Hexis string `json:"hexis_capabilities,omitempty"`
|
||||||
|
|
||||||
|
Steps []step `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptEntry — one canned model answer. Match is a substring of the user
|
||||||
|
// message; the first entry whose Match is contained in it wins, and an entry
|
||||||
|
// with an empty Match is the catch-all.
|
||||||
|
//
|
||||||
|
// Route and Reply are separate because the same model serves both contracts
|
||||||
|
// (CLAUDE.md, "LLM output contract"): a grammar-constrained call is a routing
|
||||||
|
// call and gets Route, an unconstrained one is a phrasing call and gets Reply.
|
||||||
|
type scriptEntry struct {
|
||||||
|
Match string `json:"match"`
|
||||||
|
Route string `json:"route,omitempty"`
|
||||||
|
Reply string `json:"reply,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// step — one scripted moment. At is "HH:MM" or "HH:MM:SS", interpreted in the
|
||||||
|
// start instant's location; the clock is advanced to it before the step runs.
|
||||||
|
//
|
||||||
|
// A step does exactly one thing (say / audio / signal / fact / tick / arrive)
|
||||||
|
// and then asserts. Assertions are evaluated against everything recorded since
|
||||||
|
// the run began, except expect_no_send and expect_no_call, which are scoped to
|
||||||
|
// this step — "nothing was sent because of THIS" is the useful question.
|
||||||
|
type step struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
|
||||||
|
// --- stimuli (at most one per step) ---
|
||||||
|
|
||||||
|
// Say — an utterance, as text, through the same runTurn the IPC chat path
|
||||||
|
// uses.
|
||||||
|
Say string `json:"say,omitempty"`
|
||||||
|
|
||||||
|
// Audio — a WAV under cmd/mavsttd/testdata, fed through the STT seam. This
|
||||||
|
// is #288's deferred tier 2. The harness uses the deterministic stt stub
|
||||||
|
// unless a real transcriber is available, so the assertion a scenario can
|
||||||
|
// make about an audio step is about the PIPELINE, not about whisper's
|
||||||
|
// accuracy — that is what cmd/mavsttd/golden_test.go is for.
|
||||||
|
Audio string `json:"audio,omitempty"`
|
||||||
|
|
||||||
|
// Signal — a presence/world fact arriving from a poller or /api/signal.
|
||||||
|
Signal *signalStep `json:"signal,omitempty"`
|
||||||
|
|
||||||
|
// Arrive — an intake write from a module: an ambient notification, a feed
|
||||||
|
// item, a mail candidate. Goes through the same decorated ipc.CoreAPI the
|
||||||
|
// daemon gives those callers, so it lands in the journal exactly as it
|
||||||
|
// would in production.
|
||||||
|
Arrive *arriveStep `json:"arrive,omitempty"`
|
||||||
|
|
||||||
|
// Tick — run one iteration of the proactive loop at this instant.
|
||||||
|
Tick bool `json:"tick,omitempty"`
|
||||||
|
|
||||||
|
// Fault — make every ecosystem fake answer with this HTTP status from now
|
||||||
|
// on. The degraded-mode lever; ClearFault puts them back.
|
||||||
|
Fault int `json:"fault,omitempty"`
|
||||||
|
ClearFault bool `json:"clear_fault,omitempty"`
|
||||||
|
|
||||||
|
// --- assertions ---
|
||||||
|
|
||||||
|
ExpectReply []string `json:"expect_reply_contains,omitempty"`
|
||||||
|
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
|
||||||
|
ExpectSent []string `json:"expect_sent_contains,omitempty"`
|
||||||
|
ExpectNoSend bool `json:"expect_no_send,omitempty"`
|
||||||
|
ExpectCalled []string `json:"expect_called,omitempty"`
|
||||||
|
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||||
|
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||||
|
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signalStep struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arriveStep struct {
|
||||||
|
// Note / Fact / Task — exactly one. Each mirrors the intake seam its real
|
||||||
|
// caller uses.
|
||||||
|
Note *arriveNote `json:"note,omitempty"`
|
||||||
|
Fact *signalStep `json:"fact,omitempty"`
|
||||||
|
Task *arriveTask `json:"task,omitempty"`
|
||||||
|
AsOf string `json:"as_of,omitempty"` // "HH:MM" — OccurredAt, when it differs from the step time
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arriveNote struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arriveTask struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Evidence string `json:"evidence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The world
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// simWorld — every faked boundary plus the real components between them.
|
||||||
|
type simWorld struct {
|
||||||
|
t *testing.T
|
||||||
|
clock *fakeClock
|
||||||
|
loc *time.Location
|
||||||
|
start time.Time
|
||||||
|
|
||||||
|
store *store.Store
|
||||||
|
api ipc.CoreAPI // the intake-decorated adapter, same as the daemon builds
|
||||||
|
bus *event.Bus
|
||||||
|
handler *reactiveHandler
|
||||||
|
tick *tickLoop
|
||||||
|
sink *recordingSink
|
||||||
|
llm *scriptedLLM
|
||||||
|
|
||||||
|
praxis *fakeServer
|
||||||
|
nexus *fakeServer
|
||||||
|
hexis *fakeServer
|
||||||
|
|
||||||
|
// transcript — everything that happened, in order. Printed on failure so a
|
||||||
|
// broken scenario is diagnosable without a debugger.
|
||||||
|
transcript []string
|
||||||
|
replies []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordingSink captures every send, mutex-guarded (the tick loop dispatches
|
||||||
|
// from its own goroutine in production and the race detector is on here).
|
||||||
|
type recordingSink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sends []delivery.Sendable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingSink) Send(_ context.Context, d delivery.Sendable) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sends = append(s.sends, d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingSink) all() []delivery.Sendable {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]delivery.Sendable, len(s.sends))
|
||||||
|
copy(out, s.sends)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingSink) count() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.sends)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptedLLM stands in for llama-server on BOTH contracts the resident model
|
||||||
|
// serves: grammar-constrained routing and unconstrained phrasing.
|
||||||
|
//
|
||||||
|
// It is not a stub that ignores its input — a scenario that scripts an answer
|
||||||
|
// for "что я пропустил" and gets asked something else must fail, not silently
|
||||||
|
// return the wrong intent. An unmatched call returns an error, and the router
|
||||||
|
// then falls through to the classifier cascade exactly as it does in
|
||||||
|
// production when llama-server is unreachable. That fall-through is itself
|
||||||
|
// worth exercising: it is the failure floor CLAUDE.md refuses to let rot.
|
||||||
|
type scriptedLLM struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries []scriptEntry
|
||||||
|
calls []llm.Req
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.calls = append(s.calls, r)
|
||||||
|
routing := r.Grammar != ""
|
||||||
|
for _, e := range s.entries {
|
||||||
|
if e.Match != "" && !strings.Contains(strings.ToLower(r.User), strings.ToLower(e.Match)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if routing && e.Route != "" {
|
||||||
|
return e.Route, nil
|
||||||
|
}
|
||||||
|
if !routing && e.Reply != "" {
|
||||||
|
return e.Reply, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("simulator: no scripted %s answer for %q",
|
||||||
|
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Building the world
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
start, err := time.Parse(time.RFC3339, sc.Start)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scenario %q: bad start %q: %v", sc.Name, sc.Start, err)
|
||||||
|
}
|
||||||
|
clock := newFakeClock(start)
|
||||||
|
|
||||||
|
st := newTestStore(t)
|
||||||
|
bus := event.NewBus(512)
|
||||||
|
// The same decorator the daemon wires, on the same clock: intake in a
|
||||||
|
// replay is journalled exactly as it is in production.
|
||||||
|
api := newIntakeAPI(ipc.NewStoreAPI(st), bus, clock.Now)
|
||||||
|
|
||||||
|
sink := &recordingSink{}
|
||||||
|
rules := loop.DefaultRules()
|
||||||
|
gatherer := loop.NewGatherer(st, rules)
|
||||||
|
dispatcher := delivery.NewDispatcher(delivery.Config{
|
||||||
|
Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st,
|
||||||
|
})
|
||||||
|
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
|
||||||
|
time.Minute, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
scripted := &scriptedLLM{entries: sc.Script}
|
||||||
|
|
||||||
|
w := &simWorld{
|
||||||
|
t: t, clock: clock, loc: start.Location(), start: start,
|
||||||
|
store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ecosystem fakes, wired only when the scenario supplies a body — a box
|
||||||
|
// with no praxis block has no praxis client, and a scenario must be able to
|
||||||
|
// reproduce that.
|
||||||
|
eco := &ecosystemWiring{}
|
||||||
|
if sc.Praxis != "" {
|
||||||
|
w.praxis = newFakePraxis(t, sc.Praxis)
|
||||||
|
eco.praxis = newPraxisClient(w.praxis.URL)
|
||||||
|
}
|
||||||
|
if sc.Nexus != "" {
|
||||||
|
w.nexus = newFakeNexus(t, sc.Nexus)
|
||||||
|
}
|
||||||
|
if sc.Hexis != "" {
|
||||||
|
w.hexis = newFakeHexis(t, sc.Hexis, fixtureHexisExecuted("exec_1", "completed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The router: the same cascade the daemon builds — stage-0 grammars, the
|
||||||
|
// LLM router on the scripted model, the classifier underneath. Keeping the
|
||||||
|
// classifier in is deliberate; it is the failure floor, and a scenario that
|
||||||
|
// scripts no route for an utterance exercises it.
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
matcher := tool.NewMatcher(nil)
|
||||||
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||||
|
|
||||||
|
w.handler = &reactiveHandler{
|
||||||
|
stt: simTranscriber{},
|
||||||
|
tts: simSynthesizer{},
|
||||||
|
router: rtr,
|
||||||
|
embedder: emb,
|
||||||
|
api: api,
|
||||||
|
matcher: matcher,
|
||||||
|
phraser: phraser.NewStub(),
|
||||||
|
replier: newLLMReplier(scripted, nil),
|
||||||
|
now: clock.Now,
|
||||||
|
memStore: st.VectorMemory(),
|
||||||
|
dataStore: st,
|
||||||
|
queryMinScore: config.DefaultQueryMinScore,
|
||||||
|
queryMinMargin: config.DefaultQueryMinMargin,
|
||||||
|
timeParser: router.StubDateTimeParser{},
|
||||||
|
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||||
|
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||||
|
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
|
||||||
|
ecosystem: eco,
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// simTranscriber — the STT seam. Deterministic by construction: it returns the
|
||||||
|
// text the harness parked for this step, so the pipeline under test is
|
||||||
|
// "audio arrives → a turn runs", not "whisper heard correctly". Transcription
|
||||||
|
// accuracy is cmd/mavsttd/golden_test.go's job (#288 tier 1), and duplicating
|
||||||
|
// it here would make every scenario depend on a 500 MB model.
|
||||||
|
type simTranscriber struct{ text string }
|
||||||
|
|
||||||
|
func (s simTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
||||||
|
return s.text, 1.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// simSynthesizer — the TTS seam. A scenario asserts on what Maven SAID, which
|
||||||
|
// is the reply text; the waveform is not the artefact under test.
|
||||||
|
type simSynthesizer struct{}
|
||||||
|
|
||||||
|
func (simSynthesizer) Synthesize(_ context.Context, _ string) (audio.Audio, error) {
|
||||||
|
return audio.Audio{Format: audio.PCM16kMono}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Running
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (w *simWorld) logf(format string, args ...any) {
|
||||||
|
w.transcript = append(w.transcript,
|
||||||
|
fmt.Sprintf("%s %s", w.clock.Now().In(w.loc).Format("15:04:05"), fmt.Sprintf(format, args...)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dump prints the whole transcript. Called on any failure — a scenario that
|
||||||
|
// broke on step 7 is unreadable without the six steps before it.
|
||||||
|
func (w *simWorld) dump() {
|
||||||
|
w.t.Logf("--- replay transcript ---\n%s", strings.Join(w.transcript, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceTo moves the clock to the step's offset. Time only ever moves
|
||||||
|
// FORWARD: a scenario with steps out of order is a bug in the scenario, and
|
||||||
|
// silently reordering it would hide the bug.
|
||||||
|
func (w *simWorld) advanceTo(at string) {
|
||||||
|
w.t.Helper()
|
||||||
|
if at == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := w.timeOf(at)
|
||||||
|
now := w.clock.Now()
|
||||||
|
if target.Before(now) {
|
||||||
|
w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order",
|
||||||
|
at, now.In(w.loc).Format("15:04:05"))
|
||||||
|
}
|
||||||
|
w.clock.Advance(target.Sub(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeOf resolves an "HH:MM" or "HH:MM:SS" step offset against the scenario's
|
||||||
|
// start day and location.
|
||||||
|
func (w *simWorld) timeOf(at string) time.Time {
|
||||||
|
w.t.Helper()
|
||||||
|
layout := "15:04"
|
||||||
|
if strings.Count(at, ":") == 2 {
|
||||||
|
layout = "15:04:05"
|
||||||
|
}
|
||||||
|
hm, err := time.Parse(layout, at)
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatalf("bad step time %q: %v", at, err)
|
||||||
|
}
|
||||||
|
return time.Date(w.start.Year(), w.start.Month(), w.start.Day(),
|
||||||
|
hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) run(sc scenario) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for i, s := range sc.Steps {
|
||||||
|
w.advanceTo(s.At)
|
||||||
|
if s.Note != "" {
|
||||||
|
w.logf("# %s", s.Note)
|
||||||
|
}
|
||||||
|
sendsBefore := w.sink.count()
|
||||||
|
callsBefore := w.callCount()
|
||||||
|
eventsBefore := w.bus.Len()
|
||||||
|
|
||||||
|
w.stimulate(ctx, s)
|
||||||
|
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) stimulate(ctx context.Context, s step) {
|
||||||
|
if s.Fault != 0 || s.ClearFault {
|
||||||
|
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||||
|
if fs != nil {
|
||||||
|
fs.SetFault(s.Fault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.logf("fault=%d on every ecosystem fake", s.Fault)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s.Say != "":
|
||||||
|
reply := w.handler.runTurn(ctx, s.Say)
|
||||||
|
w.replies = append(w.replies, reply)
|
||||||
|
w.logf("он: %s", s.Say)
|
||||||
|
w.logf("она: %s", reply)
|
||||||
|
|
||||||
|
case s.Audio != "":
|
||||||
|
text := w.audioText(s.Audio)
|
||||||
|
// Swap in a transcriber parked with this step's text, then run the same
|
||||||
|
// push-to-talk entry point the voice client calls.
|
||||||
|
w.handler.stt = simTranscriber{text: text}
|
||||||
|
resp, err := w.handler.HandlePushToTalk(ctx, voicePTT(), 0)
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatalf("push-to-talk on %s: %v", s.Audio, err)
|
||||||
|
}
|
||||||
|
w.replies = append(w.replies, resp.ReplyText)
|
||||||
|
w.logf("[wav %s → %q]", filepath.Base(s.Audio), text)
|
||||||
|
w.logf("она: %s", resp.ReplyText)
|
||||||
|
|
||||||
|
case s.Signal != nil:
|
||||||
|
w.write(ctx, *s.Signal, w.clock.Now())
|
||||||
|
w.logf("сигнал: %s=%s (%s)", s.Signal.Key, s.Signal.Value, s.Signal.Source)
|
||||||
|
|
||||||
|
case s.Arrive != nil:
|
||||||
|
w.arrive(ctx, *s.Arrive)
|
||||||
|
|
||||||
|
case s.Tick:
|
||||||
|
w.tick.tick(ctx, w.clock.Now())
|
||||||
|
w.logf("tick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) {
|
||||||
|
w.t.Helper()
|
||||||
|
kind := sig.Kind
|
||||||
|
if kind == "" {
|
||||||
|
kind = "env"
|
||||||
|
}
|
||||||
|
if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0,
|
||||||
|
}); err != nil {
|
||||||
|
w.t.Fatalf("write fact %s: %v", sig.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) arrive(ctx context.Context, a arriveStep) {
|
||||||
|
w.t.Helper()
|
||||||
|
// AsOf is when the thing HAPPENED, which for a feed item or a relayed
|
||||||
|
// notification is usually earlier than when Maven heard about it. It does
|
||||||
|
// not move the clock — only the timestamp on the row and the envelope.
|
||||||
|
ts := w.clock.Now()
|
||||||
|
if a.AsOf != "" {
|
||||||
|
ts = w.timeOf(a.AsOf)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case a.Fact != nil:
|
||||||
|
f := *a.Fact
|
||||||
|
if f.Source == "" {
|
||||||
|
f.Source = a.Source
|
||||||
|
}
|
||||||
|
w.write(ctx, f, ts)
|
||||||
|
w.logf("пришло: факт %s=%s (%s)", f.Key, f.Value, f.Source)
|
||||||
|
case a.Note != nil:
|
||||||
|
if _, err := w.api.WriteNote(ctx, ts, a.Note.Text, nil, a.Source); err != nil {
|
||||||
|
w.t.Fatalf("write note from %s: %v", a.Source, err)
|
||||||
|
}
|
||||||
|
w.logf("пришло: заметка от %s — %s", a.Source, truncateRunes(a.Note.Text, 60))
|
||||||
|
case a.Task != nil:
|
||||||
|
status := a.Task.Status
|
||||||
|
if status == "" {
|
||||||
|
status = store.TaskCandidate
|
||||||
|
}
|
||||||
|
if _, err := w.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||||
|
Text: a.Task.Text, Source: a.Source, Evidence: a.Task.Evidence, Status: status, Ts: ts,
|
||||||
|
}); err != nil {
|
||||||
|
w.t.Fatalf("capture task from %s: %v", a.Source, err)
|
||||||
|
}
|
||||||
|
w.logf("пришло: задача от %s — %s", a.Source, a.Task.Text)
|
||||||
|
default:
|
||||||
|
w.t.Fatalf("arrive step from %s carries nothing", a.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioText resolves a scenario's WAV reference to the text the fixture is
|
||||||
|
// known to contain, by reading cmd/mavsttd's golden manifest (#288's format,
|
||||||
|
// reused rather than duplicated). An unknown reference fails the scenario
|
||||||
|
// rather than quietly transcribing to "".
|
||||||
|
func (w *simWorld) audioText(ref string) string {
|
||||||
|
w.t.Helper()
|
||||||
|
manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json")
|
||||||
|
raw, err := os.ReadFile(manifest)
|
||||||
|
if err != nil {
|
||||||
|
w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Cases []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
WAV string `json:"wav"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
|
||||||
|
}
|
||||||
|
for _, c := range m.Cases {
|
||||||
|
if c.Name == ref || c.WAV == ref {
|
||||||
|
return c.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.t.Fatalf("audio step %q: no such case in %s", ref, manifest)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func voicePTT() voice.PushToTalkReq {
|
||||||
|
return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callCount — how many requests every wired ecosystem fake has seen.
|
||||||
|
func (w *simWorld) callCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||||
|
if fs != nil {
|
||||||
|
n += len(fs.Requests())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *simWorld) callPaths() []string {
|
||||||
|
var out []string
|
||||||
|
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
|
||||||
|
if fs == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range fs.Requests() {
|
||||||
|
out = append(out, r.Method+" "+r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assertions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) {
|
||||||
|
w.t.Helper()
|
||||||
|
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
||||||
|
if s.Note != "" {
|
||||||
|
where += " " + s.Note
|
||||||
|
}
|
||||||
|
fail := func(format string, args ...any) {
|
||||||
|
w.dump()
|
||||||
|
w.t.Errorf("%s: %s", where, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReply := ""
|
||||||
|
if len(w.replies) > 0 {
|
||||||
|
lastReply = w.replies[len(w.replies)-1]
|
||||||
|
}
|
||||||
|
for _, want := range s.ExpectReply {
|
||||||
|
if !containsFold(lastReply, want) {
|
||||||
|
fail("reply %q does not contain %q", lastReply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range s.ExpectNotReply {
|
||||||
|
if containsFold(lastReply, unwanted) {
|
||||||
|
fail("reply %q contains %q and must not", lastReply, unwanted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := w.sink.all()
|
||||||
|
for _, want := range s.ExpectSent {
|
||||||
|
if !anyContains(sendableTexts(sent), want) {
|
||||||
|
fail("nothing sent mentions %q; sent so far: %v", want, sendableTexts(sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scoped to this step on purpose: "nothing was sent BECAUSE OF THIS" is the
|
||||||
|
// question a not-a-nag constraint asks.
|
||||||
|
if s.ExpectNoSend && len(sent) > sendsBefore {
|
||||||
|
fail("expected nothing to be sent, got %v", sendableTexts(sent[sendsBefore:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := w.callPaths()
|
||||||
|
for _, want := range s.ExpectCalled {
|
||||||
|
if !anyContains(paths, want) {
|
||||||
|
fail("no ecosystem call matches %q; calls so far: %v", want, paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range s.ExpectNotCalled {
|
||||||
|
if anyContains(paths[callsBefore:], unwanted) {
|
||||||
|
fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evs := w.bus.Recent(0)
|
||||||
|
for _, want := range s.ExpectEvents {
|
||||||
|
if !anyContains(eventLines(evs), want) {
|
||||||
|
fail("no intake event matches %q; journal: %v", want, eventLines(evs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.ExpectNoEvents && w.bus.Len() > eventsBefore {
|
||||||
|
fail("expected nothing to arrive, journal grew to %d", w.bus.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendableTexts(sends []delivery.Sendable) []string {
|
||||||
|
out := make([]string, 0, len(sends))
|
||||||
|
for _, s := range sends {
|
||||||
|
out = append(out, fmt.Sprintf("[%s] %s", s.RuleName, s.Body))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventLines(evs []event.Event) []string {
|
||||||
|
out := make([]string, 0, len(evs))
|
||||||
|
for _, e := range evs {
|
||||||
|
out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(hay, needle string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(hay), strings.ToLower(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyContains(hay []string, needle string) bool {
|
||||||
|
for _, h := range hay {
|
||||||
|
if containsFold(h, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const scenarioDir = "testdata/scenarios"
|
||||||
|
|
||||||
|
// TestSimulatorScenarios replays every scenario file. Adding a scenario is
|
||||||
|
// adding a JSON file — no Go change, which is the property that makes this
|
||||||
|
// cheap enough to actually use.
|
||||||
|
func TestSimulatorScenarios(t *testing.T) {
|
||||||
|
entries, err := os.ReadDir(scenarioDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", scenarioDir, err)
|
||||||
|
}
|
||||||
|
var ran int
|
||||||
|
for _, ent := range entries {
|
||||||
|
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ran++
|
||||||
|
name := strings.TrimSuffix(ent.Name(), ".json")
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
sc := loadScenario(t, filepath.Join(scenarioDir, ent.Name()))
|
||||||
|
w := newSimWorld(t, sc)
|
||||||
|
w.run(sc)
|
||||||
|
if testing.Verbose() {
|
||||||
|
w.dump()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ran == 0 {
|
||||||
|
t.Fatalf("no scenarios in %s — the harness would pass vacuously", scenarioDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadScenario(t *testing.T, path string) scenario {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var sc scenario
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.DisallowUnknownFields() // a typo'd assertion key must fail, not be ignored
|
||||||
|
if err := dec.Decode(&sc); err != nil {
|
||||||
|
t.Fatalf("parsing %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if sc.SchemaVersion != 1 {
|
||||||
|
t.Fatalf("%s: schema_version = %d, want 1", path, sc.SchemaVersion)
|
||||||
|
}
|
||||||
|
if sc.Name == "" || sc.Start == "" || len(sc.Steps) == 0 {
|
||||||
|
t.Fatalf("%s: a scenario needs a name, a start and at least one step", path)
|
||||||
|
}
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSimulatorIsDeterministic replays one scenario twice and requires an
|
||||||
|
// identical transcript. This is the property the whole task rests on: if a
|
||||||
|
// time.Now() creeps into a replayed path, two runs diverge and this fails.
|
||||||
|
func TestSimulatorIsDeterministic(t *testing.T) {
|
||||||
|
path := filepath.Join(scenarioDir, "morning_missed.json")
|
||||||
|
sc := loadScenario(t, path)
|
||||||
|
|
||||||
|
transcriptOf := func() string {
|
||||||
|
w := newSimWorld(t, sc)
|
||||||
|
w.run(sc)
|
||||||
|
return strings.Join(w.transcript, "\n")
|
||||||
|
}
|
||||||
|
first := transcriptOf()
|
||||||
|
second := transcriptOf()
|
||||||
|
if first != second {
|
||||||
|
t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second)
|
||||||
|
}
|
||||||
|
// And the transcript's own timestamps must be the scenario's, not today's.
|
||||||
|
if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) {
|
||||||
|
t.Error("transcript carries the wall clock — something in the replay path read time.Now()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake
|
||||||
|
// that would silently produce a meaningless run.
|
||||||
|
func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
||||||
|
// Not table-driven through run() because advanceTo calls t.Fatalf; this
|
||||||
|
// checks the ordering arithmetic directly.
|
||||||
|
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
|
||||||
|
Steps: []step{{At: "09:00"}}}
|
||||||
|
w := newSimWorld(t, sc)
|
||||||
|
w.advanceTo("09:00")
|
||||||
|
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
|
||||||
|
t.Fatalf("clock at %s after advancing to 09:00", got)
|
||||||
|
}
|
||||||
|
w.advanceTo("09:30")
|
||||||
|
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" {
|
||||||
|
t.Fatalf("clock at %s after advancing to 09:30", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/smarthome"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// homeWiring — the Home Assistant client, when the `smarthome` block is present
|
||||||
|
// AND enabled. nil ⇒ the house is not wired, nothing was proposed, and an
|
||||||
|
// allowlist row that happens to look like a house row refuses to run.
|
||||||
|
//
|
||||||
|
// It lives on the voice wiring for the same reason MCP does: a house control IS
|
||||||
|
// an act. It goes through tool.Executor, the enabled allowlist and the confirm
|
||||||
|
// turn, all of which only exist on the voice/chat path.
|
||||||
|
type homeWiring struct {
|
||||||
|
client *smarthome.Client
|
||||||
|
st *store.Store
|
||||||
|
refresh time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireSmartHome builds the client and proposes what it found. It never fails
|
||||||
|
// the daemon: an instance that is down at boot is logged and retried, because
|
||||||
|
// Maven starting is not contingent on someone else's process.
|
||||||
|
func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
|
||||||
|
hc, ok := cfg.SmartHomeClient()
|
||||||
|
if !ok || st == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := smarthome.Validate(hc); err != nil {
|
||||||
|
// config.validate already ran this, so reaching here is a programming
|
||||||
|
// error rather than a config one. Still not fatal: the house off is a
|
||||||
|
// working Maven.
|
||||||
|
log.Printf("smarthome: not wired: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := &homeWiring{
|
||||||
|
client: smarthome.NewClient(hc),
|
||||||
|
st: st,
|
||||||
|
refresh: time.Duration(cfg.SmartHome.Refresh),
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
w.propose(ctx)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// caller is the tool.HomeCaller seam.
|
||||||
|
func (w *homeWiring) caller() *smarthome.Client {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.client
|
||||||
|
}
|
||||||
|
|
||||||
|
// propose writes a 'proposed' allowlist row for every controllable device. It
|
||||||
|
// does NOT enable anything: a reachable house is a place Maven may look, not a
|
||||||
|
// set of switches she may flip. Kami enables what he wants on /tools, behind
|
||||||
|
// step-up, which is the same gate a shell tool goes through.
|
||||||
|
//
|
||||||
|
// Sensors are read but never proposed — there is nothing to call on them.
|
||||||
|
func (w *homeWiring) propose(ctx context.Context) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ents, err := w.client.States(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smarthome: read states: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fresh, devices := 0, 0
|
||||||
|
for _, e := range ents {
|
||||||
|
svcs := smarthome.Services(e.Domain)
|
||||||
|
if len(svcs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices++
|
||||||
|
for _, s := range svcs {
|
||||||
|
name := smarthome.LocalName(e.ID, s.Verb)
|
||||||
|
provenance := "дом: " + s.Name + " → " + e.Name + " (" + e.ID + ")"
|
||||||
|
ok, err := w.st.ProposeSmartHomeTool(ctx, name, smarthome.Scope(e.Domain),
|
||||||
|
smarthome.Cmd(e.ID, s.Name), provenance, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smarthome: propose %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
fresh++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("smarthome: %d entities, %d controllable", len(ents), devices)
|
||||||
|
if fresh > 0 {
|
||||||
|
log.Printf("smarthome: %d new device proposal(s) waiting on /tools", fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run re-enumerates the house and picks up devices that appeared, until ctx is
|
||||||
|
// canceled.
|
||||||
|
func (w *homeWiring) run(ctx context.Context) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iv := w.refresh
|
||||||
|
if iv <= 0 {
|
||||||
|
iv = config.DefaultSmartHomeRefresh
|
||||||
|
}
|
||||||
|
t := time.NewTicker(iv)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.propose(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeSummary answers "что дома?" — a read of the current entity states, one
|
||||||
|
// short line. Read-only: it can never call a service, so it needs no confirm
|
||||||
|
// and no allowlist row.
|
||||||
|
func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||||
|
if w == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
ents, err := w.client.States(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("smarthome: summary: %v", err)
|
||||||
|
return "не смогла достучаться до дома.", true
|
||||||
|
}
|
||||||
|
if len(ents) == 0 {
|
||||||
|
return "дом ничего не отдаёт.", true
|
||||||
|
}
|
||||||
|
var on []string
|
||||||
|
var sensors []string
|
||||||
|
for _, e := range ents {
|
||||||
|
switch {
|
||||||
|
case e.Domain == "sensor" || e.Domain == "binary_sensor":
|
||||||
|
if len(sensors) < 3 && e.State != "" && e.State != "unavailable" {
|
||||||
|
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
|
||||||
|
}
|
||||||
|
case e.State == "on" || e.State == "open" || e.State == "unlocked":
|
||||||
|
on = append(on, e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if len(on) > 0 {
|
||||||
|
if len(on) > 5 {
|
||||||
|
on = on[:5]
|
||||||
|
}
|
||||||
|
parts = append(parts, "включено: "+strings.Join(on, ", "))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "всё выключено")
|
||||||
|
}
|
||||||
|
if len(sensors) > 0 {
|
||||||
|
parts = append(parts, strings.Join(sensors, ", "))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ") + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHomeQuery recognises a question about the house, narrowly. "дома" on its
|
||||||
|
// own is not enough — "я дома" is a fact, not a question — so it takes a house
|
||||||
|
// marker AND an ask AND either a device word or the word "включ…". Weather
|
||||||
|
// wording bails out first: "какая температура на улице?" belongs to the weather
|
||||||
|
// source, and both questions contain "температура".
|
||||||
|
func isHomeQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range []string{"погод", "на улице", "прогноз"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, phrase := range []string{"что включено", "что выключено", "умный дом", "что в доме включено"} {
|
||||||
|
if strings.Contains(s, phrase) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
house := homeWord(s, "дома") || strings.Contains(s, "в доме") || strings.Contains(s, "в квартире")
|
||||||
|
if !house {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ask := strings.Contains(s, "?") || homeWord(s, "что") || homeWord(s, "какая") ||
|
||||||
|
homeWord(s, "какой") || homeWord(s, "сколько")
|
||||||
|
if !ask {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, w := range []string{"свет", "лампа", "лампы", "розетк", "датчик", "температур", "включ", "выключ"} {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeWord — whole-token membership, so "дома" does not fire on "домашний".
|
||||||
|
// Punctuation is trimmed off each token because a spoken question arrives with
|
||||||
|
// a question mark glued to the last word.
|
||||||
|
func homeWord(s, w string) bool {
|
||||||
|
for _, tok := range strings.Fields(s) {
|
||||||
|
if strings.Trim(tok, ".,!?;:") == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const haStatesFixture = `[
|
||||||
|
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
|
||||||
|
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
|
||||||
|
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
|
||||||
|
]`
|
||||||
|
|
||||||
|
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
for name, cfg := range map[string]*config.Config{
|
||||||
|
"no block": {},
|
||||||
|
"written but dark": {SmartHome: &config.SmartHomeConfig{
|
||||||
|
URL: "http://ha.lan:8123", Token: "t",
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if w := wireSmartHome(cfg, st); w != nil {
|
||||||
|
t.Fatal("the house must be off unless the block is enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// nil wiring must be safe everywhere it is reachable.
|
||||||
|
var w *homeWiring
|
||||||
|
w.propose(context.Background())
|
||||||
|
w.run(context.Background())
|
||||||
|
if w.caller() != nil {
|
||||||
|
t.Fatal("a nil wiring must have no caller")
|
||||||
|
}
|
||||||
|
if _, ok := w.homeSummary(context.Background()); ok {
|
||||||
|
t.Fatal("a nil wiring must not claim a query")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unreachable instance must not stop the daemon and must propose nothing.
|
||||||
|
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||||
|
// Port 1 on loopback: nothing listens, and it fails fast.
|
||||||
|
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
|
||||||
|
}}, st)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("a configured house should still wire")
|
||||||
|
}
|
||||||
|
tools, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tools) != 0 {
|
||||||
|
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery proposes one row per controllable service, always destructive,
|
||||||
|
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
|
||||||
|
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(haStatesFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
st := newTestStore(t)
|
||||||
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||||
|
URL: srv.URL, Token: "t", Enabled: true,
|
||||||
|
}}, st)
|
||||||
|
if w == nil {
|
||||||
|
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, tl := range tools {
|
||||||
|
got[tl.Name] = true
|
||||||
|
if tl.Status != "proposed" {
|
||||||
|
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
|
||||||
|
}
|
||||||
|
if !tl.Destructive {
|
||||||
|
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
|
||||||
|
}
|
||||||
|
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
|
||||||
|
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"home_light_living_room_on", "home_light_living_room_off",
|
||||||
|
"home_switch_kettle_on", "home_switch_kettle_off",
|
||||||
|
} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("missing proposal %q (have %v)", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tools) != 4 {
|
||||||
|
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second pass must be idempotent: re-discovery duplicates nothing and
|
||||||
|
// never rewrites a row Kami already enabled.
|
||||||
|
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
|
||||||
|
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.propose(context.Background())
|
||||||
|
again, err := st.ListTools(context.Background(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(again) != 4 {
|
||||||
|
t.Fatalf("re-discovery duplicated rows: %d", len(again))
|
||||||
|
}
|
||||||
|
for _, tl := range again {
|
||||||
|
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
|
||||||
|
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeSummaryReadsState(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(haStatesFixture))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||||
|
URL: srv.URL, Token: "t", Enabled: true,
|
||||||
|
}}, newTestStore(t))
|
||||||
|
out, ok := w.homeSummary(context.Background())
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("summary did not claim the turn")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Гостиная") {
|
||||||
|
t.Errorf("the lamp that is on should be named: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "Чайник") {
|
||||||
|
t.Errorf("a device that is off should not be listed as on: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "22.5") {
|
||||||
|
t.Errorf("the sensor reading should be there: %q", out)
|
||||||
|
}
|
||||||
|
// Persona: no masculine self-reference, no "вы", no pet names.
|
||||||
|
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||||
|
if strings.Contains(strings.ToLower(out), bad) {
|
||||||
|
t.Errorf("persona violation %q in %q", bad, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHomeQuery(t *testing.T) {
|
||||||
|
yes := []string{
|
||||||
|
"что включено дома?",
|
||||||
|
"что выключено",
|
||||||
|
"какой свет горит дома",
|
||||||
|
"свет в доме включен?",
|
||||||
|
"какая температура в квартире?",
|
||||||
|
"покажи умный дом",
|
||||||
|
}
|
||||||
|
no := []string{
|
||||||
|
"",
|
||||||
|
"я дома",
|
||||||
|
"буду дома в семь",
|
||||||
|
"какая погода дома", // weather wording wins
|
||||||
|
"какая температура на улице?",
|
||||||
|
"домашние дела", // "дома" must not fire on "домашние"
|
||||||
|
"что мне нужно сделать?",
|
||||||
|
"напомни выключить чайник в семь", // a reminder, not a house read
|
||||||
|
}
|
||||||
|
for _, u := range yes {
|
||||||
|
if !isHomeQuery(u) {
|
||||||
|
t.Errorf("isHomeQuery(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range no {
|
||||||
|
if isHomeQuery(u) {
|
||||||
|
t.Errorf("isHomeQuery(%q) = true, want false", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// mavend/speaker.go — core's half of voice identification (Vikunja #255,
|
||||||
|
// docs/plans/10-speaker-recognition.md).
|
||||||
|
//
|
||||||
|
// # What is actually wired here, and what is not
|
||||||
|
//
|
||||||
|
// The enrolment plumbing is real: profiles are stored, listed and deleted, and
|
||||||
|
// the wire methods exist as soon as a speaker block is configured. The
|
||||||
|
// recognising half is NOT, and cannot be on this box, because there is no
|
||||||
|
// speaker-embedding model on disk — no ECAPA, no x-vector, no titanet, no
|
||||||
|
// wespeaker, nothing in /mnt/hdd1/llms but text ggufs. Until one is downloaded,
|
||||||
|
// newSpeakerEmbedder returns nil, internal/speaker falls back to
|
||||||
|
// speaker.Disabled, and every Identify answers ErrDisabled. The daemon logs
|
||||||
|
// which half is off at startup rather than pretending.
|
||||||
|
//
|
||||||
|
// This is deliberately not papered over with a hand-rolled MFCC floor. A
|
||||||
|
// biometric that is confidently wrong writes false claims about named people
|
||||||
|
// into his memory, and that is worse than a capability that is honestly absent.
|
||||||
|
//
|
||||||
|
// # Off unless configured
|
||||||
|
//
|
||||||
|
// No speaker block, or one without enabled, ⇒ the three methods do not exist and
|
||||||
|
// answer ErrUnknownMethod. On an unconfigured box there is no wire path that
|
||||||
|
// takes a voiceprint at all.
|
||||||
|
//
|
||||||
|
// # The refused design step
|
||||||
|
//
|
||||||
|
// The plan asks for unknown speakers to be enrolled on first interaction. That
|
||||||
|
// is refused in internal/speaker/enroll.go and there is no handler for it here:
|
||||||
|
// no request shape in the protocol enrols whoever just spoke. Taking a biometric
|
||||||
|
// of a guest who walked past the microphone is not something this daemon does.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/speaker"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// speakerWiring holds the recognizer behind the three IPC handlers.
|
||||||
|
type speakerWiring struct {
|
||||||
|
rec *speaker.Recognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSpeakerEmbedder loads the speaker-embedding model named by the config.
|
||||||
|
//
|
||||||
|
// It always returns nil today. The seam exists so that wiring a real model is a
|
||||||
|
// change to this one function and nothing else: give it a loader, and Identify
|
||||||
|
// starts working with no change to the store, the protocol, the auth table or
|
||||||
|
// the handlers. See the plan document for what to download.
|
||||||
|
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
|
||||||
|
if cfg == nil || cfg.ModelPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet; "+
|
||||||
|
"enrolment and deletion work, recognition does not (Vikunja #255)", cfg.ModelPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
|
||||||
|
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||||
|
if cfg == nil || cfg.Speaker == nil || !cfg.Speaker.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if st == nil {
|
||||||
|
log.Print("speaker: enabled but there is no store to keep profiles in; staying off")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{
|
||||||
|
Threshold: cfg.Speaker.Threshold,
|
||||||
|
MinSeconds: cfg.Speaker.MinSeconds,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("speaker: %v; staying off", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rec.Enabled() {
|
||||||
|
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
|
||||||
|
} else {
|
||||||
|
log.Print("speaker: enrolment on, recognition BLOCKED — no speaker-embedding model " +
|
||||||
|
"on this box (see docs/plans/10-speaker-recognition.md)")
|
||||||
|
}
|
||||||
|
return &speakerWiring{rec: rec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) {
|
||||||
|
p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples)
|
||||||
|
if err != nil {
|
||||||
|
return ipc.EnrollSpeakerResp{}, speakerErr(err)
|
||||||
|
}
|
||||||
|
return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) {
|
||||||
|
ps, err := w.rec.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ipc.ListSpeakersResp{}, speakerErr(err)
|
||||||
|
}
|
||||||
|
out := make([]ipc.Speaker, 0, len(ps))
|
||||||
|
for _, p := range ps {
|
||||||
|
out = append(out, toWireSpeaker(p))
|
||||||
|
}
|
||||||
|
return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error {
|
||||||
|
return speakerErr(w.rec.Forget(ctx, req.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
|
||||||
|
// not hand the biometric back out over the socket.
|
||||||
|
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
|
||||||
|
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples}
|
||||||
|
}
|
||||||
|
|
||||||
|
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
|
||||||
|
// can tell "you asked wrong" from "core broke".
|
||||||
|
func speakerErr(err error) error {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, speaker.ErrNotFound):
|
||||||
|
return ipc.ErrNoFact
|
||||||
|
case errors.Is(err, speaker.ErrBadID),
|
||||||
|
errors.Is(err, speaker.ErrBadFormat),
|
||||||
|
errors.Is(err, speaker.ErrTooShort):
|
||||||
|
return errors.Join(ipc.ErrBadParams, err)
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireSpeaker attaches the three handlers when the capability is configured.
|
||||||
|
func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) {
|
||||||
|
w := newSpeakerWiring(st, cfg)
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srv.EnrollSpeakerFn = w.enroll
|
||||||
|
srv.ListSpeakersFn = w.list
|
||||||
|
srv.ForgetSpeakerFn = w.forget
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "evening_degraded",
|
||||||
|
"description": "The tier-2 pipeline case #288 deferred here, plus degraded mode. A golden WAV goes in at the microphone end and comes out as a written fact, and then the ecosystem starts answering 503 and the proactive loop has to stay quiet instead of falling over. The audio step asserts the PIPELINE — mic to STT seam to router to store to TTS — not whisper's accuracy; cmd/mavsttd/golden_test.go owns accuracy.",
|
||||||
|
"start": "2026-08-01T21:00:00+03:00",
|
||||||
|
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"evening_medicine\"}]",
|
||||||
|
"script": [
|
||||||
|
{
|
||||||
|
"match": "выпил воды",
|
||||||
|
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "записала факт: water",
|
||||||
|
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "",
|
||||||
|
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||||
|
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"at": "21:00",
|
||||||
|
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||||
|
"audio": "ru_fact",
|
||||||
|
"expect_reply_contains": ["записала"],
|
||||||
|
"expect_reply_lacks": ["записал,", "милый", "ваш"],
|
||||||
|
"expect_events": ["water"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:05",
|
||||||
|
"note": "a healthy tick with him just having spoken stays silent",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:10",
|
||||||
|
"note": "the ecosystem goes down",
|
||||||
|
"fault": 503
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:15",
|
||||||
|
"note": "a tick against a dead ecosystem must degrade, not send half a thought",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true,
|
||||||
|
"expect_no_events": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:20",
|
||||||
|
"note": "intake keeps working while the ecosystem is down — a write does not depend on it",
|
||||||
|
"arrive": {
|
||||||
|
"source": "rss:tech",
|
||||||
|
"note": { "text": "Патч 6.19.1 [tech]\nисправления\nhttps://example.org/b" }
|
||||||
|
},
|
||||||
|
"expect_events": ["rss:tech"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "21:25",
|
||||||
|
"note": "recovery",
|
||||||
|
"clear_fault": true,
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "morning_missed",
|
||||||
|
"description": "The scenario from Vikunja #284's description, replayed. He appears at 08:30, things arrive through the morning while he is at the desk, and at 08:50 he asks what he missed. The assertions are as much about what did NOT happen — nothing was sent at him unprompted — as about what she said.",
|
||||||
|
"start": "2026-08-01T08:30:00+03:00",
|
||||||
|
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||||
|
"script": [
|
||||||
|
{
|
||||||
|
"match": "выпил воды",
|
||||||
|
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "записала факт: water",
|
||||||
|
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "что я пропустил",
|
||||||
|
"route": "[{\"intent\":\"query\",\"text\":\"что я пропустил\"}]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": "",
|
||||||
|
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||||
|
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"at": "08:30",
|
||||||
|
"note": "he appears at the desk",
|
||||||
|
"signal": { "key": "desk_active", "value": "true", "source": "infer:hyprland" },
|
||||||
|
"expect_events": ["infer:hyprland"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:32",
|
||||||
|
"note": "a feed item arrives, published half an hour ago",
|
||||||
|
"arrive": {
|
||||||
|
"source": "rss:tech",
|
||||||
|
"as_of": "08:02",
|
||||||
|
"note": { "text": "Вышло ядро 6.19 [tech]\nкраткое содержание\nhttps://example.org/a" }
|
||||||
|
},
|
||||||
|
"expect_events": ["rss:tech"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:35",
|
||||||
|
"note": "the mail reader extracts a candidate — a candidate is never spoken",
|
||||||
|
"arrive": {
|
||||||
|
"source": "email:inbox",
|
||||||
|
"task": { "text": "продлить домен", "evidence": "Домен истекает через 7 дней" }
|
||||||
|
},
|
||||||
|
"expect_events": ["email:inbox", "продлить домен"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:40",
|
||||||
|
"note": "the work calendar signal — a relayed notification, below full confidence",
|
||||||
|
"arrive": {
|
||||||
|
"source": "ambient:notif",
|
||||||
|
"fact": {
|
||||||
|
"key": "calendar_event_20260801_планёрка",
|
||||||
|
"value": "10:00-11:00 планёрка"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expect_events": ["ambient:notif", "планёрка"],
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:45",
|
||||||
|
"note": "a tick with him present and nothing wrong must stay silent",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:50",
|
||||||
|
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it.",
|
||||||
|
"say": "что я пропустил?",
|
||||||
|
"expect_reply_contains": ["не знаю"],
|
||||||
|
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "08:55",
|
||||||
|
"note": "stating a fact writes it and says so, in the feminine",
|
||||||
|
"say": "я выпил воды",
|
||||||
|
"expect_reply_contains": ["записала"],
|
||||||
|
"expect_reply_lacks": ["записал,", "милый"],
|
||||||
|
"expect_events": ["water"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "09:00",
|
||||||
|
"note": "a second tick, still nothing unprompted",
|
||||||
|
"tick": true,
|
||||||
|
"expect_no_send": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+178
-6
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -67,6 +68,14 @@ type tickLoop struct {
|
|||||||
morningRoutines []morning.Routine
|
morningRoutines []morning.Routine
|
||||||
morningLast map[string]time.Time
|
morningLast map[string]time.Time
|
||||||
|
|
||||||
|
// proposalCfg — announcement policy for routines the tick inferred itself.
|
||||||
|
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
|
||||||
|
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
|
||||||
|
// one more announcement, and a restart-per-day loop is a bigger problem
|
||||||
|
// than a duplicate proposal notice.
|
||||||
|
proposalCfg *config.PatternProposalConfig
|
||||||
|
lastProposalAt time.Time
|
||||||
|
|
||||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||||
digestQ []QueuedNudge
|
digestQ []QueuedNudge
|
||||||
@@ -92,6 +101,7 @@ func newTickLoop(
|
|||||||
digestCfg *config.DigestConfig,
|
digestCfg *config.DigestConfig,
|
||||||
routines []routine.Routine,
|
routines []routine.Routine,
|
||||||
morningRoutines []morning.Routine,
|
morningRoutines []morning.Routine,
|
||||||
|
proposalCfg *config.PatternProposalConfig,
|
||||||
) *tickLoop {
|
) *tickLoop {
|
||||||
return &tickLoop{
|
return &tickLoop{
|
||||||
store: st,
|
store: st,
|
||||||
@@ -108,6 +118,7 @@ func newTickLoop(
|
|||||||
routineLast: make(map[string]time.Time),
|
routineLast: make(map[string]time.Time),
|
||||||
morningRoutines: morningRoutines,
|
morningRoutines: morningRoutines,
|
||||||
morningLast: make(map[string]time.Time),
|
morningLast: make(map[string]time.Time),
|
||||||
|
proposalCfg: proposalCfg,
|
||||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +223,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
// path, so a pattern already sitting in history went unnoticed until he
|
// path, so a pattern already sitting in history went unnoticed until he
|
||||||
// happened to mention it again by voice. See patterns.go and
|
// happened to mention it again by voice. See patterns.go and
|
||||||
// detectPatterns below for how idempotence and dismissal are respected.
|
// detectPatterns below for how idempotence and dismissal are respected.
|
||||||
t.detectPatterns(ctx, now)
|
t.detectPatterns(ctx, now, state)
|
||||||
|
|
||||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||||
// delivery. a failed send leaves the reminder pending — the next tick
|
// delivery. a failed send leaves the reminder pending — the next tick
|
||||||
@@ -381,16 +392,19 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
|||||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||||
// calling the same shared path the voice route already used.
|
// calling the same shared path the voice route already used.
|
||||||
//
|
//
|
||||||
// This only ever creates a row for the /routines page to show. It does not
|
// By default this only creates a row for the /routines page to show: it does
|
||||||
// notify, ring, or speak — Maven is "not a nag, not autonomous" (CLAUDE.md),
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||||
// and detection is not the same act as disturbing him about it. A proposal
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||||
// only starts producing nudges once he accepts it (fireAcceptedRoutines).
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
// for the restraints that apply even then. A proposal only starts producing
|
||||||
|
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||||
|
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tick: distinct event pairs: %v", err)
|
log.Printf("tick: distinct event pairs: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
announced := false
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -401,9 +415,65 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time) {
|
|||||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||||
}
|
}
|
||||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||||
|
// One announcement per tick at most, whatever the scan turned up. The
|
||||||
|
// rest are on /routines; they are not lost, they are just not shouted.
|
||||||
|
if announced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
announced = t.announceProposal(ctx, r, now, state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// announceProposal offers a freshly inferred routine through the ordinary
|
||||||
|
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||||
|
// something was actually sent.
|
||||||
|
//
|
||||||
|
// Everything here is restraint. The feature is off unless configured; when on
|
||||||
|
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||||
|
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||||
|
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||||
|
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||||
|
// real send, but the proposal row already exists, so the next tick will not
|
||||||
|
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||||
|
// he reads it on /routines instead, which is the whole point of the page.
|
||||||
|
//
|
||||||
|
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||||
|
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||||
|
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||||
|
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||||
|
if !t.proposalCfg.AnnounceProposals() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||||
|
if cooldown <= 0 {
|
||||||
|
cooldown = config.DefaultProposalCooldown
|
||||||
|
}
|
||||||
|
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := pattern.PhraseRoutine(r)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
return false // routing dropped it — /routines still has it.
|
||||||
|
}
|
||||||
|
t.lastProposalAt = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||||
@@ -719,6 +789,77 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||||
|
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||||
|
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||||
|
//
|
||||||
|
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||||
|
// unprompted delivery in maven stays with the morning nudge and the
|
||||||
|
// dispatcher's policy.
|
||||||
|
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var events []morning.PlanEntry
|
||||||
|
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: calendar events: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range facts {
|
||||||
|
events = append(events, morning.PlanEntry{
|
||||||
|
At: f.Ts,
|
||||||
|
Text: f.Value,
|
||||||
|
Kind: morning.PlanEvent,
|
||||||
|
// Provenance below a calendar read (an ambient relay, #126) is
|
||||||
|
// hedged rather than recited as fact.
|
||||||
|
Uncertain: f.Confidence < 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminders []morning.PlanEntry
|
||||||
|
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: list reminders: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rems {
|
||||||
|
if r.Status != "pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fire := r.NextFireTs
|
||||||
|
if fire.IsZero() {
|
||||||
|
fire = r.FireTs
|
||||||
|
}
|
||||||
|
reminders = append(reminders, morning.PlanEntry{
|
||||||
|
At: fire,
|
||||||
|
Text: strings.TrimSpace(r.Payload),
|
||||||
|
Kind: morning.PlanReminder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var checklistFacts map[string]store.Fact
|
||||||
|
if len(t.morningRoutines) > 0 {
|
||||||
|
checklistFacts = t.gatherMorningFacts(ctx)
|
||||||
|
}
|
||||||
|
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||||
|
|
||||||
|
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||||
|
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||||
|
for i, it := range plan.Items {
|
||||||
|
out.Items[i] = ipc.DayPlanItem{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: string(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||||
|
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||||
|
const dayPlanMaxReminders = 500
|
||||||
|
|
||||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -798,7 +939,21 @@ type daemonAPI struct {
|
|||||||
ipc.CoreAPI
|
ipc.CoreAPI
|
||||||
getTrace func() *loop.TickTrace
|
getTrace func() *loop.TickTrace
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
chatFn func(ctx context.Context, text string) string
|
chatFn func(ctx context.Context, text string) string
|
||||||
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||||
|
// look the same to a reader on purpose, because neither is a fault and the
|
||||||
|
// page renders both as an empty table.
|
||||||
|
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
if d.getEvents == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getEvents(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||||
@@ -808,6 +963,16 @@ func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
|||||||
return d.chatFn(ctx, text), nil
|
return d.chatFn(ctx, text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
|
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||||
|
// default state and the web surface renders it as such.
|
||||||
|
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||||
|
if d.getMCPServers == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getMCPServers(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||||
trace := d.getTrace()
|
trace := d.getTrace()
|
||||||
if trace == nil {
|
if trace == nil {
|
||||||
@@ -823,6 +988,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
|||||||
return d.getMorningStatus(ctx), nil
|
return d.getMorningStatus(ctx), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||||
|
if d.getDayPlan == nil {
|
||||||
|
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||||
|
}
|
||||||
|
return d.getDayPlan(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
for i, r := range t.RuleTraces {
|
for i, r := range t.RuleTraces {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
|||||||
Nudges: st,
|
Nudges: st,
|
||||||
Reminders: st,
|
Reminders: st,
|
||||||
})
|
})
|
||||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
|
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
|||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
|
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
|
||||||
|
|
||||||
// first tick: seeds, does not fire the routine.
|
// first tick: seeds, does not fire the routine.
|
||||||
tl.tick(ctx, now)
|
tl.tick(ctx, now)
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// mavend/vision.go — core's half of image understanding (Vikunja #252,
|
||||||
|
// docs/plans/07-vision.md).
|
||||||
|
//
|
||||||
|
// The split: any surface that can receive a picture (mavweb upload, a Telegram
|
||||||
|
// photo through mavpoll, a path he names) hands the bytes to core over
|
||||||
|
// ipc.MethodDescribeImage. Core stores them content-addressed under
|
||||||
|
// media.dir, prepares a downscaled JPEG, and asks a local vision server what it
|
||||||
|
// is. The description comes back as words; nothing about the image is echoed.
|
||||||
|
//
|
||||||
|
// Off unless configured twice over: no `media` block ⇒ nowhere to keep the
|
||||||
|
// bytes, so the method does not exist; no `vision` block with enabled + a local
|
||||||
|
// endpoint ⇒ the store is wired but the describing half refuses, and the method
|
||||||
|
// still does not exist. A surface cannot make Maven look at pictures by merely
|
||||||
|
// sending one.
|
||||||
|
//
|
||||||
|
// Two things this file deliberately does not do:
|
||||||
|
//
|
||||||
|
// - No cloud vision call, ever. internal/vision refuses a non-private
|
||||||
|
// endpoint at construction; there is no config shape here that could reach
|
||||||
|
// an upstream API even if someone wanted one.
|
||||||
|
// - No automatic memory. SaveNote is opt-in per call. Glancing at a screenshot
|
||||||
|
// is not the same act as remembering it, and a 1.7B-class VLM's guess about
|
||||||
|
// a photo is not a fact worth carrying around.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/media"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/vision"
|
||||||
|
)
|
||||||
|
|
||||||
|
// prunePeriod — how often stored blobs are checked against media.retention.
|
||||||
|
// Hourly is far more often than needed for a 7-day retention and costs a
|
||||||
|
// directory walk over a handful of sidecars; the point is that the promise is
|
||||||
|
// kept by a loop that runs, not by an operator remembering a cron.
|
||||||
|
const prunePeriod = time.Hour
|
||||||
|
|
||||||
|
// mediaKeeper — the blob store plus the loop that enforces its retention. The
|
||||||
|
// two are one object because a store without the loop is a directory that grows
|
||||||
|
// forever, and shipping that would break the only interesting promise this
|
||||||
|
// capability makes.
|
||||||
|
type mediaKeeper struct {
|
||||||
|
store *media.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// openMediaStore builds the blob store from config, or returns nil when media is
|
||||||
|
// not configured. A relative dir resolves against StateDir, the same rule the db
|
||||||
|
// and socket paths follow.
|
||||||
|
func openMediaStore(cfg *config.Config) *mediaKeeper {
|
||||||
|
dir := cfg.Media.StoreDir()
|
||||||
|
if dir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
|
||||||
|
dir = filepath.Join(cfg.StateDir, dir)
|
||||||
|
}
|
||||||
|
st, err := media.Open(dir, cfg.Media.MaxBytes, time.Duration(cfg.Media.Retention))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("media: %v — image and audio intake disabled", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("media: blob store at %s, retention %s", st.Dir(), st.Retention())
|
||||||
|
return &mediaKeeper{store: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPrune deletes over-retention blobs on a loop until ctx ends. It prunes once
|
||||||
|
// immediately, so a daemon restarted after a long downtime does not sit on a
|
||||||
|
// month of stale recordings until the first tick.
|
||||||
|
func (k *mediaKeeper) runPrune(ctx context.Context) {
|
||||||
|
prune := func() {
|
||||||
|
n, err := k.store.Prune()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("media: prune: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("media: pruned %d blob(s) older than %s", n, k.store.Retention())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prune()
|
||||||
|
t := time.NewTicker(prunePeriod)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
prune()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visionIntake — one image at a time: store, prepare, describe, optionally note.
|
||||||
|
type visionIntake struct {
|
||||||
|
in *vision.Intake
|
||||||
|
st *store.Store
|
||||||
|
emb router.Embedder
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newVisionIntake returns nil when there is nothing to wire. keeper == nil means
|
||||||
|
// no media block, which disables the method outright; a missing or disabled
|
||||||
|
// vision block still wires the method, because storing an image and answering
|
||||||
|
// "I can't look at it yet" is more useful than pretending the surface does not
|
||||||
|
// exist — and it is exactly the state this box is in until a vision model is on
|
||||||
|
// disk.
|
||||||
|
func newVisionIntake(keeper *mediaKeeper, st *store.Store, emb router.Embedder, cfg *config.Config) *visionIntake {
|
||||||
|
if keeper == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
vc := cfg.Vision
|
||||||
|
maxDim := 0
|
||||||
|
var provider vision.Provider = vision.Disabled{}
|
||||||
|
if vc.LooksAtImages() {
|
||||||
|
p, err := vision.NewLocal(vision.Config{
|
||||||
|
Endpoint: vc.Endpoint,
|
||||||
|
Model: vc.Model,
|
||||||
|
Timeout: time.Duration(vc.Timeout),
|
||||||
|
MaxTokens: vc.MaxTokens,
|
||||||
|
Prompt: vc.Prompt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// A public endpoint, a hostname, a bad URL. Logged once here rather
|
||||||
|
// than failing every turn, and the store still works.
|
||||||
|
log.Printf("vision: %v — she can store images but not describe them", err)
|
||||||
|
} else {
|
||||||
|
provider = p
|
||||||
|
maxDim = vc.MaxDim
|
||||||
|
log.Printf("vision: enabled against %s", p.Endpoint())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("vision: not configured — images are stored, not described")
|
||||||
|
}
|
||||||
|
return &visionIntake{
|
||||||
|
in: vision.NewIntake(keeper.store, provider, maxDim),
|
||||||
|
st: st,
|
||||||
|
emb: emb,
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describe handles one ipc.MethodDescribeImage call.
|
||||||
|
//
|
||||||
|
// A description failure is NOT an error out of this method when the bytes were
|
||||||
|
// stored: the caller gets the id and an empty description, which is honest ("it
|
||||||
|
// is kept, I cannot read it yet") and re-runnable. A failure to store, or bytes
|
||||||
|
// that are not an image at all, is an error — there is nothing to come back to.
|
||||||
|
func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (ipc.DescribeImageResp, error) {
|
||||||
|
if len(req.Data) == 0 && req.ID == "" {
|
||||||
|
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: neither data nor id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
res vision.Result
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if req.ID != "" {
|
||||||
|
res, err = v.in.Rerun(ctx, req.ID, req.Question)
|
||||||
|
} else {
|
||||||
|
res, err = v.in.Accept(ctx, req.Data, sourceOrDefault(req.Source), req.Question)
|
||||||
|
}
|
||||||
|
if res.Blob.ID == "" {
|
||||||
|
// Nothing was stored: bad format, over the size cap, unwritable dir.
|
||||||
|
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ipc.DescribeImageResp{
|
||||||
|
ID: res.Blob.ID,
|
||||||
|
Description: res.Description,
|
||||||
|
Width: res.Image.Width,
|
||||||
|
Height: res.Image.Height,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Bytes are safe, words are not available. The log names the blob and the
|
||||||
|
// reason; it never names what was in the picture.
|
||||||
|
if errors.Is(err, vision.ErrDisabled) {
|
||||||
|
log.Printf("vision: stored %s, no vision model configured", res.Blob)
|
||||||
|
} else {
|
||||||
|
log.Printf("vision: stored %s, describe failed: %v", res.Blob, err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SaveNote {
|
||||||
|
id, werr := v.writeNote(ctx, res)
|
||||||
|
if werr != nil {
|
||||||
|
// The description is still returned: losing the note is worse as a
|
||||||
|
// silent failure than as a log line next to a successful answer.
|
||||||
|
log.Printf("vision: note write for %s failed: %v", res.Blob, werr)
|
||||||
|
} else {
|
||||||
|
resp.NoteID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("vision: described %s (%dx%d)", res.Blob, res.Image.Width, res.Image.Height)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeNote stores the description as an ordinary note so it is recallable. The
|
||||||
|
// note carries the blob id in its source, which is the only link back to the
|
||||||
|
// bytes — the note text is words about the picture, never the picture.
|
||||||
|
func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64, error) {
|
||||||
|
var vec []float32
|
||||||
|
if v.emb != nil {
|
||||||
|
// EmbedPassage, not Embed: a description is text being searched FOR, and
|
||||||
|
// the e5 embedder is asymmetric. Backwards here makes it unfindable by
|
||||||
|
// the question that should have matched it.
|
||||||
|
var err error
|
||||||
|
vec, err = router.EmbedPassage(ctx, v.emb, res.Description)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("embed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source := "media:image:" + res.Blob.ID[:12]
|
||||||
|
return v.st.WriteNote(ctx, v.now(), res.Description, vec, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceOrDefault labels a blob whose sender did not say where it came from.
|
||||||
|
func sourceOrDefault(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireVision installs the IPC hook and starts the retention loop, or leaves the
|
||||||
|
// hook nil so ipc.MethodDescribeImage reports ErrUnknownMethod. Called on both
|
||||||
|
// startup paths (unlocked boot and passkey unlock) so vision behaves the same
|
||||||
|
// either way.
|
||||||
|
//
|
||||||
|
// Returns the media keeper so the meeting recorder can share it: one blob store
|
||||||
|
// with one retention loop holds both the images and the audio, which is the
|
||||||
|
// whole point of internal/media being a shared package. nil ⇒ no media block,
|
||||||
|
// and neither capability exists.
|
||||||
|
func wireVision(ctx context.Context, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
|
||||||
|
keeper := openMediaStore(cfg)
|
||||||
|
if keeper == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
go keeper.runPrune(ctx)
|
||||||
|
|
||||||
|
vi := newVisionIntake(keeper, st, emb, cfg)
|
||||||
|
if vi == nil {
|
||||||
|
return keeper
|
||||||
|
}
|
||||||
|
srv.DescribeImageFn = vi.describe
|
||||||
|
return keeper
|
||||||
|
}
|
||||||
+21
-177
@@ -50,13 +50,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -84,6 +83,26 @@ type reactiveHandler struct {
|
|||||||
replier voice.Replier
|
replier voice.Replier
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
|
// crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand
|
||||||
|
// page reading is off, which is the default: no `crawl` block, no fetch.
|
||||||
|
crawler *crawl.Crawler
|
||||||
|
|
||||||
|
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
|
||||||
|
// only what she SAYS when asked and nothing is there: "ленты не настроены"
|
||||||
|
// instead of "ничего нового", which are different truths.
|
||||||
|
feedsOn bool
|
||||||
|
|
||||||
|
// home — the Home Assistant client (Vikunja #256). nil ⇒ the house is not
|
||||||
|
// configured, which is the default: no `smarthome` block, no reads, no
|
||||||
|
// switches. Control does not go through this field — it goes through the
|
||||||
|
// act allowlist and tool.Executor, like every other mutating act.
|
||||||
|
home *homeWiring
|
||||||
|
|
||||||
|
// netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the
|
||||||
|
// default. A scan is a read, so it has no allowlist row; what keeps it
|
||||||
|
// safe is that its range comes from config and from nowhere else.
|
||||||
|
netscan *netWiring
|
||||||
|
|
||||||
weatherProvider weather.Provider
|
weatherProvider weather.Provider
|
||||||
weatherLocation string // default location for weather queries
|
weatherLocation string // default location for weather queries
|
||||||
|
|
||||||
@@ -277,181 +296,6 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectPattern extracts an event from the written fact and runs the pattern
|
|
||||||
// detector. If a stable recurring pattern is found and no proposed routine
|
|
||||||
// exists for this action+object yet, one is created and the user is prompted
|
|
||||||
// to confirm via the park() mechanism. Returns the suggestion phrase when a
|
|
||||||
// new proposal was created and parked; "" otherwise.
|
|
||||||
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
|
|
||||||
ev := pattern.Extract(factID, key, value, ts)
|
|
||||||
if ev == nil {
|
|
||||||
return "" // not an actionable event
|
|
||||||
}
|
|
||||||
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
|
|
||||||
log.Printf("voice: create event: %v", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Detect+propose (Vikunja #43) is shared with the digestion tick's
|
|
||||||
// proactive scan — see patterns.go. Event *extraction* above stays here,
|
|
||||||
// tied to this fact write; detection over the accumulated history does
|
|
||||||
// not need to happen right now for the voice path to have already done
|
|
||||||
// its job — it's dedupe-safe to also let the next tick find the same
|
|
||||||
// pattern independently.
|
|
||||||
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if r == nil {
|
|
||||||
return "" // not enough data, too irregular, or already proposed/decided
|
|
||||||
}
|
|
||||||
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
|
||||||
|
|
||||||
// Park the proposal for voice confirmation.
|
|
||||||
phrase := pattern.PhraseRoutine(r)
|
|
||||||
h.mu.Lock()
|
|
||||||
h.pendingRoutine = &pendingRoutineConfirm{
|
|
||||||
routineID: id,
|
|
||||||
action: r.Action,
|
|
||||||
object: r.Object,
|
|
||||||
interval: r.IntervalDays,
|
|
||||||
phrase: phrase,
|
|
||||||
expiry: ts.Add(confirmTTL),
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
return phrase
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
|
||||||
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
|
|
||||||
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
|
|
||||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
|
||||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
|
||||||
// setting. See classifyQuietToggle for the matching rule.
|
|
||||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
|
||||||
on, off := classifyQuietToggle(text)
|
|
||||||
if !on && !off {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
val := "false"
|
|
||||||
reply := "тихий режим выключен."
|
|
||||||
if on {
|
|
||||||
val = "true"
|
|
||||||
reply = "тихий режим включён. буду реже напоминать."
|
|
||||||
}
|
|
||||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
|
||||||
Ts: h.now(),
|
|
||||||
Kind: "config",
|
|
||||||
Key: "quiet_hours",
|
|
||||||
Value: val,
|
|
||||||
Source: "tap:voice",
|
|
||||||
Confidence: 1.0,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("voice: write quiet_hours: %v", err)
|
|
||||||
return "не получилось переключить тихий режим.", true
|
|
||||||
}
|
|
||||||
return reply, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// quietInflections — the inflectional endings a stem may carry and still be
|
|
||||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
|
||||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
|
||||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
|
||||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
|
||||||
var quietInflections = []string{
|
|
||||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
|
||||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
|
||||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
|
||||||
}
|
|
||||||
|
|
||||||
// quietStem reports whether tok is the given stem carrying at most one
|
|
||||||
// inflectional ending. Word boundaries come from tokenisation (see
|
|
||||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
|
||||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
|
||||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
|
||||||
func quietStem(tok, stem string) bool {
|
|
||||||
if !strings.HasPrefix(tok, stem) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
suffix := tok[len(stem):]
|
|
||||||
for _, e := range quietInflections {
|
|
||||||
if suffix == e {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// quietTokens splits an utterance into lowercase word tokens, dropping
|
|
||||||
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
|
|
||||||
// way ASCII ones do.
|
|
||||||
func quietTokens(text string) []string {
|
|
||||||
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
|
|
||||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// quietPhrase matches a pattern (a sequence of stems) against the token list.
|
|
||||||
// Multi-word patterns match any contiguous run of tokens — "включи тихий
|
|
||||||
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
|
|
||||||
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
|
|
||||||
// remark about the room and must not flip a daemon-wide setting.
|
|
||||||
func quietPhrase(tokens, pattern []string) bool {
|
|
||||||
if len(pattern) == 0 || len(tokens) < len(pattern) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(pattern) == 1 {
|
|
||||||
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
|
|
||||||
}
|
|
||||||
for i := 0; i+len(pattern) <= len(tokens); i++ {
|
|
||||||
hit := true
|
|
||||||
for j, stem := range pattern {
|
|
||||||
if !quietStem(tokens[i+j], stem) {
|
|
||||||
hit = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hit {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
|
||||||
var (
|
|
||||||
quietOffPhrases = [][]string{
|
|
||||||
{"quiet", "off"}, {"quiet", "end"},
|
|
||||||
{"громк", "режим"}, {"шумн", "режим"},
|
|
||||||
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
|
||||||
}
|
|
||||||
quietOnPhrases = [][]string{
|
|
||||||
{"quiet", "on"}, {"quiet", "mode"},
|
|
||||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
|
||||||
{"тих"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
|
||||||
// resolved before ON for the same reason classifyConfirm checks negatives
|
|
||||||
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
|
||||||
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
|
||||||
// тихий режим" would turn quiet mode on. Negation wins.
|
|
||||||
func classifyQuietToggle(text string) (on, off bool) {
|
|
||||||
tokens := quietTokens(text)
|
|
||||||
for _, p := range quietOffPhrases {
|
|
||||||
if quietPhrase(tokens, p) {
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range quietOnPhrases {
|
|
||||||
if quietPhrase(tokens, p) {
|
|
||||||
return true, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// replySystem answers system-observable queries using the handler's clock
|
// replySystem answers system-observable queries using the handler's clock
|
||||||
// and (in future) system interfaces. The decision's utterance is parsed
|
// and (in future) system interfaces. The decision's utterance is parsed
|
||||||
// for keywords to determine what the user is asking about.
|
// for keywords to determine what the user is asking about.
|
||||||
|
|||||||
+55
-11
@@ -40,6 +40,22 @@ type voiceWiring struct {
|
|||||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||||
sttClient *worker.Client
|
sttClient *worker.Client
|
||||||
ttsClient *worker.Client
|
ttsClient *worker.Client
|
||||||
|
// transcriber — the STT in use, exposed so the meeting recorder
|
||||||
|
// (cmd/mavend/capture.go) can reuse it. Maven has exactly one STT and does
|
||||||
|
// not grow a second one for capture: this is the same whisper.cpp worker the
|
||||||
|
// voice path talks to.
|
||||||
|
transcriber stt.Transcriber
|
||||||
|
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
||||||
|
// server (Vikunja #251). Its tools land in the same allowlist as every
|
||||||
|
// other act, so nothing else here has to know about it.
|
||||||
|
mcp *mcpWiring
|
||||||
|
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||||
|
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||||
|
// other act, so nothing else here has to know about it.
|
||||||
|
home *homeWiring
|
||||||
|
// netscan — the LAN scanner, nil unless the `netscan` block is enabled
|
||||||
|
// (Vikunja #257).
|
||||||
|
netscan *netWiring
|
||||||
}
|
}
|
||||||
|
|
||||||
// close releases the listener + worker conns. Safe to call on nil (when
|
// close releases the listener + worker conns. Safe to call on nil (when
|
||||||
@@ -60,6 +76,7 @@ func (w *voiceWiring) close() {
|
|||||||
if w.ttsClient != nil {
|
if w.ttsClient != nil {
|
||||||
_ = w.ttsClient.Close()
|
_ = w.ttsClient.Close()
|
||||||
}
|
}
|
||||||
|
w.mcp.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
||||||
@@ -87,6 +104,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
} else {
|
} else {
|
||||||
transcriber = stt.NewStub()
|
transcriber = stt.NewStub()
|
||||||
}
|
}
|
||||||
|
w.transcriber = transcriber
|
||||||
|
|
||||||
// ----- tts (Stub in-process OR Remote) -----
|
// ----- tts (Stub in-process OR Remote) -----
|
||||||
var synthesizer tts.Synthesizer
|
var synthesizer tts.Synthesizer
|
||||||
@@ -131,6 +149,24 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// daemon restart.
|
// daemon restart.
|
||||||
seedTools(coreAPI, cfg.Voice.Tools)
|
seedTools(coreAPI, cfg.Voice.Tools)
|
||||||
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
|
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
|
||||||
|
// MCP servers (Vikunja #251): discovery PROPOSES tools into the same
|
||||||
|
// allowlist, so an MCP tool is enabled by hand on /tools like any other and
|
||||||
|
// runs through the same confirm turn. Off unless the `mcp` block configures
|
||||||
|
// an enabled server.
|
||||||
|
w.mcp = wireMCP(cfg, dataStore)
|
||||||
|
if w.mcp != nil {
|
||||||
|
exec = exec.WithMCP(w.mcp.caller())
|
||||||
|
}
|
||||||
|
// The house (Vikunja #256): same story as MCP. Discovery PROPOSES a row per
|
||||||
|
// controllable device, always destructive, and Kami enables the ones he
|
||||||
|
// wants on /tools. Off unless the `smarthome` block is enabled.
|
||||||
|
w.home = wireSmartHome(cfg, dataStore)
|
||||||
|
if w.home != nil {
|
||||||
|
exec = exec.WithHome(w.home.caller())
|
||||||
|
}
|
||||||
|
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||||
|
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||||
|
w.netscan = wireNetScan(cfg)
|
||||||
matcher := tool.NewMatcher(coreAPI)
|
matcher := tool.NewMatcher(coreAPI)
|
||||||
|
|
||||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||||
@@ -148,7 +184,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// The replier uses the same llama-server as the phraser.
|
// The replier uses the same llama-server as the phraser.
|
||||||
var llmClient *llm.Client
|
var llmClient *llm.Client
|
||||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||||
llmClient = llm.New(lp.BaseURL(), 60*time.Second)
|
// llmClientFor, not llm.New: this client must follow the phraser onto
|
||||||
|
// the new llama-server when the resident model is swapped (Vikunja #250).
|
||||||
|
llmClient = llmClientFor(lp, 60*time.Second)
|
||||||
}
|
}
|
||||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||||
@@ -201,16 +239,22 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
|
|
||||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
tts: synthesizer,
|
tts: synthesizer,
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
embedder: emb,
|
||||||
api: coreAPI,
|
api: coreAPI,
|
||||||
tools: exec,
|
tools: exec,
|
||||||
matcher: matcher,
|
matcher: matcher,
|
||||||
replier: replier,
|
replier: replier,
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
|
feedsOn: cfg.Feeds != nil,
|
||||||
|
home: w.home,
|
||||||
|
netscan: w.netscan,
|
||||||
|
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||||
|
// capability, and capabilities are off unless configured.
|
||||||
|
crawler: onDemandCrawler(cfg),
|
||||||
weatherProvider: weatherProvider,
|
weatherProvider: weatherProvider,
|
||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
memStore: memStore,
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// mavmaild — the mail reader module (Vikunja #246,
|
||||||
|
// docs/plans/01-email-reader.md).
|
||||||
|
//
|
||||||
|
// Every so often it opens one IMAP mailbox read-only, fetches the messages it
|
||||||
|
// has not read yet, and hands each one to core over ipc.MethodIngestMail. Core
|
||||||
|
// runs the extraction on the resident model and writes what comes back as task
|
||||||
|
// CANDIDATES he reviews on /tasks. Nothing here writes to the store, nothing
|
||||||
|
// here can create a reminder, and nothing here speaks.
|
||||||
|
//
|
||||||
|
// Why a separate daemon rather than a loop inside mavend, when extraction has
|
||||||
|
// to happen in mavend anyway: the credential. mavpoll set the precedent with the
|
||||||
|
// zenmoney token (#125) — the module that talks to a third party holds the
|
||||||
|
// secret, reads it from a FILE so it never appears in `ps`, in
|
||||||
|
// docker-compose.yml or in shell history, and core never sees it. Core learns
|
||||||
|
// that mail exists only as message text on one IPC method; it cannot connect to
|
||||||
|
// the mailbox even if it wanted to, and a compromised core yields no mail
|
||||||
|
// password.
|
||||||
|
//
|
||||||
|
// Off unless configured: without -password-file there is nothing to run, and
|
||||||
|
// the daemon says so and exits. If core has no `email` block the very first
|
||||||
|
// ingest comes back ErrUnknownMethod and this daemon stops polling instead of
|
||||||
|
// hammering a socket that will keep refusing.
|
||||||
|
//
|
||||||
|
// Mail is personal, so the log is counts and UIDs: how many messages were
|
||||||
|
// fetched, how many were bulk, how many candidates came back. No subject, no
|
||||||
|
// sender, no body, ever — reviewing a candidate is what /tasks is for.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/email"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "mavmaild:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("mavmaild", flag.ContinueOnError)
|
||||||
|
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||||
|
server := fs.String("imap", "", "IMAP server, host or host:993 (required)")
|
||||||
|
user := fs.String("user", "", "IMAP username (required)")
|
||||||
|
passFile := fs.String("password-file", "", "file holding the IMAP password (required — never passed as a flag value)")
|
||||||
|
mailbox := fs.String("mailbox", "INBOX", "mailbox to read, read-only")
|
||||||
|
interval := fs.Duration("interval", 15*time.Minute, "how often to read the mailbox")
|
||||||
|
lookback := fs.Duration("lookback", 72*time.Hour, "how far back to search on each poll")
|
||||||
|
max := fs.Int("max", 25, "most messages to fetch in one poll")
|
||||||
|
timeout := fs.Duration("timeout", 30*time.Second, "IMAP network timeout")
|
||||||
|
statePath := fs.String("state", "", "file remembering which UIDs were read (default: none — every poll re-reads the window)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *socket == "" {
|
||||||
|
return fmt.Errorf("-socket is required")
|
||||||
|
}
|
||||||
|
if *server == "" || *user == "" || *passFile == "" {
|
||||||
|
return fmt.Errorf("mail reading is off unless configured: set -imap, -user and -password-file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password is read from a file, never taken as a flag value: an argv
|
||||||
|
// secret is visible in `ps` to every user on the box and lands in the compose
|
||||||
|
// file and the shell history. Read once at start — a rotated password means a
|
||||||
|
// restart, which is cheaper than re-reading his credential every quarter hour.
|
||||||
|
raw, err := os.ReadFile(*passFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read password file: %w", err)
|
||||||
|
}
|
||||||
|
password := strings.TrimSpace(string(raw))
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password file %s is empty", *passFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
core, err := ipc.DialWait(*socket, 60*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer core.Close()
|
||||||
|
|
||||||
|
r := &reader{
|
||||||
|
core: core,
|
||||||
|
addr: *server,
|
||||||
|
user: *user,
|
||||||
|
mailbox: *mailbox,
|
||||||
|
lookback: *lookback,
|
||||||
|
max: *max,
|
||||||
|
timeout: *timeout,
|
||||||
|
state: newSeenState(*statePath),
|
||||||
|
}
|
||||||
|
if err := r.state.load(); err != nil {
|
||||||
|
// A missing or corrupt state file must not stop mail from being read: the
|
||||||
|
// worst case is re-reading the window, and capture dedupes on text.
|
||||||
|
log.Printf("mavmaild: state: %v (starting from an empty seen-set)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password is never logged, not even its length.
|
||||||
|
log.Printf("mavmaild: reading %s on %s every %s (lookback %s, max %d/poll)",
|
||||||
|
*mailbox, *server, *interval, *lookback, *max)
|
||||||
|
|
||||||
|
r.pollOnce(ctx, password) // don't idle a full interval on start
|
||||||
|
t := time.NewTicker(*interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("mavmaild: bye")
|
||||||
|
return nil
|
||||||
|
case <-t.C:
|
||||||
|
if r.disabled {
|
||||||
|
// Core told us mail ingestion is not configured. Nothing will change
|
||||||
|
// without a core restart, and a restart restarts us too.
|
||||||
|
log.Printf("mavmaild: core does not accept mail — idling")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.pollOnce(ctx, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailIngester — the slice of core this daemon uses. One method: hand over a
|
||||||
|
// message. It cannot write a fact, create a reminder or read the store, and the
|
||||||
|
// interface says so.
|
||||||
|
type mailIngester interface {
|
||||||
|
IngestMail(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type reader struct {
|
||||||
|
core mailIngester
|
||||||
|
addr string
|
||||||
|
user string
|
||||||
|
mailbox string
|
||||||
|
lookback time.Duration
|
||||||
|
max int
|
||||||
|
timeout time.Duration
|
||||||
|
state *seenState
|
||||||
|
|
||||||
|
// dial — connection seam for the tests; nil ⇒ implicit TLS.
|
||||||
|
dial func(addr string, timeout time.Duration) (*email.Conn, error)
|
||||||
|
|
||||||
|
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollOnce — one read of the mailbox, then one ingest per message.
|
||||||
|
//
|
||||||
|
// A fetch error aborts this poll and nothing else; the next tick tries again.
|
||||||
|
// An ingest error for one message does not skip the rest — one mail the model
|
||||||
|
// choked on should not hide the four behind it.
|
||||||
|
func (r *reader) pollOnce(ctx context.Context, password string) {
|
||||||
|
msgs, err := r.fetch(password)
|
||||||
|
if err != nil {
|
||||||
|
// The error may name a UID; it never names a subject or a sender.
|
||||||
|
log.Printf("mavmaild: fetch: %v", err)
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var junk, candidates, created int
|
||||||
|
for _, m := range msgs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Junk {
|
||||||
|
junk++
|
||||||
|
// Marked seen without a model call: the header filter already decided,
|
||||||
|
// and re-classifying it every quarter hour would be pure waste.
|
||||||
|
r.state.mark(m.UID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, err := r.core.IngestMail(ctx, ipc.IngestMailReq{
|
||||||
|
Mailbox: r.mailbox,
|
||||||
|
UID: m.UID,
|
||||||
|
From: m.From,
|
||||||
|
Subject: m.Subject,
|
||||||
|
Date: m.Date,
|
||||||
|
Body: m.Body,
|
||||||
|
})
|
||||||
|
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||||
|
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; stopping")
|
||||||
|
r.disabled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Not marked seen: an ingest that failed should be retried next poll.
|
||||||
|
log.Printf("mavmaild: ingest uid %d: %v", m.UID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.state.mark(m.UID)
|
||||||
|
candidates += len(resp.TaskIDs)
|
||||||
|
created += resp.Created
|
||||||
|
}
|
||||||
|
if err := r.state.save(); err != nil {
|
||||||
|
log.Printf("mavmaild: state: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("mavmaild: %s: %d read, %d bulk, %d candidate(s), %d new", r.mailbox, len(msgs), junk, candidates, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch reads the mailbox. Messages already in the seen-set are not fetched at
|
||||||
|
// all, so a steady mailbox costs one SEARCH per poll and nothing else.
|
||||||
|
func (r *reader) fetch(password string) ([]email.Message, error) {
|
||||||
|
f := email.FetchSince{
|
||||||
|
Addr: r.addr,
|
||||||
|
User: r.user,
|
||||||
|
Mailbox: r.mailbox,
|
||||||
|
Timeout: r.timeout,
|
||||||
|
Since: time.Now().Add(-r.lookback),
|
||||||
|
Max: r.max,
|
||||||
|
Skip: r.state.seen,
|
||||||
|
}
|
||||||
|
return f.RunWith(password, r.dial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- seen state ------------------------------------------------------------
|
||||||
|
|
||||||
|
// seenState — the UIDs already handed to core, persisted so a restart does not
|
||||||
|
// re-read (and re-extract, at multi-second LLM cost) the whole lookback window.
|
||||||
|
//
|
||||||
|
// Correctness does not depend on it: ipc.CaptureTask dedupes on normalised text
|
||||||
|
// among live tasks, so a re-read produces no duplicate rows. This exists to save
|
||||||
|
// the model's time, which is why a broken state file is a log line rather than a
|
||||||
|
// failure.
|
||||||
|
//
|
||||||
|
// UIDs are per-mailbox and monotonic, so the set is kept as a high-water mark
|
||||||
|
// plus the stragglers above it. If the server ever changes UIDVALIDITY, UIDs
|
||||||
|
// reset and the window is simply re-read once — dedupe absorbs it.
|
||||||
|
type seenState struct {
|
||||||
|
path string
|
||||||
|
high uint32
|
||||||
|
set map[uint32]bool
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSeenState(path string) *seenState {
|
||||||
|
return &seenState{path: path, set: map[uint32]bool{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type seenFile struct {
|
||||||
|
High uint32 `json:"high"`
|
||||||
|
UIDs []uint32 `json:"uids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *seenState) seen(uid uint32) bool {
|
||||||
|
return uid <= s.high || s.set[uid]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *seenState) mark(uid uint32) {
|
||||||
|
if s.seen(uid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.set[uid] = true
|
||||||
|
s.dirty = true
|
||||||
|
// Advance the high-water mark through any contiguous run, so the explicit set
|
||||||
|
// stays small on a mailbox read in order.
|
||||||
|
for {
|
||||||
|
next := s.high + 1
|
||||||
|
if !s.set[next] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delete(s.set, next)
|
||||||
|
s.high = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *seenState) load() error {
|
||||||
|
if s.path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(s.path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil // first run
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var f seenFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||||
|
}
|
||||||
|
s.high = f.High
|
||||||
|
for _, u := range f.UIDs {
|
||||||
|
s.set[u] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes the state atomically (temp file + rename), 0600: it is a list of
|
||||||
|
// message ids from his mailbox, which is metadata about his mail.
|
||||||
|
func (s *seenState) save() error {
|
||||||
|
if s.path == "" || !s.dirty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
uids := make([]uint32, 0, len(s.set))
|
||||||
|
for u := range s.set {
|
||||||
|
uids = append(uids, u)
|
||||||
|
}
|
||||||
|
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
|
||||||
|
b, err := json.Marshal(seenFile{High: s.high, UIDs: uids})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, s.path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.dirty = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/email"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- a scripted IMAP server, same shape internal/email's tests use ---------
|
||||||
|
|
||||||
|
type fakeIMAP struct {
|
||||||
|
msgs map[uint32]string
|
||||||
|
uids []uint32
|
||||||
|
cmds []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIMAP) serve(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
fmt.Fprint(c, "* OK fake ready\r\n")
|
||||||
|
r := bufio.NewReader(c)
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(strings.TrimRight(line, "\r\n"), " ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, cmd := parts[0], parts[1]
|
||||||
|
f.cmds = append(f.cmds, cmd)
|
||||||
|
upper := strings.ToUpper(cmd)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||||
|
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||||
|
var ids []string
|
||||||
|
for _, u := range f.uids {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "* SEARCH %s\r\n%s OK\r\n", strings.Join(ids, " "), tag)
|
||||||
|
case strings.HasPrefix(upper, "UID FETCH"):
|
||||||
|
uid, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||||
|
if raw, ok := f.msgs[uint32(uid)]; ok {
|
||||||
|
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n%s)\r\n", uid, len(raw), raw)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "LOGOUT"):
|
||||||
|
fmt.Fprintf(c, "* BYE\r\n%s OK\r\n", tag)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(c, "%s BAD\r\n", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIMAP) dial(_ string, timeout time.Duration) (*email.Conn, error) {
|
||||||
|
cli, srv := net.Pipe()
|
||||||
|
go f.serve(srv)
|
||||||
|
return email.NewConn(cli, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- a fake core -----------------------------------------------------------
|
||||||
|
|
||||||
|
type fakeCore struct {
|
||||||
|
got []ipc.IngestMailReq
|
||||||
|
resp ipc.IngestMailResp
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeCore) IngestMail(_ context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||||
|
c.got = append(c.got, req)
|
||||||
|
if c.err != nil {
|
||||||
|
return ipc.IngestMailResp{}, c.err
|
||||||
|
}
|
||||||
|
return c.resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mail(subject, body string, extraHeaders ...string) string {
|
||||||
|
h := "Subject: " + subject + "\r\nFrom: a@b.c\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
||||||
|
for _, e := range extraHeaders {
|
||||||
|
h += e + "\r\n"
|
||||||
|
}
|
||||||
|
return h + "\r\n" + body + "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
|
||||||
|
t.Helper()
|
||||||
|
return &reader{
|
||||||
|
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
|
||||||
|
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
|
||||||
|
state: newSeenState(statePath),
|
||||||
|
dial: f.dial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollHandsMessagesToCore(t *testing.T) {
|
||||||
|
f := &fakeIMAP{
|
||||||
|
uids: []uint32{1, 2},
|
||||||
|
msgs: map[uint32]string{
|
||||||
|
1: mail("Счёт", "Оплатить до 5 августа."),
|
||||||
|
2: mail("Скидки", "Sale!", "List-Unsubscribe: <mailto:u@x>"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
core := &fakeCore{resp: ipc.IngestMailResp{TaskIDs: []int64{1}, Created: 1}}
|
||||||
|
r := newTestReader(t, f, core, "")
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
|
||||||
|
// The newsletter is filtered before core is asked: only the real mail crosses.
|
||||||
|
if len(core.got) != 1 {
|
||||||
|
t.Fatalf("core saw %d messages, want 1 (the bulk one must not cross): %+v", len(core.got), core.got)
|
||||||
|
}
|
||||||
|
got := core.got[0]
|
||||||
|
if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
|
||||||
|
t.Errorf("ingest req = %+v", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Body, "Оплатить") {
|
||||||
|
t.Errorf("body = %q", got.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second poll must not re-send what core already saw — extraction is a
|
||||||
|
// multi-second LLM call per message.
|
||||||
|
func TestPollSkipsSeenUIDs(t *testing.T) {
|
||||||
|
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
|
||||||
|
core := &fakeCore{}
|
||||||
|
r := newTestReader(t, f, core, "")
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
if len(core.got) != 1 {
|
||||||
|
t.Errorf("core saw %d messages over two polls, want 1", len(core.got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ingest that failed is NOT marked seen: the next poll retries it.
|
||||||
|
func TestPollRetriesFailedIngest(t *testing.T) {
|
||||||
|
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
|
||||||
|
core := &fakeCore{err: fmt.Errorf("llama-server is warming up")}
|
||||||
|
r := newTestReader(t, f, core, "")
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
core.err = nil
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
if len(core.got) != 2 {
|
||||||
|
t.Errorf("core saw %d attempts, want 2 (a failed ingest is retried)", len(core.got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core without an email block ⇒ stop, don't hammer the socket.
|
||||||
|
func TestPollStopsWhenCoreRefusesMail(t *testing.T) {
|
||||||
|
f := &fakeIMAP{uids: []uint32{1, 2}, msgs: map[uint32]string{1: mail("a", "b"), 2: mail("c", "d")}}
|
||||||
|
core := &fakeCore{err: fmt.Errorf("call: %w", ipc.ErrUnknownMethod)}
|
||||||
|
r := newTestReader(t, f, core, "")
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
if !r.disabled {
|
||||||
|
t.Error("ErrUnknownMethod must disable the reader")
|
||||||
|
}
|
||||||
|
if len(core.got) != 1 {
|
||||||
|
t.Errorf("core saw %d messages, want 1 — stop at the first refusal", len(core.got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeenStatePersists(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state", "seen.json")
|
||||||
|
f := &fakeIMAP{uids: []uint32{9}, msgs: map[uint32]string{9: mail("Счёт", "текст")}}
|
||||||
|
core := &fakeCore{}
|
||||||
|
r := newTestReader(t, f, core, path)
|
||||||
|
r.pollOnce(context.Background(), "secret")
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("state file: %v", err)
|
||||||
|
}
|
||||||
|
// A list of message ids from his mailbox is metadata about his mail.
|
||||||
|
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Errorf("state file mode = %v, want 0600", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh reader with the same state file must not re-read the message.
|
||||||
|
core2 := &fakeCore{}
|
||||||
|
r2 := newTestReader(t, f, core2, path)
|
||||||
|
if err := r2.state.load(); err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
r2.pollOnce(context.Background(), "secret")
|
||||||
|
if len(core2.got) != 0 {
|
||||||
|
t.Errorf("after a restart core saw %d messages, want 0", len(core2.got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeenStateHighWaterMark(t *testing.T) {
|
||||||
|
s := newSeenState("")
|
||||||
|
s.mark(1)
|
||||||
|
s.mark(3)
|
||||||
|
s.mark(2)
|
||||||
|
if s.high != 3 {
|
||||||
|
t.Errorf("high = %d, want 3 (contiguous run collapses)", s.high)
|
||||||
|
}
|
||||||
|
if len(s.set) != 0 {
|
||||||
|
t.Errorf("explicit set = %v, want empty", s.set)
|
||||||
|
}
|
||||||
|
if !s.seen(2) || s.seen(4) {
|
||||||
|
t.Errorf("seen(2)=%v seen(4)=%v", s.seen(2), s.seen(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeenStateCorruptFileIsNotFatal(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "seen.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := newSeenState(path)
|
||||||
|
if err := s.load(); err == nil {
|
||||||
|
t.Error("a corrupt state file should report an error the caller logs")
|
||||||
|
}
|
||||||
|
if s.seen(1) {
|
||||||
|
t.Error("a corrupt state file must leave an empty seen-set, not a poisoned one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off unless configured, and the credential is never a flag value.
|
||||||
|
func TestRunRequiresConfig(t *testing.T) {
|
||||||
|
if err := run([]string{}); err == nil {
|
||||||
|
t.Error("no -socket must be an error")
|
||||||
|
}
|
||||||
|
if err := run([]string{"-socket", "/tmp/nope.sock"}); err == nil {
|
||||||
|
t.Error("no mailbox configuration must be an error, not a default mailbox")
|
||||||
|
}
|
||||||
|
// There is no -password flag at all: only -password-file.
|
||||||
|
if err := run([]string{"-socket", "/x", "-imap", "h", "-user", "u", "-password", "p"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "flag provided but not defined") {
|
||||||
|
t.Errorf("a -password flag must not exist; err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsEmptyPasswordFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "pass")
|
||||||
|
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := run([]string{"-socket", "/x/y.sock", "-imap", "h", "-user", "u", "-password-file", path})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||||
|
t.Errorf("an empty password file must be refused before dialling; err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
-3
@@ -9,6 +9,12 @@
|
|||||||
// Two sources, each its own provenance (the loop's rules trust source):
|
// Two sources, each its own provenance (the loop's rules trust source):
|
||||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
||||||
|
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
|
||||||
|
//
|
||||||
|
// The zenmoney source is why the token lives HERE and not in core: the poller
|
||||||
|
// already owns every other third-party credential, it holds no store key, and
|
||||||
|
// core never needs to know an account exists to answer a question about a fact
|
||||||
|
// the poller wrote. It is off unless -zenmoney-token-file is given.
|
||||||
//
|
//
|
||||||
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
|
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
|
||||||
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
||||||
@@ -37,6 +43,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -52,6 +59,9 @@ func run(args []string) error {
|
|||||||
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
||||||
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
|
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
|
||||||
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
|
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
|
||||||
|
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
|
||||||
|
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
|
||||||
|
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
|
||||||
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
|
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
|
||||||
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
|
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
|
||||||
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
||||||
@@ -62,8 +72,24 @@ func run(args []string) error {
|
|||||||
if *socket == "" {
|
if *socket == "" {
|
||||||
return fmt.Errorf("-socket is required")
|
return fmt.Errorf("-socket is required")
|
||||||
}
|
}
|
||||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
|
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
|
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is read from a file, never taken as a flag value: an argv token
|
||||||
|
// is visible in `ps` to every user on the box and lands in the compose file
|
||||||
|
// and the shell history. Read once at start — a rotated token means a
|
||||||
|
// restart, which is cheaper than re-reading his credential every hour.
|
||||||
|
var zen *zenmoney.Client
|
||||||
|
if *zenTokenFile != "" {
|
||||||
|
raw, err := os.ReadFile(*zenTokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read zenmoney token: %w", err)
|
||||||
|
}
|
||||||
|
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -83,9 +109,13 @@ func run(args []string) error {
|
|||||||
kumaKey: *kumaKey,
|
kumaKey: *kumaKey,
|
||||||
wgIface: *wgIface,
|
wgIface: *wgIface,
|
||||||
wgCmd: *wgCmd,
|
wgCmd: *wgCmd,
|
||||||
|
zen: zen,
|
||||||
|
zenEvery: *zenInterval,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface)
|
// The token is never logged, not even its length.
|
||||||
|
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
|
||||||
|
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
|
||||||
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||||
t := time.NewTicker(*interval)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -108,6 +138,12 @@ type poller struct {
|
|||||||
kumaKey string
|
kumaKey string
|
||||||
wgIface string
|
wgIface string
|
||||||
wgCmd string
|
wgCmd string
|
||||||
|
|
||||||
|
// zen is nil unless a token file was configured — money tracking is a
|
||||||
|
// capability, off by default like weather and telegram.
|
||||||
|
zen *zenmoney.Client
|
||||||
|
zenEvery time.Duration
|
||||||
|
zenLast time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||||
@@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
log.Printf("mavpoll: wg: %v", err)
|
log.Printf("mavpoll: wg: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Money on its own, much slower cadence: a bank feed that updates hourly
|
||||||
|
// polled every minute is 60 pointless reads of his financial history.
|
||||||
|
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
|
||||||
|
p.zenLast = now
|
||||||
|
if err := p.pollZenmoney(ctx, now); err != nil {
|
||||||
|
log.Printf("mavpoll: zenmoney: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- zenmoney: spending/income totals → money facts ------------------------
|
||||||
|
|
||||||
|
// pollZenmoney reads today's and this month's totals and writes them as
|
||||||
|
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
|
||||||
|
//
|
||||||
|
// Two properties this function exists to hold:
|
||||||
|
//
|
||||||
|
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
|
||||||
|
// to encode a summary built from zero transactions, so a poller that cannot
|
||||||
|
// reach the API leaves the last good fact in place rather than overwriting
|
||||||
|
// it with a zero Maven would then recite as fact.
|
||||||
|
// - Nothing about the money leaves the box except the diff request itself, to
|
||||||
|
// the service that already holds his bank sessions. The totals are written
|
||||||
|
// to the store and read back only when he asks; they are never search input
|
||||||
|
// and no tick rule fires on them.
|
||||||
|
//
|
||||||
|
// Both windows are read from one diff call each. Two calls an hour against an
|
||||||
|
// API whose whole job is this is not worth caching.
|
||||||
|
// moneyWindow — one fact key and the period it covers.
|
||||||
|
type moneyWindow struct {
|
||||||
|
key string
|
||||||
|
from, to time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||||
|
dFrom, dTo := zenmoney.DayWindow(now)
|
||||||
|
mFrom, mTo := zenmoney.MonthWindow(now)
|
||||||
|
windows := []moneyWindow{
|
||||||
|
{zenmoney.KeySpentToday, dFrom, dTo},
|
||||||
|
{zenmoney.KeySpentMonth, mFrom, mTo},
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
for _, w := range windows {
|
||||||
|
sum, err := p.zen.Since(ctx, w.from, w.to)
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, ok := sum.Value()
|
||||||
|
if !ok {
|
||||||
|
// Nothing read. Silence, not a zero.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||||
@@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
|
||||||
|
// money facts store an object, not a string). Kept separate rather than
|
||||||
|
// generalising writeIfChanged, because the string-valued env facts encoding
|
||||||
|
// their own value is the convention the rules rely on.
|
||||||
|
//
|
||||||
|
// The log line names the key and the source, never the figures: mavpoll's log
|
||||||
|
// is not the place his spending ends up.
|
||||||
|
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||||
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||||
|
switch {
|
||||||
|
case err == nil && prev.Value == jsonVal:
|
||||||
|
return nil
|
||||||
|
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||||
|
return fmt.Errorf("read %s: %w", key, err)
|
||||||
|
}
|
||||||
|
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||||
|
Source: source, Confidence: 1.0,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", key, err)
|
||||||
|
}
|
||||||
|
log.Printf("mavpoll: %s updated (%s)", key, source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||||
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||||
func isNoFact(err error) bool {
|
func isNoFact(err error) bool {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaxSeverity(t *testing.T) {
|
func TestMaxSeverity(t *testing.T) {
|
||||||
@@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- zenmoney (Vikunja #125) ----------------------------------------------
|
||||||
|
|
||||||
|
// factCore records the facts the poller wrote and answers "no fact yet".
|
||||||
|
type factCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
written []ipc.WriteFactReq
|
||||||
|
prev map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
if v, ok := c.prev[key+"|"+source]; ok {
|
||||||
|
return ipc.Fact{Key: key, Source: source, Value: v}, nil
|
||||||
|
}
|
||||||
|
return ipc.Fact{}, ipc.ErrNoFact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||||
|
c.written = append(c.written, req)
|
||||||
|
return int64(len(c.written)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if status != http.StatusOK {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(body)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
|
||||||
|
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := zenFixtureServer(t, body, http.StatusOK)
|
||||||
|
defer srv.Close()
|
||||||
|
zen, err := zenmoney.New("tok", srv.URL, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
core := &factCore{}
|
||||||
|
p := &poller{core: core, zen: zen}
|
||||||
|
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
|
||||||
|
if err := p.pollZenmoney(context.Background(), now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(core.written) != 2 {
|
||||||
|
t.Fatalf("wrote %d facts, want today + month", len(core.written))
|
||||||
|
}
|
||||||
|
for _, f := range core.written {
|
||||||
|
if f.Kind != "env" || f.Source != zenmoney.Source {
|
||||||
|
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
|
||||||
|
}
|
||||||
|
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
|
||||||
|
t.Errorf("fact value %q does not decode: %v", f.Value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read that returns nothing for the window writes NOTHING. Silence, not a
|
||||||
|
// zero: an invented 0 would be recited back to him as fact.
|
||||||
|
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
|
||||||
|
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
|
||||||
|
defer srv.Close()
|
||||||
|
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
|
||||||
|
core := &factCore{}
|
||||||
|
p := &poller{core: core, zen: zen}
|
||||||
|
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(core.written) != 0 {
|
||||||
|
t.Errorf("wrote %+v, want no fact at all", core.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An API failure must not overwrite the last good total either.
|
||||||
|
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
|
||||||
|
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
|
||||||
|
defer srv.Close()
|
||||||
|
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
|
||||||
|
core := &factCore{}
|
||||||
|
p := &poller{core: core, zen: zen}
|
||||||
|
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
|
||||||
|
t.Error("want the 401 reported")
|
||||||
|
}
|
||||||
|
if len(core.written) != 0 {
|
||||||
|
t.Errorf("wrote %+v on a failed read", core.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged totals do not churn the facts table.
|
||||||
|
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
|
||||||
|
core := &factCore{prev: map[string]string{
|
||||||
|
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
|
||||||
|
}}
|
||||||
|
p := &poller{core: core}
|
||||||
|
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(core.written) != 0 {
|
||||||
|
t.Errorf("wrote %+v for an unchanged value", core.written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money tracking is off unless configured: no token file, no zenmoney client,
|
||||||
|
// and the poller still refuses to start with nothing at all to poll.
|
||||||
|
func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||||
|
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
|
||||||
|
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Golden-audio STT tests (Vikunja #288).
|
||||||
|
//
|
||||||
|
// These push real audio through the real whisper.cpp binding, so a bad model
|
||||||
|
// path, a wrong language hint, a broken resample or a regressed silence gate
|
||||||
|
// is caught by `make test` rather than by the owner talking to a daemon that
|
||||||
|
// mishears him.
|
||||||
|
//
|
||||||
|
// The fixtures are piper-synthesised, not recorded — see
|
||||||
|
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
|
||||||
|
// any fixture can be rebuilt from the script plus a voice model.
|
||||||
|
//
|
||||||
|
// Matching is deliberately tolerant. Golden transcripts are model-dependent:
|
||||||
|
// swapping ggml-small for a different whisper build moves punctuation, casing
|
||||||
|
// and the odd word ending, and an exact-string assertion would turn every
|
||||||
|
// model swap into a fixture rewrite. Each case therefore asserts two things —
|
||||||
|
// the words that carry the intent are present, and the word error rate
|
||||||
|
// against the reference stays under a per-case ceiling.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenModelPath — the whisper model the golden tests run against. Same file
|
||||||
|
// the Makefile's run-stt target uses. Overridable so a box that keeps its
|
||||||
|
// models elsewhere can still run these.
|
||||||
|
func goldenModelPath() string {
|
||||||
|
if p := os.Getenv("MAVEN_WHISPER_MODEL"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Join("..", "..", "models", "stt", "ggml-small.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
WAV string `json:"wav"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
MaxWER float64 `json:"max_wer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenManifest struct {
|
||||||
|
Cases []goldenCase `json:"cases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGoldenManifest(t *testing.T) goldenManifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("testdata", "golden_v1.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden manifest: %v", err)
|
||||||
|
}
|
||||||
|
var m goldenManifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("parse golden manifest: %v", err)
|
||||||
|
}
|
||||||
|
if len(m.Cases) == 0 {
|
||||||
|
t.Fatal("golden manifest has no cases")
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeTranscript lowercases, drops punctuation, folds the Russian ё onto
|
||||||
|
// е (whisper is inconsistent about it and the router does not care), and
|
||||||
|
// collapses whitespace. Everything the comparison does happens on this form.
|
||||||
|
func normalizeTranscript(s string) []string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
switch {
|
||||||
|
case r == 'ё':
|
||||||
|
b.WriteRune('е')
|
||||||
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Fields(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordErrorRate is the Levenshtein distance between two word sequences,
|
||||||
|
// divided by the length of the reference. 0 means identical; it can exceed 1
|
||||||
|
// when the hypothesis is much longer than the reference.
|
||||||
|
func wordErrorRate(ref, hyp []string) float64 {
|
||||||
|
if len(ref) == 0 {
|
||||||
|
if len(hyp) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
prev := make([]int, len(hyp)+1)
|
||||||
|
cur := make([]int, len(hyp)+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= len(ref); i++ {
|
||||||
|
cur[0] = i
|
||||||
|
for j := 1; j <= len(hyp); j++ {
|
||||||
|
cost := 1
|
||||||
|
if ref[i-1] == hyp[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
cur[j] = min(prev[j]+1, min(cur[j-1]+1, prev[j-1]+cost))
|
||||||
|
}
|
||||||
|
prev, cur = cur, prev
|
||||||
|
}
|
||||||
|
return float64(prev[len(hyp)]) / float64(len(ref))
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingKeywords returns the keywords absent from the hypothesis. A keyword
|
||||||
|
// matches on prefix, so a different case ending ("воды" vs "воду") does not
|
||||||
|
// fail the assertion — the router's stage-0 grammar is stem-shaped too.
|
||||||
|
func missingKeywords(keywords []string, hyp []string) []string {
|
||||||
|
var missing []string
|
||||||
|
for _, kw := range keywords {
|
||||||
|
want := normalizeTranscript(kw)
|
||||||
|
if len(want) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containsSeq(hyp, want) {
|
||||||
|
missing = append(missing, kw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSeq(hyp, want []string) bool {
|
||||||
|
for i := 0; i+len(want) <= len(hyp); i++ {
|
||||||
|
ok := true
|
||||||
|
for j, w := range want {
|
||||||
|
// Prefix match, so inflection differences pass but
|
||||||
|
// distinct words do not.
|
||||||
|
if !looseWordMatch(hyp[i+j], w) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func looseWordMatch(got, want string) bool {
|
||||||
|
if got == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
g, w := []rune(got), []rune(want)
|
||||||
|
n := len(w) - 1
|
||||||
|
if len(w) > 6 {
|
||||||
|
n = len(w) - 2
|
||||||
|
}
|
||||||
|
// Words of three runes or fewer have no room for a safe prefix: require
|
||||||
|
// an exact match rather than letting "час" pass for "часть".
|
||||||
|
if n < 3 || len(g) < n {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(g[:n]) == string(w[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the model-backed test -------------------------------------------------
|
||||||
|
|
||||||
|
func TestGoldenAudioTranscription(t *testing.T) {
|
||||||
|
m := loadGoldenManifest(t)
|
||||||
|
|
||||||
|
model := goldenModelPath()
|
||||||
|
if _, err := os.Stat(model); err != nil {
|
||||||
|
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gate thresholds as mavsttd's defaults, so a regression in the
|
||||||
|
// silence gate shows up here as an empty transcript.
|
||||||
|
h, err := newWhisperHandler(model, 300, 0.01)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load whisper model %s: %v", model, err)
|
||||||
|
}
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
for _, c := range m.Cases {
|
||||||
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
|
path := filepath.Join("testdata", c.WAV)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
|
||||||
|
}
|
||||||
|
format, pcm, err := audio.PCMFromWAV(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s is not canonical 16k mono PCM: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.Transcribe(context.Background(), worker.TranscribeReq{
|
||||||
|
Audio: audio.Audio{Format: format, Bytes: pcm},
|
||||||
|
Lang: c.Lang,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transcribe %s: %v", c.WAV, err)
|
||||||
|
}
|
||||||
|
t.Logf("%s → %q (confidence %.3f)", c.WAV, resp.Text, resp.Confidence)
|
||||||
|
|
||||||
|
if strings.TrimSpace(resp.Text) == "" {
|
||||||
|
t.Fatalf("%s transcribed to empty text — the silence gate ate real speech", c.WAV)
|
||||||
|
}
|
||||||
|
if resp.Confidence <= 0 {
|
||||||
|
t.Errorf("%s: confidence %v, want > 0", c.WAV, resp.Confidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
hyp := normalizeTranscript(resp.Text)
|
||||||
|
ref := normalizeTranscript(c.Text)
|
||||||
|
|
||||||
|
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
|
||||||
|
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
|
||||||
|
}
|
||||||
|
if wer := wordErrorRate(ref, hyp); wer > c.MaxWER {
|
||||||
|
t.Errorf("%s: WER %.2f > %.2f\n want: %q\n got: %q", c.WAV, wer, c.MaxWER, c.Text, resp.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenFixturesAreCanonical checks the committed audio without needing a
|
||||||
|
// model, so a fixture regenerated at the wrong sample rate fails on every box.
|
||||||
|
func TestGoldenFixturesAreCanonical(t *testing.T) {
|
||||||
|
m := loadGoldenManifest(t)
|
||||||
|
for _, c := range m.Cases {
|
||||||
|
path := filepath.Join("testdata", c.WAV)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("fixture %s missing: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
format, pcm, err := audio.PCMFromWAV(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !format.IsValid() {
|
||||||
|
t.Errorf("%s: format %+v is not canonical", path, format)
|
||||||
|
}
|
||||||
|
a := audio.Audio{Format: format, Bytes: pcm}
|
||||||
|
if d := a.Duration(); d < 0.5 || d > 10 {
|
||||||
|
t.Errorf("%s: duration %.2fs outside the sane 0.5–10s fixture range", path, d)
|
||||||
|
}
|
||||||
|
// The fixture must clear mavsttd's own silence gate, otherwise the
|
||||||
|
// model test below would be asserting on a gated empty string.
|
||||||
|
if reason := gateReason(pcmToF32(pcm), whisperSampleRate, 300, 0.01); reason != "" {
|
||||||
|
t.Errorf("%s: would be gated as %s", path, reason)
|
||||||
|
}
|
||||||
|
if len(c.Keywords) == 0 {
|
||||||
|
t.Errorf("%s: manifest case has no keywords", c.Name)
|
||||||
|
}
|
||||||
|
if c.MaxWER <= 0 || c.MaxWER > 1 {
|
||||||
|
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pcmToF32(b []byte) []float32 {
|
||||||
|
out := make([]float32, len(b)/2)
|
||||||
|
for i := range out {
|
||||||
|
s := int16(b[i*2]) | int16(b[i*2+1])<<8
|
||||||
|
out[i] = float32(s) / 32768.0
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- matcher unit tests (no model, no fixtures) ----------------------------
|
||||||
|
|
||||||
|
func TestNormalizeTranscript(t *testing.T) {
|
||||||
|
got := normalizeTranscript(" Ещё, Раз... ")
|
||||||
|
want := []string{"еще", "раз"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("normalizeTranscript = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordErrorRate(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ref, hyp string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"identical", "напомни мне через час", "Напомни мне через час.", 0},
|
||||||
|
{"one substitution", "напомни мне через час", "напомни мне через день", 0.25},
|
||||||
|
{"one deletion", "напомни мне через час", "напомни мне час", 0.25},
|
||||||
|
{"empty hypothesis", "напомни мне", "", 1},
|
||||||
|
{"both empty", "", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := wordErrorRate(normalizeTranscript(c.ref), normalizeTranscript(c.hyp))
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("WER = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingKeywords(t *testing.T) {
|
||||||
|
hyp := normalizeTranscript("Отметь, что я выпил воду.")
|
||||||
|
if got := missingKeywords([]string{"воды", "отметь"}, hyp); len(got) != 0 {
|
||||||
|
t.Fatalf("missingKeywords = %v, want none (inflection must not fail the match)", got)
|
||||||
|
}
|
||||||
|
if got := missingKeywords([]string{"календарю"}, hyp); len(got) != 1 {
|
||||||
|
t.Fatalf("missingKeywords = %v, want the absent keyword reported", got)
|
||||||
|
}
|
||||||
|
// A short word must match exactly — no 4-rune prefix shortcut that would
|
||||||
|
// let "час" pass for "часть".
|
||||||
|
hyp2 := normalizeTranscript("через час")
|
||||||
|
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
|
||||||
|
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh. Regenerate with that script; do not hand-edit `wav`.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "ru_reminder",
|
||||||
|
"wav": "ru_reminder.wav",
|
||||||
|
"lang": "ru",
|
||||||
|
"text": "напомни мне через час позвонить маме",
|
||||||
|
"keywords": ["напомни", "час", "позвонить"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ru_fact",
|
||||||
|
"wav": "ru_fact.wav",
|
||||||
|
"lang": "ru",
|
||||||
|
"text": "отметь что я выпил воды",
|
||||||
|
"keywords": ["отметь", "воды"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ru_query",
|
||||||
|
"wav": "ru_query.wav",
|
||||||
|
"lang": "ru",
|
||||||
|
"text": "что у меня сегодня по календарю",
|
||||||
|
"keywords": ["сегодня", "календарю"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "en_act",
|
||||||
|
"wav": "en_act.wav",
|
||||||
|
"lang": "en",
|
||||||
|
"text": "restart the web server and check the disk space",
|
||||||
|
"keywords": ["restart", "server", "disk"],
|
||||||
|
"max_wer": 0.34
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,204 @@
|
|||||||
|
// Command mavupdate deploys a new build of Maven to the box she runs on, with
|
||||||
|
// an automatic rollback when the new build does not come up (Vikunja #249).
|
||||||
|
//
|
||||||
|
// It is a CLI on purpose, and it is the ONLY trigger for the update path.
|
||||||
|
//
|
||||||
|
// The obvious design — an IPC method plus a button on the web UI behind the
|
||||||
|
// step-up passkey gate, the way /tools works — was considered and refused. A
|
||||||
|
// step-up gate protects against the wrong person clicking; it does not change
|
||||||
|
// the fact that anything reachable over the network becomes, in the event of a
|
||||||
|
// mavweb bug, a remote arbitrary-code path with a build system attached. An
|
||||||
|
// update needs shell access on the host, which is a strictly higher bar than
|
||||||
|
// the gate that guards the tool allowlist. That is deliberate and it is the
|
||||||
|
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
|
||||||
|
//
|
||||||
|
// Consequently: mavend does not import internal/update, nothing runs on a timer,
|
||||||
|
// nothing checks a release server, and no act, intent, tool or LLM output can
|
||||||
|
// reach any of this. She cannot update herself. She can be updated, by him.
|
||||||
|
//
|
||||||
|
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
|
||||||
|
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||||
|
// mavupdate -config deploy/mavend.json apply -yes # the whole thing
|
||||||
|
// mavupdate -config deploy/mavend.json rollback [id] # restore + restart (default: newest)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/update"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "deploy/mavend.json", "path to mavend.json (the update block is read from it)")
|
||||||
|
yes := flag.Bool("yes", false, "required by `apply` and `rollback`: yes, restart the daemon")
|
||||||
|
flag.Usage = usage
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// The stdlib flag package stops parsing at the first non-flag argument, so a
|
||||||
|
// `-yes` written after the subcommand (which is how anyone would type it, and
|
||||||
|
// how the usage text shows it) lands in Args instead of the flag. Pick it out
|
||||||
|
// by hand rather than silently treating "apply -yes" as an unconfirmed apply.
|
||||||
|
var args []string
|
||||||
|
for _, a := range flag.Args() {
|
||||||
|
if a == "-yes" || a == "--yes" {
|
||||||
|
*yes = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args = append(args, a)
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
die("config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Update == nil {
|
||||||
|
die("no `update` block in %s — the update capability is off unless configured.\nSee the package comment in internal/update for what it does and does not do.", *cfgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
logf := func(format string, a ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...))
|
||||||
|
}
|
||||||
|
u, err := update.New(*cfg.Update, update.WithLogger(logf))
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl-C cancels the build or the health wait. It cannot cancel a rollback
|
||||||
|
// midway into leaving the box in an unknown state, because the rollback runs
|
||||||
|
// on its own context — see cmdApply.
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
cmdList(u)
|
||||||
|
case "verify":
|
||||||
|
cmdVerify(ctx, u)
|
||||||
|
case "apply":
|
||||||
|
if !*yes {
|
||||||
|
die("apply restarts mavend and can roll her back. Re-run with -yes if that is what you want.")
|
||||||
|
}
|
||||||
|
cmdApply(ctx, u)
|
||||||
|
case "rollback":
|
||||||
|
if !*yes {
|
||||||
|
die("rollback restores the previous artifacts and restarts mavend. Re-run with -yes.")
|
||||||
|
}
|
||||||
|
id := ""
|
||||||
|
if len(args) > 1 {
|
||||||
|
id = args[1]
|
||||||
|
}
|
||||||
|
cmdRollback(ctx, u, id)
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdList(u *update.Updater) {
|
||||||
|
snaps, err := u.Snapshots()
|
||||||
|
if err != nil {
|
||||||
|
die("snapshots: %v", err)
|
||||||
|
}
|
||||||
|
if len(snaps) == 0 {
|
||||||
|
fmt.Println("no snapshots yet — the first `apply` takes one before it builds anything")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%-18s %-12s %s\n", "SNAPSHOT", "COMMIT", "FILES")
|
||||||
|
for _, s := range snaps {
|
||||||
|
commit := s.Commit
|
||||||
|
if len(commit) > 12 {
|
||||||
|
commit = commit[:12]
|
||||||
|
}
|
||||||
|
if commit == "" {
|
||||||
|
commit = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-18s %-12s %d\n", s.ID, commit, len(s.Files))
|
||||||
|
}
|
||||||
|
fmt.Printf("\nrollback to the newest with: mavupdate rollback -yes\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdVerify(ctx context.Context, u *update.Updater) {
|
||||||
|
steps, err := u.Verify(ctx)
|
||||||
|
report(steps)
|
||||||
|
if err != nil {
|
||||||
|
die("%v", err)
|
||||||
|
}
|
||||||
|
fmt.Println("verified: the tree builds and passes its own tests. Nothing was deployed — run `apply -yes` for that.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdApply(ctx context.Context, u *update.Updater) {
|
||||||
|
res, err := u.Apply(ctx)
|
||||||
|
report(res.Steps)
|
||||||
|
summarize(res)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
fmt.Println("\nupdate committed: she answers on the new build.")
|
||||||
|
case errors.Is(err, update.ErrRollbackFailed):
|
||||||
|
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
||||||
|
case errors.Is(err, update.ErrRolledBack):
|
||||||
|
die("\n%v\n\nShe is answering again on the previous build. Nothing was lost; fix the change and retry.", err)
|
||||||
|
default:
|
||||||
|
die("\n%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRollback(ctx context.Context, u *update.Updater, id string) {
|
||||||
|
res, err := u.Rollback(ctx, id)
|
||||||
|
report(res.Steps)
|
||||||
|
summarize(res)
|
||||||
|
if err != nil && !errors.Is(err, update.ErrRolledBack) {
|
||||||
|
die("\n%v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nrolled back to %s; she answers on it.\n", res.SnapshotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(steps []update.Step) {
|
||||||
|
for _, s := range steps {
|
||||||
|
status := "ok"
|
||||||
|
if s.Err != nil {
|
||||||
|
status = "FAILED: " + s.Err.Error()
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-8s %-8s %s\n", s.Name, s.Took.Round(time.Second), status)
|
||||||
|
if s.Output != "" {
|
||||||
|
fmt.Printf("---- %s output ----\n%s\n-------------------\n", s.Name, s.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarize(res update.Result) {
|
||||||
|
fmt.Printf("\nverified=%v snapshot=%s installed=%d restarted=%v healthy=%v rolled_back=%v rollback_healthy=%v took=%s\n",
|
||||||
|
res.Verified, res.SnapshotID, len(res.Installed), res.Restarted, res.Healthy, res.RolledBack, res.RollbackHealthy, res.Took.Round(time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprint(os.Stderr, `mavupdate — deploy a new build of Maven, with rollback.
|
||||||
|
|
||||||
|
mavupdate [-config path] list
|
||||||
|
mavupdate [-config path] verify
|
||||||
|
mavupdate [-config path] apply -yes
|
||||||
|
mavupdate [-config path] rollback [snapshot-id] -yes
|
||||||
|
|
||||||
|
apply is: health-check the running daemon, snapshot the deployed artifacts,
|
||||||
|
make build, make test, install, restart, health-check — and restore the
|
||||||
|
snapshot if any of that fails. It never fetches code and never runs by itself.
|
||||||
|
|
||||||
|
`)
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(format string, a ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
+33
-92
@@ -12,8 +12,15 @@
|
|||||||
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
||||||
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
||||||
//
|
//
|
||||||
|
// While a reply is playing the capture side is muted (half-duplex): without
|
||||||
|
// it, Maven's own voice comes back in through the mic and she answers
|
||||||
|
// herself. -barge-in punches one hole in that gate — sustained energy well
|
||||||
|
// above the speaker's leak level cuts playback so he can talk over her. It is
|
||||||
|
// off by default because the threshold is room-specific; see playback.go.
|
||||||
|
//
|
||||||
// usage:
|
// usage:
|
||||||
// mavwaked # default ALSA device, 127.0.0.1:9100
|
// mavwaked # default ALSA device, 127.0.0.1:9100
|
||||||
|
// mavwaked -barge-in # let him interrupt her mid-reply
|
||||||
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
|
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
|
||||||
// mavwaked -test file.wav # read from file, no arecord
|
// mavwaked -test file.wav # read from file, no arecord
|
||||||
package main
|
package main
|
||||||
@@ -60,6 +67,9 @@ func run(args []string) error {
|
|||||||
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
|
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
|
||||||
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
|
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
|
||||||
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
|
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
|
||||||
|
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
|
||||||
|
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
|
||||||
|
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
@@ -117,14 +127,21 @@ func run(args []string) error {
|
|||||||
|
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
|
|
||||||
return captureLoop(ctx, src, vad, vc, *lang)
|
var barge bargeInConfig
|
||||||
|
if *bargeIn {
|
||||||
|
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
|
||||||
|
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
|
||||||
|
}
|
||||||
|
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
|
||||||
|
|
||||||
|
return captureLoop(ctx, src, sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
// captureLoop reads PCM from src, runs VAD, and sends complete utterances to
|
// captureLoop reads PCM from src and hands whole frames to the session.
|
||||||
// the voice server. Returns when ctx is done or src is exhausted.
|
// Returns when ctx is done or src is exhausted.
|
||||||
func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client, lang string) error {
|
func captureLoop(ctx context.Context, src io.Reader, sess *session) error {
|
||||||
br := bufio.NewReaderSize(src, defaultReadSize)
|
br := bufio.NewReaderSize(src, defaultReadSize)
|
||||||
frameBytes := vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
|
frameBytes := sess.vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
|
||||||
|
|
||||||
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
|
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
|
||||||
frameBytes, defaultFrameMs)
|
frameBytes, defaultFrameMs)
|
||||||
@@ -147,7 +164,7 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
|
|||||||
// Flush partial frame.
|
// Flush partial frame.
|
||||||
partial = append(partial, buf[:n]...)
|
partial = append(partial, buf[:n]...)
|
||||||
if len(partial) >= frameBytes {
|
if len(partial) >= frameBytes {
|
||||||
if err := processFrame(partial[:frameBytes], vad, vc, lang); err != nil {
|
if err := sess.feed(ctx, partial[:frameBytes]); err != nil {
|
||||||
log.Printf("mavwaked: process frame: %v", err)
|
log.Printf("mavwaked: process frame: %v", err)
|
||||||
}
|
}
|
||||||
partial = partial[frameBytes:]
|
partial = partial[frameBytes:]
|
||||||
@@ -165,107 +182,31 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
|
|||||||
partial = nil
|
partial = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := processFrame(full, vad, vc, lang); err != nil {
|
if err := sess.feed(ctx, full); err != nil {
|
||||||
log.Printf("mavwaked: process frame: %v", err)
|
log.Printf("mavwaked: process frame: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFrame feeds one 30ms PCM frame to the VAD and sends any completed
|
// voiceSender is the production utteranceSender: one PushToTalk round-trip
|
||||||
// utterance to the voice server.
|
// over the voice wire. SurfaceVoice (not the default SurfacePCClient that
|
||||||
func processFrame(frame []byte, vad *VAD, vc *voice.Client, lang string) error {
|
// c.PushToTalk uses) caps everything at L0, which is what makes an accidental
|
||||||
samples := PCMToI16(frame)
|
// VAD trigger safe.
|
||||||
utt, state := vad.Feed(samples)
|
type voiceSender struct{ vc *voice.Client }
|
||||||
|
|
||||||
if state == StateSpeech {
|
func (s *voiceSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
|
||||||
// Speech is in progress; nothing to send yet.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if utt.Bytes == nil {
|
|
||||||
// Still in silence, or short speech that didn't trigger.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// We have a complete utterance — send it to the voice server.
|
|
||||||
return sendUtterance(context.Background(), utt, vc, lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendUtterance sends audio to the voice server and plays the reply.
|
|
||||||
func sendUtterance(ctx context.Context, utt audio.Audio, vc *voice.Client, lang string) error {
|
|
||||||
dur := utt.Duration()
|
|
||||||
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...",
|
|
||||||
dur, len(utt.Bytes))
|
|
||||||
|
|
||||||
// Use SendRequest directly so we can set SurfaceVoice instead of the
|
|
||||||
// default SurfacePCClient that c.PushToTalk uses.
|
|
||||||
var resp voice.PushToTalkResp
|
var resp voice.PushToTalkResp
|
||||||
err := vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
|
err := s.vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
|
||||||
Audio: utt,
|
Audio: utt,
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
Surface: voice.SurfaceVoice,
|
Surface: voice.SurfaceVoice,
|
||||||
}, &resp)
|
}, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("push-to-talk: %w", err)
|
return audio.Audio{}, fmt.Errorf("push-to-talk: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
|
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
|
||||||
|
|
||||||
// Play the reply audio.
|
|
||||||
if len(resp.ReplyAudio.Bytes) > 0 {
|
|
||||||
go playAudio(resp.ReplyAudio)
|
|
||||||
} else {
|
|
||||||
log.Printf("mavwaked: empty reply audio (text only)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.RoutedChannels) > 0 {
|
if len(resp.RoutedChannels) > 0 {
|
||||||
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
|
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
|
||||||
}
|
}
|
||||||
|
return resp.ReplyAudio, nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// playAudio pipes PCM audio to aplay(1) for playback. Runs in a goroutine.
|
|
||||||
func playAudio(a audio.Audio) {
|
|
||||||
// Build WAV header for aplay (or pipe raw PCM with the right format flags).
|
|
||||||
cmd := exec.Command("aplay",
|
|
||||||
"-f", "S16_LE",
|
|
||||||
"-r", fmt.Sprintf("%d", a.Format.SampleRate),
|
|
||||||
"-c", fmt.Sprintf("%d", a.Format.Channels),
|
|
||||||
"-t", "raw",
|
|
||||||
)
|
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Printf("mavwaked: start aplay: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write audio to aplay's stdin.
|
|
||||||
if _, err := stdin.Write(a.Bytes); err != nil {
|
|
||||||
log.Printf("mavwaked: write to aplay: %v", err)
|
|
||||||
}
|
|
||||||
_ = stdin.Close()
|
|
||||||
|
|
||||||
// Wait for playback to finish (with a timeout).
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
done <- cmd.Wait()
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("mavwaked: aplay: %v", err)
|
|
||||||
}
|
|
||||||
case <-time.After(30 * time.Second):
|
|
||||||
log.Printf("mavwaked: aplay timeout, killing")
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Reply playback, and the half-duplex gate around it (Vikunja #287).
|
||||||
|
//
|
||||||
|
// Before this, playback was `go playAudio(reply)` — fire and forget, with no
|
||||||
|
// handle on the running aplay. Two things fell out of that, and both are
|
||||||
|
// audible:
|
||||||
|
//
|
||||||
|
// 1. Self-trigger. The capture loop keeps feeding the VAD while the speaker
|
||||||
|
// is playing, so Maven's own reply comes back in through the mic, trips
|
||||||
|
// the VAD, and is sent to the daemon as a fresh utterance. She answers
|
||||||
|
// herself. There is no acoustic echo canceller in this pipeline, so the
|
||||||
|
// only correct fix is half-duplex: while she is speaking, the capture
|
||||||
|
// side is muted.
|
||||||
|
//
|
||||||
|
// 2. No barge-in. Talking over her did nothing — there was nothing to
|
||||||
|
// cancel, because nobody held the process handle.
|
||||||
|
//
|
||||||
|
// The two are the same mechanism seen from opposite sides, so they live
|
||||||
|
// together here. Echo suppression is unconditional (it fixes a bug). Barge-in
|
||||||
|
// is off unless -barge-in is passed, because it needs a room-specific energy
|
||||||
|
// threshold: with no echo canceller, the only way to tell "he is talking over
|
||||||
|
// her" from "the mic is hearing her" is that he is louder, and how much
|
||||||
|
// louder depends on where the mic sits relative to the speaker.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// player plays one reply at a time and can be cut off mid-utterance.
|
||||||
|
type player interface {
|
||||||
|
// Play starts playback of a, replacing anything already playing, and
|
||||||
|
// returns immediately.
|
||||||
|
Play(a audio.Audio)
|
||||||
|
// Stop ends playback now. A no-op when nothing is playing.
|
||||||
|
Stop()
|
||||||
|
// Playing reports whether audio is currently going out of the speaker.
|
||||||
|
Playing() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// aplayPlayer pipes raw PCM to aplay(1). Stop kills the child, which is what
|
||||||
|
// makes barge-in instant rather than "instant at the end of the sentence".
|
||||||
|
type aplayPlayer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
playing bool
|
||||||
|
// gen rises on every Play/Stop so a finishing playback cannot clear the
|
||||||
|
// playing flag of the one that replaced it.
|
||||||
|
gen uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAplayPlayer() *aplayPlayer { return &aplayPlayer{} }
|
||||||
|
|
||||||
|
func (p *aplayPlayer) Play(a audio.Audio) {
|
||||||
|
if len(a.Bytes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Stop()
|
||||||
|
|
||||||
|
cmd := exec.Command("aplay",
|
||||||
|
"-f", "S16_LE",
|
||||||
|
"-r", strconv.Itoa(a.Format.SampleRate),
|
||||||
|
"-c", strconv.Itoa(a.Format.Channels),
|
||||||
|
"-t", "raw",
|
||||||
|
)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("mavwaked: start aplay: %v", err)
|
||||||
|
_ = stdin.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.gen++
|
||||||
|
gen := p.gen
|
||||||
|
p.cmd = cmd
|
||||||
|
p.playing = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := stdin.Write(a.Bytes); err != nil {
|
||||||
|
// Broken pipe is the expected outcome of Stop().
|
||||||
|
log.Printf("mavwaked: write to aplay: %v", err)
|
||||||
|
}
|
||||||
|
_ = stdin.Close()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavwaked: aplay: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
log.Printf("mavwaked: aplay timeout, killing")
|
||||||
|
if pr := cmd.Process; pr != nil {
|
||||||
|
_ = pr.Kill()
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.gen == gen {
|
||||||
|
p.playing = false
|
||||||
|
p.cmd = nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *aplayPlayer) Stop() {
|
||||||
|
p.mu.Lock()
|
||||||
|
cmd := p.cmd
|
||||||
|
if cmd != nil {
|
||||||
|
p.gen++
|
||||||
|
p.playing = false
|
||||||
|
p.cmd = nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *aplayPlayer) Playing() bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.playing
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The real player must be safe to poke when nothing is playing — the capture
|
||||||
|
// loop calls Playing() on every 30ms frame, and Stop() lands on an idle
|
||||||
|
// player whenever a barge-in races the end of a reply. Neither may need
|
||||||
|
// aplay(1) to be installed.
|
||||||
|
func TestAplayPlayerIdleIsSafe(t *testing.T) {
|
||||||
|
p := newAplayPlayer()
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("a fresh player reports playing")
|
||||||
|
}
|
||||||
|
p.Stop()
|
||||||
|
p.Stop()
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("playing after Stop on an idle player")
|
||||||
|
}
|
||||||
|
// Empty audio is a text-only turn: nothing to play, no process to spawn.
|
||||||
|
p.Play(audio.Audio{Format: audio.PCM16kMono})
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("empty audio started playback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAplayPlayerSatisfiesPlayer(t *testing.T) {
|
||||||
|
var _ player = newAplayPlayer()
|
||||||
|
var _ player = &fakePlayer{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The capture session: what happens to one 30ms frame, given whether Maven is
|
||||||
|
// currently speaking. Split out of main.go's processFrame so the decision is
|
||||||
|
// testable without a mic, a speaker, or a daemon (Vikunja #287).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// utteranceSender ships one complete utterance to the voice server and
|
||||||
|
// returns the reply audio to play. The real one round-trips over the voice
|
||||||
|
// wire; tests substitute a recorder.
|
||||||
|
type utteranceSender interface {
|
||||||
|
Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bargeInConfig holds the two numbers barge-in needs. Zero Frames disables
|
||||||
|
// barge-in entirely — the half-duplex gate still runs.
|
||||||
|
type bargeInConfig struct {
|
||||||
|
// RMS is the normalised energy a frame must exceed to count as him
|
||||||
|
// talking over her rather than the mic hearing her. It is deliberately
|
||||||
|
// far above the VAD's own floor: the speaker leaks into the mic at
|
||||||
|
// roughly ambient level, a person talking at the mic does not.
|
||||||
|
RMS float64
|
||||||
|
// Frames is how many consecutive frames must clear RMS before playback
|
||||||
|
// is cut. One loud frame is a door closing; five in a row is a voice.
|
||||||
|
Frames int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether barge-in should be attempted at all.
|
||||||
|
func (c bargeInConfig) Enabled() bool { return c.Frames > 0 && c.RMS > 0 }
|
||||||
|
|
||||||
|
// session is the per-client capture state machine.
|
||||||
|
type session struct {
|
||||||
|
vad *VAD
|
||||||
|
player player
|
||||||
|
sender utteranceSender
|
||||||
|
lang string
|
||||||
|
barge bargeInConfig
|
||||||
|
|
||||||
|
// loudFrames counts consecutive over-threshold frames seen while she is
|
||||||
|
// speaking. Reset whenever a frame falls back under the threshold, and
|
||||||
|
// whenever playback ends.
|
||||||
|
loudFrames int
|
||||||
|
|
||||||
|
// counters, read by tests and logged on the way out.
|
||||||
|
suppressed int // frames dropped because she was speaking
|
||||||
|
bargeIns int // times playback was cut because he spoke over her
|
||||||
|
sent int // utterances shipped to the daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session {
|
||||||
|
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feed processes one 30ms PCM frame.
|
||||||
|
//
|
||||||
|
// While the player is running the capture side is muted: the VAD is not fed
|
||||||
|
// and no utterance can be produced, so Maven's own reply cannot come back in
|
||||||
|
// as a new command. The one thing that gets through is barge-in — sustained
|
||||||
|
// energy well above the speaker's leak level cuts playback, and capture
|
||||||
|
// resumes on the very next frame with a clean VAD.
|
||||||
|
func (s *session) feed(ctx context.Context, frame []byte) error {
|
||||||
|
if s.player.Playing() {
|
||||||
|
s.suppressed++
|
||||||
|
if !s.barge.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if frameRMS(PCMToI16(frame)) < s.barge.RMS {
|
||||||
|
s.loudFrames = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.loudFrames++
|
||||||
|
if s.loudFrames < s.barge.Frames {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// He is talking over her. Cut her off, drop the VAD state that
|
||||||
|
// accumulated from the echo, and start listening for real.
|
||||||
|
s.player.Stop()
|
||||||
|
s.bargeIns++
|
||||||
|
s.loudFrames = 0
|
||||||
|
s.vad.Reset()
|
||||||
|
log.Printf("mavwaked: barge-in — stopped playback")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not speaking. If we just stopped, make sure no echo-era state leaks
|
||||||
|
// into the next utterance.
|
||||||
|
if s.loudFrames != 0 {
|
||||||
|
s.loudFrames = 0
|
||||||
|
s.vad.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
utt, state := s.vad.Feed(PCMToI16(frame))
|
||||||
|
if state == StateSpeech || utt.Bytes == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.dispatch(ctx, utt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch ships a complete utterance and plays whatever comes back.
|
||||||
|
func (s *session) dispatch(ctx context.Context, utt audio.Audio) error {
|
||||||
|
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes))
|
||||||
|
reply, err := s.sender.Send(ctx, utt, s.lang)
|
||||||
|
s.sent++
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(reply.Bytes) == 0 {
|
||||||
|
log.Printf("mavwaked: empty reply audio (text only)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The VAD has been accumulating from the buffered mic stream while the
|
||||||
|
// round-trip blocked. None of it is a command — reset before the
|
||||||
|
// speaker opens, so the first post-reply frame starts clean.
|
||||||
|
s.vad.Reset()
|
||||||
|
s.player.Play(reply)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePlayer records Play/Stop instead of shelling out to aplay.
|
||||||
|
type fakePlayer struct {
|
||||||
|
playing bool
|
||||||
|
plays int
|
||||||
|
stops int
|
||||||
|
last audio.Audio
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePlayer) Play(a audio.Audio) { p.playing = true; p.plays++; p.last = a }
|
||||||
|
func (p *fakePlayer) Stop() { p.playing = false; p.stops++ }
|
||||||
|
func (p *fakePlayer) Playing() bool { return p.playing }
|
||||||
|
|
||||||
|
// fakeSender records what was shipped and hands back a canned reply.
|
||||||
|
type fakeSender struct {
|
||||||
|
sent []audio.Audio
|
||||||
|
reply audio.Audio
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSender) Send(_ context.Context, utt audio.Audio, _ string) (audio.Audio, error) {
|
||||||
|
s.sent = append(s.sent, utt)
|
||||||
|
return s.reply, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyAudio() audio.Audio {
|
||||||
|
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameAt returns a 30ms frame whose RMS is approximately rms.
|
||||||
|
func frameAt(rms float64) []byte {
|
||||||
|
amp := rms * math.Sqrt2 * 32768
|
||||||
|
f := make([]int16, frameSamples)
|
||||||
|
for i := range f {
|
||||||
|
f[i] = int16(amp * math.Sin(2*math.Pi*440*float64(i)/16000))
|
||||||
|
}
|
||||||
|
return pcmBytes(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func silentBytes() []byte { return make([]byte, frameSamples*2) }
|
||||||
|
|
||||||
|
// newTestSession wires a session with fakes and a default VAD.
|
||||||
|
func newTestSession(barge bargeInConfig) (*session, *fakePlayer, *fakeSender) {
|
||||||
|
p := &fakePlayer{}
|
||||||
|
s := &fakeSender{reply: replyAudio()}
|
||||||
|
return newSession(NewVAD(0, 0, 0, 0), p, s, "ru", barge), p, s
|
||||||
|
}
|
||||||
|
|
||||||
|
// speakThenPause drives a full utterance through the session: enough loud
|
||||||
|
// frames to trigger, then enough silence to end it.
|
||||||
|
func speakThenPause(t *testing.T, sess *session) {
|
||||||
|
t.Helper()
|
||||||
|
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||||
|
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||||
|
loud := frameAt(0.35)
|
||||||
|
for i := 0; i < speechFrames+5; i++ {
|
||||||
|
if err := sess.feed(context.Background(), loud); err != nil {
|
||||||
|
t.Fatalf("feed loud frame %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < silenceFrames; i++ {
|
||||||
|
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||||
|
t.Fatalf("feed silent frame %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionSendsUtteranceAndPlaysReply(t *testing.T) {
|
||||||
|
sess, p, snd := newTestSession(bargeInConfig{})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
if len(snd.sent) != 1 {
|
||||||
|
t.Fatalf("sent %d utterances, want 1", len(snd.sent))
|
||||||
|
}
|
||||||
|
if snd.sent[0].Format != audio.PCM16kMono {
|
||||||
|
t.Errorf("utterance format = %+v, want canonical", snd.sent[0].Format)
|
||||||
|
}
|
||||||
|
if p.plays != 1 {
|
||||||
|
t.Errorf("plays = %d, want 1", p.plays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bug this whole file exists for: while the speaker is running, the mic
|
||||||
|
// hears Maven and the old code shipped that back as a fresh command.
|
||||||
|
func TestSessionDoesNotHearItselfWhilePlaying(t *testing.T) {
|
||||||
|
sess, p, snd := newTestSession(bargeInConfig{})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if !p.Playing() {
|
||||||
|
t.Fatal("expected playback to be running after the reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed a long stretch of loud audio — Maven's own voice coming back in.
|
||||||
|
base := sess.suppressed
|
||||||
|
loud := frameAt(0.35)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
if err := sess.feed(context.Background(), loud); err != nil {
|
||||||
|
t.Fatalf("feed echo frame %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snd.sent) != 1 {
|
||||||
|
t.Fatalf("sent %d utterances, want 1 — her own reply was captured as a command", len(snd.sent))
|
||||||
|
}
|
||||||
|
if got := sess.suppressed - base; got != 200 {
|
||||||
|
t.Errorf("suppressed %d of the 200 echo frames, want all of them", got)
|
||||||
|
}
|
||||||
|
if p.stops != 0 {
|
||||||
|
t.Errorf("stops = %d, want 0 — barge-in is off, nothing should cut her off", p.stops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With barge-in off, no amount of noise stops playback.
|
||||||
|
func TestSessionBargeInDisabledByDefault(t *testing.T) {
|
||||||
|
sess, p, _ := newTestSession(bargeInConfig{})
|
||||||
|
if sess.barge.Enabled() {
|
||||||
|
t.Fatal("zero bargeInConfig must be disabled")
|
||||||
|
}
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
veryLoud := frameAt(0.6)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
}
|
||||||
|
if p.stops != 0 || sess.bargeIns != 0 {
|
||||||
|
t.Fatalf("stops = %d, bargeIns = %d, want 0 with barge-in off", p.stops, sess.bargeIns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionBargeInCutsPlayback(t *testing.T) {
|
||||||
|
barge := bargeInConfig{RMS: 0.12, Frames: 5}
|
||||||
|
sess, p, _ := newTestSession(barge)
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if !p.Playing() {
|
||||||
|
t.Fatal("expected playback after the reply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four loud frames must not be enough — a door closing is not a voice.
|
||||||
|
veryLoud := frameAt(0.35)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
}
|
||||||
|
if p.stops != 0 {
|
||||||
|
t.Fatalf("playback cut after 4 frames, want it to hold until %d", barge.Frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fifth cuts her off.
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
if p.stops != 1 || sess.bargeIns != 1 {
|
||||||
|
t.Fatalf("stops = %d, bargeIns = %d, want 1 and 1", p.stops, sess.bargeIns)
|
||||||
|
}
|
||||||
|
if p.Playing() {
|
||||||
|
t.Fatal("still playing after barge-in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A burst that falls back under the threshold resets the counter, so noise
|
||||||
|
// spread over a whole reply never accumulates into a false barge-in.
|
||||||
|
func TestSessionBargeInNeedsConsecutiveFrames(t *testing.T) {
|
||||||
|
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
veryLoud := frameAt(0.35)
|
||||||
|
quiet := frameAt(0.02)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
_ = sess.feed(context.Background(), quiet)
|
||||||
|
}
|
||||||
|
if p.stops != 0 || sess.bargeIns != 0 {
|
||||||
|
t.Fatalf("stops = %d, bargeIns = %d, want 0 — two-frame bursts must not accumulate", p.stops, sess.bargeIns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speaker leak sits near the room floor; it must never reach the barge-in bar.
|
||||||
|
func TestSessionEchoLevelAudioNeverBargesIn(t *testing.T) {
|
||||||
|
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
base := sess.suppressed
|
||||||
|
leak := frameAt(0.05) // loud enough for the VAD, far under the barge bar
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
_ = sess.feed(context.Background(), leak)
|
||||||
|
}
|
||||||
|
if p.stops != 0 {
|
||||||
|
t.Fatalf("stops = %d, want 0 — speaker leak must not read as barge-in", p.stops)
|
||||||
|
}
|
||||||
|
if got := sess.suppressed - base; got != 300 {
|
||||||
|
t.Errorf("suppressed %d of the 300 leak frames, want all of them", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After barge-in the VAD must start clean, so the interrupting speech is
|
||||||
|
// captured as a whole utterance rather than joined onto echo state.
|
||||||
|
func TestSessionCapturesTheInterruptingUtterance(t *testing.T) {
|
||||||
|
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
|
||||||
|
veryLoud := frameAt(0.35)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_ = sess.feed(context.Background(), veryLoud)
|
||||||
|
}
|
||||||
|
if p.stops != 1 {
|
||||||
|
t.Fatalf("expected barge-in, stops = %d", p.stops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// He keeps talking; that is a new command.
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if len(snd.sent) != 2 {
|
||||||
|
t.Fatalf("sent %d utterances, want 2 — the interruption itself must be heard", len(snd.sent))
|
||||||
|
}
|
||||||
|
if p.plays != 2 {
|
||||||
|
t.Errorf("plays = %d, want 2", p.plays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed round-trip must surface as an error and must not start playback.
|
||||||
|
func TestSessionSendErrorDoesNotPlay(t *testing.T) {
|
||||||
|
p := &fakePlayer{}
|
||||||
|
snd := &fakeSender{err: errors.New("boom")}
|
||||||
|
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
||||||
|
|
||||||
|
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||||
|
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||||
|
loud := frameAt(0.35)
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < speechFrames+5; i++ {
|
||||||
|
_ = sess.feed(context.Background(), loud)
|
||||||
|
}
|
||||||
|
for i := 0; i < silenceFrames; i++ {
|
||||||
|
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
t.Fatal("send error was swallowed")
|
||||||
|
}
|
||||||
|
if p.plays != 0 || p.Playing() {
|
||||||
|
t.Fatalf("plays = %d, playing = %v, want no playback on a failed round-trip", p.plays, p.Playing())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty reply (text-only turn) must leave the capture side open.
|
||||||
|
func TestSessionEmptyReplyLeavesCaptureOpen(t *testing.T) {
|
||||||
|
p := &fakePlayer{}
|
||||||
|
snd := &fakeSender{reply: audio.Audio{Format: audio.PCM16kMono}}
|
||||||
|
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
||||||
|
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if p.plays != 0 {
|
||||||
|
t.Fatalf("plays = %d, want 0 for an empty reply", p.plays)
|
||||||
|
}
|
||||||
|
speakThenPause(t, sess)
|
||||||
|
if len(snd.sent) != 2 {
|
||||||
|
t.Fatalf("sent %d, want 2 — capture must stay open when there is no audio reply", len(snd.sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBargeInConfigEnabled(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
c bargeInConfig
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{bargeInConfig{}, false},
|
||||||
|
{bargeInConfig{RMS: 0.12}, false},
|
||||||
|
{bargeInConfig{Frames: 5}, false},
|
||||||
|
{bargeInConfig{RMS: 0.12, Frames: 5}, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.c.Enabled(); got != tc.want {
|
||||||
|
t.Errorf("%+v.Enabled() = %v, want %v", tc.c, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,15 @@ const (
|
|||||||
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
|
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
|
||||||
defaultMaxMs = 10000 // cap single utterance at 10s
|
defaultMaxMs = 10000 // cap single utterance at 10s
|
||||||
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
|
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
|
||||||
|
|
||||||
|
// Barge-in thresholds. Only used when -barge-in is passed. The RMS is
|
||||||
|
// x10000 like -min-rms, and sits an order of magnitude above the VAD's
|
||||||
|
// own floor on purpose: with no acoustic echo canceller, a frame only
|
||||||
|
// counts as "he is talking over her" if it is far louder than what the
|
||||||
|
// speaker leaks back into the mic. 5 frames is 150ms — long enough that
|
||||||
|
// a door or a cough does not cut her off mid-sentence.
|
||||||
|
defaultBargeRMS = 1200 // 0.12 normalised RMS
|
||||||
|
defaultBargeFrames = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// frameSamples — samples per 30ms frame at 16kHz.
|
// frameSamples — samples per 30ms frame at 16kHz.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/ambient — the work calendar read (Vikunja #126).
|
||||||
|
//
|
||||||
|
// Maven does not hold a work credential. A corp mail or calendar session on the
|
||||||
|
// homelab ties the box's blast radius to the employer's data, so the work
|
||||||
|
// calendar is read as a SIGNAL instead: an Android notification-listener on the
|
||||||
|
// owner's phone posts meeting notifications here over wg/LAN, and the ones that
|
||||||
|
// clearly describe a meeting become calendar events at source=ambient:notif,
|
||||||
|
// confidence below 1.0. Mail as a notification signal, not a mailbox.
|
||||||
|
//
|
||||||
|
// Off unless configured: no -ambient-token, no route. The token is a shared
|
||||||
|
// secret because the poster is a phone service, not a browser — WebAuthn has no
|
||||||
|
// answer for a background Android service. The endpoint is write-only and
|
||||||
|
// accepts exactly one shape of write; it cannot read anything back out.
|
||||||
|
//
|
||||||
|
// A notification with no recognisable clock reading stores NOTHING. Maven is
|
||||||
|
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||||
|
// is worse than a gap.
|
||||||
|
|
||||||
|
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||||
|
const ambientMaxBody = 8 << 10
|
||||||
|
|
||||||
|
type ambientResp struct {
|
||||||
|
Stored bool `json:"stored"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAmbient ingests one relayed notification. token is the configured
|
||||||
|
// shared secret; an empty token means the capability is off and the handler is
|
||||||
|
// never registered, so it is treated as a hard failure here too.
|
||||||
|
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ambientAuthorized(r, token) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var n calendar.Notification
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &n); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.Posted.IsZero() {
|
||||||
|
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ev, ok := calendar.EventFromNotification(n)
|
||||||
|
if !ok {
|
||||||
|
// Not an event. 202: the relay did its job, there is just nothing here
|
||||||
|
// worth remembering, and it must not retry.
|
||||||
|
writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := calendar.FactKey(ev)
|
||||||
|
val := calendar.FactValue(ev)
|
||||||
|
|
||||||
|
// Append-only discipline, same as cmd/mavcaldav: a phone reposts the same
|
||||||
|
// notification many times, and each repost is the same event.
|
||||||
|
if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val {
|
||||||
|
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
||||||
|
return
|
||||||
|
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
||||||
|
log.Printf("ambient: read %s: %v", key, err)
|
||||||
|
http.Error(w, "read failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// kind=env: an observation about the world, never a self-fact — a passive
|
||||||
|
// signal does not write truth about the owner. Confidence below 1.0 is the
|
||||||
|
// honest part: this is a notification about a meeting, not a reading of a
|
||||||
|
// calendar, and the query path hedges when it recites one.
|
||||||
|
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
||||||
|
Ts: ev.Start,
|
||||||
|
Kind: "env",
|
||||||
|
Key: key,
|
||||||
|
Value: val,
|
||||||
|
Source: calendar.SourceAmbient,
|
||||||
|
Confidence: calendar.AmbientConfidence,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("ambient: write %s: %v", key, err)
|
||||||
|
http.Error(w, "write failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
||||||
|
writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
|
||||||
|
// header, compared in constant time.
|
||||||
|
func ambientAuthorized(r *http.Request, token string) bool {
|
||||||
|
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||||
|
if got == "" {
|
||||||
|
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ambientTestToken = "s3cret"
|
||||||
|
|
||||||
|
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
|
||||||
|
// needs.
|
||||||
|
type ambientCore struct {
|
||||||
|
fakeCore
|
||||||
|
latest map[string]ipc.Fact // "key|source" → fact
|
||||||
|
readErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
if c.readErr != nil {
|
||||||
|
return ipc.Fact{}, c.readErr
|
||||||
|
}
|
||||||
|
f, ok := c.latest[key+"|"+source]
|
||||||
|
if !ok {
|
||||||
|
return ipc.Fact{}, ipc.ErrNoFact
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, core, token)
|
||||||
|
var resp ambientResp
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||||
|
return rr, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNotification() calendar.Notification {
|
||||||
|
return calendar.Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: "Планёрка",
|
||||||
|
Text: "10:00-10:30",
|
||||||
|
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientStoresMeeting(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if !resp.Stored {
|
||||||
|
t.Errorf("resp = %+v, want stored", resp)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||||
|
}
|
||||||
|
got := core.writeLog[0]
|
||||||
|
if got.Source != calendar.SourceAmbient {
|
||||||
|
t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient)
|
||||||
|
}
|
||||||
|
if got.Confidence >= 1.0 {
|
||||||
|
t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence)
|
||||||
|
}
|
||||||
|
if got.Confidence != calendar.AmbientConfidence {
|
||||||
|
t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence)
|
||||||
|
}
|
||||||
|
if got.Kind != "env" {
|
||||||
|
t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind)
|
||||||
|
}
|
||||||
|
if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) {
|
||||||
|
t.Errorf("key = %q, want prefix %q", got.Key, want)
|
||||||
|
}
|
||||||
|
if got.Value != "Планёрка @ 10:00-10:30" {
|
||||||
|
t.Errorf("value = %q", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phone reposts the same notification many times. Each repost is the same
|
||||||
|
// event, and the append-only log must not fill with duplicates.
|
||||||
|
func TestHandleAmbientDedupesReposts(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("first post did not write")
|
||||||
|
}
|
||||||
|
w := core.writeLog[0]
|
||||||
|
core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}}
|
||||||
|
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code)
|
||||||
|
}
|
||||||
|
if resp.Stored {
|
||||||
|
t.Error("a repost must not be stored again")
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Errorf("wrote %d facts, want 1", len(core.writeLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The conservative half: noise stores nothing at all.
|
||||||
|
func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: "3 новых письма",
|
||||||
|
Posted: time.Now(),
|
||||||
|
})
|
||||||
|
if rr.Code != http.StatusAccepted {
|
||||||
|
t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code)
|
||||||
|
}
|
||||||
|
if resp.Stored {
|
||||||
|
t.Error("a notification with no meeting time must store nothing")
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 0 {
|
||||||
|
t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
|
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||||
|
|
||||||
|
newReq := func(hdr, val string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
|
if hdr != "" {
|
||||||
|
r.Header.Set(hdr, val)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no token rejected", func(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("", ""), core, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 0 {
|
||||||
|
t.Error("an unauthorized post must not write")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong token rejected", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("X-Maven-Token accepted", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("capability off", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "")
|
||||||
|
if rr.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503 when no token is configured", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET rejected", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil)
|
||||||
|
handleAmbient(rr, r, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientBadInput(t *testing.T) {
|
||||||
|
t.Run("bad json", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope"))
|
||||||
|
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing posted_at", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`))
|
||||||
|
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("read error surfaces", func(t *testing.T) {
|
||||||
|
core := &ambientCore{readErr: fmt.Errorf("socket closed")}
|
||||||
|
rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want 502", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{template "shellTop" "events"}}
|
||||||
|
<h1>Intake</h1>
|
||||||
|
<div class=hint>Everything that arrived, newest first — a relayed notification, a mail candidate, a feed
|
||||||
|
item, a changed page, a spend, a presence probe. One envelope per write; the durable row is still the
|
||||||
|
fact, note or task itself. Held in memory only, so a restart empties this.</div>
|
||||||
|
{{if .Err}}<div class=hint>journal unavailable: {{.Err}}</div>{{end}}
|
||||||
|
{{if and (not .Events) (not .Err)}}
|
||||||
|
<div class=hint>nothing has arrived yet</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Events}}
|
||||||
|
<div class=scroll><table class=mono>
|
||||||
|
<tr><th>when<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||||
|
{{range .Events}}<tr>
|
||||||
|
<td>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||||
|
<td class=gray>{{.Source}}</td>
|
||||||
|
<td class=gray>{{.Kind}}</td>
|
||||||
|
<td class=gray>{{.Priority}}</td>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td class=gray>{{.Body}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventsCore serves a canned intake journal. Embedding
|
||||||
|
// ipc.UnimplementedCoreAPI means any other call fails loudly.
|
||||||
|
type eventsCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
events []ipc.IntakeEvent
|
||||||
|
err error
|
||||||
|
gotN int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *eventsCore) RecentEvents(_ context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
c.gotN = n
|
||||||
|
return c.events, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEvents(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleEvents(w, httptest.NewRequest(http.MethodGet, "/events", nil), core)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||||
|
core := &eventsCore{events: []ipc.IntakeEvent{
|
||||||
|
{Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||||
|
OccurredAt: time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC)},
|
||||||
|
{Source: "ambient:notif", Kind: "fact", Title: "calendar_event_20260801_планёрка",
|
||||||
|
Body: "10:00-11:00 планёрка", Priority: "low",
|
||||||
|
OccurredAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)},
|
||||||
|
}}
|
||||||
|
w := getEvents(t, core)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("page does not mention %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if core.gotN != eventsPageLimit {
|
||||||
|
t.Errorf("asked core for %d events, want %d", core.gotN, eventsPageLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageSaysNothingArrived(t *testing.T) {
|
||||||
|
w := getEvents(t, &eventsCore{})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "nothing has arrived yet") {
|
||||||
|
t.Error("empty journal did not render the empty-state line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageReportsAReadFailure(t *testing.T) {
|
||||||
|
// An unreachable journal must say so rather than render an empty table,
|
||||||
|
// which would imply nothing arrived.
|
||||||
|
w := getEvents(t, &eventsCore{err: errors.New("core is down")})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 with the error rendered", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "journal unavailable") || !strings.Contains(body, "core is down") {
|
||||||
|
t.Errorf("page did not report the read failure: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "nothing has arrived yet") {
|
||||||
|
t.Error("a failed read rendered as an empty journal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageWithoutCore(t *testing.T) {
|
||||||
|
w := getEvents(t, nil)
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||||
|
// Titles come from outside — a feed headline, a notification. They are shown
|
||||||
|
// on a page and must never be able to inject markup into it.
|
||||||
|
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||||
|
Source: "rss:x", Kind: "note", Priority: "low",
|
||||||
|
Title: `<script>alert(1)</script>`,
|
||||||
|
OccurredAt: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||||
|
}}}
|
||||||
|
body := getEvents(t, core).Body.String()
|
||||||
|
if strings.Contains(body, "<script>alert(1)</script>") {
|
||||||
|
t.Error("intake title was not escaped")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "<script>") {
|
||||||
|
t.Error("intake title is missing from the page entirely")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,26 @@ type fakeCore struct {
|
|||||||
// for handleTrace tests
|
// for handleTrace tests
|
||||||
tickTrace ipc.TickTrace
|
tickTrace ipc.TickTrace
|
||||||
traceErr error
|
traceErr error
|
||||||
|
|
||||||
|
// for handleChatAPI tests
|
||||||
|
chatText string
|
||||||
|
chatErr error
|
||||||
|
|
||||||
|
// for the MCP section of /tools
|
||||||
|
mcpServers []ipc.MCPServerStatus
|
||||||
|
mcpErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||||
|
return f.mcpServers, f.mcpErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
||||||
|
f.chatText = text
|
||||||
|
if f.chatErr != nil {
|
||||||
|
return "", f.chatErr
|
||||||
|
}
|
||||||
|
return "поняла", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
||||||
@@ -1045,3 +1065,110 @@ func TestHandleRoutines_NilCore_503(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 503", rr.Code)
|
t.Fatalf("status = %d, want 503", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- handleChatAPI step-up gate (Vikunja #317) ---
|
||||||
|
//
|
||||||
|
// POST /api/chat reaches the router, the LLM and the act path, so it carries
|
||||||
|
// the same gate as POST /tools and POST /api/revert.
|
||||||
|
|
||||||
|
func postChat(text string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("text="+url.QueryEscape(text)))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_RequireStepUp_FailsClosed(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("выключи свет"), core, nil, true)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.chatText != "" {
|
||||||
|
t.Errorf("core.Chat called with %q, but -require-stepup should deny", core.chatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_UnassertedSession_Denied(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("выключи свет"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
if core.chatText != "" {
|
||||||
|
t.Errorf("core.Chat called with %q despite an unasserted session", core.chatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_AssertedSession_PassesGate(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("привет"), core, stepUpSession(), true)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.chatText != "привет" {
|
||||||
|
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ chat keeps
|
||||||
|
// working, resting on the transport-level auth in front of mavweb.
|
||||||
|
func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("привет"), core, nil, false)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
if core.chatText != "привет" {
|
||||||
|
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The MCP section renders the configured servers, and a proposal that already
|
||||||
|
// knows its cmd prefills the enable form so the argv is not retyped by hand.
|
||||||
|
func TestHandleTools_GET_MCPSection(t *testing.T) {
|
||||||
|
core := &fakeCore{
|
||||||
|
proposed: []ipc.Tool{{
|
||||||
|
Name: "vikunja_list_tasks", Scope: "mcp:vikunja",
|
||||||
|
Cmd: []string{"mcp", "vikunja", "list_tasks"}, Destructive: true,
|
||||||
|
Utterance: "mcp vikunja/list_tasks: List tasks in a project.",
|
||||||
|
}},
|
||||||
|
mcpServers: []ipc.MCPServerStatus{
|
||||||
|
{Name: "vikunja", Transport: "http", Target: "http://192.168.1.104:9100/mcp", Connected: true, Server: "vikunja 0.1.0", Tools: 4},
|
||||||
|
{Name: "files", Transport: "stdio", Target: "mcp-server-fs /srv", Err: "start: no such file"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"MCP servers", "vikunja", "192.168.1.104:9100/mcp", "vikunja 0.1.0",
|
||||||
|
"files", "no such file",
|
||||||
|
`value="mcp vikunja list_tasks"`, // the enable form is prefilled
|
||||||
|
"checked", // and pre-marked destructive (no readOnlyHint)
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("missing %q in /tools output", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP off (or an older core that does not know the method) renders the section
|
||||||
|
// empty instead of breaking the page.
|
||||||
|
func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
|
||||||
|
core := &fakeCore{mcpErr: ipc.ErrNotImplemented}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "no MCP servers configured") {
|
||||||
|
t.Error("expected the empty-state copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+339
-13
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,9 @@ var notificationsHTML string
|
|||||||
//go:embed reminders.html
|
//go:embed reminders.html
|
||||||
var remindersHTML string
|
var remindersHTML string
|
||||||
|
|
||||||
|
//go:embed tasks.html
|
||||||
|
var tasksHTML string
|
||||||
|
|
||||||
//go:embed voice.html
|
//go:embed voice.html
|
||||||
var voiceHTML string
|
var voiceHTML string
|
||||||
|
|
||||||
@@ -67,6 +71,9 @@ var ecosystemHTML string
|
|||||||
//go:embed morning.html
|
//go:embed morning.html
|
||||||
var morningHTML string
|
var morningHTML string
|
||||||
|
|
||||||
|
//go:embed events.html
|
||||||
|
var eventsHTML string
|
||||||
|
|
||||||
// ── Ethos Workstation Shell ──
|
// ── Ethos Workstation Shell ──
|
||||||
//
|
//
|
||||||
// Two template pieces that wrap every page:
|
// Two template pieces that wrap every page:
|
||||||
@@ -97,9 +104,11 @@ var sidebarSections = []struct {
|
|||||||
Pages: []struct{ Label, URL, Key string }{
|
Pages: []struct{ Label, URL, Key string }{
|
||||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||||
|
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||||
|
{Label: "Intake", URL: "/events", Key: "events"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,6 +128,7 @@ var sidebarSections = []struct {
|
|||||||
Label: "Settings",
|
Label: "Settings",
|
||||||
Pages: []struct{ Label, URL, Key string }{
|
Pages: []struct{ Label, URL, Key string }{
|
||||||
{Label: "Tools", URL: "/tools", Key: "tools"},
|
{Label: "Tools", URL: "/tools", Key: "tools"},
|
||||||
|
{Label: "Model", URL: "/models", Key: "models"},
|
||||||
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -170,6 +180,8 @@ func pageIcon(key string) string {
|
|||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||||
|
case "tasks":
|
||||||
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "routines":
|
case "routines":
|
||||||
@@ -184,6 +196,8 @@ func pageIcon(key string) string {
|
|||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "tools":
|
case "tools":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
||||||
|
case "models":
|
||||||
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||||
case "passkey":
|
case "passkey":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||||
default:
|
default:
|
||||||
@@ -202,6 +216,8 @@ func pageTitle(key string) string {
|
|||||||
return "Rule Trace"
|
return "Rule Trace"
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return "Notifications"
|
return "Notifications"
|
||||||
|
case "tasks":
|
||||||
|
return "Tasks"
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return "Reminders"
|
return "Reminders"
|
||||||
case "routines":
|
case "routines":
|
||||||
@@ -216,6 +232,8 @@ func pageTitle(key string) string {
|
|||||||
return "Ecosystem"
|
return "Ecosystem"
|
||||||
case "tools":
|
case "tools":
|
||||||
return "Tools"
|
return "Tools"
|
||||||
|
case "models":
|
||||||
|
return "Resident Model"
|
||||||
case "passkey":
|
case "passkey":
|
||||||
return "Passkey"
|
return "Passkey"
|
||||||
default:
|
default:
|
||||||
@@ -304,6 +322,10 @@ var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).
|
|||||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||||
// checklist state changes on the scale of minutes, not seconds.
|
// checklist state changes on the scale of minutes, not seconds.
|
||||||
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||||
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||||
|
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
||||||
|
|
||||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
@@ -329,11 +351,15 @@ func main() {
|
|||||||
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
||||||
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
||||||
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
||||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (/tools POST, /api/revert) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||||
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
||||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
|
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
||||||
|
// reads the work calendar as a signal instead of holding a work credential
|
||||||
|
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
||||||
|
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var core ipc.CoreAPI
|
var core ipc.CoreAPI
|
||||||
@@ -381,6 +407,14 @@ func main() {
|
|||||||
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleSignal(w, r, core)
|
handleSignal(w, r, core)
|
||||||
})
|
})
|
||||||
|
// Off unless configured: no token, no route — an unconfigured ingest is not
|
||||||
|
// a 503 waiting to be probed, it does not exist.
|
||||||
|
if *ambientToken != "" {
|
||||||
|
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleAmbient(w, r, core, *ambientToken)
|
||||||
|
})
|
||||||
|
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
|
||||||
|
}
|
||||||
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleDash(w, r, core)
|
handleDash(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -396,9 +430,17 @@ func main() {
|
|||||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleReminders(w, r, core)
|
handleReminders(w, r, core)
|
||||||
})
|
})
|
||||||
|
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
||||||
|
// why a task write is not in the same class as /tools or /routines.
|
||||||
|
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleTasks(w, r, core)
|
||||||
|
})
|
||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleEvents(w, r, core)
|
||||||
|
})
|
||||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleEcosystem(w, r, ecoURLsCfg)
|
handleEcosystem(w, r, ecoURLsCfg)
|
||||||
@@ -434,9 +476,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
if stepUpSession == nil {
|
if stepUpSession == nil {
|
||||||
if *requireStepUp {
|
if *requireStepUp {
|
||||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv) and POST /api/revert will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES) and POST /api/revert (voids the latest fact for a key). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,15 +495,33 @@ func main() {
|
|||||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
|
// /models — the resident-model surface (Vikunja #250). Same step-up gate as
|
||||||
|
// /tools, and for a comparable reason: which model is loaded decides how every
|
||||||
|
// utterance is routed and how every reply is worded. GET is read-only.
|
||||||
|
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleModels(w, r, core, stepUpSession, *requireStepUp)
|
||||||
|
})
|
||||||
|
|
||||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
//
|
||||||
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools).
|
// POST /tools step-up — defines argv that internal/tool executes
|
||||||
|
// POST /routines step-up — accepting schedules recurring firing
|
||||||
|
// POST /api/revert step-up — voids the latest fact for a key
|
||||||
|
// POST /api/chat step-up — reaches the router, LLM and the act path
|
||||||
|
// POST /api/signal none — appends a presence fact, no argv, no act
|
||||||
|
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
|
||||||
|
// is itself only reachable inside the deploy
|
||||||
|
//
|
||||||
|
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
||||||
|
// otherwise fail-open unless -require-stepup, which denies.
|
||||||
|
//
|
||||||
|
// GET /chat only renders the page and echoes back the q/r query params the
|
||||||
|
// POST redirect set — nothing to gate.
|
||||||
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChatPage(w, r, core)
|
handleChatPage(w, r, core)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChatAPI(w, r, core)
|
handleChatAPI(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
||||||
@@ -643,7 +703,7 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||||
{{range .Proposed}}<tr>
|
{{range .Proposed}}<tr>
|
||||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||||
@@ -651,8 +711,8 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
<input type=hidden name=action value=enable>
|
<input type=hidden name=action value=enable>
|
||||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
|
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||||
<label><input type=checkbox name=destructive> destructive</label>
|
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||||
<button class=btn>enable</button></form>
|
<button class=btn>enable</button></form>
|
||||||
<form method=post action=/tools class=inline-form>
|
<form method=post action=/tools class=inline-form>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
@@ -681,6 +741,19 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
||||||
|
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
||||||
|
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
||||||
|
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
||||||
|
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
||||||
|
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||||
|
<div>no MCP servers configured</div>
|
||||||
|
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
{{template "shellBottom"}}`
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||||
@@ -720,6 +793,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
|
|||||||
|
|
||||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||||
|
|
||||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||||
|
|
||||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||||
@@ -790,6 +865,187 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||||
|
// template holds no date logic.
|
||||||
|
type taskRow struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
Evidence string
|
||||||
|
Status string
|
||||||
|
Due string
|
||||||
|
Created string
|
||||||
|
Resolved string
|
||||||
|
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||||
|
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||||
|
// rendering: he never said this one mattered more.
|
||||||
|
Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTasks serves the task review surface (GET) and the four writes it
|
||||||
|
// offers (POST): add, confirm, done, drop.
|
||||||
|
//
|
||||||
|
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||||
|
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||||
|
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||||
|
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||||
|
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||||
|
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||||
|
// page.
|
||||||
|
//
|
||||||
|
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||||
|
// from something she read into work he owns. That review step is why derived
|
||||||
|
// tasks are captured as candidates in the first place.
|
||||||
|
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
var msg, errMsg string
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var err error
|
||||||
|
msg, err = applyTaskPost(ctx, core, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tasks: %v", err)
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := core.ListTasks(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tasks: list: %v", err)
|
||||||
|
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||||
|
// and the voice reply can never disagree about what comes first. Resolved
|
||||||
|
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||||
|
var live []tasks.Item
|
||||||
|
var resolved []taskRow
|
||||||
|
for _, t := range all {
|
||||||
|
switch t.Status {
|
||||||
|
case "candidate", "open":
|
||||||
|
live = append(live, tasks.Item{
|
||||||
|
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||||
|
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
resolved = append(resolved, taskRow{
|
||||||
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byID := make(map[int64]ipc.Task, len(all))
|
||||||
|
for _, t := range all {
|
||||||
|
byID[t.ID] = t
|
||||||
|
}
|
||||||
|
var cands, open []taskRow
|
||||||
|
for _, r := range tasks.Rank(live, time.Now()) {
|
||||||
|
t := byID[r.ID]
|
||||||
|
row := taskRow{
|
||||||
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
Why: r.Reason,
|
||||||
|
}
|
||||||
|
if t.Status == "candidate" {
|
||||||
|
// A candidate's due date is Maven's reading of a mail, so its
|
||||||
|
// ranking reason is not shown as if he had set a priority.
|
||||||
|
row.Why = ""
|
||||||
|
cands = append(cands, row)
|
||||||
|
} else {
|
||||||
|
open = append(open, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tasksTmpl.Execute(w, struct {
|
||||||
|
Msg, Err string
|
||||||
|
Candidates []taskRow
|
||||||
|
Open []taskRow
|
||||||
|
Resolved []taskRow
|
||||||
|
}{msg, errMsg, cands, open, resolved}); err != nil {
|
||||||
|
log.Printf("tasks render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTaskPost performs one write and returns the message to show. A bad
|
||||||
|
// request returns an error, which the page renders inline rather than as a
|
||||||
|
// bare 400 — this is a form surface, not an API.
|
||||||
|
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||||
|
action := r.FormValue("action")
|
||||||
|
if action == "add" {
|
||||||
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
|
if text == "" {
|
||||||
|
return "", errors.New("empty task text")
|
||||||
|
}
|
||||||
|
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
|
||||||
|
// Importance is his, stated on the form. Out-of-range values are
|
||||||
|
// clamped rather than rejected — a bad select is not worth a 400.
|
||||||
|
if v := r.FormValue("weight"); v != "" {
|
||||||
|
var wgt int
|
||||||
|
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
|
||||||
|
return "", fmt.Errorf("bad weight %q", v)
|
||||||
|
}
|
||||||
|
if wgt > tasks.MaxWeight {
|
||||||
|
wgt = tasks.MaxWeight
|
||||||
|
}
|
||||||
|
req.Weight = wgt
|
||||||
|
}
|
||||||
|
if d := r.FormValue("due"); d != "" {
|
||||||
|
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("bad due date %q", d)
|
||||||
|
}
|
||||||
|
req.Due = &due
|
||||||
|
}
|
||||||
|
resp, err := core.CaptureTask(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !resp.Created {
|
||||||
|
return "already on the list", nil
|
||||||
|
}
|
||||||
|
return "added task", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
|
||||||
|
return "", errors.New("invalid id")
|
||||||
|
}
|
||||||
|
var status, msg string
|
||||||
|
switch action {
|
||||||
|
case "confirm":
|
||||||
|
status, msg = "open", "confirmed task"
|
||||||
|
case "done":
|
||||||
|
status, msg = "done", "task done"
|
||||||
|
case "drop":
|
||||||
|
status, msg = "dropped", "dropped task"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown action %q", action)
|
||||||
|
}
|
||||||
|
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTaskTime(t *time.Time) string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.Local().Format("02 Jan 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTaskDate(t *time.Time) string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.Local().Format("02 Jan")
|
||||||
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||||
// how long ago she noticed it.
|
// how long ago she noticed it.
|
||||||
type routineRow struct {
|
type routineRow struct {
|
||||||
@@ -935,12 +1191,63 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
view := morningView{Routines: status}
|
||||||
|
// The day plan (#128) shows on this page because it is the same question at
|
||||||
|
// a different scale. A plan read that fails must not take the checklist
|
||||||
|
// down with it — the page degrades to what it had before.
|
||||||
|
plan, err := core.DayPlan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("morning: day plan: %v", err)
|
||||||
|
view.PlanErr = err.Error()
|
||||||
|
} else {
|
||||||
|
view.Plan = &plan
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := morningTmpl.Execute(w, status); err != nil {
|
if err := morningTmpl.Execute(w, view); err != nil {
|
||||||
log.Printf("morning render: %v", err)
|
log.Printf("morning render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morningView — what /morning renders: today's plan on top, the checklist
|
||||||
|
// state under it. PlanErr is set instead of Plan when the core could not build
|
||||||
|
// a plan, so the page says so rather than showing an empty day.
|
||||||
|
type morningView struct {
|
||||||
|
Plan *ipc.DayPlan
|
||||||
|
PlanErr string
|
||||||
|
Routines []ipc.MorningRoutineStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsView — what /events renders. Err is set instead of Events when the
|
||||||
|
// core could not serve the journal, so the page says why rather than showing an
|
||||||
|
// empty intake and implying nothing arrived.
|
||||||
|
type eventsView struct {
|
||||||
|
Events []ipc.IntakeEvent
|
||||||
|
Err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
||||||
|
// page is for scanning what just happened, not for archaeology.
|
||||||
|
const eventsPageLimit = 200
|
||||||
|
|
||||||
|
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var view eventsView
|
||||||
|
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("events: %v", err)
|
||||||
|
view.Err = err.Error()
|
||||||
|
} else {
|
||||||
|
view.Events = evs
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := eventsTmpl.Execute(w, view); err != nil {
|
||||||
|
log.Printf("events render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||||
@@ -1068,12 +1375,20 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
|||||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// MCP is off by default and an older core may not know the method at all,
|
||||||
|
// so a failure here renders an empty section rather than breaking the page.
|
||||||
|
servers, err := core.MCPServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tools: mcp servers: %v", err)
|
||||||
|
servers = nil
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := toolsTmpl.Execute(w, struct {
|
if err := toolsTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []ipc.Tool
|
Proposed []ipc.Tool
|
||||||
Enabled []ipc.Tool
|
Enabled []ipc.Tool
|
||||||
}{msg, proposed, enabled}); err != nil {
|
MCP []ipc.MCPServerStatus
|
||||||
|
}{msg, proposed, enabled, servers}); err != nil {
|
||||||
log.Printf("tools render: %v", err)
|
log.Printf("tools render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1258,7 +1573,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||||
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
//
|
||||||
|
// State-changing, and the widest surface on this server: the text reaches the
|
||||||
|
// router, the LLM, and through mavend's applyAction the whole action path
|
||||||
|
// including `act` — so it is gated on the same step-up as POST /tools and
|
||||||
|
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
|
||||||
|
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
|
||||||
|
// denies, which is the point of that flag.
|
||||||
|
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -1267,6 +1589,10 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
text := strings.TrimSpace(r.FormValue("text"))
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
if text == "" {
|
if text == "" {
|
||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The resident-model surface (Vikunja #250).
|
||||||
|
//
|
||||||
|
// GET shows which model llama-server actually has loaded and which files the
|
||||||
|
// daemon is configured to allow. POST swaps to one of them, behind the same
|
||||||
|
// step-up gate as POST /tools: the loaded model decides how every utterance is
|
||||||
|
// routed and how every reply is worded, so it is an owner action.
|
||||||
|
//
|
||||||
|
// There is nothing on this page Maven can press. The swap is an IPC method rated
|
||||||
|
// AuthStepUp in internal/auth, unreachable from an act, an intent or a timer.
|
||||||
|
|
||||||
|
// modelController — the two non-CoreAPI methods this page needs. *ipc.Client
|
||||||
|
// satisfies it; a core without a swap allowlist answers ErrUnknownMethod, which
|
||||||
|
// the page renders as "not configured" rather than an error.
|
||||||
|
type modelController interface {
|
||||||
|
ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error)
|
||||||
|
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
const modelsHTML = `{{template "shellTop" "models"}}
|
||||||
|
<h1>Resident model</h1>
|
||||||
|
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||||
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
|
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||||
|
{{if .Off}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>swap not configured</h2>
|
||||||
|
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>loaded now</h2>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||||
|
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||||
|
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||||
|
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||||
|
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||||
|
</table></div>
|
||||||
|
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||||
|
</section>
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||||
|
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||||
|
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||||
|
<td><form method=post action=/models class=inline-form>
|
||||||
|
<input type=hidden name=model_path value="{{.}}">
|
||||||
|
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
|
type modelsPage struct {
|
||||||
|
Msg string
|
||||||
|
Err string
|
||||||
|
Off bool
|
||||||
|
Status ipc.ModelStatusResp
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleModels renders the model surface (GET) and applies a swap (POST).
|
||||||
|
//
|
||||||
|
// A failed swap is reported as a failure with the model that is still serving
|
||||||
|
// named, because that is the state the operator needs: the daemon rolled back
|
||||||
|
// and is answering turns, it just is not answering them with what he asked for.
|
||||||
|
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mc, ok := core.(modelController)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
page := modelsPage{}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(r.FormValue("model_path"))
|
||||||
|
if path == "" {
|
||||||
|
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := ipc.SwapModelReq{ModelPath: path}
|
||||||
|
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
|
||||||
|
req.NCtx = v
|
||||||
|
}
|
||||||
|
res, err := mc.SwapModel(ctx, req)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
|
||||||
|
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
|
||||||
|
case errors.Is(err, ipc.ErrForbidden):
|
||||||
|
http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||||
|
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
case res.RolledBack:
|
||||||
|
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
|
||||||
|
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
|
||||||
|
default:
|
||||||
|
page.Err = "swap failed: " + err.Error()
|
||||||
|
log.Printf("models: swap to %s failed: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := mc.ModelStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||||
|
page.Off = true
|
||||||
|
} else {
|
||||||
|
log.Printf("models: status: %v", err)
|
||||||
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page.Status = st
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := modelsTmpl.Execute(w, page); err != nil {
|
||||||
|
log.Printf("models render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeModelCore is a core that supports the two model methods. It records what
|
||||||
|
// the page asked for, so the tests can assert the gate rather than the HTML.
|
||||||
|
type fakeModelCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
status ipc.ModelStatusResp
|
||||||
|
statusErr error
|
||||||
|
|
||||||
|
swapResp ipc.SwapModelResp
|
||||||
|
swapErr error
|
||||||
|
swapped []ipc.SwapModelReq
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||||
|
return f.status, f.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||||
|
f.swapped = append(f.swapped, req)
|
||||||
|
return f.swapResp, f.swapErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleModels(w, r, core, session, requireStepUp)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
|
||||||
|
core := &fakeModelCore{status: ipc.ModelStatusResp{
|
||||||
|
Model: "Qwen3-1.7B-UD-Q4_K_XL",
|
||||||
|
ModelPath: "/opt/maven/models/llm/qwen3.gguf",
|
||||||
|
BaseURL: "http://127.0.0.1:18099",
|
||||||
|
NCtx: 4096,
|
||||||
|
Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
|
||||||
|
}}
|
||||||
|
w := modelsGET(t, core)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /models = %d; want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("page does not mention %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(core.swapped) != 0 {
|
||||||
|
t.Errorf("a GET swapped the model: %v", core.swapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
|
||||||
|
// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
|
||||||
|
// like POST /tools. Nothing reaches core.
|
||||||
|
core := &fakeModelCore{}
|
||||||
|
w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
|
||||||
|
}
|
||||||
|
if len(core.swapped) != 0 {
|
||||||
|
t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
|
||||||
|
core := &fakeModelCore{
|
||||||
|
swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
|
||||||
|
status: ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
|
||||||
|
}
|
||||||
|
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /models = %d; want 200", w.Code)
|
||||||
|
}
|
||||||
|
if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
|
||||||
|
t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
|
||||||
|
t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
|
||||||
|
core := &fakeModelCore{
|
||||||
|
swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
|
||||||
|
swapErr: errBrokenModel{},
|
||||||
|
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
|
||||||
|
}
|
||||||
|
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "rolled back to qwen3") {
|
||||||
|
t.Errorf("page does not say it rolled back:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_RefusedPathIs403(t *testing.T) {
|
||||||
|
core := &fakeModelCore{swapErr: ipc.ErrForbidden}
|
||||||
|
w := modelsPOST(t, core, nil, false, "/etc/passwd")
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
|
||||||
|
core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
|
||||||
|
w := modelsGET(t, core)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "swap not configured") {
|
||||||
|
t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
|
||||||
|
// An in-process CoreAPI (no swap methods) must not 500 the page.
|
||||||
|
w := modelsGET(t, ipc.UnimplementedCoreAPI{})
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errBrokenModel struct{}
|
||||||
|
|
||||||
|
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||||
+20
-2
@@ -1,9 +1,27 @@
|
|||||||
{{template "shellTop" "morning"}}
|
{{template "shellTop" "morning"}}
|
||||||
|
<h1>Today</h1>
|
||||||
|
{{with .Plan}}
|
||||||
|
<div class=hint>{{.Date.Format "02.01.2006"}}</div>
|
||||||
|
{{if not .Items}}
|
||||||
|
<div class=hint>nothing planned</div>
|
||||||
|
{{else}}
|
||||||
|
<div class=scroll><table class=mono>
|
||||||
|
<tr><th>at<th>kind<th>what</tr>
|
||||||
|
{{range .Items}}<tr>
|
||||||
|
<td>{{.At.Format "15:04"}}</td>
|
||||||
|
<td class=gray>{{.Kind}}</td>
|
||||||
|
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .PlanErr}}<div class=hint>plan unavailable: {{.PlanErr}}</div>{{end}}
|
||||||
|
|
||||||
<h1>Morning Routines</h1>
|
<h1>Morning Routines</h1>
|
||||||
{{if not .}}
|
{{if not .Routines}}
|
||||||
<div class=hint>no morning routines configured</div>
|
<div class=hint>no morning routines configured</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{range .}}
|
{{range .Routines}}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div><strong>{{.Name}}</strong>
|
<div><strong>{{.Name}}</strong>
|
||||||
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const prfTestOrigin = "https://maven.test"
|
||||||
|
const prfTestRPID = "maven.test"
|
||||||
|
|
||||||
|
// fakeKeyIPC stands in for the mavend socket and records exactly what secret
|
||||||
|
// each call received — the point of the whole test file is that it is the PRF
|
||||||
|
// output and never the credential public key.
|
||||||
|
type fakeKeyIPC struct {
|
||||||
|
unlockSecret []byte
|
||||||
|
wrapSecret []byte
|
||||||
|
unlockCalls int
|
||||||
|
wrapCalls int
|
||||||
|
unlockErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
|
||||||
|
f.unlockCalls++
|
||||||
|
f.unlockSecret = bytes.Clone(secret)
|
||||||
|
return f.unlockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
|
||||||
|
f.wrapCalls++
|
||||||
|
f.wrapSecret = bytes.Clone(secret)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
|
||||||
|
// prfAuthenticator is a minimal software authenticator: a P-256 key plus the
|
||||||
|
// COSE encoding of its public half.
|
||||||
|
type prfAuthenticator struct {
|
||||||
|
key *ecdsa.PrivateKey
|
||||||
|
credID []byte
|
||||||
|
cose []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPRFAuthenticator(t *testing.T) *prfAuthenticator {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
x := key.PublicKey.X.FillBytes(make([]byte, 32))
|
||||||
|
y := key.PublicKey.Y.FillBytes(make([]byte, 32))
|
||||||
|
// COSE_Key: {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y}
|
||||||
|
var c []byte
|
||||||
|
c = append(c, 0xa5) // map(5)
|
||||||
|
c = append(c, 0x01, 0x02) // 1: 2
|
||||||
|
c = append(c, 0x03, 0x26) // 3: -7
|
||||||
|
c = append(c, 0x20, 0x01) // -1: 1
|
||||||
|
c = append(c, 0x21, 0x58, 0x20) // -2: bytes(32)
|
||||||
|
c = append(c, x...)
|
||||||
|
c = append(c, 0x22, 0x58, 0x20) // -3: bytes(32)
|
||||||
|
c = append(c, y...)
|
||||||
|
return &prfAuthenticator{key: key, credID: []byte("prf-cred"), cose: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *prfAuthenticator) authData(flags byte, counter uint32, attested bool) []byte {
|
||||||
|
h := sha256.Sum256([]byte(prfTestRPID))
|
||||||
|
d := append([]byte{}, h[:]...)
|
||||||
|
d = append(d, flags)
|
||||||
|
cb := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(cb, counter)
|
||||||
|
d = append(d, cb...)
|
||||||
|
if attested {
|
||||||
|
d = append(d, make([]byte, 16)...) // aaguid
|
||||||
|
l := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(l, uint16(len(a.credID)))
|
||||||
|
d = append(d, l...)
|
||||||
|
d = append(d, a.credID...)
|
||||||
|
d = append(d, a.cose...)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientDataJSON(typ, challenge string) []byte {
|
||||||
|
b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": prfTestOrigin})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// register drives POST /register/finish with a valid attestation.
|
||||||
|
func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
|
||||||
|
t.Helper()
|
||||||
|
_, chal, err := h.rp.CreationOptions([]byte("u"), "user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreationOptions: %v", err)
|
||||||
|
}
|
||||||
|
// {"fmt":"none","attStmt":{},"authData":<bytes>}
|
||||||
|
att := []byte{0xa3}
|
||||||
|
att = append(att, 0x63, 'f', 'm', 't', 0x64, 'n', 'o', 'n', 'e')
|
||||||
|
att = append(att, 0x67, 'a', 't', 't', 'S', 't', 'm', 't', 0xa0)
|
||||||
|
ad := a.authData(1<<6|0x05, 0, true)
|
||||||
|
att = append(att, 0x68, 'a', 'u', 't', 'h', 'D', 'a', 't', 'a')
|
||||||
|
att = append(att, 0x59, byte(len(ad)>>8), byte(len(ad)))
|
||||||
|
att = append(att, ad...)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"challenge": chal,
|
||||||
|
"credential": map[string]any{
|
||||||
|
"id": b64u(a.credID),
|
||||||
|
"type": "public-key",
|
||||||
|
"response": map[string]any{
|
||||||
|
"clientDataJSON": b64u(clientDataJSON("webauthn.create", chal)),
|
||||||
|
"attestationObject": b64u(att),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.RegisterFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/register/finish", bytes.NewReader(body)))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("RegisterFinish: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert drives POST /assert/finish with a valid assertion and the given
|
||||||
|
// base64url PRF result.
|
||||||
|
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
_, chal, err := h.rp.AssertionOptions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AssertionOptions: %v", err)
|
||||||
|
}
|
||||||
|
ad := a.authData(0x05, 7, false)
|
||||||
|
cdj := clientDataJSON("webauthn.get", chal)
|
||||||
|
hash := sha256.Sum256(cdj)
|
||||||
|
sig, err := ecdsa.SignASN1(rand.Reader, a.key, append(append([]byte{}, ad...), hash[:]...))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"challenge": chal,
|
||||||
|
"prf": prf,
|
||||||
|
"credential": map[string]any{
|
||||||
|
"id": b64u(a.credID),
|
||||||
|
"type": "public-key",
|
||||||
|
"response": map[string]any{
|
||||||
|
"clientDataJSON": b64u(cdj),
|
||||||
|
"authenticatorData": b64u(ad),
|
||||||
|
"signature": b64u(sig),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.AssertFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/assert/finish", bytes.NewReader(body)))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPRFHandle(t *testing.T, key *fakeKeyIPC) *PasskeyHandle {
|
||||||
|
t.Helper()
|
||||||
|
store, err := newCredentialStore(filepath.Join(t.TempDir(), "passkeys.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credential store: %v", err)
|
||||||
|
}
|
||||||
|
return &PasskeyHandle{
|
||||||
|
rp: webauthn.NewRP(webauthn.Config{Origin: prfTestOrigin, RPID: prfTestRPID, RPName: "maven"}),
|
||||||
|
encryptFn: key,
|
||||||
|
store: store,
|
||||||
|
session: webauthn.NewPasskeySession(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fix for Vikunja #14: what goes over IPC is the PRF secret from the
|
||||||
|
// authenticator, not the credential public key sitting in passkeys.json.
|
||||||
|
func TestAssertSendsPRFSecretNotPublicKey(t *testing.T) {
|
||||||
|
key := &fakeKeyIPC{}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
// Enrolment must not wrap anything: create() yields no PRF result.
|
||||||
|
if key.wrapCalls != 0 || key.unlockCalls != 0 {
|
||||||
|
t.Fatalf("registration touched the key IPC (wrap=%d unlock=%d)", key.wrapCalls, key.unlockCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := make([]byte, 32)
|
||||||
|
for i := range secret {
|
||||||
|
secret[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.unlockCalls != 1 || key.wrapCalls != 1 {
|
||||||
|
t.Fatalf("unlock=%d wrap=%d, want 1 and 1", key.unlockCalls, key.wrapCalls)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(key.unlockSecret, secret) {
|
||||||
|
t.Errorf("Unlock got %x, want the PRF secret %x", key.unlockSecret, secret)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(key.wrapSecret, secret) {
|
||||||
|
t.Errorf("StoreEncryptionKey got %x, want the PRF secret %x", key.wrapSecret, secret)
|
||||||
|
}
|
||||||
|
// And explicitly: not the credential public key.
|
||||||
|
pub, _, err := h.store.Lookup(b64u(auth.credID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(key.unlockSecret, pub) {
|
||||||
|
t.Fatal("the credential public key was sent as the unlock secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An authenticator without PRF must produce no unlock attempt at all — the
|
||||||
|
// assertion still succeeds (step-up works), but cold-start unlock stays off
|
||||||
|
// rather than falling back to something weaker.
|
||||||
|
func TestAssertWithoutPRFDoesNotUnlock(t *testing.T) {
|
||||||
|
for _, prf := range []string{"", "!!!not-base64!!!", b64u(make([]byte, 32)), b64u(make([]byte, 16))} {
|
||||||
|
key := &fakeKeyIPC{}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
w := auth.assert(t, h, prf)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("prf=%q: AssertFinish %d %s", prf, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if key.unlockCalls != 0 || key.wrapCalls != 0 {
|
||||||
|
t.Errorf("prf=%q: unlock=%d wrap=%d, want no key IPC at all", prf, key.unlockCalls, key.wrapCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed unlock must not fail the assertion: step-up is independently valid,
|
||||||
|
// and a locked daemon degrades rather than breaking the login.
|
||||||
|
func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
|
||||||
|
key := &fakeKeyIPC{unlockErr: errors.New("wrong credential")}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
secret := bytes.Repeat([]byte{3}, 32)
|
||||||
|
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if key.unlockCalls != 1 {
|
||||||
|
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forged assertion must never reach the unlock path.
|
||||||
|
func TestForgedAssertionNeverUnlocks(t *testing.T) {
|
||||||
|
key := &fakeKeyIPC{}
|
||||||
|
h := newPRFHandle(t, key)
|
||||||
|
auth := newPRFAuthenticator(t)
|
||||||
|
auth.register(t, h)
|
||||||
|
|
||||||
|
// A different key signing over the same credential id.
|
||||||
|
attacker := newPRFAuthenticator(t)
|
||||||
|
attacker.credID = auth.credID
|
||||||
|
w := attacker.assert(t, h, b64u(bytes.Repeat([]byte{4}, 32)))
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
t.Fatal("an assertion signed by the wrong key was accepted")
|
||||||
|
}
|
||||||
|
if key.unlockCalls != 0 || key.wrapCalls != 0 {
|
||||||
|
t.Fatalf("a forged assertion reached the key IPC (unlock=%d wrap=%d)", key.unlockCalls, key.wrapCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser side is the only place the PRF result exists. If the page stops
|
||||||
|
// asking for it or stops reading it back, cold-start unlock silently dies with
|
||||||
|
// nothing failing, so the page source is asserted directly.
|
||||||
|
func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
|
||||||
|
for _, want := range []string{
|
||||||
|
"getClientExtensionResults",
|
||||||
|
"ext.prf.results.first",
|
||||||
|
"body:JSON.stringify({challenge,prf,",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(passkeyPageHTML, want) {
|
||||||
|
t.Errorf("the passkey page no longer contains %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{template "shellTop" "tasks"}}
|
||||||
|
<h1>Tasks</h1>
|
||||||
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
|
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||||
|
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>add</h2>
|
||||||
|
<form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=action value=add>
|
||||||
|
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||||
|
<input type=date name=due title="due date (optional)">
|
||||||
|
<select name=weight title="importance (optional)">
|
||||||
|
<option value=0>normal</option>
|
||||||
|
<option value=2>важно</option>
|
||||||
|
<option value=3>срочно</option>
|
||||||
|
</select>
|
||||||
|
<button class=btn>add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Candidates}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||||
|
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||||
|
{{range .Candidates}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||||
|
<td>{{.Due}}</td>
|
||||||
|
<td class=muted>{{.Created}}</td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=confirm>
|
||||||
|
<button class=btn>confirm</button></form></td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=drop>
|
||||||
|
<button class="btn btn-muted">drop</button></form></td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||||
|
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||||
|
{{if .Open}}<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||||
|
{{range .Open}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td class=hint>{{.Why}}</td>
|
||||||
|
<td class=hint>{{.Source}}</td>
|
||||||
|
<td>{{.Due}}</td>
|
||||||
|
<td class=muted>{{.Created}}</td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=done>
|
||||||
|
<button class=btn>done</button></form></td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=drop>
|
||||||
|
<button class="btn btn-muted">drop</button></form></td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||||
|
<div>no open tasks</div>
|
||||||
|
<div class=hint>add one above, or tell maven "добавь в задачи …"</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Resolved}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||||
|
{{range .Resolved}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td class=muted>{{.Resolved}}</td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
|
||||||
|
// writes the page made.
|
||||||
|
type fakeTaskCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
tasks []ipc.Task
|
||||||
|
listErr error
|
||||||
|
|
||||||
|
captured []ipc.CaptureTaskReq
|
||||||
|
created bool
|
||||||
|
captureErr error
|
||||||
|
|
||||||
|
statusID int64
|
||||||
|
statusVal string
|
||||||
|
statusErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||||
|
if f.listErr != nil {
|
||||||
|
return nil, f.listErr
|
||||||
|
}
|
||||||
|
return f.tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
f.captured = append(f.captured, req)
|
||||||
|
if f.captureErr != nil {
|
||||||
|
return ipc.CaptureTaskResp{}, f.captureErr
|
||||||
|
}
|
||||||
|
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||||
|
f.statusID, f.statusVal = id, status
|
||||||
|
return f.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
resolved := now.Add(time.Hour)
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
|
||||||
|
{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
|
||||||
|
{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"купить молоко", "продлить страховку", "полить цветы",
|
||||||
|
"полис истекает", // the evidence trail is visible for review
|
||||||
|
"found, not confirmed", // candidates get their own section
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The candidate must offer confirm, and the open task must not.
|
||||||
|
if !strings.Contains(body, "value=confirm") {
|
||||||
|
t.Error("candidate row has no confirm action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(core.captured) != 1 {
|
||||||
|
t.Fatalf("captured %d requests, want 1", len(core.captured))
|
||||||
|
}
|
||||||
|
got := core.captured[0]
|
||||||
|
if got.Text != "позвонить в банк" {
|
||||||
|
t.Errorf("text = %q, want trimmed", got.Text)
|
||||||
|
}
|
||||||
|
if got.Source != "tap:web" {
|
||||||
|
t.Errorf("source = %q, want tap:web", got.Source)
|
||||||
|
}
|
||||||
|
if got.Status != "open" {
|
||||||
|
t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
|
||||||
|
}
|
||||||
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||||
|
t.Errorf("due = %v", got.Due)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "added task") {
|
||||||
|
t.Error("no confirmation message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: false}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
if !strings.Contains(rec.Body.String(), "already on the list") {
|
||||||
|
t.Error("a deduped capture must not claim it saved something new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksStatusActions(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ action, want string }{
|
||||||
|
{"confirm", "open"},
|
||||||
|
{"done", "done"},
|
||||||
|
{"drop", "dropped"},
|
||||||
|
} {
|
||||||
|
core := &fakeTaskCore{}
|
||||||
|
form := url.Values{"action": {tc.action}, "id": {"42"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if core.statusID != 42 || core.statusVal != tc.want {
|
||||||
|
t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{}
|
||||||
|
form := url.Values{"action": {"explode"}, "id": {"1"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
// The page still renders, with the error inline — and nothing was written.
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if core.statusVal != "" || len(core.captured) != 0 {
|
||||||
|
t.Error("an unknown action must write nothing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "unknown action") {
|
||||||
|
t.Error("error not surfaced on the page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksNoCore(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The open list is ordered by the ranker, and the reason is shown so the page
|
||||||
|
// says why a task is first instead of asking him to trust the order.
|
||||||
|
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
due := now
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
|
||||||
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
|
||||||
|
t.Error("want the dated task rendered first")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "сегодня") {
|
||||||
|
t.Error("want the ranker's reason shown in the why column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate is ranked into place but never carries a priority reason: its due
|
||||||
|
// date is Maven's reading of a mail, not something he stated.
|
||||||
|
func TestHandleTasksHidesCandidateReason(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
due := now
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
if strings.Contains(rec.Body.String(), "сегодня") {
|
||||||
|
t.Error("a candidate must not be shown with a priority reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if len(core.captured) != 1 || core.captured[0].Weight != 3 {
|
||||||
|
t.Fatalf("captured = %+v, want weight 3", core.captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||||
|
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if core.captured[0].Weight != tasks.MaxWeight {
|
||||||
|
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-33
@@ -23,8 +23,8 @@ type assertIPC interface {
|
|||||||
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
||||||
// StoreEncryptionKey and Unlock are silently skipped.
|
// StoreEncryptionKey and Unlock are silently skipped.
|
||||||
type keyIPC interface {
|
type keyIPC interface {
|
||||||
StoreEncryptionKey(ctx context.Context, publicKey []byte) error
|
StoreEncryptionKey(ctx context.Context, secret []byte) error
|
||||||
Unlock(ctx context.Context, publicKey []byte) error
|
Unlock(ctx context.Context, secret []byte) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
|
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
|
||||||
@@ -102,17 +102,31 @@ async function enroll(){try{
|
|||||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||||
say(r.ok?'enrolled ✓':'enroll failed: '+await r.text(),r.ok);
|
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||||
|
// The wrapped key can only be written from an assertion: PRF results are
|
||||||
|
// not produced at create() time on most authenticators. Enrolment reports
|
||||||
|
// whether PRF is available at all so he is not told cold-start works when
|
||||||
|
// it cannot.
|
||||||
|
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||||
|
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||||
|
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||||
|
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||||
}catch(e){say('enroll error: '+e,false);}}
|
}catch(e){say('enroll error: '+e,false);}}
|
||||||
async function assert(){try{
|
async function assert(){try{
|
||||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||||
options.challenge=ub64(options.challenge);
|
options.challenge=ub64(options.challenge);
|
||||||
const c=await navigator.credentials.get({publicKey:options});
|
const c=await navigator.credentials.get({publicKey:options});
|
||||||
|
// The PRF result is the cold-start secret. It never touches localStorage
|
||||||
|
// and is posted once, over the same request as the assertion.
|
||||||
|
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||||
|
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
body:JSON.stringify({challenge,prf,credential:{id:c.id,type:c.type,response:{
|
||||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||||
signature:b64u(c.response.signature)}}})});
|
signature:b64u(c.response.signature)}}})});
|
||||||
say(r.ok?'stepped up ✓ — enable tools now':'assert failed: '+await r.text(),r.ok);
|
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||||
|
say(prf?'stepped up ✓ — enable tools now':
|
||||||
|
'stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);
|
||||||
}catch(e){say('assert error: '+e,false);}}
|
}catch(e){say('assert error: '+e,false);}}
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
@@ -140,9 +154,7 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var enrolledPublicKey []byte
|
|
||||||
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
||||||
enrolledPublicKey = publicKey
|
|
||||||
return h.store.Save(id, publicKey)
|
return h.store.Save(id, publicKey)
|
||||||
}
|
}
|
||||||
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
||||||
@@ -153,19 +165,15 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Printf("webauthn: registered credential %s", credID)
|
log.Printf("webauthn: registered credential %s", credID)
|
||||||
|
|
||||||
// If mavend is reachable and supports key wrapping, store the encryption
|
// Note what does NOT happen here: the encryption key is not wrapped at
|
||||||
// key wrapped with this credential's public key — enables cold-start unlock.
|
// enrolment. Wrapping needs the authenticator's PRF output, and create()
|
||||||
if h.encryptFn != nil && enrolledPublicKey != nil {
|
// does not produce one on most authenticators — it only reports whether
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
// the extension is supported. The wrapped key is written on the first
|
||||||
defer cancel()
|
// assertion instead (see AssertFinish).
|
||||||
if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil {
|
//
|
||||||
log.Printf("webauthn: store encryption key: %v", err)
|
// This used to wrap the key under the credential *public* key, which is
|
||||||
// Non-fatal: enrollment still succeeded, the wrapped key can be
|
// written to passkeys.json next to the wrapped blob. See the header of
|
||||||
// created later via the same endpoint.
|
// internal/webauthn/keywrap.go.
|
||||||
} else {
|
|
||||||
log.Printf("webauthn: encryption key wrapped with credential %s", credID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||||
}
|
}
|
||||||
@@ -189,6 +197,11 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
Credential map[string]any `json:"credential"`
|
Credential map[string]any `json:"credential"`
|
||||||
|
// PRF is the base64url WebAuthn PRF output the browser read out of
|
||||||
|
// getClientExtensionResults(). Empty when the authenticator has no
|
||||||
|
// PRF extension: cold-start unlock is then unavailable and we say so
|
||||||
|
// rather than falling back to something weaker.
|
||||||
|
PRF string `json:"prf"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||||
@@ -222,26 +235,32 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the daemon is locked (cold-start), send the credential's public key
|
// Cold-start unlock and key wrapping, both keyed on the PRF secret that
|
||||||
// over IPC so mavend can unwrap its encryption key and open the store.
|
// this assertion just produced. The secret is used here and dropped; it is
|
||||||
// The public key comes from the local credential store (it was stored
|
// never stored on this side.
|
||||||
// during enrollment). Non-fatal: if IPC doesn't support Unlock or the
|
//
|
||||||
// daemon is already unlocked, the call is a no-op on the server side.
|
// Order matters: unlock first (if the daemon is locked there is nothing to
|
||||||
|
// wrap yet), then re-wrap, which writes the blob on the first assertion
|
||||||
|
// after enrolment and is a harmless rewrite afterwards. Both are
|
||||||
|
// best-effort — the assertion itself is valid either way.
|
||||||
if h.encryptFn != nil {
|
if h.encryptFn != nil {
|
||||||
publicKey, _, err := h.store.Lookup(credID)
|
secret, err := webauthn.DecodePRFResult(body.PRF)
|
||||||
if err == nil && publicKey != nil {
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
|
||||||
|
default:
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := h.encryptFn.Unlock(ctx, publicKey); err != nil {
|
if err := h.encryptFn.Unlock(ctx, secret); err != nil {
|
||||||
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
||||||
// Non-fatal: assertion succeeded; if the daemon stays locked
|
|
||||||
// the user will see errors on subsequent pages, but the
|
|
||||||
// assertion itself is valid.
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
||||||
}
|
}
|
||||||
} else if err != nil {
|
if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil {
|
||||||
log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err)
|
log.Printf("webauthn: wrap encryption key: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("webauthn: encryption key wrapped for credential %s", credID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,67 @@ present (see `.dockerignore`).
|
|||||||
| `/var/lib/maven` (volume) | encrypted db at rest |
|
| `/var/lib/maven` (volume) | encrypted db at rest |
|
||||||
| `/dev/shm` (tmpfs) | decrypted db working copy (RAM only) |
|
| `/dev/shm` (tmpfs) | decrypted db working copy (RAM only) |
|
||||||
|
|
||||||
|
## Reading the outside world (off by default)
|
||||||
|
|
||||||
|
`mavend.json` ships without a `feeds` block, which means no RSS/Atom feed is
|
||||||
|
fetched and no outbound request is made. Switching it on is adding the block:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"feeds": {
|
||||||
|
"poll_interval": "30m",
|
||||||
|
"max_items": 5,
|
||||||
|
"max_age": "24h",
|
||||||
|
"sources": [
|
||||||
|
{ "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
|
||||||
|
"category": "технологии", "exclude": ["реклама"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does and does not do:
|
||||||
|
|
||||||
|
- items are written as notes with source `rss:<name>`, visible on `/dash`;
|
||||||
|
- **nothing is announced.** She reads them back when asked — "что нового в
|
||||||
|
лентах?", "что нового по технологиям?" — and never on arrival. There is no
|
||||||
|
severity or channel knob here on purpose;
|
||||||
|
- the fetcher is allowlisted to the hosts of the configured feeds, plus any
|
||||||
|
`allow_hosts`. It refuses non-http(s) schemes and every private address
|
||||||
|
(loopback, the LAN, the `10.42.0.0/24` wg range, cloud metadata). It caps the
|
||||||
|
response at 2 MiB and redirects at 3, and makes at most one request per host
|
||||||
|
per second. See `internal/webfetch`;
|
||||||
|
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
|
||||||
|
a restart does not re-note yesterday's headlines.
|
||||||
|
|
||||||
|
### Reading a page (`crawl`, also off by default)
|
||||||
|
|
||||||
|
There is no `crawl` block either, so no page is fetched. Two halves, separately
|
||||||
|
switched:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"crawl": {
|
||||||
|
"on_demand": true,
|
||||||
|
"interval": "6h",
|
||||||
|
"max_runes": 4000,
|
||||||
|
"watches": [
|
||||||
|
{ "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `on_demand` lets her read a page he names in the utterance: "посмотри
|
||||||
|
https://example.org/x — что там?". The page becomes context for his question,
|
||||||
|
and only the URL leaves the box. Without a URL nothing is fetched, so this is
|
||||||
|
a fallback and not a habit;
|
||||||
|
- `watches` re-reads a fixed list on its interval and writes a note when the
|
||||||
|
text changed. Like the feeds, it announces nothing;
|
||||||
|
- the answer path sits **last** in the query chain, behind his memory, his notes
|
||||||
|
and (once wired) the local Kiwix ZIMs. A local read costs nothing;
|
||||||
|
- `robots.txt` is fetched first and obeyed with no override; a `Disallow` is a
|
||||||
|
refusal she says out loud. `Crawl-delay` is honoured;
|
||||||
|
- same guarded fetcher as the feeds: allowlist/denylist, no private addresses,
|
||||||
|
size cap, redirect cap, timeout, one request per host per second;
|
||||||
|
- dedup state is the config fact `crawl:hash:<name>`.
|
||||||
|
|
||||||
## Not yet verified / host-dependent
|
## Not yet verified / host-dependent
|
||||||
|
|
||||||
This stack is correct-by-construction but has **not been build-tested here**
|
This stack is correct-by-construction but has **not been build-tested here**
|
||||||
@@ -53,3 +114,49 @@ build on the target host, most likely in one of these:
|
|||||||
work fine over the core socket.
|
work fine over the core socket.
|
||||||
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
|
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
|
||||||
netdata runs elsewhere.
|
netdata runs elsewhere.
|
||||||
|
|
||||||
|
## Updating her (`mavupdate`, Vikunja #249)
|
||||||
|
|
||||||
|
Off unless configured, and there is deliberately no button for it. There is no
|
||||||
|
IPC method, no web route, no timer and no act that starts an update — the trigger
|
||||||
|
is a human running `mavupdate` on the host, which needs shell access, a strictly
|
||||||
|
higher bar than the step-up passkey gate that guards `/tools`. She cannot update
|
||||||
|
herself; she can be updated. Nothing here ever fetches code: the new version is
|
||||||
|
whatever you pulled into the working tree yourself.
|
||||||
|
|
||||||
|
Add an `update` block to `mavend.json` (mavend ignores it — only the CLI reads
|
||||||
|
it), with paths as they exist **on the host**, not inside a container:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"update": {
|
||||||
|
"source_dir": "/home/kami/apps/Maven",
|
||||||
|
"install_dir": "/home/kami/apps/Maven",
|
||||||
|
"snapshot_dir": "/var/lib/maven-snapshots",
|
||||||
|
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
|
||||||
|
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
|
||||||
|
"config_files": ["deploy/mavend.json"],
|
||||||
|
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
|
||||||
|
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
|
||||||
|
"health_timeout_sec": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`snapshot_dir` must be outside `install_dir` (a restore must not read from what
|
||||||
|
the install writes) and `health_socket` is required: an update that cannot check
|
||||||
|
its own result cannot roll itself back, so the config is refused without one.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||||
|
mavupdate -config deploy/mavend.json apply -yes # snapshot, verify, install, restart, health-check
|
||||||
|
mavupdate -config deploy/mavend.json list # what you can roll back to
|
||||||
|
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart
|
||||||
|
```
|
||||||
|
|
||||||
|
`apply` refuses to start if she is not already answering — otherwise a failed
|
||||||
|
update and a box that was already broken are indistinguishable afterwards. On any
|
||||||
|
failure after the install it restores the snapshot, restarts, and checks again;
|
||||||
|
if that also fails it says so loudly and names the directory to copy back by hand.
|
||||||
|
The database is never snapshotted or rolled back (see the package comment in
|
||||||
|
`internal/update`); schema compatibility is `store.Migrate`'s job.
|
||||||
|
|||||||
@@ -8,6 +8,18 @@
|
|||||||
#
|
#
|
||||||
# Maven's own compose joins this same network (add `ecosystem` as an external
|
# Maven's own compose joins this same network (add `ecosystem` as an external
|
||||||
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
|
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
|
||||||
|
#
|
||||||
|
# NO RELEASE PINNING (Vikunja #354): each `build:` below points at a sibling
|
||||||
|
# WORKING TREE, so `up --build` ships whatever is checked out there, including
|
||||||
|
# uncommitted edits. Before bringing this up, check what you are about to
|
||||||
|
# deploy:
|
||||||
|
#
|
||||||
|
# for r in nexus praxis hexis; do git -C ../../../$r status --short; \
|
||||||
|
# git -C ../../../$r log -1 --oneline; done
|
||||||
|
#
|
||||||
|
# The host nginx that fronts these is deploy/ecosystem/nginx.conf — it binds
|
||||||
|
# the wg and LAN addresses only, with allow/deny. Keep it that way: none of
|
||||||
|
# these containers has auth of its own.
|
||||||
name: ecosystem
|
name: ecosystem
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the
|
# Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
|
||||||
# nginx-panel app) and reload. Assumes the compose publishes each service on
|
# your nginx sites (or the nginx-panel app) and reload. Assumes the compose
|
||||||
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server.
|
# publishes each service on 127.0.0.1:<port>. Add TLS (certbot / your existing
|
||||||
|
# cert block) per server.
|
||||||
#
|
#
|
||||||
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
|
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
|
||||||
# elsewhere first (the app now owns hexis.*).
|
# elsewhere first (the app now owns hexis.*).
|
||||||
@@ -12,6 +13,50 @@
|
|||||||
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
||||||
# that removes the only access control these containers have.
|
# that removes the only access control these containers have.
|
||||||
|
|
||||||
|
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
|
||||||
|
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
|
||||||
|
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
|
||||||
|
# /routines, /api/revert and /api/chat (Vikunja #317). Without
|
||||||
|
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
|
||||||
|
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
|
||||||
|
# allow/deny anyway — belt and braces on an RCE surface.
|
||||||
|
#
|
||||||
|
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
|
||||||
|
# Upgrade/Connection headers below are required, not decoration. The map keeps
|
||||||
|
# `Connection: upgrade` off plain requests; it sits in the http context, which
|
||||||
|
# is where sites-available files are included — if your nginx already defines
|
||||||
|
# $connection_upgrade, drop this block.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 10.42.0.1:80;
|
||||||
|
listen 192.168.1.104:80;
|
||||||
|
server_name maven.kvmx.ru;
|
||||||
|
|
||||||
|
allow 10.42.0.0/24;
|
||||||
|
allow 192.168.1.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
# push-to-talk uploads raw PCM; the default 1m is enough for a short
|
||||||
|
# utterance but not for a long one.
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9201;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 10.42.0.1:80;
|
listen 10.42.0.1:80;
|
||||||
listen 192.168.1.104:80;
|
listen 192.168.1.104:80;
|
||||||
|
|||||||
@@ -26,6 +26,45 @@
|
|||||||
"severity_ceiling": 2
|
"severity_ceiling": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"pattern_proposals": {
|
||||||
|
"notify": false,
|
||||||
|
"cooldown": "24h"
|
||||||
|
},
|
||||||
|
|
||||||
|
"mcp": {
|
||||||
|
"timeout": "15s",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"name": "vikunja",
|
||||||
|
"url": "http://192.168.1.104:9100/mcp",
|
||||||
|
"allow_private": true,
|
||||||
|
"allow_tools": ["list_projects", "list_tasks", "get_task_details", "create_task"],
|
||||||
|
"max_tools": 6,
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"smarthome": {
|
||||||
|
"provider": "homeassistant",
|
||||||
|
"url": "http://192.168.1.50:8123",
|
||||||
|
"token": "${HA_TOKEN}",
|
||||||
|
"domains": ["light", "switch", "sensor"],
|
||||||
|
"max_entities": 40,
|
||||||
|
"timeout": "10s",
|
||||||
|
"refresh": "15m",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"netscan": {
|
||||||
|
"subnets": ["192.168.1.0/24"],
|
||||||
|
"ports": [22, 80, 443, 8080],
|
||||||
|
"timeout": "400ms",
|
||||||
|
"rate": 50,
|
||||||
|
"max_hosts": 256,
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
"praxis": { "url": "http://praxis:8989" },
|
"praxis": { "url": "http://praxis:8989" },
|
||||||
"hexis": { "url": "http://hexis:9741" },
|
"hexis": { "url": "http://hexis:9741" },
|
||||||
|
|||||||
@@ -101,9 +101,42 @@ services:
|
|||||||
"-netdata", "http://127.0.0.1:19999",
|
"-netdata", "http://127.0.0.1:19999",
|
||||||
"-kuma", "http://127.0.0.1:3001/metrics",
|
"-kuma", "http://127.0.0.1:3001/metrics",
|
||||||
"-kuma-key", "uk5_mavpoll-key"]
|
"-kuma-key", "uk5_mavpoll-key"]
|
||||||
|
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
|
||||||
|
# which mavpoll reads from a FILE so it never appears in `ps`, in
|
||||||
|
# this file, or in shell history. To enable, mount the token and
|
||||||
|
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
|
||||||
|
# (optionally "-zenmoney-interval", "1h"). Core never sees the
|
||||||
|
# token — the poller writes facts(kind=env, source=poll:zenmoney)
|
||||||
|
# and mavend only reads those back when he asks.
|
||||||
depends_on: [mavend]
|
depends_on: [mavend]
|
||||||
volumes:
|
volumes:
|
||||||
- sockets:/run/maven
|
- sockets:/run/maven
|
||||||
|
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
|
||||||
|
|
||||||
|
# The mail reader (Vikunja #246) is OFF and commented out: it needs an IMAP
|
||||||
|
# account, and there is none on this box. mavmaild reads the password from a
|
||||||
|
# FILE so it never appears in `ps`, in this file, or in shell history — the
|
||||||
|
# same rule mavpoll follows for the zenmoney token. Core never sees the
|
||||||
|
# password: the reader hands core message text on one IPC method, and core
|
||||||
|
# writes what the model extracts as task CANDIDATES he reviews on /tasks.
|
||||||
|
# Nothing here can create a reminder, so a misread mail cannot fire.
|
||||||
|
#
|
||||||
|
# To enable: write the password to deploy/imap.password (0600, gitignored),
|
||||||
|
# add an "email": {} block to deploy/mavend.json, and uncomment this service.
|
||||||
|
# mavmaild:
|
||||||
|
# <<: *image
|
||||||
|
# command: ["mavmaild", "-socket", "/run/maven/mavend.sock",
|
||||||
|
# "-imap", "imap.example.org:993",
|
||||||
|
# "-user", "kami@example.org",
|
||||||
|
# "-password-file", "/run/secrets/imap.password",
|
||||||
|
# "-mailbox", "INBOX",
|
||||||
|
# "-interval", "15m",
|
||||||
|
# "-state", "/var/lib/maven/mail-seen.json"]
|
||||||
|
# depends_on: [mavend]
|
||||||
|
# volumes:
|
||||||
|
# - sockets:/run/maven
|
||||||
|
# - dbdata:/var/lib/maven
|
||||||
|
# - ./deploy/imap.password:/run/secrets/imap.password:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
|||||||
@@ -25,3 +25,46 @@
|
|||||||
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
|
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
|
||||||
7. Add `memory_eval` block to `deploy/mavend.json`
|
7. Add `memory_eval` block to `deploy/mavend.json`
|
||||||
8. Test with synthetic store state — verify observations match expected patterns
|
8. Test with synthetic store state — verify observations match expected patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status 2026-08-01 — foundation shipped (Vikunja #248)
|
||||||
|
|
||||||
|
**Shipped:** `internal/memeval` (not `internal/memory/eval.go` — `internal/store`
|
||||||
|
imports `internal/memory` for the vector backend, so an evaluator that reads
|
||||||
|
`store.Fact` there would close an import cycle). `Evaluator.Evaluate` reads
|
||||||
|
`RecentFacts` / `RecentNotes` / `RecentNudges`, prompts the resident model under
|
||||||
|
a GBNF grammar for at most three `{observation, confidence, suggested_action}`
|
||||||
|
objects, drops anything under `min_confidence`, deduplicates against what earlier
|
||||||
|
evaluations wrote, and records the rest as notes with source `infer:memory-eval`.
|
||||||
|
Driver: `cmd/mavend/memoryeval.go`, its own goroutine on its own ticker. Config:
|
||||||
|
the `memory_eval` block — **absent ⇒ the loop does not run**. Visibility: `/dash`
|
||||||
|
already renders notes with their source, so evaluation output is visible with no
|
||||||
|
UI change.
|
||||||
|
|
||||||
|
**Deliberately not shipped — this is policy, not an unfinished edge:**
|
||||||
|
|
||||||
|
- *Dispatching observations as care nudges (plan step 4).* An hourly LLM loop
|
||||||
|
with permission to speak is a machine for generating interruptions, and the
|
||||||
|
content is model-generated text about his own life. The evaluator has no
|
||||||
|
dispatcher reference at all, so it cannot reach a channel by accident. Wiring
|
||||||
|
it to `delivery.Dispatcher` is a separate decision with its own opt-in.
|
||||||
|
- *Acting on `suggested_action`.* It is recorded inside the note text and
|
||||||
|
interpreted by nobody. No reminder, routine or fact is created.
|
||||||
|
- *Writing observation embeddings.* Notes are written with a nil embedding, so
|
||||||
|
they stay out of the RAG recall pool. Feeding generated text back into the pool
|
||||||
|
it came from is how a small model starts citing its own guesses as evidence.
|
||||||
|
|
||||||
|
**Deferred, wants a decision or another capability:**
|
||||||
|
|
||||||
|
- *Plan step 6, the `/eval` IPC method and an evaluation-history view.* `/dash`
|
||||||
|
covers reading the output; a dedicated trace surface is worth building once
|
||||||
|
there is real output to look at, and it should probably show the prompt too.
|
||||||
|
- *`RecentEvents`.* The plan lists it; the evaluator reads facts, notes and
|
||||||
|
nudges. Detected action/object events already drive pattern proposals (#43), and
|
||||||
|
duplicating them here would mostly re-derive that.
|
||||||
|
- *Output quality is unmeasured.* There is no fixture for "did she notice
|
||||||
|
something true". The tests cover the machinery — empty store, confidence floor,
|
||||||
|
dedupe, own-notes exclusion, error handling — not the observations. Until
|
||||||
|
someone reads a week of real output on `/dash`, treat the wording and the
|
||||||
|
`min_confidence` default as unvalidated.
|
||||||
|
|||||||
+101
-22
@@ -1,27 +1,106 @@
|
|||||||
# Plan: Vision — Image Understanding Capability
|
# Plan: Vision — Image Understanding Capability
|
||||||
|
|
||||||
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), run vision inference via a local or remote multimodal model, and answer questions about the image content or extract structured information.
|
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), store them, run inference via a **local** multimodal model, and answer questions about the image content or extract text from it.
|
||||||
|
|
||||||
**Done when:**
|
**Status (2026-08-01):** intake, storage, config seam and the provider are shipped. The
|
||||||
- Vision model backend is configurable: local multimodal LLM (e.g., LLaVA, Qwen-VL via `llama-server` mmproj) or remote API
|
describing half is **BLOCKED on a model download** — see "What is blocked" below.
|
||||||
- `internal/vision/` package handles image preprocessing, model inference, result parsing
|
|
||||||
- Voice/text commands like "что на картинке?" or "прочитай текст с экрана" route to the vision handler
|
|
||||||
- Extracted information can be written as facts/notes through `ipc.CoreAPI`
|
|
||||||
- Telegram image messages are processed through the same pipeline
|
|
||||||
|
|
||||||
**Scope:**
|
## What shipped
|
||||||
- New `internal/vision/` package — image loader (Go stdlib `image` + `golang.org/x/image`), inference client
|
|
||||||
- New config block: `voice.vision` in `config.Config` — `{enabled, provider, model_path, mmproj_path, remote_url}`
|
|
||||||
- Router intent extension: new `IntentVision` or reuse `IntentQuery` with a vision flag
|
|
||||||
- Reuses `internal/llm.Client` for API-compatible backends (OpenAI-compatible vision API)
|
|
||||||
- Reuses `internal/ipc.CoreAPI` for writing extracted data
|
|
||||||
|
|
||||||
**Steps:**
|
| Piece | Where |
|
||||||
1. Create `internal/vision/provider.go` — `Provider` interface with `Describe(image []byte, prompt string) (string, error)` and `ExtractText(image []byte) (string, error)`
|
|---|---|
|
||||||
2. Implement `LocalProvider` — spawns `llama-server` with mmproj, sends multimodal chat completion requests
|
| Blob store (content-addressed, retention-pruned) | `internal/media/store.go` |
|
||||||
3. Implement `RemoteProvider` — calls an OpenAI-compatible vision API endpoint, reuses `internal/llm.Client`
|
| Image decode / flatten / downscale / JPEG | `internal/media/image.go` |
|
||||||
4. Create `internal/vision/processor.go` — image preprocessing (resize, format conversion to JPEG/PNG, base64 encoding)
|
| `Provider` seam + `Disabled` floor + `LocalProvider` | `internal/vision/vision.go` |
|
||||||
5. Wire vision into `cmd/mavend/voice.go:reactiveHandler` — detect vision intent from router (new `IntentVision` or a `Slots.HasImage` flag)
|
| Store-then-describe orchestration, re-runnable | `internal/vision/intake.go` |
|
||||||
6. Add IPC method `MethodDescribeImage` for programmatic access (mavweb upload, telegram bot)
|
| Config blocks `media` and `vision` | `internal/config/config.go` |
|
||||||
7. Add vision config block to `config.Config` and wire in `cmd/mavend/main.go`
|
| IPC method `describe_image` (`AuthRead`) | `internal/ipc/{wire,api,client,server}.go`, `internal/auth/policy.go` |
|
||||||
8. Test with a local multimodal model: send an image via mavweb, verify description and text extraction
|
| Daemon wiring + hourly retention prune | `cmd/mavend/vision.go` |
|
||||||
|
|
||||||
|
`internal/media` is deliberately shared: hearing (#253) and speaker recognition (#255) have
|
||||||
|
the same intake problem — a blob arrives, gets stored, gets described — and they store their
|
||||||
|
audio in the same place under the same retention.
|
||||||
|
|
||||||
|
## Design decisions worth knowing
|
||||||
|
|
||||||
|
**Store before describe.** `Intake.Accept` writes the blob to disk *first*, then asks the
|
||||||
|
model. If the model is missing or broken — which is this box's actual state — the answer is
|
||||||
|
"it's kept, I can't read it yet" with a content-addressed id, and `Intake.Rerun(id, question)`
|
||||||
|
describes it later. Nothing is lost to a missing model.
|
||||||
|
|
||||||
|
**No `RemoteProvider`.** The original step 3 called for "an OpenAI-compatible vision API
|
||||||
|
endpoint". Refused. The surviving hard constraint in CLAUDE.md after "never phones home" was
|
||||||
|
deprecated is *no cloud model, inference stays on the box*, and a photo of his flat is the
|
||||||
|
worst possible exception. `vision.NewLocal` therefore validates the endpoint at construction:
|
||||||
|
loopback, a private IP, or `localhost`. A hostname is refused too — it could resolve anywhere,
|
||||||
|
and resolving it would mean trusting DNS with his pictures.
|
||||||
|
|
||||||
|
**Blobs are not in the database.** The sqlite store is small, encrypted and read every tick;
|
||||||
|
a 40 MB blob has no business there. What lands in the database is the *text* the blob produced,
|
||||||
|
as an ordinary note (`source: media:image:<id-prefix>`), and only when the caller asks for it
|
||||||
|
(`save_note`). Glancing at a screenshot is not the same act as remembering it.
|
||||||
|
|
||||||
|
**Images are never search input and never embedded.** Only the derived description
|
||||||
|
participates in recall, and only after he can see it as a note.
|
||||||
|
|
||||||
|
**Retention is enforced by a loop, not by a promise.** `media.retention` defaults to 7 days
|
||||||
|
and `cmd/mavend` prunes hourly, starting at boot. A store that grows forever would be the real
|
||||||
|
failure mode of this capability.
|
||||||
|
|
||||||
|
**No webp.** The stdlib has no webp decoder and this repo takes no new dependencies (the box
|
||||||
|
is offline). `media.SniffImage` recognises webp well enough to refuse it *by name*, so the log
|
||||||
|
says "webp is not supported" instead of "not an image". Telegram sends webp for stickers; that
|
||||||
|
is a known gap, not a mystery.
|
||||||
|
|
||||||
|
**Text extraction is not a second method.** "прочитай текст с картинки" is a prompt. A VLM has
|
||||||
|
no separate OCR mode to select, and a second interface method would only duplicate the first.
|
||||||
|
|
||||||
|
## What is blocked, and on what
|
||||||
|
|
||||||
|
There is **no vision-capable gguf and no mmproj file on this box**. Checked 2026-08-01:
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/hdd1/llms/{Bonsai,LFM2.5,llama3.2,ministral,nemotron3-nano,qwen3,qwen3.5}
|
||||||
|
```
|
||||||
|
|
||||||
|
— sixteen ggufs, all text-only, no `*mmproj*` anywhere. The resident Qwen3-1.7B is text-only
|
||||||
|
by construction, so vision needs a *second* model. The ≤1.7B ceiling in CLAUDE.md is about the
|
||||||
|
resident router/phraser, not about a second model loaded on demand — but iGPU VRAM still is,
|
||||||
|
so keep it small.
|
||||||
|
|
||||||
|
To unblock, download one pair to `/mnt/hdd1/llms/vision/` (bind-mounted to
|
||||||
|
`/opt/maven/models/llm`), a gguf **and** its mmproj:
|
||||||
|
|
||||||
|
- `Qwen2.5-VL-3B-Instruct` (Q4_K_M + `mmproj-F16.gguf`) — the safe default; reads Russian, and
|
||||||
|
its OCR is the best of this size class.
|
||||||
|
- `SmolVLM2-2.2B-Instruct` — smaller and faster, weaker at Cyrillic text in images.
|
||||||
|
- `moondream2` — smallest, English-only in practice. Do not bother, per the sub-500M lesson.
|
||||||
|
|
||||||
|
Then run a second llama-server on 8081 with `--mmproj`, point `vision.endpoint` at it, and
|
||||||
|
walk the QA steps on Vikunja #252.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
"media": { "dir": "media", "retention": "168h", "max_bytes": 67108864 },
|
||||||
|
"vision": {
|
||||||
|
"enabled": true,
|
||||||
|
"endpoint": "http://127.0.0.1:8081",
|
||||||
|
"model": "qwen2.5-vl-3b",
|
||||||
|
"max_dim": 896,
|
||||||
|
"max_tokens": 300,
|
||||||
|
"timeout": "90s"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both absent by default. No `media` block ⇒ `describe_image` does not exist at all; a `media`
|
||||||
|
block with no `vision` block ⇒ images are stored and honestly not described.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- **Router intent.** "что на картинке?" does not route anywhere yet. Adding an intent is
|
||||||
|
premature while nothing can answer it; the IPC method is the surface a Telegram photo or a
|
||||||
|
mavweb upload calls today.
|
||||||
|
- **Telegram photo path** in `mavpoll` (download the file, call `DescribeImage`).
|
||||||
|
- **mavweb upload page** and a `/media` listing so stored blobs are visible and deletable from
|
||||||
|
the authed surface.
|
||||||
|
|||||||
+117
-24
@@ -1,28 +1,121 @@
|
|||||||
# Plan: Hearing — Audio Stream Monitoring & Meeting Summarization
|
# Plan: Hearing — Meeting Capture & Summarisation
|
||||||
|
|
||||||
**Goal:** Maven can "hear" ambient audio from workpc — microphone input during meetings, system audio — and on demand (or on trigger) produce transcripts, summaries, or extract action items. A typical use case: "Maven, запиши встречу" starts capture, "хватит" stops it, and Maven writes a summary note.
|
**Goal:** "Maven, запиши встречу" starts a recording, "хватит" stops it, and she writes a
|
||||||
|
summary note. The audio stays on the box, is pruned by retention, and nothing is recorded that
|
||||||
|
nobody asked for.
|
||||||
|
|
||||||
**Done when:**
|
**Status (2026-08-01):** the recorder, the storage, the chunked transcription, the map-reduce
|
||||||
- `internal/audio/capture.go` — remote microphone capture client (receives PCM stream from workpc over WebSocket or the existing voice TCP protocol)
|
summariser, the config seam and the four IPC methods are shipped and tested. What is not
|
||||||
- `internal/stt/` — streaming transcription (uses existing `stt.Transcriber` interface, extended with streaming support)
|
shipped is the workpc-side microphone agent and the router intent — see "Still open".
|
||||||
- Meeting capture triggered by voice command (IntentCapture) or configurable keyword ("maven record")
|
|
||||||
- Raw audio is either streamed to STT in real-time or saved to a WAV file and transcribed after capture ends
|
|
||||||
- Transcription + LLM summary is written as a note (`source:capture:meeting`) through `ipc.CoreAPI`
|
|
||||||
- New `mavheary` module (`cmd/mavheard/`) — the workpc-side agent that captures mic/speaker audio and streams it to mavend
|
|
||||||
|
|
||||||
**Scope:**
|
## What shipped
|
||||||
- New `cmd/mavheard/` — workpc-side agent: captures microphone (PortAudio or ALSA `arecord`), streams over WebSocket to mavend
|
|
||||||
- `internal/audio/` extended with capture types: `MicCapture`, `SystemCapture`, `FileCapture`
|
|
||||||
- `internal/stt/stt.go` extended with `StreamingTranscriber` interface (or reuse existing with chunked input)
|
|
||||||
- Router: new `IntentCapture` intent for start/stop commands
|
|
||||||
- Reuses `internal/llm.Client` for summarization
|
|
||||||
- Reuses `internal/voice/server.go` TCP protocol for streaming audio
|
|
||||||
|
|
||||||
**Steps:**
|
| Piece | Where |
|
||||||
1. Create `cmd/mavheard/main.go` — workpc-side daemon: captures microphone via `arecord` pipe or PortAudio, opens WebSocket or TCP connection to mavend, streams PCM frames
|
|---|---|
|
||||||
2. Create `internal/audio/capture.go` — `Capture` interface: `Start()`, `Stop()`, `AudioCh <-chan Audio`; implement `MicCapture` (reads from `mavheard` stream) and `FileCapture` (reads WAV)
|
| Session state machine: start / append / stop / abort / status | `internal/capture/capture.go` |
|
||||||
3. Extend `internal/stt/stt.go` — add `TranscribeStream(ctx, audio <-chan Audio) (string, error)` to `Transcriber` interface; `Stub` returns empty; `Remote` forwards chunks to worker socket
|
| Map-reduce summarisation against `n_ctx` 4096 | `internal/capture/summarize.go` |
|
||||||
4. Add `IntentCapture` to `internal/router/intent.go` — slots: `Action` ("start"/"stop"/"status"), `Duration`
|
| Audio blobs in the shared store, pruned by `media.retention` | `internal/media` (from #252) |
|
||||||
5. Wire capture handler in `cmd/mavend/voice.go:reactiveHandler` — start = spawn goroutine receiving audio, stream to STT; stop = finalize, send to LLM for summarization, write note via `WriteNote`
|
| Config block `capture`, off by default | `internal/config/config.go` |
|
||||||
6. Add capture config to `voice` block in `config.Config` — `{capture_enabled, capture_timeout}`
|
| IPC `capture_start` / `capture_append` / `capture_stop` / `capture_status` | `internal/ipc/{wire,api,client,server}.go` |
|
||||||
7. Test with a recorded WAV file — simulate a meeting, verify transcription + summary note is created
|
| Authority: the three write methods `AuthWrite`, status `AuthRead` | `internal/auth/policy.go` |
|
||||||
|
| Daemon wiring, note write, STT reuse | `cmd/mavend/capture.go` |
|
||||||
|
|
||||||
|
The audio lands in the same content-addressed blob store as images, under the same retention
|
||||||
|
loop, because #252 and #253 have the same intake problem and solving it twice would mean two
|
||||||
|
directories to remember to prune.
|
||||||
|
|
||||||
|
## The refusals, and why
|
||||||
|
|
||||||
|
**Nothing listens.** The original step 8 called for capture "triggered by voice command
|
||||||
|
(IntentCapture) **or configurable keyword ('maven record')**". The keyword half is refused.
|
||||||
|
Noticing a keyword requires listening to the room continuously, which is precisely the
|
||||||
|
behaviour this capability must not have, and the refusal is in the code rather than in a
|
||||||
|
comment: `Recorder.Append` is the only way audio enters, and it returns `ErrNoSession` unless
|
||||||
|
someone explicitly started a session. Audio arriving at an idle core is dropped, not buffered
|
||||||
|
"just in case".
|
||||||
|
|
||||||
|
**Off unless configured, twice over.** No `media` block ⇒ nowhere to keep audio ⇒ the four
|
||||||
|
methods do not exist. No `capture` block with `enabled: true` ⇒ they still do not exist. On an
|
||||||
|
unconfigured box there is no wire path at all that begins a recording. That is the only
|
||||||
|
guarantee worth making here, and it is the reason the hooks use the nil-hook ⇒
|
||||||
|
`ErrUnknownMethod` pattern rather than an in-handler check.
|
||||||
|
|
||||||
|
**A forgotten session ends itself.** `max_minutes` defaults to 120 and is checked on every
|
||||||
|
append, not on a timer that could be missed. Past the cap `Append` returns `ErrExpired`
|
||||||
|
permanently, so a client that ignores the error cannot grow the recording; the audio collected
|
||||||
|
before the cap is kept and `Stop` still works.
|
||||||
|
|
||||||
|
**"Забудь, не записывай" leaves nothing behind.** `capture_stop` with `discard: true` throws
|
||||||
|
the session away without storing, transcribing or summarising anything — not a blob with a note
|
||||||
|
saying it was abandoned. Nothing.
|
||||||
|
|
||||||
|
**The transcript is not saved by default.** The summary is written where he will read it; the
|
||||||
|
verbatim record of what other people said in a room is a heavier thing to keep and takes a
|
||||||
|
deliberate `save_transcript: true`. The audio blob is pruned by `media.retention` either way.
|
||||||
|
|
||||||
|
**No second STT.** Step 3 of the original plan extended the `Transcriber` interface with
|
||||||
|
streaming. Not needed and not done: whisper.cpp already runs as `mavsttd`, and `internal/capture`
|
||||||
|
takes the ordinary `stt.Transcriber` the voice path already holds (exposed as
|
||||||
|
`voiceWiring.transcriber`). Long recordings are handed over in five-minute windows —
|
||||||
|
`chunkAudio`, cut on sample boundaries — for the same reason whisper itself works in 30-second
|
||||||
|
windows: an hour of PCM in one call either times out or blocks the voice path for minutes.
|
||||||
|
Capture with voice off is refused rather than degraded, because storing hours of unreadable
|
||||||
|
audio of other people is worse than not recording.
|
||||||
|
|
||||||
|
**Not `AuthStepUp`.** Recording people is invasive enough to argue for the top rung, and it is
|
||||||
|
still wrong: step-up needs a passkey gesture, which the voice path cannot make, so
|
||||||
|
"запиши встречу" could never work by voice — the only way he will actually use this. `AuthWrite`
|
||||||
|
plus the off-unless-configured gate is the honest combination.
|
||||||
|
|
||||||
|
## Long audio against a 4096-token context
|
||||||
|
|
||||||
|
The resident model is a Thinking variant at `n_ctx` 4096, so an hour of transcript does not fit
|
||||||
|
in one prompt and never will. `summarize.go` does map-reduce and nothing cleverer: split the
|
||||||
|
transcript on sentence boundaries into 3000-rune windows (about 1100 Qwen tokens of Russian,
|
||||||
|
leaving room for the persona block, the reasoning and the answer), summarise each, then
|
||||||
|
summarise the summaries. A transcript that fits in one window skips the reduce step.
|
||||||
|
|
||||||
|
Truncation was the alternative and is rejected: a truncated meeting summary reads as complete
|
||||||
|
and is not, and he would act on it. Past `max_chunks` (40, roughly the two-hour cap) the
|
||||||
|
transcript *is* cut, and the summary says so in the note.
|
||||||
|
|
||||||
|
Two degradations are deliberate and both are reported rather than hidden:
|
||||||
|
|
||||||
|
- No llama-server ⇒ transcript, no summary. The words exist.
|
||||||
|
- The reduce call fails ⇒ the per-chunk summaries are returned joined. Real work, not thrown
|
||||||
|
away over the last call.
|
||||||
|
|
||||||
|
The map and reduce prompts contain no first person at all, so the persona's feminine-form rules
|
||||||
|
have nothing to get wrong in them; the reply she actually gives him is phrased by the ordinary
|
||||||
|
replier, which does carry the persona.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
"media": { "dir": "media", "retention": "168h" },
|
||||||
|
"capture": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_minutes": 120,
|
||||||
|
"stt_window": "5m",
|
||||||
|
"chunk_runes": 3000,
|
||||||
|
"max_chunks": 40,
|
||||||
|
"save_transcript": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both absent by default. `capture` alone does nothing without `media`.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- **`cmd/mavheard`** — the workpc-side microphone agent. Deferred, not refused: the core half
|
||||||
|
is the part with the invariants in it, and a mic client is straightforward once there is a
|
||||||
|
stable wire to stream at. It should be an explicit-start process, not a resident one, for the
|
||||||
|
same reason the recorder has no keyword trigger. The four IPC methods are the wire it will
|
||||||
|
use; `mavenclient` already has the mic plumbing to borrow.
|
||||||
|
- **Router intent.** "запиши встречу" / "хватит" does not route anywhere yet. It needs the
|
||||||
|
`system` intent plus slots, and it needs care: "хватит" is also how someone tells her to stop
|
||||||
|
talking, so the recorder's stop and the speech barge-in must not collide.
|
||||||
|
- **A `/dash` panel** showing a running session, so a recording is visible on a surface and not
|
||||||
|
only in a log line.
|
||||||
|
- **Speaker attribution** — who said what — is #255 and is blocked on a model; see
|
||||||
|
`docs/plans/10-speaker-recognition.md`.
|
||||||
|
|||||||
@@ -27,3 +27,36 @@
|
|||||||
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
||||||
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
||||||
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status (2026-08-01) — partially shipped, deliberately narrowed
|
||||||
|
|
||||||
|
Shipped on `overnight/behavior-profile`:
|
||||||
|
|
||||||
|
- `internal/memory/behavior.go` — `BuildProfile` counts habits per weekday out of
|
||||||
|
self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and
|
||||||
|
`FormatWeekdayRU` / `FormatOverallRU` for the spoken answer.
|
||||||
|
- `internal/router/habit.go` — `ParseHabitQuery`, which requires a habit marker
|
||||||
|
("обычно", "каждую", "привычки", …) and parses the weekday deterministically.
|
||||||
|
- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю
|
||||||
|
по вторникам?" is answered.
|
||||||
|
|
||||||
|
**Not shipped, and not to be shipped as written:**
|
||||||
|
|
||||||
|
- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not
|
||||||
|
generated. A 1.7B asked to summarise a year of habits produces fluent claims
|
||||||
|
about the owner's life that no row supports, and a wrong claim about him is the
|
||||||
|
most expensive kind of wrong maven can be. Counting is verifiable and cheap.
|
||||||
|
- *Step 5, incremental updates on fact write.* There is no cache to keep fresh —
|
||||||
|
the profile is recomputed on the question, so a new fact is already in the next
|
||||||
|
answer. A cached profile that can disagree with its own rows is two truths.
|
||||||
|
- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag,
|
||||||
|
and a nudge at 08:00 every day proposing the day is the definition of one. The
|
||||||
|
sanctioned path from "she noticed a pattern" to "she acts on it" already exists:
|
||||||
|
`internal/pattern/detector.go` proposes a routine, and the owner accepts it on
|
||||||
|
`/routines`. It goes through him.
|
||||||
|
|
||||||
|
Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7).
|
||||||
|
The counted profile needs no new IPC method to be *asked* about — the query source
|
||||||
|
reads `RecentFacts` over the existing surface — so this is a display concern only.
|
||||||
|
|||||||
@@ -1,27 +1,125 @@
|
|||||||
# Plan: Speaker Recognition
|
# Plan: Speaker Recognition
|
||||||
|
|
||||||
**Goal:** Maven can distinguish between different speakers on the voice channel — recognize known voices (the user, family members) and tag facts/notes/transcripts with a speaker identity.
|
**Goal:** Maven can tell who is speaking on the voice channel, and tag what she writes with
|
||||||
|
who said it.
|
||||||
|
|
||||||
**Done when:**
|
**Status (2026-08-01, Vikunja #255):** the enrolment half is shipped. The recognising half is
|
||||||
- Speaker embedding extractor (e.g., ECAPA-TDNN or a simple MFCC + GMM) runs on incoming voice PCM before STT
|
**BLOCKED on a model download** — there is no speaker-embedding model on this box, and one
|
||||||
- Embedding is compared against enrolled speaker profiles (stored as vectors in the `memory_vectors` table alongside semantic memory)
|
was not invented to fill the gap. See "Blocked, and on what" below.
|
||||||
- Unknown speakers are enrolled on first interaction (prompt: "кто это?")
|
|
||||||
- All voice fact/note writes are tagged with `speaker:<id>` in the value/source metadata
|
|
||||||
- Speaker identity is available as context to the router, phraser, and replier ("ok, <name>")
|
|
||||||
|
|
||||||
**Scope:**
|
## What shipped
|
||||||
- New `internal/speaker/` package — enrollment, recognition, embedding extraction
|
|
||||||
- Reuses `internal/store.MemoryStore` for speaker vector storage (same `memory_vectors` table, different `source` prefix)
|
|
||||||
- Reuses `internal/audio` for PCM preprocessing
|
|
||||||
- Integration point: `cmd/mavend/voice.go:HandlePushToTalk` — speaker ID extracted before STT, passed through context
|
|
||||||
|
|
||||||
**Steps:**
|
| Piece | Where | State |
|
||||||
1. Research speaker embedding approaches — simplest floor: MFCC + cosine similarity via `github.com/mjibson/go-dsp` or a pre-trained ONNX model (SpeechBrain ECAPA)
|
|---|---|---|
|
||||||
2. Create `internal/speaker/recognizer.go` — `Recognizer` interface: `Identify(pcm []float32) (SpeakerID, confidence)`, `Enroll(id, pcm)`
|
| `Recognizer` — identify, list, get, forget | `internal/speaker/recognizer.go` | done; `Identify` answers `ErrDisabled` until a model exists |
|
||||||
3. Create `internal/speaker/store.go` — speaker profile CRUD via `store.MemoryStore`: `Insert("speaker:<id>", embedding, meta)`, `Search(embedding, k)`
|
| Enrolment — several samples, averaged, re-normalised | `internal/speaker/enroll.go` | done |
|
||||||
4. Create `internal/speaker/enroll.go` — enrollment flow: capture N seconds of audio, extract embedding, prompt for name via TTS + STT round-trip
|
| Profile shape, id validation, cosine similarity | `internal/speaker/speaker.go` | done |
|
||||||
5. Wire into `cmd/mavend/voice.go:HandlePushToTalk` — run speaker ID on the PCM before STT; pass speaker ID through `context.Context` to `applyAction`
|
| Profile storage as `speaker:<id>` vectors | `internal/memory` `Catalog` + `internal/store/memory.go` | done, no schema migration |
|
||||||
6. Tag all voice-written facts/notes with speaker ID — `Source` becomes `tap:voice:speaker:<id>` or metadata field
|
| Config block, off by default | `internal/config` `SpeakerConfig` | done |
|
||||||
7. Add IPC methods `MethodEnrollSpeaker`, `MethodListSpeakers`, `MethodRemoveSpeaker`
|
| `enroll_speaker` / `list_speakers` / `forget_speaker` | `internal/ipc` | done, absent unless configured |
|
||||||
8. Add speaker config block to `voice` in `config.Config` — `{speaker_recognition: true, model_path}`
|
| Authority rows | `internal/auth/policy.go` | done — enrol step-up, forget write, list read |
|
||||||
9. Test with 2+ recorded voice samples — verify correct identification and rejection of unknown speakers
|
| Daemon wiring + honest startup log | `cmd/mavend/speaker.go` | done |
|
||||||
|
| Embedding backend | `newSpeakerEmbedder` | **BLOCKED** — returns nil, seam only |
|
||||||
|
| Tagging voice writes with the speaker | `cmd/mavend/voice.go` | not wired; nothing to tag with yet |
|
||||||
|
|
||||||
|
## Blocked, and on what
|
||||||
|
|
||||||
|
A voiceprint needs a speaker-embedding model. The box was searched: `/mnt/hdd1/llms` holds
|
||||||
|
sixteen ggufs across seven families and every one of them is a text model. There is no ECAPA,
|
||||||
|
no x-vector, no titanet, no wespeaker, and no `.onnx` under `/mnt/hdd1` at all. There are also
|
||||||
|
no enrolment samples, because nothing has ever recorded any.
|
||||||
|
|
||||||
|
To unblock, two things are needed and neither can be done from inside the repo:
|
||||||
|
|
||||||
|
1. **A model.** SpeechBrain ECAPA-TDNN exported to ONNX (`speechbrain/spkrec-ecapa-voxceleb`,
|
||||||
|
192-dim) is the usual choice and runs on CPU in well under a second for a few seconds of
|
||||||
|
audio. Download it per the recipe in `AGENTS.md`, put it beside the other models so the
|
||||||
|
bind mount picks it up, and point `speaker.model_path` at it.
|
||||||
|
2. **An implementation of one function.** `newSpeakerEmbedder` in `cmd/mavend/speaker.go` is
|
||||||
|
the entire seam: give it an ONNX session that turns `audio.Audio` into a `[]float32` and
|
||||||
|
`Identify` starts working. Nothing else changes — not the store, not the protocol, not the
|
||||||
|
authority table, not the handlers. `internal/onnx` already loads the e5 embedder, so the
|
||||||
|
runtime wiring exists to copy.
|
||||||
|
3. **Enrolment samples**, three or more per person, recorded deliberately.
|
||||||
|
|
||||||
|
### Why there is no fallback
|
||||||
|
|
||||||
|
The original plan offered "a simple MFCC + GMM" as the floor. That is refused. MFCC cosine
|
||||||
|
distance is a channel and loudness detector as much as a voice detector: it will happily match
|
||||||
|
two different people who sit at the same distance from the same microphone, and it drifts when
|
||||||
|
the room changes. A general classifier that is sometimes wrong is a nuisance; a **biometric**
|
||||||
|
that is confidently wrong writes false claims about named people into his memory, and then
|
||||||
|
those claims get recalled as fact. For this capability a bad floor is worse than none, so the
|
||||||
|
shipped state is honest absence: `speaker.Disabled`, `ErrDisabled`, and a startup line saying
|
||||||
|
so.
|
||||||
|
|
||||||
|
## The refusals, and why
|
||||||
|
|
||||||
|
- **Unknown speakers are NOT enrolled on first interaction.** The plan's fourth "done when"
|
||||||
|
bullet asked for exactly that, with a TTS "кто это?" prompt. It is refused in
|
||||||
|
`enroll.go`'s doc comment and there is no request shape in the protocol that could express
|
||||||
|
it. Enrolling a voice is taking a biometric of a person; doing it automatically to whoever
|
||||||
|
walks past the microphone does it to guests who are not party to the exchange, and a
|
||||||
|
synthesised question into a room is not consent from whoever happens to answer. Enrolment is
|
||||||
|
an explicit act: an id, a name, and samples recorded for the purpose.
|
||||||
|
- **One sample is not enough.** Three separate utterances and nine seconds minimum. A profile
|
||||||
|
built from one sentence encodes that sentence as much as the person, and the threshold then
|
||||||
|
behaves unpredictably against everything else.
|
||||||
|
- **An unknown voice stays unknown.** Below threshold, `Identify` returns `ErrUnknown` naming
|
||||||
|
the closest profile in the error text for diagnosis, never as an answer. Guessing who is in
|
||||||
|
the room is how false memories about people get written.
|
||||||
|
- **Deletion is one authority rung below enrolment.** Everywhere else in `policy.go` the
|
||||||
|
destructive direction is gated at least as hard as the constructive one. Here that would be
|
||||||
|
backwards: getting rid of a biometric must never be the harder half.
|
||||||
|
- **The voiceprint never crosses the socket.** `ListSpeakersResp` carries ids, names, dates
|
||||||
|
and sample counts. The vector stays in core.
|
||||||
|
- **Off unless configured.** No `speaker` block ⇒ the three methods answer
|
||||||
|
`ErrUnknownMethod`. There is no wire path on a default box that takes a voiceprint.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Profiles live in the existing `memory_vectors` table under the `speaker:` id prefix, as the
|
||||||
|
plan intended, so there is no migration. What that needed was a wider interface than
|
||||||
|
`memory.Store`: `memory.Catalog` adds `ByPrefix` and `Delete`. `Delete` is the load-bearing
|
||||||
|
one — a voiceprint someone asked to be rid of has to actually go, and a search-only store
|
||||||
|
cannot do that. `InMemoryStore.Insert` also became an upsert by id, matching what the
|
||||||
|
persistent store already did, so re-enrolling replaces a profile instead of stacking a second
|
||||||
|
one behind the first.
|
||||||
|
|
||||||
|
Profiles do not collide with note or fact vectors: they are only ever read through
|
||||||
|
`ByPrefix("speaker:")`, and a note search never returns one because the prefix is not in its
|
||||||
|
query path.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
"speaker": {
|
||||||
|
"enabled": true,
|
||||||
|
"model_path": "/opt/maven/models/spk/ecapa-voxceleb.onnx",
|
||||||
|
"lib_path": "/opt/maven/lib",
|
||||||
|
"threshold": 0.7,
|
||||||
|
"min_seconds": 2.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off
|
||||||
|
rather than as a capability that fails every turn. With `enabled` and no model the daemon still
|
||||||
|
attaches the three methods — profiles can be created, listed and deleted — and logs that
|
||||||
|
recognition is blocked.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- The embedding backend (above). Everything below waits on it.
|
||||||
|
- **Tagging voice writes.** `Profile.Source("tap:voice")` already produces
|
||||||
|
`tap:voice:speaker:kami`, which is the shape step 6 asked for, but nothing calls it yet:
|
||||||
|
with no recogniser there is no id to tag with. When the model lands, the hook is in the
|
||||||
|
voice path before STT.
|
||||||
|
- **Speaker as router/phraser context.** Same dependency. Note the persona constraint when it
|
||||||
|
arrives: Maven addresses the owner informally and speaks to him, so "ok, <name>" needs care
|
||||||
|
for anyone who is not him.
|
||||||
|
- **An enrolment surface.** The three IPC methods exist; no page drives them. Enrolment is
|
||||||
|
step-up, so it belongs on `/dash` behind a passkey, with a per-profile forget button next to
|
||||||
|
each row — that button is the reason `list_speakers` exists.
|
||||||
|
- **A speaker column on the meeting recorder** (#253). Attributing lines in a transcript is
|
||||||
|
the obvious pairing, and it is the place where getting attribution wrong is most damaging,
|
||||||
|
so it waits for a real model too.
|
||||||
|
|||||||
@@ -27,3 +27,28 @@
|
|||||||
7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery`
|
7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery`
|
||||||
8. Add `feeds` block to `config.Config` and `deploy/mavend.json`
|
8. Add `feeds` block to `config.Config` and `deploy/mavend.json`
|
||||||
9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table
|
9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped 2026-08-01 (#258)
|
||||||
|
|
||||||
|
`internal/webfetch` (the guarded HTTP door: scheme, allow/deny hosts, private-address
|
||||||
|
refusal in the dialer, size cap, redirect cap, per-host rate limit), `internal/rss`
|
||||||
|
(RSS 2.0 + Atom parser, poller with durable marks and a keyword filter),
|
||||||
|
`cmd/mavend/feeds.go` (ticker, fetcher adapter, `rss:latest:<feed>` fact marks),
|
||||||
|
config block `feeds`, and the `feeds` query source with `router.ParseFeedQuery`.
|
||||||
|
|
||||||
|
Deviations from the plan above, both deliberate:
|
||||||
|
|
||||||
|
- **Step 5 (breaking-news nudges) was not built.** A feed that dispatches is a nag,
|
||||||
|
and the one thing Maven is not is a nag. Items are read when asked and nowhere else.
|
||||||
|
If breaking news is ever wanted, it belongs behind the existing delivery policy
|
||||||
|
(severity, quiet hours, digest), not in the poller.
|
||||||
|
- **Step 3 (embedder relevance) is a seam, not an implementation.** `rss.Ranker`
|
||||||
|
exists and is wired nil. Scoring items against an "interest profile" needs a
|
||||||
|
profile, and there is none yet; a threshold with nothing to compare against is a
|
||||||
|
random filter with a confident name. The filter that runs is the per-feed
|
||||||
|
include/exclude keyword list, which he can read and predict.
|
||||||
|
|
||||||
|
No new dependency: stdlib `encoding/xml`, no gofeed. Stock deploy config has no
|
||||||
|
`feeds` block, so the capability is off.
|
||||||
|
|||||||
@@ -28,3 +28,41 @@
|
|||||||
7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)`
|
7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)`
|
||||||
8. Add `crawls` block to `config.Config` and `deploy/mavend.json`
|
8. Add `crawls` block to `config.Config` and `deploy/mavend.json`
|
||||||
9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly
|
9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly
|
||||||
|
|
||||||
|
## Shipped 2026-08-01 (#259)
|
||||||
|
|
||||||
|
Built as `internal/crawl` (pure: robots, extraction, watcher) plus
|
||||||
|
`cmd/mavend/crawls.go` (fetcher, ticker, dedup facts), on top of the guarded
|
||||||
|
`internal/webfetch` door added with the feed reader (#258). Off unless
|
||||||
|
configured, in two separately-switched halves: `crawl.on_demand` for a URL he
|
||||||
|
names, `crawl.watches` for a scheduled re-read.
|
||||||
|
|
||||||
|
**Limits are code, not documentation** (`internal/webfetch`, tested one test per
|
||||||
|
limit): host allowlist/denylist, no private addresses (loopback, RFC1918 —
|
||||||
|
hence the LAN and the `10.42.0.0/24` wg range —, link-local incl. cloud
|
||||||
|
metadata, CGNAT, v6 ULA) enforced in the dialer's `Control` hook so DNS
|
||||||
|
rebinding and every redirect hop are covered, response size cap, redirect cap,
|
||||||
|
timeout, one request per host per second. `robots.txt` is fetched first, cached
|
||||||
|
per host, and a `Disallow` is refused with no override.
|
||||||
|
|
||||||
|
Deliberate deviations from the plan above:
|
||||||
|
|
||||||
|
- **No CSS selectors and no LLM structured extraction** (steps 2). The output is
|
||||||
|
plaintext handed to the phraser as context for the question he asked. A 1.7B
|
||||||
|
extracting a JSON price table from 4000 runes is a worse bet than reading, and
|
||||||
|
`goquery` is not vendored.
|
||||||
|
- **No `crawl` act verb and no new IPC methods** (steps 5, 7). Reading a page is
|
||||||
|
a query source (`queryWeb` in `actions_query.go`, last in the chain, behind
|
||||||
|
Kiwix once that is wired), not an action he commands. Nothing needs a new wire
|
||||||
|
method to work.
|
||||||
|
- **Notes, not facts.** A page's text is not a fact about him. Only the dedup
|
||||||
|
hash is a fact (`crawl:hash:<name>`, kind `config`, source `poll:crawl`).
|
||||||
|
- **Nothing is dispatched.** A changed page writes a note; it does not nudge.
|
||||||
|
Not a nag.
|
||||||
|
- **No `/tools` crawl history page.** The notes and the hash facts are already
|
||||||
|
visible on `/dash`.
|
||||||
|
|
||||||
|
**No new dependency.** The vendored tree has no `x/net/html`, no `goquery` and
|
||||||
|
no `temoto/robotstxt`, so robots parsing and HTML-to-text are stdlib
|
||||||
|
(`regexp`, `html`) — RE2 has no backreferences, hence the `pairsRE` builder in
|
||||||
|
`extract.go`.
|
||||||
|
|||||||
@@ -393,3 +393,82 @@ func mustWriteFactParams(source string) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRequirement_SwapModel — loading a different resident model is an owner
|
||||||
|
// action at the same rung as mutating the tool allowlist: it decides how every
|
||||||
|
// utterance is routed and how every reply is worded. The read side is not.
|
||||||
|
func TestRequirement_SwapModel(t *testing.T) {
|
||||||
|
if got := Requirement(ipc.MethodSwapModel); got != AuthStepUp {
|
||||||
|
t.Errorf("SwapModel authority = %v; want AuthStepUp", got)
|
||||||
|
}
|
||||||
|
if got := Requirement(ipc.MethodModelStatus); got != AuthRead {
|
||||||
|
t.Errorf("ModelStatus authority = %v; want AuthRead", got)
|
||||||
|
}
|
||||||
|
// A surface that cannot carry a passkey gesture cannot swap the model, no
|
||||||
|
// matter what it is enrolled as — this is the "never through voice" property.
|
||||||
|
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||||
|
if err := Can(ipc.MethodSwapModel, voice, nil); !errors.Is(err, ErrForbidden) {
|
||||||
|
t.Errorf("voice swapping the model = %v; want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
// And with no step-up session asserted, the gate refuses even a capable surface.
|
||||||
|
noSession := &Gate{Enrollment: NewFloorEnrollment()}
|
||||||
|
if err := noSession.Check(context.Background(), ipc.MethodSwapModel, nil); !errors.Is(err, ipc.ErrForbidden) {
|
||||||
|
t.Errorf("SwapModel with no asserted step-up = %v; want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequirement_Capture — recording other people is a write, not a read: it
|
||||||
|
// puts audio of them on disk. The read side, "что ты записываешь?", is not.
|
||||||
|
//
|
||||||
|
// It is deliberately NOT AuthStepUp. Step-up needs a passkey gesture, which the
|
||||||
|
// voice path cannot make, so putting it there would mean "запиши встречу" could
|
||||||
|
// never work by voice. The real gate on this capability is that the methods do
|
||||||
|
// not exist at all unless the operator enabled a capture block.
|
||||||
|
func TestRequirement_Capture(t *testing.T) {
|
||||||
|
for _, m := range []ipc.Method{
|
||||||
|
ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop,
|
||||||
|
} {
|
||||||
|
if got := Requirement(m); got != AuthWrite {
|
||||||
|
t.Errorf("%s authority = %v; want AuthWrite", m, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := Requirement(ipc.MethodCaptureStatus); got != AuthRead {
|
||||||
|
t.Errorf("CaptureStatus authority = %v; want AuthRead", got)
|
||||||
|
}
|
||||||
|
// Voice can start one: it is the surface he will actually use to say
|
||||||
|
// "запиши встречу", and it carries AuthWrite.
|
||||||
|
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||||
|
if err := Can(ipc.MethodCaptureStart, voice, nil); err != nil {
|
||||||
|
t.Errorf("voice starting a capture = %v; want allowed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequirement_Speaker — a voiceprint is a biometric of a named person, so
|
||||||
|
// taking one is step-up: a deliberate act from a surface that can carry a
|
||||||
|
// passkey gesture, never something the voice path does mid-conversation.
|
||||||
|
//
|
||||||
|
// Deletion is one rung lower, and that asymmetry is the point. Everywhere else
|
||||||
|
// in the table the destructive direction is gated at least as hard as the
|
||||||
|
// constructive one; for a biometric that would be backwards, because getting
|
||||||
|
// rid of it must never be the harder half.
|
||||||
|
func TestRequirement_Speaker(t *testing.T) {
|
||||||
|
if got := Requirement(ipc.MethodEnrollSpeaker); got != AuthStepUp {
|
||||||
|
t.Errorf("EnrollSpeaker authority = %v; want AuthStepUp", got)
|
||||||
|
}
|
||||||
|
if got := Requirement(ipc.MethodForgetSpeaker); got != AuthWrite {
|
||||||
|
t.Errorf("ForgetSpeaker authority = %v; want AuthWrite", got)
|
||||||
|
}
|
||||||
|
if got := Requirement(ipc.MethodListSpeakers); got != AuthRead {
|
||||||
|
t.Errorf("ListSpeakers authority = %v; want AuthRead", got)
|
||||||
|
}
|
||||||
|
// Voice cannot enrol anybody, however the utterance is phrased.
|
||||||
|
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||||
|
if err := Can(ipc.MethodEnrollSpeaker, voice, nil); err == nil {
|
||||||
|
t.Error("voice enrolling a speaker was allowed; want refused")
|
||||||
|
}
|
||||||
|
// But it can read the roster, which is what answering "кого ты знаешь?"
|
||||||
|
// needs.
|
||||||
|
if err := Can(ipc.MethodListSpeakers, voice, nil); err != nil {
|
||||||
|
t.Errorf("voice listing speakers = %v; want allowed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+77
-1
@@ -53,6 +53,44 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
// asserted — never a module or the voice/chat path. maven can propose
|
// asserted — never a module or the voice/chat path. maven can propose
|
||||||
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
||||||
return AuthStepUp
|
return AuthStepUp
|
||||||
|
case ipc.MethodSwapModel:
|
||||||
|
// Swapping the resident model changes what routes every utterance and
|
||||||
|
// what words every reply. It is the owner's call, from a surface that can
|
||||||
|
// carry a passkey gesture — the same rung as mutating the tool allowlist,
|
||||||
|
// and for the same reason: nothing Maven says or does may reach it.
|
||||||
|
// MethodModelStatus is only the read side, so it stays at AuthRead.
|
||||||
|
return AuthStepUp
|
||||||
|
case ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop:
|
||||||
|
// Recording a meeting (Vikunja #253). AuthWrite, not AuthRead: it puts
|
||||||
|
// audio of other people on disk, which is a heavier thing than reading a
|
||||||
|
// fact, and it is not something a read-only surface should be able to
|
||||||
|
// begin. Append and Stop sit on the same rung as Start deliberately —
|
||||||
|
// a surface that may not start a recording has no business feeding or
|
||||||
|
// harvesting one either.
|
||||||
|
//
|
||||||
|
// Not AuthStepUp, and this is the interesting line: step-up needs a
|
||||||
|
// passkey gesture, which the voice path cannot make. Putting it here
|
||||||
|
// would mean "запиши встречу" could never work by voice, and the real
|
||||||
|
// gate on this capability is elsewhere and stronger — the methods do not
|
||||||
|
// exist at all unless the operator enabled a capture block, and no
|
||||||
|
// recording can begin without someone saying so.
|
||||||
|
return AuthWrite
|
||||||
|
case ipc.MethodEnrollSpeaker:
|
||||||
|
// Taking a voiceprint (Vikunja #255). AuthStepUp, and unlike recording a
|
||||||
|
// meeting there is no reason to soften it: enrolment is not a thing anyone
|
||||||
|
// does by voice mid-conversation. It is a deliberate sit-down with a
|
||||||
|
// surface that can carry a passkey gesture, and it writes a biometric of a
|
||||||
|
// named person. If the gesture is inconvenient, that is the correct amount
|
||||||
|
// of friction for this particular write.
|
||||||
|
return AuthStepUp
|
||||||
|
case ipc.MethodForgetSpeaker:
|
||||||
|
// Deleting a voiceprint. One rung BELOW enrolment on purpose. Everywhere
|
||||||
|
// else in this table the destructive direction is gated at least as hard
|
||||||
|
// as the constructive one, and here that would be wrong: getting rid of a
|
||||||
|
// biometric must never be the harder half. The worst a caller at this rung
|
||||||
|
// can do is make Maven stop recognising someone, which is the state the
|
||||||
|
// box ships in anyway.
|
||||||
|
return AuthWrite
|
||||||
case ipc.MethodWriteFact:
|
case ipc.MethodWriteFact:
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
case ipc.MethodAssertStepUp:
|
case ipc.MethodAssertStepUp:
|
||||||
@@ -65,7 +103,45 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
ipc.MethodCreateReminder,
|
ipc.MethodCreateReminder,
|
||||||
ipc.MethodMarkReminder,
|
ipc.MethodMarkReminder,
|
||||||
ipc.MethodRecordNudge,
|
ipc.MethodRecordNudge,
|
||||||
ipc.MethodResolveNudge:
|
ipc.MethodResolveNudge,
|
||||||
|
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
||||||
|
// the default so the intent is on the record: capturing a task is a
|
||||||
|
// module write, not an allowlist mutation and not a new standing reason
|
||||||
|
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
||||||
|
// at AuthRead, the same rung as CreateReminder, which is the closest
|
||||||
|
// existing analogue.
|
||||||
|
ipc.MethodCaptureTask,
|
||||||
|
ipc.MethodListTasks,
|
||||||
|
ipc.MethodSetTaskStatus,
|
||||||
|
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
|
||||||
|
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
||||||
|
// reminder, or touch the tool allowlist, so a compromised mail reader can
|
||||||
|
// at worst put junk on a review page he clears in one click.
|
||||||
|
ipc.MethodIngestMail,
|
||||||
|
// Looking at one image (Vikunja #252). AuthRead because of what it can
|
||||||
|
// produce: words about a picture, and optionally a note. It cannot write
|
||||||
|
// a fact, set a reminder, or touch the tool allowlist. The invasive part
|
||||||
|
// of this capability is not the authority rung — it is that the bytes are
|
||||||
|
// kept on disk, which media.retention bounds, and that they never leave
|
||||||
|
// the box, which internal/vision enforces by refusing a non-private
|
||||||
|
// endpoint.
|
||||||
|
ipc.MethodDescribeImage,
|
||||||
|
// "что ты записываешь?" — the read side of the recorder. It reports a
|
||||||
|
// label, a start time and a byte count, begins nothing and keeps nothing.
|
||||||
|
ipc.MethodCaptureStatus,
|
||||||
|
// Who is enrolled. Returns ids, names and enrolment dates — never the
|
||||||
|
// voiceprints themselves, which stay in core. Listing the people Maven can
|
||||||
|
// recognise is exactly the read a surface needs to offer a "forget" button.
|
||||||
|
ipc.MethodListSpeakers,
|
||||||
|
// The read side of the model swap: which model is resident, which ones are
|
||||||
|
// allowlisted. It loads nothing and changes nothing.
|
||||||
|
ipc.MethodModelStatus,
|
||||||
|
// The unified intake journal (Vikunja #283). AuthRead, and listed
|
||||||
|
// explicitly rather than inherited so the reasoning is on the record: it
|
||||||
|
// reports what already arrived — sources, keys, note headlines — which is
|
||||||
|
// the same material RecentFacts and RecentNotes already return at this
|
||||||
|
// rung. It writes nothing, and it holds nothing a fact read does not.
|
||||||
|
ipc.MethodRecentEvents:
|
||||||
return AuthRead
|
return AuthRead
|
||||||
}
|
}
|
||||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ambient events — the work calendar read (Vikunja #126).
|
||||||
|
//
|
||||||
|
// The work calendar is not read by holding a work credential. A corp mail or
|
||||||
|
// calendar session living on the homelab ties the box's blast radius to the
|
||||||
|
// employer's data, which is the thing the task exists to refuse. What maven
|
||||||
|
// reads instead is the SIGNAL: an Android notification-listener on the owner's
|
||||||
|
// phone relays meeting notifications over wg/LAN, and maven turns the ones that
|
||||||
|
// clearly describe a meeting into calendar events.
|
||||||
|
//
|
||||||
|
// That makes the provenance honest. A notification is evidence about an event,
|
||||||
|
// not a reading of the calendar, so it is stored under SourceAmbient at
|
||||||
|
// AmbientConfidence — never indistinguishable from a real CalDAV read, and the
|
||||||
|
// query path hedges when it recites one.
|
||||||
|
//
|
||||||
|
// The parse is deliberately conservative. A notification with no recognisable
|
||||||
|
// clock reading produces nothing at all: maven is not a guesser-of-truth, and a
|
||||||
|
// mailbox full of noise turned into invented events is worse than a gap. Mail
|
||||||
|
// as a notification signal, not a mailbox.
|
||||||
|
|
||||||
|
// Notification — one relayed Android notification. Package is the posting app
|
||||||
|
// (for the log and for the owner to see where a wrong event came from), Title
|
||||||
|
// and Text are the notification's two text lines, Posted is when the phone
|
||||||
|
// showed it. Nothing else off the notification is kept.
|
||||||
|
type Notification struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Posted time.Time `json:"posted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventFromNotification turns a notification into the event it describes, or
|
||||||
|
// reports false when it does not clearly describe one.
|
||||||
|
//
|
||||||
|
// It needs two things: a clock reading, and a summary that is not just that
|
||||||
|
// clock reading. Everything else is defaulted — the date is Posted's day (a
|
||||||
|
// meeting notification is about today or it would not be firing now), and a
|
||||||
|
// bare start time gets DefaultReminderDuration.
|
||||||
|
func EventFromNotification(n Notification) (Event, bool) {
|
||||||
|
if n.Posted.IsZero() {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||||
|
start, end, ok := parseTimeRange(line)
|
||||||
|
if !ok {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
summary := notificationSummary(n)
|
||||||
|
if summary == "" {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
y, m, d := n.Posted.Date()
|
||||||
|
loc := n.Posted.Location()
|
||||||
|
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||||||
|
var e time.Time
|
||||||
|
if end != nil {
|
||||||
|
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
|
||||||
|
// A range that ends before it starts crossed midnight.
|
||||||
|
if !e.After(s) {
|
||||||
|
e = e.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e = s.Add(DefaultReminderDuration)
|
||||||
|
}
|
||||||
|
return Event{Summary: summary, Start: s, End: e}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificationSummary picks the text that names the meeting: the title when it
|
||||||
|
// carries words, otherwise the body. The clock reading is stripped out — it
|
||||||
|
// already lives in the times, and FactValue renders it again.
|
||||||
|
func notificationSummary(n Notification) string {
|
||||||
|
for _, cand := range []string{n.Title, n.Text} {
|
||||||
|
s := strings.TrimSpace(stripClock(cand))
|
||||||
|
s = strings.Trim(s, " \t-–—,;:@|·")
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
if hasLetters(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type clock struct{ hour, min int }
|
||||||
|
|
||||||
|
// parseTimeRange finds the first clock reading in s, and a second one if the
|
||||||
|
// text spells a range. Accepted separators between hours and minutes are ":"
|
||||||
|
// and "."; between the two ends of a range, "-", "–", "—" or "до".
|
||||||
|
//
|
||||||
|
// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far
|
||||||
|
// more often a count, a date or an unread badge than a meeting time, and an
|
||||||
|
// invented event is worse than no event.
|
||||||
|
func parseTimeRange(s string) (start clock, end *clock, ok bool) {
|
||||||
|
first, _, firstEnd, ok := nextClock(s, 0)
|
||||||
|
if !ok {
|
||||||
|
return clock{}, nil, false
|
||||||
|
}
|
||||||
|
sep := strings.TrimLeft(s[firstEnd:], " \t")
|
||||||
|
for _, p := range []string{"-", "–", "—", "до "} {
|
||||||
|
if !strings.HasPrefix(sep, p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 {
|
||||||
|
return first, &second, true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return first, nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and
|
||||||
|
// returns it with the byte range it occupied. Digits and separators are ASCII,
|
||||||
|
// so byte offsets are safe over Cyrillic text.
|
||||||
|
func nextClock(s string, from int) (c clock, start, end int, ok bool) {
|
||||||
|
for i := from; i < len(s); i++ {
|
||||||
|
if !isDigit(s[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isDigit(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
// A run longer than two digits is a year, an id or an unread count.
|
||||||
|
if j-i > 2 {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if j >= len(s) || (s[j] != ':' && s[j] != '.') {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := j + 1
|
||||||
|
for k < len(s) && isDigit(s[k]) {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
if k-(j+1) != 2 {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Reject a group that is a link in a longer dotted or colon chain:
|
||||||
|
// "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline
|
||||||
|
// date invented as a meeting time is exactly the wrong kind of guess.
|
||||||
|
// A trailing ":ss" is fine — that is a time with seconds.
|
||||||
|
if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) {
|
||||||
|
i = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) {
|
||||||
|
i = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hour, min := atoi(s[i:j]), atoi(s[j+1:k])
|
||||||
|
if hour > 23 || min > 59 {
|
||||||
|
i = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return clock{hour, min}, i, k, true
|
||||||
|
}
|
||||||
|
return clock{}, 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||||
|
|
||||||
|
func atoi(s string) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
n = n*10 + int(s[i]-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripClock removes every clock reading from a summary candidate, along with
|
||||||
|
// the preposition or separator that introduced it.
|
||||||
|
func stripClock(s string) string {
|
||||||
|
for {
|
||||||
|
_, start, end, ok := nextClock(s, 0)
|
||||||
|
if !ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t"))
|
||||||
|
s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimTrailingPreposition drops the word that introduced a clock reading, so
|
||||||
|
// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not
|
||||||
|
// keep a dangling "с". It repeats, because a range has two of them.
|
||||||
|
func trimTrailingPreposition(s string) string {
|
||||||
|
preps := []string{"в", "с", "до", "от", "at", "from", "to"}
|
||||||
|
for again := true; again; {
|
||||||
|
again = false
|
||||||
|
s = strings.TrimRight(s, " \t")
|
||||||
|
for _, p := range preps {
|
||||||
|
if s == p {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(s, " "+p) {
|
||||||
|
s = s[:len(s)-len(p)-1]
|
||||||
|
again = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLetters(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventFromNotification(t *testing.T) {
|
||||||
|
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
title, text string
|
||||||
|
wantOK bool
|
||||||
|
wantSummary string
|
||||||
|
wantStart string // "15:04"
|
||||||
|
wantEnd string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "range in the body",
|
||||||
|
title: "Планёрка",
|
||||||
|
text: "10:00-10:30",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Планёрка",
|
||||||
|
wantStart: "10:00", wantEnd: "10:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian preposition and single time",
|
||||||
|
title: "Встреча с подрядчиком в 14:00",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Встреча с подрядчиком",
|
||||||
|
wantStart: "14:00", wantEnd: "14:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "en dash range",
|
||||||
|
title: "Sprint review",
|
||||||
|
text: "Today 16:00 – 17:00, Meet",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Sprint review",
|
||||||
|
wantStart: "16:00", wantEnd: "17:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "до as a range separator",
|
||||||
|
title: "Созвон",
|
||||||
|
text: "с 11:30 до 12:15",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Созвон",
|
||||||
|
wantStart: "11:30", wantEnd: "12:15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dotted clock",
|
||||||
|
title: "Обед 13.00",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Обед",
|
||||||
|
wantStart: "13:00", wantEnd: "13:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range crossing midnight",
|
||||||
|
title: "Ночной релиз",
|
||||||
|
text: "23:30-00:30",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Ночной релиз",
|
||||||
|
wantStart: "23:30", wantEnd: "00:30",
|
||||||
|
},
|
||||||
|
// The conservative half: no clock reading, no event.
|
||||||
|
{name: "no time at all", title: "3 новых письма", wantOK: false},
|
||||||
|
{name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false},
|
||||||
|
{name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false},
|
||||||
|
{name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false},
|
||||||
|
{name: "time but nothing named", title: "10:00-10:30", wantOK: false},
|
||||||
|
{name: "impossible clock", title: "Смена 99:99", wantOK: false},
|
||||||
|
{name: "empty", wantOK: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: tt.title,
|
||||||
|
Text: tt.text,
|
||||||
|
Posted: posted,
|
||||||
|
})
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.Summary != tt.wantSummary {
|
||||||
|
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
|
||||||
|
}
|
||||||
|
if got := ev.Start.Format("15:04"); got != tt.wantStart {
|
||||||
|
t.Errorf("start = %s, want %s", got, tt.wantStart)
|
||||||
|
}
|
||||||
|
if got := ev.End.Format("15:04"); got != tt.wantEnd {
|
||||||
|
t.Errorf("end = %s, want %s", got, tt.wantEnd)
|
||||||
|
}
|
||||||
|
if !ev.End.After(ev.Start) {
|
||||||
|
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||||
|
}
|
||||||
|
// The event lands on the day the phone showed it, in the phone's
|
||||||
|
// location — not shifted into UTC.
|
||||||
|
if ev.Start.Location() != posted.Location() {
|
||||||
|
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||||
|
}
|
||||||
|
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
||||||
|
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||||
|
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||||
|
t.Error("a notification with no posted_at has no date to sit on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ambient event must never be indistinguishable from a calendar read.
|
||||||
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Title: "Планёрка 10:00-10:30",
|
||||||
|
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an event")
|
||||||
|
}
|
||||||
|
if FactKey(ev) == "" || FactValue(ev) == "" {
|
||||||
|
t.Fatal("ambient events must use the shared fact encoding")
|
||||||
|
}
|
||||||
|
if AmbientConfidence >= 1.0 {
|
||||||
|
t.Fatal("ambient confidence must be below a calendar read's")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripClock(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"Встреча в 14:00", "Встреча"},
|
||||||
|
{"Планёрка 10:00-10:30", "Планёрка"},
|
||||||
|
{"с 11:30 до 12:15 Созвон", "Созвон"},
|
||||||
|
{"Ничего", "Ничего"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := stripClock(tt.in); got != tt.want {
|
||||||
|
t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package calendar is the one calendar data model the rest of maven shares:
|
||||||
|
// an Event, the iCal text it is parsed from and rendered to, and the fact
|
||||||
|
// encoding that puts it in the store.
|
||||||
|
//
|
||||||
|
// It exists because three separate features read or write the same events and
|
||||||
|
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
|
||||||
|
// #126/#127), the write-only render target that publishes maven's own
|
||||||
|
// reminders as a calendar (#127), and the day plan that recites them (#128).
|
||||||
|
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
|
||||||
|
// format was a Sprintf in two places.
|
||||||
|
//
|
||||||
|
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
|
||||||
|
// impurity, the way internal/morning and internal/loop do.
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fact sources. A calendar event reaches the store as a
|
||||||
|
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
|
||||||
|
// the source is the whole provenance story:
|
||||||
|
//
|
||||||
|
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
|
||||||
|
// state stays in sqlite; the calendar is a render target (#127).
|
||||||
|
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
|
||||||
|
// maven ever writes to it: no code path pairs this source with a PUT.
|
||||||
|
// - SourceAmbient — inferred from an Android notification-listener relay
|
||||||
|
// rather than read from a server (#126). Confidence is below 1.0 because a
|
||||||
|
// notification is a signal about an event, not the event.
|
||||||
|
const (
|
||||||
|
SourcePersonal = "poll:caldav"
|
||||||
|
SourceWork = "poll:caldav:work"
|
||||||
|
SourceAmbient = "ambient:notif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientConfidence — the confidence a notification-derived event is stored
|
||||||
|
// with. A parsed notification line is evidence, not a reading of the calendar,
|
||||||
|
// so it must never be indistinguishable from one (#126).
|
||||||
|
const AmbientConfidence = 0.6
|
||||||
|
|
||||||
|
// Sources lists every source a calendar event may legitimately carry, for the
|
||||||
|
// store query that reads the calendar back out. Ordered from most to least
|
||||||
|
// trusted.
|
||||||
|
func Sources() []string {
|
||||||
|
return []string{SourcePersonal, SourceWork, SourceAmbient}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOnlySource reports whether events from this source may never be written
|
||||||
|
// back. The work calendar is read-only by definition — see #126: maven holding
|
||||||
|
// a credential that can write to an employer's calendar is the thing the task
|
||||||
|
// exists to avoid.
|
||||||
|
func ReadOnlySource(source string) bool {
|
||||||
|
return source == SourceWork || source == SourceAmbient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event — one calendar entry. UID is the iCal UID when the event was parsed
|
||||||
|
// from a server and the identity maven renders under when it publishes one;
|
||||||
|
// Start/End are instants. All-day events are not modelled: the busy gate and
|
||||||
|
// the day plan both need a time of day, and an all-day marker answers neither.
|
||||||
|
type Event struct {
|
||||||
|
UID string
|
||||||
|
Summary string
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactKey is the store key for an event: one key per day per summary, stable
|
||||||
|
// across polls so re-reading an unchanged calendar rewrites nothing.
|
||||||
|
//
|
||||||
|
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
||||||
|
// by key prefix, not by a timestamp column.
|
||||||
|
func FactKey(e Event) string {
|
||||||
|
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactValue is the human-readable rendering stored as the fact value, and the
|
||||||
|
// string the day plan and the query path read back.
|
||||||
|
func FactValue(e Event) string {
|
||||||
|
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||||
|
// range-scans between two of these.
|
||||||
|
func KeyPrefixForDay(day time.Time) string {
|
||||||
|
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busy reports whether any event covers the instant now — the read the loop
|
||||||
|
// gate uses to suppress nudges during a meeting.
|
||||||
|
func Busy(events []Event, now time.Time) bool {
|
||||||
|
for _, e := range events {
|
||||||
|
if !now.Before(e.Start) && now.Before(e.End) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlapping returns the events intersecting [from, to), sorted by start.
|
||||||
|
func Overlapping(events []Event, from, to time.Time) []Event {
|
||||||
|
var out []Event
|
||||||
|
for _, e := range events {
|
||||||
|
if e.End.After(from) && e.Start.Before(to) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||||
|
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||||
|
// the day prefix carries the identity and this only disambiguates within a day.
|
||||||
|
func safeKey(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ' ' || r == '_':
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseICalDayKeepsOnlyToday(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
body := []byte(`BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:a@example
|
||||||
|
DTSTART:20260703T090000Z
|
||||||
|
DTEND:20260703T100000Z
|
||||||
|
SUMMARY:Morning standup
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260703T140000Z
|
||||||
|
DTEND:20260703T150000Z
|
||||||
|
SUMMARY:Team sync
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260702T140000Z
|
||||||
|
DTEND:20260702T150000Z
|
||||||
|
SUMMARY:Yesterday retro
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260704T090000Z
|
||||||
|
DTEND:20260704T100000Z
|
||||||
|
SUMMARY:Tomorrow standup
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART;VALUE=DATE:20260704
|
||||||
|
DTEND;VALUE=DATE:20260705
|
||||||
|
SUMMARY:All-day event
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`)
|
||||||
|
|
||||||
|
events := ParseICalDay(body, now)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Summary != "Morning standup" || events[0].UID != "a@example" {
|
||||||
|
t.Errorf("events[0] = %+v", events[0])
|
||||||
|
}
|
||||||
|
if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("events[0].Start = %v", events[0].Start)
|
||||||
|
}
|
||||||
|
if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("events[0].End = %v", events[0].End)
|
||||||
|
}
|
||||||
|
if events[1].Summary != "Team sync" {
|
||||||
|
t.Errorf("events[1].Summary = %q", events[1].Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: "today" is the owner's day, in the owner's location. Taking the
|
||||||
|
// day number off a local clock but building the boundaries in UTC made the
|
||||||
|
// evening fall outside the window on any box east of Greenwich.
|
||||||
|
func TestParseICalDayUsesOwnersDay(t *testing.T) {
|
||||||
|
plus4 := time.FixedZone("+04", 4*60*60)
|
||||||
|
// 01:00 on Aug 1 local is 21:00 on Jul 31 UTC.
|
||||||
|
now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4)
|
||||||
|
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
||||||
|
"DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" +
|
||||||
|
"END:VEVENT\nEND:VCALENDAR")
|
||||||
|
|
||||||
|
events := ParseICalDay(body, now)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("got %d events, want the in-progress one", len(events))
|
||||||
|
}
|
||||||
|
if !Busy(events, now.UTC()) {
|
||||||
|
t.Error("an event in progress right now must read as busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVEVENT(t *testing.T) {
|
||||||
|
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||||
|
e, ok := parseVEVENT(block)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a parsed event")
|
||||||
|
}
|
||||||
|
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("start = %v", e.Start)
|
||||||
|
}
|
||||||
|
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("end = %v", e.End)
|
||||||
|
}
|
||||||
|
if e.Summary != "Stand up meeting" {
|
||||||
|
t.Errorf("summary = %q", e.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||||
|
if _, ok := parseVEVENT(allDay); ok {
|
||||||
|
t.Error("all-day event should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDT(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
want time.Time
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||||
|
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
|
||||||
|
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
|
||||||
|
{"garbage", "DTSTART:garbage", time.Time{}, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := parseDT(tt.line)
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||||
|
}
|
||||||
|
if !got.Equal(tt.want) {
|
||||||
|
t.Errorf("got %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeKey(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"Stand up meeting", "Stand-up-meeting"},
|
||||||
|
{"Hello_World", "Hello-World"},
|
||||||
|
{"special@#$chars!!", "specialchars"},
|
||||||
|
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := safeKey(tt.in); got != tt.want {
|
||||||
|
t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactKeyAndValue(t *testing.T) {
|
||||||
|
e := Event{
|
||||||
|
Summary: "Team sync",
|
||||||
|
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
|
||||||
|
t.Errorf("FactKey = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
|
||||||
|
t.Errorf("FactValue = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
|
||||||
|
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
|
||||||
|
t.Error("FactKey must start with the day prefix the store range-scans on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusyAndOverlapping(t *testing.T) {
|
||||||
|
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
events := []Event{
|
||||||
|
{Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)},
|
||||||
|
{Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)},
|
||||||
|
}
|
||||||
|
if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) {
|
||||||
|
t.Error("should be busy inside the early event")
|
||||||
|
}
|
||||||
|
if Busy(events, base.Add(12*time.Hour)) {
|
||||||
|
t.Error("should be free at noon")
|
||||||
|
}
|
||||||
|
// Half-open: the end instant is free.
|
||||||
|
if Busy(events, base.Add(10*time.Hour)) {
|
||||||
|
t.Error("the end instant should not count as busy")
|
||||||
|
}
|
||||||
|
got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour))
|
||||||
|
if len(got) != 1 || got[0].Summary != "early" {
|
||||||
|
t.Fatalf("Overlapping = %+v", got)
|
||||||
|
}
|
||||||
|
all := Overlapping(events, base, base.AddDate(0, 0, 1))
|
||||||
|
if len(all) != 2 || all[0].Summary != "early" {
|
||||||
|
t.Fatalf("Overlapping must sort by start: %+v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceTrust(t *testing.T) {
|
||||||
|
if ReadOnlySource(SourcePersonal) {
|
||||||
|
t.Error("the personal calendar is the one maven may render to")
|
||||||
|
}
|
||||||
|
if !ReadOnlySource(SourceWork) {
|
||||||
|
t.Error("the work calendar must be read-only")
|
||||||
|
}
|
||||||
|
if !ReadOnlySource(SourceAmbient) {
|
||||||
|
t.Error("an ambient notification is not a writable calendar")
|
||||||
|
}
|
||||||
|
if AmbientConfidence >= 1.0 {
|
||||||
|
t.Error("ambient events must be less trusted than a calendar read")
|
||||||
|
}
|
||||||
|
if len(Sources()) != 3 {
|
||||||
|
t.Errorf("Sources() = %v", Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseICal scans iCal text for VEVENT components and returns the events
|
||||||
|
// overlapping [from, to). All-day events are skipped: parseDT reports no time
|
||||||
|
// for a VALUE=DATE value, and an event with no clock reading answers neither
|
||||||
|
// the busy gate nor the day plan.
|
||||||
|
func ParseICal(body []byte, from, to time.Time) []Event {
|
||||||
|
var events []Event
|
||||||
|
text := string(body)
|
||||||
|
for {
|
||||||
|
i := strings.Index(text, "BEGIN:VEVENT")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = text[i+len("BEGIN:VEVENT"):]
|
||||||
|
j := strings.Index(text, "END:VEVENT")
|
||||||
|
if j < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
block := text[:j]
|
||||||
|
text = text[j+len("END:VEVENT"):]
|
||||||
|
|
||||||
|
e, ok := parseVEVENT(block)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.End.After(from) && e.Start.Before(to) {
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
|
||||||
|
// location — the window cmd/mavcaldav polls.
|
||||||
|
//
|
||||||
|
// The location matters. The old inline version took the day number off a local
|
||||||
|
// clock reading but built the boundaries in UTC, so east of Greenwich the
|
||||||
|
// window was shifted by the offset and part of the evening fell outside
|
||||||
|
// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
|
||||||
|
// owner's day is the day the day plan and the busy gate mean.
|
||||||
|
func ParseICalDay(body []byte, now time.Time) []Event {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
return ParseICal(body, start, start.AddDate(0, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
|
||||||
|
// Reports false for all-day events and parse failures.
|
||||||
|
func parseVEVENT(block string) (Event, bool) {
|
||||||
|
var e Event
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "DTSTART"):
|
||||||
|
if t, ok := parseDT(line); ok {
|
||||||
|
e.Start = t
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "DTEND"):
|
||||||
|
if t, ok := parseDT(line); ok {
|
||||||
|
e.End = t
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "SUMMARY"):
|
||||||
|
e.Summary = afterColon(line)
|
||||||
|
case strings.HasPrefix(line, "UID"):
|
||||||
|
e.UID = afterColon(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Start.IsZero() || e.End.IsZero() {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func afterColon(line string) string {
|
||||||
|
if i := strings.Index(line, ":"); i >= 0 {
|
||||||
|
return strings.TrimSpace(line[i+1:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDT parses a DTSTART/DTEND value:
|
||||||
|
//
|
||||||
|
// - UTC: DTEND:20260703T100000Z
|
||||||
|
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||||
|
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
|
||||||
|
//
|
||||||
|
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
|
||||||
|
// CalDAV server and the poller run in the same timezone, and the busy gate only
|
||||||
|
// needs busy/not-busy to be right.
|
||||||
|
func parseDT(line string) (time.Time, bool) {
|
||||||
|
if strings.Contains(line, "VALUE=DATE:") {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(line, ":")
|
||||||
|
if i < 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
|
||||||
|
t, err := time.Parse("20060102T150405", val)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t.UTC(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
|
||||||
|
// collection. One event per file is the CalDAV convention, so callers normally
|
||||||
|
// pass a single event.
|
||||||
|
//
|
||||||
|
// This is the write half of #127 and it only ever renders: the canonical state
|
||||||
|
// is sqlite, the calendar is a view of it. Nothing reads a rendered file back.
|
||||||
|
func RenderICal(events []Event) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString("BEGIN:VEVENT\r\n")
|
||||||
|
fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID))
|
||||||
|
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary))
|
||||||
|
b.WriteString("END:VEVENT\r\n")
|
||||||
|
}
|
||||||
|
b.WriteString("END:VCALENDAR\r\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that
|
||||||
|
// would otherwise let a reminder payload inject iCal properties.
|
||||||
|
func escapeText(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
s = strings.ReplaceAll(s, ";", "\\;")
|
||||||
|
s = strings.ReplaceAll(s, ",", "\\,")
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\\n")
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderICalRoundTrips(t *testing.T) {
|
||||||
|
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
|
||||||
|
if e.UID != "maven-reminder-7" {
|
||||||
|
t.Errorf("UID = %q", e.UID)
|
||||||
|
}
|
||||||
|
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
|
||||||
|
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
|
||||||
|
}
|
||||||
|
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
|
||||||
|
t.Errorf("ReminderPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := RenderICal([]Event{e})
|
||||||
|
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
|
||||||
|
t.Fatalf("not a VCALENDAR body:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
|
||||||
|
if len(back) != 1 {
|
||||||
|
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
|
||||||
|
}
|
||||||
|
if back[0].UID != e.UID || back[0].Summary != e.Summary {
|
||||||
|
t.Errorf("round trip lost identity: %+v", back[0])
|
||||||
|
}
|
||||||
|
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
|
||||||
|
t.Errorf("round trip lost times: %+v", back[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderICalIsDeterministic(t *testing.T) {
|
||||||
|
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
|
||||||
|
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
|
||||||
|
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reminder payload is owner-supplied text. It must not be able to close the
|
||||||
|
// VEVENT and inject properties of its own.
|
||||||
|
func TestRenderICalEscapesInjection(t *testing.T) {
|
||||||
|
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
|
||||||
|
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
|
||||||
|
body := RenderICal([]Event{e})
|
||||||
|
// Count line-initial occurrences: the escaped text still contains the
|
||||||
|
// characters "BEGIN:VEVENT", it just can no longer start a line.
|
||||||
|
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
|
||||||
|
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
|
||||||
|
}
|
||||||
|
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
|
||||||
|
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
|
||||||
|
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||||
|
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||||
|
if e.Summary != "напоминание" {
|
||||||
|
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReminderUIDPrefix namespaces every event maven publishes. Two reasons it is a
|
||||||
|
// fixed prefix and not a random UUID: the render is idempotent (the same
|
||||||
|
// reminder always lands on the same UID, so re-rendering overwrites instead of
|
||||||
|
// duplicating), and everything maven owns in the target collection is
|
||||||
|
// identifiable at a glance — she never touches a file she did not create.
|
||||||
|
const ReminderUIDPrefix = "maven-reminder-"
|
||||||
|
|
||||||
|
// DefaultReminderDuration — how long a rendered reminder occupies. A reminder
|
||||||
|
// is an instant, a calendar entry is a span, so one has to be invented; 30
|
||||||
|
// minutes reads as a block in a calendar app without swallowing the afternoon.
|
||||||
|
const DefaultReminderDuration = 30 * time.Minute
|
||||||
|
|
||||||
|
// ReminderEvent maps a reminder to the event that represents it. id and fire
|
||||||
|
// come from the store; payload is the RU text as the owner said it, rendered
|
||||||
|
// verbatim as the summary — the calendar is a view of sqlite, not a place to
|
||||||
|
// rephrase.
|
||||||
|
func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) Event {
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = DefaultReminderDuration
|
||||||
|
}
|
||||||
|
summary := strings.TrimSpace(payload)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "напоминание"
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
UID: fmt.Sprintf("%s%d", ReminderUIDPrefix, id),
|
||||||
|
Summary: summary,
|
||||||
|
Start: fire,
|
||||||
|
End: fire.Add(dur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderPath is the collection-relative filename for a rendered reminder.
|
||||||
|
// One event per resource, per the CalDAV convention.
|
||||||
|
func ReminderPath(id int64) string {
|
||||||
|
return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// Package capture is Maven's meeting recorder (Vikunja #253,
|
||||||
|
// docs/plans/08-hearing.md).
|
||||||
|
//
|
||||||
|
// One session at a time, with an explicit start and an explicit stop:
|
||||||
|
//
|
||||||
|
// Start("встреча") → audio frames appended → Stop() → transcript → summary
|
||||||
|
//
|
||||||
|
// # Nothing here listens
|
||||||
|
//
|
||||||
|
// This is the most invasive capability in the backlog and the design is
|
||||||
|
// constrained accordingly. The constraints are the code, not a preamble:
|
||||||
|
//
|
||||||
|
// - There is no ambient path. `Session.Append` is the only way audio enters,
|
||||||
|
// and it only accepts frames while a session someone started is running.
|
||||||
|
// A keyword-triggered recorder ("maven record" heard in the room) was in the
|
||||||
|
// plan document and is refused: it requires listening in order to notice the
|
||||||
|
// keyword, which is the exact behaviour this capability must not have.
|
||||||
|
// - A session that is not stopped stops itself. MaxDuration is a hard cap
|
||||||
|
// checked on every Append, not a suggestion; a forgotten recording is a
|
||||||
|
// recording that ends, not one that runs until the disk is full.
|
||||||
|
// - Audio is stored under internal/media, which means retention prunes it and
|
||||||
|
// it never leaves the box. Both the audio blob and the transcript stay
|
||||||
|
// local; only the summary is written where he will read it.
|
||||||
|
// - The transcript is never search input for anything outside this box. It is
|
||||||
|
// text about a conversation with other people in it.
|
||||||
|
//
|
||||||
|
// # Long audio against a 4096-token context
|
||||||
|
//
|
||||||
|
// The resident model is a Thinking variant at n_ctx 4096, so an hour of meeting
|
||||||
|
// transcript does not fit in one prompt and never will. summarize.go does the
|
||||||
|
// obvious map-reduce: split the transcript on sentence boundaries into windows
|
||||||
|
// that fit, summarise each, then summarise the summaries. That is handled
|
||||||
|
// explicitly rather than by truncation, because a truncated meeting summary is
|
||||||
|
// worse than none — it looks complete and is not.
|
||||||
|
//
|
||||||
|
// # Transcription
|
||||||
|
//
|
||||||
|
// There is exactly one STT in Maven and this package does not add a second: it
|
||||||
|
// takes an stt.Transcriber, which in deploy is the whisper.cpp worker behind
|
||||||
|
// cmd/mavsttd. Long audio is transcribed in windows too (see chunkAudio), for
|
||||||
|
// the same reason whisper itself works in 30s windows — handing a worker an hour
|
||||||
|
// of PCM in one call is a request that either times out or blocks everything
|
||||||
|
// else for minutes.
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/media"
|
||||||
|
"github.com/kami/maven/internal/stt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultMaxDuration — how long one capture may run before it stops itself.
|
||||||
|
// Two hours covers a long meeting and bounds the damage of a forgotten session:
|
||||||
|
// at 16 kHz mono that is about 230 MB of PCM, which is over media's default
|
||||||
|
// per-blob cap, so a session at the limit is stored truncated rather than
|
||||||
|
// refused. That trade is deliberate — a partial recording of a meeting he asked
|
||||||
|
// for beats an error after two hours.
|
||||||
|
const DefaultMaxDuration = 2 * time.Hour
|
||||||
|
|
||||||
|
// DefaultSTTWindow — how much audio goes to the transcriber in one call. Five
|
||||||
|
// minutes of 16 kHz mono is under 10 MB, transcribes in well under whisper's
|
||||||
|
// own timeout on this box, and keeps the worker responsive to the voice path
|
||||||
|
// between windows.
|
||||||
|
const DefaultSTTWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// Errors callers distinguish.
|
||||||
|
var (
|
||||||
|
// ErrDisabled — capture is not configured. A capability is off unless
|
||||||
|
// configured, and a recorder most of all.
|
||||||
|
ErrDisabled = errors.New("capture: not configured")
|
||||||
|
// ErrBusy — a session is already running. One at a time: two concurrent
|
||||||
|
// recordings would make "хватит" ambiguous.
|
||||||
|
ErrBusy = errors.New("capture: a session is already running")
|
||||||
|
// ErrNoSession — stop or append with nothing running.
|
||||||
|
ErrNoSession = errors.New("capture: nothing is being recorded")
|
||||||
|
// ErrBadFormat — a frame is not the canonical 16 kHz mono PCM shape.
|
||||||
|
ErrBadFormat = errors.New("capture: audio format not supported")
|
||||||
|
// ErrEmptyCapture — the session ended with no audio in it.
|
||||||
|
ErrEmptyCapture = errors.New("capture: nothing was recorded")
|
||||||
|
// ErrExpired — the session hit MaxDuration and was closed. Returned from
|
||||||
|
// Append so the caller stops sending; the audio collected so far is kept.
|
||||||
|
ErrExpired = errors.New("capture: session reached its time limit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session — one recording in progress. Not created directly; Recorder.Start
|
||||||
|
// makes it. Guarded by a mutex because frames arrive from a network goroutine
|
||||||
|
// while a status call may read from another.
|
||||||
|
type Session struct {
|
||||||
|
Label string
|
||||||
|
Started time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pcm []byte
|
||||||
|
format audio.Format
|
||||||
|
expired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration is how much audio has been collected, from the bytes rather than the
|
||||||
|
// wall clock: a stream that dropped frames should report the audio that exists,
|
||||||
|
// not the time that passed.
|
||||||
|
func (s *Session) Duration() time.Duration {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.duration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) duration() time.Duration {
|
||||||
|
a := audio.Audio{Format: s.format, Bytes: s.pcm}
|
||||||
|
return time.Duration(a.Duration() * float64(time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes is how much PCM has been collected. For a status line.
|
||||||
|
func (s *Session) Bytes() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.pcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status — what a "что записываешь?" answer needs, and what /dash shows. It is
|
||||||
|
// the read side of a running session and is safe to ask for at any time.
|
||||||
|
type Status struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Started time.Time `json:"started,omitempty"`
|
||||||
|
Duration time.Duration `json:"duration,omitempty"`
|
||||||
|
Bytes int `json:"bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorder owns the single session slot, the blob store and the two models a
|
||||||
|
// finished capture needs. Build it with New; a zero Recorder is not usable.
|
||||||
|
type Recorder struct {
|
||||||
|
blobs *media.Store
|
||||||
|
tr stt.Transcriber
|
||||||
|
sum *Summarizer
|
||||||
|
maxDuration time.Duration
|
||||||
|
sttWindow time.Duration
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
current *Session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config — the recorder's knobs, built from config.CaptureConfig by the daemon.
|
||||||
|
type Config struct {
|
||||||
|
// MaxDuration — hard cap on one session. 0 ⇒ DefaultMaxDuration.
|
||||||
|
MaxDuration time.Duration
|
||||||
|
// STTWindow — audio per transcription call. 0 ⇒ DefaultSTTWindow.
|
||||||
|
STTWindow time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Recorder. blobs and tr are required — a recorder with nowhere to
|
||||||
|
// put the audio, or nothing to transcribe it with, is not a recorder. sum may be
|
||||||
|
// nil: the transcript is still produced and stored, and the summary is simply
|
||||||
|
// absent, which is the honest degradation when there is no llama-server.
|
||||||
|
func New(blobs *media.Store, tr stt.Transcriber, sum *Summarizer, cfg Config) (*Recorder, error) {
|
||||||
|
if blobs == nil {
|
||||||
|
return nil, errors.New("capture: no blob store")
|
||||||
|
}
|
||||||
|
if tr == nil {
|
||||||
|
return nil, errors.New("capture: no transcriber")
|
||||||
|
}
|
||||||
|
maxDur := cfg.MaxDuration
|
||||||
|
if maxDur <= 0 {
|
||||||
|
maxDur = DefaultMaxDuration
|
||||||
|
}
|
||||||
|
window := cfg.STTWindow
|
||||||
|
if window <= 0 {
|
||||||
|
window = DefaultSTTWindow
|
||||||
|
}
|
||||||
|
return &Recorder{
|
||||||
|
blobs: blobs,
|
||||||
|
tr: tr,
|
||||||
|
sum: sum,
|
||||||
|
maxDuration: maxDur,
|
||||||
|
sttWindow: window,
|
||||||
|
now: time.Now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxDuration is the configured hard cap. For the reply that tells him how long
|
||||||
|
// she will keep going if he forgets to say "хватит".
|
||||||
|
func (r *Recorder) MaxDuration() time.Duration { return r.maxDuration }
|
||||||
|
|
||||||
|
// Start opens a session. label is what the meeting is called ("встреча с
|
||||||
|
// подрядчиком"); it ends up in the summary note so the note is findable.
|
||||||
|
// ErrBusy if one is already running — the caller says so rather than silently
|
||||||
|
// discarding the first recording.
|
||||||
|
func (r *Recorder) Start(label string) (*Session, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.current != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %q since %s", ErrBusy, r.current.Label,
|
||||||
|
r.current.Started.Format(time.Kitchen))
|
||||||
|
}
|
||||||
|
s := &Session{
|
||||||
|
Label: strings.TrimSpace(label),
|
||||||
|
Started: r.now().UTC(),
|
||||||
|
format: audio.PCM16kMono,
|
||||||
|
}
|
||||||
|
r.current = s
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append adds one frame to the running session. ErrNoSession when nothing is
|
||||||
|
// running, which is the guard that makes an ambient path impossible: a stream
|
||||||
|
// arriving at a Recorder nobody started is refused frame by frame.
|
||||||
|
//
|
||||||
|
// ErrExpired once the session is at MaxDuration. The audio collected so far is
|
||||||
|
// kept and Stop still works — the cap ends the recording, it does not throw it
|
||||||
|
// away.
|
||||||
|
func (r *Recorder) Append(a audio.Audio) error {
|
||||||
|
if !a.Format.IsValid() {
|
||||||
|
return fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
s := r.current
|
||||||
|
r.mu.Unlock()
|
||||||
|
if s == nil {
|
||||||
|
return ErrNoSession
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.expired {
|
||||||
|
return ErrExpired
|
||||||
|
}
|
||||||
|
s.pcm = append(s.pcm, a.Bytes...)
|
||||||
|
if s.duration() >= r.maxDuration {
|
||||||
|
s.expired = true
|
||||||
|
return ErrExpired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports the running session, or Running=false.
|
||||||
|
func (r *Recorder) Status() Status {
|
||||||
|
r.mu.Lock()
|
||||||
|
s := r.current
|
||||||
|
r.mu.Unlock()
|
||||||
|
if s == nil {
|
||||||
|
return Status{}
|
||||||
|
}
|
||||||
|
return Status{
|
||||||
|
Running: true,
|
||||||
|
Label: s.Label,
|
||||||
|
Started: s.Started,
|
||||||
|
Duration: s.Duration(),
|
||||||
|
Bytes: s.Bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result — a finished capture.
|
||||||
|
type Result struct {
|
||||||
|
// BlobID — the stored audio, content-addressed. Empty only if storing failed.
|
||||||
|
BlobID string
|
||||||
|
// Label / Started / Duration — what was recorded and when.
|
||||||
|
Label string
|
||||||
|
Started time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
// Transcript — the full text, joined across STT windows.
|
||||||
|
Transcript string
|
||||||
|
// Summary — the map-reduced summary, or empty when no summarizer was wired
|
||||||
|
// or the model failed. Empty summary with a non-empty transcript is a
|
||||||
|
// degraded success, not a failure: the words are there.
|
||||||
|
Summary string
|
||||||
|
// Chunks — how many windows the transcript was summarised in. 1 means it fit
|
||||||
|
// in one prompt. Reported so a suspiciously vague summary can be explained.
|
||||||
|
Chunks int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the session and produces the result: store the audio, transcribe it
|
||||||
|
// in windows, summarise it in windows. The session slot is freed before any of
|
||||||
|
// the slow work starts, so a stuck model cannot block the next recording.
|
||||||
|
//
|
||||||
|
// The order matters and is the same as vision's: the audio is stored FIRST. If
|
||||||
|
// transcription or summarisation fails, the recording is still on disk and can
|
||||||
|
// be run again; a meeting that happened once must not be lost to a model error.
|
||||||
|
func (r *Recorder) Stop(ctx context.Context) (Result, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
s := r.current
|
||||||
|
r.current = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
if s == nil {
|
||||||
|
return Result{}, ErrNoSession
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
pcm := s.pcm
|
||||||
|
format := s.format
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
res := Result{Label: s.Label, Started: s.Started}
|
||||||
|
if len(pcm) == 0 {
|
||||||
|
return res, ErrEmptyCapture
|
||||||
|
}
|
||||||
|
full := audio.Audio{Format: format, Bytes: pcm}
|
||||||
|
res.Duration = time.Duration(full.Duration() * float64(time.Second))
|
||||||
|
|
||||||
|
// Stored as WAV, not headerless PCM: a blob on disk that `aplay` and whisper
|
||||||
|
// can both open without being told the format is worth 44 bytes.
|
||||||
|
wav, err := audio.WAVFromPCM(format, pcm)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("capture: wav: %w", err)
|
||||||
|
}
|
||||||
|
blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav)
|
||||||
|
if err != nil {
|
||||||
|
// Over the per-blob cap is the expected case for a very long meeting.
|
||||||
|
// Report it and keep going: a transcript without the audio still beats
|
||||||
|
// nothing, and the words are what he will read.
|
||||||
|
return res, fmt.Errorf("capture: store audio: %w", err)
|
||||||
|
}
|
||||||
|
res.BlobID = blob.ID
|
||||||
|
|
||||||
|
text, err := r.transcribe(ctx, full)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("capture: transcribe: %w", err)
|
||||||
|
}
|
||||||
|
res.Transcript = text
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return res, ErrEmptyCapture
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.sum == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
summary, chunks, err := r.sum.Summarize(ctx, s.Label, text)
|
||||||
|
res.Chunks = chunks
|
||||||
|
if err != nil {
|
||||||
|
// Degraded success: the transcript is real and stored, only the summary
|
||||||
|
// is missing. The caller writes the transcript note and says so.
|
||||||
|
return res, fmt.Errorf("capture: summarize: %w", err)
|
||||||
|
}
|
||||||
|
res.Summary = summary
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort throws the running session away without transcribing or storing it.
|
||||||
|
// This is what "забудь, не записывай" must map to: a recording someone changed
|
||||||
|
// their mind about leaves nothing behind, not a blob with a note saying it was
|
||||||
|
// abandoned. Returns whether anything was running.
|
||||||
|
func (r *Recorder) Abort() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.current == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.current = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcribe runs the transcriber over the audio in windows and joins the text.
|
||||||
|
// A window that fails is fatal: a summary of a meeting with a silent hole in the
|
||||||
|
// middle is a summary that misleads.
|
||||||
|
func (r *Recorder) transcribe(ctx context.Context, a audio.Audio) (string, error) {
|
||||||
|
windows := chunkAudio(a, r.sttWindow)
|
||||||
|
parts := make([]string, 0, len(windows))
|
||||||
|
for i, w := range windows {
|
||||||
|
text, _, err := r.tr.Transcribe(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("window %d/%d: %w", i+1, len(windows), err)
|
||||||
|
}
|
||||||
|
if t := strings.TrimSpace(text); t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkAudio splits audio into windows of at most window duration, cut on
|
||||||
|
// sample boundaries. A window shorter than one sample is impossible; audio
|
||||||
|
// shorter than one window comes back as a single element, so the caller never
|
||||||
|
// special-cases the short case.
|
||||||
|
func chunkAudio(a audio.Audio, window time.Duration) []audio.Audio {
|
||||||
|
bytesPerSample := a.Format.SampleBits / 8 * a.Format.Channels
|
||||||
|
if bytesPerSample <= 0 || a.Format.SampleRate <= 0 || window <= 0 {
|
||||||
|
return []audio.Audio{a}
|
||||||
|
}
|
||||||
|
per := int(window.Seconds()) * a.Format.SampleRate * bytesPerSample
|
||||||
|
if per <= 0 || len(a.Bytes) <= per {
|
||||||
|
return []audio.Audio{a}
|
||||||
|
}
|
||||||
|
var out []audio.Audio
|
||||||
|
for off := 0; off < len(a.Bytes); off += per {
|
||||||
|
end := off + per
|
||||||
|
if end > len(a.Bytes) {
|
||||||
|
end = len(a.Bytes)
|
||||||
|
}
|
||||||
|
// Never cut mid-sample: a split inside an int16 shifts every following
|
||||||
|
// sample by a byte and turns the tail of the window into noise.
|
||||||
|
end -= (end - off) % bytesPerSample
|
||||||
|
if end <= off {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, audio.Audio{Format: a.Format, Bytes: a.Bytes[off:end]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTranscriber returns a fixed phrase per call so a windowed transcription is
|
||||||
|
// visible in the joined output.
|
||||||
|
type fakeTranscriber struct {
|
||||||
|
calls int
|
||||||
|
err error
|
||||||
|
phrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTranscriber) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return "", 0, f.err
|
||||||
|
}
|
||||||
|
p := f.phrase
|
||||||
|
if p == "" {
|
||||||
|
p = "окно"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%d", p, f.calls), 1.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeCompleter records prompts and replies from a script.
|
||||||
|
type fakeCompleter struct {
|
||||||
|
replies []string
|
||||||
|
systems []string
|
||||||
|
users []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
|
||||||
|
f.systems = append(f.systems, system)
|
||||||
|
f.users = append(f.users, user)
|
||||||
|
if f.err != nil {
|
||||||
|
return "", f.err
|
||||||
|
}
|
||||||
|
if len(f.replies) == 0 {
|
||||||
|
return "итог", nil
|
||||||
|
}
|
||||||
|
r := f.replies[0]
|
||||||
|
f.replies = f.replies[1:]
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// frame builds n seconds of silence in the canonical format.
|
||||||
|
func frame(seconds float64) audio.Audio {
|
||||||
|
n := int(seconds*16000) * 2
|
||||||
|
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, n)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRecorder(t *testing.T, tr *fakeTranscriber, sum *Summarizer, cfg Config) (*Recorder, *media.Store) {
|
||||||
|
t.Helper()
|
||||||
|
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, err := New(blobs, tr, sum, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r, blobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRequiresStoreAndTranscriber(t *testing.T) {
|
||||||
|
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := New(nil, &fakeTranscriber{}, nil, Config{}); err == nil {
|
||||||
|
t.Error("recorder built with no blob store")
|
||||||
|
}
|
||||||
|
if _, err := New(blobs, nil, nil, Config{}); err == nil {
|
||||||
|
t.Error("recorder built with no transcriber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The invariant that matters most: audio arriving at a recorder nobody started
|
||||||
|
// is refused. There is no ambient path in.
|
||||||
|
func TestAppendWithoutStartIsRefused(t *testing.T) {
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||||
|
if err := r.Append(frame(1)); !errors.Is(err, ErrNoSession) {
|
||||||
|
t.Fatalf("got %v, want ErrNoSession", err)
|
||||||
|
}
|
||||||
|
if r.Status().Running {
|
||||||
|
t.Error("a refused frame started a session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopWithoutStartIsRefused(t *testing.T) {
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||||
|
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrNoSession) {
|
||||||
|
t.Fatalf("got %v, want ErrNoSession", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneSessionAtATime(t *testing.T) {
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||||
|
if _, err := r.Start("встреча"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
|
||||||
|
t.Fatalf("got %v, want ErrBusy", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrEmptyCapture) {
|
||||||
|
t.Fatalf("empty stop: %v", err)
|
||||||
|
}
|
||||||
|
// The slot is free again after a stop, even a failed one.
|
||||||
|
if _, err := r.Start("третья"); err != nil {
|
||||||
|
t.Errorf("slot not released: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
|
||||||
|
tr := &fakeTranscriber{phrase: "совещание"}
|
||||||
|
sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil)
|
||||||
|
r, blobs := testRecorder(t, tr, sum, Config{})
|
||||||
|
|
||||||
|
if _, err := r.Start("встреча с подрядчиком"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := r.Append(frame(2)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := r.Stop(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop: %v", err)
|
||||||
|
}
|
||||||
|
if res.BlobID == "" {
|
||||||
|
t.Error("no audio blob stored")
|
||||||
|
}
|
||||||
|
blob, data, err := blobs.Read(res.BlobID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blob unreadable: %v", err)
|
||||||
|
}
|
||||||
|
if blob.Kind != media.KindAudio || blob.Source != "capture:meeting" {
|
||||||
|
t.Errorf("blob metadata = %+v", blob)
|
||||||
|
}
|
||||||
|
if string(data[:4]) != "RIFF" {
|
||||||
|
t.Error("audio was not stored as a playable WAV")
|
||||||
|
}
|
||||||
|
if res.Transcript == "" {
|
||||||
|
t.Error("no transcript")
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Summary, "насос") {
|
||||||
|
t.Errorf("summary = %q", res.Summary)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Summary, "встреча с подрядчиком") {
|
||||||
|
t.Errorf("label missing from summary: %q", res.Summary)
|
||||||
|
}
|
||||||
|
if res.Duration != 6*time.Second {
|
||||||
|
t.Errorf("duration = %v, want 6s", res.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forgotten session stops itself, and the audio collected before the cap is
|
||||||
|
// kept rather than thrown away.
|
||||||
|
func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
|
||||||
|
tr := &fakeTranscriber{}
|
||||||
|
r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second})
|
||||||
|
if _, err := r.Start("длинная"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(3)); err != nil {
|
||||||
|
t.Fatalf("first frame: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
|
||||||
|
t.Fatalf("got %v, want ErrExpired", err)
|
||||||
|
}
|
||||||
|
// Further frames keep being refused, so a client that ignores the error
|
||||||
|
// cannot grow the recording past the cap.
|
||||||
|
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
|
||||||
|
t.Fatalf("post-expiry frame: %v", err)
|
||||||
|
}
|
||||||
|
res, err := r.Stop(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop after expiry: %v", err)
|
||||||
|
}
|
||||||
|
if res.Duration != 6*time.Second {
|
||||||
|
t.Errorf("duration = %v, want the 6s collected before the cap", res.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendRejectsWrongFormat(t *testing.T) {
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||||
|
if _, err := r.Start("x"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bad := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 100)}
|
||||||
|
if err := r.Append(bad); !errors.Is(err, ErrBadFormat) {
|
||||||
|
t.Fatalf("got %v, want ErrBadFormat", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "забудь, не записывай" must leave nothing behind — no blob, no transcript.
|
||||||
|
func TestAbortLeavesNothing(t *testing.T) {
|
||||||
|
tr := &fakeTranscriber{}
|
||||||
|
r, blobs := testRecorder(t, tr, nil, Config{})
|
||||||
|
if _, err := r.Start("зря начали"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(5)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !r.Abort() {
|
||||||
|
t.Fatal("Abort reported nothing running")
|
||||||
|
}
|
||||||
|
if r.Status().Running {
|
||||||
|
t.Error("session survived Abort")
|
||||||
|
}
|
||||||
|
list, err := blobs.List(media.KindAudio)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Errorf("Abort stored %d blob(s)", len(list))
|
||||||
|
}
|
||||||
|
if tr.calls != 0 {
|
||||||
|
t.Errorf("Abort transcribed anyway (%d calls)", tr.calls)
|
||||||
|
}
|
||||||
|
if r.Abort() {
|
||||||
|
t.Error("second Abort reported a session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusReportsTheRunningSession(t *testing.T) {
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||||
|
if got := r.Status(); got.Running {
|
||||||
|
t.Error("idle recorder reports running")
|
||||||
|
}
|
||||||
|
if _, err := r.Start("планёрка"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(10)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := r.Status()
|
||||||
|
if !st.Running || st.Label != "планёрка" {
|
||||||
|
t.Fatalf("status = %+v", st)
|
||||||
|
}
|
||||||
|
if st.Duration != 10*time.Second {
|
||||||
|
t.Errorf("duration = %v", st.Duration)
|
||||||
|
}
|
||||||
|
if st.Bytes != 10*16000*2 {
|
||||||
|
t.Errorf("bytes = %d", st.Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long audio goes to the transcriber in windows: handing a whisper worker an
|
||||||
|
// hour of PCM in one call blocks the voice path for minutes.
|
||||||
|
func TestLongAudioIsTranscribedInWindows(t *testing.T) {
|
||||||
|
tr := &fakeTranscriber{}
|
||||||
|
r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second})
|
||||||
|
if _, err := r.Start("длинная"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(9)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := r.Stop(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop: %v", err)
|
||||||
|
}
|
||||||
|
if tr.calls != 5 { // 2+2+2+2+1
|
||||||
|
t.Errorf("transcriber called %d times, want 5", tr.calls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Transcript, "окно5") {
|
||||||
|
t.Errorf("last window missing from transcript: %q", res.Transcript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hole in the middle of a meeting summary would mislead, so a failed window is
|
||||||
|
// fatal — but the audio is already stored and re-runnable.
|
||||||
|
func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
|
||||||
|
tr := &fakeTranscriber{err: errors.New("whisper is down")}
|
||||||
|
r, blobs := testRecorder(t, tr, nil, Config{})
|
||||||
|
if _, err := r.Start("встреча"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(2)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := r.Stop(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("transcription failure was not reported")
|
||||||
|
}
|
||||||
|
if res.BlobID == "" {
|
||||||
|
t.Fatal("no blob id to retry with")
|
||||||
|
}
|
||||||
|
if _, _, err := blobs.Read(res.BlobID); err != nil {
|
||||||
|
t.Errorf("audio was not kept: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No llama-server ⇒ transcript only. That is the honest degradation, not an
|
||||||
|
// error.
|
||||||
|
func TestNoSummarizerStillProducesATranscript(t *testing.T) {
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
|
||||||
|
if _, err := r.Start("встреча"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := r.Stop(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stop: %v", err)
|
||||||
|
}
|
||||||
|
if res.Transcript == "" {
|
||||||
|
t.Error("no transcript")
|
||||||
|
}
|
||||||
|
if res.Summary != "" {
|
||||||
|
t.Errorf("summary appeared from nowhere: %q", res.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A summariser failure is a degraded success: the words exist and are returned.
|
||||||
|
func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
|
||||||
|
sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil)
|
||||||
|
r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{})
|
||||||
|
if _, err := r.Start("встреча"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.Append(frame(1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := r.Stop(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("summary failure was not reported")
|
||||||
|
}
|
||||||
|
if res.Transcript == "" {
|
||||||
|
t.Error("transcript lost to a summary failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkAudioNeverCutsMidSample(t *testing.T) {
|
||||||
|
a := audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000*2*5+1)}
|
||||||
|
for _, w := range chunkAudio(a, 2*time.Second) {
|
||||||
|
if len(w.Bytes)%2 != 0 {
|
||||||
|
t.Fatalf("window of %d bytes cuts an int16 in half", len(w.Bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkAudioShortInputIsOneWindow(t *testing.T) {
|
||||||
|
a := frame(1)
|
||||||
|
if got := chunkAudio(a, time.Minute); len(got) != 1 {
|
||||||
|
t.Errorf("got %d windows, want 1", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user