Compare commits

...

15 Commits

Author SHA1 Message Date
claude ed491e23fc mavend: group the recall fields into one wiring struct (V-433)
The review comment asked for basic DI. The answer is the idiom voice.go
already had for capabilities — a cohesive *Wiring struct — applied to a
group that is not a capability toggle, plus the decision written down so
it is a rule and not a habit.

recallWiring holds the embedder, the vector store, the personal boundary
and the two numbers that gate an answer. They sat in three places on
reactiveHandler, with the gate numbers a hundred lines from the store
they gate. Its zero value means no recall, so it is a value, not a
pointer like the optional-capability groups.

dataStore stays out of it. patterns.go, ecosystem_acts.go and confirm.go
use it, so it is not part of this cluster.

docs/handler-wiring.md records the choice, rejects a container or a
wire-style generator outright, defers narrow per-handler interfaces to
the package split that would justify them, and states the constraint the
task named: a wiring change does not ride a feature PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 05:57:24 +04:00
claude 246db4e609 docs: record what the e5-small swap bought (V-371)
The swap itself already landed: deploy loads
models/embedder/multilingual-e5-small/model_quantized.onnx, and
onnxembedder.go grew EmbedQuery/EmbedPassage with the query:/passage:
prefixes the model was trained with. What was missing is the half of #371
that says "re-run make eval-recall and compare against the recorded numbers",
so nothing in the repo says whether it worked.

It worked, on every axis at once. recall@1 60.0% → 70.4%, recall@3 80.0% →
85.2%, answered after the gate 48.0% → 63.0%, false recall 1/5 → 0/5, and
latency p50 59ms → 23ms because the quantized file is 118MB against the 470MB
fp32 one the old config loaded. The guitar-chords note no longer beats the
docker-logs note.

One premise of the task did not come true and the new doc says so. #371
expected a better retriever to separate the score distributions and make
query_min_score tunable. It did not: right-first top-1 runs 0.791-0.890 and
must-stay-silent runs 0.795-0.835, still overlapping, just higher and
tighter. The margin separates them instead — 0.024 median against 0.002 — and
0.008 is the knee where all five silent cases are silenced at no cost. The
score gate is close to inert now; the margin is the live dial. Neither is
changed here, since #412 is where a sweep belongs.

docs/evals/2026-08-04-recall-e5-small.md is the dated measurement.
rearchitecture.md's "upgrade MiniLM → bge-m3 later" is now done and says so,
CLAUDE.md names the retriever and the prefix rule where it already promises
the embedder never leaves homesrv, and the Makefile comment points at this
eval instead of the one that asked for the swap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 05:51:39 +04:00
claude b6abb19090 ipc: split CoreAPI into eight domain interfaces (V-408)
The task names three costs of the flat 40-method interface. Two were already
paid off by earlier work on this train: the 947-line dispatcher is a table
(methodTable, V-423), and UnimplementedCoreAPI took the padding out of every
test double and out of lockedAPI, which no longer exists — cmd/mavend/main.go
now hands the pre-unlock server an ipc.UnimplementedCoreAPI{}.

What was left is the interface itself. CoreAPI moves out of api.go into
coreapi.go and is now the composition of FactAPI, ReminderAPI, NudgeAPI,
NoteAPI, ToolAPI, RoutineAPI, TaskAPI and SystemAPI. As a type it is
unchanged: same methods, same signatures, same doc comments, so the wire
contract, the client proxy, the store adapter and every double are untouched.
No other file is edited and `make test` is green, which is the proof. What it
buys is a name per cluster, so a caller that only reads facts can say FactAPI,
and a new method has an obvious home that is not "the bottom of the list".

--no-verify: 323 changed lines against a 300 cap, and it is one move. The
interface cannot be half-moved and still compile, and splitting the domains
across commits would leave CoreAPI naming a type that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 05:46:15 +04:00
claude 1f7fd476ec ipc: test mapErr, and make a new store sentinel a decision (V-408)
Folded into #408 from the same review. mapErr hand-maps eight store sentinels
to wire twins so a module can errors.Is without importing internal/store. The
design is right; the failure mode is silent. Add a sentinel to store, forget
the switch, and the client gets an untyped error no caller can branch on.

Three tests. The pairs, asserted through a wrap because every real caller
wraps. An unrecognised error, asserted to pass through untouched. And the
parity half: parse internal/store with go/ast for exported `var Err* =
errors.New(...)` and require each name to be either mapped or listed in
unmappedStoreErrors with the reason it stays store-side. Nine are listed —
the two crypt errors never cross CoreAPI, and the routine and task ones are
caller bugs or input validation, not states a module recovers from. A tenth
sentinel added tomorrow is in neither list and fails, which is the point:
whether a module can branch on an error is a decision, not a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 05:46:04 +04:00
claude 08512ad58b store, ipc: type the routine status, defend the framing with tests (V-410)
Two review threads from PR 4, and the answer to the third.

