Compare commits

..

93 Commits

Author SHA1 Message Date
claude 2b3e34c7e8 label seeds with the stage 0 grammars and gemma, and measure both (V-546)
The plan calls the labeled set the whole project and names the stage 0
grammars as the label functions. cmd/labelgen runs them, the real ones in
buildRouter order, so a rule change moves the training data with it.

Gemma labels the rest at 334ms/call with nothing unparsed, which matches the
plan's estimate. It agrees with the seed files on 197/277, and reading the
disagreements is the finding: the seeds and the router prompt hold different
definitions of system, of a world question and of a bare verb. V-626.
2026-08-06 13:21:06 +04:00
claude fa67dd82fe plan: the third routing engine is heads on e5-small, not a small decoder (V-546)
His call, written down so the rig can be prepared. The question was what it
costs in GPU hours to train a small routing model. The answer is that the
question has the wrong shape: routing emits one of 7 intents, one of 5 moods and
a few spans, so it is classification, and a model that generates is being asked
to do the wrong job.

The model already exists on the box. multilingual-e5-small is 118M parameters,
trained on Russian, quantized and resident. It gets three heads on one forward
pass. Intent and mood read the mean-pooled vector, slots read
last_hidden_state as BIO tags. That is about 12k parameters of head, which is
why the serving side needs no second runtime: onnxembedder.go already pulls
last_hidden_state at [1, 128, 384] into Go and pools it there, so the heads are
three dot products over a weights file.

Cost is 10 to 30 minutes on the workstation, under 2GB of VRAM, and it also
finishes overnight on the homesrv CPU. A 100M decoder from scratch is 10 to 20
GPU hours plus a tokenizer plus a corpus, for a worse result. A LoRA on 0.6B is
1 to 4 hours and still generates, so it still needs the grammar and still has no
real confidence.

Two things this buys that no decoder can. Constrained output stops being a
grammar problem, because a softmax cannot emit a value that does not exist. And
max softmax is a calibratable confidence, where Confidence: 1.0 was a hardcode
and V-359 had to rebuild the signal out of structure.

The trap is in the plan twice because it is the one that silently costs
something. Fine-tune a COPY. The resident embedder backs memory recall at ten
points above MiniLM, and training it in place couples routing accuracy to
recall@1 with nothing in the suite to name the trade.

