Compare commits

...

9 Commits

Author SHA1 Message Date
claude 86dcd99de2 docs: decide where mavwaked and mavenclient run — not on homesrv (V-463)
They appear in no compose file and run as no host process, and the task
asked whether that is a gap to close or a decision to write down. It is a
decision.

The reason is not hardware. homesrv is a Lenovo laptop and
/proc/asound/cards lists its ACP mic array with capture devices, so
passing /dev/snd into a container would work. It would also listen to an
empty room. A wake-word daemon is worth having where he is standing, and
that is not where the server is.

mavenclient is a client by name and design, mavwaked is the gate in
front of it, and the wire already reaches off-box: ipc.Dial takes
tcp://host:port?token=... through the netaddr seam, with the token
checked before internal/ipc sees the connection. So this needs a machine
and a config line, not protocol work.

The honest consequence is worse than the task suggested, and both docs
now say it: the wake word and the VAD gate are covered by unit tests and
by nothing else. QA session 1 step 2 was reworded to claim only what it
checks, which is push-to-talk through /dash. CLAUDE.md listed all nine
binaries with no column for where they run, which is how this went
unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:11:16 +04:00
claude 554181ccbd docs: correct four QA steps that described an older daemon (V-480)
Found running QA 253 on 02-08. Every one of the four failed the same
way: the daemon is right and the step is stale.

253/3 expected mavend to boot with the capture methods unknown when
there is no media block. Validate refuses to start instead
(config.go:1651), which is the better behaviour — a capture config with
nowhere to put the audio is a mistake he should hear at boot.

253/10 expected no :transcript note by default. writeNotes writes one
whenever the summary is empty, ignoring save_transcript, so a dead
llama-server does not lose the meeting. The step was therefore false in
exactly the degradation scenario 253/16 creates. It now says "with a
summary present".

255/5 expected "speaker: enrolment on, recognition BLOCKED". That line
no longer ships. Recognizes() was written as the gate, documented as
one, and never called; calling it turned enabled-with-no-model from a
half-working capability into a refusal, and the three methods are now
absent. docs/plans/10-speaker-recognition.md described the old wiring
and is corrected here too.

252/3 quoted "vision: stored image <id-prefix>". vision.go:199 emits
"vision: stored <id>".

The steps themselves live in the Vikunja tasks and were rewritten there.
docs/qa.md records what changed and why, so the next reader does not
re-derive it from a diff.

The gap that made the steps unrunnable is V-514, not this: no shipped
client can start a recording, so 253 steps 7 to 16 stay blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:08:19 +04:00
claude 58635f1a69 docs: decide the ambient calendar path — keep it, change the contract (V-432)
The task's confirmed defect is out of date. 4e4c917 added day words and a
past-grace refusal, so "завтра в 15:00" dates correctly, and 45a5e37
(V-482, this week) fixed a zone bug the task did not know about. What is
left is explicit dates ("5 августа"), which fail safe by being dropped
rather than stored on the wrong day. The task's third question also has
an answer: both readers hedge, plan.go:174 prefixes "похоже, ".

Everything else hangs on one question that this repo cannot answer, so
the doc names it as his: can the relay app read Android's calendar
provider, or only the notification text? A NotificationListenerService
sees a title and a body and cannot know a meeting's real start, so if
that is all there is, free-text parsing here is not a choice. If it can
read CalendarContract, the parser stops being necessary and nothing is
inferred at all. Reading the phone's calendar does not break the design
constraint, which is about holding a work credential on the homelab.

Decision: keep the endpoint, make a structured event the primary shape,
keep the free-text parse as the degraded path, delete only if the relay
is not being built. And do not patch the date parser first — that is the
patch the task explicitly refuses as closure, and it is the wrong order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:03:56 +04:00
claude fd3d063e02 docs: decide the board surface — build the board, not the argument (V-431)
The decision the task asked for. Build it, in a smaller shape than the
task imagined, because most of it is already there: the tasks table, the
capture parse, the recite matcher and the /tasks page all landed under
#130, #129 and #128.

Three findings changed the shape.

The intake form cannot live on the voice path. resolveConfirm is a
binary yes/no slot with a 90-second life, so filling four fields is a
mechanism nobody has written, and the definition of done is the worst
possible field to dictate through whisper. It moves to the page. Voice
captures a line and recites the list; the page turns a candidate into an
open item.