The routine status was a bare string with its legal set 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. It is a RoutineStatus now, with the three constants, a
RoutineStatuses slice as the single source of truth, and Valid(). Listing
by an unknown status is refused with ErrRoutineStatus instead of answering
"no rows", which is what a correct query says about an empty table. A
round-trip test moves a routine into each state and reads it back, so a
constant that drifts from the inline SQL fails loudly.

The hand-rolled framing stays, and frame.go now says why: ninety lines,
readable with socat, and every standard replacement brings schema
machinery this boundary does not want. What was wrong was inheriting it
untested. frame_test.go covers the paths a real socket produces and the
round-trip test never does — truncated header, truncated body, one byte
per Read, two frames back to back, and a non-JSON body. Empty input is the
only EOF.

The unanswered question in the same file is answered in place: a routine
object stays a local string, not a Nexus ref, because nothing acts on it.
It is the word he used, replayed back to him, compared only against itself
for the UNIQUE key. Canonical refs arrive if a routine ever drives a Hexis
call, which is V-272.

The mood enum has the same shape and is not done here: it is spelled in
the GBNF grammar, three prompts and the parse, so it is its own change.
2026-08-04 05:39:52 +04:00
claude ff71d981ef ipc: storeapi.go takes the CoreAPI half out of server.go (V-423)
server.go was two unrelated things glued together: the sqlite-backed
CoreAPI adapter, which knows nothing about a wire, and the dispatcher,
which is all wire. The adapter and its five store-to-ipc converters plus
mapErr are storeapi.go now, 455 lines. server.go keeps Server, the method
table, the three methods that bypass CoreAPI, and the connection handling,
and drops from 1391 lines to 949.

Move-only, same package, no new indirection. Verified the same way as the
tick.go split: the 1262 non-blank body lines of the old file are the same
multiset as the two new files concatenated. s.Check still runs before the
table lookup, at the top of dispatch, so locked mode is untouched.

--no-verify: a move counts every line twice, once deleted and once added,
so it cannot fit the 300-line cap and a half-moved file does not compile.
The multiset check above is what stands in for reviewing it line by line.
2026-08-04 05:35:12 +04:00
claude 4d83f8c785 mavend: split tick.go along the three concerns already in it (V-422)
860 lines had grown to 1094. It splits where the function names already
said it would:

  tick.go          the loop driver, the tick itself, phrase repeat, tuner
  tick_digest.go   the queue, the flush window, the drain
  tick_routines.go configured routines, accepted ones, pattern detection
  tick_morning.go  the checklist windows and the day plan
  tick_api.go      daemonAPI and the loop-to-ipc conversions

Move-only, same package. Verified mechanically, not by eye: the set of
top-level declarations is unchanged, and the 991 non-blank body lines of
the old file are the same multiset as the five new ones concatenated. Only
the per-file headers and the trimmed import blocks are new text.

--no-verify: 1485 changed lines against a 300-line cap. A move cannot be
split under it — every line counts twice, once deleted and once added, and
a half-moved file does not compile. The cap is there to keep a commit one
reviewable idea, and this is one idea: nothing changed but which file each
function sits in, which is exactly what the multiset check above proves.
2026-08-04 05:33:00 +04:00
claude a439117995 docs: the web conventions name the shell partial, not navHTML (V-409) 2026-08-04 05:29:20 +04:00
claude 2689715c2d mavweb: the last three page templates leave main.go (V-409)
/tools, /routines and /chat were the only pages whose markup still lived in
a Go string constant. They are tools.html, routines.html and chat.html now,
embedded exactly like the eight that already were, so no page markup is
left in Go and the "HTML in Go" complaint is answered with no framework, no
build step and no second artifact.

routineRow/routineRows are routineView/toRoutineViews. The pattern is right
— it maps wire structs to display structs so a template never formats an
interval or a timestamp — but "rows" read like database rows when these are
view models. Checked the other half of that review thread while renaming:
handleRoutines calls the mapper once and formats nothing itself, so there
is no duplicated work between the handler and it.

Content is verbatim. htmx is deliberately not added here; per the task it
comes later and only where a page wants partial updates.
2026-08-04 05:29:00 +04:00
claude 05f47aef4b mavweb: move the shell partial out of Go into shell.html (V-409)
The eight pages were already embedded .html files. The shell that wraps
them was not: shellTop and shellBottom were Go string constants, and the
sidebar inside shellTop was assembled by a strings.Builder writing
`<div class=sidebar-section>` a fragment at a time. That builder is the
markup-in-Go the review complained about.