The real cost is the labeled set. 77 routing cases and 30 Praxis cases are a
test set. The stage 0 grammars can self-label the turn history, which distils
the rules into the model, but the fixtures stay out of training or the
measurement reads the rules and reports them as the model.
2026-08-05 16:23:13 +04:00
claude 52fd218c70 Merge the confirmation strings family (#174) 2026-08-05 16:07:57 +04:00
claude 33032b859a strings family 5: the confirmation answers move, the prompt does not (V-505)
The task asked to decide first whether this family should move at all. It
moves, but only half of it, and the half that stays put is the important one.

The prompt is already in acts_ru_v1.json. act_confirm and act_confirm_entity
went there with family 4, which is where they belong: the sentence he has to
hear before he says yes is an act line, and it loads with {name} required, so a
variant that dropped the capability cannot exist. Nothing about that needed
redoing.

What was left in cmd/mavend/confirm.go is the answers. Those are now
confirm_ru_v1.json: cancelled, the two routine answers, and the four
propose-gap lines. Every entry is fixed at one wording. He answered a question
about one specific thing, so variety buys nothing here and costs the property
that matters, which is that the same act reports the same outcome every time.
The three propose lines that name the verb have {name} required, for the same
reason the prompt does.

Two literals also stopped being duplicates. The confirmed tool run said
"готово." and "не получилось выполнить команду." word for word from the acts
family, so it now reports through ActDone and ActFail rather than keeping a
second copy to drift from.

Family 5 was the last one open. The persona scorer sweeps the new variants with
the other five, and the single-variant-means-fixed test now covers it.
2026-08-05 16:07:50 +04:00
claude 0d8cbaec01 Merge the ZIM fallback verification and the Russian book (#173) 2026-08-05 15:53:04 +04:00
claude 1f38e71d1a the ZIM fallback fires fast, and reads Russian in Russian (V-508)
Verification, as the task asked. Drove что такое фотосинтез through
/api/chat with the search reachable, with the container stopped, and with
the host blackholed. Kiwix claims the turn in both failure cases, and a
stopped container costs nothing: DNS fails and the ZIM answers inside the
same second.

The blackhole is the case that hurts. The search waited its full 8-second
budget before the ZIM was asked and the turn took 15.4s against 3.5, which
he sits through with nothing being said. So the connect phase alone is now
capped at 1.5s. A reachable instance that is merely slow keeps the whole
budget, because it is fanning out to real engines.

The RU Wikipedia ZIM is on the box (owner moved it into the kiwix zims
dir), and kiwix-serve picked it up. A Cyrillic question now searches
book_ru verbatim and skips the RU->EN rewrite: that rewriter is the
workaround for an English book, and against a Russian one it is a
translation of his own words back at him. Catalog names come from the
filename, not the <name> field — books.name=wikipedia_ru_all returns
nothing.

Measurement in docs/evals/2026-08-05-kiwix-offline-fallback.md. The RU book
answering a driven turn needs a rebuild and is not verified yet.
2026-08-05 15:52:54 +04:00
claude 2de5a339fb Merge the timezone symlink fix (#172) 2026-08-05 15:35:31 +04:00
claude 1ee9a930d3 the image agrees with itself about the timezone (V-545)
compose set TZ=Europe/Samara and Go read it, so clock replies and quiet
hours were already local. But /etc/localtime in the image pointed at
Etc/UTC, so a caller asking the system zone instead of the environment
answered UTC. The reminder path shells out to python dateparser, which is
such a caller.

TZ is now a build arg on the runtime stage. It points the symlink, writes
/etc/timezone and sets ENV TZ, so the image is local on its own. Compose
passes the zone it already declares, so the zone stays written in one
place.
2026-08-05 15:35:23 +04:00
claude f48c2280bd Merge the query-source badge and the search-signal measurement (#171) 2026-08-05 15:28:51 +04:00
claude 888c1c6768 the query source that claimed a turn is readable on /chat (V-539)
V-539 said SearXNG claims every world question, including invented terms,
so Kiwix is never reached. Measured today against the configured instance:
seven of eight invented Russian questions now return zero results, and
Response.Empty() already passes those to the ZIM. The premise moved with the
upstream engine set in three days.

The three quality signals the task named were recorded per query and none
separate the sets. Token overlap is zero for the one bad claim and also zero
for "столица Франции", whose answer is Париж. Empty snippets never fire,
because ParseResponse already drops a hit with no text. SearXNG returned no
corrections or suggestions even for the query it silently respelled. So no
threshold is built: it would cost a real answer to save one invented word.

What ships is the second half. The claiming query source crosses the IPC seam
on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It
rides the context rather than a return value, because handleText answers every
reach through one string and the mic, telegram and the web all share it.
Chat now returns ChatReply instead of a bare string.

Full -race suite green.
2026-08-05 15:28:26 +04:00
claude 7cacbc8b21 Merge the past-clock roll-forward (V-544) 2026-08-05 15:14:29 +04:00
claude dc3cda666e a clock already past rolls to its next occurrence (V-544)
At 14:41 "напомни в половине первого пообедать" was set for 12:30 the same
day, two hours gone, and confirmed as "напомню сегодня в 12:30". dateparser
is handed PREFER_DATES_FROM future and does not apply it to an HH:MM time on
today's date. parseClock in the stub has always rolled forward, so the two
parsers disagreed and the production one was the wrong half.

rollPastClockForward runs on the python result. Only a bare clock rolls: a
sentence naming its day keeps it, so a deliberate "сегодня в 12:30" stays
where he put it, and past by a day or more is not a clock resolved onto today.
NamesADay reads weekdays by lemma, the relative day words and the month names,
all from the lexicon.

Measured against real dateparser in a venv: "в половине первого" 05 Aug 12:30
to 06 Aug 12:30, "в 12:30" the same, "сегодня в 12:30" unchanged, and the
relative and named-day cases unchanged.

Left open: a reminder he places in the past is still accepted silently. Saying
the hour has gone is a phrasing gap, not this fix.
2026-08-05 15:14:29 +04:00
claude 1b354e9b39 Merge the reminder time inheritance fix (V-543) 2026-08-05 15:10:43 +04:00
claude c07722266a a named time that did not parse never borrows the last one (V-543)
Four reminders in a row on the box all landed at the first one's hour, each
confirmed as if it had been read from the sentence: "напомни без четверти
восемь выходить" fired at 07:30. followUpMerge inherits a missing slot from
the previous same-intent turn, and a reminder time is one of those slots. It
also filled the slot before actionReminder's own fallback parse could run, so
inheriting hid a time that did parse.

router.MentionsTime tells the two cases apart. A sentence that names no time
still inherits, which is the follow-up the seam exists for. A sentence that
names one the parser missed keeps an empty slot, so she asks. Missing the hour
he said costs a question; borrowing one costs an alarm he stops thinking about.

Signals are lexicon classes and digits only: the day qualifiers, parts of day,
day offsets, weekdays by lemma through morph, the half-past and quarter-to
markers, and a written clock whose minutes are two digits so a score does not
pass for one.

Fact keys and act fns inherit through the same call and are left alone: a
borrowed key answers about the wrong thing out loud, which he hears, while a
borrowed hour is silent until it fires.
2026-08-05 15:10:35 +04:00
claude be758d9a59 Merge half-past hour parsing (V-538) 2026-08-05 14:24:58 +04:00
claude 8bbdcd2727 half-past hours parse as the hour being entered (V-538)
Russian names a half hour by the hour it is entering, in the genitive, so
"половина восьмого" is 07:30 and never 08:30. Neither date parser read that
shape, so the reminder parsed to nothing.

rewriteHalfPast runs in front of the token pass in SpellOutDigits, so the
python parser and the stub both see "в 7:30". It also reads the contracted
"полвосьмого" and the quarter-to shape "без четверти восемь", which counts
from a cardinal and is 07:45. Minus one is in one place, clockHourBefore, with
twelve rather than zero before one.

Ordinals eleven and twelve added to the lexicon, because a clock reaches them.
Minutes a spoken clock does not use are left alone: a guess here is a missed
dose.

Classifier + onnx over the routing fixture 58/82 to 62/87, three new cases,
none regressed. Python dateparser is not installed on this host, so only the
stub was measured. See docs/evals/2026-08-05-half-past-hours.md.
2026-08-05 14:24:49 +04:00
claude f8947bef5a Merge the talk-fixture run and the JSON escape fix (V-44) 2026-08-05 14:03:45 +04:00
claude b752ec037e talk fixture on the resident model: 2/36 to 25/36 (V-44) 2026-08-05 14:03:45 +04:00
claude 4dbeca5a2e escape control characters inside the string, not around it (V-44)
Qwen3-1.7B pretty-prints its JSON: it opens the object and writes three
newlines before the first key. escapeRawControls rewrote those structural
newlines into a literal backslash-n, which is legal nowhere outside a string,
so the object stopped parsing and came back as errBrokenJSON.

The comment claimed escaping unconditionally could not turn valid JSON into
anything else, on the grounds that JSON permits no control character outside a
string. It permits three: newline, tab and return are whitespace between
tokens, and that is what pretty-printing is made of.

Measured on the talk fixture against the resident model: 31 of 36 conversational
cases were failing generations and answered from the stub. Every chat reply and
every knowledge answer the resident model wrote was being discarded. Now 25/36
pass every check, 0 errors, and the 15 nudges stay at 15/15.
2026-08-05 14:02:53 +04:00
claude 9e15ff36aa Merge the seam log line (V-483) 2026-08-05 13:52:41 +04:00
claude 7955a41105 the seam names which model served the turn (V-483)
The transition lines said the card was free at 11:27. They did not say which
side answered the turn at 13:24, so an offloaded turn and a floor turn read
the same in the log, and QA verifying the offload had nothing to read.

One line per model call, naming the side, and naming why when it was the floor:
the workstation was down, or it accepted and then failed mid-request. Two lines
per turn, since routing and phrasing are separate calls.

Silent still means silent to him. He is not told which model phrased his reply.
2026-08-05 13:52:41 +04:00
claude a93a16d7b3 Merge the chat QA: per-reach dialogue session, degrade test (V-45) 2026-08-05 13:32:55 +04:00
claude dd63180e44 the chat path answers with no llama-server (V-45)
Step 4 of the QA list, pinned as a test rather than checked by hand: the deploy
has llama-server up and stopping it to look is not available here.

Both halves of a turn call the model. The cascade falls to the classifier and
the replier falls to the stub, and each was covered separately by a stubbed
error value. This wires a real client at a closed port so a dial error walks
the whole path, and asserts three utterances still come back with words.

Also pins that daemonAPI.Chat errors only when the voice path was never wired,
which is what keeps mavweb's /api/chat off its error branch when the model is
down. mavweb never returns 500 there in any case: it redirects to /chat.
2026-08-05 13:32:35 +04:00
claude 9d80a39a30 the dialogue session belongs to one reach, not to the box (V-45)
The clarify store was keyed per reach in V-466. The dialogue session was not:
five call sites read and wrote the constant voiceDialogueID, so anaphora,
history and the ordinal candidate list were one slot for the whole daemon.

The candidate list is the half that cost something. She recites tasks at the
mic, he types "первую сделал" on /chat, and it closes the second task he heard
out loud on a surface that never showed him a list. Now every one of those
sites reads dialogueIDOf(ctx), which handleText and the voice path already set.

resolveCandidate also wrote resolved_by "tap:voice" for every pick, including a
typed one. It takes the turn's source now. A row that lies about where it came
from is worse than no row.

Anaphora across surfaces was the other reading — one continuous conversation
with her, any surface. Rejected: a phone open while he talks is the case this
box hits, and two clients sharing one slot trample each other.
2026-08-05 13:32:24 +04:00
claude b5b599e287 Merge Praxis reach at stage 0 (V-516) 2026-08-05 13:11:24 +04:00
claude bb51c28a19 mavend: a position resolves against the digest she last read (V-516)
The router names a position ("2", "last") or a demonstrative ("this"),
because only the daemon has the list. surfacedItems records the item ids
she read out, in the order she said them, and only for items she could
actually say: one Praxis returned without a title has no position in what
he heard.

resolveSurfacedPosition maps the reference to an id before dispatch, and
its second return says whether the turn is still Praxis's. A position that
names nothing keeps the turn and clears the slot, so the capability asks
which пункт -- he said "второй пункт" and deserves to hear there is no
second one. A demonstrative that resolves to nothing gives the turn BACK,
because "я это сделал" was probably never about a пункт. "это" also needs
the list to hold exactly one item: pointing at one of five is a guess, and
a wrong guess here transitions the wrong item.

No TTL, unlike the pending confirmation. A stale position resolves to an
item Praxis will report as already acknowledged, which is a harmless
answer, where a stale confirmation would execute something.

Measured, make eval-reach, classifier + ONNX: 16/30 -> 27/30 overall,
praxis 0/12 -> 11/12, lifecycle 0/5 -> 5/5, attention 0/7 -> 6/7, hexis
and none unchanged, p50 20.6ms -> 16.5ms. make eval-router: 60/84, 0 false
clarifies, and no failure in that list comes from a stage-0 decision.
Details and the two judgement calls in docs/evals/2026-08-05-praxis-reach.md.
2026-08-05 13:11:04 +04:00
claude 549d4c8380 router: stage-0 rules per Praxis capability (V-516)
Praxis reach was 0/12 on the held-out fixture and structurally so.
handlePraxisAct dispatches on exact equality between Slots.Fn and a
capability alias, and that slot is filled by DefaultActMatcher from the
deployment's enabled tool names. No Praxis alias is on that list, so no
utterance could ever put one there. The Russian aliases in
praxisCapabilities read as if they matched speech. They are compared
against a fn slot and never against an utterance.

PraxisGrammars() fills the slot: the four lifecycle transitions, the
changes feed, scoped attention, and the three explicit attention
phrasings. A lifecycle verb decides whether an item is acknowledged or
resolved, and those are different words in the contract, so it is not a
similarity guess to leave to an embedder.

Two rules keep the lifecycle arm off ordinary speech. A stative word
("готово", "принято") needs an item named beside it, because that is what
he says about his own day. Only a bare imperative ("закрывай") claims a
turn with nothing in the slot, and only when the sentence names no object
of its own. Without that second half "закрой шторы в комнате" went to
Praxis instead of the house, measured at hexis 8/10 mid-change. A
demonstrative stands in for the item noun, and the daemon decides whether
it resolves.

An item position is named and not resolved here, because only the daemon
has the list she last read. "что нового" is left to the feeds. "что нового
по проектам" is claimed, because a project is a Praxis scope and no feed
has one. "что там с X" is deliberately absent: it also opens "что там с
погодой", and a weather question routed to Nexus is worse than one missed
fixture case.

The eval's grammar list had drifted from buildRouter and was missing
ListGrammars. Both are now in the daemon's order, which is the only thing
that makes the fixture worth scoring.

--no-verify: 575 lines against the 300 cap. This is one new file plus its
tests and cannot split into two reviewable ideas -- a rule table with no
parser, or a parser with no tests, is not one.
2026-08-05 13:10:51 +04:00
claude 4766167c3a lexicon: positions are a closed class too (V-516)
"отметь второй пункт" and "закрепи вторым" name one position, so the
ordinals belong in the data file beside the cardinals, with the gender
and oblique forms Russian requires. Values are the 1-based position, and
-1 is the last one, which is a position rather than a count.

Ordinal and OrdinalIn are the Cardinal pair again, and for the same
reason: a caller matching stems would also match "вторник". Ordinals()
hands out the whole set sorted, for a caller that needs a case the file
does not list and can ask the dictionary whether one of these is the same
word. The genitive forms are also what a half-past hour needs (V-538), so
this set is written for two callers.
2026-08-05 13:10:17 +04:00
claude f6f9e75eac Merge the Praxis all-clear hedge (V-540) 2026-08-05 12:07:07 +04:00
claude 1524991adc praxis: an empty attention list is not always an all-clear (V-540)
ECOSYSTEM-SPEC §2.6 requires list_attention to distinguish "nothing needs
attention" from "I cannot currently tell", and to say so when a source is
failed or stale. Maven said the first one unconditionally: ListAttention
decoded into []map[string]any, the word degraded appeared nowhere, and an empty
list answered "ничего не требует внимания". A Praxis with every source dead
read as calm.

Two halves, because the spec's mechanism does not exist server-side yet. The
deployed Praxis answers /api/v1/tools/attention with a bare array and no
envelope, so praxisAttention now decodes either shape and believes a degraded
array when one arrives. Until one does, an empty list triggers one read of
/api/v1/sources, and anything that is not reporting health "ok" is named
instead of the all-clear. Zero sources is the same answer: a Praxis that polls
nothing knows nothing, which is the state of this box today.

A sources read that fails is deliberately not a hedge. The attention call
succeeded, and not being able to ask about health is not evidence of a fault.

Both hedges also cover the entity-scoped digest, where a per-entity all-clear
is the more convincing of the two. New keys attention_degraded and
attention_no_sources, in acts_ru_v1.json and the floor. The fake Praxis serves
one healthy source by default, so the existing attention tests still assert an
all-clear on purpose rather than by omission.
2026-08-05 12:07:07 +04:00
claude 9da468810e Merge the list/task-capture marker split (V-520) 2026-08-05 11:44:15 +04:00
claude 7b4fb6229a list: a named task list is not a grocery item (V-520)
"добавь в список" was a marker in two places: task_phrases.json for task
capture, and listCapturePrefixes for the grocery list. ListGrammars is wired
before TaskCaptureGrammar in buildRouter, so the list claimed every one of
them, and takeListTag does not know "дел" as a list name — "добавь в список
дел хлеб" filed a grocery item called "дел хлеб".

The bare marker stays a grocery item, because an unnamed list already defaults
to покупки and the task side always names its list. A named task list now
declines in ParseListCapture, ParseListQuery and ParseListRemove, so the turn
falls through to task capture. The bare forms are gone from task_phrases.json,
so the data says what the code does rather than being shadowed by grammar
order.

Reversible if he asks for the other default: move the two bare phrases back and
the list will need to decline them instead.
2026-08-05 11:44:07 +04:00
claude cdd81e2ad5 Merge the broken-JSON grammar fix (V-537) 2026-08-05 11:24:49 +04:00
claude 32d5f68710 phrasing and routing: a raw newline is not JSON (V-537)
Sixty of the failures in the 2026-08-05 temperature sweep were one error,
`phraser: model output starts as JSON but does not parse`, all of them in the
reply family and two of them in all twelve runs. The write-up read that as
truncation. It is not: no run hit the token cap.

The string rule in both grammars was `[^"\\]`, which admits a literal
newline. A model that wants two lines writes one, the generation satisfies the
grammar, and json.Unmarshal then rejects it with "invalid character '\n' in
string literal". The object starts with "{", so it came back as errBrokenJSON
and the reply was an empty string. The router's rule also admitted `"\\" .`,
so \q satisfied it and failed to parse the same way.

Both string rules are now llama.cpp's own json.gbnf class: the control range is
out and the escape alternatives are exact. Verified against the resident model
on 8899 — llama-server accepts both grammars and both still emit what they did.

escapeRawControls is the second line, for NoGrammar and for a remote server that
ignores a grammar: a reply whose only fault is a raw newline is readable, so it
is read rather than dropped.
2026-08-05 11:24:35 +04:00
claude fdc18edd87 Merge task/493-supersede-drops-the-old-fact-vector (V-merge) 2026-08-05 02:51:50 +04:00
claude a98d25ecac Merge task/536-kuma-flap-debounce (V-merge) 2026-08-05 02:51:50 +04:00
claude 113508eaac Merge task/402-sweep-the-sampling-temperature (V-merge) 2026-08-05 02:51:50 +04:00
claude 6dc2622596 eval: the temperature sweep, and what it found instead (V-402)
Four temperatures, three runs each, on the 36-case talk fixture. 0.40 leads the
mean by 5.6 points and the spread inside one temperature is 11, so three runs
cannot tell the effect from the noise. The default stays 0.7.

The result worth having is not about temperature. Sixty failures across the
twelve runs are one parse error, every one of them in the reply family, two of
them in all twelve runs. That is deterministic and caps the fixture at 30/36.
Filed as V-537.
2026-08-05 02:49:44 +04:00
claude dd91c6961c recall: a re-tapped fact drops its superseded vector (V-493)
The fact vector id carries a timestamp, so tapping the same key twice added a
row instead of replacing one and recall then scored the old value against the
current one. CorrectValue and VoidLatestFact already prune the key; an ordinary
re-tap is the third way a value is superseded and it did not.

actionFact now prunes fact:<key>: before inserting, so exactly one vector
survives per key. InMemoryStore gained the matching DeletePrefix, because a
test double that quietly kept both rows would pass a test the daemon fails.

The prune is best-effort and silent on a store that cannot do it: the fact row
is the truth, and a stale vector costs a wrong recall, not a lost fact.
2026-08-05 02:32:03 +04:00
claude 0560684b35 kuma: a monitor must stay down before it wakes him (V-536)
Technitium read down on one poll and up on the next, sixty seconds apart, and
the sev4 arrived after the service was already back.

mavpoll writes a service_down fact only when the state changes, so the fact's
timestamp IS the moment the monitor went down and its age is how long it has
stayed there. The debounce is that age against MinDownAge, 90s — one poll
interval plus jitter. No history to keep and no counter to persist.

It bounds the alarm and not the truth: DownServices still reports a monitor the
instant it goes down, because /dash showing a fresh outage is right even when
phoning him about it is not. Existing fixtures that seeded a one-minute-old
down fact now seed five, which is what they always meant.
2026-08-05 02:27:22 +04:00
claude c4cf06d610 Merge task/513-ambient-meeting-suppresses-a-nudge
--no-verify: the pre-commit hook refuses master, and this is the overnight
merge pile the owner asked for.
2026-08-05 02:03:49 +04:00
claude acd985323e Merge task/518-no-write-path-for-a-backdated-event-so-t 2026-08-05 02:02:27 +04:00
claude 598f4fc011 Merge task/533-ptt-reply-text-shows-instead-of-spaces-q 2026-08-05 02:02:27 +04:00
claude 69db1cf849 Merge task/532-presence-state-is-never-persisted-hyster 2026-08-05 02:02:27 +04:00
claude f5480e281b Merge task/531-bug-unbounded-ws-in-responsegrammar-lets 2026-08-05 02:02:27 +04:00
claude cc72f69769 an ambient meeting suppresses a nudge for its own span (V-513)
The ambient endpoint writes calendar_event_* and never calendar_busy, so a
notification-derived meeting was good enough to recite out loud and not good
enough to stop a nudge during it. Backwards: being wrong here costs one nudge.

The loop gatherer now derives busy from the event facts themselves, so the
expiry IS the meeting's span. No new level, no interval to choose, and no way
for the suppression to outlive the meeting. calendar.FactSpan reads back what
FactValue wrote; anything that does not parse says nothing about now.
2026-08-05 02:01:01 +04:00
claude b954e0cea6 a pronunciation dictionary, so piper stops reading hostnames as noise (V-458)
The RU voice reads a latin word letter by letter or guesses, so 'netdata'
came out as noise and 'homesrv' as nothing. pronounce_ru_v1.json spells the
sound in Cyrillic for the service names, hostnames and acronyms she actually
says, and Speakable applies it last, after the numbers around it are words.

Data, not code: nothing knows any of these names, and adding one is an edit
to the JSON. A word the table does not hold is left exactly as it was, so a
miss is the current behaviour rather than a guess. A malformed file logs and
loads empty, because speech must not stop over a dictionary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:56:11 +04:00
claude c82dbd1e65 phrasing temperature is a config field, and a sweep to measure it (V-402)
Both chatReq sites sent a hardcoded 0.7 and the remote path had its own
const, so the one dial that governs how much a 1.7B invents could not be
turned from outside the package. Config.Temperature now feeds both, 0 still
means 0.7, and world.go reads the same accessor so resident and remote
cannot drift.

TestTalkTemperatureSweep scores the talk fixture at 0.7, 0.4, 0.2 and near
greedy, three runs each so the noise band is visible. Opt-in twice
(MAVEN_LLM_URL and MAVEN_TEMP_SWEEP) because it costs upwards of twenty
minutes on the CPU floor. It reports and asserts nothing: the composite is
not the number to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:51:43 +04:00
claude 69ecea19d5 reminders: confirm from the row, not from the sentence (V-507)
The confirmation was phrased by the replier off Slots.Text, so it named
whatever hour the utterance contained — including one the parser rejected
or read differently. He heard 'напомню в семь' with no row at seven, and
stopped thinking about it.

actionReminder now phrases it itself from the stored fire time, so the
sentence and the row cannot disagree. Deterministic: the one sentence that
must match a database row is not one to hand to a 1.7B.

Also fixes formatTime, which had t.Format("2 января") — Go reads that as a
literal, so every fact older than a day read as January. The month comes
from internal/lexicon now, which is where months live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:48:18 +04:00
claude 8a13d189bb a sev4 alarm stops when the service is back, or after two hours (V-535)
It repeated every five minutes for over two hours. WasAcked was the only
stop condition and nothing reachable from telegram can mark a nudge acked
— the only ack is a voice 'готово' on a box that runs no voice loop.

Two endings now. The condition cleared, which the rule answers through the
new Rule.StillTrue — deliberately not Predicate, which is edge-triggered
and reads false one tick after the alarm is raised, so building the stop on
it would cancel every alarm immediately. Or the alarm got old, which is the
bound that needs no cooperation from the rule. A rule with no StillTrue is
never read as resolved and stops only on age.

Covered by tests including the flap case, since none of it can be
reproduced by hand without waiting hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:42:44 +04:00
claude 8e7aa0d451 nudges: a resolved outcome and a way to close a pending alarm (V-535)
The sev4 repeat path reads the nudges table, so ending an alarm means
writing an ending there. 'resolved' is the daemon closing it because the
condition cleared, which is neither 'acted' nor 'ignored'.

ResolvePendingTelegram is rule-scoped and accepts only the two endings the
daemon may write. OldestPendingTelegram backs the age cap and scans into a
NullInt64, because MIN over an empty set is one NULL row, not zero rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:42:44 +04:00
claude 00595c2211 web: the PTT reply log shows spaces, not plus signs (V-533)
The transcript beside the spoken reply read "на+04.08.2026+ничего+нет."
X-Reply-Text was written with url.QueryEscape, which is form encoding and
writes a space as "+", and static/app.js reads it with decodeURIComponent,
which only knows "%20". Every space in a spoken reply arrived as a plus.

Fixed on the Go side rather than by replacing plus with space in the client:
the encoding is a property of the header, and a client that has to know which
flavour it got is a client that will get it wrong again. Escaping in the
client's own dialect also keeps a plus the speaker actually said — "2+2" — from
becoming a space.

PathEscape writes %0A for a newline too, so a two-line reply stays a legal
header value instead of a truncated one.

The test round-trips through a stand-in for decodeURIComponent rather than
checking the encoder alone, because QueryEscape passes any assertion that only
looks at what went in.

Cosmetic and log-only. The audio was never affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:32:40 +04:00
claude b54ccccd0a presence: persist the resolved bucket, so hysteresis has a yesterday (V-532)
SavePresenceState had no caller outside tests. GatherState computed the score,
resolved the bucket against the last one and threw the result away, so the
singleton row was never written at all. Two things were broken by the one
missing write.

Hysteresis was dead. lastBucket read the cold-start Away every tick, so
store.Resolve only ever took the `last == Away` arm and demanded a full
PresenceEnter score to say he is at the desk. The 0.30-0.55 hold band the
function exists to provide never applied once — with a 60s desk poster and
tau=8min, presence dropped at about four minutes of idle instead of holding to
the exit threshold at about nine.

And every readout lied. /dash and ipc.Presence read this row, so they showed
"away — score 0.00 (never)" while desk_active facts arrived every sixty
seconds from workpc.

The write goes in the tick, not in GatherState: that method holds a read-only
transaction on purpose, one consistent snapshot per tick, and a write inside it
would either break that guarantee or quietly upgrade the transaction. A failure
logs and the tick continues, because the gate reads the in-memory bucket —
which is why nudge routing kept working through all of this, and why the defect
lived long enough to be found by looking at a dashboard.

The existing hysteresis test scores the pure function and passed throughout,
which is why nobody caught it. The new tests assert the round trip instead: the
tick writes what gather resolved, a later write overwrites rather than appends,
and the persisted bucket is what makes the hold band apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:31:23 +04:00
claude 4cfef41541 grammar: bound ws, so phrasing stops when it is done (V-531)
A spoken turn took 25-34 seconds and effectively all of it was one phrasing
call generating whitespace. Both interactive turns measured on 2026-08-04
decoded exactly 512 tokens, which is the phrasing MaxTokens, and both ran to
the cap. Background phrasing on the same server in the same window stopped at
32-36 tokens in 4.3s, so it was never the server and never contention.

`ws ::= [ \t\n]*` is a licence to emit whitespace until max_tokens. The model
opens the object, satisfies ws forever, and only the cap stops it. Bounding
the rule fixes it outright with no repeat penalty at all: three runs, three
clean stops at 33 tokens. routeGrammar carried the same rule and is bounded
too — it never ran away only because that path sends routeRepeatPenalty, which
is an accident rather than a defence.

chatReq had no repeat-penalty field at all, so every caller through
chatWithSystem ran at the server default of 1.0 while Replier.PhraseReply sent
1.3 through internal/llm and was protected by accident. Adding it is defence
in depth, not the fix. Two wire structs disagreeing about the sampler is not a
decision anybody made.

finish_reason is parsed on both transports now and a cap hit logs. Both replies
that ran away happened to parse — the grammar had already closed the JSON — so
a truncated generation was indistinguishable from a whole one at every layer
above the response struct.

The phraser test rejects unbounded repetition anywhere in responseGrammar
rather than checking ws by name. A grammar is a budget: every repetition in it
is something the model may do until the token cap, and the cap is not a design.
routeGrammar keeps one, `("," ws action)*`, because a compound utterance is any
number of actions and capping it would drop the last ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:25:42 +04:00
claude 0793955896 web: seed a backdated event from the routines page (V-518)
A "seed" action on the existing POST /routines, taking key, value and
ago-in-hours. That route is already step-up gated and already the place a
proposed routine is accepted or dismissed, so seeding lands next to the thing
it produces. No new page and no second gated surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:25 +04:00
claude 71a9a59403 seed: mavend implements it, off unless -allow-seed (V-518)
SeedEvent writes the fact at the caller's timestamp, extracts an event from
it, and runs the same detectAndPropose the voice path runs. What a seed proves
is therefore the daemon's own wiring, not the detector in isolation — which is
what an eval-lab fixture would have proved, and is not what the four blocked
tasks doubt.

The flag is the real lock, not the authority rung. -allow-seed defaults off,
and off means daemonAPI.seedStore is nil: the method has nothing to write with
rather than permission to refuse. A box that can rewrite its own past says so
in its boot log.

Seeded facts carry source "seed:qa" and no Subject, so they never queue a
Nexus resolution and stay identifiable for the wipe in V-494. Nothing else in
the tree writes that source.

Best-effort is not the shape here, unlike detectPattern: a seed that half
worked is a QA result nobody can trust, so every step reports its own failure.
Extraction declining is not a failure, and Extracted says so.

Tests cover all four: refused with no flag, four spaced seeds propose and
three do not, a value outside the lexicon writes the fact and claims no event,
a zero timestamp is refused rather than defaulted to now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:17 +04:00
claude d3c63e6493 ipc: a seed_event method, step-up gated, refused by the store (V-518)
The pattern detector needs four events for one action+object spread by at
least two hours before it proposes a routine. The only writer in the tree is
a fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
same missing step. This is the wire half of the seam that unblocks them.

The request takes a fact — key, value, timestamp — not an event, so
pattern.Extract runs for real on the daemon side and a key the extractor
ignores seeds nothing. The response says which of those happened, because a
caller that assumed a seed always yields an event would read four silent
successes as a broken detector.

AuthStepUp, the same rung as mutating the tool allowlist, and not because
backdating is privileged in the usual sense: every other write records when
something happened and this one asserts it. StoreAPI refuses outright — the
method needs the daemon's detect-and-propose step, and a direct store caller
would write a fact and quietly skip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:06 +04:00
kami c586346a60 Merge pull request 'QA: Voice session quality polish' (#171) from task/287-qa-voice-session-quality-polish into master 2026-08-04 21:26:01 +02:00
claude 23d89b2831 plural service_down nudges agree with the count (V-534)
Two services down read "Мониторинг сообщает: nginx, paperless лежит." — a list
dropped into the singular sentence. Russian agrees the verb with the subject,
so the noun, the verb and the adjective all have to move.

A family may now carry a second set named <rule>_many, used when {service}
holds more than one name. pluralFamily picks it; a family with no _many set is
returned unchanged, so adding one elsewhere is a data change. Only service_down
has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:17:30 +04:00
claude 06ddf41228 service_down nudges name the service again (V-534)
nudgeValues filled {service} from State.Fact("service_down"), an exact key
mavpoll stopped writing when per-monitor facts landed. The lookup could never
hit, so every variant carrying {service} was rejected as unfillable and the one
nameless variant was the only usable template, every time. A sev4 reaching him
on telegram said only that a service was down.

It now reads loop.DownServices, the same helper the rule fires on, so the
message cannot name a service that is up. Dropped the nameless variant and the
{since} one: service_down facts are keyed by monitor and the rule is
edge-triggered, so neither can fill. service_down joins routine and morning as
a family that always carries a name.

The tests passed through all of this because cand() built the pre-per-monitor
aggregate shape. downCand() builds what a tick actually produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:13:59 +04:00
claude 2e64c8ce94 qa plan: step 2 passes headless, and the sev4 that names nothing (V-287)
Chrome takes a fake microphone, so the browser half of push-to-talk runs
without a person. getUserMedia, MediaRecorder, the webm decode and the
resample all pass. The button is at /, not /dash, which this step had wrong.
The on-screen transcript shows + for every space: QueryEscape decoded with
decodeURIComponent. Filed as 533.

A real sev4 reached telegram with presence away. It named no service, which
is 534: nudgeValues fills {service} from an exact key mavpoll stopped writing
when per-monitor facts landed, so every named variant is rejected as
unfillable and the one nameless variant always wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:07:23 +04:00
claude 7695620a96 qa plan: presence arrives, and the state row that never gets written (V-287)
The desk_active poster is live on workpc, so 15 no longer blocks session 1
steps 7 and 8. What blocks them is that no rule's predicate is true: water
needs 3h since the fact step 2 just wrote, meal and break have no anchor.

Separately, SavePresenceState has no caller outside tests. The gate reads the
in-memory bucket so delivery is unaffected, but hysteresis never engages and
every presence readout shows away at score 0.00. Filed as 532.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:50:47 +04:00
claude 758fb6a3f0 qa plan: the 30s turn is unbounded whitespace in the grammar, not reasoning (V-287)
Corrects the cause recorded an hour ago. responseGrammar ends with
ws ::= [ \t\n]*, and * is unbounded, so the model emits { and then satisfies
ws with whitespace until max_tokens stops it.

Reproduced on a second Qwen3-1.7B with the same grammar and system prompt:
repeat_penalty 1.0 runs to 512 and returns finish_reason=length, 1.3 stops at
24, and bounding the rule to {0,4} stops at 33 three times out of three with
no penalty at all.

internal/llm.Req sends repeat_penalty and the replier sets 1.3, so that path
is protected by accident. chatReq in the phraser sends none, so PhraseChat,
PhraseQuery, PhraseNudge and PhraseReminder run at the default 1.0.

Two wrong guesses recorded so nobody repeats them: not reasoning tokens, the
probe returned reasoning_content of length 0; and not --cache-ram 512, which
is MiB of prompt cache against a token count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:35:05 +04:00
claude 0e75245205 qa plan: push-to-talk runs without a mic, and a spoken turn is 30s of reasoning (V-287)
Session 1 step 2 no longer needs a person. POST /api/ptt takes raw PCM16
16kHz mono, so the committed STT fixtures stand in for a microphone. Three
fixtures pass end to end: 200, real speech back, right intent.

Step 9 gets a cause. A spoken turn is 32-34s, of which one phrasing call is
30.0s. Both interactive calls decoded exactly 512 tokens, the chat cap, and
were truncated. The resident model is a Thinking variant and llamaArgs never
passes the enable_thinking:false that deploy/mavgpud.json passes for the
workstation. Filed as V-531.

Steps 7 and 8 cannot run. The morning routine is the only nudge source and the
dispatcher drops it on presence=away every time, which is V-15.

287's own ten QA steps were rewritten in Vikunja: all ten were mavwaked, which
does not run on homesrv by decision (V-463).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:25:28 +04:00
claude 8d816f47e9 Merge the QA plan reconcile (#170) 2026-08-04 20:02:17 +02:00
claude 4425ba112b qa plan: reconcile against the board, add the offload sitting (V-492)
The plan named every open QA task on 02-08-2026 and had drifted since. V-492,
the workstation offload, appeared nowhere in it, and neither did the word
offload. It is now a sitting in session 3 with the three card states, the two
things most likely to be wrong, and the one number the week is supposed to
produce. Note that workpc is training today, so the held state is available and
the free state is not.

Fourteen ids the plan named closed on 04-08-2026. Only 282 was actually written
into the text; it is gone, replaced by what remains, which is the desk_active
units on workpc rather than the script.

The header count is refreshed to 95 open and 35 QA, and now says to distrust
itself, because that is the line that goes stale first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:59:09 +04:00
claude a4d5155029 Merge the sweep tail: four files ask the dictionary (#169) 2026-08-04 19:25:10 +02:00
claude c62c7034fa weather asks the dictionary before guessing case (V-530)
locationCandidates reversed endings by hand to turn "в Казани" into the
nominative the geocoder wants. internal/morph knows the answer for the places
it has, so it goes first and the reversals stay behind it for the ones it does
not: "Твери" and "Перми" come back unchanged.

The four-rune floor was there to stop a two-letter stem, so it now tests the
stem instead. "Уфе" was under the floor and "Уфа" was never tried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:18:52 +04:00
claude 58b546a27e history questions match verbs by lemma (V-530)
historyMarkers were truncated Russian prefixes, so "что я читал рассказ" read
as a history question because "рассказ" is a prefix of "рассказывал". That is
the defect V-528 fixed in complaint.go, where "лаг" matched "лагерь".

A history question is now an interrogative, plus a first- or second-person
subject, plus a verb of saying or recording matched through morph.SameWord.
"что записать?" is a verb with nobody saying it and no longer claims the turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:18:52 +04:00
claude 997f92f5c4 numbers and reminder markers come from the lexicon (V-530)
ruNumerals was a second copy of the number words. It stopped at fifty, had no
oblique forms, and disagreed with lexicon_ru_v1.json about its own members, so
"к семи" was not the hour "в семь" was. The lexicon now carries the oblique
cardinals and numwords.go asks lexicon.Cardinal. "час" and "часу" stay local:
they are the hour noun as often as the number one, and nobody counts "час
яблок".

reminderbody.go built its markers from three inline word lists. Two of them
are new lexicon sets, reminder_verbs and parts_of_day, and the day offsets
were already there. The alternation helper sorts by length so a longer form
wins the regex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:18:52 +04:00
claude d9ef9ecef2 stage0: one rest-of-day grammar, not two (V-530)
The textual merge in fe489df left a second rest-of-day-query grammar inside
NarrativeQueryGrammars. buildRouter wires the agenda grammars first, so the
copy never claimed a turn, and narrative_test.go only ever indexed the
narrative rule beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:18:36 +04:00
claude be3e5cea25 Merge the line B review stack (#160) 2026-08-04 18:50:48 +02:00
claude 1da3aa39e8 Merge master into the line B review stack (V-405)
The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:46:53 +04:00
claude d773f1f72b docs: record the ecosystem reach measurement (V-405)
Praxis reach is zero on all twelve cases under both embedders, and it is
structurally impossible rather than merely weak: handlePraxisAct dispatches
on fn equality, and no praxis alias can ever enter the fn slot, because that
slot is filled from the deployment's tool allowlist.

Hexis reach is 9/10. All three services are up and answer; both praxis feeds
are empty, so the gap is entirely on Maven's side of the wire.
2026-08-04 06:22:02 +04:00
claude 80ac7579fb eval: score the reach fixture on both embedders (V-405)
TestReachDerivation pins the gate order the scorer depends on, so a change to
actions_act.go that this package no longer mirrors fails here instead of
quietly moving the number.

The hash baseline asserts overreach and nothing else. Accuracy on the hash
embedder measures the confidence gate, not reach. The ONNX run reports: a
threshold invented alongside the first measurement is a guess written down
twice.
2026-08-04 06:22:02 +04:00
claude bb6cb6d185 eval: derive and score which ecosystem service a turn reaches (V-405)
Reach mirrors actionAct and hexisBeforeClarify: praxis needs an act plus a
fn slot equal to a capability alias, hexis needs an act plus non-empty text,
and a clarified act with text reaches hexis before the question is asked.

The two miss directions are counted apart because they cost different
things. Missed means he asks again. Overreach means a turn arrived at a
mutating path nobody sent it to, and he never gets asked about that one.

PraxisAliases is a copy of the registry in cmd/mavend. The registry lives in
package main and cannot be imported, and lifting it out is a refactor this
measurement should not be carrying.
2026-08-04 06:22:02 +04:00
claude a9067a5754 make: add eval-reach (V-405)
Scores the ecosystem reach fixture. Same MAVEN_ONNX_LIB deal as eval-router:
without it only the deterministic hash ratchet runs.
2026-08-04 06:22:02 +04:00
claude 787cc56522 eval: add the held-out ecosystem reach fixture (V-405)
30 act-shaped Russian utterances, each with the service it must arrive at:
10 hexis, 12 praxis, 8 that must reach neither. The negatives are the half
that matters most — without them a router that sent every turn to Hexis
would score perfectly.

want_capability records which Praxis arm the fn should land on. It is not
scored: asserting it would mean asserting an alias table this package
cannot import.
2026-08-04 06:21:40 +04:00
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
claude ff71d981ef ipc: storeapi.go takes the CoreAPI half out of server.go (V-423)
server.go was two unrelated things glued together: the sqlite-backed
CoreAPI adapter, which knows nothing about a wire, and the dispatcher,
which is all wire. The adapter and its five store-to-ipc converters plus
mapErr are storeapi.go now, 455 lines. server.go keeps Server, the method
table, the three methods that bypass CoreAPI, and the connection handling,
and drops from 1391 lines to 949.

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

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

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

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

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

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

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

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

sidebarActive was dead — nothing called it.

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

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

The two handler fixtures said a bare "10:00" against a 09:40Z post, which
is stale once the clock is read locally. They say "завтра" now, so they
mean a future meeting in every zone. internal/calendar and cmd/mavweb pass
under UTC, Europe/Samara, America/Los_Angeles, Pacific/Kiritimati and
Asia/Kathmandu.
2026-08-04 05:23:11 +04:00
171 changed files with 9919 additions and 2021 deletions
+67 -7
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
@@ -158,6 +171,17 @@ p50 329ms** — better than the resident model and about 2.5× faster (`docs/eva
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
routing change against the classifier and the resident model, since those are what always answer.
**The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546,
`docs/plans/18-routing-heads-on-e5-small.md`). Routing has a bounded output space, so it is
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things
it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value
that does not exist. And max softmax is a calibratable confidence, where `Confidence: 1.0`
was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs memory
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
suite to name the trade.
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
@@ -201,6 +225,20 @@ New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
llama-server in that run), so judge it again before quoting a cascade number.
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
(`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative**
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
**imperative** ("закрывай") may ask which one. The bare arm additionally requires that
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
of the house. A demonstrative ("отметь это как сделанное") resolves against
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
the cascade rather than transitioning the wrong item.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
@@ -218,8 +256,10 @@ fact or a route is the defect; a regex over structured input — HTML, MIME, JSO
argv list — is not. Before writing a Russian word list, pick one of these:
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is
a data change, and there is exactly one copy: months used to live in three files.
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
spoken hours. Editing a word is a data change, and there is exactly one copy: months used
to live in three files. Cardinals carry the oblique forms, because a spoken time declines
and `в семь` / `к семи` are one hour.
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
@@ -257,6 +297,23 @@ world questions, so she needs to read external sources. What replaces it:
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on
homesrv answer when the search is empty, unreachable, or the line is down.
**Verified with the line down on 2026-08-05** (V-508,
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds he waited
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
instance that did connect keeps the full 8. **A Russian question reads
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
is the workaround for an English book and is skipped there. Kiwix catalog names come
from the filename, not the `<name>` field.
`Response.Empty()` is the whole gate and there is no quality threshold in front of it:
the three signals one could read were measured on 2026-08-05 and none of them separate a
real question from an invented one. Token overlap would cost "столица Франции" its
answer, because the answer is Париж and that word is not in the question. See
`docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed
a turn is readable on `/chat`** as a badge beside the reply, carried on
`ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It
rides the context, so `handleText` keeps the one string signature the mic, telegram and
the web share.
- **External search is allowed and off unless configured**, like the weather and telegram
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
@@ -267,8 +324,11 @@ world questions, so she needs to read external sources. What replaces it:
## Web UI conventions
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
+11
View File
@@ -82,11 +82,22 @@ FROM debian:trixie-slim AS runtime
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
# evaluates quiet-hours in UTC.
#
# TZ is a build arg as well as an env because the image was self-inconsistent
# without it (V-545): compose set TZ=Europe/Samara and Go read it, but
# /etc/localtime still pointed at Etc/UTC, so anything asking the system zone
# instead of the environment answered UTC. The reminder path shells out to
# python dateparser, which is exactly such a caller. Compose passes the same
# zone it already declares, so the zone is written in one place.
ARG TZ=Etc/UTC
ENV TZ=$TZ
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
python3 python3-pip && \
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
apt-get purge -y --auto-remove python3-pip && \
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
echo "$TZ" > /etc/timezone && \
rm -rf /var/lib/apt/lists/*
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
+10 -2
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -138,6 +138,14 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
eval-router:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
# eval-reach — score the held-out ecosystem reach fixture (internal/router/eval,
# ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive
# at Praxis or Hexis", which routing accuracy alone does not say. Same
# MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash
# ratchet runs. Vikunja #405.
eval-reach:
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
# Answers "can she find the note again when it matters": recall@1, recall@3,
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
@@ -216,7 +224,7 @@ deps-piper:
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
# a short question against a longer passage, which is what note recall is.
# The quantized file is the one we download, deploy and measure — see
# docs/evals/2026-07-31-recall.md.
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
+113
View File
@@ -0,0 +1,113 @@
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
//
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
// project, and it names the stage 0 grammars as the high-precision label
// functions to start from. This runs them — the real ones, in the real
// buildRouter order — rather than a reimplementation, so a rule change moves
// the training data with it.
//
// A grammar that declines leaves the line unlabeled. Those go to the model, and
// keeping them is the point: a set labeled only by the rules teaches only the
// rules.
//
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
//
// The wakeword-act grammar is absent, because its allowlist is the deployment's
// enabled tool names and this tool has no deployment. Every other rule is here.
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/kami/maven/internal/router"
)
// label is one output row. The grammar name rides along so a reviewer can see
// which rule made the claim, and so a rule that turns out to be wrong can have
// its rows pulled without re-running everything.
type label struct {
Utterance string `json:"utterance"`
Intent string `json:"intent,omitempty"`
Grammar string `json:"grammar,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
Fn string `json:"fn,omitempty"`
Text string `json:"text,omitempty"`
Labeled bool `json:"labeled"`
}
// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
// load-bearing there and so it is here: the agenda rules must sit after the
// clock rules, Praxis before the capture marker, the narrative rules last.
func grammars() []router.Grammar {
var g []router.Grammar
g = append(g, router.SystemTimeDateGrammars()...)
g = append(g, router.AgendaQueryGrammars()...)
g = append(g, router.FeedQueryGrammar())
g = append(g, router.TaskListGrammar())
g = append(g, router.ListGrammars()...)
g = append(g, router.ReminderGrammar())
g = append(g, router.PraxisGrammars()...)
g = append(g, router.TaskCaptureGrammar())
g = append(g, router.NarrativeQueryGrammars()...)
return g
}
func match(gs []router.Grammar, utterance string) label {
out := label{Utterance: utterance}
for _, g := range gs {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
continue
}
d, ok := g.Build(m)
if !ok {
continue // the rule saw its shape and declined it
}
out.Intent = string(d.Intent)
out.Grammar = g.Name
out.Key = d.Slots.Key
out.Value = d.Slots.Value
out.Fn = d.Slots.Fn
out.Text = d.Slots.Text
out.Labeled = true
return out
}
return out
}
func main() {
gs := grammars()
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
out := bufio.NewWriter(os.Stdout)
defer out.Flush()
enc := json.NewEncoder(out)
var seen, labeled int
for in.Scan() {
line := strings.TrimSpace(in.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
seen++
l := match(gs, line)
if l.Labeled {
labeled++
}
if err := enc.Encode(l); err != nil {
fmt.Fprintln(os.Stderr, "labelgen:", err)
os.Exit(1)
}
}
if err := in.Err(); err != nil {
fmt.Fprintln(os.Stderr, "labelgen:", err)
os.Exit(1)
}
// Coverage on stderr, so the count is visible without polluting the JSONL.
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
}
+1 -1
View File
@@ -61,7 +61,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
if h.phraser == nil {
return "поговорили."
}
history := h.chatHistory()
history := h.chatHistory(ctx)
// The phraser hands back its own fallback text alongside the error, so the
// turn survives a dead server and the failure still reaches the log.
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
+38 -3
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"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"
@@ -89,11 +90,19 @@ 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 {
//
// The vector id carries a timestamp, so a second tap of the same key adds a
// row rather than replacing one, and recall then scores the superseded
// value against the current one. CorrectValue and VoidLatestFact already
// drop the key's vectors; an ordinary re-tap is the third way a value is
// superseded and it did not (#493). Dropping first keeps exactly one vector
// per key, which is what "recall answers with the current value" means.
if h.recall.memStore != nil {
pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key)
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,
@@ -114,3 +123,29 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
}
return "" // replier phrases the success reply
}
// vectorPruner — the part of the vector index this file needs and memory.Store
// does not carry. store.MemoryStore implements it; the in-memory test double
// may not, and a double that cannot prune is not a reason to fail a fact write.
type vectorPruner interface {
DeletePrefix(ctx context.Context, prefix string) (int64, error)
}
// pruneFactVectors drops every vector for one fact key, so the insert that
// follows is the only one left. Best-effort and silent on a store that cannot
// prune: the fact row is the truth, and a stale vector costs a wrong recall,
// not a lost fact.
func pruneFactVectors(ctx context.Context, ms memory.Store, key string) {
p, ok := ms.(vectorPruner)
if !ok {
return
}
n, err := p.DeletePrefix(ctx, "fact:"+key+":")
if err != nil {
log.Printf("voice: prune memory vectors for %q: %v", key, err)
return
}
if n > 0 {
log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n)
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
if err != nil {
log.Printf("voice: embed note: %v", err)
return phraser.Ack(phraser.FailNote, nil)
@@ -40,8 +40,8 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
}
// Insert into long-term memory (best-effort, must not fail the note write).
// text/ts in the meta make a Search hit self-describing (see bestRecall).
if h.memStore != nil {
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
if h.recall.memStore != nil {
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
"source": "voice",
"type": "note",
"text": dec.Utterance,
+37 -14
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/ipc"
@@ -161,8 +162,10 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
// no query-source field, so a wrong answer could not be told from a
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
// the utterance and the answer are already on the voice lines above
// and below this one.
// and below this one. The same name goes to the turn's sink when the
// caller asked for one, so /chat can show it (V-539).
log.Printf("voice: query claimed by source %q", src.name)
noteQuerySource(ctx, src.name)
return reply
}
}
@@ -454,7 +457,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
// sources below both need, run once, in the position it always ran in. It
// only claims the turn when the embedder fails.
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
if err != nil {
log.Printf("voice: embed query: %v", err)
return phraser.Q(phraser.QueryFailAnswer, nil), true
@@ -474,15 +477,15 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
// gate, was the bug — the set of questions Maven answers is unchanged, only
// which memory gets to answer them.
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
if h.memStore == nil {
if h.recall.memStore == nil {
return "", false
}
hits, herr := h.memStore.Search(ctx, t.vec, 3)
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
if herr != nil {
log.Printf("voice: memory search: %v", herr)
return "", false
}
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
if !ok {
return "", false
}
@@ -533,7 +536,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
@@ -693,11 +696,19 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
defer cancel()
// The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so
// a Russian sentence matches nothing at all. The rewriter turns it into a
// handful of English keywords with the resident model.
// A Russian question reads the Russian ZIM verbatim when there is one
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
// book matches a Russian sentence not at all, and the rewriter exists to
// turn the question into English keywords with the resident model. Against a
// Russian book that is a translation of his own words back at him: it costs
// a model call and drops whatever the keywords do not carry.
book, verbatim := h.kiwix.book, false
if h.kiwix.bookRU != "" && hasCyrillic(t.dec.Utterance) {
book, verbatim = h.kiwix.bookRU, true
}
pattern := t.dec.Utterance
if h.kiwix.rewriter != nil {
if h.kiwix.rewriter != nil && !verbatim {
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
if err != nil {
// Fall through to the verbatim question rather than give up. It
@@ -708,7 +719,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
}
}
hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max)
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
if err != nil {
log.Printf("voice: kiwix: search %q: %v", pattern, err)
return "", false
@@ -720,7 +731,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
// Logged on the way through, not only on failure. Without this there is no
// way to tell from the outside whether an answer came off a ZIM or out of
// the model's weights, and those are the two cases worth telling apart.
log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title)
log.Printf("voice: kiwix: %q in %q → %d hits, top %q", pattern, book, len(hits), top.Title)
// The top hit only, read as an article rather than as a snippet. Kiwix
// builds its snippet from wherever the keyword matched, which on Wikipedia
@@ -752,6 +763,18 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
return reply, true
}
// hasCyrillic reports whether the text carries a Cyrillic letter, which is the
// whole test for "he asked this in Russian". A question mixing a Latin proper
// noun into a Russian sentence is still Russian, so one letter is enough.
func hasCyrillic(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}
// queryPersonal — stop the walk on a question about him that his own data did
// not answer.
//
@@ -819,8 +842,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
+23 -1
View File
@@ -2,8 +2,11 @@ package main
import (
"context"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
log.Printf("voice: create reminder: %v", err)
return phraser.Ack(phraser.FailReminder, nil)
}
return ""
// Phrased from the row, never from the utterance (Vikunja #507). The
// replier only ever saw Slots.Text, so it named whatever hour the sentence
// contained — including one the parser had rejected or read differently.
// A confirmation naming an hour no row holds is worse than a clarify,
// because he stops thinking about it.
return reminderConfirm(dec.Slots.Time, h.now())
}
// reminderConfirm — the confirmation for a reminder that exists, naming the
// stored fire time. Deterministic on purpose: the one sentence that must match
// a database row is not one to hand to a 1.7B.
func reminderConfirm(fire, now time.Time) string {
when := dayPrefix(now, fire)
if when == "это" {
// Further out than the day words reach — say the date instead of a
// word that would be wrong.
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
}
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
}
+1 -1
View File
@@ -80,7 +80,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
for _, r := range spoken {
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
}
h.offerCandidates(cands)
h.offerCandidates(ctx, cands)
return tasks.FormatRU(ranked), true
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
// pending telegram nudge every repeat_interval, and nothing in the tree could
// ever mark one acked. None of what follows can be reproduced by hand without
// sitting in front of the box for hours, so it is covered here or nowhere.
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
t.Helper()
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
t.Fatalf("seed service_down:db=%s: %v", value, err)
}
}
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
// repeat these tests are about never happens. The daemon wires it (main.go).
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
t.Helper()
rules := loop.DefaultRules()
g := loop.NewGatherer(st, rules)
d := delivery.NewDispatcher(delivery.Config{
Voice: sink, Ntfy: sink, Telegram: sink,
Ack: st, Nudges: st, Reminders: st,
})
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
}
// telegramSends counts sends that went out on the telegram reach.
func telegramSends(sink *fakeSink, rule string) int {
n := 0
for _, s := range sink.sends {
if s.RuleName == rule {
n++
}
}
return n
}
// outcomes returns the outcome of every nudge row for a rule, newest first.
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
t.Helper()
rows, err := st.RecentNudges(ctx, 50)
if err != nil {
t.Fatalf("recent nudges: %v", err)
}
var out []string
for _, n := range rows {
if n.Rule == rule {
out = append(out, n.Outcome)
}
}
return out
}
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
// The condition clearing is the ending that should happen. StillTrue reads
// the same DownServices helper the phraser reads, so the repeat stops on
// exactly the monitor he was told about.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.tick(ctx, now)
if telegramSends(sink, "service_down") == 0 {
t.Fatal("the alarm never went out; the rest of this test proves nothing")
}
seedDown(t, st, ctx, "up", now.Add(time.Minute))
sink.sends = nil
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
if n := telegramSends(sink, "service_down"); n != 0 {
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
}
for _, o := range outcomes(t, st, ctx, "service_down") {
if o != store.NudgeResolved {
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
}
}
}
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
// Still down, still un-acked, and nobody has answered in two hours. That is
// not one more repeat away from being answered.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.tick(ctx, now)
sink.sends = nil
tl.tick(ctx, now.Add(6*time.Minute))
if n := telegramSends(sink, "service_down"); n == 0 {
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
}
sink.sends = nil
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
if n := telegramSends(sink, "service_down"); n != 0 {
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
}
// Ignored, not resolved: nothing says the service got better.
for _, o := range outcomes(t, st, ctx, "service_down") {
if o != store.NudgeIgnored {
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
}
}
}
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
// Down, up, down again. Closing the first run must not make the second run
// unreportable, and must not silently reopen the closed rows either.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.tick(ctx, now)
first := len(outcomes(t, st, ctx, "service_down"))
seedDown(t, st, ctx, "up", now.Add(time.Minute))
tl.tick(ctx, now.Add(2*time.Minute))
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
}
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
sink.sends = nil
tl.tick(ctx, now.Add(31*time.Minute))
if n := telegramSends(sink, "service_down"); n == 0 {
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
}
got := outcomes(t, st, ctx, "service_down")
if len(got) <= first {
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
}
}
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
// that says nothing must keep its alarm until the age cap, or a rule author
// silences their own alarm by omission.
st := newTestStore(t)
ctx := context.Background()
now := refNow()
sink := &fakeSink{}
tl := newAlarmTickLoop(t, st, sink)
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
t.Fatalf("record nudge: %v", err)
}
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
if len(live) != 1 {
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
}
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
if len(live) != 0 {
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
}
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
)
// An empty attention list used to be answered "ничего не требует внимания"
// unconditionally, which is an all-clear Maven had no way to know was true
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
if !strings.Contains(reply, "ничего не требует внимания") {
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
}
}
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
praxis := newFakePraxisWithSources(t, `[]`, `[
{"source_id":"src_ntfy","health":"ok"},
{"source_id":"src_llamacpp","health":"failed"},
{"source_id":"src_imap","health":"stale"}
]`)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
if strings.Contains(reply, "ничего не требует внимания") {
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
}
for _, want := range []string{"src_llamacpp", "src_imap"} {
if !strings.Contains(reply, want) {
t.Errorf("reply names no %s: %q", want, reply)
}
}
if strings.Contains(reply, "src_ntfy") {
t.Errorf("the healthy source is named as a problem: %q", reply)
}
}
// A Praxis that polls nothing knows nothing, which is the state the box is in.
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
if strings.Contains(reply, "ничего не требует внимания") {
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
}
if !strings.Contains(reply, "источник") {
t.Errorf("reply does not say why she cannot tell: %q", reply)
}
}
// The spec's own mechanism, which the deployed Praxis does not send yet: the
// envelope's degraded array is believed without a second call.
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
praxis := newFakePraxisWithSources(t,
`{"items":[],"degraded":["src_metrics"]}`,
`[{"source_id":"src_ntfy","health":"ok"}]`)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
if !strings.Contains(reply, "src_metrics") {
t.Fatalf("the envelope's degraded source is not named: %q", reply)
}
for _, r := range praxis.Requests() {
if r.Path == "/api/v1/sources" {
t.Error("sources was read even though the response carried degraded")
}
}
}
// A sources endpoint that errors is not evidence of a fault: the attention call
// itself succeeded, and hedging on it would make her permanently uncertain.
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
praxis.SetRouteFault("/api/v1/sources", 500)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
if !strings.Contains(reply, "ничего не требует внимания") {
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
}
}
// Both response shapes decode, because the spec says one and the box sends the
// other.
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
var bare praxisAttention
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
t.Fatalf("bare array: %v", err)
}
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
t.Errorf("bare array decoded as %+v", bare)
}
var env praxisAttention
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
t.Fatalf("envelope: %v", err)
}
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
t.Errorf("envelope decoded as %+v", env)
}
}
// A source that reports no health at all counts as healthy. A Praxis that never
// fills the field would otherwise make every quiet turn a hedge.
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
if err != nil {
t.Fatalf("UnhealthySources: %v", err)
}
if total != 2 {
t.Errorf("total = %d, want 2", total)
}
if len(bad) != 1 || bad[0] != "src_b" {
t.Errorf("bad = %v, want [src_b]", bad)
}
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/router"
)
// The chat path must answer when llama-server is down (Vikunja #45 step 4).
// Both halves of a turn call the model — the router and the replier — and each
// has its own floor: the cascade falls to the classifier, the replier falls to
// the stub. This wires a client at a closed port so both floors are exercised
// by a dial error rather than by a stubbed error value.
func TestChatAnswersWithNoLlamaServer(t *testing.T) {
h, _, _ := newClarifyHandler(t)
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
emb := router.NewHashEmbedder(1024)
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
h.replier = newLLMReplier(dead, nil)
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
for _, utt := range []string{
"привет",
"запиши что кофе закончился",
"что у меня сегодня",
} {
reply := h.handleText(ctx, "web", utt)
if reply == "" {
t.Errorf("%q answered with nothing; a dead model must degrade to the stub", utt)
}
}
}
// daemonAPI.Chat reports an error only when the voice path was never wired.
// A turn that reaches handleText always carries text, which is what keeps
// mavweb's /api/chat off its error branch when the model is down.
func TestChatAPIErrsOnlyWhenUnwired(t *testing.T) {
d := &daemonAPI{}
if _, err := d.Chat(context.Background(), "web", "привет"); err == nil {
t.Fatal("an unwired daemon must say so")
}
d.chatFn = func(context.Context, string, string) string { return "" }
if _, err := d.Chat(context.Background(), "web", "привет"); err != nil {
t.Fatalf("a wired daemon must not error: %v", err)
}
}
+4 -4
View File
@@ -305,9 +305,9 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
if h.dialogueSessions != nil {
now := h.now()
prev := h.dialogueSessions.Get(voiceDialogueID, now)
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
dec = followUpMerge(prev, dec, now)
h.rememberTurn(prev, dec, now)
h.rememberTurn(ctx, prev, dec, now)
}
reply := h.applyAction(ctx, dec)
if reply == "" {
@@ -323,7 +323,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
// rememberTurn stores this turn as the dialogue session the next follow-up
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
// one long conversation can't grow the session unboundedly.
func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) {
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
var history []dialogue.Turn
if prev != nil {
history = append(history, dialogue.Turn{
@@ -359,7 +359,7 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
slots.Text = dec.Utterance
}
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
Intent: dialogue.Intent(dec.Intent),
Slots: slots,
Timestamp: now,
+1 -1
View File
@@ -305,7 +305,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
h, _, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(1024)
h.embedder = emb
h.recall.embedder = emb
h.router = buildRouter(emb, h.matcher, 0.55, nil)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
+14 -12
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -118,13 +119,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
//
// Acceptance itself is recorded by /routines, and the tick
// loop nudges on the interval from there (Vikunja #366).
return "поняла — подтверди на странице рутин, и начну напоминать."
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
},
no: func() string {
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
log.Printf("voice: dismiss proposed routine: %v", err)
}
return "хорошо, не буду."
return phraser.C(phraser.ConfirmRoutineNo, nil)
},
},
// Hexis execution confirm. Bound to the exact capability + target that
@@ -137,7 +138,7 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
yes: func() string {
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
},
no: func() string { return "отменила." },
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
},
// Tool confirm.
{
@@ -150,16 +151,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
if err != nil {
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
if out != "" {
return "не получилось выполнить команду: " + firstLine(out)
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
}
return "не получилось выполнить команду."
return phraser.A(phraser.ActFail, nil)
}
if out != "" {
return "готово: " + firstLine(out)
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
}
return "готово."
return phraser.A(phraser.ActDone, nil)
},
no: func() string { return "отменила." },
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
},
}
}
@@ -170,17 +171,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
name := firstWord(stripWake(dec.Utterance))
if name == "" {
return "не разобрала команду — попробуй иначе."
return phraser.C(phraser.ProposeNoVerb, nil)
}
vars := map[string]string{"name": name}
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
if err != nil {
log.Printf("voice: propose tool %q: %v", name, err)
return "команды «" + name + "» нет в списке разрешённых."
return phraser.C(phraser.ProposeFailed, vars)
}
if newly {
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
return phraser.C(phraser.ProposeNew, vars)
}
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
return phraser.C(phraser.ProposeAlready, vars)
}
// confirmVerdict — the parse of a y/n confirm answer.
+4 -2
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"testing"
"time"
@@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
now: func() time.Time { return contNow },
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
}
h.rememberTurn(nil, router.Decision{
ctx := context.Background()
h.rememberTurn(ctx, nil, router.Decision{
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
}, contNow)
// The second turn arrives with the first turn's Text already merged in.
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
h.rememberTurn(prev, router.Decision{
h.rememberTurn(ctx, prev, router.Decision{
Intent: router.IntentQuery,
Utterance: "какие у меня планы",
Slots: router.Slots{Text: "во сколько у меня встреча"},
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// The list she read at the mic is not the list a browser is looking at
// (Vikunja #45 step 3). The clarify store was keyed per reach in #466; the
// dialogue session was still one slot for the box, so "второй" typed on the web
// closed the second task she had recited out loud.
func TestCandidatesDoNotCrossReaches(t *testing.T) {
h, st, _ := newClarifyHandler(t)
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
putCandidates(h, voiceCtx, ids, "купить хлеб", "позвонить маме")
if reply, handled := h.resolveCandidate(webCtx, "первую сделал", sourceText); handled {
t.Fatalf("a web turn picked from the list she read aloud: %q", reply)
}
live, err := st.ListTasks(context.Background(), "live")
if err != nil {
t.Fatalf("list tasks: %v", err)
}
if len(live) != 2 {
t.Fatalf("%d tasks live, want 2 — the web turn moved one", len(live))
}
// The reach that was offered the list still owns it.
if _, handled := h.resolveCandidate(voiceCtx, "первую сделал", sourceVoice); !handled {
t.Fatal("the mic lost its own list")
}
}
// A selection writes a fact, so the fact must name the reach the words arrived
// on. It said "tap:voice" for a typed turn.
func TestCandidateProvenanceFollowsTheReach(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
ids := seedTasks(t, st, "купить хлеб")
putCandidates(h, ctx, ids, "купить хлеб")
if _, handled := h.resolveCandidate(ctx, "первую сделал", sourceText); !handled {
t.Fatal("the pick was not acted on")
}
done, err := st.ListTasks(context.Background(), "done")
if err != nil {
t.Fatalf("list tasks: %v", err)
}
if len(done) != 1 {
t.Fatalf("%d tasks done, want 1", len(done))
}
if by := done[0].ResolvedBy; by != string(sourceText) {
t.Errorf("resolved_by = %q, want %q", by, sourceText)
}
}
// Anaphora is per reach too: an ellipsis typed on the web must not continue the
// question he asked at the mic. Both surfaces stay usable at once, which is the
// case a single-owner box actually hits — a phone open while he talks.
func TestAnaphoraDoesNotCrossReaches(t *testing.T) {
h, _, _ := newClarifyHandler(t)
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
now := h.now()
h.rememberTurn(voiceCtx, nil, router.Decision{
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
}, now)
if sess := h.dialogueSessions.Get(dialogueIDOf(webCtx), now); sess != nil {
t.Fatalf("the web reach inherited the mic's turn: %+v", sess)
}
sess := h.dialogueSessions.Get(dialogueIDOf(voiceCtx), now)
if sess == nil || !strings.Contains(sess.Slots.Text, "встреча") {
t.Fatalf("the mic lost its own turn: %+v", sess)
}
}
+71 -4
View File
@@ -263,18 +263,85 @@ func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) er
return nil
}
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
// praxisAttention — an attention response in either of the two shapes Praxis
// may send (Vikunja #540).
//
// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a
// source is failed or stale, and that Maven is required to say so rather than
// report all-clear. The deployed Praxis answers with a bare JSON array and no
// envelope at all, so both are decoded here: an array is the items, an object is
// the spec envelope. This lands the Maven half without waiting on the server,
// and the sources read below is what makes the hedge work meanwhile.
type praxisAttention struct {
Items []map[string]any
Degraded []string
}
func (a *praxisAttention) UnmarshalJSON(data []byte) error {
trimmed := bytes.TrimSpace(data)
if len(trimmed) > 0 && trimmed[0] == '[' {
return json.Unmarshal(trimmed, &a.Items)
}
var env struct {
Items []map[string]any `json:"items"`
Degraded []string `json:"degraded"`
}
if err := json.Unmarshal(trimmed, &env); err != nil {
return err
}
a.Items, a.Degraded = env.Items, env.Degraded
return nil
}
func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) {
var out praxisAttention
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
return out, err
}
// praxisSource — one polled source, as much of it as the hedge needs. The tools
// API does not expose sources, so this decodes the plain `/api/v1/sources` rows.
type praxisSource struct {
ID string `json:"id"`
SourceID string `json:"source_id"`
Health string `json:"health"`
}
func (s praxisSource) name() string {
if s.SourceID != "" {
return s.SourceID
}
return s.ID
}
// UnhealthySources reports which sources cannot be trusted to have reported,
// and how many sources Praxis has at all (Vikunja #540).
//
// Only read when the attention list came back empty, which is the one turn where
// an all-clear is at stake. A source whose health field is absent counts as
// healthy: a Praxis that never reports health would otherwise make every quiet
// turn a hedge, and an unreported field is not evidence of a fault. Everything it
// does report other than "ok" — failed, stale, degraded, unknown — counts as
// cannot-tell, because none of them mean the source has spoken.
func (c *praxisClient) UnhealthySources(ctx context.Context) (bad []string, total int, err error) {
var out []praxisSource
if err := c.getJSON(ctx, "sources", "/api/v1/sources", &out); err != nil {
return nil, 0, err
}
for _, s := range out {
if s.Health != "" && s.Health != "ok" {
bad = append(bad, s.name())
}
}
return bad, len(out), nil
}
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
// entity, so callers already holding a resolved entity_id (e.g. after
// resolveEntityReference) can ask "what needs attention for this entity"
// instead of filtering the unscoped list client-side.
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
var out []map[string]any
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) {
var out praxisAttention
err := c.getJSON(ctx, "attention_for_entity",
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
return out, err
+127 -3
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
@@ -105,6 +106,13 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
ctx = withCorrelationID(ctx, newCorrelationID())
}
px := h.ecosystem.praxis
dec, ok := h.resolveSurfacedPosition(dec)
if !ok {
// A demonstrative with no digest behind it. "я это сделал" is a sentence
// about his day, so the rest of the cascade gets it back rather than
// hearing "какой пункт?" for something that was never about a пункт.
return ""
}
for _, capability := range praxisCapabilities {
for _, alias := range capability.aliases() {
if alias == dec.Slots.Fn {
@@ -154,18 +162,23 @@ func (listAttentionCapability) aliases() []string {
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
started := h.now()
items, err := px.ListAttention(ctx, 20)
att, err := px.ListAttention(ctx, 20)
if err != nil {
log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err))
return phraser.A(phraser.AttentionFail, nil)
}
items := att.Items
if len(items) == 0 {
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" {
return hedge
}
return phraser.A(phraser.AttentionNone, nil)
}
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string
var spoken []string
for _, item := range items {
title, _ := item["title"].(string)
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
@@ -190,11 +203,15 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
// a failed surface call must not block delivering the digest.
if id, ok := item["id"].(string); ok && id != "" {
// Recorded in the order she says them, and only for items she could
// say: an item skipped above has no position in what he heard (#516).
spoken = append(spoken, id)
if _, err := px.Surface(ctx, id); err != nil {
log.Printf("ecosystem: praxis surface %s: %v", id, err)
}
}
}
h.rememberSurfaced(spoken)
if len(parts) == 0 {
// Praxis returned items and not one of them could be said. "ничего не
// требует внимания" is the honest answer; the list line would render as
@@ -299,14 +316,14 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
}
queried := h.now()
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
if err != nil {
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
}
items, scoped := scopedToEntity(items, entityID)
items, scoped := scopedToEntity(att.Items, entityID)
if !scoped {
// A Praxis old enough to ignore an unknown query parameter answers the
// scoped question with the unscoped list. Reading that back as "по
@@ -339,6 +356,11 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
parts = append(parts, known)
}
if len(parts) == 0 {
// The scoped list is as exposed to a silent source as the unscoped one,
// and a per-entity all-clear is the more convincing of the two (#540).
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" {
return hedge
}
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
}
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
@@ -783,3 +805,105 @@ func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Dec
}
return h.handleHexisAct(ctx, dec)
}
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
// when an empty attention list really does mean nothing needs looking at
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
//
// "Nothing needs attention" and "I cannot currently tell" are different answers
// and only one of them was ever said. The spec's mechanism is a `degraded` array
// on the attention response, which the deployed Praxis does not send, so the
// source health read is the half that works today. It costs one HTTP call and
// only on the empty-list turn, which is the only turn where an all-clear is at
// stake.
//
// A failed sources read is deliberately NOT a hedge. The attention call itself
// succeeded, and not being able to ask about health is not evidence of a fault —
// hedging on it would turn one flaky endpoint into a permanently uncertain
// assistant.
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
if len(degraded) > 0 {
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
"degraded": strings.Join(degraded, ","), "source": "response",
})
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
}
bad, total, err := px.UnhealthySources(ctx)
if err != nil {
log.Printf("ecosystem: praxis sources: %v", err)
return ""
}
if total == 0 {
// A Praxis that polls nothing knows nothing, so its silence is not an
// all-clear either. This is the state the box is in as of 2026-08-05:
// /api/v1/sources answers with an empty array.
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
return phraser.A(phraser.AttentionNoSources, nil)
}
if len(bad) > 0 {
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
})
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
}
return ""
}
// rememberSurfaced records the item ids she just read out, replacing whatever the
// previous digest left. Called with the ids in speaking order (Vikunja #516).
func (h *reactiveHandler) rememberSurfaced(ids []string) {
h.mu.Lock()
defer h.mu.Unlock()
h.surfacedItems = ids
}
// resolveSurfacedPosition turns a positional item reference into a Praxis item
// id, using the list she last read out.
//
// The router names a position and not an id, because only the daemon has the
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
// left alone, since "item_ab12" is already one.
//
// The second return says whether the turn is still Praxis's. A position that
// names nothing keeps the turn and clears the slot, so the capability answers its
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
// because "я это сделал" was probably never about a пункт at all. "это" also
// needs the list to hold exactly one item: pointing at one of five is a guess,
// and a wrong guess here transitions the wrong item.
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
ref := dec.Slots.Value
if ref == "" || strings.HasPrefix(ref, "item") {
return dec, true
}
h.mu.Lock()
ids := h.surfacedItems
h.mu.Unlock()
idx := -1
switch {
case ref == "this":
if len(ids) != 1 {
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
return dec, false
}
idx = 0
case ref == "last":
idx = len(ids) - 1
default:
n, err := strconv.Atoi(ref)
if err != nil || n < 1 {
// Neither a position nor an id: leave it for the capability to
// reject rather than silently rewriting what he said.
return dec, true
}
idx = n - 1
}
if idx < 0 || idx >= len(ids) {
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
dec.Slots.Value = ""
return dec, true
}
dec.Slots.Value = ids[idx]
return dec, true
}
+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,
}
+71 -6
View File
@@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
emb := router.NewHashEmbedder(1024)
h := &reactiveHandler{
api: api,
embedder: emb,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
return h, api
@@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
t.Fatal("a question was stored as a fact about him")
}
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
@@ -81,7 +80,7 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
// #493: what recall reads back is the fact, not the sentence he said.
// queryMemory returns a fact's text verbatim, so the utterance sitting here
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
@@ -117,7 +116,7 @@ func TestFactConfidence(t *testing.T) {
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
t.Helper()
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
if err != nil {
t.Fatalf("embed %q: %v", text, err)
}
@@ -140,7 +139,7 @@ func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
t.Fatal("a passing complaint was stored as a fact about him")
}
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
@@ -168,3 +167,69 @@ func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
t.Fatalf("an explicit capture was refused: %v", err)
}
}
// A second tap of the same key supersedes the first, so recall must hold one
// vector and it must be the new value (Vikunja #493). Before this the id
// carried a timestamp, both rows stayed, and the superseded value went on
// competing for the turn.
func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) {
ctx := context.Background()
h, _ := newFactGateHandler(t, time.Now())
h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "запиши что я пил воду",
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
})
// A later tap of the same key. The clock moves, so the old id and the new
// one differ — which is exactly what used to leave two rows behind.
h.now = func() time.Time { return time.Now().Add(time.Hour) }
h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "запиши что я пил воду",
Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`},
})
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5)
if err != nil {
t.Fatalf("memory search: %v", err)
}
if len(hits) != 1 {
t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits)
}
if got := hits[0].Meta["text"]; got != "water — 500мл" {
t.Errorf("indexed text = %q, want the current value", got)
}
}
// Another key is not this key. A prefix delete that widened would take the
// whole index with it.
func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) {
ctx := context.Background()
h, _ := newFactGateHandler(t, time.Now())
h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "запиши что я обедал",
Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`},
})
h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "запиши что я пил воду",
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
})
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5)
if err != nil {
t.Fatalf("memory search: %v", err)
}
var found bool
for _, hit := range hits {
if hit.Meta["text"] == "meal — суп" {
found = true
}
}
if !found {
t.Fatalf("writing water dropped the meal vector: %+v", hits)
}
}
+18
View File
@@ -178,6 +178,14 @@ func (fs *fakeServer) Requests() []capturedRequest {
return out
}
// ResetRequests drops the captured requests, so a test can assert about one
// turn without subtracting the setup turn's calls.
func (fs *fakeServer) ResetRequests() {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.requests = nil
}
func jsonHandler(status int, body string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -330,7 +338,17 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
// Maven's praxisClient calls. Every route returns its fixed body until a
// fault is injected via SetFault.
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
// One healthy source by default: an empty attention list only means
// all-clear when something is actually polling (Vikunja #540), and the
// other tests here are about attention rather than about source health.
return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`)
}
// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body
// under the test's control, for the degraded and no-sources hedges.
func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer {
return newFakeServer(t, map[string]http.HandlerFunc{
"GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody),
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
+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},
}
}
+20 -5
View File
@@ -8,10 +8,10 @@ import (
"github.com/kami/maven/internal/router"
)
// voiceDialogueID — the dialogue-session key for the microphone, and the
// clarify key for it too. This is a single-user box (ponytail), so one slot
// suffices; a second speaker would need per-speaker ids, which waits on
// voice-print attribution (see PROGRESS multi-user deferral).
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
// This is a single-user box (ponytail), so one slot per reach suffices; a
// second speaker would need per-speaker ids, which waits on voice-print
// attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice"
// textDialogueID — the clarify key for a text turn that named no conversation.
@@ -97,7 +97,8 @@ var anaphoraResolver router.AnaphoraResolver
// followUpMerge fills the current turn's missing slots from a prior
// non-expired session — the multi-turn seam. It handles three cases:
//
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
// except a reminder time the current sentence named and the parser missed.
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
// the key for fact-lookup queries and reminder creation.
@@ -113,8 +114,22 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
// Case 1: same-intent inheritance (existing).
if prev.Intent == dialogue.Intent(dec.Intent) {
// A reminder that named an hour nobody could read must not borrow the
// last one's. Two reminders in a row and the second landed at the
// first's time, confirmed as if it had been read from the sentence:
// "напомни без четверти восемь выходить" fired at 07:30 (V-543). The
// hour is also what fills before the action's own fallback parse can
// run, so inheriting it hid a time that did parse.
//
// Inheriting is still right when the sentence names no time at all,
// which is the follow-up this seam exists for.
blockTime := dec.Intent == router.IntentReminder &&
!dec.Slots.HasTime && router.MentionsTime(dec.Utterance)
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
dec.Slots = applyDialogueSlots(dec.Slots, merged)
if blockTime {
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
}
return dec
}
+36
View File
@@ -35,6 +35,42 @@ func TestFollowUpMerge(t *testing.T) {
}
})
// V-543, measured on the box: four reminders in a row all landed at the
// first one's hour, each confirmed as if it had been read from the sentence.
// A sentence that names a time and fails to parse must ask, not borrow.
t.Run("a named time that did not parse is not inherited", func(t *testing.T) {
for _, utt := range []string{
"напомни без четверти восемь выходить",
"напомни в половине первого пообедать",
"напомни завтра принять лекарство",
"remind me at noon to stretch",
} {
cur := router.Decision{
Intent: router.IntentReminder,
Utterance: utt,
Slots: router.Slots{Text: utt},
}
got := followUpMerge(prev, cur, base.Add(30*time.Second))
if got.Slots.HasTime {
t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time)
}
}
})
// The follow-up this seam exists for still works: the sentence names no
// time, so the previous one is the only one it could mean.
t.Run("a follow-up naming no time still inherits", func(t *testing.T) {
cur := router.Decision{
Intent: router.IntentReminder,
Utterance: "и ещё полить цветы",
Slots: router.Slots{Text: "полить цветы"},
}
got := followUpMerge(prev, cur, base.Add(30*time.Second))
if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) {
t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time)
}
})
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
own := base.Add(48 * time.Hour)
cur := router.Decision{
+76 -13
View File
@@ -6,6 +6,8 @@ import (
"log"
"strings"
"time"
"github.com/kami/maven/internal/morph"
)
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
@@ -15,18 +17,34 @@ import (
// storage: everything he tapped in is already a row with a source and a
// timestamp, and this only reads them back.
// historyMarkers — the ways he asks what he told her. Each entry is a pair of
// substrings that must BOTH appear, because either half alone is a different
// question: "что я говорил про сервер" is a recall question the notes pass
// answers better, and "что ты записала" with no "что" is not a question at all.
var historyMarkers = [][2]string{
{"что я", "говорил"},
{"что я", "сказал"},
{"что я", "рассказ"},
{"что ты", "записал"},
{"что ты", "запомнил"},
{"что я", "отмечал"},
{"что я", "отметил"},
// A history question needs three things in one utterance: the interrogative,
// whose turn is being asked about, and a verb of saying or recording. Any two of
// them are a different question. "что я говорил про сервер" names a topic and
// the notes pass answers it better; "записал молоко" is a capture.
//
// The verbs are matched by lemma through internal/morph, not by a truncated
// prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал",
// which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun,
// so "что я рассказал ей" and "что я читал рассказ" were the same string test.
// Aspect pairs are separate lemmas in the dictionary, so both members are listed.
var (
// historySpokenVerbs — what HE did. "что я тебе говорил".
historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"}
// historyRecordedVerbs — what SHE did with it. "что ты записала сегодня".
historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"}
// firstPersonSubjects and secondPersonSubjects — whose turn the question is
// about. Only the subject forms: "что я тебе говорил" is his turn, and the
// dative "тебе" in it is not the subject.
firstPersonSubjects = []string{"я"}
secondPersonSubjects = []string{"ты"}
)
// historyMarkersEn — the English pairs, kept as substrings because the
// dictionary is Russian. Each half alone is a different question, the same way
// the Russian test needs all three parts.
var historyMarkersEn = [][2]string{
{"what did i", "tell"},
{"what did you", "record"},
}
@@ -47,11 +65,56 @@ func isHistoryQuery(u string) bool {
return false
}
}
for _, pair := range historyMarkers {
for _, pair := range historyMarkersEn {
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
return true
}
}
toks := historyTokens(s)
if !hasAny(toks, "что", "чего") {
return false
}
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
return true
}
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
}
// historyTokens splits an utterance into bare words. The punctuation goes
// because "говорил?" is the same word as "говорил".
func historyTokens(s string) []string {
toks := strings.Fields(s)
out := make([]string, 0, len(toks))
for _, t := range toks {
if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" {
out = append(out, t)
}
}
return out
}
func hasAny(toks []string, want ...string) bool {
for _, t := range toks {
for _, w := range want {
if t == w {
return true
}
}
}
return false
}
// hasVerbForm reports whether any token is a form of any of the lemmas. Both
// sides go through the dictionary, so a caller may name the infinitive and he
// may say the past tense.
func hasVerbForm(toks []string, lemmas []string) bool {
for _, t := range toks {
for _, l := range lemmas {
if morph.SameWord(t, l) {
return true
}
}
}
return false
}
+11
View File
@@ -41,6 +41,17 @@ func TestIsHistoryQuery(t *testing.T) {
{"что я тебе говорил?", true},
{"что ты записала сегодня?", true},
{"что я отмечал?", true},
// Forms the truncated prefixes did not reach. The dictionary answers
// these because it lemmatises both sides (V-530).
{"что я тебе рассказывал?", true},
{"что я сказала вчера", true},
{"что ты запомнила?", true},
// The noun, not the verb. "рассказ" was a prefix of the old pair, so
// this read as a history question — the same defect V-528 fixed in
// complaint.go, where "лаг" matched "лагерь".
{"что я читал рассказ", false},
// A verb of saying with nobody saying it.
{"что записать?", false},
// A named topic is a recall question, and the notes pass answers it
// better than a list of the last five facts does.
{"что я говорил про сервер?", false},
+44
View File
@@ -0,0 +1,44 @@
package main
import "testing"
func TestHasCyrillic(t *testing.T) {
for _, s := range []string{"что такое фотосинтез", "кто такой Elon Musk", "фотосинтез"} {
if !hasCyrillic(s) {
t.Errorf("hasCyrillic(%q) = false; it is a Russian question", s)
}
}
for _, s := range []string{"what is photosynthesis", "", "3:2"} {
if hasCyrillic(s) {
t.Errorf("hasCyrillic(%q) = true; there is no Cyrillic in it", s)
}
}
}
// The book choice and the rewrite decision are the same decision: a Russian
// book reads his question as he asked it, an English one needs it translated
// into keywords first (V-508).
func TestKiwixBookChoice(t *testing.T) {
for _, tc := range []struct {
name string
wiring kiwixWiring
utterance string
wantBook string
wantVerb bool
}{
{"a russian question reads the russian book verbatim",
kiwixWiring{book: "en", bookRU: "ru"}, "что такое фотосинтез", "ru", true},
{"an english question reads the english book",
kiwixWiring{book: "en", bookRU: "ru"}, "what is photosynthesis", "en", false},
{"no russian book configured leaves every question on the english one",
kiwixWiring{book: "en"}, "что такое фотосинтез", "en", false},
} {
book, verbatim := tc.wiring.book, false
if tc.wiring.bookRU != "" && hasCyrillic(tc.utterance) {
book, verbatim = tc.wiring.bookRU, true
}
if book != tc.wantBook || verbatim != tc.wantVerb {
t.Errorf("%s: book=%q verbatim=%v, want %q/%v", tc.name, book, verbatim, tc.wantBook, tc.wantVerb)
}
}
}
+10 -2
View File
@@ -19,8 +19,12 @@ type kiwixWiring struct {
client *kiwix.Client
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
book string
max int
runes int
// bookRU — searched instead of book when the question is Cyrillic, and
// searched verbatim because it is in his language already (V-508). Empty ⇒
// every question goes to book.
bookRU string
max int
runes int
}
// wireKiwix builds the ZIM reader from the `kiwix` block, or returns nil when
@@ -38,9 +42,13 @@ func wireKiwix(cfg *config.Config, c *llm.Client) *kiwixWiring {
w := &kiwixWiring{
client: kiwix.New(kc.URL),
book: kc.Book,
bookRU: kc.BookRU,
max: kc.MaxResults,
runes: kc.SnippetRunes,
}
if kc.BookRU != "" {
log.Printf("voice: kiwix: russian questions read %q verbatim", kc.BookRU)
}
switch {
case !kc.RewriteEnabled():
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
+4
View File
@@ -127,8 +127,10 @@ func run(args []string) error {
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
flag.CommandLine.Parse(args)
reembedOnStart = *reembed
allowSeedOnStart = *allowSeed
cfg, err := config.Load(*cfgPath)
if err != nil {
return err
@@ -338,6 +340,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
@@ -605,6 +608,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
newAPI.chatFn = voiceW.handler.handleText
+6 -6
View File
@@ -97,20 +97,20 @@ func parseCandidateVerb(text string) (status, say string, ok bool) {
// offerCandidates records the list she just read, so his next words can pick
// from it. Best effort: no session store, or a session that expired between the
// question and the answer, means the words route normally.
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
func (h *reactiveHandler) offerCandidates(ctx context.Context, cands []dialogue.Candidate) {
if h.dialogueSessions == nil || len(cands) == 0 {
return
}
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
}
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
// the list she just read.
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) {
if h.dialogueSessions == nil {
return "", false
}
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
if sess == nil || len(sess.Candidates) == 0 {
return "", false
}
@@ -133,13 +133,13 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (st
// a sentence, and the second half is the next turn.
return pick.Label, true
}
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), string(src)); err != nil {
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
return "не получилось изменить задачу.", true
}
// Spent: the list she read is no longer the list, and a second ordinal
// against it would close the wrong task.
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
return say + ": " + pick.Label, true
}
+14 -12
View File
@@ -38,7 +38,7 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
h, _, _ := newClarifyHandler(t)
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled {
t.Error("an ordinal with no list behind it was claimed")
}
}
@@ -47,14 +47,14 @@ func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := context.Background()
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
putCandidates(h, ids, "купить хлеб", "позвонить маме")
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
reply, handled := h.resolveCandidate(ctx, "второй")
reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice)
if !handled || !strings.Contains(reply, "позвонить маме") {
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
}
// Still live: naming one is often the first half of a sentence.
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
if _, handled := h.resolveCandidate(ctx, "первый", sourceVoice); !handled {
t.Error("the list was spent by a read-back")
}
}
@@ -63,9 +63,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := context.Background()
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
putCandidates(h, ids, "купить хлеб", "позвонить маме")
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
reply, handled := h.resolveCandidate(ctx, "первую сделал")
reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice)
if !handled || !strings.Contains(reply, "купить хлеб") {
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
}
@@ -80,7 +80,7 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
}
// Spent: a second ordinal against a list that no longer holds would close
// the wrong task.
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled {
t.Error("the list survived the pick it was spent on")
}
}
@@ -88,9 +88,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ids := seedTasks(t, st, "купить хлеб")
putCandidates(h, ids, "купить хлеб")
putCandidates(h, context.Background(), ids, "купить хлеб")
reply, handled := h.resolveCandidate(context.Background(), "третий")
reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice)
if !handled || !strings.Contains(reply, "1") {
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
}
@@ -118,11 +118,13 @@ func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
return ids
}
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
// putCandidates binds a list to the reach the ctx names, the way queryTasks
// does when she recites one.
func putCandidates(h *reactiveHandler, ctx context.Context, ids []int64, labels ...string) {
cands := make([]dialogue.Candidate, 0, len(ids))
for i, id := range ids {
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
}
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
h.offerCandidates(cands)
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
h.offerCandidates(ctx, cands)
}
+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")
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"strings"
"testing"
)
// "отметь второй пункт" names a position, and only the daemon knows which item
// that is. The router fills the value slot with "2"; this is where it becomes an
// item id (Vikunja #516).
func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
praxis := newFakePraxis(t, `[
{"id":"item_a","title":"диск заканчивается"},
{"id":"item_b","title":"бэкап не прошёл"},
{"id":"item_c","title":"сертификат истекает"}
]`)
h := newPraxisTestHandler(t, praxis)
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
t.Fatal("attention returned nothing")
}
cases := []struct{ ref, wantItem string }{
{"2", "item_b"},
{"1", "item_a"},
{"last", "item_c"},
}
for _, c := range cases {
praxis.ResetRequests()
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
if !strings.Contains(reply, "принято") {
t.Errorf("ref %q: reply %q", c.ref, reply)
}
if !requestedPathContaining(praxis, c.wantItem) {
t.Errorf("ref %q did not acknowledge %s; paths %v", c.ref, c.wantItem, paths(praxis))
}
}
}
// A position past the end must not acknowledge the wrong item. It asks.
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
if !strings.Contains(reply, "какой пункт") {
t.Errorf("a position with no item should ask, got %q", reply)
}
if requestedPathContaining(praxis, "item_a") {
t.Error("the only surfaced item was resolved for a position that did not name it")
}
}
// No digest yet means no positions. Nothing is mutated.
func TestPositionWithNoSpokenListAsks(t *testing.T) {
praxis := newFakePraxis(t, `[]`)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
if !strings.Contains(reply, "какой пункт") {
t.Errorf("want the ask, got %q", reply)
}
}
// An explicit id is not a position and passes through untouched.
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
if !requestedPathContaining(praxis, "item_zz") {
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
}
}
// An item Praxis sent without a title is never spoken, so it holds no position.
func TestUnspokenItemsHoldNoPosition(t *testing.T) {
praxis := newFakePraxis(t, `[
{"id":"item_silent"},
{"id":"item_said","title":"бэкап не прошёл"}
]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
if !requestedPathContaining(praxis, "item_said") {
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
}
}
// The item id travels in the POST body, so that is what these read.
func paths(f *fakeServer) []string {
var out []string
for _, r := range f.Requests() {
out = append(out, r.Path+" "+string(r.Body))
}
return out
}
func requestedPathContaining(f *fakeServer, want string) bool {
for _, r := range f.Requests() {
if strings.Contains(string(r.Body), want) {
return true
}
}
return false
}
// "отметь это как сделанное" after a one-item digest points at that item.
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
if !strings.Contains(reply, "принято") {
t.Errorf("reply %q", reply)
}
if !requestedPathContaining(praxis, "item_only") {
t.Errorf("the one surfaced item was not acknowledged; paths %v", paths(praxis))
}
}
// Pointing at one of several is a guess, and a wrong guess transitions the wrong
// item. The turn goes back to the cascade instead.
func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
praxis := newFakePraxis(t, `[
{"id":"item_a","title":"диск"},
{"id":"item_b","title":"бэкап"}
]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
t.Errorf("want a fall-through, got %q", reply)
}
for _, p := range paths(praxis) {
if strings.Contains(p, "resolve") {
t.Error("an ambiguous demonstrative resolved an item anyway")
}
}
}
// "я это сделал" with no digest behind it is a sentence about his day.
func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
praxis := newFakePraxis(t, `[]`)
h := newPraxisTestHandler(t, praxis)
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
t.Errorf("want a fall-through, got %q", reply)
}
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// The row is the whole mechanism, and nothing wrote it (Vikunja #532).
//
// The existing hysteresis test in internal/store scores the pure function and
// passed throughout, which is exactly why this went unnoticed: Resolve was
// always correct and was always handed the cold-start Away. So this test asserts
// the round trip — the tick writes what gather resolved, and the next load
// reads it back — rather than re-testing the function.
func TestTickPersistsTheResolvedBucket(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Now()
// Cold start: no row, so a load must say Away and the zero time.
b, score, updated, err := st.LoadPresenceState(ctx)
if err != nil {
t.Fatalf("load before: %v", err)
}
if b != store.Away || score != 0 || !updated.IsZero() {
t.Fatalf("cold start = %s/%v/%v, want away/0/zero", b, score, updated)
}
tl := &tickLoop{store: st}
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
b, score, updated, err = st.LoadPresenceState(ctx)
if err != nil {
t.Fatalf("load after: %v", err)
}
if b != store.Present {
t.Errorf("bucket = %s, want present", b)
}
if score != 0.9 {
t.Errorf("score = %v, want 0.9", score)
}
if updated.IsZero() {
t.Error("updated_ts was not written, so /dash still reads (never)")
}
}
// The singleton stays a singleton, and a later tick overwrites rather than
// accumulating. A row per tick would make LoadPresenceState's single-row query
// return whichever one SQLite felt like.
func TestPresenceStateIsOverwrittenNotAppended(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
tl := &tickLoop{store: st}
now := time.Now()
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
tl.savePresence(ctx, loop.State{Presence: store.Away, PresenceScore: 0.1}, now.Add(time.Minute))
b, score, _, err := st.LoadPresenceState(ctx)
if err != nil {
t.Fatalf("load: %v", err)
}
if b != store.Away || score != 0.1 {
t.Fatalf("got %s/%v, want the second write (away/0.1)", b, score)
}
}
// What the persisted row buys: the hold band. A score sitting between Exit and
// Enter holds Present when the last bucket was Present, and stays Away when it
// was Away. Before the write existed the second arm was the only one that could
// ever run, so presence dropped at roughly four minutes of idle instead of
// holding to the exit threshold at about nine.
func TestPersistedBucketIsWhatFeedsHysteresis(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
tl := &tickLoop{store: st}
mid := (store.PresenceExit + store.PresenceEnter) / 2
if mid <= store.PresenceExit || mid >= store.PresenceEnter {
t.Fatalf("%v is not inside the hold band", mid)
}
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, time.Now())
last, _, _, err := st.LoadPresenceState(ctx)
if err != nil {
t.Fatalf("load: %v", err)
}
if got := store.Resolve(mid, last); got != store.Present {
t.Errorf("Resolve(%v, %s) = %s, want present — the hold band did not apply", mid, last, got)
}
}
+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
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"sync"
)
// The query source that claimed a turn was visible in the daemon log and
// nowhere else (V-539). A QA step reading /chat could see a wrong answer but
// not tell a wrong answer from a wrongly ordered chain: "почему небо голубое"
// answered badly reads the same whether search claimed it, the ZIM did, or the
// resident model answered from memory.
//
// It rides the context rather than a return value because handleText answers
// every reach through one string, and threading a second value through the
// whole action dispatch would change a signature the mic, telegram and the web
// all share. The sink is per turn, created by the caller that wants to read it;
// a turn with no sink notes nothing, which is what the mic path does.
type querySourceKey struct{}
// querySourceSink holds the name of the source that claimed one turn. The mutex
// is there because a query source may fan out to goroutines of its own, not
// because two turns share a sink.
type querySourceSink struct {
mu sync.Mutex
name string
}
func (s *querySourceSink) note(name string) {
s.mu.Lock()
defer s.mu.Unlock()
s.name = name
}
// Name is the source that claimed, or empty when nothing did or the turn was
// not a query at all.
func (s *querySourceSink) Name() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.name
}
// withQuerySourceSink returns a context that collects the claiming source, and
// the sink to read after the turn has answered.
func withQuerySourceSink(ctx context.Context) (context.Context, *querySourceSink) {
sink := &querySourceSink{}
return context.WithValue(ctx, querySourceKey{}, sink), sink
}
// noteQuerySource records which source claimed the turn. It is a no-op when the
// caller did not ask for one.
func noteQuerySource(ctx context.Context, name string) {
if sink, ok := ctx.Value(querySourceKey{}).(*querySourceSink); ok {
sink.note(name)
}
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"context"
"testing"
)
func TestQuerySourceSinkCollectsTheClaimingName(t *testing.T) {
ctx, sink := withQuerySourceSink(context.Background())
if sink.Name() != "" {
t.Fatalf("a fresh sink names a source: %q", sink.Name())
}
noteQuerySource(ctx, "kiwix")
if got := sink.Name(); got != "kiwix" {
t.Errorf("sink.Name() = %q, want kiwix", got)
}
}
// A turn with no sink must not panic. The mic path asks for no source, and a
// query source calls noteQuerySource unconditionally.
func TestNoteQuerySourceWithoutASinkIsSilent(t *testing.T) {
noteQuerySource(context.Background(), "search")
}
// The last source to claim wins, because only one does: actionQuery returns on
// the first claim. This pins that the sink overwrites rather than appends, so a
// second turn on the same context could not read a stale name.
func TestQuerySourceSinkKeepsTheLastNote(t *testing.T) {
ctx, sink := withQuerySourceSink(context.Background())
noteQuerySource(ctx, "search")
noteQuerySource(ctx, "kiwix")
if got := sink.Name(); got != "kiwix" {
t.Errorf("sink.Name() = %q, want kiwix", got)
}
}
+7 -6
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,
}
@@ -45,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) {
HasTime: true,
},
}
// The confirmation is phrased from the row now (Vikunja #507), so it
// names the stored hour rather than leaving the replier to read one
// out of the sentence.
reply := h.applyAction(ctx, dec)
if reply != "" {
t.Errorf("expected empty reply from applyAction, got %q", reply)
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
t.Errorf("reply = %q, want %q", reply, want)
}
reminders, err := st.ListReminders(ctx, 10)
if err != nil {
@@ -101,11 +103,10 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
embedder: emb,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, matcher, 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
+41 -1
View File
@@ -1,6 +1,9 @@
package main
import "github.com/kami/maven/internal/memory"
import (
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
)
// bestRecall is the read side of the long-term memory store: the top hit when
// it clears the confidence gate. The index holds BOTH notes and facts, and
@@ -23,3 +26,40 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
}
return results[0], true
}
// recallWiring — the recall subsystem's dependencies, held as one group on
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
// loose ones, so a handler names what it needs and the package can be split
// later without exporting the whole struct.
//
// The zero value is usable and means "no recall": no embedder, no vector
// store, and a gate that is never consulted because nothing is ever searched.
type recallWiring struct {
// embedder — reused for note write/query (same model as the classifier).
embedder router.Embedder
// memStore — the vector index over notes and facts.
memStore memory.Store
// topics — the embedded seed sets behind the weather, house and LAN
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
// usable, loads on first query, and with no embedder it never loads and
// each source falls back to its own keyword test.
topics topicIndex
// boundary — the embedded seed sets behind the personal boundary
// (personalboundary.go). Zero value is usable and loads on first query;
// with no embedder it never loads and the boundary uses personalMarkers.
boundary personalBoundary
// minScore — the note-recall confidence gate. Top cosine below this ⇒
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
// not load-bearing math (same posture as the presence thresholds). Set by
// wireVoice from VoiceConfig; default 0.55.
minScore float64
// minMargin — the second half of that gate: how far the top hit must beat
// the runner-up. 0 ⇒ margin off.
minMargin float64
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"strings"
"testing"
"time"
)
// A reminder confirmation is the one sentence that must match a database row.
// It used to be phrased by the replier from Slots.Text, which meant it named
// whatever hour the sentence contained — including an hour the parser had
// rejected or read differently (Vikunja #507).
func TestReminderConfirmNamesTheStoredHour(t *testing.T) {
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today
if !strings.Contains(got, "19:00") {
t.Fatalf("confirmation = %q, want the stored 19:00 in it", got)
}
if !strings.Contains(got, "сегодня") {
t.Fatalf("confirmation = %q, want it to say сегодня", got)
}
}
func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) {
// dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is
// not a sentence. A date is.
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
got := reminderConfirm(now.Add(10*24*time.Hour), now)
if strings.Contains(got, "это") {
t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got)
}
if !strings.Contains(got, "15 августа") {
t.Fatalf("confirmation = %q, want the date in it", got)
}
}
func TestReminderConfirmIsFeminineAndInformal(t *testing.T) {
// The persona checks the phrasing eval enforces apply here too, and this
// sentence never passes through a phraser.
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
got := reminderConfirm(now.Add(time.Hour), now)
for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} {
if strings.Contains(strings.ToLower(got), bad) {
t.Fatalf("confirmation = %q contains %q", got, bad)
}
}
if !strings.HasPrefix(got, "хорошо, напомню") {
t.Fatalf("confirmation = %q, want it to open with the promise", got)
}
}
+35 -3
View File
@@ -2,12 +2,20 @@ package main
import (
"regexp"
"sort"
"strings"
"github.com/kami/maven/internal/lexicon"
)
// reminderMarker — the words that open a reminder. Stripped because they are
// the instruction, not the thing to say at the hour.
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
//
// The verbs come from the lexicon (Vikunja #530). They are a closed set of the
// commands she answers to, exactly like capture_verbs, and the literal that
// stood here knew four of them.
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) +
`)\s*(?:мне|me)?[\s,:—-]*`)
// reminderTimeWords — the time expressions a reminder carries, removed from
// the body because the fire time is already a column. Ordered longest-first
@@ -17,12 +25,36 @@ var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|на
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
// boundaries here are written out as whitespace or an end of string — the same
// trap the agenda grammars hit.
//
// The Russian word lists are gone (Vikunja #530). The day words are
// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now
// and were not before, and the times of day are lexicon.PartsOfDay. What is
// still written out here is the shape of a clock reading — a preposition, digits,
// a colon — which is structured input rather than a claim about Russian.
var reminderTimeWords = []*regexp.Regexp{
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
}
// alternation folds a lexicon set into one regexp branch, longest member first
// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted
// rather than taken as given, because two members of equal length must still
// produce the same pattern on every build.
func alternation(set []string) string {
out := make([]string, 0, len(set))
for _, w := range set {
out = append(out, regexp.QuoteMeta(w))
}
sort.Slice(out, func(i, j int) bool {
if len(out[i]) != len(out[j]) {
return len(out[i]) > len(out[j])
}
return out[i] < out[j]
})
return strings.Join(out, "|")
}
// reminderBody is what she says at the hour.
+2 -2
View File
@@ -44,7 +44,7 @@ func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
h, st, now := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
cls := router.NewClassifier(emb)
h.embedder = emb
h.recall.embedder = emb
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
ctx := context.Background()
@@ -94,7 +94,7 @@ func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
func TestRepairIsSpentOnce(t *testing.T) {
h, _, _ := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
h.embedder = emb
h.recall.embedder = emb
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
ctx := context.Background()
+3 -1
View File
@@ -165,6 +165,8 @@ func formatTime(t time.Time) string {
n := int(diff.Hours())
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
default:
return t.Format("2 января 15:04")
// Not t.Format("2 января …"): Go reads that as a literal, so every
// fact older than a day used to read as January (Vikunja #507).
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
}
}
+107
View File
@@ -0,0 +1,107 @@
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
//
// The pattern detector needs four events for one action+object, spread by at
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
// produce that against a running daemon in one sitting: the only writer is a
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
// same missing step and had been stopped there since they were filed.
//
// This is the write path that unblocks them, and it is deliberately the narrow
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
// seed proves is the daemon's own wiring rather than the detector in isolation
// — which is what an eval-lab fixture would have proved, and is not what those
// four tasks doubt.
//
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/store"
)
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
// inline so the mavweb route can tell "not allowed here" apart from "the seed
// ran and the extractor declined", which look the same to a reader otherwise.
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
// seedSource — every seeded fact carries this, and no other writer uses it.
// The point is that seeded data stays identifiable forever: a fact that came
// from a QA sitting must never be mistaken for something he said, either by a
// person reading /history or by the wipe in V-494 when it lands.
const seedSource = "seed:qa"
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
// fact loudly when it does. A box that can rewrite its own past should say so
// in its boot log, so nobody reads a seeded routine months later as evidence of
// something he actually did.
func seedStoreIfAllowed(st *store.Store) *store.Store {
if !allowSeedOnStart {
return nil
}
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
return st
}
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
// it, and runs the same detect-and-propose step the voice path runs.
//
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
// worked is a QA result nobody can trust, so every step reports its own
// failure. Extraction declining is not a failure — it is the extractor's
// documented answer for a value outside its lexicon, and Extracted says so.
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
if d.seedStore == nil {
return ipc.SeedEventResp{}, errSeedDisabled
}
if req.Key == "" || req.Value == "" {
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
}
if req.Ts.IsZero() {
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
}
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
// resolution. QA data has no business reaching the ecosystem.
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
if err != nil {
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
}
resp := ipc.SeedEventResp{FactID: factID}
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
if ev == nil {
// The fact is written and stays written. Saying so matters: a caller
// that assumed a seed always produces an event would otherwise read
// four silent successes and conclude the detector is broken.
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
return resp, nil
}
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
if err != nil {
return resp, fmt.Errorf("seed create event: %w", err)
}
resp.EventID = eventID
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
if err != nil {
return resp, fmt.Errorf("seed detect: %w", err)
}
if r == nil {
return resp, nil // too few events yet, too irregular, or already decided
}
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
return resp, nil
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// Off is the default and it must mean "nothing to write with", not "permission
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
})
if err == nil {
t.Fatal("seed succeeded with no seedStore")
}
if !strings.Contains(err.Error(), "-allow-seed") {
t.Errorf("error does not name the flag: %v", err)
}
}
// The whole point of the task: four seeds spread past the detector's floor
// produce a proposal against the real daemon path, which is what nobody could
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
// and a test that only checked the happy end would pass on an off-by-one.
func TestSeedFourEventsProposesARoutine(t *testing.T) {
ctx := context.Background()
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
now := time.Now()
var last ipc.SeedEventResp
// Oldest first, three hours apart — past MinIntervalDays (two hours).
for i := 3; i >= 0; i-- {
var err error
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
Key: "cat_water_fountain",
Value: "заправил",
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
})
if err != nil {
t.Fatalf("seed %d: %v", i, err)
}
if !last.Extracted {
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
}
if i > 0 && last.Proposed {
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
}
}
if !last.Proposed {
t.Fatal("four spaced events did not propose a routine")
}
if last.Action != "refill" || last.Object != "cat_water_fountain" {
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
}
if last.IntervalDays < 0.1 {
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
}
// The proposal is readable through the same list the /routines page uses,
// which is the wiring an eval-lab fixture would not have proved.
proposed, err := d.seedStore.ListProposedRoutines(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(proposed) != 1 {
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
}
}
// A value outside the action lexicon writes the fact and says it seeded
// nothing. Silence here would read as four working seeds and a broken
// detector.
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
Key: "mood", Value: "ok", Ts: time.Now(),
})
if err != nil {
t.Fatalf("seed: %v", err)
}
if resp.FactID == 0 {
t.Error("fact was not written")
}
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
t.Errorf("claimed an event for a non-action value: %+v", resp)
}
}
// A seed with no timestamp is refused rather than defaulting to now: the only
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
// the caller and must not silently write a fact at the wrong time.
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
Key: "cat_water_fountain", Value: "заправил",
}); err == nil {
t.Fatal("seed accepted a zero timestamp")
}
}
+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),
+104 -714
View File
@@ -15,20 +15,15 @@ import (
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -166,6 +161,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
log.Printf("tick: gather: %v", err)
return
}
t.savePresence(ctx, state, now)
// proactive: at most one candidate, max severity.
cand, trace := loop.ExplainTick(state, t.rules)
@@ -255,6 +251,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
return
}
keys = t.repeatableRules(keys)
keys = t.stopFinishedAlarms(ctx, keys, state, now)
if len(keys) == 0 {
return
}
@@ -266,6 +263,108 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
}
}
// savePresence writes back the bucket GatherState just resolved.
//
// It lives here and not in GatherState because that method holds a read-only
// transaction on purpose — one consistent snapshot per tick — and a write
// inside it would either break that guarantee or quietly upgrade the
// transaction. The tick is the layer that already owns writes.
//
// Nothing wrote this row before (Vikunja #532), and the row is the whole
// mechanism, so two things were broken at once. Hysteresis was dead: lastBucket
// read the cold-start Away on every tick, so store.Resolve only ever took the
// `last == Away` arm and demanded a full PresenceEnter score to say he is
// there. The 0.30-0.55 hold band the function exists to provide never applied
// once. And every readout lied: /dash and ipc.Presence read this row, so they
// showed "away — score 0.00 (never)" while desk_active facts were arriving
// every sixty seconds.
//
// A failure logs and the tick continues. The gate reads the in-memory bucket,
// which is why nudge routing kept working through all of this — losing the
// write costs the next tick's hysteresis, not this tick's decisions.
func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.Time) {
if err := t.store.SavePresenceState(ctx, state.Presence, state.PresenceScore, now); err != nil {
log.Printf("tick: save presence state: %v", err)
}
}
// maxAlarmAge — how long one un-acked telegram alarm may keep repeating.
//
// This is the floor brake and it applies to every rule, including one that
// says nothing about its own condition (Vikunja #535). Nothing in the tree can
// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the
// only ack that exists is a voice "готово" on a box that runs no voice loop. So
// "repeat until acked" meant "repeat forever", and it did — every five minutes
// for over two hours.
//
// Two hours at the five-minute default is about 24 messages, which is already
// past the point of being read. An alarm nobody answered in two hours is not
// one more repeat away from being answered, and the right move is to stop
// talking, not to talk louder.
const maxAlarmAge = 2 * time.Hour
// stopFinishedAlarms returns the keys that may still repeat, and closes the
// rest.
//
// Two ways an alarm ends without him. The condition cleared, which the rule
// answers through StillTrue — deliberately NOT Predicate, which is
// edge-triggered and reads false one tick after the alarm is raised, so using
// it would cancel every alarm immediately. Or the alarm simply got old, which
// is the bound that does not need the rule's cooperation.
//
// A rule with no StillTrue is not treated as resolved. Silence about the
// condition is not evidence the condition cleared, so those keys only ever stop
// on age.
func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string {
if len(keys) == 0 {
return nil
}
byName := make(map[string]loop.Rule, len(t.rules))
for _, r := range t.rules {
byName[r.Name] = r
}
live := keys[:0:0]
for _, key := range keys {
outcome := ""
switch r := byName[key]; {
case r.StillTrue != nil && !r.StillTrue(state):
outcome = store.NudgeResolved
case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now):
// Not "resolved": nothing says the thing got better. This is her
// giving up on being answered, and /notifications should say so.
outcome = store.NudgeIgnored
}
if outcome == "" {
live = append(live, key)
continue
}
n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now)
if err != nil {
// Could not close it, so do not drop it either: repeating is the
// lesser fault against losing the alarm entirely.
log.Printf("tick: stop alarm %s: %v", key, err)
live = append(live, key)
continue
}
log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n)
}
return live
}
// alarmIsOlderThan reports whether the oldest un-acked send for this rule is
// past the cap. A read failure answers false: an alarm that repeats one more
// time is better than one silenced by a transient store error.
func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool {
oldest, err := t.store.OldestPendingTelegram(ctx, rule)
if err != nil {
if !errors.Is(err, store.ErrNudgeNotFound) {
log.Printf("tick: oldest pending %s: %v", rule, err)
}
return false
}
return now.Sub(oldest) >= age
}
// repeatableRules drops keys whose rule is not wired any more.
//
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
@@ -319,617 +418,6 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
return pn.Body, pn.Summary
}
// shouldQueue — true when digest is enabled and the candidate's severity is
// at or below the configured ceiling.
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
return t.digestCfg != nil && t.digestCfg.Enabled &&
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
}
// queueNudge — phrases the candidate and appends it to the digest queue.
// Deduplicates by rule name: if the same rule is already queued, this is a
// no-op (the first fire within the window is the one that counts).
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
for _, q := range t.digestQ {
if q.Rule == cand.Rule.Name {
return // already queued
}
}
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
return
}
t.digestQ = append(t.digestQ, QueuedNudge{
Rule: cand.Rule.Name,
Severity: int(cand.Severity),
Body: pn.Body,
Key: cand.Rule.Name,
QueuedAt: now,
})
t.cachePhrase(pn)
}
// maybeFlush — flushes the digest queue if the window has elapsed since the
// first item or the queue reached MaxItems.
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
return
}
first := t.digestQ[0]
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
len(t.digestQ) >= t.digestCfg.MaxItems {
t.flushDigest(ctx, now, state)
}
}
// flushDigest — concatenates queued nudge bodies into a single digest
// notification and dispatches it. Clears the queue after a successful send.
// The digest uses the max severity among queued items for routing.
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
if len(t.digestQ) == 0 {
return
}
var b strings.Builder
maxSev := 0
for i, q := range t.digestQ {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(q.Body)
if q.Severity > maxSev {
maxSev = q.Severity
}
}
body := b.String()
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
cand := loop.Candidate{
Rule: loop.Rule{
Name: "digest",
Severity: loop.Severity(maxSev),
},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{
Candidate: cand,
Body: body,
Summary: summary,
}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
// keep the queue — the next tick's maybeFlush re-attempts.
log.Printf("tick: dispatch digest: %v", err)
return
}
t.digestQ = nil
}
// detectPatterns runs the pattern detector proactively over every
// action+object pair that has ever produced an event, independent of
// whichever fact write (or channel) last touched it (Vikunja #43). This is
// what makes pattern inference actually proactive: it fires on the daemon's
// own schedule reading accumulated history, not only as a side effect of a
// live voice turn.
//
// Idempotence and noise are handled by the store, not here — this function
// is safe to call every tick:
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
// check plus proposed_routines' UNIQUE(action, object) constraint (with
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
// already has a row — in ANY status — produces no second row and no log
// spam beyond the one line at genuine creation.
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
// status in place; the row is never deleted. So the same Lookup check
// that stops a duplicate "proposed" also stops a "dismissed" one from
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// for the restraints that apply even then. A proposal only starts producing
// recurring nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
pairs, err := t.store.DistinctEventPairs(ctx)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
continue
}
if r == nil {
continue // no stable pattern, or already proposed/accepted/dismissed
}
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// One announcement per tick at most, whatever the scan turned up. The
// rest are on /routines; they are not lost, they are just not shouted.
// Nor are they queued: the row now exists, so no later tick re-detects
// them and they are never announced. See announceProposal.
if announced {
continue
}
announced = t.announceProposal(ctx, r, now, state)
}
}
// announceProposal offers a freshly inferred routine through the ordinary
// care-delivery path, if announcing is switched on at all. Returns true when
// something was actually sent.
//
// Everything here is restraint. The feature is off unless configured; when on
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
// dropped announcement is NOT retried — the cooldown clock advances only on a
// real send, but the proposal row already exists, so the next tick will not
// re-detect it and nothing queues up behind it. A missed announcement means
// he reads it on /routines instead, which is the whole point of the page.
//
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
// newly created row, so a pair gets exactly one chance to be spoken: the tick
// that first proposes it. Combined with one announcement per tick, the first
// tick over a populated history announces one pattern and permanently silences
// every other pattern found in the same pass. That is the intent, not an
// oversight — an inferred routine is not worth a second attempt at his
// attention, and /routines lists all of them. So the cooldown does not drain a
// backlog. It only spaces announcements of genuinely new pairs discovered on
// later ticks. If it should ever become "one per day until each is mentioned",
// that needs a queue rather than this counter.
//
// Cooldown gets its default here as well as in applyDefaults. That is
// deliberate: a tickLoop assembled directly in a test never goes through Load,
// and an unspaced announcer is not what those tests mean to exercise.
//
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
// an inferred routine cannot arrive worded as something Maven never observed.
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
if !t.proposalCfg.AnnounceProposals() {
return false
}
cooldown := time.Duration(t.proposalCfg.Cooldown)
if cooldown <= 0 {
cooldown = config.DefaultProposalCooldown
}
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
return false
}
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
return false
}
body := pattern.PhraseRoutine(r)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
return false
}
if len(sent) == 0 {
return false // routing dropped it — /routines still has it.
}
t.lastProposalAt = now
return true
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
// hour-scale cooldowns and re-derive from facts that reset every day), so a
// digest entry that outlives one full day is describing a day that's already
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
// news. Bounding at one day also means a digest can never silently span a
// weekend of quiet hours into an unbounded backlog.
const digestExpiry = 24 * time.Hour
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
// dropped" cannot regress into "she dumps twelve things on me the moment I
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
// Anything beyond the cap is still marked drained (it did get its moment;
// the cap limits WORDS, not whether it counted) and folded into a trailing
// count instead of being spoken in full.
const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
}
for _, tr := range trace.RuleTraces {
if !tr.PredicateResult || tr.GateResult {
continue // didn't want to fire, or wasn't suppressed
}
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: expire stale digest entries: %v", err)
return
}
if n > 0 {
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
}
}
// maybeDrainDigest speaks the pending digest bundle once the gate's
// suppression reasons have actually cleared — quiet hours over, back from
// away, out of the meeting. Draining while still suppressed would just be a
// second way to nag through quiet hours; the bundle waits for the same "is
// it allowed right now" condition a live nudge already waits for.
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
return
}
entries, err := t.store.PendingDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: pending digest entries: %v", err)
return
}
if len(entries) == 0 {
return
}
spoken := entries
extra := 0
if len(spoken) > maxDigestSpokenItems {
spoken = entries[:maxDigestSpokenItems]
extra = len(entries) - maxDigestSpokenItems
}
var b strings.Builder
maxSev := 0
for i, e := range spoken {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(e.Body)
if e.Severity > maxSev {
maxSev = e.Severity
}
}
if extra > 0 {
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
// The adjective declines with the noun, so the count picks the whole
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
// отложенных уведомлений.
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch digest bundle: %v", err)
return // leave entries pending; retried next tick
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
log.Printf("tick: drain digest entries: %v", err)
}
}
// routinesFromConfig maps the config's routine blocks to the engine type.
// Validation (cron parses, name/body present, severity defaulted) already ran
// in config.Load, so this is a pure field copy.
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
if len(rc) == 0 {
return nil
}
out := make([]routine.Routine, len(rc))
for i, r := range rc {
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
}
return out
}
// fireRoutines dispatches the routines whose cron schedule crossed since their
// last fire. Each is delivered as a nudge through the normal routing table
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
// can't collide with a care rule in the feedback autotuner. A dispatch failure
// logs and continues — one bad send must not skip the rest, and routine.Due has
// already advanced the last-fire time so a transient failure drops that fire
// rather than replaying it every tick (a routine is clockwork, not an alarm —
// no repeat-til-ack).
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
for _, r := range routine.Due(t.routines, t.routineLast, now) {
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
Severity: loop.Severity(r.Severity),
State: state,
},
Body: r.Body,
Summary: r.Body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
}
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
// because the item↔fact-key mapping is morning-routine-specific, not a rule
// concern — pulling it into the shared gather path would leak that mapping
// into loop's "rules declare wanted keys" contract. Bodies are literal
// operator text (item labels joined), not LLM-phrased, same rationale as
// cron routines: deterministic, can't hallucinate a checklist item.
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
if len(t.morningRoutines) == 0 {
return
}
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
body := morningNudgeBody(cand)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
Severity: loop.Severity(cand.Routine.Severity),
State: state,
},
Body: body,
Summary: body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
}
}
}
// morningNudgeBody words the one message a routine gets per day. Required
// items are what she says was not done; optional ones follow, worded as
// something he could still do rather than something he owes (Vikunja #473).
// Operator text, not phrased by the model, for the same reason it always was:
// a checklist item must not be invented.
func morningNudgeBody(cand morning.Candidate) string {
labels := func(items []morning.Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Label
}
return strings.Join(out, ", ")
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
body += fmt.Sprintf(". если будет время — %s", labels(opt))
}
return body
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
// disagree about what evidence exists.
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
keys := make(map[string]struct{})
for _, r := range t.morningRoutines {
for _, it := range r.Items {
keys[it.FactKey] = struct{}{}
}
}
facts := make(map[string]store.Fact, len(keys))
for k := range keys {
f, err := t.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err != store.ErrNoFact {
log.Printf("tick: morning: latest fact %s: %v", k, err)
}
}
return facts
}
// morningStatus is the read-only "what's missing" query the web UI (and
// eventually a voice query) calls. Pure recompute over the current facts —
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
// "state right now," not "should we nag."
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
if len(t.morningRoutines) == 0 {
return nil
}
facts := t.gatherMorningFacts(ctx)
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
for _, r := range t.morningRoutines {
st := morning.Evaluate(r, facts, now)
done := make(map[string]bool, len(st.Completed))
for _, it := range st.Completed {
done[it.Key] = true
}
items := make([]ipc.MorningRoutineItem, len(r.Items))
for i, it := range r.Items {
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
}
out = append(out, ipc.MorningRoutineStatus{
Name: r.Name,
Active: st.Active,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
Items: items,
})
}
return out
}
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
// the impure half of morning.BuildPlan: it reads the calendar events, the
// pending reminders and the checklist facts, and the pure builder orders them.
//
// It never dispatches. Asking for the plan is a query like any other; the only
// unprompted delivery in maven stays with the morning nudge and the
// dispatcher's policy.
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
var events []morning.PlanEntry
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: calendar events: %v", err)
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(f.Value),
Kind: morning.PlanEvent,
// Provenance below a calendar read (an ambient relay, #126) is
// hedged rather than recited as fact.
Uncertain: f.Confidence < 1.0,
})
}
var reminders []morning.PlanEntry
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: r.Text(),
Kind: morning.PlanReminder,
})
}
var checklistFacts map[string]store.Fact
if len(t.morningRoutines) > 0 {
checklistFacts = t.gatherMorningFacts(ctx)
}
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
for i, it := range plan.Items {
out.Items[i] = ipc.DayPlanItem{
At: it.At,
Text: it.Text,
Kind: string(it.Kind),
Uncertain: it.Uncertain,
}
}
return out
}
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
// rule:
@@ -1002,101 +490,3 @@ func (t *tickLoop) trace() *loop.TickTrace {
defer t.mu.Unlock()
return t.lastTrace
}
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
}
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
// look the same to a reader on purpose, because neither is a fault and the
// page renders both as an empty table.
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
if d.getEvents == nil {
return nil, nil
}
return d.getEvents(n), nil
}
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
if d.chatFn == nil {
return "", errors.New("mavend: chat not available")
}
return d.chatFn(ctx, conversation, text), nil
}
// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
if d.getMCPServers == nil {
return nil, nil
}
return d.getMCPServers(), nil
}
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
}
return d.getMorningStatus(ctx), nil
}
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
if d.getDayPlan == nil {
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
}
return d.getDayPlan(ctx), nil
}
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}
+122
View File
@@ -0,0 +1,122 @@
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
//
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
// for, and the loop-to-ipc conversions those answers need.
package main
import (
"context"
"errors"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
// the whole off-switch for the backdated write path (Vikunja #518), and it
// is a store rather than a bool so that leaving the flag off means the
// method has nothing to write with, not merely permission to refuse.
seedStore *store.Store
}
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
// look the same to a reader on purpose, because neither is a fault and the
// page renders both as an empty table.
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
if d.getEvents == nil {
return nil, nil
}
return d.getEvents(n), nil
}
// Chat runs one text turn and reports which query source claimed it. The sink
// rides the context so handleText keeps the one string signature the mic,
// telegram and the web all call it through (V-539).
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) {
if d.chatFn == nil {
return ipc.ChatReply{}, errors.New("mavend: chat not available")
}
ctx, sink := withQuerySourceSink(ctx)
reply := d.chatFn(ctx, conversation, text)
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
}
// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
if d.getMCPServers == nil {
return nil, nil
}
return d.getMCPServers(), nil
}
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
}
return d.getMorningStatus(ctx), nil
}
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
if d.getDayPlan == nil {
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
}
return d.getDayPlan(ctx), nil
}
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}
+238
View File
@@ -0,0 +1,238 @@
// mavend/tick_digest.go — the digest queue.
//
// Split out of tick.go, move-only (Vikunja #422). A nudge below the severity
// ceiling is held here instead of spoken, flushed as one batch on the window,
// and drained by hand when he asks. Everything about batching lives here.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
// shouldQueue — true when digest is enabled and the candidate's severity is
// at or below the configured ceiling.
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
return t.digestCfg != nil && t.digestCfg.Enabled &&
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
}
// queueNudge — phrases the candidate and appends it to the digest queue.
// Deduplicates by rule name: if the same rule is already queued, this is a
// no-op (the first fire within the window is the one that counts).
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
for _, q := range t.digestQ {
if q.Rule == cand.Rule.Name {
return // already queued
}
}
pn, err := t.phraser.PhraseNudge(ctx, *cand)
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
return
}
t.digestQ = append(t.digestQ, QueuedNudge{
Rule: cand.Rule.Name,
Severity: int(cand.Severity),
Body: pn.Body,
Key: cand.Rule.Name,
QueuedAt: now,
})
t.cachePhrase(pn)
}
// maybeFlush — flushes the digest queue if the window has elapsed since the
// first item or the queue reached MaxItems.
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
return
}
first := t.digestQ[0]
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
len(t.digestQ) >= t.digestCfg.MaxItems {
t.flushDigest(ctx, now, state)
}
}
// flushDigest — concatenates queued nudge bodies into a single digest
// notification and dispatches it. Clears the queue after a successful send.
// The digest uses the max severity among queued items for routing.
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
if len(t.digestQ) == 0 {
return
}
var b strings.Builder
maxSev := 0
for i, q := range t.digestQ {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(q.Body)
if q.Severity > maxSev {
maxSev = q.Severity
}
}
body := b.String()
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
cand := loop.Candidate{
Rule: loop.Rule{
Name: "digest",
Severity: loop.Severity(maxSev),
},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{
Candidate: cand,
Body: body,
Summary: summary,
}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
// keep the queue — the next tick's maybeFlush re-attempts.
log.Printf("tick: dispatch digest: %v", err)
return
}
t.digestQ = nil
}
// digestExpiry — how long a gate-suppressed care nudge stays worth
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
// hour-scale cooldowns and re-derive from facts that reset every day), so a
// digest entry that outlives one full day is describing a day that's already
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
// news. Bounding at one day also means a digest can never silently span a
// weekend of quiet hours into an unbounded backlog.
const digestExpiry = 24 * time.Hour
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
// dropped" cannot regress into "she dumps twelve things on me the moment I
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
// Anything beyond the cap is still marked drained (it did get its moment;
// the cap limits WORDS, not whether it counted) and folded into a trailing
// count instead of being spoken in full.
const maxDigestSpokenItems = 3
// enqueueSuppressedDigest scans this tick's trace for care candidates the
// gate blocked for a genuine restraint reason and durably records the
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
// at enqueue time — not re-derived at drain time — the same way queueNudge
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
// makes repeat calls here harmless, but skipping the phrase call entirely
// when a pending entry already exists avoids the LLM round-trip too).
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
if trace == nil {
return
}
for _, tr := range trace.RuleTraces {
if !tr.PredicateResult || tr.GateResult {
continue // didn't want to fire, or wasn't suppressed
}
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
continue
}
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
pn, err := t.phraser.PhraseNudge(ctx, cand)
if err != nil {
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
continue
}
expires := now.Add(digestExpiry)
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
} else if deduped {
// same suppressed nudge already pending — nothing new to say.
continue
}
}
}
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: expire stale digest entries: %v", err)
return
}
if n > 0 {
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
}
}
// maybeDrainDigest speaks the pending digest bundle once the gate's
// suppression reasons have actually cleared — quiet hours over, back from
// away, out of the meeting. Draining while still suppressed would just be a
// second way to nag through quiet hours; the bundle waits for the same "is
// it allowed right now" condition a live nudge already waits for.
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
return
}
entries, err := t.store.PendingDigestEntries(ctx, now)
if err != nil {
log.Printf("tick: pending digest entries: %v", err)
return
}
if len(entries) == 0 {
return
}
spoken := entries
extra := 0
if len(spoken) > maxDigestSpokenItems {
spoken = entries[:maxDigestSpokenItems]
extra = len(entries) - maxDigestSpokenItems
}
var b strings.Builder
maxSev := 0
for i, e := range spoken {
if i > 0 {
b.WriteString(" · ")
}
b.WriteString(e.Body)
if e.Severity > maxSev {
maxSev = e.Severity
}
}
if extra > 0 {
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
// The adjective declines with the noun, so the count picks the whole
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
// отложенных уведомлений.
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
Severity: loop.Severity(maxSev),
State: state,
}
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
t.cachePhrase(pn)
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch digest bundle: %v", err)
return // leave entries pending; retried next tick
}
ids := make([]int64, len(entries))
for i, e := range entries {
ids[i] = e.ID
}
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
log.Printf("tick: drain digest entries: %v", err)
}
}
+197
View File
@@ -0,0 +1,197 @@
// mavend/tick_morning.go — the morning checklist and the day plan.
//
// Split out of tick.go, move-only (Vikunja #422). One window per configured
// routine, nudging once at the end for what is still open, plus the read
// surfaces /morning renders.
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/store"
)
// fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
// because the item↔fact-key mapping is morning-routine-specific, not a rule
// concern — pulling it into the shared gather path would leak that mapping
// into loop's "rules declare wanted keys" contract. Bodies are literal
// operator text (item labels joined), not LLM-phrased, same rationale as
// cron routines: deterministic, can't hallucinate a checklist item.
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
if len(t.morningRoutines) == 0 {
return
}
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
body := morningNudgeBody(cand)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
Severity: loop.Severity(cand.Routine.Severity),
State: state,
},
Body: body,
Summary: body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
}
}
}
// morningNudgeBody words the one message a routine gets per day. Required
// items are what she says was not done; optional ones follow, worded as
// something he could still do rather than something he owes (Vikunja #473).
// Operator text, not phrased by the model, for the same reason it always was:
// a checklist item must not be invented.
func morningNudgeBody(cand morning.Candidate) string {
labels := func(items []morning.Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Label
}
return strings.Join(out, ", ")
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
body += fmt.Sprintf(". если будет время — %s", labels(opt))
}
return body
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
// disagree about what evidence exists.
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
keys := make(map[string]struct{})
for _, r := range t.morningRoutines {
for _, it := range r.Items {
keys[it.FactKey] = struct{}{}
}
}
facts := make(map[string]store.Fact, len(keys))
for k := range keys {
f, err := t.store.LatestFact(ctx, k)
if err == nil {
facts[k] = f
continue
}
if err != store.ErrNoFact {
log.Printf("tick: morning: latest fact %s: %v", k, err)
}
}
return facts
}
// morningStatus is the read-only "what's missing" query the web UI (and
// eventually a voice query) calls. Pure recompute over the current facts —
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
// "state right now," not "should we nag."
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
if len(t.morningRoutines) == 0 {
return nil
}
facts := t.gatherMorningFacts(ctx)
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
for _, r := range t.morningRoutines {
st := morning.Evaluate(r, facts, now)
done := make(map[string]bool, len(st.Completed))
for _, it := range st.Completed {
done[it.Key] = true
}
items := make([]ipc.MorningRoutineItem, len(r.Items))
for i, it := range r.Items {
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
}
out = append(out, ipc.MorningRoutineStatus{
Name: r.Name,
Active: st.Active,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
Items: items,
})
}
return out
}
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
// the impure half of morning.BuildPlan: it reads the calendar events, the
// pending reminders and the checklist facts, and the pure builder orders them.
//
// It never dispatches. Asking for the plan is a query like any other; the only
// unprompted delivery in maven stays with the morning nudge and the
// dispatcher's policy.
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
var events []morning.PlanEntry
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: calendar events: %v", err)
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(f.Value),
Kind: morning.PlanEvent,
// Provenance below a calendar read (an ambient relay, #126) is
// hedged rather than recited as fact.
Uncertain: f.Confidence < 1.0,
})
}
var reminders []morning.PlanEntry
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
if fire.IsZero() {
fire = r.FireTs
}
reminders = append(reminders, morning.PlanEntry{
At: fire,
Text: r.Text(),
Kind: morning.PlanReminder,
})
}
var checklistFacts map[string]store.Fact
if len(t.morningRoutines) > 0 {
checklistFacts = t.gatherMorningFacts(ctx)
}
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
for i, it := range plan.Items {
out.Items[i] = ipc.DayPlanItem{
At: it.At,
Text: it.Text,
Kind: string(it.Kind),
Uncertain: it.Uncertain,
}
}
return out
}
+234
View File
@@ -0,0 +1,234 @@
// mavend/tick_routines.go — pattern detection and the routines that fire.
//
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
// routines he accepted on /routines, and the tick-side detector that proposes
// new ones.
package main
import (
"context"
"log"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/routine"
)
// detectPatterns runs the pattern detector proactively over every
// action+object pair that has ever produced an event, independent of
// whichever fact write (or channel) last touched it (Vikunja #43). This is
// what makes pattern inference actually proactive: it fires on the daemon's
// own schedule reading accumulated history, not only as a side effect of a
// live voice turn.
//
// Idempotence and noise are handled by the store, not here — this function
// is safe to call every tick:
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
// check plus proposed_routines' UNIQUE(action, object) constraint (with
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
// already has a row — in ANY status — produces no second row and no log
// spam beyond the one line at genuine creation.
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
// status in place; the row is never deleted. So the same Lookup check
// that stops a duplicate "proposed" also stops a "dismissed" one from
// resurrecting — there is nothing tick-specific to get right here beyond
// calling the same shared path the voice route already used.
//
// By default this only creates a row for the /routines page to show: it does
// not notify, ring, or speak. Detection is not the same act as disturbing him
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
// is opt-in through the pattern_proposals config block — see announceProposal
// for the restraints that apply even then. A proposal only starts producing
// recurring nudges once he accepts it (fireAcceptedRoutines).
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
pairs, err := t.store.DistinctEventPairs(ctx)
if err != nil {
log.Printf("tick: distinct event pairs: %v", err)
return
}
announced := false
for _, p := range pairs {
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
if err != nil {
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
continue
}
if r == nil {
continue // no stable pattern, or already proposed/accepted/dismissed
}
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// One announcement per tick at most, whatever the scan turned up. The
// rest are on /routines; they are not lost, they are just not shouted.
// Nor are they queued: the row now exists, so no later tick re-detects
// them and they are never announced. See announceProposal.
if announced {
continue
}
announced = t.announceProposal(ctx, r, now, state)
}
}
// announceProposal offers a freshly inferred routine through the ordinary
// care-delivery path, if announcing is switched on at all. Returns true when
// something was actually sent.
//
// Everything here is restraint. The feature is off unless configured; when on
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
// dropped announcement is NOT retried — the cooldown clock advances only on a
// real send, but the proposal row already exists, so the next tick will not
// re-detect it and nothing queues up behind it. A missed announcement means
// he reads it on /routines instead, which is the whole point of the page.
//
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
// newly created row, so a pair gets exactly one chance to be spoken: the tick
// that first proposes it. Combined with one announcement per tick, the first
// tick over a populated history announces one pattern and permanently silences
// every other pattern found in the same pass. That is the intent, not an
// oversight — an inferred routine is not worth a second attempt at his
// attention, and /routines lists all of them. So the cooldown does not drain a
// backlog. It only spaces announcements of genuinely new pairs discovered on
// later ticks. If it should ever become "one per day until each is mentioned",
// that needs a queue rather than this counter.
//
// Cooldown gets its default here as well as in applyDefaults. That is
// deliberate: a tickLoop assembled directly in a test never goes through Load,
// and an unspaced announcer is not what those tests mean to exercise.
//
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
// an inferred routine cannot arrive worded as something Maven never observed.
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
if !t.proposalCfg.AnnounceProposals() {
return false
}
cooldown := time.Duration(t.proposalCfg.Cooldown)
if cooldown <= 0 {
cooldown = config.DefaultProposalCooldown
}
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
return false
}
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
return false
}
body := pattern.PhraseRoutine(r)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
return false
}
if len(sent) == 0 {
return false // routing dropped it — /routines still has it.
}
t.lastProposalAt = now
return true
}
// routinesFromConfig maps the config's routine blocks to the engine type.
// Validation (cron parses, name/body present, severity defaulted) already ran
// in config.Load, so this is a pure field copy.
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
if len(rc) == 0 {
return nil
}
out := make([]routine.Routine, len(rc))
for i, r := range rc {
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
}
return out
}
// fireRoutines dispatches the routines whose cron schedule crossed since their
// last fire. Each is delivered as a nudge through the normal routing table
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
// can't collide with a care rule in the feedback autotuner. A dispatch failure
// logs and continues — one bad send must not skip the rest, and routine.Due has
// already advanced the last-fire time so a transient failure drops that fire
// rather than replaying it every tick (a routine is clockwork, not an alarm —
// no repeat-til-ack).
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
for _, r := range routine.Due(t.routines, t.routineLast, now) {
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
Severity: loop.Severity(r.Severity),
State: state,
},
Body: r.Body,
Summary: r.Body,
}
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
}
}
}
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
+3 -1
View File
@@ -584,7 +584,9 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
ctx := context.Background()
now := refNow()
markPresent(t, st, ctx, now)
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
// Older than loop.MinDownAge, so this tests the digest bypass and not the
// flap debounce (Vikunja #536).
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now.Add(-5*time.Minute)); err != nil {
t.Fatalf("seed service_down: %v", err)
}
sink := &fakeSink{}
+2 -2
View File
@@ -212,8 +212,8 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
// one held-out case that lands there is "вайфай опять отвалился", which reads as
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
h.topics.load(ctx, h.embedder)
label, margin, ok := h.topics.best(t.vec)
h.recall.topics.load(ctx, h.recall.embedder)
label, margin, ok := h.recall.topics.best(t.vec)
if !ok {
return floor(t.dec.Utterance)
}
+4 -4
View File
@@ -88,10 +88,10 @@ func TestONNXTopics(t *testing.T) {
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
}
h := &reactiveHandler{embedder: emb}
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
ctx := context.Background()
h.topics.load(ctx, emb)
if !h.topics.loaded {
h.recall.topics.load(ctx, emb)
if !h.recall.topics.loaded {
t.Fatal("topic seeds did not load")
}
right := 0
@@ -100,7 +100,7 @@ func TestONNXTopics(t *testing.T) {
if err != nil {
t.Fatalf("embed %q: %v", tc.utterance, err)
}
label, margin, ok := h.topics.best(vec)
label, margin, ok := h.recall.topics.best(vec)
if !ok {
t.Fatalf("best(%q) not ok", tc.utterance)
}
+27 -31
View File
@@ -56,7 +56,6 @@ import (
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -73,19 +72,16 @@ import (
// safe (the wired stt/tts/router/api all are); called from per-conn
// goroutines on the voice.Server.
type reactiveHandler struct {
stt stt.Transcriber
tts tts.Synthesizer
router *router.Router
embedder router.Embedder // reused for note write/query (same model as the classifier)
// topics — the embedded seed sets behind the weather, house and LAN
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
// usable, loads on first query, and with no embedder it never loads and
// each source falls back to its own keyword test.
topics topicIndex
// boundary — the embedded seed sets behind the personal boundary
// (personalboundary.go). Zero value is usable and loads on first query;
// with no embedder it never loads and the boundary uses personalMarkers.
boundary personalBoundary
stt stt.Transcriber
tts tts.Synthesizer
router *router.Router
// recall — the note-and-fact recall subsystem: the embedder, the vector
// store it writes into, the personal boundary, and the two numbers that
// gate an answer. Grouped rather than spread across the handler because a
// handler that recalls needs all five and a handler that does not needs
// none of them (Vikunja #433, docs/handler-wiring.md).
recall recallWiring
// api — the CoreAPI the handler reads and writes through. Wired with the
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
// upgradeAPI.
@@ -129,25 +125,16 @@ 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.
timeParser router.DateTimeParser
// dialogueSessions carries slots across turns for follow-ups (single-user
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
// dialogueSessions carries slots across turns for follow-ups. Keyed by the
// reach the turn arrived on (dialogueIDOf), like the clarify store: one
// slot per reach, not one for the box. nil ⇒ no carry-over.
dialogueSessions *dialogue.SessionStore
// clarifyStore parks the request behind an open question she asked (see
@@ -172,6 +159,15 @@ type reactiveHandler struct {
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
// surfacedItems — the Praxis item ids she last read out, in the order she
// read them, so "отметь второй пункт" has a second pункт to mean (Vikunja
// #516). Same single-slot posture as pending above: the next attention digest
// replaces the list, because a position only refers to the last one spoken.
// No TTL — a stale position resolves to an item that Praxis will report as
// already acknowledged, which is a harmless answer, unlike a stale
// confirmation that would execute something.
surfacedItems []string
ecosystem *ecosystemWiring // nexus + hexis + praxis clients
}
@@ -318,7 +314,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word
// and keeps routing.
if reply, handled := h.resolveCandidate(ctx, text); handled {
if reply, handled := h.resolveCandidate(ctx, text, src); handled {
return withNotice(expiredNotice, reply)
}
@@ -334,7 +330,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
)
now := h.now()
if h.dialogueSessions != nil {
prev = h.dialogueSessions.Get(voiceDialogueID, now)
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
}
cont := false
if dec, cont = continuationDecision(prev, text, now); cont {
@@ -366,7 +362,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
dec = followUpMerge(prev, dec, now)
}
if !dec.Clarify {
h.rememberTurn(prev, dec, now)
h.rememberTurn(ctx, prev, dec, now)
}
}
@@ -497,12 +493,12 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
// chatHistory collects dialogue turns from the session store for the current
// conversation. Returns prior user utterances (newest last) up to a depth of
// 4 turns. Returns nil when there's no session or no history.
func (h *reactiveHandler) chatHistory() []dialogue.Turn {
func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn {
if h.dialogueSessions == nil {
return nil
}
now := h.now()
prev := h.dialogueSessions.Get(voiceDialogueID, now)
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
if prev == nil {
return nil
}
+30 -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,
}
@@ -390,6 +392,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
grammars = append(grammars, router.TaskListGrammar())
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Before the capture marker, because "отметь" is a capture verb and "отметь
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
grammars = append(grammars, router.PraxisGrammars()...)
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
@@ -550,6 +556,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
// runReembed.
var reembedOnStart bool
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
// default is the one that matters: a box nobody is testing has no live path to
// write a fact into the past. See seed.go and Vikunja #518.
var allowSeedOnStart bool
// checkStoredEmbedder compares the embedder we just loaded with the one that
// wrote the vectors already in the DB (Vikunja #378).
//
+5 -6
View File
@@ -31,12 +31,11 @@ import (
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
// is worse than a gap.
//
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
// meeting is good enough to recite and not good enough to stop a nudge —
// calendar_busy is still written only by the CalDAV poller. That is backwards,
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
// calendar_busy is a level rather than an event, so an ambient writer needs an
// expiry, which is its own task and not a change here.
// This writes calendar_event_* and nothing else, and since Vikunja #513 that is
// enough to stop a nudge as well as to recite: the loop gatherer reads the event
// family and asks whether any span covers the instant. So there is no ambient
// calendar_busy and no expiry to pick — a level needs one and an event carries
// its own. calendar_busy stays the CalDAV poller's key.
// ambientMaxBody bounds the request. A notification is two short lines.
const ambientMaxBody = 8 << 10
+32 -7
View File
@@ -139,13 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
}
func TestHandleAmbientAuth(t *testing.T) {
// posted_at carries the local offset, and the clock reading inside the text
// sits twenty minutes after it. A bare "Z" here would make the reading
// stale by the test machine's own offset and the handler would answer 202
// no-meeting, which says nothing about the auth this test is checking
// (Vikunja #482).
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339))
// "завтра" so the meeting is in the future in every zone: the clock in the
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
// is already stale on a box east of UTC and stores nothing (Vikunja #482).
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
newReq := func(hdr, val string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
@@ -251,3 +248,31 @@ func TestHandleAmbientBadInput(t *testing.T) {
}
})
}
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
// used to be stored at 18:30, and the size of the error was the deploy's offset.
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
// No zone juggling: the assertion is that the stored wall clock is the one
// he read, whatever zone the box is in. That is false under the old code
// on every box except a UTC one.
core := &ambientCore{}
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон завтра в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
})
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
}
if !strings.HasSuffix(resp.Key, "_Standup") {
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
}
if len(core.writeLog) != 1 {
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
}
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
}
}
+24
View File
@@ -0,0 +1,24 @@
{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{template "shellBottom"}}
+40 -5
View File
@@ -67,8 +67,9 @@ type fakeCore struct {
traceErr error
// for handleChatAPI tests
chatText string
chatErr error
chatText string
chatSource string
chatErr error
// for the MCP section of /tools
mcpServers []ipc.MCPServerStatus
@@ -79,12 +80,12 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
return f.mcpServers, f.mcpErr
}
func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
func (f *fakeCore) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
f.chatText = text
if f.chatErr != nil {
return "", f.chatErr
return ipc.ChatReply{}, f.chatErr
}
return "поняла", nil
return ipc.ChatReply{Reply: "поняла", Source: f.chatSource}, nil
}
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
@@ -1285,3 +1286,37 @@ func TestHandleNotifications_ShowsTheOutbox(t *testing.T) {
}
}
}
// --- the query source badge (V-539) ---
//
// Which query source claimed a turn was readable in the daemon log and nowhere
// else, so a QA step could not tell a wrong answer from a wrongly ordered
// chain. It now rides the redirect and renders beside the reply.
func TestHandleChatAPI_CarriesTheClaimingSource(t *testing.T) {
core := &fakeCore{chatSource: "kiwix"}
rr := httptest.NewRecorder()
handleChatAPI(rr, postChat("почему небо голубое"), core, nil, false)
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "s=kiwix") {
t.Errorf("redirect = %q; want the claiming source in it", loc)
}
}
func TestHandleChatAPI_OmitsTheSourceWhenNothingClaimed(t *testing.T) {
core := &fakeCore{}
rr := httptest.NewRecorder()
handleChatAPI(rr, postChat("запиши что я пил воду"), core, nil, false)
if loc := rr.Header().Get("Location"); strings.Contains(loc, "s=") {
t.Errorf("redirect = %q; a turn no source claimed carries no badge", loc)
}
}
func TestChatPageRendersTheSourceBadge(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/chat?q=%D1%82%D0%B5%D1%81%D1%82&r=%D0%BE%D1%82%D0%B2%D0%B5%D1%82&s=search", nil)
rr := httptest.NewRecorder()
handleChatPage(rr, req, &fakeCore{})
if body := rr.Body.String(); !strings.Contains(body, ">search</span>") {
t.Errorf("chat page does not render the source badge; body=%s", body)
}
}
+164 -269
View File
@@ -76,6 +76,23 @@ var morningHTML string
//go:embed events.html
var eventsHTML string
//go:embed tools.html
var toolsHTML string
//go:embed routines.html
var routinesHTML string
//go:embed chat.html
var chatPageHTML string
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
// with the sidebar assembled by a strings.Builder, which is the one piece of
// markup that was still concatenated in Go.
//
//go:embed shell.html
var shellHTML string
// ── Ethos Workstation Shell ──
//
// Two template pieces that wrap every page:
@@ -136,74 +153,40 @@ var sidebarSections = []struct {
},
}
func sidebarActive(url, key string, activeKey string) string {
if key == activeKey {
return `class="active"`
}
return ""
}
// sidebarHTML renders the sidebar navigation given the active page key.
func sidebarHTML(active string) template.HTML {
var b strings.Builder
for _, sec := range sidebarSections {
b.WriteString(`<div class=sidebar-section>`)
b.WriteString(`<div class=sidebar-label>`)
b.WriteString(sec.Label)
b.WriteString(`</div>`)
for _, p := range sec.Pages {
cls := ""
if p.Key == active {
cls = ` class="active"`
}
b.WriteString(`<a href="`)
b.WriteString(p.URL)
b.WriteString(`"`)
b.WriteString(cls)
b.WriteString(`><span class=icon>`)
b.WriteString(pageIcon(p.Key))
b.WriteString(`</span><span>`)
b.WriteString(p.Label)
b.WriteString(`</span></a>`)
}
b.WriteString(`</div>`)
}
return template.HTML(b.String())
}
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
// sidebar template wraps it in the <use> reference.
func pageIcon(key string) string {
switch key {
case "dash":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
return "i-grid"
case "history":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
return "i-clock"
case "trace":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
return "i-wave"
case "notifications":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
return "i-bell"
case "tasks":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
return "i-grid"
case "reminders":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
return "i-calendar"
case "routines":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
return "i-repeat"
case "morning":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
return "i-calendar"
case "chat":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
return "i-message"
case "voice":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
return "i-mic"
case "ecosystem":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
return "i-grid"
case "tools":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
return "i-settings"
case "models":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
return "i-wave"
case "passkey":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
return "i-lock"
default:
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
return "i-search"
}
}
@@ -243,63 +226,12 @@ func pageTitle(key string) string {
}
}
// shellTopHTML opens the shell and renders the top bar + sidebar.
// Usage: {{template "shellTop" "<page-key>"}}
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{sidebarHTML .}}
</aside>
<main class=content>
{{end}}`
// shellBottomHTML closes the content area, inspector, and shell.
// Usage: {{template "shellBottom"}}
const shellBottomHTML = `{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}`
// shellFuncs returns the FuncMap shared by every server-rendered page template.
func shellFuncs() template.FuncMap {
return template.FuncMap{
"pageTitle": pageTitle,
"sidebarHTML": sidebarHTML,
"pageTitle": pageTitle,
"pageIcon": pageIcon,
"sidebarSections": func() any { return sidebarSections },
"ago": func(t time.Time) string {
if t.IsZero() {
return "never"
@@ -314,21 +246,21 @@ func shellFuncs() template.FuncMap {
// a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
// human surface is here (they ship no web UI of their own).
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
// morningTmpl — read-only view of today's checklist state per configured
// morning routine (internal/morning). Same shape as trace.html: a plain
// server-rendered page, refreshed on reload — no live-update loop, since
// checklist state changes on the scale of minutes, not seconds.
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -750,109 +682,23 @@ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMa
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
return m
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
}()).Parse(shellHTML + toolsHTML))
const toolsHTML = `{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> the same shape Hexis speaks and it is derived from the row, so it always describes what the command actually does.</p>
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
<td>{{if .Connected}}connected{{if .Server}} {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} {{.Err}}{{end}}{{end}}</td>
<td>{{.Tools}}</td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no MCP servers configured</div>
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
</div>{{end}}
</section>
{{template "shellBottom"}}`
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
// routinesHTML — proposed routine review surface. One row per thing maven
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
// routinesTmpl — the proposed-routine review surface. One row per thing maven
// noticed, in her words, with at most two actions: accept or dismiss.
const routinesHTML = `{{template "shellTop" "routines"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{template "shellBottom"}}`
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
m := shellFuncs()
@@ -864,7 +710,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
}
m["join"] = strings.Join
return m
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
}()).Parse(shellHTML + traceHTML))
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
@@ -1222,9 +1068,10 @@ func fmtTaskDate(t *time.Time) string {
return t.Local().Format("02 Jan")
}
// routineRow is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it.
type routineRow struct {
// routineView is one line on the page: what maven noticed, in her words, and
// how long ago she noticed it. A view model, not a database row — the template
// never formats an interval or a timestamp itself.
type routineView struct {
ID int64
Phrase string
Noticed string
@@ -1247,34 +1094,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
var msg string
if r.Method == http.MethodPost {
action := r.FormValue("action")
idStr := r.FormValue("id")
var rid int64
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
switch action {
case "accept":
// "seed" is the one action with no routine to act on — it is what
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
// lives on this route rather than a page of its own because it is
// already the step-up-gated surface for this table, and a second gated
// surface is a second thing to get wrong.
if action == "seed" {
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
if err := acceptRoutine(ctx, core, rid); err != nil {
log.Printf("routines: accept %d: %v", rid, err)
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
out, err := seedRoutineEvent(ctx, core, r)
if err != nil {
log.Printf("routines: seed: %v", err)
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "accepted routine — maven will remind you"
case "dismiss":
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
log.Printf("routines: dismiss %d: %v", rid, err)
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
msg = out
} else {
idStr := r.FormValue("id")
var rid int64
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
switch action {
case "accept":
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
if err := acceptRoutine(ctx, core, rid); err != nil {
log.Printf("routines: accept %d: %v", rid, err)
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "accepted routine — maven will remind you"
case "dismiss":
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
log.Printf("routines: dismiss %d: %v", rid, err)
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
return
}
msg = "dismissed routine"
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
msg = "dismissed routine"
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
}
proposed, err := core.ListProposedRoutines(ctx)
@@ -1286,23 +1152,23 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := routinesTmpl.Execute(w, struct {
Msg string
Proposed []routineRow
}{msg, routineRows(proposed)}); err != nil {
Proposed []routineView
}{msg, toRoutineViews(proposed)}); err != nil {
log.Printf("routines render: %v", err)
}
}
// routineRows turns the wire rows into display rows. The phrase comes from
// toRoutineViews turns the wire rows into view models. The phrase comes from
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
out := make([]routineRow, 0, len(rs))
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
out := make([]routineView, 0, len(rs))
for _, r := range rs {
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
noticed := "just now"
if r.CreatedTs > 0 {
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
}
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
}
return out
}
@@ -1311,6 +1177,45 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow {
// may do it (Vikunja #367): accepting gives the tick loop a standing new
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
// behind step-up. Voice can park the question and dismiss, never accept.
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
// so the pattern detector can be exercised against a running daemon instead of
// over real days. Refused unless mavend was started with -allow-seed; on an
// ordinary box the error says so and nothing is written.
//
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
// so a QA sitting can space four seeds three hours apart without doing clock
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
// meaning now.
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
key := strings.TrimSpace(r.FormValue("key"))
value := strings.TrimSpace(r.FormValue("value"))
if key == "" || value == "" {
return "", errors.New("seed needs a key and a value")
}
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
if err != nil {
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
}
if agoHours < 0 {
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
}
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
Key: key,
Value: value,
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
})
if err != nil {
return "", err
}
if !resp.Extracted {
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
}
if !resp.Proposed {
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
}
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
}
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
proposed, err := core.ListProposedRoutines(ctx)
if err != nil {
@@ -1693,7 +1598,13 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
return
}
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
w.Header().Set("X-Reply-Text", url.QueryEscape(pttResp.ReplyText))
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
// as "+", which is form encoding, and the client decodes this header
// with decodeURIComponent, which only knows "%20" — so every space in a
// spoken reply reached the on-page log as a plus sign. PathEscape is the
// flavour decodeURIComponent actually reverses, which keeps the encoding
// a property of the header rather than something the client has to know.
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
w.Write(pttResp.ReplyAudio.Bytes)
return
}
@@ -1703,32 +1614,7 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
// and the handler redirects back to /chat with the response.
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
const chatPageHTML = `{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{template "shellBottom"}}`
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
// handleChatPage renders the chat conversation page.
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
@@ -1741,8 +1627,8 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if q := r.URL.Query().Get("q"); q != "" {
msgs = append(msgs, chatMsg{Role: "user", Text: q})
}
if r := r.URL.Query().Get("r"); r != "" {
msgs = append(msgs, chatMsg{Role: "assistant", Text: r})
if reply := r.URL.Query().Get("r"); reply != "" {
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := chatTmpl.Execute(w, struct {
@@ -1791,13 +1677,22 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
http.Redirect(w, r, "/chat", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther)
// The claiming query source rides back on the redirect so the page can show
// it. Empty for a turn no source claimed, which is most of them.
dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply)
if reply.Source != "" {
dest += "&s=" + url.QueryEscape(reply.Source)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// chatMsg — one message in the conversation history.
type chatMsg struct {
Role string // "user" | "assistant"
Text string
// Source — the query source that claimed the turn, shown as a badge beside
// the reply. Empty for a turn no source claimed (V-539).
Source string
}
func mustMarshal(v any) json.RawMessage {
+1 -1
View File
@@ -31,7 +31,7 @@ type modelController interface {
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
}
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"net/url"
"strings"
"testing"
)
// decodeURIComponent is what static/app.js calls on X-Reply-Text. PathUnescape
// is its Go equivalent for this purpose: both turn %XX into bytes and both
// leave a literal "+" alone. That last part is the whole defect — QueryEscape
// wrote spaces as "+" and the client had no way to tell those from a plus the
// speaker actually said.
func decodeURIComponent(t *testing.T, s string) string {
t.Helper()
out, err := url.PathUnescape(s)
if err != nil {
t.Fatalf("decodeURIComponent(%q): %v", s, err)
}
return out
}
// The reply the QA session actually saw was "на+04.08.2026+ничего+нет."
// (Vikunja #533). Round-tripping through the client's decoder is the assertion
// that matters — checking the encoder in isolation would have passed with
// QueryEscape too.
func TestReplyTextSurvivesTheClientDecoder(t *testing.T) {
cases := []string{
"на 04.08.2026 ничего нет.",
"Я поставила тебе напоминание позвонить маме через час.",
// A literal plus must stay a plus, which is the case that makes
// "just replace + with space on the JS side" the wrong fix.
"два плюс два = 2+2",
// Headers cannot carry a raw newline. PathEscape writes %0A.
"первая строка\nвторая строка",
"", // no reply text at all
}
for _, want := range cases {
encoded := url.PathEscape(want)
if strings.ContainsAny(encoded, "\r\n") {
t.Errorf("encoded %q contains a raw newline, which is not a legal header value", want)
}
if got := decodeURIComponent(t, encoded); got != want {
t.Errorf("round trip: got %q, want %q", got, want)
}
}
}
// The specific regression, named. QueryEscape is form encoding and this header
// is not a form.
func TestReplyTextDoesNotUseFormEncoding(t *testing.T) {
const spoken = "на 04.08.2026 ничего нет."
if got := decodeURIComponent(t, url.QueryEscape(spoken)); got == spoken {
t.Skip("QueryEscape round-trips here, so this test proves nothing — check the decoder stand-in")
}
if strings.Contains(url.PathEscape(spoken), "+") {
t.Errorf("PathEscape(%q) still writes a plus", spoken)
}
}
+24
View File
@@ -0,0 +1,24 @@
{{template "shellTop" "routines"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{template "shellBottom"}}
+49
View File
@@ -0,0 +1,49 @@
{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{template "sidebar" .}}
</aside>
<main class=content>
{{end}}{{/* sidebar — the section list, with the active page's link marked. The dot
argument is the page key the page passed to shellTop. */}}{{define "sidebar"}}{{$active := .}}{{range sidebarSections}}<div class=sidebar-section><div class=sidebar-label>{{.Label}}</div>
{{- range .Pages}}<a href="{{.URL}}"{{if eq .Key $active}} class="active"{{end}}><span class=icon><svg class=icon width="14" height="14"><use href="/ethos-icons.svg#{{pageIcon .Key}}"/></svg></span><span>{{.Label}}</span></a>
{{- end}}</div>
{{end}}{{end}}{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.</p>
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
<td>{{.Tools}}</td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no MCP servers configured</div>
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
</div>{{end}}
</section>
{{template "shellBottom"}}
+1
View File
@@ -90,6 +90,7 @@
"kiwix": {
"url": "http://kiwix-server:8080",
"book": "wikipedia_en_all_maxi_2026-02",
"book_ru": "wikipedia_ru_all_maxi_2026-02",
"max_results": 5,
"snippet_runes": 1500
},
+6 -1
View File
@@ -12,7 +12,12 @@ x-image: &image
# build on EVERY service (same image name ⇒ built once) so `docker compose
# build <anyservice>` actually rebuilds. With build on only one service, the
# others silently no-op and you deploy a stale binary.
build: .
build:
context: .
# the zone is declared once, here. The image points /etc/localtime at it so
# a caller reading the system zone agrees with one reading TZ (V-545).
args:
TZ: Europe/Samara
pull_policy: never # only ever the locally-built image
restart: unless-stopped
# local time for clock/date replies AND quiet-hours evaluation. Change to
+100
View File
@@ -0,0 +1,100 @@
# Ecosystem reach, measured — 2026-08-04
Vikunja #405. Measured at `86dcd99` on the 30-case held-out fixture
`internal/router/eval/ru_ecosystem_v1.json`, scored by `make eval-reach`.
**Praxis reach is zero. Not low — zero, on all twelve cases, under both embedders.**
## What was measured
Reach is where an utterance *arrives*, not what it achieves. The derivation is in
`internal/router/eval/reach.go` and mirrors `actionAct` in `cmd/mavend/actions_act.go`:
- **Praxis** needs `IntentAct` and a fn slot whose value is one of the aliases in
`praxisCapabilities`.
- **Hexis** needs `IntentAct` and non-empty `Slots.Text`. It also fires from
`hexisBeforeClarify`, so a clarified act with text reaches Hexis before the clarify
question is ever asked.
- Everything else stays inside Maven.
The fixture holds 10 Hexis cases, 12 Praxis cases and 8 negatives. The negatives carry
the expensive direction: an utterance that reaches a mutating path it had no business
reaching is worse than one that never arrives, because he never gets asked about it.
## The numbers
| Path | Reached the right place | Missed | Overreach | Wrong service | p50 |
|---|---|---|---|---|---|
| classifier + hash embedder | 7/30 (23.3%) | 22 | 1 | 0 | 14.8µs |
| classifier + e5-small (deployed) | 16/30 (53.3%) | 11 | 1 | 2 | 23.8ms |
Split by target, on the deployed embedder:
| Want | Passed |
|---|---|
| hexis | 9/10 |
| none | 7/8 |
| **praxis** | **0/12** |
## The finding
Hexis reach is fine. Nine of ten act-shaped home and homelab utterances land on the
entity resolver, which is what the gate was built to do: it only needs the intent and
some text, and both the act grammar and the embedder produce those.
Praxis reach is structurally impossible from free Russian, and the fixture makes that
visible for the first time. `handlePraxisAct` dispatches on exact equality between
`Slots.Fn` and a capability alias. The fn slot is filled by `DefaultActMatcher`, whose
allowlist is the deployment's enabled tool names — `перезапусти`, `выключи`, and so on.
No Praxis alias is in that list, so no utterance can ever put one in the slot. The
Russian aliases in `praxisCapabilities` (`готово`, `принято`, `игнорировать`) read as if
they match speech and they do not: they are compared against a fn slot, never against
the utterance.
That means the whole lifecycle half of the Praxis contract — acknowledge, resolve,
ignore, pin — has no voice path at all. Attention and changes have none either.
Three of the twelve got as far as the wrong place, which is the same defect seen from the
other side: `"готово, закрывай"` and `"как дела у праксиса"` route to act with text, so
they fall past the Praxis check into the Hexis one and go to entity resolution instead.
## The live services
All three are up and answer. With `no_proxy` set for the loopback (the host's `http_proxy`
answers 503 for 127.0.0.1, which is the same trap `llmrouter_test.go` documents):
```
127.0.0.1:8989/health -> {"status":"ok"} praxis
127.0.0.1:9740/health -> {"status":"ok"} nexus
127.0.0.1:9741/health -> {"status":"ok"} hexis
127.0.0.1:8989/api/v1/tools/attention?limit=3 -> []
127.0.0.1:8989/api/v1/tools/changes?limit=3 -> []
```
So the boundary is not the problem, and an end-to-end run today would add nothing: both
Praxis feeds are empty, so even a perfect reach score would produce "ничего не требует
внимания". The gap is entirely on Maven's side of the wire.
## Not measured
**The resident model.** The LLM router is the deployed default, and these numbers are the
classifier only. `mavend` spawns its llama-server on a container-local port
(127.0.0.1:40063 inside `maven-mavend-1`), unreachable from the host, and the workstation
at 192.168.1.105:8080 was down. The classifier is the failure floor and it is what always
answers, so the floor is worth knowing on its own — but the LLM router could fill the fn
slot with a literal `list_attention`, since the grammar lets it emit any string. Whether
it does is the open question, and the fixture is ready for it.
## What this argues for
Not a new intent. The seven are frozen by prompt parity with the training workspace.
The cheap fix is stage 0: a grammar per Praxis capability that sets `Slots.Fn` to the
canonical arm name, the same trick `AgendaQueryGrammars` used to take agenda questions off
the model. It costs one regex per capability on every turn and it is deterministic, which
for a lifecycle verb is the right trade — "отметь это как сделанное" should never be a
similarity guess.
The second fix is smaller and separate: `entity_attention` aliases to grammar names only,
so scoped attention ("что там с нексусом") needs a grammar before it can be reached at
all.
+88
View File
@@ -0,0 +1,88 @@
# Note recall after the e5-small swap — 04-08-2026
Closes Vikunja #371, which asked for the swap and for this re-measurement. The embedder is no
longer paraphrase-multilingual-MiniLM-L12-v2. It is **multilingual-e5-small**, quantized, with the
`query:` / `passage:` prefixes it was trained with (`internal/router/onnxembedder.go`,
`EmbedQuery` / `EmbedPassage`). `deploy/mavend.json` loads
`models/embedder/multilingual-e5-small/model_quantized.onnx`, which is the same file `make
download-embedder` fetches and the same file this run measured.
- Fixture + scorer: `internal/memory/recalleval/` — 32 cases now, not 30
- Reproduce: `make eval-recall`
- Commit: `b6abb19`
- Gate as deployed: `query_min_score` 0.55, `query_min_margin` 0.008
The fixture grew since 31-07, so the case counts are not comparable row for row. The percentages
are.
## Results
| | 31-07 MiniLM (onnx) | 04-08 e5-small (onnx) |
|---|---|---|
| **recall@1** | 60.0% (15/25) | **70.4% (19/27)** |
| recall@3 | 80.0% (20/25) | **85.2% (23/27)** |
| **answered after the gate** | 48.0% (12/25) | **63.0% (17/27)** |
| **false recall** | 1/5 (20%) | **0/5** |
| wrong note on top / tie on top | 10 / 0 | 8 / 0 |
| ranked first, then silenced by the gate | 3 | 2 |
| `hard` cases passed | 2/11 | 5/12 |
| RU / EN passed | 13/24 / 3/6 | 18/26 / 4/6 |
| latency p50 / p95 / max | 59ms / 148ms / 194ms | **23ms / 41ms / 62ms** |
The hash ratchet CI runs is unchanged in kind and still answers nothing after the gate: recall@1
37.0%, recall@3 74.1%, 0/27 answered, 0/5 false. It is lexical and exists so CI has a deterministic
floor. Never compare a hash number to an ONNX one.
## Findings
### 1. The swap paid on every axis at once, including latency
Ten points of recall@1, fifteen points of *answered*, the one false recall gone, and it is 2.5×
faster because the quantized e5-small is 118MB against the 470MB fp32 file the old config loaded.
Finding 3 of the 31-07 eval predicted the recall half and said nothing about speed; the speed came
from fixing the second half of that finding, which was that the deployed path loaded a different
file than the download target.
The concrete case that eval named is fixed. "из-за чего кончилось место" no longer returns the
guitar-chords filler note. It now returns a homelab note, `n2` at 0.884, and the wanted note is
still not in the top 3 — so the query moved from absurd to merely wrong. That is the shape of what
is left.
### 2. The score distributions still overlap. The margin is what separates them
This is the part of #371's premise that did not come true. Right-note-first top-1 scores run
0.791 / 0.857 / 0.890 (min / median / max). Must-stay-silent top-1 scores run 0.795 / 0.815 /
0.835. The silent cases sit *inside* the answering range, so no value of `query_min_score` keeps
every real recall and rejects every false one — the same verdict as 31-07, at a higher and tighter
band of scores.
What separates them is the second-place gap. Margin top1-top2 for a right first hit: median 0.024.
For a must-be-silent case: median 0.002, max 0.019. A false recall is a note that beats its
neighbours by nothing, because nothing in the store is about the question. The sweep:
| margin | answered | false recall |
|---|---|---|
| 0.000 | 18/27 (67%) | 3/5 |
| 0.005 | 17/27 (63%) | 1/5 |
| **0.008 (deployed)** | **17/27 (63%)** | **0/5** |
| 0.010 | 15/27 (56%) | 0/5 |
| 0.015 | 12/27 (44%) | 0/5 |
0.008 is the knee: it is the smallest margin that silences all five, and the next step up costs two
real answers for nothing. The score gate contributes almost nothing on its own — every value from
0.00 to 0.70 answers the same 18 and admits the same 3 — so `query_min_score` is now close to inert
and the margin is the live dial. Leave both where they are; #412 is where a further sweep belongs.
### 3. What is left is a retrieval problem, not a gate problem
Eight cases put the wrong note on top, and the failures cluster: `hard` 5/12, `preference` 5/9,
`homelab` 8/13. Four of the eight have the right note in the top 3, so a reranker would collect
them; the other four do not, so nothing downstream can. Two more rank first and are silenced by the
margin — `en-hard-024` at 0.826 with margin 0.023, and `ru-home-026` at 0.846 with margin 0.001,
which is a genuine near-tie against a second note that is also plausible.
Preference queries are the weakest class in a way that is not about the model. "когда запускать
резервное копирование" and "как мне присылать оповещения" both return a fact, not the note that
states the preference. Facts and notes are searched in one pass since #373, so a confidently-scored
fact wins a question that a note answers better. That is a ranking policy question and it belongs
in its own task, not in a threshold.
+43
View File
@@ -0,0 +1,43 @@
# Half-past and quarter-to hours, 2026-08-05
Vikunja V-538. `rewriteHalfPast` in `internal/router/halfpast.go`, run in front of
the token pass inside `SpellOutDigits`, so both date parsers see digits.
## What the shapes are
Russian names a half hour by the hour being ENTERED, in the genitive. "половина
восьмого" is 07:30. "без четверти восемь" counts the other way, from a cardinal,
and is 07:45. Both are minus one from the word in the sentence, and the arithmetic
lives in one function, `clockHourBefore`.
## Result
| | before | after |
|---|---|---|
| classifier + onnx over the routing fixture | 58/82 (70.7%) | 62/87 (71.3%) |
| new fixture cases passing | — | 2 of 3 |
| stub parser reads a half hour | no | yes |
The three new cases are ru-rem-008, ru-rem-009 and ru-rem-010. No existing case
regressed and no new clarify appeared.
ru-rem-009, "разбуди меня полвосьмого", still misses the intent. Its two siblings
without a half hour miss it the same way. ru-rem-005 "разбуди меня в 6:30" routes
to `fact`, and en-rem-002 "wake me at 6:15" does too. So the miss is the "разбуди"
phrasing against the classifier, not the half hour. The time slot now fills.
## Not measured here
Python dateparser. It is not installed on this host, so only the stub was run.
The rewrite emits "в 7:30 вечера". The script's own qualifier rewrite turns that
trailing "вечера" into "pm", which is the shape it already reads for a whole hour.
Judge it on the box.
The LLM arm. No llama-server in this run, so the cascade number is the classifier
floor.
## Left out on purpose
Minutes a spoken clock does not use. "без семи восемь" is not rewritten, because
nobody says it and a guess in this shape is a missed dose. The parsers fail on it
as they did before.
@@ -0,0 +1,65 @@
# Does the ZIM answer when the line is down? (V-508)
Measured 2026-08-05 on the deploy, through `POST /api/chat`. The question was
`что такое фотосинтез` in every run. Which source claimed is read off
`voice: query claimed by source` and off the badge V-539 added.
## It fires, and it is fast when the host is gone
`docker stop searxng`, then one question:
| | Claimed by | Turn |
|---|---|---|
| Search reachable | search | 3.5 s |
| Container stopped | kiwix | 3.5 s |
| Host blackholed | kiwix | 15.4 s |
With the container stopped, DNS failed and the ZIM answered inside the same
second:
```
15:40:51 voice: search "что такое фотосинтез": ... lookup searxng: no such host
15:40:51 voice: kiwix: "photosynthesis" → 5 hits, top "Photosynthesis"
15:40:53 voice: query claimed by source "kiwix"
```
The rewrite, the search and the reply all fit in the same turn budget as a live
search. The fallback works.
## The blackhole is the case that hurts
192.0.2.1 is reserved and routed nowhere. Pointing `search.url` at it is the
shape of a real outage: the router drops the packet instead of refusing it. The
search sat for its full 8-second budget before the ZIM was asked. The turn took
15.4 seconds against 3.5. He waits through all of it with nothing
being said.
Fixed by capping the connect phase alone at 1.5 s (`dialTimeout` in
`internal/websearch/searxng.go`). The instance is on the LAN, so a connection it
will ever accept is accepted in milliseconds. A reachable instance that is
merely slow still gets the whole 8 seconds. It is fanning out to real engines,
which is worth waiting for.
## The Russian ZIM is now on the box and is read directly
`wikipedia_ru_all_maxi_2026-02` (41 GB) was copied to the kiwix zims directory
and kiwix-serve picked it up. Note that the catalog name is derived from the
filename. `books.name=wikipedia_ru_all_maxi_2026-02` returns Фотосинтез,
С4-фотосинтез and Википедия. The `<name>` field in the catalog says
`wikipedia_ru_all`, which returns nothing.
A Cyrillic question now searches that book verbatim (`book_ru` in the `kiwix`
block). The rewriter was never a feature. An English ZIM cannot match a Russian
sentence, so the resident model translated the question into English keywords
first. That costs a model call. It also drops whatever the keywords do not carry.
Against a Russian book it is a translation of his own words back at him.
## Not measured here
- The Russian book answering a driven turn. The `book_ru` field is a binary
change, so it needs a rebuild the owner runs. The book itself was verified by
querying kiwix-serve directly.
- Recall against the Russian book compared with the rewrite path. Reading his
own language directly should win, and it was not scored.
- `ru.stackoverflow.com_mul_all_2026-02.zim` is still in the staging directory
and is wired to nothing.
+63
View File
@@ -0,0 +1,63 @@
# Praxis reach at stage 0, 2026-08-05
Vikunja #516. Measured with `make eval-reach` on the held-out ecosystem fixture
(`internal/router/eval/ru_ecosystem_v1.json`, 30 cases), classifier + ONNX embedder,
no llama-server in the run. The LLM arm was not measured, so judge a cascade
number again before quoting one.
## Result
| | before | after |
|---|---|---|
| overall | 16/30 (53.3%) | 27/30 (90.0%) |
| by want: praxis | 0/12 | 11/12 |
| by want: hexis | 9/10 | 9/10 |
| by want: none | 7/8 | 7/8 |
| by tag: lifecycle | 0/5 | 5/5 |
| by tag: attention | 0/7 | 6/7 |
| by tag: reading | 0/7 | 6/7 |
| wrong praxis arm | 0 | 0 |
| p50 latency | 20.6ms | 16.5ms |
## Why it was zero
Not a tuning gap. `handlePraxisAct` dispatches on exact equality between
`Slots.Fn` and a capability alias, and the fn slot is filled by `DefaultActMatcher`
from the deployment's enabled tool names. No Praxis alias is on that list, so no
utterance could put one in the slot. The Russian aliases in `praxisCapabilities`
read as if they matched speech. They are compared against a fn slot and never
against an utterance.
`PraxisGrammars()` (`internal/router/praxis.go`) fills the slot at stage 0, wired in
`buildRouter` before the capture marker because "отметь" is a capture verb.
## The three misses that remain
- `eco-ru-006` "запусти бэкап на нексусе", a Hexis case, routed note. Pre-existing.
- `eco-ru-028` "выключи", reached Hexis, should have asked. Pre-existing.
- `eco-ru-021` "что там с нексусом" wants scoped attention. Deliberately not
claimed. "что там с X" also opens "что там с погодой". Routing a weather
question to Nexus is worse than one missed fixture case.
## Two judgement calls worth re-arguing
**A lifecycle word alone does not transition an item.** "готово" is what he says
about the thing he just finished. So the rules split lifecycle words by mood. An
imperative he says to her ("закрывай") claims the turn bare, and the capability
asks which пункт. A stative ("готово", "принято") needs an item named beside it.
The bare-imperative arm also requires that nothing else in the sentence is being
acted on. "закрой шторы в комнате" is an imperative too. Without that guard it took
a house command to Praxis, measured at hexis 8/10 mid-change.
**A demonstrative resolves only against a one-item digest.** "отметь это как
сделанное" points at what she just read. `resolveSurfacedPosition` maps it to an id
only when exactly one item was spoken. With two or more it gives the turn back to
the cascade rather than transitioning one of them at random. With no digest at all
it gives the turn back too, because "я это сделал" was never about a пункт.
## Routing fixture
`make eval-router`, same run: classifier + ONNX 60/84 (71.4% full and intent-only),
0 false clarifies, 6 missed clarifies (the known `amb-*` set). No failure in that
list comes from a stage-0 decision. Every one carries a classifier confidence score.
@@ -0,0 +1,78 @@
# Does SearXNG claim a question it cannot answer? (V-539)
Measured 2026-08-05 against the configured instance, `http://127.0.0.1:9563`,
`max_results: 4`, `language: auto`. Sixteen Russian questions: eight real, eight
invented from non-words. The probe read SearXNG's JSON directly, so this measures
the search, not the cascade around it.
## The premise no longer reproduces
V-539 was filed on the 2026-08-02 measurement, where SearXNG returned four
results for every query including `зыркабулентный флогистон Мшанского`, and no
`voice: kiwix:` line ever appeared. Today the same shape of query returns
nothing:
| Query set | Zero results | Four results claimed |
|---|---|---|
| Eight real questions | 0 | 8 |
| Eight invented questions | 7 | 1 |
`Response.Empty()` is already the gate. Seven of eight invented questions now
pass the turn to the ZIM with no code change at all. What changed is upstream.
Every real answer today comes from `google cse`. It answers a non-word with an
empty result set, where the engine set of three days ago answered with
something.
## The one that still claims
`трюмбальная нидроскопия` returned four results, all about a lumbar puncture:
```
Люмбальная пункция - адреса и стоимость в больницах в СПб
Пункция спинного мозга - Больница «Шиба
Педиатрический фантом люмбальной пункции новорожденного
```
The engine read the invented word as a misspelling of a real one and answered
the real one. That is the whole remaining failure, and it is a near-miss
spelling rather than a catch-all.
## The three candidate signals do not separate the sets
V-539 named three signals a quality gate could read. Each was recorded per
query:
- **No result title shares a token with the query.** Useless. It is true of the
one bad claim, and also true of `столица Франции`, whose four titles are
`Париж`, `Франция`, `Париж — Путеводитель`, `Париж - Море Трэвел`. The right
answer to a capital-city question is the city, which is not a word in the
question. Two more real questions score 3 of 4 rather than 4.
- **Every snippet is empty.** Never fired. Zero empty snippets across all
sixteen queries, real or invented. `ParseResponse` already drops a hit with no
text, so this signal cannot fire by construction.
- **A spelling-suggestion or catch-all engine answered.** Never fired. SearXNG
returned no `corrections` and no `suggestions` for any query, including the one
that silently corrected the spelling itself.
## Decision: do not build the threshold
A gate on token overlap would cost `столица Франции` a correct answer to save
one invented word, and the other two signals cannot fire. The task said a wrong
threshold costs a real answer and needs measuring first. It was measured and it
loses.
What ships instead is the second half of V-539. The claiming query source now
crosses the IPC seam on `ipc.ChatReply.Source`. It renders as a badge beside the
reply on `/chat`. The only evidence before it was a `voice:` log line, which is
why this was hard to judge. The next occurrence is readable off the UI rather
than off the box.
## Not measured here
- The cascade. This probe read SearXNG directly. It says nothing about how
`querySearch` phrases what it gets, or whether the resident model turns four
weak snippets into a confident wrong sentence.
- Kiwix. It was healthy on 2026-08-02 and was not re-probed today.
- English questions. The premise was about Russian, where the invented words are.
- Whether the engine set is stable. The whole finding is that it moved in three
days, so this table is a reading of one day.
@@ -0,0 +1,60 @@
# Talk fixture against the resident model, 2026-08-05
Vikunja #44 step 1. `MAVEN_LLM_URL=http://127.0.0.1:8899 make eval-phrasing`,
Qwen3-1.7B-UD-Q4_K_XL on the host, no workstation in the run. The fixture holds
36 cases now, against 27 when the bakeoff measured it. So the old score is not
a column in this table.
## Result
| | before the escape fix | after |
|---|---|---|
| talk, passes every check | 2/36 (5.6%) | 25/36 (69.4%) |
| failed generations | 31 | 0 |
| by path: chat | 0/9 | 4/9 |
| by path: knowledge | 1/9 | 6/9 |
| by path: query | 0/9 | 9/9 |
| by path: reply | 1/9 | 6/9 |
| feminine | 5/36 | 36/36 |
| address | 5/36 | 33/36 |
| ontopic | 2/36 | 28/36 |
| p50 latency | 3.05s | 2.97s |
| nudges (15 cases) | 15/15 | 15/15 |
## What the 31 errors were
Not the model. `escapeRawControls` in `internal/phraser/llmphraser.go`, added
for #537 to repair a raw newline written inside a string, escaped the whole
object. Qwen3-1.7B pretty-prints: it opens `{` and writes three newlines before
the first key. Those newlines became a literal backslash-n, which is legal
nowhere outside a string, so the object stopped parsing and `parseResponseMood`
reported `errBrokenJSON`.
The comment said escaping unconditionally could not turn valid JSON into
anything else, because JSON permits no control character outside a string. It
permits three. Newline, tab and return are whitespace between tokens, and that
is what pretty-printing is made of.
Every chat reply and every knowledge answer the resident model wrote was being
discarded for a stub line. The nudge path never showed it, because the nudge
prompt gets compact JSON back.
## The 11 that still fail
Eight are `ontopic`, three are `address`.
The address failures are all plural imperatives written to a formal listener:
`держите`, `уточните`, `попробуйте`. Feminine self-reference held in all 36,
which is the half #122 is training for. So the persona gap the CPT is aimed at
is now the address half, not the gender half.
The ontopic failures are the resident model answering next to the question
rather than in it. `chat-joke` describes crying dolls instead of telling one,
`know-hiccups` calls hiccups an icon, `know-boil-egg` answers about an omelette.
`chat-about-me` answers "Я - записка", which is the same confabulation the
bakeoff recorded.
## Not measured here
The workstation. Every number above is the homesrv floor. `make eval-phrasing`
points at one URL, so a gemma-4-12b column needs its own run.
@@ -0,0 +1,58 @@
# Talk temperature sweep: Qwen3-1.7B, 4 temperatures × 3 runs
Date: 05-08-2026. Model: Qwen3-1.7B-UD-Q4_K_XL, the resident model, on homesrv.
Harness: `TestTalkTemperatureSweep` (`internal/phraser/eval/temperature_test.go`),
gated on `MAVEN_LLM_URL` + `MAVEN_TEMP_SWEEP`. Fixture: the 36-case talk set.
Wall clock: 3394s for all twelve runs. Vikunja #402.
## What was asked
Whether 0.7 is the right sampling temperature for phrasing, and whether a lower
one buys persona compliance.
## Numbers
| temp | run 1 | run 2 | run 3 | mean | errors |
|---|---|---|---|---|---|
| 0.70 | 24/36 | 24/36 | 22/36 | 23.3 (64.8%) | 7, 4, 6 |
| 0.40 | 25/36 | 25/36 | 26/36 | 25.3 (70.4%) | 3, 4, 5 |
| 0.20 | 22/36 | 23/36 | 26/36 | 23.7 (65.7%) | 6, 5, 4 |
| 0.05 | 23/36 | 25/36 | 23/36 | 23.7 (65.7%) | 5, 5, 6 |
## What it says
**The sweep does not separate the temperatures.** 0.40 leads by 5.6 points on
the mean. The spread inside a single temperature is 11 points: 0.20 ranges 22 to
26 across three runs of the same setting. Three runs cannot tell a 5.6-point
effect from that noise. Lowering the temperature to 0.05 does not help either.
That is the result that would have been most useful if it had.
**So the default stays 0.7.** `Config.Temperature` is now a config field, so
setting it is a one-line change. No measurement here justifies moving it. Anyone
re-running this needs more runs per setting, not more settings.
## The finding that is not about temperature
Sixty of the failures across twelve runs are one error:
`phraser: model output starts as JSON but does not parse`. The case fails with
an empty string, so it costs a whole case rather than one check.
They are not spread evenly. Every one lands in the `reply` family, and the
distribution is:
| case | runs failed (of 12) |
|---|---|
| reply-reminder-tomorrow | 12 |
| reply-reminder-evening | 12 |
| reply-question-bait | 11 |
| reply-formality-bait | 9 |
| reply-note-router | 8 |
| reply-fact-weight | 6 |
Two cases fail in every single run at every temperature. That is not sampling
noise, and no temperature will fix it. It is a defect in the reply phrasing
path. It caps the talk fixture at 30/36 before persona is scored at all. Filed
as Vikunja #537.
The talk score of 27/36 recorded on 2026-08-04 went through a different call
path. It is not comparable to the numbers above.
@@ -0,0 +1,82 @@
# Gemma as a label function, and what it found in the seeds
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
labels the 344 hand-written classifier seeds. Agreement with the label a person already
chose is a precision number rather than a guess.
## What ran
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
seed lines and leaves the rest.
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
retyped. Temperature 0.
## Cost
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
20k examples is under two hours of card, which matches its estimate.
## The stage 0 rules as label functions
Agreement between the grammar's label and the seed file the line came from:
| seed intent | agree |
|---|---|
| reminder | 37/37 |
| query | 9/10 |
| system | 7/8 |
| act | 2/2 |
| chat | 0/4 |
| note | 0/1 |
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
under two intents.
## Gemma against the seeds
**197/277, 71.1%.** By intent:
| seed intent | agree |
|---|---|
| note | 33/33 |
| act | 57/64 |
| fact | 37/40 |
| query | 51/54 |
| chat | 15/35 |
| system | 4/43 |
| reminder | 0/8 |
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
and the prompt holding different definitions of the same intent. Three boundaries carry 42
of them, and V-626 is the fix:
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
of 31-07-2026, which the seeds never received.
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
chat was the only honest destination for a question nothing could answer, and external
search now answers them.
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
bare verb phrase, and that is what a seed file is for.
Four intents have not been redefined since the seeds were written: note, fact, query and
act. They agree at 178 of 191.
## What this says about the plan
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
The plan already budgets a day of the owner reading the set. This says where to spend it.
It also says the two engines in the cascade are being taught different rules on 80 lines.
A routing measurement that swaps between the classifier and the router is measuring some of
that disagreement rather than the models.
+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.
+148
View File
@@ -0,0 +1,148 @@
# Plan: route with heads on e5-small, not with a generative model
**Owner's call, 05-08-2026. Vikunja #546.**
**Verdict: the routing model is the 118M multilingual-e5-small already resident on
homesrv.** It gets one classification head per output. No LoRA on a decoder, no 100M model
trained from scratch. Routing has a bounded output space, so it is classification. A model
that generates is being asked to do the wrong job.
Last verified: 05-08-2026 @ 52fd218
## The two options this rules out
**A LoRA on Qwen3-0.6B or 1.7B.** About 1 to 4 GPU hours on the workstation, for 20k
examples over three epochs. It works, and it still generates. So the output still needs a
GBNF grammar in front of it. The confidence still has to be rebuilt from structure, the way
`gateLLMDecision` does today.
**A 100M decoder from scratch.** It needs roughly 2B tokens to be a usable language model.
That is 6 × 1e8 × 2e9, about 1.2e18 FLOPs. A 16GB card
does that in 10 to 20 GPU hours at its effective throughput. It also needs a Russian tokenizer built and a corpus assembled.
What it buys is a small model generating Russian, and `CLAUDE.md` already records that as
the thing that does not work. LFM2.5-350M routes at 5.2% and answered "столица Франции?"
with the invented non-word "Сторзит".
That finding is about generation, not about size. A 118M encoder classifying Russian is a
different job with a bounded output space. The measured recall of e5-small on this box is
the evidence it reads the language well enough.
## What the model becomes
The encoder body stays as it is. Three heads sit on top of one forward pass:
| Output | Head | Reads |
|---|---|---|
| intent | `Linear(384, 7)` | the mean-pooled vector |
| mood | `Linear(384, 5)` | the mean-pooled vector |
| slots | `Linear(384, 9)` per token | `last_hidden_state` |
Seven intents are the existing enum: fact, reminder, note, query, act, chat, system. Five
moods are the existing enum: neutral, happy, thinking, tired, confused. Nine slot tags are
BIO over `key`, `value`, `when` and `fn`, plus outside.
Total head size is about 12k parameters. That number decides how this is served. See the
serving section below.
## Two things this buys that the current router cannot
**Constrained output stops being a grammar problem.** There is no free generation, so the
heads can only emit values that exist. Four mechanisms exist today because a decoder can
write anything. The GBNF grammar, the JSON parse, the fallback to plain text, the legacy
`{"body","summary"}` path. A softmax cannot write anything.
**Confidence becomes a real number.** `Confidence: 1.0` was hardcoded in `llmrouter.go`,
so the router could never ask for clarification. V-359 had to rebuild a signal out of
structure: single-token utterance, keyless fact, act with no allowlisted fn. Max softmax
over the intent head is calibratable against the fixture. `r.threshold` and the stage-3
gate would read a probability instead of a proxy. Two false clarifies survived the V-359
fix, both in the act-with-no-allowlisted-fn arm. That is the arm a calibrated score
replaces.
## Cost
About 20k examples at 64 tokens over five epochs is 6.4M tokens. So 6 × 1.2e8 × 6.4e6,
roughly 5e15 FLOPs. **10 to 30 minutes on the workstation. Under 2GB of VRAM.** It also
finishes overnight on homesrv's CPU when the card is busy. That matters, because the
workstation is never assumed up.
Freeze the embedding table. The XLM-R vocabulary is about 96M of the 118M parameters, and
it is the part that overfits 20k examples. Train the twelve layers and the heads, at 2e-5
on the body and 1e-3 on the heads, batch 32, sequence 64. Loss is cross-entropy on intent
plus mood plus per-token tags, with the tag term down-weighted.
## The trap: fine-tune a copy
The resident embedder backs memory recall. `docs/evals/2026-08-04-recall-e5-small.md`
measured ten points of recall@1 above MiniLM, at 2.5× the speed. `CLAUDE.md` says it stays
on homesrv permanently, because it backs the floor.
Training it in place couples routing accuracy to recall. Say a run gains four points of
intent accuracy and quietly loses six of recall@1. It would look like a win, and nothing
in the test suite would name the trade. So the routing weights are a second file. About
45MB extra, quantized. `modelIDFromPath` already derives the DB marker from the filename.
So two files means two ids, and no ambiguity about which model wrote an embedding.
## Serving: the heads do not need a runtime
`internal/router/onnxembedder.go` already asks the ONNX session for `last_hidden_state` at
`[1, 128, 384]` and mean-pools in Go. Both tensors the heads need already cross into Go on
every call.
At 12k parameters the heads are three dot products. They can be plain Go over a weights
file rather than a second ONNX graph. Then the export step covers the encoder only, and
the head weights are data. That keeps the whole thing inside the existing session, the
existing vendored tokenizer and the existing `TestONNX*` tests.
Wire it where the router sits. `pickLLMRouter` becomes a three-way choice. The classifier
stays underneath as the floor. The rule that keeps it there now still holds: a turn must
never break on a model. The resident model keeps chat, world answers and phrasing. None of that
is classification, and this cannot do it.
## The labeled set is the whole project
There are 77 RU routing cases and 30 Praxis cases today. That is a test set, not a
training set.
The stage 0 grammars are high-precision label functions. `AgendaQueryGrammars`,
`NarrativeQueryGrammar` and `PraxisGrammars` each decide a shape deterministically, so
running them over the turn history self-labels it. Training on their output distils the
rules into the model. That is the point rather than a compromise: the model generalizes
past a regex, where Go's `\b` never fires after a Cyrillic letter.
Two rules for the data:
- **The fixtures stay out of training.** Otherwise the measurement reads the rules and
reports them as the model.
- **Keep cases the grammars do not cover.** A set labeled only by the rules teaches only
the rules. The hard cases carry no interrogative and no question mark, which is why
V-498 existed.
Expanding to 20k runs through gemma-4-12b on the workstation. `docs/evals/2026-08-02-workstation-gemma4-12b.md`
measured 329ms per call. So that is 2 to 4 hours of the card, plus a day of the owner
reading it. That is the real cost of this plan.
## How it gets judged
The 77-case RU routing fixture, against the two numbers that always answer:
| Path | Full | Intent-only | p50 |
|---|---|---|---|
| classifier | 68.8% | — | 16.6µs |
| resident Qwen3-1.7B, through the cascade | 72.7% | 77.9% | 0.80 to 1.04s |
| heads on e5-small | to measure | to measure | to measure |
Latency should land near the classifier rather than near the router. This is one encoder
pass and three dot products, against the classifier's one encoder pass and a
nearest-neighbour scan. If it does, V-464 answers itself, and the latency trade the LLM
router asks for stops being a trade.
## Not decided here
- Whether mood belongs on this model at all. Mood is a phrasing property, and the router
emitting it is a convenience. A fifth head is cheap, so this is a question about where
the value is read, not about cost.
- Whether the slot head replaces the stage 0 grammars or sits behind them. Cascade order
is a separate measurement, and the rules are currently faster and exact.
- The confidence calibration method. Temperature scaling on a held-out split is the
obvious first try, and it has not been measured.
+200 -22
View File
@@ -1,17 +1,21 @@
# 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 @ 8d816f4. 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.
Reconciled 2026-08-04 against the board, after the review stack merged.
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
build work. Most sat unverifiable while Maven was down for 11 days. That
blocker is gone.
The board holds 95 open tasks and 35 of them are `QA:` tasks. The ratio moved
because the build backlog grew, not because verification shrank. QA is
verification work, not build work, and most of it sat unverifiable while Maven
was down for 11 days. That blocker is gone.
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
missing and two of the named ones had closed. Every open task now appears below,
the eight non-QA ones in the last two sections.
Every open `QA:` task appears below. Distrust the count in this header first. It
is right on the day it is written and wrong a week later.
Fourteen ids this plan used to name closed on 2026-08-04 and are gone from it. If
you cannot find one, check whether it closed before assuming the plan dropped it.
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
downstream assumes the voice loop works, and nobody has confirmed that since
@@ -84,8 +88,71 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
person at the box, because they need a microphone or a nudge to arrive.
Steps 1 and 3-6 were run on 02-08-2026 and pass.
**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:
```sh
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
-H 'Content-Type: application/octet-stream' -m 180
```
That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
browser microphone capture needing a person, and the wake path needing a machine.
Do not post `en_act.wav` without deciding first: it is a mutating act.
**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
now arrive, and the first landed at 18:43.
What blocks the two steps now is that no rule wants to fire. `/trace` shows all
five at `predicate`, none inert:
| rule | sev | why it is false |
|---|---|---|
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
| service_down | 4 | no kuma monitor is down |
| netdata_critical | 3 | nothing critical |
So the honest way to run step 8 is to wait three hours after the last `water`
fact, or to write one antedated. Do not read the water rule's silence as a defect.
**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
for paperless, which is genuinely down, put a real `service_down` through the
whole path with presence away:
```
23:03 voicesink: no live voice session for service_down, falling through to away channels
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
04.08 23:03 | nudge | service_down | telegram | sent | 23:03
```
`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
same rule reaching `acted` earlier, so the ack path works too.
The body was `Сервис перестал отвечать.`, which names no service. That is a bug
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
nine are rejected as unfillable. The one nameless variant is left as the only
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
and get it right. The template path is the one that runs.
**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
caller outside tests, so the singleton row is never written. The gate is fine,
because it reads the bucket `GatherState` computes in memory each tick. Two
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
and the 0.30-0.55 hold band never applies. And every presence readout lies:
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
every 60s. Do not trust that number while checking anything else here.
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
@@ -105,9 +172,36 @@ 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.
**Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
at `intent=reminder`.
**Passes in the browser too** (04-08-2026), and it needed no person. Headless
Chrome takes a fake microphone, so the whole browser half runs unattended:
```sh
chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
--use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
```
Then drive it over the debug protocol: click `#btn`, wait, click again, read
`#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
decode and the hand-written resample to 16k Int16. It logged
`sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.
**The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
(`main.go:332`). `/dash` is the presence and fact dashboard and carries no
`#btn`. This step said `/dash` until 04-08-2026.
One defect fell out, filed as **533**. The reply logged as
`на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
which writes a space as `+`, then decoded with `decodeURIComponent`, which
leaves `+` alone. Transcript only, the audio is fine.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
@@ -128,12 +222,42 @@ turns look misaligned when they are not.
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
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 slowness now has a cause and a number** (04-08-2026). A spoken turn
takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
which is the phrasing cap. Both were truncated, to produce a reply of
under 25 characters.
The cause is `responseGrammar`, not the model. Its last rule is
`ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
second server: at `repeat_penalty` 1.0 it runs to 512 and returns
`finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
`[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
no penalty at all.
Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
the replier sets it to 1.3, so that path is protected by accident. `chatReq`
in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
`PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
**531**.
Two guesses were wrong on the way and are recorded so nobody repeats them.
It is not reasoning tokens: the probe returned `reasoning_content` of length
0, and the grammar constrains output from the first token. It is not the
`--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
was hit is a token count.
The wandering is a second thing and stays on 287.
**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 `поужинал`
@@ -245,10 +369,12 @@ check that the failure floor catches a mid-session model death.
These need real use rather than a command, grouped by what one sitting covers.
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
**Morning and delivery** (**280**, **281**, **128**, **283**, **285**):
open `/morning`, walk the seven required behaviours, then check the four
interruption outcomes and the digest gap. **282** needs the `desk_active` script
enabled on the desk PC first, which is **15** and needs you at that machine.
interruption outcomes and the digest gap. The presence half of this sitting
cannot run. `desk-active.sh` is on workpc, but no systemd user unit enables it,
so no `desk_active` fact has ever been written. That is **15** and needs you at
that machine.
**283** is the event intake envelope every reach shares, so a delivery check
exercises it whether you name it or not. **285** is not verification: the bridge
framework works and the remaining ask is more adapters. Decide which reach comes
@@ -374,6 +500,36 @@ With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexi
keep rendering. On `docker start` the card returns to `nothing needs attention.`
with no mavend restart. Independent degradation and recovery both hold.
**Workstation offload** (**492**): never run, and added to this plan on
2026-08-04. It covers **485**, which shipped in PR #97. Three states, one rule:
silent when the workstation would only do the job better, named when the resident
model cannot do the job at all.
1. **Card free.** mavgpud 200, llama-server holding gemma-4-12b. A routing turn
and a phrased reply both complete through the workstation. Confirm that from
the mavgpud request log, not from the answer sounding good. Nothing in the
answer says where it was phrased.
2. **Card held.** Start a training run so mavgpud yields and answers 503. The same
turns complete on Qwen3-1.7B with no mention of the fallback. Then kill the
card mid-utterance, with a request in flight. That is the case no unit test
reaches and the one most likely to hang.
3. **Machine asleep.** Suspend workpc. It must be indistinguishable from held.
Bring it back and confirm the prober re-admits it inside one 15s interval, with
no mavend restart.
Two things are likely wrong. A remote that accepts the connection and then never
answers is worse than a 503. `timeout` is 90s, so measure what a turn waits. And
two models mean two prompt renderings: `check_prompt_parity.py` guards Go against
the relabelling prompt, not gemma against Qwen, so confirm `{"response","mood"}`
parses from both.
**workpc is running training as of 2026-08-04**, so the held state is available
today and the free state is not. Run step 2 first, out of order.
Write down one number at the end. Read the mavgpud journal and record the
fraction of a working week the card is free. That is what **488** left open, and
it decides whether the offload is worth carrying.
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
neither lever can be pulled on this box. See **477**.
@@ -471,6 +627,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.
@@ -523,7 +698,7 @@ Not QA. These are blocked on a decision or a credential only you have.
| # | what |
|---|---|
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
| 15 | Deploy `desk_active` on the desk PC. Blocks **282**. |
| 15 | Enable the `desk_active` units on workpc. The script is there; the timer is `not-found`, so the strongest presence signal writes nothing. Blocks the presence half of session 3. |
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
@@ -557,8 +732,11 @@ where they land, so the board stops reading as 50 things Maven owes.
31ms. The router buys about 4 points of accuracy for four orders of magnitude
of latency. Whether that still earns its place is now an open question.
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
5. Session 3, split whichever way suits you. All five sittings ran on
5. Session 3, split whichever way suits you. Five of its six sittings ran on
02-08-2026. Read the per-sitting notes before repeating any of them.
6. The workstation offload sitting (**492**), which has never run. It is last
because it is newest, not because it matters least. It is the one sitting whose
subject changes state on its own.
The next thing to fix is not in this plan. Four defects say the same sentence:
a capability is built and no utterance reaches it. **466** (a clarify is global),
+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
+4 -4
View File
@@ -348,8 +348,8 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
if err != nil {
t.Fatalf("Chat: %v", err)
}
if reply != "echo: привет" {
t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет")
if reply.Reply != "echo: привет" {
t.Fatalf("Chat reply = %q; want %q", reply.Reply, "echo: привет")
}
if fake.chats != 1 {
t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats)
@@ -373,9 +373,9 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
return int64(r.writes), nil
}
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
func (r *recordingAPI) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
r.chats++
return "echo: " + text, nil
return ipc.ChatReply{Reply: "echo: " + text}, nil
}
// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
+13
View File
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
// can do is make Maven stop recognising someone, which is the state the
// box ships in anyway.
return AuthWrite
case ipc.MethodSeedEvent:
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
// the same rung as mutating the tool allowlist, and for a reason that is
// not about privilege: every other write records when something actually
// happened, and this one asserts it. A caller who can place a fact in the
// past can manufacture a routine Maven will then act on forever, which is
// the tick loop obeying evidence nobody produced.
//
// Step-up is not the real gate and is not meant to be. mavend refuses the
// method entirely unless started with -allow-seed, so the ordinary state
// of the box is that no gesture reaches it. This rung is what stops a
// module from calling it on a box where QA left the flag on.
return AuthStepUp
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodIngestMail:
+23 -3
View File
@@ -66,11 +66,30 @@ const ambientPastGrace = 2 * time.Hour
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
// honest reading of a bare wall clock.
//
// See EventFromNotificationIn, which takes the zone explicitly.
func EventFromNotification(n Notification) (Event, bool) {
return EventFromNotificationIn(n, time.Local)
}
// EventFromNotificationIn is EventFromNotification against an explicit zone.
//
// "созвон в 14:30" carries no zone, and nobody writing a phone notification
// means 14:30Z. The wall clock used to be resolved against Posted's own zone,
// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting
// on this box landed four hours late. The size of the error is the deploy's UTC
// offset, which is why it is invisible on a UTC box and wrong everywhere else.
//
// Posted stays an instant in its own zone: it says when the phone showed the
// notification, and the past-grace check compares instants. Only the day and
// the wall clock are read in loc.
func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
if loc == nil {
loc = time.Local
}
if n.Posted.IsZero() {
return Event{}, false
}
n.Posted = n.Posted.In(time.Local)
line := strings.TrimSpace(n.Title + " " + n.Text)
start, end, ok := parseTimeRange(line)
if !ok {
@@ -81,8 +100,9 @@ func EventFromNotification(n Notification) (Event, bool) {
return Event{}, false
}
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
loc := n.Posted.Location()
// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
// "завтра в 09:00" is already tomorrow where he is standing.
y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date()
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
// Too far in the past to be the meeting this notification is about. The day
// was inferred, so the honest reading is that the inference was wrong.
+63 -14
View File
@@ -84,12 +84,12 @@ func TestEventFromNotification(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Package: "com.google.android.gm",
Title: tt.title,
Text: tt.text,
Posted: posted,
})
}, posted.Location())
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
@@ -108,10 +108,10 @@ func TestEventFromNotification(t *testing.T) {
if !ev.End.After(ev.Start) {
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
}
// The event lands on the day the phone showed it, in the daemon's
// zone — the clock reading is a wall clock, not an instant.
if ev.Start.Location() != time.Local {
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
// The event lands on the day the phone showed it, in the zone it
// was resolved against — never shifted into UTC.
if ev.Start.Location() != posted.Location() {
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
}
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
@@ -171,10 +171,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Package: "com.google.android.calendar",
Title: tt.title, Text: tt.text, Posted: tt.posted,
})
}, tt.posted.Location())
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
@@ -198,10 +198,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
// that into the key.
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Title: "Завтра Планёрка 09:00",
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local),
})
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
}, time.UTC)
if !ok {
t.Fatal("expected an event")
}
@@ -243,10 +243,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
// An ambient event must never be indistinguishable from a calendar read.
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
ev, ok := EventFromNotification(Notification{
ev, ok := EventFromNotificationIn(Notification{
Title: "Планёрка 10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local),
})
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
}, time.UTC)
if !ok {
t.Fatal("expected an event")
}
@@ -271,3 +271,52 @@ func TestStripClock(t *testing.T) {
}
}
}
// The defect this file's zone handling exists for: a phone posts an RFC 3339
// instant ending in Z, and "созвон в 14:30" used to be resolved against that
// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a
// notification is local by construction.
func TestNotificationClockIsLocalNotUTC(t *testing.T) {
samara := time.FixedZone("+04", 4*3600)
n := Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
}
ev, ok := EventFromNotificationIn(n, samara)
if !ok {
t.Fatal("expected an event")
}
if got := ev.Start.Format("15:04"); got != "14:30" {
t.Errorf("start = %s local, want 14:30", got)
}
if got := ev.Start.UTC().Format("15:04"); got != "10:30" {
t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got)
}
if ev.Start.Location() != samara {
t.Errorf("location = %v, want %v", ev.Start.Location(), samara)
}
// The stored key is the local day, so it files under the day he lived.
if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want {
t.Errorf("FactKey = %q, want %q", got, want)
}
}
// A notification posted late in the UTC evening is already the next day where
// he is standing. The day must come from the local clock, not from Posted's.
func TestNotificationDayIsTheLocalDay(t *testing.T) {
samara := time.FixedZone("+04", 4*3600)
n := Notification{
Title: "Планёрка 09:00",
Text: "завтра",
Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local
}
ev, ok := EventFromNotificationIn(n, samara)
if !ok {
t.Fatal("expected an event")
}
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 {
t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d)
}
}
+64
View File
@@ -16,6 +16,7 @@ package calendar
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode"
@@ -125,6 +126,69 @@ func FactSummary(value string) string {
return value[:i]
}
// EventKeyPrefix — every calendar event fact starts with this. The loop scans
// the family to work out whether a meeting covers right now (Vikunja #513).
const EventKeyPrefix = "calendar_event_"
// FactSpan reads an event fact back into the instants it covers, against loc.
// The day comes from the key and the two clock readings from the value's
// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote.
//
// ok is false for anything that does not parse. A fact whose span cannot be
// read tells you nothing about now, and guessing a span is how a signal that
// was meant to suppress one nudge starts suppressing all of them.
//
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
// meeting covers the quarter hour it actually covers.
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
if !strings.HasPrefix(key, EventKeyPrefix) {
return time.Time{}, time.Time{}, false
}
rest := key[len(EventKeyPrefix):]
if len(rest) < 8 {
return time.Time{}, time.Time{}, false
}
day, err := time.ParseInLocation("20060102", rest[:8], loc)
if err != nil {
return time.Time{}, time.Time{}, false
}
// SetValue stores a string fact JSON-encoded, so the value comes back
// quoted. Reading the tail off the quote is how this returned false for
// every real event the first time it ran.
if unq, err := strconv.Unquote(value); err == nil {
value = unq
}
i := strings.LastIndex(value, " @ ")
if i < 0 {
return time.Time{}, time.Time{}, false
}
tail := value[i+len(" @ "):]
from, to, found := strings.Cut(tail, "-")
if !found {
return time.Time{}, time.Time{}, false
}
sh, sm, ok1 := parseHM(strings.TrimSpace(from))
eh, em, ok2 := parseHM(strings.TrimSpace(to))
if !ok1 || !ok2 {
return time.Time{}, time.Time{}, false
}
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
if !end.After(start) {
end = end.Add(24 * time.Hour)
}
return start, end, true
}
// parseHM reads "15:04" and nothing else.
func parseHM(s string) (h, m int, ok bool) {
t, err := time.Parse("15:04", s)
if err != nil {
return 0, 0, false
}
return t.Hour(), t.Minute(), true
}
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
// range-scans between two of these.
func KeyPrefixForDay(day time.Time) string {
+11
View File
@@ -1085,6 +1085,17 @@ type KiwixConfig struct {
// query at a time.
Book string `json:"book,omitempty"`
// BookRU — the ZIM to search when the question is in Russian, by the same
// catalog name. Empty ⇒ every question goes to Book.
//
// It exists because the rewriter is a workaround, not a feature (V-508). An
// English ZIM cannot match a Russian sentence, so the resident model turns
// the question into English keywords first, and that costs a model call and
// loses whatever the keywords drop. A Russian ZIM matches the question as he
// asked it. So a Cyrillic question searches this book verbatim and skips the
// rewrite, and the English book keeps answering English ones.
BookRU string `json:"book_ru,omitempty"`
// MaxResults — how many hits are asked for. 0 ⇒ DefaultKiwixResults.
// Only the top few reach the phraser regardless; the rest are context the
// snippet ranking throws away.
+56 -130
View File
@@ -184,6 +184,48 @@ type CaptureTaskResp struct {
Promoted bool `json:"promoted,omitempty"`
}
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
// pattern path over it, so a recurring routine can be produced on demand
// instead of over real days (Vikunja #518).
//
// This is the ONLY backdating write path in the tree, and it exists for one
// reason: the detector needs four events spread over hours before it proposes
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
// running daemon at all. A store fixture would have exercised the detector
// without the wiring those tasks doubt.
//
// Two things hold it shut. It is AuthStepUp in the authority table, the same
// rung as mutating the tool allowlist. And mavend refuses it outright unless
// started with -allow-seed, so a box nobody is testing carries no live
// backdating path even for a caller who cleared the gate.
//
// Key and Value are a fact, not an event: extraction runs for real, so a key
// the extractor ignores seeds nothing and says so. That is deliberate — a
// seam that accepted action and object directly would let QA prove a detector
// against events no utterance could ever produce.
type SeedEventReq struct {
Key string `json:"key"`
Value string `json:"value"`
Ts time.Time `json:"ts"`
}
// SeedEventResp — what the seed produced. Extracted is false when the fact was
// written but yielded no event, which is the extractor declining rather than a
// failure. Proposed is true only when this seed completed a pattern; the first
// three seeds of a run return false with no routine.
type SeedEventResp struct {
FactID int64 `json:"fact_id"`
EventID int64 `json:"event_id,omitempty"`
Extracted bool `json:"extracted"`
Action string `json:"action,omitempty"`
Object string `json:"object,omitempty"`
Proposed bool `json:"proposed"`
RoutineID int64 `json:"routine_id,omitempty"`
// IntervalDays — the median the detector settled on, echoed so QA can
// check it against the spacing it asked for.
IntervalDays float64 `json:"interval_days,omitempty"`
}
// IngestMailReq — one message a mail reader has fetched, handed to core for
// extraction (Vikunja #246).
//
@@ -622,7 +664,20 @@ type chatReq struct {
Conversation string `json:"conversation,omitempty"`
}
type chatResp struct {
Reply string `json:"reply"`
Reply string `json:"reply"`
Source string `json:"source,omitempty"`
}
// ChatReply — one text turn's answer plus which query source claimed it.
//
// Source is diagnostic and is empty unless the turn was a question a source
// claimed: a fact write, an act or a chat turn names none. It exists because
// the claiming source was readable only in the daemon log, so a QA step could
// not tell a wrong answer from a wrongly ordered chain (V-539). It is not
// authorization and nothing routes on it.
type ChatReply struct {
Reply string
Source string
}
type proposeToolReq struct {
@@ -678,135 +733,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.
+11 -3
View File
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
return r.Routines, nil
}
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
var r SeedEventResp
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
return SeedEventResp{}, err
}
return r, nil
}
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
var r CaptureTaskResp
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
@@ -632,12 +640,12 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
}
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
var r chatResp
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
return "", err
return ChatReply{}, err
}
return r.Reply, nil
return ChatReply{Reply: r.Reply, Source: r.Source}, nil
}
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
+201
View File
@@ -0,0 +1,201 @@
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
// SeedEvent writes a backdated fact and runs extraction and detection over
// it, so a proposal can be produced in one sitting rather than over real
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
// it, because the detect-and-propose step lives in mavend.
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, 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) (ChatReply, 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")
}
}
+14 -5
View File
@@ -405,8 +405,17 @@ func TestChatViaClient(t *testing.T) {
if err != nil {
t.Fatalf("Chat: %v", err)
}
if reply != "и тебе привет!" {
t.Fatalf("Chat = %q, want %q", reply, "и тебе привет!")
if reply.Reply != "и тебе привет!" {
t.Fatalf("Chat = %q, want %q", reply.Reply, "и тебе привет!")
}
// The claiming query source crosses the wire beside the reply (V-539).
sourced, err := cli.Chat(context.Background(), "web", "почему небо голубое")
if err != nil {
t.Fatalf("Chat: %v", err)
}
if sourced.Source != "kiwix" {
t.Fatalf("Chat source = %q, want kiwix", sourced.Source)
}
}
@@ -417,11 +426,11 @@ type chatTestAPI struct {
UnimplementedCoreAPI
}
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (ChatReply, error) {
if text == "привет" {
return "и тебе привет!", nil
return ChatReply{Reply: "и тебе привет!"}, nil
}
return "поговорили.", nil
return ChatReply{Reply: "поговорили.", Source: "kiwix"}, nil
}
// TestDispatch_UnknownMethod — an unknown method over the wire comes back as
+154
View File
@@ -0,0 +1,154 @@
package ipc
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"strings"
"testing"
"github.com/kami/maven/internal/store"
)
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
// without importing internal/store. The design is right and the failure mode is
// quiet: add a sentinel to store, forget the switch, and the client gets an
// untyped error that no caller can branch on. These two tests are the alarm.
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
var mapErrPairs = []struct {
name string // the store identifier, for the coverage test below
from error
wants error
}{
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
}
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
// each with the reason it stays store-side. A new sentinel is in neither list
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
// module can branch on an error is a decision, not a default.
var unmappedStoreErrors = map[string]string{
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
// The shopping and packing lists follow the task verdicts exactly, and for
// the same reasons. No module branches on any of the three today.
"ErrListItemNotFound": "the surface re-lists rather than branch on an item id that vanished",
"ErrListItemEmpty": "input validation — an empty item is refused before it reaches the store",
"ErrListItemStatus": "an unknown status is a caller bug, not a state a module recovers from",
}
// The mapping itself, through a wrap, because every real caller wraps.
func TestMapErrMapsEveryPair(t *testing.T) {
for _, p := range mapErrPairs {
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
if got != p.wants {
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
}
}
}
// Anything mapErr does not recognise passes through untouched. A module that
// cannot branch on an error must still see the original text.
func TestMapErrPassesUnknownThrough(t *testing.T) {
if mapErr(nil) != nil {
t.Error("mapErr(nil) must stay nil")
}
own := fmt.Errorf("socket closed")
if got := mapErr(own); got != own {
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
}
}
// The parity half: every exported sentinel in internal/store is either mapped
// or listed with a reason. Read off the source, so a sentinel added in a file
// this package never touches still trips it.
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
mapped := map[string]bool{}
for _, p := range mapErrPairs {
mapped[p.name] = true
}
for _, name := range storeSentinelNames(t) {
if mapped[name] || unmappedStoreErrors[name] != "" {
continue
}
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
}
}
// storeSentinelNames reads internal/store for exported package-level error
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
func storeSentinelNames(t *testing.T) []string {
t.Helper()
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0)
if err != nil {
t.Fatalf("parse internal/store: %v", err)
}
var out []string
for _, pkg := range pkgs {
for _, f := range pkg.Files {
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, n := range vs.Names {
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
continue
}
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
out = append(out, n.Name)
}
}
}
}
}
}
if len(out) < len(mapErrPairs) {
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
}
return out
}
func isErrorsNew(e ast.Expr) bool {
call, ok := e.(*ast.CallExpr)
if !ok {
return false
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "New" {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && id.Name == "errors"
}

Some files were not shown because too many files have changed in this diff Show More