Merge the line B review stack (#160)

This commit was merged in pull request #160.
This commit is contained in:
2026-08-04 18:50:48 +02:00
54 changed files with 3707 additions and 1685 deletions
+21 -5
View File
@@ -32,7 +32,11 @@ See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for t
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
stays on homesrv permanently, because it backs that floor. Read `docs/offload.md` before
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
are the work.
@@ -73,8 +77,8 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
| `mavttsd` | Text-to-speech (piper subprocess). |
| `mavwaked` | Wake-word / VAD gate. |
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
| `mavpoll` | Telegram long-poll reach. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
@@ -83,6 +87,15 @@ Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/ser
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
daemon there listens to nobody. They belong on a client machine where he is standing.
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
needs building to allow it, but no such machine exists yet. **The consequence: the wake
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
## The ecosystem: Nexus, Praxis, Hexis
Maven is one of four services. It owns conversation and personal memory. It does not
@@ -267,8 +280,11 @@ world questions, so she needs to read external sources. What replaces it:
## Web UI conventions
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
+10 -2
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: 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 deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
.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 deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -138,6 +138,14 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
eval-router:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
# eval-reach — score the held-out ecosystem reach fixture (internal/router/eval,
# ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive
# at Praxis or Hexis", which routing accuracy alone does not say. Same
# MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash
# ratchet runs. Vikunja #405.
eval-reach:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
# Answers "can she find the note again when it matters": recall@1, recall@3,
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
@@ -216,7 +224,7 @@ deps-piper:
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
# a short question against a longer passage, which is what note recall is.
# The quantized file is the one we download, deploy and measure — see
# docs/evals/2026-07-31-recall.md.
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
+3 -3
View File
@@ -89,11 +89,11 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
// hears; storing the utterance meant recall answered with his own sentence
// rather than the value. The utterance stays alongside as provenance —
// readable on /trace, never the answer and never embedded.
if h.memStore != nil {
if h.recall.memStore != nil {
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
log.Printf("voice: embed fact for memory: %v", err)
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
"source": "voice",
"type": "fact",
"text": text,
+3 -3
View File
@@ -27,7 +27,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
if err != nil {
log.Printf("voice: embed note: %v", err)
return phraser.Ack(phraser.FailNote, nil)
@@ -40,8 +40,8 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
}
// Insert into long-term memory (best-effort, must not fail the note write).
// text/ts in the meta make a Search hit self-describing (see bestRecall).
if h.memStore != nil {
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
if h.recall.memStore != nil {
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
"source": "voice",
"type": "note",
"text": dec.Utterance,
+7 -7
View File
@@ -454,7 +454,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
// sources below both need, run once, in the position it always ran in. It
// only claims the turn when the embedder fails.
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
if err != nil {
log.Printf("voice: embed query: %v", err)
return phraser.Q(phraser.QueryFailAnswer, nil), true
@@ -474,15 +474,15 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
// gate, was the bug — the set of questions Maven answers is unchanged, only
// which memory gets to answer them.
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
if h.memStore == nil {
if h.recall.memStore == nil {
return "", false
}
hits, herr := h.memStore.Search(ctx, t.vec, 3)
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
if herr != nil {
log.Printf("voice: memory search: %v", herr)
return "", false
}
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
if !ok {
return "", false
}
@@ -533,7 +533,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
for i, n := range notes {
noteScores[i] = n.Score
}
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
return "", false
}
// Same topic veto as queryMemory above: the best note must be about what
@@ -819,8 +819,8 @@ func isPersonalQuery(utterance string) bool {
// computed. Same shape as the cascade: the better test leads, the offline one
// always answers.
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
h.boundary.load(ctx, h.embedder)
if personal, world, ok := h.boundary.score(t.vec); ok {
h.recall.boundary.load(ctx, h.recall.embedder)
if personal, world, ok := h.recall.boundary.score(t.vec); ok {
if personal > world {
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
return true
+1 -1
View File
@@ -305,7 +305,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
h, _, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(1024)
h.embedder = emb
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, nil)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
+1 -2
View File
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
h := &reactiveHandler{
api: api,
embedder: emb,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: rtr,
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
+5 -6
View File
@@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
emb := router.NewHashEmbedder(1024)
h := &reactiveHandler{
api: api,
embedder: emb,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
return h, api
@@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
t.Fatal("a question was stored as a fact about him")
}
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
@@ -81,7 +80,7 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
// #493: what recall reads back is the fact, not the sentence he said.
// queryMemory returns a fact's text verbatim, so the utterance sitting here
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
@@ -117,7 +116,7 @@ func TestFactConfidence(t *testing.T) {
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
t.Helper()
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
if err != nil {
t.Fatalf("embed %q: %v", text, err)
}
@@ -140,7 +139,7 @@ func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
t.Fatal("a passing complaint was stored as a fact about him")
}
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
+6 -6
View File
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
}
}
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
replier: voice.NewStubReplier(),
phraser: phraser.NewStub(),
now: func() time.Time { return now },
feedsOn: feedsOn,
embedder: nil,
api: ipc.NewStoreAPI(st),
replier: voice.NewStubReplier(),
phraser: phraser.NewStub(),
now: func() time.Time { return now },
feedsOn: feedsOn,
recall: recallWiring{embedder: nil},
}
}
+2 -2
View File
@@ -70,7 +70,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
{"how do i boil an egg", false},
}
h := &reactiveHandler{embedder: emb}
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
ctx := context.Background()
wrong := 0
for _, c := range cases {
@@ -80,7 +80,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
}
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
got := h.isPersonalTurn(ctx, turn)
p, w, ok := h.boundary.score(vec)
p, w, ok := h.recall.boundary.score(vec)
if !ok {
t.Fatal("seeds did not load with a working embedder")
}
+7 -5
View File
@@ -85,15 +85,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac
phr := &recordingPhraser{Stub: phraser.NewStub()}
h := &reactiveHandler{
api: ipc.NewStoreAPI(st),
embedder: emb,
api: ipc.NewStoreAPI(st),
recall: recallWiring{
embedder: emb,
memStore: mem,
minScore: 0.55,
minMargin: 0.008,
},
replier: voice.NewStubReplier(),
phraser: phr,
now: func() time.Time { return now },
memStore: mem,
dataStore: st,
queryMinScore: 0.55,
queryMinMargin: 0.008,
weatherProvider: nil,
}
return h, phr
+2 -4
View File
@@ -26,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
h := &reactiveHandler{
api: api,
embedder: emb,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: rtr,
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
@@ -101,11 +100,10 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
embedder: emb,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, matcher, 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
+41 -1
View File
@@ -1,6 +1,9 @@
package main
import "github.com/kami/maven/internal/memory"
import (
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
)
// bestRecall is the read side of the long-term memory store: the top hit when
// it clears the confidence gate. The index holds BOTH notes and facts, and
@@ -23,3 +26,40 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
}
return results[0], true
}
// recallWiring — the recall subsystem's dependencies, held as one group on
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
// loose ones, so a handler names what it needs and the package can be split
// later without exporting the whole struct.
//
// The zero value is usable and means "no recall": no embedder, no vector
// store, and a gate that is never consulted because nothing is ever searched.
type recallWiring struct {
// embedder — reused for note write/query (same model as the classifier).
embedder router.Embedder
// memStore — the vector index over notes and facts.
memStore memory.Store
// topics — the embedded seed sets behind the weather, house and LAN
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
// usable, loads on first query, and with no embedder it never loads and
// each source falls back to its own keyword test.
topics topicIndex
// boundary — the embedded seed sets behind the personal boundary
// (personalboundary.go). Zero value is usable and loads on first query;
// with no embedder it never loads and the boundary uses personalMarkers.
boundary personalBoundary
// minScore — the note-recall confidence gate. Top cosine below this ⇒
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
// not load-bearing math (same posture as the presence thresholds). Set by
// wireVoice from VoiceConfig; default 0.55.
minScore float64
// minMargin — the second half of that gate: how far the top hit must beat
// the runner-up. 0 ⇒ margin off.
minMargin float64
}
+2 -2
View File
@@ -44,7 +44,7 @@ func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
h, st, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
cls := router.NewClassifier(emb)
h.embedder = emb
h.recall.embedder = emb
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
ctx := context.Background()
@@ -94,7 +94,7 @@ func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
func TestRepairIsSpentOnce(t *testing.T) {
h, _, _ := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
h.embedder = emb
h.recall.embedder = emb
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
ctx := context.Background()
+9 -7
View File
@@ -428,20 +428,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
w.handler = &reactiveHandler{
stt: simTranscriber{},
tts: simSynthesizer{},
router: rtr,
embedder: emb,
stt: simTranscriber{},
tts: simSynthesizer{},
router: rtr,
recall: recallWiring{
embedder: emb,
memStore: st.VectorMemory(),
minScore: config.DefaultQueryMinScore,
minMargin: config.DefaultQueryMinMargin,
},
api: api,
matcher: matcher,
tools: tool.NewExecutor(api, 5*time.Second),
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),
-715
View File
@@ -10,25 +10,19 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -319,617 +313,6 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
return pn.Body, pn.Summary
}
// shouldQueue — true when digest is enabled and the candidate's severity is
// at or below the configured ceiling.
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
return t.digestCfg != nil && t.digestCfg.Enabled &&
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
}
// queueNudge — phrases the candidate and appends it to the digest queue.
// Deduplicates by rule name: if the same rule is already queued, this is a
// no-op (the first fire within the window is the one that counts).
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
for _, q := range t.digestQ {
if q.Rule == cand.Rule.Name {
return // already queued
}
}
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
return
}
t.digestQ = append(t.digestQ, QueuedNudge{
Rule: cand.Rule.Name,
Severity: int(cand.Severity),
Body: pn.Body,
Key: cand.Rule.Name,
QueuedAt: now,
})
t.cachePhrase(pn)
}
// maybeFlush — flushes the digest queue if the window has elapsed since the
// first item or the queue reached MaxItems.
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
return
}
first := t.digestQ[0]
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
len(t.digestQ) >= t.digestCfg.MaxItems {
t.flushDigest(ctx, now, state)
}
}
// flushDigest — concatenates queued nudge bodies into a single digest
// notification and dispatches it. Clears the queue after a successful send.
// The digest uses the max severity among queued items for routing.
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
if len(t.digestQ) == 0 {
return
}
var b strings.Builder
maxSev := 0
for i, q := range t.digestQ {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(q.Body)
if q.Severity > maxSev {
maxSev = q.Severity
}
}
body := b.String()
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
cand := loop.Candidate{
Rule: loop.Rule{
Name: "digest",
Severity: loop.Severity(maxSev),
},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{
Candidate: cand,
Body: body,
Summary: summary,
}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
// keep the queue — the next tick's maybeFlush re-attempts.
log.Printf("tick: dispatch digest: %v", err)
return
}
t.digestQ = nil
}
// detectPatterns runs the pattern detector proactively over every
// action+object pair that has ever produced an event, independent of
// whichever fact write (or channel) last touched it (Vikunja #43). This is
// what makes pattern inference actually proactive: it fires on the daemon's
// own schedule reading accumulated history, not only as a side effect of a
// live voice turn.
//
// Idempotence and noise are handled by the store, not here — this function
// is safe to call every tick:
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
// check plus proposed_routines' UNIQUE(action, object) constraint (with
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
// already has a row — in ANY status — produces no second row and no log
// spam beyond the one line at genuine creation.
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
// status in place; the row is never deleted. So the same Lookup check
// that stops a duplicate "proposed" also stops a "dismissed" one from
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// 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)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
continue
}
if r == nil {
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)
// 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.
// Nor are they queued: the row now exists, so no later tick re-detects
// them and they are never announced. See announceProposal.
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.
//
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
// newly created row, so a pair gets exactly one chance to be spoken: the tick
// that first proposes it. Combined with one announcement per tick, the first
// tick over a populated history announces one pattern and permanently silences
// every other pattern found in the same pass. That is the intent, not an
// oversight — an inferred routine is not worth a second attempt at his
// attention, and /routines lists all of them. So the cooldown does not drain a
// backlog. It only spaces announcements of genuinely new pairs discovered on
// later ticks. If it should ever become "one per day until each is mentioned",
// that needs a queue rather than this counter.
//
// Cooldown gets its default here as well as in applyDefaults. That is
// deliberate: a tickLoop assembled directly in a test never goes through Load,
// and an unspaced announcer is not what those tests mean to exercise.
//
// 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
// 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
// digest entry that outlives one full day is describing a day that's already
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
// news. Bounding at one day also means a digest can never silently span a
// weekend of quiet hours into an unbounded backlog.
const digestExpiry = 24 * time.Hour
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
// dropped" cannot regress into "she dumps twelve things on me the moment I
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
// Anything beyond the cap is still marked drained (it did get its moment;
// the cap limits WORDS, not whether it counted) and folded into a trailing
// count instead of being spoken in full.
const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
}
for _, tr := range trace.RuleTraces {
if !tr.PredicateResult || tr.GateResult {
continue // didn't want to fire, or wasn't suppressed
}
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: expire stale digest entries: %v", err)
return
}
if n > 0 {
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
}
}
// maybeDrainDigest speaks the pending digest bundle once the gate's
// suppression reasons have actually cleared — quiet hours over, back from
// away, out of the meeting. Draining while still suppressed would just be a
// second way to nag through quiet hours; the bundle waits for the same "is
// it allowed right now" condition a live nudge already waits for.
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
return
}
entries, err := t.store.PendingDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: pending digest entries: %v", err)
return
}
if len(entries) == 0 {
return
}
spoken := entries
extra := 0
if len(spoken) > maxDigestSpokenItems {
spoken = entries[:maxDigestSpokenItems]
extra = len(entries) - maxDigestSpokenItems
}
var b strings.Builder
maxSev := 0
for i, e := range spoken {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(e.Body)
if e.Severity > maxSev {
maxSev = e.Severity
}
}
if extra > 0 {
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
// The adjective declines with the noun, so the count picks the whole
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
// отложенных уведомлений.
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch digest bundle: %v", err)
return // leave entries pending; retried next tick
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
log.Printf("tick: drain digest entries: %v", err)
}
}
// routinesFromConfig maps the config's routine blocks to the engine type.
// Validation (cron parses, name/body present, severity defaulted) already ran
// in config.Load, so this is a pure field copy.
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
if len(rc) == 0 {
return nil
}
out := make([]routine.Routine, len(rc))
for i, r := range rc {
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
}
return out
}
// fireRoutines dispatches the routines whose cron schedule crossed since their
// last fire. Each is delivered as a nudge through the normal routing table
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
// can't collide with a care rule in the feedback autotuner. A dispatch failure
// logs and continues — one bad send must not skip the rest, and routine.Due has
// already advanced the last-fire time so a transient failure drops that fire
// rather than replaying it every tick (a routine is clockwork, not an alarm —
// no repeat-til-ack).
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
for _, r := range routine.Due(t.routines, t.routineLast, now) {
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
Severity: loop.Severity(r.Severity),
State: state,
},
Body: r.Body,
Summary: r.Body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
}
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
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: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
// because the item↔fact-key mapping is morning-routine-specific, not a rule
// concern — pulling it into the shared gather path would leak that mapping
// into loop's "rules declare wanted keys" contract. Bodies are literal
// operator text (item labels joined), not LLM-phrased, same rationale as
// cron routines: deterministic, can't hallucinate a checklist item.
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
if len(t.morningRoutines) == 0 {
return
}
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
body := morningNudgeBody(cand)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
Severity: loop.Severity(cand.Routine.Severity),
State: state,
},
Body: body,
Summary: body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
}
}
}
// morningNudgeBody words the one message a routine gets per day. Required
// items are what she says was not done; optional ones follow, worded as
// something he could still do rather than something he owes (Vikunja #473).
// Operator text, not phrased by the model, for the same reason it always was:
// a checklist item must not be invented.
func morningNudgeBody(cand morning.Candidate) string {
labels := func(items []morning.Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Label
}
return strings.Join(out, ", ")
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
body += fmt.Sprintf(". если будет время — %s", labels(opt))
}
return body
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
// disagree about what evidence exists.
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
keys := make(map[string]struct{})
for _, r := range t.morningRoutines {
for _, it := range r.Items {
keys[it.FactKey] = struct{}{}
}
}
facts := make(map[string]store.Fact, len(keys))
for k := range keys {
f, err := t.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err != store.ErrNoFact {
log.Printf("tick: morning: latest fact %s: %v", k, err)
}
}
return facts
}
// morningStatus is the read-only "what's missing" query the web UI (and
// eventually a voice query) calls. Pure recompute over the current facts —
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
// "state right now," not "should we nag."
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
if len(t.morningRoutines) == 0 {
return nil
}
facts := t.gatherMorningFacts(ctx)
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
for _, r := range t.morningRoutines {
st := morning.Evaluate(r, facts, now)
done := make(map[string]bool, len(st.Completed))
for _, it := range st.Completed {
done[it.Key] = true
}
items := make([]ipc.MorningRoutineItem, len(r.Items))
for i, it := range r.Items {
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
}
out = append(out, ipc.MorningRoutineStatus{
Name: r.Name,
Active: st.Active,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
Items: items,
})
}
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,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(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.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: r.Text(),
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
}
// 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
// rule:
@@ -1002,101 +385,3 @@ func (t *tickLoop) trace() *loop.TickTrace {
defer t.mu.Unlock()
return t.lastTrace
}
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, conversation, 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, conversation, text string) (string, error) {
if d.chatFn == nil {
return "", errors.New("mavend: chat not available")
}
return d.chatFn(ctx, conversation, 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) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
}
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 {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}
+111
View File
@@ -0,0 +1,111 @@
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
//
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
// for, and the loop-to-ipc conversions those answers need.
package main
import (
"context"
"errors"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
)
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, conversation, 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, conversation, text string) (string, error) {
if d.chatFn == nil {
return "", errors.New("mavend: chat not available")
}
return d.chatFn(ctx, conversation, 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) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
}
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 {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}
+238
View File
@@ -0,0 +1,238 @@
// mavend/tick_digest.go — the digest queue.
//
// Split out of tick.go, move-only (Vikunja #422). A nudge below the severity
// ceiling is held here instead of spoken, flushed as one batch on the window,
// and drained by hand when he asks. Everything about batching lives here.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
// shouldQueue — true when digest is enabled and the candidate's severity is
// at or below the configured ceiling.
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
return t.digestCfg != nil && t.digestCfg.Enabled &&
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
}
// queueNudge — phrases the candidate and appends it to the digest queue.
// Deduplicates by rule name: if the same rule is already queued, this is a
// no-op (the first fire within the window is the one that counts).
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
for _, q := range t.digestQ {
if q.Rule == cand.Rule.Name {
return // already queued
}
}
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
return
}
t.digestQ = append(t.digestQ, QueuedNudge{
Rule: cand.Rule.Name,
Severity: int(cand.Severity),
Body: pn.Body,
Key: cand.Rule.Name,
QueuedAt: now,
})
t.cachePhrase(pn)
}
// maybeFlush — flushes the digest queue if the window has elapsed since the
// first item or the queue reached MaxItems.
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
return
}
first := t.digestQ[0]
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
len(t.digestQ) >= t.digestCfg.MaxItems {
t.flushDigest(ctx, now, state)
}
}
// flushDigest — concatenates queued nudge bodies into a single digest
// notification and dispatches it. Clears the queue after a successful send.
// The digest uses the max severity among queued items for routing.
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
if len(t.digestQ) == 0 {
return
}
var b strings.Builder
maxSev := 0
for i, q := range t.digestQ {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(q.Body)
if q.Severity > maxSev {
maxSev = q.Severity
}
}
body := b.String()
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
cand := loop.Candidate{
Rule: loop.Rule{
Name: "digest",
Severity: loop.Severity(maxSev),
},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{
Candidate: cand,
Body: body,
Summary: summary,
}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
// keep the queue — the next tick's maybeFlush re-attempts.
log.Printf("tick: dispatch digest: %v", err)
return
}
t.digestQ = nil
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// 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
// digest entry that outlives one full day is describing a day that's already
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
// news. Bounding at one day also means a digest can never silently span a
// weekend of quiet hours into an unbounded backlog.
const digestExpiry = 24 * time.Hour
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
// dropped" cannot regress into "she dumps twelve things on me the moment I
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
// Anything beyond the cap is still marked drained (it did get its moment;
// the cap limits WORDS, not whether it counted) and folded into a trailing
// count instead of being spoken in full.
const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
}
for _, tr := range trace.RuleTraces {
if !tr.PredicateResult || tr.GateResult {
continue // didn't want to fire, or wasn't suppressed
}
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: expire stale digest entries: %v", err)
return
}
if n > 0 {
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
}
}
// maybeDrainDigest speaks the pending digest bundle once the gate's
// suppression reasons have actually cleared — quiet hours over, back from
// away, out of the meeting. Draining while still suppressed would just be a
// second way to nag through quiet hours; the bundle waits for the same "is
// it allowed right now" condition a live nudge already waits for.
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
return
}
entries, err := t.store.PendingDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: pending digest entries: %v", err)
return
}
if len(entries) == 0 {
return
}
spoken := entries
extra := 0
if len(spoken) > maxDigestSpokenItems {
spoken = entries[:maxDigestSpokenItems]
extra = len(entries) - maxDigestSpokenItems
}
var b strings.Builder
maxSev := 0
for i, e := range spoken {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(e.Body)
if e.Severity > maxSev {
maxSev = e.Severity
}
}
if extra > 0 {
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
// The adjective declines with the noun, so the count picks the whole
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
// отложенных уведомлений.
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch digest bundle: %v", err)
return // leave entries pending; retried next tick
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
log.Printf("tick: drain digest entries: %v", err)
}
}
+197
View File
@@ -0,0 +1,197 @@
// mavend/tick_morning.go — the morning checklist and the day plan.
//
// Split out of tick.go, move-only (Vikunja #422). One window per configured
// routine, nudging once at the end for what is still open, plus the read
// surfaces /morning renders.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/store"
)
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
// because the item↔fact-key mapping is morning-routine-specific, not a rule
// concern — pulling it into the shared gather path would leak that mapping
// into loop's "rules declare wanted keys" contract. Bodies are literal
// operator text (item labels joined), not LLM-phrased, same rationale as
// cron routines: deterministic, can't hallucinate a checklist item.
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
if len(t.morningRoutines) == 0 {
return
}
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
body := morningNudgeBody(cand)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
Severity: loop.Severity(cand.Routine.Severity),
State: state,
},
Body: body,
Summary: body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
}
}
}
// morningNudgeBody words the one message a routine gets per day. Required
// items are what she says was not done; optional ones follow, worded as
// something he could still do rather than something he owes (Vikunja #473).
// Operator text, not phrased by the model, for the same reason it always was:
// a checklist item must not be invented.
func morningNudgeBody(cand morning.Candidate) string {
labels := func(items []morning.Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Label
}
return strings.Join(out, ", ")
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
body += fmt.Sprintf(". если будет время — %s", labels(opt))
}
return body
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
// disagree about what evidence exists.
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
keys := make(map[string]struct{})
for _, r := range t.morningRoutines {
for _, it := range r.Items {
keys[it.FactKey] = struct{}{}
}
}
facts := make(map[string]store.Fact, len(keys))
for k := range keys {
f, err := t.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err != store.ErrNoFact {
log.Printf("tick: morning: latest fact %s: %v", k, err)
}
}
return facts
}
// morningStatus is the read-only "what's missing" query the web UI (and
// eventually a voice query) calls. Pure recompute over the current facts —
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
// "state right now," not "should we nag."
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
if len(t.morningRoutines) == 0 {
return nil
}
facts := t.gatherMorningFacts(ctx)
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
for _, r := range t.morningRoutines {
st := morning.Evaluate(r, facts, now)
done := make(map[string]bool, len(st.Completed))
for _, it := range st.Completed {
done[it.Key] = true
}
items := make([]ipc.MorningRoutineItem, len(r.Items))
for i, it := range r.Items {
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
}
out = append(out, ipc.MorningRoutineStatus{
Name: r.Name,
Active: st.Active,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
Items: items,
})
}
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,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(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.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: r.Text(),
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
}
+234
View File
@@ -0,0 +1,234 @@
// mavend/tick_routines.go — pattern detection and the routines that fire.
//
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
// routines he accepted on /routines, and the tick-side detector that proposes
// new ones.
package main
import (
"context"
"log"
"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/routine"
)
// detectPatterns runs the pattern detector proactively over every
// action+object pair that has ever produced an event, independent of
// whichever fact write (or channel) last touched it (Vikunja #43). This is
// what makes pattern inference actually proactive: it fires on the daemon's
// own schedule reading accumulated history, not only as a side effect of a
// live voice turn.
//
// Idempotence and noise are handled by the store, not here — this function
// is safe to call every tick:
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
// check plus proposed_routines' UNIQUE(action, object) constraint (with
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
// already has a row — in ANY status — produces no second row and no log
// spam beyond the one line at genuine creation.
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
// status in place; the row is never deleted. So the same Lookup check
// that stops a duplicate "proposed" also stops a "dismissed" one from
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// 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)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
continue
}
if r == nil {
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)
// 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.
// Nor are they queued: the row now exists, so no later tick re-detects
// them and they are never announced. See announceProposal.
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.
//
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
// newly created row, so a pair gets exactly one chance to be spoken: the tick
// that first proposes it. Combined with one announcement per tick, the first
// tick over a populated history announces one pattern and permanently silences
// every other pattern found in the same pass. That is the intent, not an
// oversight — an inferred routine is not worth a second attempt at his
// attention, and /routines lists all of them. So the cooldown does not drain a
// backlog. It only spaces announcements of genuinely new pairs discovered on
// later ticks. If it should ever become "one per day until each is mentioned",
// that needs a queue rather than this counter.
//
// Cooldown gets its default here as well as in applyDefaults. That is
// deliberate: a tickLoop assembled directly in a test never goes through Load,
// and an unspaced announcer is not what those tests mean to exercise.
//
// 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
}
// routinesFromConfig maps the config's routine blocks to the engine type.
// Validation (cron parses, name/body present, severity defaulted) already ran
// in config.Load, so this is a pure field copy.
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
if len(rc) == 0 {
return nil
}
out := make([]routine.Routine, len(rc))
for i, r := range rc {
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
}
return out
}
// fireRoutines dispatches the routines whose cron schedule crossed since their
// last fire. Each is delivered as a nudge through the normal routing table
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
// can't collide with a care rule in the feedback autotuner. A dispatch failure
// logs and continues — one bad send must not skip the rest, and routine.Due has
// already advanced the last-fire time so a transient failure drops that fire
// rather than replaying it every tick (a routine is clockwork, not an alarm —
// no repeat-til-ack).
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
for _, r := range routine.Due(t.routines, t.routineLast, now) {
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
Severity: loop.Severity(r.Severity),
State: state,
},
Body: r.Body,
Summary: r.Body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
}
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
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: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
+2 -2
View File
@@ -212,8 +212,8 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
// one held-out case that lands there is "вайфай опять отвалился", which reads as
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
h.topics.load(ctx, h.embedder)
label, margin, ok := h.topics.best(t.vec)
h.recall.topics.load(ctx, h.recall.embedder)
label, margin, ok := h.recall.topics.best(t.vec)
if !ok {
return floor(t.dec.Utterance)
}
+4 -4
View File
@@ -88,10 +88,10 @@ func TestONNXTopics(t *testing.T) {
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
}
h := &reactiveHandler{embedder: emb}
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
ctx := context.Background()
h.topics.load(ctx, emb)
if !h.topics.loaded {
h.recall.topics.load(ctx, emb)
if !h.recall.topics.loaded {
t.Fatal("topic seeds did not load")
}
right := 0
@@ -100,7 +100,7 @@ func TestONNXTopics(t *testing.T) {
if err != nil {
t.Fatalf("embed %q: %v", tc.utterance, err)
}
label, margin, ok := h.topics.best(vec)
label, margin, ok := h.recall.topics.best(vec)
if !ok {
t.Fatalf("best(%q) not ok", tc.utterance)
}
+10 -24
View File
@@ -56,7 +56,6 @@ import (
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -73,19 +72,16 @@ import (
// safe (the wired stt/tts/router/api all are); called from per-conn
// goroutines on the voice.Server.
type reactiveHandler struct {
stt stt.Transcriber
tts tts.Synthesizer
router *router.Router
embedder router.Embedder // reused for note write/query (same model as the classifier)
// topics — the embedded seed sets behind the weather, house and LAN
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
// usable, loads on first query, and with no embedder it never loads and
// each source falls back to its own keyword test.
topics topicIndex
// boundary — the embedded seed sets behind the personal boundary
// (personalboundary.go). Zero value is usable and loads on first query;
// with no embedder it never loads and the boundary uses personalMarkers.
boundary personalBoundary
stt stt.Transcriber
tts tts.Synthesizer
router *router.Router
// recall — the note-and-fact recall subsystem: the embedder, the vector
// store it writes into, the personal boundary, and the two numbers that
// gate an answer. Grouped rather than spread across the handler because a
// handler that recalls needs all five and a handler that does not needs
// none of them (Vikunja #433, docs/handler-wiring.md).
recall recallWiring
// api — the CoreAPI the handler reads and writes through. Wired with the
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
// upgradeAPI.
@@ -129,18 +125,8 @@ type reactiveHandler struct {
weatherProvider weather.Provider
weatherLocation string // default location for weather queries
memStore memory.Store
dataStore *store.Store // direct store access for event extraction + pattern detection
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not
// load-bearing math (same posture as the presence thresholds). Set by
// wireVoice from VoiceConfig; default 0.55.
queryMinScore float64
// queryMinMargin — the second half of that gate: how far the top hit must
// beat the runner-up. 0 ⇒ margin off.
queryMinMargin float64
// timeParser — used as a fallback for stage-0 reminder grammar matches
// (where the extractor didn't run). Shared with the router's extractor.
// The production dateparser will replace StubDateTimeParser here too.
+21 -19
View File
@@ -262,19 +262,18 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
h := &reactiveHandler{
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
matcher: matcher,
replier: replier,
phraser: phr,
now: time.Now,
feedsOn: cfg.Feeds != nil,
home: w.home,
netscan: w.netscan,
stt: transcriber,
tts: synthesizer,
router: rtr,
api: coreAPI,
tools: exec,
matcher: matcher,
replier: replier,
phraser: phr,
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),
@@ -283,18 +282,21 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
search: wireSearch(cfg),
// nil unless a `kiwix` block names a server. Same swap-aware client the
// router and replier use, so the rewriter follows a model swap.
kiwix: wireKiwix(cfg, llmClient),
weatherProvider: weatherProvider,
weatherLocation: weatherLocation,
memStore: memStore,
kiwix: wireKiwix(cfg, llmClient),
weatherProvider: weatherProvider,
weatherLocation: weatherLocation,
recall: recallWiring{
embedder: emb,
memStore: memStore,
minScore: cfg.Voice.QueryMinScore,
minMargin: cfg.Voice.QueryMinMargin,
},
dataStore: dataStore,
dialogueSessions: dialogueSessions,
clarifyStore: clarifyStore,
// 0 here (unset config) ⇒ the dialogue default.
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
queryMinScore: cfg.Voice.QueryMinScore,
queryMinMargin: cfg.Voice.QueryMinMargin,
timeParser: timeParser,
ecosystem: eco,
}
+32 -7
View File
@@ -139,13 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
}
func TestHandleAmbientAuth(t *testing.T) {
// posted_at carries the local offset, and the clock reading inside the text
// sits twenty minutes after it. A bare "Z" here would make the reading
// stale by the test machine's own offset and the handler would answer 202
// no-meeting, which says nothing about the auth this test is checking
// (Vikunja #482).
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339))
// "завтра" so the meeting is in the future in every zone: the clock in the
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
// is already stale on a box east of UTC and stores nothing (Vikunja #482).
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))
@@ -251,3 +248,31 @@ func TestHandleAmbientBadInput(t *testing.T) {
}
})
}
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
// used to be stored at 18:30, and the size of the error was the deploy's offset.
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
// No zone juggling: the assertion is that the stored wall clock is the one
// he read, whatever zone the box is in. That is false under the old code
// on every box except a UTC one.
core := &ambientCore{}
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон завтра в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
})
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
}
if !strings.HasSuffix(resp.Key, "_Standup") {
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
}
if len(core.writeLog) != 1 {
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
}
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
}
}
+24
View File
@@ -0,0 +1,24 @@
{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{template "shellBottom"}}
+67 -245
View File
@@ -76,6 +76,23 @@ var morningHTML string
//go:embed events.html
var eventsHTML string
//go:embed tools.html
var toolsHTML string
//go:embed routines.html
var routinesHTML string
//go:embed chat.html
var chatPageHTML string
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
// with the sidebar assembled by a strings.Builder, which is the one piece of
// markup that was still concatenated in Go.
//
//go:embed shell.html
var shellHTML string
// ── Ethos Workstation Shell ──
//
// Two template pieces that wrap every page:
@@ -136,74 +153,40 @@ var sidebarSections = []struct {
},
}
func sidebarActive(url, key string, activeKey string) string {
if key == activeKey {
return `class="active"`
}
return ""
}
// sidebarHTML renders the sidebar navigation given the active page key.
func sidebarHTML(active string) template.HTML {
var b strings.Builder
for _, sec := range sidebarSections {
b.WriteString(`<div class=sidebar-section>`)
b.WriteString(`<div class=sidebar-label>`)
b.WriteString(sec.Label)
b.WriteString(`</div>`)
for _, p := range sec.Pages {
cls := ""
if p.Key == active {
cls = ` class="active"`
}
b.WriteString(`<a href="`)
b.WriteString(p.URL)
b.WriteString(`"`)
b.WriteString(cls)
b.WriteString(`><span class=icon>`)
b.WriteString(pageIcon(p.Key))
b.WriteString(`</span><span>`)
b.WriteString(p.Label)
b.WriteString(`</span></a>`)
}
b.WriteString(`</div>`)
}
return template.HTML(b.String())
}
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
// sidebar template wraps it in the <use> reference.
func pageIcon(key string) string {
switch key {
case "dash":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
return "i-grid"
case "history":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
return "i-clock"
case "trace":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
return "i-wave"
case "notifications":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
return "i-bell"
case "tasks":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
return "i-grid"
case "reminders":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
return "i-calendar"
case "routines":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
return "i-repeat"
case "morning":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
return "i-calendar"
case "chat":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
return "i-message"
case "voice":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
return "i-mic"
case "ecosystem":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
return "i-grid"
case "tools":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
return "i-settings"
case "models":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
return "i-wave"
case "passkey":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
return "i-lock"
default:
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
return "i-search"
}
}
@@ -243,63 +226,12 @@ func pageTitle(key string) string {
}
}
// shellTopHTML opens the shell and renders the top bar + sidebar.
// Usage: {{template "shellTop" "<page-key>"}}
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{sidebarHTML .}}
</aside>
<main class=content>
{{end}}`
// shellBottomHTML closes the content area, inspector, and shell.
// Usage: {{template "shellBottom"}}
const shellBottomHTML = `{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}`
// shellFuncs returns the FuncMap shared by every server-rendered page template.
func shellFuncs() template.FuncMap {
return template.FuncMap{
"pageTitle": pageTitle,
"sidebarHTML": sidebarHTML,
"pageTitle": pageTitle,
"pageIcon": pageIcon,
"sidebarSections": func() any { return sidebarSections },
"ago": func(t time.Time) string {
if t.IsZero() {
return "never"
@@ -314,21 +246,21 @@ func shellFuncs() template.FuncMap {
// a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
// human surface is here (they ship no web UI of their own).
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
// 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 eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
// morningTmpl — read-only view of today's checklist state per configured
// morning routine (internal/morning). Same shape as trace.html: a plain
// server-rendered page, refreshed on reload — no live-update loop, since
// checklist state changes on the scale of minutes, not seconds.
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -750,109 +682,23 @@ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMa
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
return m
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
}()).Parse(shellHTML + toolsHTML))
const toolsHTML = `{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<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. 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>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> the same shape Hexis speaks and it is derived from the row, so it always describes what the command actually does.</p>
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</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"}}`
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
// routinesHTML — proposed routine review surface. One row per thing maven
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
// routinesTmpl — the proposed-routine review surface. One row per thing maven
// noticed, in her words, with at most two actions: accept or dismiss.
const routinesHTML = `{{template "shellTop" "routines"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{template "shellBottom"}}`
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + 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(shellHTML + routinesHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
m := shellFuncs()
@@ -864,7 +710,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
}
m["join"] = strings.Join
return m
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
}()).Parse(shellHTML + traceHTML))
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
@@ -1222,9 +1068,10 @@ func fmtTaskDate(t *time.Time) string {
return t.Local().Format("02 Jan")
}
// routineRow is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it.
type routineRow struct {
// routineView is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it. A view model, not a database row — the template
// never formats an interval or a timestamp itself.
type routineView struct {
ID int64
Phrase string
Noticed string
@@ -1286,23 +1133,23 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := routinesTmpl.Execute(w, struct {
Msg string
Proposed []routineRow
}{msg, routineRows(proposed)}); err != nil {
Proposed []routineView
}{msg, toRoutineViews(proposed)}); err != nil {
log.Printf("routines render: %v", err)
}
}
// routineRows turns the wire rows into display rows. The phrase comes from
// toRoutineViews turns the wire rows into view models. The phrase comes from
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
out := make([]routineRow, 0, len(rs))
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
out := make([]routineView, 0, len(rs))
for _, r := range rs {
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
noticed := "just now"
if r.CreatedTs > 0 {
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
}
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
}
return out
}
@@ -1703,32 +1550,7 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
// and the handler redirects back to /chat with the response.
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
const chatPageHTML = `{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{template "shellBottom"}}`
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
// handleChatPage renders the chat conversation page.
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+1 -1
View File
@@ -31,7 +31,7 @@ type modelController interface {
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
}
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
+24
View File
@@ -0,0 +1,24 @@
{{template "shellTop" "routines"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{template "shellBottom"}}
+49
View File
@@ -0,0 +1,49 @@
{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{template "sidebar" .}}
</aside>
<main class=content>
{{end}}{{/* sidebar — the section list, with the active page's link marked. The dot
argument is the page key the page passed to shellTop. */}}{{define "sidebar"}}{{$active := .}}{{range sidebarSections}}<div class=sidebar-section><div class=sidebar-label>{{.Label}}</div>
{{- range .Pages}}<a href="{{.URL}}"{{if eq .Key $active}} class="active"{{end}}><span class=icon><svg class=icon width="14" height="14"><use href="/ethos-icons.svg#{{pageIcon .Key}}"/></svg></span><span>{{.Label}}</span></a>
{{- end}}</div>
{{end}}{{end}}{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<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. 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>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.</p>
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</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"}}
+100
View File
@@ -0,0 +1,100 @@
# Ecosystem reach, measured — 2026-08-04
Vikunja #405. Measured at `86dcd99` on the 30-case held-out fixture
`internal/router/eval/ru_ecosystem_v1.json`, scored by `make eval-reach`.
**Praxis reach is zero. Not low — zero, on all twelve cases, under both embedders.**
## What was measured
Reach is where an utterance *arrives*, not what it achieves. The derivation is in
`internal/router/eval/reach.go` and mirrors `actionAct` in `cmd/mavend/actions_act.go`:
- **Praxis** needs `IntentAct` and a fn slot whose value is one of the aliases in
`praxisCapabilities`.
- **Hexis** needs `IntentAct` and non-empty `Slots.Text`. It also fires from
`hexisBeforeClarify`, so a clarified act with text reaches Hexis before the clarify
question is ever asked.
- Everything else stays inside Maven.
The fixture holds 10 Hexis cases, 12 Praxis cases and 8 negatives. The negatives carry
the expensive direction: an utterance that reaches a mutating path it had no business
reaching is worse than one that never arrives, because he never gets asked about it.
## The numbers
| Path | Reached the right place | Missed | Overreach | Wrong service | p50 |
|---|---|---|---|---|---|
| classifier + hash embedder | 7/30 (23.3%) | 22 | 1 | 0 | 14.8µs |
| classifier + e5-small (deployed) | 16/30 (53.3%) | 11 | 1 | 2 | 23.8ms |
Split by target, on the deployed embedder:
| Want | Passed |
|---|---|
| hexis | 9/10 |
| none | 7/8 |
| **praxis** | **0/12** |
## The finding
Hexis reach is fine. Nine of ten act-shaped home and homelab utterances land on the
entity resolver, which is what the gate was built to do: it only needs the intent and
some text, and both the act grammar and the embedder produce those.
Praxis reach is structurally impossible from free Russian, and the fixture makes that
visible for the first time. `handlePraxisAct` dispatches on exact equality between
`Slots.Fn` and a capability alias. The fn slot is filled by `DefaultActMatcher`, whose
allowlist is the deployment's enabled tool names — `перезапусти`, `выключи`, and so on.
No Praxis alias is in that list, so no utterance can ever put one in the slot. The
Russian aliases in `praxisCapabilities` (`готово`, `принято`, `игнорировать`) read as if
they match speech and they do not: they are compared against a fn slot, never against
the utterance.
That means the whole lifecycle half of the Praxis contract — acknowledge, resolve,
ignore, pin — has no voice path at all. Attention and changes have none either.
Three of the twelve got as far as the wrong place, which is the same defect seen from the
other side: `"готово, закрывай"` and `"как дела у праксиса"` route to act with text, so
they fall past the Praxis check into the Hexis one and go to entity resolution instead.
## The live services
All three are up and answer. With `no_proxy` set for the loopback (the host's `http_proxy`
answers 503 for 127.0.0.1, which is the same trap `llmrouter_test.go` documents):
```
127.0.0.1:8989/health -> {"status":"ok"} praxis
127.0.0.1:9740/health -> {"status":"ok"} nexus
127.0.0.1:9741/health -> {"status":"ok"} hexis
127.0.0.1:8989/api/v1/tools/attention?limit=3 -> []
127.0.0.1:8989/api/v1/tools/changes?limit=3 -> []
```
So the boundary is not the problem, and an end-to-end run today would add nothing: both
Praxis feeds are empty, so even a perfect reach score would produce "ничего не требует
внимания". The gap is entirely on Maven's side of the wire.
## Not measured
**The resident model.** The LLM router is the deployed default, and these numbers are the
classifier only. `mavend` spawns its llama-server on a container-local port
(127.0.0.1:40063 inside `maven-mavend-1`), unreachable from the host, and the workstation
at 192.168.1.105:8080 was down. The classifier is the failure floor and it is what always
answers, so the floor is worth knowing on its own — but the LLM router could fill the fn
slot with a literal `list_attention`, since the grammar lets it emit any string. Whether
it does is the open question, and the fixture is ready for it.
## What this argues for
Not a new intent. The seven are frozen by prompt parity with the training workspace.
The cheap fix is stage 0: a grammar per Praxis capability that sets `Slots.Fn` to the
canonical arm name, the same trick `AgendaQueryGrammars` used to take agenda questions off
the model. It costs one regex per capability on every turn and it is deterministic, which
for a lifecycle verb is the right trade — "отметь это как сделанное" should never be a
similarity guess.
The second fix is smaller and separate: `entity_attention` aliases to grammar names only,
so scoped attention ("что там с нексусом") needs a grammar before it can be reached at
all.
+88
View File
@@ -0,0 +1,88 @@
# Note recall after the e5-small swap — 04-08-2026
Closes Vikunja #371, which asked for the swap and for this re-measurement. The embedder is no
longer paraphrase-multilingual-MiniLM-L12-v2. It is **multilingual-e5-small**, quantized, with the
`query:` / `passage:` prefixes it was trained with (`internal/router/onnxembedder.go`,
`EmbedQuery` / `EmbedPassage`). `deploy/mavend.json` loads
`models/embedder/multilingual-e5-small/model_quantized.onnx`, which is the same file `make
download-embedder` fetches and the same file this run measured.
- Fixture + scorer: `internal/memory/recalleval/` — 32 cases now, not 30
- Reproduce: `make eval-recall`
- Commit: `b6abb19`
- Gate as deployed: `query_min_score` 0.55, `query_min_margin` 0.008
The fixture grew since 31-07, so the case counts are not comparable row for row. The percentages
are.
## Results
| | 31-07 MiniLM (onnx) | 04-08 e5-small (onnx) |
|---|---|---|
| **recall@1** | 60.0% (15/25) | **70.4% (19/27)** |
| recall@3 | 80.0% (20/25) | **85.2% (23/27)** |
| **answered after the gate** | 48.0% (12/25) | **63.0% (17/27)** |
| **false recall** | 1/5 (20%) | **0/5** |
| wrong note on top / tie on top | 10 / 0 | 8 / 0 |
| ranked first, then silenced by the gate | 3 | 2 |
| `hard` cases passed | 2/11 | 5/12 |
| RU / EN passed | 13/24 / 3/6 | 18/26 / 4/6 |
| latency p50 / p95 / max | 59ms / 148ms / 194ms | **23ms / 41ms / 62ms** |
The hash ratchet CI runs is unchanged in kind and still answers nothing after the gate: recall@1
37.0%, recall@3 74.1%, 0/27 answered, 0/5 false. It is lexical and exists so CI has a deterministic
floor. Never compare a hash number to an ONNX one.
## Findings
### 1. The swap paid on every axis at once, including latency
Ten points of recall@1, fifteen points of *answered*, the one false recall gone, and it is 2.5×
faster because the quantized e5-small is 118MB against the 470MB fp32 file the old config loaded.
Finding 3 of the 31-07 eval predicted the recall half and said nothing about speed; the speed came
from fixing the second half of that finding, which was that the deployed path loaded a different
file than the download target.
The concrete case that eval named is fixed. "из-за чего кончилось место" no longer returns the
guitar-chords filler note. It now returns a homelab note, `n2` at 0.884, and the wanted note is
still not in the top 3 — so the query moved from absurd to merely wrong. That is the shape of what
is left.
### 2. The score distributions still overlap. The margin is what separates them
This is the part of #371's premise that did not come true. Right-note-first top-1 scores run
0.791 / 0.857 / 0.890 (min / median / max). Must-stay-silent top-1 scores run 0.795 / 0.815 /
0.835. The silent cases sit *inside* the answering range, so no value of `query_min_score` keeps
every real recall and rejects every false one — the same verdict as 31-07, at a higher and tighter
band of scores.
What separates them is the second-place gap. Margin top1-top2 for a right first hit: median 0.024.
For a must-be-silent case: median 0.002, max 0.019. A false recall is a note that beats its
neighbours by nothing, because nothing in the store is about the question. The sweep:
| margin | answered | false recall |
|---|---|---|
| 0.000 | 18/27 (67%) | 3/5 |
| 0.005 | 17/27 (63%) | 1/5 |
| **0.008 (deployed)** | **17/27 (63%)** | **0/5** |
| 0.010 | 15/27 (56%) | 0/5 |
| 0.015 | 12/27 (44%) | 0/5 |
0.008 is the knee: it is the smallest margin that silences all five, and the next step up costs two
real answers for nothing. The score gate contributes almost nothing on its own — every value from
0.00 to 0.70 answers the same 18 and admits the same 3 — so `query_min_score` is now close to inert
and the margin is the live dial. Leave both where they are; #412 is where a further sweep belongs.
### 3. What is left is a retrieval problem, not a gate problem
Eight cases put the wrong note on top, and the failures cluster: `hard` 5/12, `preference` 5/9,
`homelab` 8/13. Four of the eight have the right note in the top 3, so a reranker would collect
them; the other four do not, so nothing downstream can. Two more rank first and are silenced by the
margin — `en-hard-024` at 0.826 with margin 0.023, and `ru-home-026` at 0.846 with margin 0.001,
which is a genuine near-tie against a second note that is also plausible.
Preference queries are the weakest class in a way that is not about the model. "когда запускать
резервное копирование" and "как мне присылать оповещения" both return a fact, not the note that
states the preference. Facts and notes are searched in one pass since #373, so a confidently-scored
fact wins a question that a note answers better. That is a ranking policy question and it belongs
in its own task, not in a threshold.
+62
View File
@@ -0,0 +1,62 @@
# How reactiveHandler is wired
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
The decision Vikunja #433 asked for, and the rule that follows from it.
## The decision
**Group the fields into cohesive wiring structs. No container, no generator, no
framework.** `reactiveHandler` (`cmd/mavend/voice.go`) stays the one type the voice
server talks to. What changes is that a capability arrives as one named group, not as
four more loose fields on a struct that already had thirty.
The pattern was already in the file before this was written down: `searchWiring`,
`kiwixWiring`, `homeWiring`, `netWiring` and `ecosystemWiring` are all this shape, each
`nil` when the capability is off. #433 makes it the rule rather than a habit, and adds
the case the habit had missed — a group that is not a capability toggle.
## The worked example
`recallWiring` (`cmd/mavend/recall.go`) holds the five things the recall path needs:
the embedder, the vector store, the personal boundary, and the score and margin that
gate an answer. They used to sit in three separate places on the handler with the two
gate numbers a hundred lines away from the store they gate.
Its zero value means "no recall", which is why it is a value and not a pointer. The
`*Wiring` types that model an optional capability stay pointers, because `nil` is how
"not configured" is spelled and a zero-valued search client would be a client pointed at
nothing.
## Why not the alternatives
**Narrow consumer-side interfaces at each handler** is the more idiomatic Go answer and
it is not rejected, only deferred. It is the right move at the point a handler is pulled
into its own package, because that is when the import direction starts to matter. Doing
it first would mean writing an interface per handler against a struct nobody can pass
anywhere, which is churn bought against a package split that has not happened.
**A container or a wire-style generator** is rejected outright. This is one binary with
one composition root (`wireVoice` in `cmd/mavend/voicewire.go`). Generated wiring would
add a build step and a layer of indirection to solve a problem that is currently one
composite literal long, and it would make the "is this capability configured" question
harder to answer by reading, which is the question this file is mostly about.
## What this unlocks
The reason `cmd/mavend/` cannot split into `mavend/actions/` today is that every action
handler is a method on a struct with thirty unexported fields: moving handlers to a
subdirectory means exporting all of them or inventing an interface to pass through. That
was the answer given on the tick.go and voice.go splits, and it is still true. Grouping
is the step that makes it false later — a handler that takes `recallWiring` and nothing
else can move without the other twenty-five fields following it.
## The rule
**A wiring change does not ride a feature PR.** The voice.go and tick.go splits were
safe to merge because the moved code diffed identical, line for line. A regrouping that
touches the confirm gate or the act allowlist is its own change, reviewed on its own, or
it is not reviewable at all.
New capability, new group. A capability that adds four fields to `reactiveHandler`
instead of one struct is the thing this decision exists to stop.
+8 -3
View File
@@ -103,9 +103,14 @@ query path.
```
`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.
rather than as a capability that fails every turn.
That gate was written, documented, and then never called. It is called now, and the behaviour it
describes changed with it. `enabled` with no `model_path` used to attach all three methods and log
that enrolment was on. Today `newSpeakerWiring` returns nil, so the methods are absent, and the log
says why: there is nothing to embed with, so enrol, list and forget would all be no-ops. That is
the one config shape where the operator most needs to be told otherwise, and it was the shape that
lied.
## Still open
+119
View File
@@ -0,0 +1,119 @@
# Plan: The work board surface
**The decision Vikunja #431 asked for. Written 04-08-2026.**
**Verdict: build it, in a smaller shape than the task imagined.** The board is worth
moving out of the file. The intake form belongs on the `/tasks` page, not on the voice
path. The argument is not built, now or later.
## Why it is worth building
The reason is the one the task gives and it holds: company rules forbid pointing Claude
at work repos, and Maven is the one assistant on the box that work material may reach.
No telemetry, no cloud model, no third-party account. That is not a preference here, it
is the whole permission.
The build is also small, because most of it landed already:
| Piece | Where | State |
|---|---|---|
| task rows, dedupe, status lifecycle | `internal/store/migrations.go:149` and migration #15 | done |
| capture from speech, urgency stripped | `router.ParseTaskCapture`, `router.TaskCaptureGrammar` | done |
| recite the list on request | `router.IsTaskListQuery` | done |
| a page to read and change the board | `/tasks` in `cmd/mavweb` | done |
| counting a shape without judging it | `internal/memory/behavior.go` | done, as precedent |
| a proposal he reads when he chooses | `/routines`, the proposed-routine queue | done, as precedent |
`tasks` already carries `status` (candidate, open, done, dropped), `due_ts`, `weight`,
`source`, `evidence`, `ext_id` and `resolved_by`. Three things are missing. It has no
definition of done and no blocked-on. There is no way to edit a task after capture:
`SetTaskStatus` moves the status and nothing writes text, date or weight again. And
there is no grouping he controls, because order is computed by `tasks.Rank` alone.
## Where the form lives, and why not voice
The task asks the form to refuse a capture with no definition of done. That refusal
cannot live on the voice path, for two reasons.
**The parked-state mechanism is binary.** `resolveConfirm` in `cmd/mavend/confirm.go`
answers yes or no against a slot with a 90-second life. Filling four fields over four
turns is slot filling, which is a different mechanism and a new one. Nothing in the
daemon does it today.
**The definition of done is the worst possible field to dictate.** It is the one string
that has to be exact, because its whole purpose is to be unarguable later. Whisper
transcribing a sentence of Russian work vocabulary is where exactness goes to die, and
the capture path already had to strip a question mark that whisper invented.
So: voice captures a line and recites the list. The page is where a line becomes an
item with a definition of done, a blocked-on and a date. A captured line lands as
`candidate` and stays there until it is filled in, which is what `candidate` was for.
The refusal the task wants survives, moved: the page will not promote a candidate to
`open` without a definition of done, the same way `ParseTaskCapture` will not file a
marker with nothing after it. And the field must close on either outcome, so "it already
works" counts as complete. A definition of done that only one result satisfies is a wish.
## Does the stage-0 trick stretch
The task asks this before any shape is committed to. It was checked. The answer is
partly.
`TaskCaptureGrammar` matches every utterance and lets `ParseTaskCapture` decide inside
`Build`, keeping the intent at `note` and leaving the frozen seven-intent contract alone.
That trick stretches to **recite** and to **status change**: both are a marker plus a
referent, both are a lookup, and a status change is a small closed verb set over a list
he can see. It does not stretch to **intake**, because intake is not one utterance, and
it does not need to, because intake moved to the page.
One cost to name. Each such grammar matches everything and runs its parser on every
turn, ahead of the resident model. Two more of them is fine. A dozen would make stage 0
a second router with no evaluation behind it, and at that point the frozen enum is the
smaller problem.
## What is not built: the argument
Not now and not later behind a flag. The task is right about why, and
`internal/memory/behavior.go` already argued it for habits: a 1.7B asked whether evidence
proves anything will agree fluently and launder a guess into a decision. A wrong claim
about his work, stated confidently, is the most expensive kind of wrong Maven can be.
The line is the same line behaviour memory drew. She may **count**:
- no state change in eleven days
- blocked on a person, with no date
- four of nine waiting on two people
Those are queries over rows. She may not assess whether a build proves anything, whether
a blocker is real, or whether a task should be dropped.
## Persona
A progress tracker is a nag by default, and "not a nag" is hard. The line is already
drawn twice in the codebase and it is drawn the same way here:
- A date he set becomes a reminder. He set it, so it is not her raising it.
- A stall becomes a proposal he reads when he chooses, on a page, like `/routines`.
- Ask what is on the board and she recites. She never opens with it.
The day plan is the place to watch. `tickLoop.dayPlan` reads calendar events, pending
reminders and checklist facts, and it does not read tasks. Adding the board to the
morning nudge is exactly the move that turns this into a nag, so the board goes on the
page and into the answer when asked, and not into the unprompted morning message.
## The build, as tasks
1. Two columns on `tasks`: definition of done, and blocked-on. Blocked-on resolves
through Nexus like any other person reference, because identity lives in Nexus.
2. An edit path. Today a task is write-once except for its status, so the form has
nothing to save into.
3. `/tasks` grows the form: promote candidate to open only with a definition of done,
set a date, set blocked-on. A date set here writes a reminder.
4. A status-change grammar at stage 0, following `TaskCaptureGrammar`.
5. Counted stall shapes on `/tasks`, phrased as counts. No assessment.
Note for whoever picks up 3: `/tasks` accepts its POST without the step-up gate, while
`/routines` and `/tools` require a passkey. That was deliberate for capture. Adding an
edit path is the moment to re-argue it, not to inherit it silently.
Each is separable and each is worth stopping after.
+87
View File
@@ -0,0 +1,87 @@
# Plan: What the ambient calendar path should be
**The decision Vikunja #432 asked for. Written 04-08-2026.**
**Verdict: keep the endpoint, change the contract.** The relay app sends structured
fields, not a notification blob. The free-text parser stays as the degraded path, because
there is a real case where the phone cannot produce structure. Delete the endpoint only
if the answer to the one open question below is no.
## First, the task's premise is out of date
#432 states as confirmed that every ambient event lands on the day the notification was
posted, because there is no date parsing at all. That was true when the task was filed
and it is not true now.
`4e4c917` added `dayWords` and `dayOffset` (`internal/calendar/ambient.go:53`), so
"завтра в 15:00" now dates to tomorrow. The same commit added `ambientPastGrace`, which
refuses an event landing more than two hours before the notification, on the reasoning
that the day was inferred and a stale inference is wrong rather than late. `45a5e37`
(#482, this week) fixed a second dating bug the task did not know about: the wall clock
was resolved against the notification's own zone, so every ambient meeting on a non-UTC
box landed off by the deploy's UTC offset.
What is still missing is an explicit date. "5 августа в 15:00" and "12.08 15:00" carry no
day word, so they date to today and the past-grace check drops them. That is a smaller
defect than the one filed, and it fails safe rather than storing a wrong meeting.
The task's third question also has an answer, and the answer is yes. `internal/morning/plan.go:174`
prefixes an uncertain item with "похоже, " and `cmd/mavend/actions_query.go:334` carries
`Confidence < 1.0` into the calendar recital. Both readers hedge.
## The open question, and it is the owner's
**Can the phone read Android's calendar provider, or only the notification text?**
Everything follows from this and nothing in this repo can answer it.
A `NotificationListenerService` sees a title and a body. It cannot know a meeting's real
start, end or organiser, because those are not in the notification. So if the relay is
limited to the notification stream, free-text parsing on this side is not a choice, it is
the only thing available, and #432's suggestion that the phone send structured JSON
cannot be honoured.
If the app may instead read `CalendarContract`, it has the actual event rows, and the
whole parser stops being necessary. That is the better shape by a wide margin: a real
start and end, a real title, an explicit date, no clock-reading heuristic and no
past-grace guard, because nothing is inferred.
Reading the phone's calendar provider does not break the constraint the design was built
around. The refusal in `internal/calendar/ambient.go:10` is about holding a work
credential **on the homelab**, which is what ties the box's blast radius to the employer.
The phone already holds that session. Nothing new lands on homesrv either way.
**Assumption, and it needs his answer:** a managed work profile may block a third-party
app from reading work calendar rows. If it does, the notification stream is all there is.
## The decision
**Keep the endpoint.** Deleting it costs the parser, the tests and the wg-facing token,
and buys nothing while the question above is open. It is off unless `-ambient-token` is
set, so an unbuilt relay carries no surface today.
**Make structured the primary shape.** `/api/ambient` should accept an event with an
explicit start, end and title, and store it without parsing anything. Confidence stays
below 1.0 and the source stays `ambient:notif`, because the provenance claim is unchanged:
this is the phone telling Maven what it sees, not Maven reading a calendar.
**Keep the free-text shape as the degraded path.** It is what a notification-only relay
can send, and it is already written and tested.
**Delete it instead if** the relay is not going to be built. That is the one answer that
closes this without code, and it is his to give.
## What not to do
Do not add date parsing to the free-text path yet. That is the patch #432 explicitly
refuses to accept as closure, and it is the wrong order: if the relay can send a date, no
date parser is needed, and if it cannot, the parser is guessing at a date from text that
was never meant to carry one.
## Follow-on, unrelated to the decision
`cmd/mavweb/ambient.go:33` records a known gap worth keeping visible: an ambient meeting
writes `calendar_event_*` and never `calendar_busy`, so it is good enough to recite and
not good enough to suppress a nudge. That is backwards. Suppressing a nudge is the
lower-risk use of a low-confidence signal, and reciting one is the higher-risk use. It
needs an expiry on the busy level, so it is its own task either way.
@@ -0,0 +1,60 @@
# Plan: Where mavwaked and mavenclient run
**The decision Vikunja #463 asked for. Written 04-08-2026.**
**Verdict: not in compose on homesrv. They run on a client machine in the room he is in.**
The transport for that already exists and nothing needs building to allow it. What needs
building is a way to check the wake path at all, which is a separate task.
## The state that prompted this
`docker-compose.yml` runs mavend, mavsttd, mavttsd, mavweb and mavpoll. `mavwaked` and
`mavenclient` appear in no compose file and run as no host process. Both build under
`make build`. So the wake word and the voice-activity gate are untested by construction:
QA session 1 step 2 covers push-to-talk from `/dash` only, and #287 (voice session
quality) can never be more than half-answered while this holds.
## The reason is not hardware
homesrv has a microphone. It is a Lenovo IdeaPad 5 Pro, and `/proc/asound/cards` lists
the ACP digital mic array with capture devices at `/dev/snd/pcmC1D0c` and
`/dev/snd/pcmC2D0c`. Adding `/dev/snd` to compose and joining the `audio` group would
work.
It would also be pointless. A wake-word daemon is worth having in the room he is standing
in. homesrv is a server, so its microphone hears the room the server is in, which is not
where anybody talks to Maven. Wiring audio into a container to listen to an empty room is
work spent on a capability nobody can use.
## The reason it belongs off-box
`mavenclient` is a client by name and by design: microphone, then STT, then core, then
TTS. It is the one binary in the tree meant to run somewhere else. `mavwaked` is the gate
in front of it, so it goes wherever the microphone goes.
Maven already has components that are not containers on homesrv. `mavupdate` is a
host-side tool. The resident model, STT and TTS prefer the workstation and fall back to
homesrv (`docs/offload.md`). Off-box is a shape this system already has.
**And the wire already supports it.** `ipc.Dial` takes a netaddr seam address:
a bare path is the unix socket, and `tcp://host:port?token=...` reaches a core on another
host, with the token checked in `internal/netaddr` before `internal/ipc` sees the
connection. So a client machine reaching mavend over wg or the LAN needs no new protocol
work. It needs mavend to listen on TCP, which `deploy/mavend.json` does not currently ask
for.
## What this means for the QA plan
QA session 1 step 2 should say what it actually covers, which is push-to-talk through
`/dash`. It should not read as though it covers the voice loop. The wake path is checked
on the client machine or it is not checked, and today there is no client machine.
That is the honest state, and it is worse than the task suggests: this is not a
configuration gap that a compose entry closes. Until a machine with a microphone runs
`mavwaked` and `mavenclient` against a TCP-listening mavend, `internal/wake` and
`cmd/mavwaked`'s VAD are covered by their unit tests and by nothing else.
## What was wrong in CLAUDE.md
The daemon table lists all nine binaries with no column for where they run, which is how
this went unnoticed for as long as it did. It now says which two are not on the box.
+35 -9
View File
@@ -1,6 +1,6 @@
# QA plan: checking Maven properly
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-04 @ 58635f1. Living doc: correct it in place, do not append.*
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
@@ -105,9 +105,9 @@ turns look misaligned when they are not.
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
back. This is the only check that covers mic to STT to core to TTS to
speaker as one path. It is also the path the eleven-day outage most likely
broke.
back. This covers browser mic to STT to core to TTS as one path. It does
**not** cover the wake word or the voice-activity gate, and no step here
does — see below.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
@@ -129,11 +129,18 @@ turns look misaligned when they are not.
and stitch unrelated topics. Asked whether he should move flats, she opened
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
appear in no compose file and run as no host process. Step 2 covers only
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
are untested by construction. Decide whether they belong in compose or on a
client machine, and say which in the deploy docs. Tracked as **463**.
**The wake path cannot be checked here, and that is now the decision rather
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
no host process. They are not going to. They belong on a client machine in the
room he is standing in, because homesrv's microphone is real and in the wrong
room — **463**, written up in `docs/plans/17-where-the-voice-loop-runs.md`.
So the wake word and the VAD gate are covered by their unit tests and by
nothing else, and no session at this box changes that. Checking them needs a
machine with a microphone running both binaries against a TCP-listening mavend.
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is a machine
and a config line, not protocol work. Until then, **287** can only be
half-answered, and step 2 above is push-to-talk, not the voice loop.
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
@@ -471,6 +478,25 @@ at any address (**478**). `allow_private` does work, measured both ways.
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
never calls it (**480**). Two of its QA steps are also stale.
**Four stale QA steps were rewritten on 04-08-2026** under **480**, against the
code rather than against what the plans said. All four failed the same way: the
daemon was right and the step described an older daemon.
| Step | Said | Says now |
|---|---|---|
| 253/3 | boots with the methods unknown | refuses to boot, `config.go:1651` |
| 253/10 | no `:transcript` note by default | true only with a summary present |
| 255/5 | `speaker: enrolment on, recognition BLOCKED` | that line is gone, the capability stays off |
| 252/3 | `vision: stored image <id-prefix>` | `vision: stored <id>`, `vision.go:199` |
Two of them are worth reading past the correction. 253/10 was false in exactly
the scenario 253/16 creates, because `writeNotes` saves the transcript whenever
the summary is empty so a dead llama-server does not lose the meeting. And 255/5
changed because `Recognizes()` was written as the gate, documented as one, and
never called — calling it turned `enabled` with no model from a half-working
capability into a refusal. Enrolling into a store nothing can match against is
not a working half.
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
need the block enabled. Step 10 is Bluetooth and stays skipped.
+5 -3
View File
@@ -1,6 +1,6 @@
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
> Supersedes the classifier-first routing model. Agreed in a design session
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
@@ -40,8 +40,10 @@ utterance
are deferred until the main feature set is complete.
- **Embedder demoted from router to tool** — it now backs `memory.search`
(RAG) and gives the router a cheap "similar past notes/intents" hint. The
router no longer depends on it clearing a threshold. Upgrade MiniLM → bge-m3
for better RU retrieval later (model swap, not architecture).
router no longer depends on it clearing a threshold. The MiniLM upgrade is
done: it is multilingual-e5-small, asymmetric, with the `query:`/`passage:`
prefixes (Vikunja #371, `docs/evals/2026-08-04-recall-e5-small.md`). A
further swap is a model swap, not architecture (Vikunja #412).
### Router output
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
+23 -3
View File
@@ -66,11 +66,30 @@ const ambientPastGrace = 2 * time.Hour
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
// honest reading of a bare wall clock.
//
// See EventFromNotificationIn, which takes the zone explicitly.
func EventFromNotification(n Notification) (Event, bool) {
return EventFromNotificationIn(n, time.Local)
}
// EventFromNotificationIn is EventFromNotification against an explicit zone.
//
// "созвон в 14:30" carries no zone, and nobody writing a phone notification
// means 14:30Z. The wall clock used to be resolved against Posted's own zone,
// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting
// on this box landed four hours late. The size of the error is the deploy's UTC
// offset, which is why it is invisible on a UTC box and wrong everywhere else.
//
// Posted stays an instant in its own zone: it says when the phone showed the
// notification, and the past-grace check compares instants. Only the day and
// the wall clock are read in loc.
func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
if loc == nil {
loc = time.Local
}
if n.Posted.IsZero() {
return Event{}, false
}
n.Posted = n.Posted.In(time.Local)
line := strings.TrimSpace(n.Title + " " + n.Text)
start, end, ok := parseTimeRange(line)
if !ok {
@@ -81,8 +100,9 @@ func EventFromNotification(n Notification) (Event, bool) {
return Event{}, false
}
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
loc := n.Posted.Location()
// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
// "завтра в 09:00" is already tomorrow where he is standing.
y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date()
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
// Too far in the past to be the meeting this notification is about. The day
// was inferred, so the honest reading is that the inference was wrong.
+63 -14
View File
@@ -84,12 +84,12 @@ func TestEventFromNotification(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Package: "com.google.android.gm",
Title: tt.title,
Text: tt.text,
Posted: posted,
})
}, posted.Location())
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
@@ -108,10 +108,10 @@ func TestEventFromNotification(t *testing.T) {
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 daemon's
// zone — the clock reading is a wall clock, not an instant.
if ev.Start.Location() != time.Local {
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
// The event lands on the day the phone showed it, in the zone it
// was resolved against — never 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)
@@ -171,10 +171,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Package: "com.google.android.calendar",
Title: tt.title, Text: tt.text, Posted: tt.posted,
})
}, tt.posted.Location())
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
@@ -198,10 +198,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
// that into the key.
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Title: "Завтра Планёрка 09:00",
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local),
})
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
}, time.UTC)
if !ok {
t.Fatal("expected an event")
}
@@ -243,10 +243,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
// An ambient event must never be indistinguishable from a calendar read.
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Title: "Планёрка 10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local),
})
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
}, time.UTC)
if !ok {
t.Fatal("expected an event")
}
@@ -271,3 +271,52 @@ func TestStripClock(t *testing.T) {
}
}
}
// The defect this file's zone handling exists for: a phone posts an RFC 3339
// instant ending in Z, and "созвон в 14:30" used to be resolved against that
// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a
// notification is local by construction.
func TestNotificationClockIsLocalNotUTC(t *testing.T) {
samara := time.FixedZone("+04", 4*3600)
n := Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
}
ev, ok := EventFromNotificationIn(n, samara)
if !ok {
t.Fatal("expected an event")
}
if got := ev.Start.Format("15:04"); got != "14:30" {
t.Errorf("start = %s local, want 14:30", got)
}
if got := ev.Start.UTC().Format("15:04"); got != "10:30" {
t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got)
}
if ev.Start.Location() != samara {
t.Errorf("location = %v, want %v", ev.Start.Location(), samara)
}
// The stored key is the local day, so it files under the day he lived.
if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want {
t.Errorf("FactKey = %q, want %q", got, want)
}
}
// A notification posted late in the UTC evening is already the next day where
// he is standing. The day must come from the local clock, not from Posted's.
func TestNotificationDayIsTheLocalDay(t *testing.T) {
samara := time.FixedZone("+04", 4*3600)
n := Notification{
Title: "Планёрка 09:00",
Text: "завтра",
Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local
}
ev, ok := EventFromNotificationIn(n, samara)
if !ok {
t.Fatal("expected an event")
}
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 {
t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d)
}
}
-129
View File
@@ -678,135 +678,6 @@ type acceptProposedRoutineReq struct {
ID int64 `json:"id"`
}
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
// - the in-process store adapter (server.go storeAPI) — used by the daemon
// for modules that live in-process for now (router, delivery) and by tests,
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
// - the client proxy (client.go) — same interface, over the wire.
//
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
// That swappability is the seam the auth layer will insert into without
// touching the module code.
type CoreAPI interface {
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
LatestFact(ctx context.Context, key string) (Fact, error)
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
Presence(ctx context.Context) (Presence, error)
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error
ListReminders(ctx context.Context, n int) ([]Reminder, error)
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
RecentFacts(ctx context.Context, n int) ([]Fact, error)
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
// DeliveryAttempts reads the outbox, newest first. An empty status means
// every status (Vikunja #390).
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
RecentNotes(ctx context.Context, n int) ([]Note, error)
// RecentNotesFromSource — the newest n notes whose source starts with
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
// from recall, so this is the only way to reach them, and it keeps the feed
// answer from being crowded out of a fixed window by his own notes.
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
// returns whether a new proposal was written. EnableTool fills cmd +
// destructive and flips status to 'enabled'. DisableTool reverts an
// enabled tool back to proposed (it stays in the store, won't run).
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
// ProposeTool is maven-callable (no step-up — she has no passkey).
// LookupTool/ListTools read them.
// scope defaults to "homelab" when empty.
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
DisableTool(ctx context.Context, name string) error
DeleteTool(ctx context.Context, name string) error
LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, error)
RevertFact(ctx context.Context, key string) (int64, error)
// ListProposedRoutines returns proposed routines, newest first.
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// CaptureTask records a task. See CaptureTaskReq — this is the single
// intake seam for the voice path, the web form and the future email
// extractor. Idempotent per live normalised text; the response says
// whether a row was actually created.
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
// ListTasks returns tasks in one status, newest first. "" is every row,
// "live" is candidate + open (outstanding work).
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
TickTrace(ctx context.Context) (TickTrace, error)
// MorningStatus returns each configured morning routine's current
// checklist state (see internal/morning): active today/now, which items
// are done, which are still missing. The store adapter returns an error
// (morning routines are daemon-config, not persisted) — same shape as
// TickTrace.
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
// MCPServers reports the configured MCP servers and their health
// (Vikunja #251). Read-only introspection for /tools — there is no
// "call this tool" method on purpose: an MCP tool runs through the same
// allowlist, confirm turn and act path as any other tool, and a second
// mutation path would be a second thing to get wrong. Empty when the
// mcp config block is absent, which is the default.
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
// DayPlan returns today's ordered plan — calendar events, pending
// reminders and any morning checklist still outstanding (see
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
// Read-only: asking for the plan never dispatches or schedules anything.
// The store adapter returns an error (the plan needs the daemon's routine
// config) — same shape as TickTrace and MorningStatus.
DayPlan(ctx context.Context) (DayPlan, error)
// Chat routes a text utterance through the reactive handler's core path
// (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram).
//
// conversation names the thread. A parked clarifying question is held per
// conversation, so an unanswered question on one reach cannot eat the next
// utterance from another (Vikunja #466). Empty means the unattributed text
// tap and is still one conversation of its own, separate from the mic.
Chat(ctx context.Context, conversation, text string) (string, error)
// RecentEvents returns the daemon's unified intake journal, newest first
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
// it came from: a relayed notification, a mail candidate, a feed item, a
// changed page, a spend, a presence probe.
//
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
// the store adapter returns an error, because the journal is a bounded
// in-memory ring and not a table. Its contents are a window over intake,
// never the durable record — that is still the fact, note or task the
// intake path wrote.
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
}
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
// event.Event field for field; the ipc package does not import internal/event
// so the wire shape stays independent of the in-process type.
+194
View File
@@ -0,0 +1,194 @@
package ipc
import (
"context"
"time"
)
// CoreAPI and the eight domain interfaces it composes (Vikunja #408).
//
// It used to be one flat block of forty methods, and the cost of that shape
// was paid three times over: every method added an arm to the dispatcher, a
// stub to the daemon's locked API, and a stub to every test double. Two of
// those three are already gone — the dispatcher is a table (methodTable in
// server.go), and UnimplementedCoreAPI took the padding out of the doubles and
// out of lockedAPI, which no longer exists.
//
// What was left is the interface itself, and this file is that half. CoreAPI
// is unchanged as a type: the same forty methods, in the same order, so the
// wire contract, the client proxy and the store adapter are all untouched.
// What it gains is a named seam per domain, so a caller that only reads facts
// can say FactAPI and a reader can see which cluster a method belongs to
// without counting lines.
//
// Add a method to the domain it belongs to, not to CoreAPI.
// FactAPI — the fact store: write, read the current value, read history, and
// undo. Presence sits here because it is a hysteresis view over presence
// facts, not a store of its own.
type FactAPI interface {
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
LatestFact(ctx context.Context, key string) (Fact, error)
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
Presence(ctx context.Context) (Presence, error)
RecentFacts(ctx context.Context, n int) ([]Fact, error)
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RevertFact(ctx context.Context, key string) (int64, error)
}
// ReminderAPI — scheduled sends the owner asked for.
type ReminderAPI interface {
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error
ListReminders(ctx context.Context, n int) ([]Reminder, error)
}
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
// they went out through.
type NudgeAPI interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
// DeliveryAttempts reads the outbox, newest first. An empty status means
// every status (Vikunja #390).
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
}
// NoteAPI — free text he captured, plus the embedded recall over it.
type NoteAPI interface {
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
RecentNotes(ctx context.Context, n int) ([]Note, error)
// RecentNotesFromSource — the newest n notes whose source starts with
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
// from recall, so this is the only way to reach them, and it keeps the feed
// answer from being crowded out of a fixed window by his own notes.
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
}
// ToolAPI — the capability allowlist and its read side.
//
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
// returns whether a new proposal was written. EnableTool fills cmd +
// destructive and flips status to 'enabled'. DisableTool reverts an
// enabled tool back to proposed (it stays in the store, won't run).
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
// ProposeTool is maven-callable (no step-up — she has no passkey).
// LookupTool/ListTools read them.
// scope defaults to "homelab" when empty.
type ToolAPI interface {
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
DisableTool(ctx context.Context, name string) error
DeleteTool(ctx context.Context, name string) error
LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, error)
// MCPServers reports the configured MCP servers and their health
// (Vikunja #251). Read-only introspection for /tools — there is no
// "call this tool" method on purpose: an MCP tool runs through the same
// allowlist, confirm turn and act path as any other tool, and a second
// mutation path would be a second thing to get wrong. Empty when the
// mcp config block is absent, which is the default.
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
}
// RoutineAPI — the shapes of his day: routines Maven noticed and proposed, the
// morning checklist, and today's plan.
//
// MorningStatus and DayPlan are daemon-computed rather than stored, so the
// store adapter returns an error for both — the same shape as TickTrace.
type RoutineAPI interface {
// ListProposedRoutines returns proposed routines, newest first.
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// MorningStatus returns each configured morning routine's current
// checklist state (see internal/morning): active today/now, which items
// are done, which are still missing.
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
// DayPlan returns today's ordered plan — calendar events, pending
// reminders and any morning checklist still outstanding (see
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
// Read-only: asking for the plan never dispatches or schedules anything.
DayPlan(ctx context.Context) (DayPlan, error)
}
// TaskAPI — outstanding work, whatever surface it arrived from.
type TaskAPI interface {
// CaptureTask records a task. See CaptureTaskReq — this is the single
// intake seam for the voice path, the web form and the future email
// extractor. Idempotent per live normalised text; the response says
// whether a row was actually created.
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
// ListTasks returns tasks in one status, newest first. "" is every row,
// "live" is candidate + open (outstanding work).
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
}
// SystemAPI — what the daemon knows about itself, plus the one method that
// runs a whole turn.
type SystemAPI interface {
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
TickTrace(ctx context.Context) (TickTrace, error)
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
// RecentEvents returns the daemon's unified intake journal, newest first
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
// it came from: a relayed notification, a mail candidate, a feed item, a
// changed page, a spend, a presence probe.
//
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
// the store adapter returns an error, because the journal is a bounded
// in-memory ring and not a table. Its contents are a window over intake,
// never the durable record — that is still the fact, note or task the
// intake path wrote.
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
// Chat routes a text utterance through the reactive handler's core path
// (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram).
//
// conversation names the thread. A parked clarifying question is held per
// conversation, so an unanswered question on one reach cannot eat the next
// utterance from another (Vikunja #466). Empty means the unattributed text
// tap and is still one conversation of its own, separate from the mic.
Chat(ctx context.Context, conversation, text string) (string, error)
}
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
// - the in-process store adapter (storeapi.go) — used by the daemon
// for modules that live in-process for now (router, delivery) and by tests,
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
// - the client proxy (client.go) — same interface, over the wire.
//
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
// That swappability is the seam the auth layer inserts into without touching
// the module code.
type CoreAPI interface {
FactAPI
ReminderAPI
NudgeAPI
NoteAPI
ToolAPI
RoutineAPI
TaskAPI
SystemAPI
}
+7
View File
@@ -36,6 +36,13 @@ const maxFrame = 4 << 20
// (we read the length but not the body), so the caller must close it.
var ErrFrameTooLarge = errors.New("ipc: frame too large")
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
// lines, readable on the wire with socat, and every standard replacement
// brings schema machinery this boundary does not want. The condition is that
// it is defended by tests rather than inherited untested — truncated header,
// truncated body, partial reads, frame boundaries and a non-JSON body all
// live in frame_test.go.
//
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
+142
View File
@@ -0,0 +1,142 @@
package ipc
import (
"bytes"
"encoding/binary"
"errors"
"io"
"testing"
)
// The framing is hand-rolled, and it is the protocol all nine daemons depend
// on, so a bug in it is a bug everywhere (Vikunja #410). The review asked
// whether to replace it with something standard. It stays: length-prefixed
// JSON over a unix socket is ninety lines, it is readable with socat, and the
// alternatives (net/rpc, gRPC, a codec library) all buy schema machinery this
// boundary does not want. What was wrong was inheriting it untested. These
// are the paths a real socket produces that the round-trip test never does.
// A short header is not EOF. EOF means the peer closed cleanly between
// frames, which the server treats as a normal disconnect; a header that stops
// halfway is a truncated frame and must be reported as an error, or a peer
// that dies mid-write looks like one that hung up politely.
func TestReadFrameTruncatedHeader(t *testing.T) {
var v any
err := readFrame(bytes.NewReader([]byte{0, 0, 4}), &v)
if err == nil {
t.Fatal("a three-byte header must fail")
}
if errors.Is(err, io.EOF) {
t.Fatalf("err = %v, want a truncation error, not EOF", err)
}
}
// A header promising more body than follows. Same reasoning: the frame never
// arrived, so it must not decode into a zero value the caller then trusts.
func TestReadFrameTruncatedBody(t *testing.T) {
var buf bytes.Buffer
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], 32)
buf.Write(hdr[:])
buf.WriteString(`{"m":"pi`)
var got map[string]string
if err := readFrame(&buf, &got); err == nil {
t.Fatal("a body shorter than its prefix must fail")
}
if len(got) != 0 {
t.Errorf("decoded %v from a truncated frame", got)
}
}
// Nothing at all is EOF, and only this is.
func TestReadFrameEmptyIsEOF(t *testing.T) {
var v any
if err := readFrame(bytes.NewReader(nil), &v); !errors.Is(err, io.EOF) {
t.Fatalf("err = %v, want io.EOF", err)
}
}
// byteAtATime returns one byte per Read, which is what a socket is allowed to
// do and what a bytes.Reader never does. readFrame uses io.ReadFull for both
// the header and the body; this is the test that would fail if either turned
// into a bare Read.
type byteAtATime struct {
b []byte
i int
}
func (r *byteAtATime) Read(p []byte) (int, error) {
if r.i >= len(r.b) {
return 0, io.EOF
}
if len(p) == 0 {
return 0, nil
}
p[0] = r.b[r.i]
r.i++
return 1, nil
}
func TestReadFrameReassemblesPartialReads(t *testing.T) {
type payload struct {
Msg string `json:"m"`
N int `json:"n"`
}
want := payload{Msg: "привет", N: 7}
var buf bytes.Buffer
if err := writeFrame(&buf, want); err != nil {
t.Fatalf("writeFrame: %v", err)
}
var got payload
if err := readFrame(&byteAtATime{b: buf.Bytes()}, &got); err != nil {
t.Fatalf("readFrame: %v", err)
}
if got != want {
t.Fatalf("got %+v, want %+v", got, want)
}
}
// Two frames written back to back must come back as two frames. A reader that
// consumed more than one frame's body would desynchronize the connection, and
// the symptom would be a reply attributed to the wrong request.
func TestReadFrameStopsAtTheFrameBoundary(t *testing.T) {
var buf bytes.Buffer
for _, m := range []string{"first", "second"} {
if err := writeFrame(&buf, map[string]string{"m": m}); err != nil {
t.Fatalf("writeFrame: %v", err)
}
}
r := &byteAtATime{b: buf.Bytes()}
for _, want := range []string{"first", "second"} {
var got map[string]string
if err := readFrame(r, &got); err != nil {
t.Fatalf("readFrame(%s): %v", want, err)
}
if got["m"] != want {
t.Fatalf("got %q, want %q", got["m"], want)
}
}
var extra map[string]string
if err := readFrame(r, &extra); !errors.Is(err, io.EOF) {
t.Fatalf("after two frames: err = %v, want io.EOF", err)
}
}
// A body that is not JSON is an error, not a zero value. The peer is either
// broken or not speaking this protocol; either way the caller must not read
// on as though it decoded.
func TestReadFrameRejectsNonJSONBody(t *testing.T) {
var buf bytes.Buffer
var hdr [4]byte
body := []byte("not json at all")
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
buf.Write(hdr[:])
buf.Write(body)
var got map[string]string
if err := readFrame(&buf, &got); err == nil {
t.Fatal("a non-JSON body must fail")
}
}
+154
View File
@@ -0,0 +1,154 @@
package ipc
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"strings"
"testing"
"github.com/kami/maven/internal/store"
)
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
// without importing internal/store. The design is right and the failure mode is
// quiet: add a sentinel to store, forget the switch, and the client gets an
// untyped error that no caller can branch on. These two tests are the alarm.
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
var mapErrPairs = []struct {
name string // the store identifier, for the coverage test below
from error
wants error
}{
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
}
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
// each with the reason it stays store-side. A new sentinel is in neither list
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
// module can branch on an error is a decision, not a default.
var unmappedStoreErrors = map[string]string{
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
// The shopping and packing lists follow the task verdicts exactly, and for
// the same reasons. No module branches on any of the three today.
"ErrListItemNotFound": "the surface re-lists rather than branch on an item id that vanished",
"ErrListItemEmpty": "input validation — an empty item is refused before it reaches the store",
"ErrListItemStatus": "an unknown status is a caller bug, not a state a module recovers from",
}
// The mapping itself, through a wrap, because every real caller wraps.
func TestMapErrMapsEveryPair(t *testing.T) {
for _, p := range mapErrPairs {
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
if got != p.wants {
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
}
}
}
// Anything mapErr does not recognise passes through untouched. A module that
// cannot branch on an error must still see the original text.
func TestMapErrPassesUnknownThrough(t *testing.T) {
if mapErr(nil) != nil {
t.Error("mapErr(nil) must stay nil")
}
own := fmt.Errorf("socket closed")
if got := mapErr(own); got != own {
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
}
}
// The parity half: every exported sentinel in internal/store is either mapped
// or listed with a reason. Read off the source, so a sentinel added in a file
// this package never touches still trips it.
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
mapped := map[string]bool{}
for _, p := range mapErrPairs {
mapped[p.name] = true
}
for _, name := range storeSentinelNames(t) {
if mapped[name] || unmappedStoreErrors[name] != "" {
continue
}
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
}
}
// storeSentinelNames reads internal/store for exported package-level error
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
func storeSentinelNames(t *testing.T) []string {
t.Helper()
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0)
if err != nil {
t.Fatalf("parse internal/store: %v", err)
}
var out []string
for _, pkg := range pkgs {
for _, f := range pkg.Files {
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, n := range vs.Names {
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
continue
}
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
out = append(out, n.Name)
}
}
}
}
}
}
if len(out) < len(mapErrPairs) {
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
}
return out
}
func isErrorsNew(e ast.Expr) bool {
call, ok := e.(*ast.CallExpr)
if !ok {
return false
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "New" {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && id.Name == "errors"
}
-442
View File
@@ -2,9 +2,7 @@ package ipc
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net"
@@ -13,449 +11,9 @@ import (
"time"
"github.com/kami/maven/internal/netaddr"
"github.com/kami/maven/internal/store"
"golang.org/x/sys/unix"
)
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
// these inside the core process; the socket Server calls it through the
// CoreAPI interface, so over-the-wire and in-process callers behave
// identically. The translation here is the only place store sentinels cross
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
// view of errors stable regardless of transport.
type storeAPI struct {
s *store.Store
}
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
// unlocked handle held ONLY in core's address space; this adapter never
// returns it to a caller — core mediates.
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
var voids sql.NullInt64
if req.VoidsID != nil {
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
}
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
return id, mapErr(err)
}
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
f, err := a.s.LatestFact(ctx, key)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
f, err := a.s.LatestFactBySource(ctx, key, source)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
d, err := a.s.Since(ctx, key, now)
return d, mapErr(err)
}
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
b, score, upd, err := a.s.LoadPresenceState(ctx)
if err != nil {
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
}
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
}
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
return id, mapErr(err)
}
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
return mapErr(a.s.MarkReminder(ctx, id, status))
}
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rs, err := a.s.ListReminders(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Reminder, len(rs))
for i, r := range rs {
out[i] = toReminder(r)
}
return out, nil
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now))
}
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
return id, mapErr(err)
}
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
}
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
out, err := a.s.RecentOutcomes(ctx, rule, n)
return out, mapErr(err)
}
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
fs, err := a.s.RecentFacts(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
fs, err := a.s.CalendarEvents(ctx, from, to)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
trs, err := a.s.RecentEcosystemTraces(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]EcosystemTrace, len(trs))
for i, tr := range trs {
out[i] = EcosystemTrace{
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
}
}
return out, nil
}
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
ns, err := a.s.RecentNudges(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Nudge, len(ns))
for i, ng := range ns {
out[i] = toNudge(ng)
}
return out, nil
}
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]DeliveryAttempt, len(as))
for i, at := range as {
out[i] = DeliveryAttempt{
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
Channel: at.Channel, Status: at.Status, Created: at.Created,
}
if at.HasComplete {
t := at.Completed
out[i].Completed = &t
}
}
return out, nil
}
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
return id, mapErr(err)
}
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
ns, err := a.s.QueryNotes(ctx, embedding, k)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, n := range ns {
out[i] = toNote(n)
}
return out, nil
}
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
ns, err := a.s.RecentNotes(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
return ok, mapErr(err)
}
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
}
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
return mapErr(a.s.DisableTool(ctx, name))
}
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
t, err := a.s.LookupTool(ctx, name)
if err != nil {
return Tool{}, mapErr(err)
}
return toTool(t), nil
}
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
return newID, mapErr(err)
}
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
return "", errors.New("store: chat not available via direct store API")
}
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
return nil, errors.New("store: morning status not available via direct store API")
}
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
// in the daemon's memory, not a table, so a bare store cannot serve it.
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
return nil, errors.New("store: intake events not available via direct store API")
}
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
return nil, nil // no manager behind a bare store: nothing configured
}
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
return DayPlan{}, errors.New("store: day plan not available via direct store API")
}
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Tool, len(ts))
for i, t := range ts {
out[i] = toTool(t)
}
return out, nil
}
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
return mapErr(a.s.DeleteTool(ctx, name))
}
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
}
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
}
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
ts, err := a.s.ListTasks(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]ProposedRoutine, len(rs))
for i, r := range rs {
out[i] = ProposedRoutine{
ID: r.ID,
Action: r.Action,
Object: r.Object,
IntervalDays: r.IntervalDays,
Status: r.Status,
CreatedTs: r.CreatedTs.UnixMilli(),
}
if r.ReminderID != nil {
out[i].ReminderID = r.ReminderID
}
}
return out, nil
}
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id))
}
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
}
func toTool(t store.Tool) Tool {
return Tool{
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
}
}
func toReminder(r store.Reminder) Reminder {
return Reminder{
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
}
}
func toNote(n store.Note) Note {
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
}
func toNudge(n store.Nudge) Nudge {
out := Nudge{
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
Message: n.Message, Outcome: n.Outcome,
}
if n.OutcomeTs.Valid {
v := n.OutcomeTs.Int64
out.OutcomeTs = &v
}
return out
}
func toFact(f store.Fact) Fact {
out := Fact{
ID: f.ID,
Ts: f.Ts,
Kind: string(f.Kind),
Key: f.Key,
Value: f.Value,
Source: f.Source,
Confidence: f.Confidence,
}
if f.VoidsID.Valid {
v := f.VoidsID.Int64
out.VoidsID = &v
}
return out
}
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
// keeping internal text off the wire except to the daemon log).
func mapErr(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, store.ErrNoFact):
return ErrNoFact
case errors.Is(err, store.ErrConfidence):
return ErrConfidence
case errors.Is(err, store.ErrVoidsMissing):
return ErrVoidsMissing
case errors.Is(err, store.ErrNudgeNotFound):
return ErrNudgeNotFound
case errors.Is(err, store.ErrNudgeOutcome):
return ErrNudgeOutcome
case errors.Is(err, store.ErrReminderNotFound):
return ErrReminderNotFound
case errors.Is(err, store.ErrReminderState):
return ErrReminderState
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
}
return err
}
// Server — the core side of the boundary. Listens on a unix domain socket,
// accepts module connections, frames requests to a CoreAPI and responses back.
// One Server per daemon process; concurrent connections are handled in their
+455
View File
@@ -0,0 +1,455 @@
// ipc/storeapi.go — the sqlite-backed CoreAPI.
//
// Split out of server.go, move-only (Vikunja #423). server.go was two
// unrelated things: this adapter, and the dispatcher that calls it over the
// socket. Nothing here knows there is a wire.
package ipc
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/kami/maven/internal/store"
)
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
// these inside the core process; the socket Server calls it through the
// CoreAPI interface, so over-the-wire and in-process callers behave
// identically. The translation here is the only place store sentinels cross
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
// view of errors stable regardless of transport.
type storeAPI struct {
s *store.Store
}
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
// unlocked handle held ONLY in core's address space; this adapter never
// returns it to a caller — core mediates.
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
var voids sql.NullInt64
if req.VoidsID != nil {
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
}
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
return id, mapErr(err)
}
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
f, err := a.s.LatestFact(ctx, key)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
f, err := a.s.LatestFactBySource(ctx, key, source)
if err != nil {
return Fact{}, mapErr(err)
}
return toFact(f), nil
}
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
d, err := a.s.Since(ctx, key, now)
return d, mapErr(err)
}
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
b, score, upd, err := a.s.LoadPresenceState(ctx)
if err != nil {
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
}
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
}
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
return id, mapErr(err)
}
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
return mapErr(a.s.MarkReminder(ctx, id, status))
}
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rs, err := a.s.ListReminders(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Reminder, len(rs))
for i, r := range rs {
out[i] = toReminder(r)
}
return out, nil
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now))
}
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
return id, mapErr(err)
}
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
}
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
out, err := a.s.RecentOutcomes(ctx, rule, n)
return out, mapErr(err)
}
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
fs, err := a.s.RecentFacts(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
fs, err := a.s.CalendarEvents(ctx, from, to)
if err != nil {
return nil, mapErr(err)
}
out := make([]Fact, len(fs))
for i, f := range fs {
out[i] = toFact(f)
}
return out, nil
}
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
trs, err := a.s.RecentEcosystemTraces(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]EcosystemTrace, len(trs))
for i, tr := range trs {
out[i] = EcosystemTrace{
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
}
}
return out, nil
}
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
ns, err := a.s.RecentNudges(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Nudge, len(ns))
for i, ng := range ns {
out[i] = toNudge(ng)
}
return out, nil
}
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]DeliveryAttempt, len(as))
for i, at := range as {
out[i] = DeliveryAttempt{
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
Channel: at.Channel, Status: at.Status, Created: at.Created,
}
if at.HasComplete {
t := at.Completed
out[i].Completed = &t
}
}
return out, nil
}
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
return id, mapErr(err)
}
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
ns, err := a.s.QueryNotes(ctx, embedding, k)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, n := range ns {
out[i] = toNote(n)
}
return out, nil
}
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
ns, err := a.s.RecentNotes(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Note, len(ns))
for i, note := range ns {
out[i] = toNote(note)
}
return out, nil
}
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
return ok, mapErr(err)
}
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
}
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
return mapErr(a.s.DisableTool(ctx, name))
}
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
t, err := a.s.LookupTool(ctx, name)
if err != nil {
return Tool{}, mapErr(err)
}
return toTool(t), nil
}
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
return newID, mapErr(err)
}
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
return "", errors.New("store: chat not available via direct store API")
}
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
return nil, errors.New("store: morning status not available via direct store API")
}
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
// in the daemon's memory, not a table, so a bare store cannot serve it.
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
return nil, errors.New("store: intake events not available via direct store API")
}
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
return nil, nil // no manager behind a bare store: nothing configured
}
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
return DayPlan{}, errors.New("store: day plan not available via direct store API")
}
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Tool, len(ts))
for i, t := range ts {
out[i] = toTool(t)
}
return out, nil
}
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
return mapErr(a.s.DeleteTool(ctx, name))
}
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
}
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
}
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
ts, err := a.s.ListTasks(ctx, status)
if err != nil {
return nil, mapErr(err)
}
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]ProposedRoutine, len(rs))
for i, r := range rs {
out[i] = ProposedRoutine{
ID: r.ID,
Action: r.Action,
Object: r.Object,
IntervalDays: r.IntervalDays,
Status: string(r.Status),
CreatedTs: r.CreatedTs.UnixMilli(),
}
if r.ReminderID != nil {
out[i].ReminderID = r.ReminderID
}
}
return out, nil
}
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id))
}
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
}
func toTool(t store.Tool) Tool {
return Tool{
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
}
}
func toReminder(r store.Reminder) Reminder {
return Reminder{
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
}
}
func toNote(n store.Note) Note {
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
}
func toNudge(n store.Nudge) Nudge {
out := Nudge{
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
Message: n.Message, Outcome: n.Outcome,
}
if n.OutcomeTs.Valid {
v := n.OutcomeTs.Int64
out.OutcomeTs = &v
}
return out
}
func toFact(f store.Fact) Fact {
out := Fact{
ID: f.ID,
Ts: f.Ts,
Kind: string(f.Kind),
Key: f.Key,
Value: f.Value,
Source: f.Source,
Confidence: f.Confidence,
}
if f.VoidsID.Valid {
v := f.VoidsID.Int64
out.VoidsID = &v
}
return out
}
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
// keeping internal text off the wire except to the daemon log).
func mapErr(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, store.ErrNoFact):
return ErrNoFact
case errors.Is(err, store.ErrConfidence):
return ErrConfidence
case errors.Is(err, store.ErrVoidsMissing):
return ErrVoidsMissing
case errors.Is(err, store.ErrNudgeNotFound):
return ErrNudgeNotFound
case errors.Is(err, store.ErrNudgeOutcome):
return ErrNudgeOutcome
case errors.Is(err, store.ErrReminderNotFound):
return ErrReminderNotFound
case errors.Is(err, store.ErrReminderState):
return ErrReminderState
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
}
return err
}
+295
View File
@@ -0,0 +1,295 @@
package eval
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/kami/maven/internal/router"
)
//go:embed ru_ecosystem_v1.json
var reachFixtureJSON []byte
// Service — where an utterance arrives. Reach stops at the service boundary on
// purpose: whether Nexus knows the entity and whether Hexis holds a capability
// for it are those services' answers, and a fixture that asserted them would be
// measuring three systems and reporting one number.
type Service string
const (
// ServiceNone — the turn stays inside Maven.
ServiceNone Service = "none"
// ServicePraxis — handlePraxisAct dispatched to a capability.
ServicePraxis Service = "praxis"
// ServiceHexis — the text reached resolveEntityReference.
ServiceHexis Service = "hexis"
)
// ReachCase — one utterance and the service it must arrive at.
//
// WantCapability is informational and unscored: it says which Praxis arm the fn
// slot should land on, so a failure reads as "reached Praxis, wrong arm" rather
// than only "reached Praxis". Scoring it would assert an alias table this
// package cannot import.
type ReachCase struct {
ID string `json:"id"`
Utterance string `json:"utterance"`
Lang string `json:"lang"`
WantService Service `json:"want_service"`
WantCapability string `json:"want_capability"`
Tags []string `json:"tags"`
Note string `json:"note"`
}
// ReachFixture — the versioned envelope, same shape as Fixture.
type ReachFixture struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
ReferenceNow string `json:"reference_now"`
Notes []string `json:"notes"`
Cases []ReachCase `json:"cases"`
}
// LoadReach returns the embedded ecosystem fixture.
func LoadReach() (ReachFixture, error) {
var f ReachFixture
if err := json.Unmarshal(reachFixtureJSON, &f); err != nil {
return ReachFixture{}, fmt.Errorf("parse reach fixture: %w", err)
}
if f.SchemaVersion != SchemaVersion {
return ReachFixture{}, fmt.Errorf("reach fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
}
if len(f.Cases) == 0 {
return ReachFixture{}, fmt.Errorf("reach fixture has no cases")
}
return f, nil
}
// Now — the fixture's reference clock, same contract as Fixture.Now.
func (f ReachFixture) Now() (time.Time, error) {
t, err := time.Parse(time.RFC3339, f.ReferenceNow)
if err != nil {
return time.Time{}, fmt.Errorf("parse reference_now %q: %w", f.ReferenceNow, err)
}
return t, nil
}
// PraxisAliases — the fn slots handlePraxisAct dispatches on, copied from
// praxisCapabilities in cmd/mavend/ecosystem_acts.go.
//
// It is a copy because the registry lives in package main and cannot be
// imported. That is a drift risk and it is deliberate: the alternative is
// lifting the whole capability registry out of the daemon, which is a
// refactor this measurement should not be carrying. TestPraxisAliasesShape
// asserts the arms are all present; a new capability adds a line here.
var PraxisAliases = map[string]string{
"list_attention": "list_attention",
"attention": "list_attention",
"внимание": "list_attention",
"что требует внимания": "list_attention",
"что нового": "list_attention",
"acknowledge_item": "acknowledge_item",
"принято": "acknowledge_item",
"понял": "acknowledge_item",
"поняла": "acknowledge_item",
"resolve_item": "resolve_item",
"сделано": "resolve_item",
"готово": "resolve_item",
"решено": "resolve_item",
"ignore_item": "ignore_item",
"игнорировать": "ignore_item",
"неважно": "ignore_item",
"pin_item": "pin_item",
"закрепить": "pin_item",
"list_changes": "list_changes",
"changes": "list_changes",
"изменения": "list_changes",
"что изменилось": "list_changes",
"entity_attention": "entity_attention",
"entity_status": "entity_attention",
}
// Reach derives which ecosystem service a decision arrives at, assuming all
// three are configured. It mirrors actionAct in cmd/mavend/actions_act.go and
// hexisBeforeClarify in cmd/mavend/ecosystem_acts.go, in their order:
//
// 1. A clarified act with text and no fn reaches Hexis before the clarify
// question is ever asked. That path runs on the raw slots, so the matcher
// does not get to fill fn first.
// 2. Otherwise the act matcher may earn a fn from the text slot.
// 3. A fn that is a Praxis capability alias dispatches to Praxis.
// 4. Non-empty text reaches Hexis.
// 5. Anything else stays inside Maven.
//
// It returns the service and, for Praxis, the capability the fn landed on.
func Reach(d router.Decision, m router.ActMatcher) (Service, string) {
if d.Intent != router.IntentAct {
return ServiceNone, ""
}
if d.Clarify {
if !d.Slots.HasFn && d.Slots.Text != "" {
return ServiceHexis, ""
}
return ServiceNone, ""
}
fn, hasFn := d.Slots.Fn, d.Slots.HasFn
if !hasFn && d.Slots.Text != "" && m != nil {
if matched, _, ok := m.Match(d.Slots.Text); ok {
fn, hasFn = matched, true
}
}
if hasFn {
if capability, ok := PraxisAliases[strings.ToLower(strings.TrimSpace(fn))]; ok {
return ServicePraxis, capability
}
}
if d.Slots.Text != "" {
return ServiceHexis, ""
}
return ServiceNone, ""
}
// ReachOutcome — one scored case.
type ReachOutcome struct {
Case ReachCase
Decision router.Decision
Got Service
Capability string
Err error
Latency time.Duration
Pass bool
Reason string
}
// ReachReport — the aggregate.
//
// The two miss directions are kept apart because they cost different things.
// Missed is an utterance that should have reached a service and did not: he
// asks again, or does it himself. Overreach is an utterance that reached a
// service it had no business reaching, and on the Hexis side that is one
// resolution away from executing a capability nobody asked for.
type ReachReport struct {
Name string
Total int
Passed int
Missed int
Overreach int
// WrongService — reached a service, but the other one.
WrongService int
// WrongCapability — reached Praxis on the wrong arm. Reported, not failed.
WrongCapability int
Errors int
Outcomes []ReachOutcome
// ByService is keyed by the fixture's want_service.
ByService map[string]TagStat
ByTag map[string]TagStat
P50, P95 time.Duration
Max time.Duration
}
// Accuracy — fraction of cases that arrived where the fixture says they must.
func (r ReachReport) Accuracy() float64 {
if r.Total == 0 {
return 0
}
return float64(r.Passed) / float64(r.Total)
}
// ScoreReach runs every case through the router and derives where it lands. It
// never fails the run on a route error: an erroring case scores as a miss and
// is counted, because "the model was down" and "the router was wrong" are
// different numbers.
func ScoreReach(ctx context.Context, name string, r Router, m router.ActMatcher, f ReachFixture) (ReachReport, error) {
now, err := f.Now()
if err != nil {
return ReachReport{}, err
}
rep := ReachReport{
Name: name,
Total: len(f.Cases),
ByService: map[string]TagStat{},
ByTag: map[string]TagStat{},
}
lat := make([]time.Duration, 0, len(f.Cases))
for _, c := range f.Cases {
start := time.Now()
d, err := r.Route(ctx, c.Utterance, now)
o := ReachOutcome{Case: c, Decision: d, Err: err, Latency: time.Since(start)}
lat = append(lat, o.Latency)
switch {
case err != nil:
rep.Errors++
o.Reason = fmt.Sprintf("route error: %v", err)
default:
o.Got, o.Capability = Reach(d, m)
switch {
case o.Got == c.WantService:
o.Pass = true
if c.WantCapability != "" && o.Capability != c.WantCapability {
rep.WrongCapability++
o.Reason = fmt.Sprintf("reached praxis on %q, want %q", o.Capability, c.WantCapability)
}
case c.WantService == ServiceNone:
rep.Overreach++
o.Reason = fmt.Sprintf("reached %s, want none (intent %q, clarify %v, text %q)",
o.Got, d.Intent, d.Clarify, d.Slots.Text)
case o.Got == ServiceNone:
rep.Missed++
o.Reason = fmt.Sprintf("stayed local, want %s (intent %q, clarify %v, fn %q)",
c.WantService, d.Intent, d.Clarify, d.Slots.Fn)
default:
rep.WrongService++
o.Reason = fmt.Sprintf("reached %s, want %s (fn %q)", o.Got, c.WantService, d.Slots.Fn)
}
}
if o.Pass {
rep.Passed++
}
bump(rep.ByService, string(c.WantService), o.Pass)
for _, tag := range c.Tags {
bump(rep.ByTag, tag, o.Pass)
}
rep.Outcomes = append(rep.Outcomes, o)
}
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
if len(lat) > 0 {
rep.Max = lat[len(lat)-1]
}
return rep, nil
}
// String renders the report.
func (r ReachReport) String() string {
var b strings.Builder
fmt.Fprintf(&b, "%s: %d/%d reached the right place (%.1f%%)\n", r.Name, r.Passed, r.Total, 100*r.Accuracy())
fmt.Fprintf(&b, " missed: %d (should have reached, didn't) | overreach: %d (reached, shouldn't) | wrong service: %d | wrong praxis arm: %d | errors: %d\n",
r.Missed, r.Overreach, r.WrongService, r.WrongCapability, r.Errors)
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
fmt.Fprintf(&b, " by want: %s\n", renderStats(r.ByService))
fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag))
return b.String()
}
// Failures — the per-case detail, sorted by ID so two runs diff cleanly. A
// passing case with a wrong Praxis arm is listed too: it carries a reason.
func (r ReachReport) Failures() string {
var b strings.Builder
out := append([]ReachOutcome(nil), r.Outcomes...)
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
for _, o := range out {
if o.Reason == "" {
continue
}
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, o.Reason)
}
return b.String()
}
+228
View File
@@ -0,0 +1,228 @@
package eval
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/kami/maven/internal/router"
)
func TestLoadReachFixture(t *testing.T) {
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
if _, err := f.Now(); err != nil {
t.Fatalf("Now: %v", err)
}
valid := map[Service]bool{ServiceNone: true, ServicePraxis: true, ServiceHexis: true}
seen := map[string]bool{}
byService := map[Service]int{}
for _, c := range f.Cases {
if c.ID == "" || seen[c.ID] {
t.Errorf("case %q: empty or duplicate id", c.ID)
}
seen[c.ID] = true
if !valid[c.WantService] {
t.Errorf("%s: want_service %q, want none|praxis|hexis", c.ID, c.WantService)
}
if c.Lang != "ru" && c.Lang != "en" {
t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang)
}
// A capability only means something on the Praxis side, and it must
// name an arm that exists — otherwise the case asserts a target the
// dispatch can never hit.
if c.WantCapability != "" {
if c.WantService != ServicePraxis {
t.Errorf("%s: want_capability on %q", c.ID, c.WantService)
}
if PraxisAliases[c.WantCapability] != c.WantCapability {
t.Errorf("%s: capability %q is not a praxis arm", c.ID, c.WantCapability)
}
}
byService[c.WantService]++
}
// Coverage floor. The negative cases matter most: without them a router
// that sent everything to Hexis would score perfectly.
for s := range valid {
if byService[s] < 5 {
t.Errorf("want_service %q has %d cases, want >= 5", s, byService[s])
}
}
}
// TestReachFixtureIsHeldOut — same rule as TestFixtureIsHeldOut. The classifier
// routes by similarity to frozen seeds, so a case copied from models/seeds
// would measure memorisation rather than reach.
func TestReachFixtureIsHeldOut(t *testing.T) {
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
seeds := loadSeeds(t)
for _, c := range f.Cases {
if src, ok := seeds[normalize(c.Utterance)]; ok {
t.Errorf("%s: %q is verbatim in %s — not held out", c.ID, c.Utterance, src)
}
}
}
// TestReachDerivation pins the gate order that ScoreReach depends on. These are
// the four branches of actionAct plus the clarify pre-empt, asserted directly
// so a change to cmd/mavend/actions_act.go that this package no longer mirrors
// fails here rather than silently moving the score.
func TestReachDerivation(t *testing.T) {
m := router.DefaultActMatcher{Fns: actFns}
cases := []struct {
name string
dec router.Decision
want Service
capability string
}{
{
name: "act with a praxis alias in the fn slot",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "list_attention", HasFn: true}},
want: ServicePraxis, capability: "list_attention",
},
{
name: "act with a russian praxis alias",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "готово", HasFn: true}},
want: ServicePraxis, capability: "resolve_item",
},
{
name: "act with an entity but no fn earns one from the matcher",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: "выключи свет в спальне"}},
want: ServiceHexis,
},
{
name: "act with a non-praxis fn and text goes to hexis",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти гитею"}},
want: ServiceHexis,
},
{
name: "act with a fn and no text stays local",
dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true}},
want: ServiceNone,
},
{
name: "a clarified act with text still reaches hexis",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "выключи это"}},
want: ServiceHexis,
},
{
name: "a clarified act that already has a fn does not",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти"}},
want: ServiceNone,
},
{
name: "no intent but act reaches nothing",
dec: router.Decision{Intent: router.IntentQuery, Slots: router.Slots{Fn: "list_attention", HasFn: true, Text: "что требует внимания"}},
want: ServiceNone,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, capability := Reach(tc.dec, m)
if got != tc.want {
t.Errorf("Reach = %q, want %q", got, tc.want)
}
if capability != tc.capability {
t.Errorf("capability = %q, want %q", capability, tc.capability)
}
})
}
}
// TestPraxisAliasesShape — the copy of praxisCapabilities in reach.go is a
// drift risk (see its comment). This does not close it, but it does catch the
// cheap half: an arm losing all its aliases, or an alias pointing at an arm
// that no longer has a canonical name.
func TestPraxisAliasesShape(t *testing.T) {
arms := map[string]int{}
for alias, capability := range PraxisAliases {
if alias == "" || capability == "" {
t.Errorf("empty alias or capability: %q → %q", alias, capability)
}
arms[capability]++
}
for _, want := range []string{
"list_attention", "acknowledge_item", "resolve_item",
"ignore_item", "pin_item", "list_changes", "entity_attention",
} {
if arms[want] == 0 {
t.Errorf("praxis arm %q has no aliases", want)
}
// Every arm must be reachable by its own name, which is the alias the
// LLM router's fn slot actually emits.
if PraxisAliases[want] != want {
t.Errorf("arm %q does not alias to itself", want)
}
}
}
// TestReachBaselineHash — the deterministic ratchet. Same deal as
// TestClassifierBaseline: the hash embedder never clears the confidence gate on
// paraphrases, so almost everything lands on clarify, and the number this
// asserts is the overreach count rather than the accuracy.
//
// Overreach is the direction worth a hard assertion. An utterance that should
// stay inside Maven and instead resolves an entity through Nexus is one Hexis
// capability away from executing something nobody asked for, and unlike a miss
// he never gets asked about it.
func TestReachBaselineHash(t *testing.T) {
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
m := router.DefaultActMatcher{Fns: actFns}
rep, err := ScoreReach(context.Background(), "reach: classifier+hash",
newBaselineRouter(t, router.NewHashEmbedder(1024), nil), m, f)
if err != nil {
t.Fatalf("ScoreReach: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
if rep.Overreach > 4 {
t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s",
rep.Overreach, rep.Failures())
}
}
// TestReachBaselineONNX — the deployed configuration: the cascade with the
// multilingual e5 embedder. Opt-in via MAVEN_ONNX_LIB, same as TestONNXBaseline,
// because deps/ is gitignored. `make eval-reach` points it at the vendored copy.
//
// Reports rather than asserts. This is the number Vikunja #405 asked for, and a
// threshold invented alongside the first measurement is not a ratchet, it is a
// guess written down twice.
func TestReachBaselineONNX(t *testing.T) {
lib := os.Getenv("MAVEN_ONNX_LIB")
if lib == "" {
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
}
model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx")
tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json")
for _, p := range []string{lib, model, tok} {
if _, err := os.Stat(p); err != nil {
t.Skipf("missing %s: %v", p, err)
}
}
emb, err := router.NewONNXEmbedder(model, tok, lib)
if err != nil {
t.Skipf("onnx embedder unavailable: %v", err)
}
defer emb.Close()
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
rep, err := ScoreReach(context.Background(), "reach: classifier+onnx",
newBaselineRouter(t, emb, nil), router.DefaultActMatcher{Fns: actFns}, f)
if err != nil {
t.Fatalf("ScoreReach: %v", err)
}
t.Log("\n" + rep.String() + rep.Failures())
}
+284
View File
@@ -0,0 +1,284 @@
{
"schema_version": 1,
"name": "ru_ecosystem_v1",
"reference_now": "2026-08-04T10:00:00+03:00",
"notes": [
"Held out from models/seeds/*.txt, same rule as ru_routing_v1: a case that is verbatim a seed measures memorisation.",
"want_service is where the utterance must ARRIVE, not what it must achieve. Praxis means handlePraxisAct dispatched to a capability; hexis means the text reached resolveEntityReference. Whether the entity exists in Nexus and the capability exists in Hexis is the services' answer, not the router's.",
"The reach model is derived in reach.go from cmd/mavend/actions_act.go. Praxis needs IntentAct AND a fn slot whose value is a praxis capability alias. Hexis needs IntentAct AND non-empty text, and it also fires from hexisBeforeClarify, so a clarified act still reaches it.",
"want_capability is informational and is not scored. It records which praxis arm the fn should land on, so a report can say WHERE a praxis reach went wrong rather than only that it did.",
"none means the utterance must NOT reach either service. Those cases are the expensive direction: a note or a query that lands on a mutating Hexis path is a wrong act, not a missed one."
],
"cases": [
{
"id": "eco-ru-001",
"utterance": "выключи свет в спальне",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": "The canonical act. Verb in the allowlist, entity text after it."
},
{
"id": "eco-ru-002",
"utterance": "включи свет на кухне, пожалуйста",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-003",
"utterance": "перезапусти вукунью",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": "Service name in Russian, declined. Nexus owns the alias, the router only has to hand it over."
},
{
"id": "eco-ru-004",
"utterance": "перезагрузи прокси",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": ""
},
{
"id": "eco-ru-005",
"utterance": "останови гитею на минуту",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": "Trailing qualifier the entity resolver has to survive."
},
{
"id": "eco-ru-006",
"utterance": "запусти бэкап на нексусе",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["homelab", "mutating"],
"note": ""
},
{
"id": "eco-ru-007",
"utterance": "закрой шторы в комнате",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-008",
"utterance": "открой гараж",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": "Two words. The single-token thinner spares it, the verb ending carries the subject."
},
{
"id": "eco-ru-009",
"utterance": "поставь чайник",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-010",
"utterance": "выключи телевизор в гостиной",
"lang": "ru",
"want_service": "hexis",
"want_capability": "",
"tags": ["home", "mutating"],
"note": ""
},
{
"id": "eco-ru-011",
"utterance": "что требует внимания",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_attention",
"tags": ["attention", "reading"],
"note": "The task's own example. Query-shaped, and the reach model only dispatches to Praxis from IntentAct, so this is the case most likely to miss."
},
{
"id": "eco-ru-012",
"utterance": "что сейчас требует внимания",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_attention",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-013",
"utterance": "что нового по проектам",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_attention",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-014",
"utterance": "что изменилось за сегодня",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_changes",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-015",
"utterance": "покажи изменения",
"lang": "ru",
"want_service": "praxis",
"want_capability": "list_changes",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-016",
"utterance": "отметь это как сделанное",
"lang": "ru",
"want_service": "praxis",
"want_capability": "resolve_item",
"tags": ["lifecycle", "mutating"],
"note": "The task's second example. The item id is the value slot and is missing here, so the capability asks for it — reaching Praxis with no id is still reaching Praxis."
},
{
"id": "eco-ru-017",
"utterance": "готово, закрывай",
"lang": "ru",
"want_service": "praxis",
"want_capability": "resolve_item",
"tags": ["lifecycle", "mutating"],
"note": ""
},
{
"id": "eco-ru-018",
"utterance": "принято, я это видел",
"lang": "ru",
"want_service": "praxis",
"want_capability": "acknowledge_item",
"tags": ["lifecycle", "mutating"],
"note": "Acknowledged is not resolved. The alias must not collapse into the resolve arm."
},
{
"id": "eco-ru-019",
"utterance": "игнорировать это пока",
"lang": "ru",
"want_service": "praxis",
"want_capability": "ignore_item",
"tags": ["lifecycle", "mutating"],
"note": ""
},
{
"id": "eco-ru-020",
"utterance": "закрепи этот пункт",
"lang": "ru",
"want_service": "praxis",
"want_capability": "pin_item",
"tags": ["lifecycle", "mutating"],
"note": ""
},
{
"id": "eco-ru-021",
"utterance": "что там с нексусом",
"lang": "ru",
"want_service": "praxis",
"want_capability": "entity_attention",
"tags": ["attention", "reading"],
"note": "Scoped attention. The alias list for this arm is grammar names only, so free Russian reaching it depends on a grammar that does not exist yet."
},
{
"id": "eco-ru-022",
"utterance": "как дела у праксиса",
"lang": "ru",
"want_service": "praxis",
"want_capability": "entity_attention",
"tags": ["attention", "reading"],
"note": ""
},
{
"id": "eco-ru-023",
"utterance": "напомни завтра в девять позвонить маме",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "A reminder is Maven's own store. Nothing outside her hears it."
},
{
"id": "eco-ru-024",
"utterance": "запиши что мне понравился этот подход",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "A note carrying a verb-shaped word. It must not become an act."
},
{
"id": "eco-ru-025",
"utterance": "какая завтра погода",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": ""
},
{
"id": "eco-ru-026",
"utterance": "мой рост сто восемьдесят два",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "A fact about him. The personal boundary means this never leaves the box."
},
{
"id": "eco-ru-027",
"utterance": "как ты сегодня",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": ""
},
{
"id": "eco-ru-028",
"utterance": "выключи",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative", "ambiguous"],
"note": "A verb with no target. Reaching Hexis with an empty entity text is the failure this case guards: it must clarify, not resolve."
},
{
"id": "eco-ru-029",
"utterance": "сделай это",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative", "ambiguous"],
"note": "The known dangerous case. It carries text, so under the current gates it does reach Hexis — the fixture says it should not, and that disagreement is the finding."
},
{
"id": "eco-ru-030",
"utterance": "во сколько у меня встреча",
"lang": "ru",
"want_service": "none",
"want_capability": "",
"tags": ["negative"],
"note": "Stage 0 routes agenda questions to query. The calendar is Maven's, not Praxis's."
}
]
}
+47 -8
View File
@@ -8,14 +8,37 @@ import (
"time"
)
// The three states a proposal can be in. A proposal starts 'proposed' and
// moves once, either way, and never moves again.
// RoutineStatus — the state a proposal is in. A defined type, not a bare
// string, because the legal set used to live in a comment: nothing caught a
// typo at compile time, nothing enumerated the set for a test, and a bad value
// surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46,
// #410).
type RoutineStatus string
// The three states a proposal can be in. A proposal starts proposed and moves
// once, either way, and never moves again.
const (
RoutineProposed = "proposed"
RoutineAccepted = "accepted"
RoutineDismissed = "dismissed"
RoutineProposed RoutineStatus = "proposed"
RoutineAccepted RoutineStatus = "accepted"
RoutineDismissed RoutineStatus = "dismissed"
)
// RoutineStatuses — the legal set, and the single source of truth a test can
// range over. Adding a state means adding it here.
var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed}
// Valid reports whether s is one of RoutineStatuses.
func (s RoutineStatus) Valid() bool {
for _, v := range RoutineStatuses {
if s == v {
return true
}
}
return false
}
func (s RoutineStatus) String() string { return string(s) }
// ProposedRoutine — a detected pattern the system wants to nudge about on a
// repeating interval. Status 'proposed' means awaiting human confirmation;
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
@@ -30,7 +53,7 @@ type ProposedRoutine struct {
Action string
Object string
IntervalDays float64
Status string // proposed | accepted | dismissed
Status RoutineStatus
CreatedTs time.Time
ReminderID *int64
AcceptedTs *time.Time
@@ -40,6 +63,7 @@ type ProposedRoutine struct {
var (
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
ErrRoutineStatus = errors.New("store: unknown routine status")
)
// CreateProposedRoutine inserts a new proposed routine. Returns
@@ -51,6 +75,16 @@ var (
// keep finding the pattern, and every re-propose is refused here. Maven is not
// a nag.
//
// The object stays a local string. It is not resolved against Nexus and it
// carries no canonical entity ref (asked on the PR 4 review, decided here,
// Vikunja #410). Nexus owns identity for things the ecosystem acts on, and
// nothing acts on a routine object: it is the word he used, replayed back to
// him in a nudge, and compared only against itself for the UNIQUE key. Two
// spellings of the same watering can are two routines, and that is the right
// answer when the point is to say the sentence he would say. Canonical refs
// arrive here only if a routine ever drives a Hexis call, which is Vikunja
// #272, not this.
//
// Vikunja #43: this is called both from the voice fact-write path (for the
// immediate spoken confirmation) and from the digestion tick's proactive
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
@@ -102,8 +136,13 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
}
// ListProposedRoutinesByStatus returns routines in one status, newest first.
// An empty status returns every row.
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
// An empty status returns every row; an unknown one is refused rather than
// silently answering with nothing, since a typo and a genuinely empty state
// read the same otherwise.
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) {
if status != "" && !status.Valid() {
return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status)
}
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines`
var args []any
+57 -1
View File
@@ -235,7 +235,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
}
cases := []struct {
status string
status RoutineStatus
want int
}{
{RoutineProposed, 0},
@@ -266,3 +266,59 @@ func TestLookupMissingProposedRoutine(t *testing.T) {
t.Fatal("want nil for missing routine")
}
}
// Every legal status must survive the database. The status column is written
// by three different UPDATE statements with the value spelled inline, so a
// constant that drifts from its SQL is exactly the failure this catches: the
// row would come back in a state no Go code compares equal to.
func TestRoutineStatusRoundTrip(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
write := map[RoutineStatus]func(id int64) error{
RoutineProposed: func(int64) error { return nil },
RoutineAccepted: func(id int64) error { return s.AcceptProposedRoutine(ctx, id, now) },
RoutineDismissed: func(id int64) error { return s.DismissProposedRoutine(ctx, id) },
}
for _, want := range RoutineStatuses {
if !want.Valid() {
t.Fatalf("%q is in RoutineStatuses but not Valid()", want)
}
object := "object-" + want.String()
id, err := s.CreateProposedRoutine(ctx, "полить", object, 3, now)
if err != nil {
t.Fatalf("CreateProposedRoutine(%s): %v", want, err)
}
if err := write[want](id); err != nil {
t.Fatalf("move to %s: %v", want, err)
}
got, err := s.LookupProposedRoutine(ctx, "полить", object)
if err != nil || got == nil {
t.Fatalf("LookupProposedRoutine(%s): %v", want, err)
}
if got.Status != want {
t.Errorf("status = %q, want %q", got.Status, want)
}
list, err := s.ListProposedRoutinesByStatus(ctx, want)
if err != nil {
t.Fatalf("ListProposedRoutinesByStatus(%s): %v", want, err)
}
if len(list) != 1 {
t.Errorf("status %s: listed %d rows, want 1", want, len(list))
}
}
}
// A typo used to read as "nothing is in that state", which is the same answer
// a correct query gives on an empty table.
func TestListByStatusRefusesAnUnknownStatus(t *testing.T) {
s := newTestStore(t)
if _, err := s.ListProposedRoutinesByStatus(context.Background(), "accpeted"); !errors.Is(err, ErrRoutineStatus) {
t.Fatalf("err = %v, want ErrRoutineStatus", err)
}
if RoutineStatus("accpeted").Valid() {
t.Error("a typo must not be Valid()")
}
}