The stage-0 trick stretches to recite and to status change, both of
which are a marker plus a lookup. It does not stretch to intake, and it
does not have to.

A task is write-once except for its status. SetTaskStatus is the only
mutation, so the form has nothing to save into until an edit path
exists. That is now step 2 of four, and it was not in the task text.

The argument stays unbuilt. Same line internal/memory/behavior.go
already drew for habits: she counts a stall and never assesses one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:00:56 +04:00
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
32 changed files with 1188 additions and 242 deletions
+16 -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.
@@ -73,8 +77,8 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
| `mavttsd` | Text-to-speech (piper subprocess). |
| `mavwaked` | Wake-word / VAD gate. |
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
| `mavpoll` | Telegram long-poll reach. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
@@ -83,6 +87,15 @@ Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/ser
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
daemon there listens to nobody. They belong on a client machine where he is standing.
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
needs building to allow it, but no such machine exists yet. **The consequence: the wake
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
## The ecosystem: Nexus, Praxis, Hexis
Maven is one of four services. It owns conversation and personal memory. It does not
+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),
+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,
}
+88
View File
@@ -0,0 +1,88 @@
# Note recall after the e5-small swap — 04-08-2026
Closes Vikunja #371, which asked for the swap and for this re-measurement. The embedder is no
longer paraphrase-multilingual-MiniLM-L12-v2. It is **multilingual-e5-small**, quantized, with the
`query:` / `passage:` prefixes it was trained with (`internal/router/onnxembedder.go`,
`EmbedQuery` / `EmbedPassage`). `deploy/mavend.json` loads
`models/embedder/multilingual-e5-small/model_quantized.onnx`, which is the same file `make
download-embedder` fetches and the same file this run measured.
- Fixture + scorer: `internal/memory/recalleval/` — 32 cases now, not 30
- Reproduce: `make eval-recall`
- Commit: `b6abb19`
- Gate as deployed: `query_min_score` 0.55, `query_min_margin` 0.008
The fixture grew since 31-07, so the case counts are not comparable row for row. The percentages
are.
## Results
| | 31-07 MiniLM (onnx) | 04-08 e5-small (onnx) |
|---|---|---|
| **recall@1** | 60.0% (15/25) | **70.4% (19/27)** |
| recall@3 | 80.0% (20/25) | **85.2% (23/27)** |
| **answered after the gate** | 48.0% (12/25) | **63.0% (17/27)** |
| **false recall** | 1/5 (20%) | **0/5** |
| wrong note on top / tie on top | 10 / 0 | 8 / 0 |
| ranked first, then silenced by the gate | 3 | 2 |
| `hard` cases passed | 2/11 | 5/12 |
| RU / EN passed | 13/24 / 3/6 | 18/26 / 4/6 |
| latency p50 / p95 / max | 59ms / 148ms / 194ms | **23ms / 41ms / 62ms** |
The hash ratchet CI runs is unchanged in kind and still answers nothing after the gate: recall@1
37.0%, recall@3 74.1%, 0/27 answered, 0/5 false. It is lexical and exists so CI has a deterministic
floor. Never compare a hash number to an ONNX one.
## Findings
### 1. The swap paid on every axis at once, including latency
Ten points of recall@1, fifteen points of *answered*, the one false recall gone, and it is 2.5×
faster because the quantized e5-small is 118MB against the 470MB fp32 file the old config loaded.
Finding 3 of the 31-07 eval predicted the recall half and said nothing about speed; the speed came
from fixing the second half of that finding, which was that the deployed path loaded a different
file than the download target.
The concrete case that eval named is fixed. "из-за чего кончилось место" no longer returns the
guitar-chords filler note. It now returns a homelab note, `n2` at 0.884, and the wanted note is
still not in the top 3 — so the query moved from absurd to merely wrong. That is the shape of what
is left.
### 2. The score distributions still overlap. The margin is what separates them
This is the part of #371's premise that did not come true. Right-note-first top-1 scores run
0.791 / 0.857 / 0.890 (min / median / max). Must-stay-silent top-1 scores run 0.795 / 0.815 /
0.835. The silent cases sit *inside* the answering range, so no value of `query_min_score` keeps
every real recall and rejects every false one — the same verdict as 31-07, at a higher and tighter
band of scores.
What separates them is the second-place gap. Margin top1-top2 for a right first hit: median 0.024.
For a must-be-silent case: median 0.002, max 0.019. A false recall is a note that beats its
neighbours by nothing, because nothing in the store is about the question. The sweep:
| margin | answered | false recall |
|---|---|---|
| 0.000 | 18/27 (67%) | 3/5 |
| 0.005 | 17/27 (63%) | 1/5 |
| **0.008 (deployed)** | **17/27 (63%)** | **0/5** |
| 0.010 | 15/27 (56%) | 0/5 |
| 0.015 | 12/27 (44%) | 0/5 |
0.008 is the knee: it is the smallest margin that silences all five, and the next step up costs two
real answers for nothing. The score gate contributes almost nothing on its own — every value from
0.00 to 0.70 answers the same 18 and admits the same 3 — so `query_min_score` is now close to inert
and the margin is the live dial. Leave both where they are; #412 is where a further sweep belongs.
### 3. What is left is a retrieval problem, not a gate problem
Eight cases put the wrong note on top, and the failures cluster: `hard` 5/12, `preference` 5/9,
`homelab` 8/13. Four of the eight have the right note in the top 3, so a reranker would collect
them; the other four do not, so nothing downstream can. Two more rank first and are silenced by the
margin — `en-hard-024` at 0.826 with margin 0.023, and `ru-home-026` at 0.846 with margin 0.001,
which is a genuine near-tie against a second note that is also plausible.
Preference queries are the weakest class in a way that is not about the model. "когда запускать
резервное копирование" and "как мне присылать оповещения" both return a fact, not the note that
states the preference. Facts and notes are searched in one pass since #373, so a confidently-scored
fact wins a question that a note answers better. That is a ranking policy question and it belongs
in its own task, not in a threshold.
+62
View File
@@ -0,0 +1,62 @@
# How reactiveHandler is wired
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
The decision Vikunja #433 asked for, and the rule that follows from it.
## The decision
**Group the fields into cohesive wiring structs. No container, no generator, no
framework.** `reactiveHandler` (`cmd/mavend/voice.go`) stays the one type the voice
server talks to. What changes is that a capability arrives as one named group, not as
four more loose fields on a struct that already had thirty.
The pattern was already in the file before this was written down: `searchWiring`,
`kiwixWiring`, `homeWiring`, `netWiring` and `ecosystemWiring` are all this shape, each
`nil` when the capability is off. #433 makes it the rule rather than a habit, and adds
the case the habit had missed — a group that is not a capability toggle.
## The worked example
`recallWiring` (`cmd/mavend/recall.go`) holds the five things the recall path needs:
the embedder, the vector store, the personal boundary, and the score and margin that
gate an answer. They used to sit in three separate places on the handler with the two
gate numbers a hundred lines away from the store they gate.
Its zero value means "no recall", which is why it is a value and not a pointer. The
`*Wiring` types that model an optional capability stay pointers, because `nil` is how
"not configured" is spelled and a zero-valued search client would be a client pointed at
nothing.
## Why not the alternatives
**Narrow consumer-side interfaces at each handler** is the more idiomatic Go answer and
it is not rejected, only deferred. It is the right move at the point a handler is pulled
into its own package, because that is when the import direction starts to matter. Doing
it first would mean writing an interface per handler against a struct nobody can pass
anywhere, which is churn bought against a package split that has not happened.
**A container or a wire-style generator** is rejected outright. This is one binary with
one composition root (`wireVoice` in `cmd/mavend/voicewire.go`). Generated wiring would
add a build step and a layer of indirection to solve a problem that is currently one
composite literal long, and it would make the "is this capability configured" question
harder to answer by reading, which is the question this file is mostly about.
## What this unlocks
The reason `cmd/mavend/` cannot split into `mavend/actions/` today is that every action
handler is a method on a struct with thirty unexported fields: moving handlers to a
subdirectory means exporting all of them or inventing an interface to pass through. That
was the answer given on the tick.go and voice.go splits, and it is still true. Grouping
is the step that makes it false later — a handler that takes `recallWiring` and nothing
else can move without the other twenty-five fields following it.
## The rule
**A wiring change does not ride a feature PR.** The voice.go and tick.go splits were
safe to merge because the moved code diffed identical, line for line. A regrouping that
touches the confirm gate or the act allowlist is its own change, reviewed on its own, or
it is not reviewable at all.
New capability, new group. A capability that adds four fields to `reactiveHandler`
instead of one struct is the thing this decision exists to stop.
+8 -3
View File
@@ -103,9 +103,14 @@ query path.
```
`Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off
rather than as a capability that fails every turn. With `enabled` and no model the daemon still
attaches the three methods — profiles can be created, listed and deleted — and logs that
recognition is blocked.
rather than as a capability that fails every turn.
That gate was written, documented, and then never called. It is called now, and the behaviour it
describes changed with it. `enabled` with no `model_path` used to attach all three methods and log
that enrolment was on. Today `newSpeakerWiring` returns nil, so the methods are absent, and the log
says why: there is nothing to embed with, so enrol, list and forget would all be no-ops. That is
the one config shape where the operator most needs to be told otherwise, and it was the shape that
lied.
## Still open
+119
View File
@@ -0,0 +1,119 @@
# Plan: The work board surface
**The decision Vikunja #431 asked for. Written 04-08-2026.**
**Verdict: build it, in a smaller shape than the task imagined.** The board is worth
moving out of the file. The intake form belongs on the `/tasks` page, not on the voice
path. The argument is not built, now or later.
## Why it is worth building
The reason is the one the task gives and it holds: company rules forbid pointing Claude
at work repos, and Maven is the one assistant on the box that work material may reach.
No telemetry, no cloud model, no third-party account. That is not a preference here, it
is the whole permission.
The build is also small, because most of it landed already:
| Piece | Where | State |
|---|---|---|
| task rows, dedupe, status lifecycle | `internal/store/migrations.go:149` and migration #15 | done |
| capture from speech, urgency stripped | `router.ParseTaskCapture`, `router.TaskCaptureGrammar` | done |
| recite the list on request | `router.IsTaskListQuery` | done |
| a page to read and change the board | `/tasks` in `cmd/mavweb` | done |
| counting a shape without judging it | `internal/memory/behavior.go` | done, as precedent |
| a proposal he reads when he chooses | `/routines`, the proposed-routine queue | done, as precedent |
`tasks` already carries `status` (candidate, open, done, dropped), `due_ts`, `weight`,
`source`, `evidence`, `ext_id` and `resolved_by`. Three things are missing. It has no
definition of done and no blocked-on. There is no way to edit a task after capture:
`SetTaskStatus` moves the status and nothing writes text, date or weight again. And
there is no grouping he controls, because order is computed by `tasks.Rank` alone.
## Where the form lives, and why not voice
The task asks the form to refuse a capture with no definition of done. That refusal
cannot live on the voice path, for two reasons.
**The parked-state mechanism is binary.** `resolveConfirm` in `cmd/mavend/confirm.go`
answers yes or no against a slot with a 90-second life. Filling four fields over four
turns is slot filling, which is a different mechanism and a new one. Nothing in the
daemon does it today.
**The definition of done is the worst possible field to dictate.** It is the one string
that has to be exact, because its whole purpose is to be unarguable later. Whisper
transcribing a sentence of Russian work vocabulary is where exactness goes to die, and
the capture path already had to strip a question mark that whisper invented.
So: voice captures a line and recites the list. The page is where a line becomes an
item with a definition of done, a blocked-on and a date. A captured line lands as
`candidate` and stays there until it is filled in, which is what `candidate` was for.
The refusal the task wants survives, moved: the page will not promote a candidate to
`open` without a definition of done, the same way `ParseTaskCapture` will not file a
marker with nothing after it. And the field must close on either outcome, so "it already
works" counts as complete. A definition of done that only one result satisfies is a wish.
## Does the stage-0 trick stretch
The task asks this before any shape is committed to. It was checked. The answer is
partly.
`TaskCaptureGrammar` matches every utterance and lets `ParseTaskCapture` decide inside
`Build`, keeping the intent at `note` and leaving the frozen seven-intent contract alone.
That trick stretches to **recite** and to **status change**: both are a marker plus a
referent, both are a lookup, and a status change is a small closed verb set over a list
he can see. It does not stretch to **intake**, because intake is not one utterance, and
it does not need to, because intake moved to the page.
One cost to name. Each such grammar matches everything and runs its parser on every
turn, ahead of the resident model. Two more of them is fine. A dozen would make stage 0
a second router with no evaluation behind it, and at that point the frozen enum is the
smaller problem.
## What is not built: the argument
Not now and not later behind a flag. The task is right about why, and
`internal/memory/behavior.go` already argued it for habits: a 1.7B asked whether evidence
proves anything will agree fluently and launder a guess into a decision. A wrong claim
about his work, stated confidently, is the most expensive kind of wrong Maven can be.
The line is the same line behaviour memory drew. She may **count**:
- no state change in eleven days
- blocked on a person, with no date
- four of nine waiting on two people
Those are queries over rows. She may not assess whether a build proves anything, whether
a blocker is real, or whether a task should be dropped.
## Persona
A progress tracker is a nag by default, and "not a nag" is hard. The line is already
drawn twice in the codebase and it is drawn the same way here:
- A date he set becomes a reminder. He set it, so it is not her raising it.
- A stall becomes a proposal he reads when he chooses, on a page, like `/routines`.
- Ask what is on the board and she recites. She never opens with it.
The day plan is the place to watch. `tickLoop.dayPlan` reads calendar events, pending
reminders and checklist facts, and it does not read tasks. Adding the board to the
morning nudge is exactly the move that turns this into a nag, so the board goes on the
page and into the answer when asked, and not into the unprompted morning message.
## The build, as tasks
1. Two columns on `tasks`: definition of done, and blocked-on. Blocked-on resolves
through Nexus like any other person reference, because identity lives in Nexus.
2. An edit path. Today a task is write-once except for its status, so the form has
nothing to save into.
3. `/tasks` grows the form: promote candidate to open only with a definition of done,
set a date, set blocked-on. A date set here writes a reminder.
4. A status-change grammar at stage 0, following `TaskCaptureGrammar`.
5. Counted stall shapes on `/tasks`, phrased as counts. No assessment.
Note for whoever picks up 3: `/tasks` accepts its POST without the step-up gate, while
`/routines` and `/tools` require a passkey. That was deliberate for capture. Adding an
edit path is the moment to re-argue it, not to inherit it silently.
Each is separable and each is worth stopping after.
+87
View File
@@ -0,0 +1,87 @@
# Plan: What the ambient calendar path should be
**The decision Vikunja #432 asked for. Written 04-08-2026.**
**Verdict: keep the endpoint, change the contract.** The relay app sends structured
fields, not a notification blob. The free-text parser stays as the degraded path, because
there is a real case where the phone cannot produce structure. Delete the endpoint only
if the answer to the one open question below is no.
## First, the task's premise is out of date
#432 states as confirmed that every ambient event lands on the day the notification was
posted, because there is no date parsing at all. That was true when the task was filed
and it is not true now.
`4e4c917` added `dayWords` and `dayOffset` (`internal/calendar/ambient.go:53`), so
"завтра в 15:00" now dates to tomorrow. The same commit added `ambientPastGrace`, which
refuses an event landing more than two hours before the notification, on the reasoning
that the day was inferred and a stale inference is wrong rather than late. `45a5e37`
(#482, this week) fixed a second dating bug the task did not know about: the wall clock
was resolved against the notification's own zone, so every ambient meeting on a non-UTC
box landed off by the deploy's UTC offset.
What is still missing is an explicit date. "5 августа в 15:00" and "12.08 15:00" carry no
day word, so they date to today and the past-grace check drops them. That is a smaller
defect than the one filed, and it fails safe rather than storing a wrong meeting.
The task's third question also has an answer, and the answer is yes. `internal/morning/plan.go:174`
prefixes an uncertain item with "похоже, " and `cmd/mavend/actions_query.go:334` carries
`Confidence < 1.0` into the calendar recital. Both readers hedge.
## The open question, and it is the owner's
**Can the phone read Android's calendar provider, or only the notification text?**
Everything follows from this and nothing in this repo can answer it.
A `NotificationListenerService` sees a title and a body. It cannot know a meeting's real
start, end or organiser, because those are not in the notification. So if the relay is
limited to the notification stream, free-text parsing on this side is not a choice, it is
the only thing available, and #432's suggestion that the phone send structured JSON
cannot be honoured.
If the app may instead read `CalendarContract`, it has the actual event rows, and the
whole parser stops being necessary. That is the better shape by a wide margin: a real
start and end, a real title, an explicit date, no clock-reading heuristic and no
past-grace guard, because nothing is inferred.
Reading the phone's calendar provider does not break the constraint the design was built
around. The refusal in `internal/calendar/ambient.go:10` is about holding a work
credential **on the homelab**, which is what ties the box's blast radius to the employer.
The phone already holds that session. Nothing new lands on homesrv either way.
**Assumption, and it needs his answer:** a managed work profile may block a third-party
app from reading work calendar rows. If it does, the notification stream is all there is.
## The decision
**Keep the endpoint.** Deleting it costs the parser, the tests and the wg-facing token,
and buys nothing while the question above is open. It is off unless `-ambient-token` is
set, so an unbuilt relay carries no surface today.
**Make structured the primary shape.** `/api/ambient` should accept an event with an
explicit start, end and title, and store it without parsing anything. Confidence stays
below 1.0 and the source stays `ambient:notif`, because the provenance claim is unchanged:
this is the phone telling Maven what it sees, not Maven reading a calendar.
**Keep the free-text shape as the degraded path.** It is what a notification-only relay
can send, and it is already written and tested.
**Delete it instead if** the relay is not going to be built. That is the one answer that
closes this without code, and it is his to give.
## What not to do
Do not add date parsing to the free-text path yet. That is the patch #432 explicitly
refuses to accept as closure, and it is the wrong order: if the relay can send a date, no
date parser is needed, and if it cannot, the parser is guessing at a date from text that
was never meant to carry one.
## Follow-on, unrelated to the decision
`cmd/mavweb/ambient.go:33` records a known gap worth keeping visible: an ambient meeting
writes `calendar_event_*` and never `calendar_busy`, so it is good enough to recite and
not good enough to suppress a nudge. That is backwards. Suppressing a nudge is the
lower-risk use of a low-confidence signal, and reciting one is the higher-risk use. It
needs an expiry on the busy level, so it is its own task either way.
@@ -0,0 +1,60 @@
# Plan: Where mavwaked and mavenclient run
**The decision Vikunja #463 asked for. Written 04-08-2026.**
**Verdict: not in compose on homesrv. They run on a client machine in the room he is in.**
The transport for that already exists and nothing needs building to allow it. What needs
building is a way to check the wake path at all, which is a separate task.
## The state that prompted this
`docker-compose.yml` runs mavend, mavsttd, mavttsd, mavweb and mavpoll. `mavwaked` and
`mavenclient` appear in no compose file and run as no host process. Both build under
`make build`. So the wake word and the voice-activity gate are untested by construction:
QA session 1 step 2 covers push-to-talk from `/dash` only, and #287 (voice session
quality) can never be more than half-answered while this holds.
## The reason is not hardware
homesrv has a microphone. It is a Lenovo IdeaPad 5 Pro, and `/proc/asound/cards` lists
the ACP digital mic array with capture devices at `/dev/snd/pcmC1D0c` and
`/dev/snd/pcmC2D0c`. Adding `/dev/snd` to compose and joining the `audio` group would
work.
It would also be pointless. A wake-word daemon is worth having in the room he is standing
in. homesrv is a server, so its microphone hears the room the server is in, which is not
where anybody talks to Maven. Wiring audio into a container to listen to an empty room is
work spent on a capability nobody can use.
## The reason it belongs off-box
`mavenclient` is a client by name and by design: microphone, then STT, then core, then
TTS. It is the one binary in the tree meant to run somewhere else. `mavwaked` is the gate
in front of it, so it goes wherever the microphone goes.
Maven already has components that are not containers on homesrv. `mavupdate` is a
host-side tool. The resident model, STT and TTS prefer the workstation and fall back to
homesrv (`docs/offload.md`). Off-box is a shape this system already has.
**And the wire already supports it.** `ipc.Dial` takes a netaddr seam address:
a bare path is the unix socket, and `tcp://host:port?token=...` reaches a core on another
host, with the token checked in `internal/netaddr` before `internal/ipc` sees the
connection. So a client machine reaching mavend over wg or the LAN needs no new protocol
work. It needs mavend to listen on TCP, which `deploy/mavend.json` does not currently ask
for.
## What this means for the QA plan
QA session 1 step 2 should say what it actually covers, which is push-to-talk through
`/dash`. It should not read as though it covers the voice loop. The wake path is checked
on the client machine or it is not checked, and today there is no client machine.
That is the honest state, and it is worse than the task suggests: this is not a
configuration gap that a compose entry closes. Until a machine with a microphone runs
`mavwaked` and `mavenclient` against a TCP-listening mavend, `internal/wake` and
`cmd/mavwaked`'s VAD are covered by their unit tests and by nothing else.
## What was wrong in CLAUDE.md
The daemon table lists all nine binaries with no column for where they run, which is how
this went unnoticed for as long as it did. It now says which two are not on the box.
+35 -9
View File
@@ -1,6 +1,6 @@
# QA plan: checking Maven properly
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-04 @ 58635f1. Living doc: correct it in place, do not append.*
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
@@ -105,9 +105,9 @@ turns look misaligned when they are not.
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
back. This is the only check that covers mic to STT to core to TTS to
speaker as one path. It is also the path the eleven-day outage most likely
broke.
back. This covers browser mic to STT to core to TTS as one path. It does
**not** cover the wake word or the voice-activity gate, and no step here
does — see below.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
@@ -129,11 +129,18 @@ turns look misaligned when they are not.
and stitch unrelated topics. Asked whether he should move flats, she opened
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
appear in no compose file and run as no host process. Step 2 covers only
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
are untested by construction. Decide whether they belong in compose or on a
client machine, and say which in the deploy docs. Tracked as **463**.
**The wake path cannot be checked here, and that is now the decision rather
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
no host process. They are not going to. They belong on a client machine in the
room he is standing in, because homesrv's microphone is real and in the wrong
room — **463**, written up in `docs/plans/17-where-the-voice-loop-runs.md`.
So the wake word and the VAD gate are covered by their unit tests and by
nothing else, and no session at this box changes that. Checking them needs a
machine with a microphone running both binaries against a TCP-listening mavend.
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is a machine
and a config line, not protocol work. Until then, **287** can only be
half-answered, and step 2 above is push-to-talk, not the voice loop.
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
@@ -471,6 +478,25 @@ at any address (**478**). `allow_private` does work, measured both ways.
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
never calls it (**480**). Two of its QA steps are also stale.
**Four stale QA steps were rewritten on 04-08-2026** under **480**, against the
code rather than against what the plans said. All four failed the same way: the
daemon was right and the step described an older daemon.
| Step | Said | Says now |
|---|---|---|
| 253/3 | boots with the methods unknown | refuses to boot, `config.go:1651` |
| 253/10 | no `:transcript` note by default | true only with a summary present |
| 255/5 | `speaker: enrolment on, recognition BLOCKED` | that line is gone, the capability stays off |
| 252/3 | `vision: stored image <id-prefix>` | `vision: stored <id>`, `vision.go:199` |
Two of them are worth reading past the correction. 253/10 was false in exactly
the scenario 253/16 creates, because `writeNotes` saves the transcript whenever
the summary is empty so a dead llama-server does not lose the meeting. And 255/5
changed because `Recognizes()` was written as the gate, documented as one, and
never called — calling it turned `enabled` with no model from a half-working
capability into a refusal. Enrolling into a store nothing can match against is
not a working half.
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
need the block enabled. Step 10 is Bluetooth and stays skipped.
+5 -3
View File
@@ -1,6 +1,6 @@
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
> Supersedes the classifier-first routing model. Agreed in a design session
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
@@ -40,8 +40,10 @@ utterance
are deferred until the main feature set is complete.
- **Embedder demoted from router to tool** — it now backs `memory.search`
(RAG) and gives the router a cheap "similar past notes/intents" hint. The
router no longer depends on it clearing a threshold. Upgrade MiniLM → bge-m3
for better RU retrieval later (model swap, not architecture).
router no longer depends on it clearing a threshold. The MiniLM upgrade is
done: it is multilingual-e5-small, asymmetric, with the `query:`/`passage:`
prefixes (Vikunja #371, `docs/evals/2026-08-04-recall-e5-small.md`). A
further swap is a model swap, not architecture (Vikunja #412).
### Router output
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
-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"
}
+1 -1
View File
@@ -356,7 +356,7 @@ func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine,
Action: r.Action,
Object: r.Object,
IntervalDays: r.IntervalDays,
Status: r.Status,
Status: string(r.Status),
CreatedTs: r.CreatedTs.UnixMilli(),
}
if r.ReminderID != nil {
+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()")
}
}