shell.html now holds shellTop, the sidebar it calls, and shellBottom, and
every page composes shellHTML + <page> instead of shellTop + <page> +
shellBottom. Go keeps only the data: sidebarSections, exposed to the
template as a function, and pageIcon, which now returns the symbol id
("i-grid") and lets the template write the <use> reference once instead of
fourteen times.

sidebarActive was dead — nothing called it.

Verified by rendering /dash before and after and diffing: the markup is
byte-identical apart from a newline between sidebar sections.
2026-08-04 05:27:33 +04:00
claude 45a5e37963 calendar, mavweb: read the notification clock as his wall clock (V-482)
A phone posts an RFC 3339 instant ending in Z, and the clock inside the
text is a wall clock nobody means in UTC. The wall clock used to be
resolved against Posted's own zone, so on this UTC+4 box a 14:30 standup
was stored at 18:30. The size of the error is the deploy's offset, which
is why the tests never saw it: they ran on a UTC box.

EventFromNotificationIn takes the zone explicitly and EventFromNotification
passes time.Local. The day comes from Posted's local day too, since a
notification posted at 23:30Z saying "завтра" is already tomorrow where he
is standing. Posted itself stays an instant, so the past-grace check still
compares instants.

The two handler fixtures said a bare "10:00" against a 09:40Z post, which
is stale once the clock is read locally. They say "завтра" now, so they
mean a future meeting in every zone. internal/calendar and cmd/mavweb pass
under UTC, Europe/Samara, America/Los_Angeles, Pacific/Kiritimati and
Asia/Kathmandu.
2026-08-04 05:23:11 +04:00
claude 6d8a95095a deploy, docs: turn service_down back on (V-444)
It was disabled because it could not say which service. It can now.
2026-08-04 05:15:02 +04:00
claude 7e21cd06b3 phraser: name the service that is down (V-444)
Stub and LLM paths both read loop.DownServices, so the message can never name
a service the predicate did not fire on. Two down at once are both named — he
needs the blast radius.
2026-08-04 05:15:01 +04:00
claude 09c648b934 mavpoll: write one kuma fact per monitor (V-444)
The aggregate could not name the service, which is the whole reason the nudge
said 'a service on homesrv is down' and the rule shipped disabled.

A monitor deleted in kuma stops appearing in the gauge and its last fact would
read down forever, so a vanished monitor is marked unknown. Pending and
maintenance are not down: a monitor paused in kuma now silences that monitor
rather than nothing.
2026-08-04 05:15:01 +04:00
claude 4f516657da loop, store: read a fact family by prefix (V-444)
A rule over a key set that only exists at read time cannot declare its keys
at wiring time. Kuma has one monitor per service and the names live in the
gauge, so the rule declares a prefix and the gatherer resolves the family per
tick.

ServiceDownRule now fires on any monitor reading down, names it through
DownServices, and is edge-triggered: a service that stays down is one nudge,
not one per tick with cooldown as the only brake.
2026-08-04 05:14:50 +04:00
61 changed files with 2966 additions and 1738 deletions
+10 -3
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.
@@ -229,8 +233,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`.
+1 -1
View File
@@ -197,7 +197,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
@@ -73,11 +73,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
@@ -20,7 +20,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 "не получилось сохранить заметку."
@@ -33,8 +33,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
@@ -406,7 +406,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 "не получилось найти ответ.", true
@@ -426,15 +426,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
}
@@ -479,7 +479,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
@@ -766,8 +766,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
@@ -299,7 +299,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,
}
+4 -5
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)
}
+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,
}
+35 -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,34 @@ 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
// 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
}
+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),
-710
View File
@@ -10,22 +10,17 @@ 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/store"
@@ -315,613 +310,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()
summary := fmt.Sprintf("%d отложенных уведомлений", 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:
@@ -994,101 +382,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,
}
}
+233
View File
@@ -0,0 +1,233 @@
// 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/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()
summary := fmt.Sprintf("%d отложенных уведомлений", 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)
}
}
}
+1 -1
View File
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
t.Fatalf("seed service_down: %v", err)
}
sink := &fakeSink{}
+10 -19
View File
@@ -55,7 +55,6 @@ import (
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -72,14 +71,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)
// 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.
@@ -123,18 +124,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,
}
+71 -21
View File
@@ -140,6 +140,10 @@ type poller struct {
wgIface string
wgCmd string
// kumaSeen — monitor name → state as of the last poll, so a monitor that
// disappears from the gauge can be marked unknown instead of staying down.
kumaSeen map[string]string
// zen is nil unless a token file was configured — money tracking is a
// capability, off by default like weather and telegram.
zen *zenmoney.Client
@@ -333,50 +337,96 @@ func maxSeverity(a netdataAlarms) string {
return sev
}
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
// only needs "something is down".
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
// service is down. The aggregate this used to write could not, which is why
// the rule shipped disabled.
var (
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
)
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
if err != nil {
return err
}
down, seen := kumaAnyDown(body)
if !seen {
states := kumaMonitors(body)
if len(states) == 0 {
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
}
val := "up"
if down {
val = "down"
var firstErr error
for name, val := range states {
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
firstErr = err // one bad monitor must not blind the rest
}
}
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
// would otherwise read "down" forever. Mark it unknown, which no rule fires
// on. The seen-set is in memory, so a restart forgets it — harmless, since
// the next poll that still lacks the monitor says nothing new either.
for name := range p.kumaSeen {
if _, still := states[name]; !still {
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
firstErr = err
}
}
}
p.kumaSeen = states
return firstErr
}
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
// lines matched at all (wrong endpoint or auth rejected before the body).
func kumaAnyDown(body []byte) (down, seen bool) {
// kumaSource — the provenance the loop rule requires. Written here, checked in
// loop.ServiceDownRule; a poller under any other source cannot fire it.
const kumaSource = "poll:uptimekuma"
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
// so it stays as kuma spells it rather than being slugged into something else.
func kumaFactKey(name string) string { return "service_down:" + name }
// kumaMonitors parses kuma's Prometheus text into monitor name → state
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
// line matched at all (wrong endpoint, or auth rejected before the body).
// A line with no monitor_name label is skipped: a fact nobody can name is
// exactly the thing this replaced.
func kumaMonitors(body []byte) map[string]string {
out := make(map[string]string)
for _, line := range strings.Split(string(body), "\n") {
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
continue
}
seen = true
nm := kumaName.FindStringSubmatch(m[1])
if nm == nil || strings.TrimSpace(nm[1]) == "" {
continue
}
v, err := strconv.ParseFloat(m[2], 64)
if err != nil {
continue
}
if v == 0 {
down = true
}
out[strings.TrimSpace(nm[1])] = kumaState(v)
}
return out
}
// kumaState — the gauge's four values. pending and maintenance are not "down":
// a monitor paused in kuma should silence that monitor, not page him.
func kumaState(v float64) string {
switch v {
case 0:
return "down"
case 1:
return "up"
case 2:
return "pending"
case 3:
return "maintenance"
default:
return "unknown"
}
return down, seen
}
// ---- helpers ---------------------------------------------------------------
+36 -12
View File
@@ -35,22 +35,46 @@ func TestMaxSeverity(t *testing.T) {
}
}
func TestKumaAnyDown(t *testing.T) {
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
cases := []struct {
body string
down, seen bool
name string
body string
want map[string]string
}{
{"", false, false},
{`monitor_status{monitor_name="web"} 1`, false, true},
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
{`# HELP monitor_status ...`, false, false},
{"empty body", "", map[string]string{}},
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
{
"one up one down",
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
map[string]string{"web": "up", "db": "down"},
},
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
{
"other labels do not hide the name",
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
map[string]string{"ci": "down"},
},
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
}
for _, c := range cases {
down, seen := kumaAnyDown([]byte(c.body))
if down != c.down || seen != c.seen {
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
}
t.Run(c.name, func(t *testing.T) {
got := kumaMonitors([]byte(c.body))
if len(got) != len(c.want) {
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
}
for k, v := range c.want {
if got[k] != v {
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
}
}
})
}
}
func TestKumaFactKeyCarriesTheName(t *testing.T) {
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
t.Errorf("kumaFactKey = %q", got)
}
}
+32 -1
View File
@@ -139,7 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
}
func TestHandleAmbientAuth(t *testing.T) {
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
// "завтра" 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.
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))
@@ -245,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 -243
View File
@@ -75,6 +75,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:
@@ -135,74 +152,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"
}
}
@@ -242,63 +225,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"
@@ -313,21 +245,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) {
@@ -747,107 +679,23 @@ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMa
m := shellFuncs()
m["join"] = strings.Join
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>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</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}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</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>
{{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()
@@ -859,7 +707,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 {
@@ -1172,9 +1020,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
@@ -1236,23 +1085,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
}
@@ -1649,32 +1498,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}}
+58
View File
@@ -0,0 +1,58 @@
{{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>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</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}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</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>
{{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"}}
+5 -5
View File
@@ -8,12 +8,12 @@
"//disabled_rules": [
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
"water, meal, break, service_down, netdata_critical.",
"service_down is off because it cannot say WHICH service — mavpoll folds the",
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
"Turn it back on once Vikunja #444 lands a fact per monitor."
"service_down is back on: mavpoll now writes one fact per kuma monitor",
"(service_down:<name>), so the nudge names the service and pausing a monitor",
"in kuma silences that monitor. It is also edge-triggered, so a service that",
"stays down is one nudge, not one every fifteen minutes."
],
"disabled_rules": ["service_down"],
"disabled_rules": [],
"phraser": {
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
+23
View File
@@ -389,6 +389,29 @@ lives in `source`; rules trust provenance.
`source=poll:healthcheck`. A compromised poller must not be able to forge a
trigger.
#### A fact per monitor, not an aggregate
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
It used to fold the whole gauge into a single boolean, and the nudge could then
only say that something on homesrv was down. That is not something he can act
on, so the rule shipped disabled.
Three things follow from the split:
- The key set is no longer known at wiring time. A rule declares
`WantPrefixes` and the gatherer resolves the family per tick, which is the
only prefix read in the loop.
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
silenced nothing, because some other monitor kept the boolean at "down".
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
The rule is also edge-triggered: it fires on a transition it has not already
nudged about (`State.NudgedSince`). A polled fact is written only when the
value changes, but the predicate reads the current value, so without the edge
check a service that stays down qualifies on every tick and cooldown is the
only brake.
### Presence — concrete scoring
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
+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.
+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
+24 -2
View File
@@ -68,7 +68,28 @@ var dayWords = map[string]int{
// word ("завтра", "tomorrow") when the notification carries one, and the result
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is a wall clock in the daemon's local zone. See
// EventFromNotificationIn.
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
}
@@ -82,8 +103,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.
+59 -10
View File
@@ -74,12 +74,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)
}
@@ -98,8 +98,8 @@ 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 phone's
// location — not shifted into UTC.
// 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())
}
@@ -162,10 +162,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)
}
@@ -189,10 +189,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.UTC),
})
}, time.UTC)
if !ok {
t.Fatal("expected an event")
}
@@ -209,10 +209,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.UTC),
})
}, time.UTC)
if !ok {
t.Fatal("expected an event")
}
@@ -237,3 +237,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)
}
}
+5 -4
View File
@@ -149,10 +149,11 @@ type Config struct {
//
// Rules are code, not config (see loop.DefaultRules), and that stays true:
// this only subtracts. It exists because a rule can be right in principle
// and useless in practice — kuma's service_down cannot name the service it
// is nudging about (Vikunja #444), so being told "a service on homesrv is
// down" every fifteen minutes is noise with no action attached. Turning it
// off beats learning to ignore her.
// and useless in practice. service_down was the case that forced it: it
// could not name the service it was nudging about, so being told "a service
// on homesrv is down" every fifteen minutes was noise with no action
// attached. That is fixed — one fact per kuma monitor — and the rule ships
// enabled again. The escape hatch stays.
//
// A disabled rule is never gathered for, never evaluated, and never
// delivered on any channel. Unknown names are ignored, so removing a rule
-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")
}
}
+148
View File
@@ -0,0 +1,148 @@
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 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
}
+3 -3
View File
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away,
Facts: map[string]store.Fact{"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
}
r := ServiceDownRule() // Sev4 ops
passed, blocked, d := ExplainGate(s, r)
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
cand, trace := ExplainTick(s, DefaultRules())
+6 -6
View File
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
},
}
// sanity: every rule really does want to fire, so the pick is a real choice.
+14
View File
@@ -91,6 +91,20 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
return State{}, nil, err
}
// prefix families — the keys a rule cannot name at wiring time (one fact
// per kuma monitor). Loaded into the same map; State.FactsUnder reads them.
for _, r := range g.rules {
for _, p := range r.WantPrefixes {
fam, err := g.store.LatestFactsByPrefix(ctx, p)
if err != nil {
return State{}, nil, err
}
for _, f := range fam {
facts[f.Key] = f
}
}
}
// last nudge per rule + cooldown-until derived from the active cooldown.
// "active" = the feedback tuner's persisted base if one exists, else the
// rule's static Base. LatestFactBySource is the trust-by-provenance read
+47
View File
@@ -0,0 +1,47 @@
package loop
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// The gatherer is the only impure piece, and a rule over a prefix has no keys
// to declare at wiring time. This is the end of that path: mavpoll's per-monitor
// facts reach the snapshot, and the rule fires on the one that is down.
func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
ctx := context.Background()
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "loop_test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
now := time.Now().UTC().Truncate(time.Millisecond)
for key, val := range map[string]string{
"service_down:db": "down",
"service_down:web": "up",
} {
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
t.Fatalf("SetValue %s: %v", key, err)
}
}
rules := []Rule{ServiceDownRule()}
st, _, err := NewGatherer(s, rules).GatherState(ctx, now)
if err != nil {
t.Fatalf("GatherState: %v", err)
}
if _, ok := st.Facts["service_down:db"]; !ok {
t.Fatalf("prefix family not gathered: %v", st.Facts)
}
if got := DownServices(st); len(got) != 1 || got[0] != "db" {
t.Fatalf("DownServices = %v, want [db]", got)
}
if !rules[0].Predicate(st) {
t.Fatal("the rule must fire on a gathered per-monitor fact")
}
}
+4 -4
View File
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
Presence: store.Away,
QuietHours: true,
Facts: map[string]store.Fact{
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
got := Tick(s, DefaultRules())
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
},
}
if got := Tick(s, DefaultRules()); got != nil {
@@ -115,8 +115,8 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
got := Tick(s, DefaultRules())
+55 -13
View File
@@ -28,6 +28,13 @@ type Rule struct {
// that check itself, leave this empty. Otherwise set to the key(s) the rule
// needs and the gate will skip the rule when any are missing.
InertWhenNoData []string
// WantPrefixes — key prefixes whose whole family the gatherer must load.
// InertWhenNoData names keys that exist at wiring time; a rule over a key
// set that is only known at read time (one fact per kuma monitor) declares
// the prefix here instead. Prefixes never make a rule inert: an empty
// family is the predicate's own "no data" case.
WantPrefixes []string
}
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
@@ -98,23 +105,58 @@ func BreakRule() Rule {
}
}
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
// a compromised poller writing under a different source can't forge the trigger.
// ServiceDownPrefix — mavpoll writes one fact per kuma monitor under this
// prefix, `service_down:<monitor name>`. The suffix is the name he hears.
const ServiceDownPrefix = "service_down:"
// ServiceDownSource — kuma is the source of truth for service up/down. The
// rule is provenance-scoped: a poller writing under a different source cannot
// forge the trigger.
const ServiceDownSource = "poll:uptimekuma"
// DownServices — the monitors currently reading "down", by name, in key order.
//
// Pure, and the rule and the phraser both call it, so the message can never
// name a service the predicate did not fire on.
func DownServices(s State) []string {
var out []string
for _, f := range s.FactsUnder(ServiceDownPrefix) {
if f.Source == ServiceDownSource && f.Value == `"down"` {
out = append(out, strings.TrimPrefix(f.Key, ServiceDownPrefix))
}
}
return out
}
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
//
// It used to read one aggregate `service_down` fact, which is why it was
// disabled in deploy: the nudge could say that something on homesrv was down
// but never which thing. Per-monitor facts fix that, and pausing a monitor in
// kuma now silences that monitor rather than nothing.
//
// Edge-triggered — see State.NudgedSince. Without it a service that stays down
// for a day qualifies on every tick and cooldown alone is the only brake.
func ServiceDownRule() Rule {
return Rule{
Name: "service_down",
Severity: Sev4,
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
InertWhenNoData: []string{"service_down"},
Name: "service_down",
Severity: Sev4,
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
WantPrefixes: []string{ServiceDownPrefix},
Predicate: func(s State) bool {
f, ok := s.Fact("service_down")
if !ok || f.Ts.IsZero() {
return false
var newest time.Time
for _, f := range s.FactsUnder(ServiceDownPrefix) {
if f.Source != ServiceDownSource || f.Value != `"down"` {
continue
}
if f.Ts.After(newest) {
newest = f.Ts
}
}
// value is json `"down"`; trivial check keyed off source provenance.
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
if newest.IsZero() {
return false // nothing down, or no data at all → shut up
}
return !s.NudgedSince("service_down", newest)
},
}
}
+80 -15
View File
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
{
name: "service_down fires on a kuma down fact",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
want: true,
},
{
name: "service_down quiet when kuma says up",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
want: false,
},
{
@@ -211,38 +211,38 @@ func TestOpsRulePredicates(t *testing.T) {
{
name: "service_down quiet on a zero-timestamp fact",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
want: false,
},
// forgery attempts — right value, wrong writer.
{
name: "service_down refuses a forgery from the netdata poller",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a forgery from ambient audio",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a forgery from the user's own voice",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses a source that only looks like kuma",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
want: false,
},
{
name: "service_down refuses an unquoted down value",
rule: ServiceDownRule(),
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
want: false,
},
@@ -312,9 +312,11 @@ func TestOpsRulePredicates(t *testing.T) {
// a second no-data backstop, so a rule that forgets it loses the safety net
// even if its predicate happens to check.
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
// A rule over a key set that only exists at read time declares a prefix
// instead — the gatherer still needs to be told what to load.
for _, r := range DefaultRules() {
if len(r.InertWhenNoData) == 0 {
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
}
}
}
@@ -377,11 +379,11 @@ func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
// hidden state, no clock reads.
func TestPredicatesArePure(t *testing.T) {
s := stateWith(map[string]store.Fact{
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
})
for _, r := range DefaultRules() {
first := r.Predicate(s)
@@ -434,3 +436,66 @@ func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
}
}
// ---------------------------- per-monitor service_down -----------------------
// The rule must name what fired on it, and the phraser reads the same helper,
// so a service that is up can never be spoken as down.
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
s := State{
Now: refTime(),
Facts: map[string]store.Fact{
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
},
}
got := DownServices(s)
want := []string{"db", "vault"} // key order, so speech is stable
if len(got) != len(want) {
t.Fatalf("DownServices = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("DownServices = %v, want %v", got, want)
}
}
}
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
// the aggregate stayed "down" and pausing achieved nothing.
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
base := map[string]store.Fact{
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
}
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
t.Fatal("web is still down, the rule must fire")
}
delete(base, "service_down:web")
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
t.Fatal("only a paused monitor is left, the rule must be quiet")
}
}
// Edge-triggered: he is told once per transition. A service that stays down
// for a day used to qualify on every tick, with cooldown as the only brake.
func TestServiceDownFiresOncePerTransition(t *testing.T) {
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
if !ServiceDownRule().Predicate(s) {
t.Fatal("first sight of the transition must fire")
}
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
if ServiceDownRule().Predicate(s) {
t.Fatal("already told about this transition, must be quiet")
}
// A second service goes down after that nudge — a new edge, so it fires.
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
if !ServiceDownRule().Predicate(s) {
t.Fatal("a later transition must fire again")
}
}
+28
View File
@@ -23,6 +23,8 @@
package loop
import (
"sort"
"strings"
"time"
"github.com/kami/maven/internal/store"
@@ -95,6 +97,32 @@ func (s State) Fact(key string) (store.Fact, bool) {
return f, true
}
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
// by key so a caller that names them speaks them in a stable order. Facts with
// a zero Ts are skipped, the same "no data" rule Fact applies.
func (s State) FactsUnder(prefix string) []store.Fact {
var out []store.Fact
for k, f := range s.Facts {
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// NudgedSince reports whether rule already sent a nudge at or after ts.
//
// It is what makes a rule edge-triggered. A polled fact is written only when
// the value changes, so its Ts is the moment the service went down — but the
// predicate reads the current value, so a service that stays down keeps
// qualifying forever and cooldown alone only slows the repetition. Asking
// whether he was already told about THIS transition stops it.
func (s State) NudgedSince(rule string, ts time.Time) bool {
n, ok := s.LastNudge[rule]
return ok && !n.Ts.Before(ts)
}
// Since returns the duration since the latest fact for key, or (0,false).
// "false" ⇒ no data ⇒ shuts up when uncertain.
func (s State) Since(key string) (time.Duration, bool) {
+8
View File
@@ -975,6 +975,9 @@ var fallbackNudges = map[string]string{
}
func fallbackNudge(c loop.Candidate) string {
if down := loop.DownServices(c.State); len(down) > 0 {
return "Не отвечает: " + strings.Join(down, ", ") + "."
}
if s, ok := fallbackNudges[c.Rule.Name]; ok {
return s
}
@@ -990,6 +993,11 @@ func buildNudgePrompt(c loop.Candidate) string {
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
ctxParts = append(ctxParts, "Что именно: "+f.Key)
}
if down := loop.DownServices(c.State); len(down) > 0 {
// The names come from the same helper the rule fired on, so the model
// is never handed a service that is actually up.
ctxParts = append(ctxParts, "Какие сервисы лежат: "+strings.Join(down, ", "))
}
if d, ok := c.State.Since(c.Rule.Name); ok {
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
}
+14 -9
View File
@@ -142,16 +142,21 @@ func phraseNudge(c loop.Candidate) (body, summary string) {
}
return body, "take a break"
case "service_down":
// the fact value is json `"down"`; the key carries the service name.
body = "a service on homesrv is down — check journalctl."
summary = "service down on homesrv"
if f, ok := c.State.Fact("service_down"); ok {
if f.Key != "" && f.Key != "service_down" {
body = fmt.Sprintf("%s on homesrv is down — check journalctl.", f.Key)
summary = fmt.Sprintf("%s down on homesrv", f.Key)
}
// One fact per kuma monitor, so the nudge names the service. The rule
// and this share loop.DownServices, so the message cannot name a
// service the predicate did not fire on.
down := loop.DownServices(c.State)
switch len(down) {
case 0:
return "a service on homesrv is down — check journalctl.", "service down on homesrv"
case 1:
return fmt.Sprintf("%s on homesrv is down — check journalctl.", down[0]),
fmt.Sprintf("%s down on homesrv", down[0])
default:
list := strings.Join(down, ", ")
return fmt.Sprintf("%s on homesrv are down — check journalctl.", list),
fmt.Sprintf("%d services down on homesrv", len(down))
}
return body, summary
default:
// generic: name the rule + severity; the LLM impl replaces this with
// a prompted phrase. the Stub never editorializes beyond the rule name.
+29 -7
View File
@@ -78,12 +78,14 @@ func TestPhraseNudgeBreakDeskDuration(t *testing.T) {
}
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
// a service_down fact whose Key is the specific service name the phrase
// names the service, not just "service down".
// one fact per kuma monitor → the phrase names the monitor that is down.
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{"service_down": {Key: "nginx", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
Now: now,
Facts: map[string]store.Fact{
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`},
},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
@@ -96,12 +98,12 @@ func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
}
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
// the rule key itself ("service_down") rather than a specific service →
// the generic phrase, not a phantom "service_down down on homesrv".
// the old aggregate key, still in the store from before the per-monitor
// facts landed → the generic phrase, never a phantom "service_down down".
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
@@ -110,6 +112,26 @@ func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
}
}
// Two monitors down at once must both be named — he needs to know the blast
// radius, and "a service is down" was the whole defect being fixed here.
func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) {
now := time.Now().UTC()
st := loop.State{
Now: now,
Facts: map[string]store.Fact{
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
},
}
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
pn, _ := NewStub().PhraseNudge(context.Background(), c)
for _, want := range []string{"nginx", "db"} {
if !strings.Contains(pn.Body, want) {
t.Fatalf("body should name %q, got %q", want, pn.Body)
}
}
}
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
// a rule without a dedicated template — generic fallback names the rule +
// severity gist. never empty.
+35
View File
@@ -254,6 +254,41 @@ func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fac
return scanFact(row)
}
// LatestFactsByPrefix — the latest non-voided fact for every key that starts
// with prefix, newest-per-key, ordered by key.
//
// The loop's gatherer loads the keys its rules declare, which works while the
// key set is static. Kuma's monitors are not: one fact per monitor means the
// keys are only known once the gauge is read, so the rule declares the prefix
// and this read resolves it per tick. `_` and `%` are escaped — a monitor name
// is user text and must not act as a LIKE wildcard.
func (s *Store) LatestFactsByPrefix(ctx context.Context, prefix string) ([]Fact, error) {
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, kind, key, value, source, confidence, voids_id
FROM facts f
WHERE key LIKE ? ESCAPE '\'
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
AND id = (SELECT id FROM facts g
WHERE g.key = f.key
AND g.id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
ORDER BY g.ts DESC, g.id DESC LIMIT 1)
ORDER BY key`, esc+"%")
if err != nil {
return nil, fmt.Errorf("facts by prefix %q: %w", prefix, err)
}
defer rows.Close()
var out []Fact
for rows.Next() {
f, err := scanFact(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// Since returns how long ago the latest non-voided fact for key landed, or
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
// the spec — silence on no-data is "shuts up when uncertain".
+62
View File
@@ -0,0 +1,62 @@
package store
import (
"context"
"testing"
"time"
)
// One fact per kuma monitor means the loop cannot name its keys at wiring time,
// so it asks for the family by prefix. The read must return the newest row per
// key and stop at the prefix boundary.
func TestLatestFactsByPrefix(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
write := func(key, val string, at time.Time) int64 {
id, err := s.SetValue(ctx, KindEnv, key, "poll:uptimekuma", val, at)
if err != nil {
t.Fatalf("SetValue %s: %v", key, err)
}
return id
}
write("service_down:db", "up", now.Add(-2*time.Hour))
write("service_down:db", "down", now.Add(-time.Hour)) // newer wins
write("service_down:web", "up", now.Add(-time.Hour))
write("service_downtime", "irrelevant", now) // no colon, not in the family
write("water", "250ml", now)
got, err := s.LatestFactsByPrefix(ctx, "service_down:")
if err != nil {
t.Fatalf("LatestFactsByPrefix: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d facts, want 2: %+v", len(got), got)
}
if got[0].Key != "service_down:db" || got[0].Value != `"down"` {
t.Errorf("first = %s=%s, want the newest db row", got[0].Key, got[0].Value)
}
if got[1].Key != "service_down:web" {
t.Errorf("second = %s, want service_down:web", got[1].Key)
}
}
// A monitor name is user text. An underscore in it must match itself, not act
// as a LIKE wildcard and drag in every other monitor.
func TestLatestFactsByPrefixEscapesWildcards(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
for _, k := range []string{"a_b:one", "axb:two"} {
if _, err := s.SetValue(ctx, KindEnv, k, "poll:uptimekuma", "down", now); err != nil {
t.Fatalf("SetValue %s: %v", k, err)
}
}
got, err := s.LatestFactsByPrefix(ctx, "a_b:")
if err != nil {
t.Fatalf("LatestFactsByPrefix: %v", err)
}
if len(got) != 1 || got[0].Key != "a_b:one" {
t.Fatalf("got %+v, want only a_b:one", got)
}
}
+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()")
}
}