Compare commits

..

75 Commits

Author SHA1 Message Date
kami 0db31d21b9 hexis: re-vendor the client so a configured token is actually sent
The vendored copy of github.com/kami/hexis predated Client.WithToken:
no token field, no setter, no header hook, and an unexported httpClient,
so there was no way to attach auth from outside the package. wireEcosystem
handled that by refusing to wire Hexis at all when a token was configured,
which was the honest reading of the code but left the deployment silently
without its executing service.

go.mod already replaces the module with /home/kami/apps/hexis, and that
source has had WithToken and the Bearer header for a while. Only the
checked-in vendor/ copy was stale. Refreshed it (client.go plus the new
capability.go) and wired Hexis like Nexus and Praxis.

Two tests cover the outcome the refusal was standing in for: a configured
token reaches the wire as Authorization, and no token still wires unauthed,
because Hexis without auth is a valid deployment on a trusted box.

Also corrected the discoverCapabilities comment. It claimed the client
stamped the correlation header on Execute only; do() stamps it on every
request, and did before the re-vendor too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 16:29:56 +04:00
kami df3220d039 auth: put mail ingestion on the same rung as resolving a task
IngestMail was AuthRead and SetTaskStatus was AuthWrite, and they answer
the same question: may this module change what is on his lists? The old
argument for AuthRead — ingestion is additive, it can only produce
candidate tasks — is still true and is the weaker half, because a
compromised mail reader that can fill the review page indefinitely is
not a read.

Nothing loses access. AuthWrite outside WriteFact only requires
enrollment, which mavmaild already has, and the method does not exist
unless the operator wired a mail block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 16:29:45 +04:00
kami 76a251a20d Merge branch 'fix/g08' into fix/integrated
# Conflicts:
#	internal/store/migrations.go
2026-08-01 14:38:39 +04:00
kami 724e90759e llm: keep the priority gate and the swap drain apart
Two fix branches independently added a Gate to internal/llm. One is priority
between a voice turn and a background job, the other is admission control while
the resident model is swapped. They are orthogonal and both are needed, so the
swap one is now SwapGate, with SetSwapGate to install it.

Complete takes the priority gate first and the drain second. A background
request can wait a long time on priority, and counting it as in flight against
the drain that whole time would stall a swap on a request that has not started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:38:11 +04:00
kami 2bf11f052d Merge branch 'fix/g07' into fix/integrated
# Conflicts:
#	internal/ipc/api.go
#	internal/ipc/client.go
#	internal/llm/client.go
2026-08-01 14:36:48 +04:00
kami 2ca5ffa4f9 capture: answer the stop before summarising, and always leave a note
capture_stop held the IPC request open for the whole map reduce, up to
twenty minutes. A voice turn that says "хватит" waited for forty model
calls before Maven said anything. Stop now returns the transcript and the
summary runs on a goroutine in the daemon's WaitGroup, on the daemon
context so a client that hung up does not cancel the only readable record
of the meeting.

With no summary and save_transcript false, writeNotes wrote nothing at
all: an hour of meeting left a blob that prunes in seven days and no
trace in the note store. The transcript is written instead when the
summary is missing. That flag decides whether the verbatim record is kept
in addition to a summary, not whether the meeting is remembered.

The wire carries the session token now, and the contract comments say
what the code does: the summary is usually absent from the stop
response, and re running a stored blob is a manual job because no method
takes a blob id. The save_transcript comment says the cost is recall
corpus rather than disk.

Found in review of #73.
2026-08-01 14:36:17 +04:00
kami 77888c1a9c capture: spool the meeting to disk, own it by token, reap it by the clock
Four invariants the comments claimed and the code did not hold.

The recording lived in mavend's heap as one growing []byte, doubled at
Stop when the WAV was built. Frames now go to a spool file and the
transcript is read back off disk one window at a time, so memory is flat
whatever the length.

A store failure returned before transcription ran, so a meeting over the
blob cap produced no transcript, no summary and no note. It now records
the failure and keeps going, and the spool file survives until the words
have been read off it.

The session had no owner. Any module on the write rung could call stop on
a recording it did not start and receive the verbatim words of everyone
in the room. Start hands back a token and append, stop and abort require
it.

The duration cap was only checked when a frame arrived, so a phone whose
tab was closed left the slot occupied and every later start answered
ErrBusy with a meeting from last week. The wall clock is checked in
start, status, append and stop.

Smaller things in the same pass. Append compares the frame format against
the session format, so a client that switches sample rate mid meeting no
longer has its frames concatenated under a header that lies. One failed
STT window leaves a marker instead of discarding the other twenty four.
Summarize is separate from Stop and assigns the salvaged per chunk text
before it reports the error.

Found in review of #73.
2026-08-01 14:36:17 +04:00
kami 71b42e31bd media: move a file into the store instead of reading it in
Put takes a []byte, so storing a recording meant the whole recording in
memory. A two hour meeting at 16 kHz mono is about 230 MB of WAV, and
building it from PCM held a second copy of the same size in the process
that also owns the database and the resident model. PutFile stats the
file, hashes it in a stream and renames it into place, so the peak is one
buffer regardless of length. SpoolFile hands out the scratch file it
moves from, under the media dir so it shares the same disk and the same
permissions.

Audio also gets its own per blob cap of 512 MiB. The image cap of 64 MiB
is 35 minutes of audio, which contradicted the two hour session cap: the
long meeting was exactly the one that failed to store.

audio.WAVHeader is split out of WAVFromPCM because a spooled capture
writes a placeholder header first and stamps the real length at the end.

Found in review of #73.
2026-08-01 14:36:17 +04:00
kami cb04799b09 ipc: gofmt the client 2026-08-01 14:33:39 +04:00
kami 2a3701d012 update: fix the startup fixture the rollback validation now refuses
The docker-shaped update block in TestUpdateBlockValidatedAtStartup has
source_dir equal to install_dir and no source_rollback, which is exactly the
deployment the new validation refuses. The fixture is meant to be the good
case, so it now says how the source is rolled back.

Found in review of #69.
2026-08-01 14:33:26 +04:00
kami 327726a06a crawl: stop letting a watch widen on-demand reading, and honour Crawl-delay
The on-demand crawler was built over allow_hosts plus every watched host.
webfetch reads a non-empty allow list as these and nothing else, so a config
with one watch and no allow_hosts at all silently narrowed on-demand reading
to the watched site. Every other url he pasted came back as a flat refusal
with nothing in the log to explain it. The two crawlers now take two host
lists from one crawlHosts helper.

Crawl-delay was parsed into Rules and never read. The only pacing was the
fetcher's flat one request per host per second, which cannot express what a
site asked for, and deploy/README claimed the field was honoured. Page now
waits it out between the robots fetch and the page fetch, and a delay longer
than the turn fails the read instead of hanging it.

A robots.txt that failed was treated as no rules, so a site whose server was
having a bad minute became a site with no restrictions. A 5xx now refuses the
crawl. A 404 still means unrestricted, which is what the standard says.

The refusal check matched substrings of webfetch's message text from a package
that cannot import webfetch, so a reworded error would have silently turned
into a robots verdict. internal/crawl now exports ErrFetchRefused and
ErrFetchStatus and the adapter in cmd/mavend maps the webfetch sentinels onto
them. Robots group selection picks the longest matching agent prefix instead
of the first one in file order.

queryWeb passed a claim it could not serve when no crawler was configured, so
an unconfigured deployment answered a web question with an apology instead of
falling through to the model.

Found in review of #67.
2026-08-01 14:33:26 +04:00
kami 57161fb762 store: keep what she read out of what he said
Nothing at read time told a feed item or a crawled page apart from his own
notes. QueryNotes ranked every note by cosine and the notes answer handed the
nearest five to the phraser, so "что я говорил про переезд" could be answered
out of a stranger's web page, prefixed with "вот что я нашла: ". Recall now
excludes the read sources, rss: and crawl:, and the list is one place.

The feed answer needed a different read as a result, and it needed one anyway:
it scanned the last 200 notes of any source, so a busy day of voice notes pushed
the newest headline out of the window and she said "в лентах пока ничего
нового" while the poller was working fine. RecentNotesFromSource asks for feed
notes by source, so the window holds 200 of them.

Found in review of #66 and #67.
2026-08-01 14:24:40 +04:00
kami 8846b7e43c Merge branch 'fix/g10' into fix/integrated 2026-08-01 14:24:05 +04:00
kami b436be69c3 Merge branch 'fix/g09' into fix/integrated 2026-08-01 14:22:24 +04:00
kami d0e98a9419 simulator: make the negative assertions mean something, and give the ecosystem a scenario
expect_not_called could pass on a step that made the forbidden call. callPaths
concatenates per server and callCount was a total, so slicing the concatenated
list by the total examined the wrong window. With praxis on three requests and
nexus on one, a fourth praxis call landed at index three and paths[4:] never
saw it, while the stale nexus call was reported as new. The mark is now
per server and the paths are taken per server from it.

Neither scenario ever produced an act, so all three fakes saw zero requests and
the fault lever changed no outcome. The two headline capabilities of the
harness had no coverage. act_degraded scripts an act against an enabled
allowlist row and runs it healthy, at 503 and healthy again, asserting the
reply, the call, the absence of a call on a tick, and that nothing was pushed
at him either way. That needed two seams the world did not have: allowlist rows
from the scenario, and a matcher on the real store rather than a nil API, which
would have panicked the moment any scenario produced an act.

expect_no_events compared bus.Len(), which stops growing at the ring capacity,
so a scenario long enough to fill the ring made every later expect_no_events
pass unconditionally. It counts publishes through a subscriber now.

A scenario could not express a fact below full confidence, because write
hardcoded 1.0, and morning_missed annotated its ambient step as if it could.
factPriority branches on exactly that, so no replay could reach the low branch.
signalStep takes a confidence, the ambient step sets the 0.6 the ambient path
writes, and the event line carries the priority so a scenario can assert it.

TestSimulatorIsDeterministic compared the transcript against time.Now, which
fails for the half hour a day the scenario itself covers. It checks that every
stamped line falls inside the scenario span instead. TestSimulatorRefusesBackwardsSteps
tested the forwards case, because reaching the backwards branch ended the test.
A fatalf seam makes the refusal observable.

Smaller notes: the step doc comment now states which assertions are run scoped
and which are step scoped, audioText parses the golden manifest once per world
rather than once per step, and the feminine checks list the masculine form with
its following character, since the earlier check on a comma alone passed on
"записал что ты выпил воды".

Found in review of #79.
2026-08-01 14:22:14 +04:00
kami 9a9f4464d5 ipc: gofmt unlock_test.go
The explicit-wrap assertions went in unformatted and make test's
fmt-check step failed on them.

Found in review of #77.
2026-08-01 14:21:55 +04:00
kami 543aefde4b vision: scope the note, settle the contract, wait for the prune
Saving a description writes recall corpus. writeNote embeds it under
media:image:<id>, a source no enrollment owns, and the method sits at
AuthRead, so any enrolled module could put a small VLM's guess into what
Maven knows and have it come back in a later turn as something she
believes. The describing half stays a read; save_note is now held to the
same source-scope rule WriteFact is, and the stored text carries a
marker saying it came off a picture.

Three doc comments said the method exists only when vision is enabled
and the code says otherwise. The code is right, and storing without
describing is the state this box is in, so the comments were corrected
rather than the behaviour. A request carrying both data and id used to
take the id branch and drop the bytes without a word; it is refused.

A media dir that cannot be created and a vision endpoint that is a typo
were logged at wiring time and the capability just stayed off, which is
the hardest kind of misconfiguration to notice. Both fail at startup.
runPrune was the one loop started with a bare go and not in the daemon's
WaitGroup, so shutdown did not wait for a prune that was deleting files.

Found in review of #72.
2026-08-01 14:21:46 +04:00
kami e926e4e6df vision: hold the second request to the rule the first one follows
checkPrivate validates the configured endpoint literal and validated
nothing after it. The client followed redirects, so a 302 from the local
llama-server would have sent the photo, as a data URI in the POST body,
to whatever the redirect named. "No provider in this repo may upload a
blob" was true only of the first hop. Redirects are refused now, and the
reply is read through a cap rather than however much the endpoint feels
like sending.

ValidateEndpoint exports the same check so config can fail at startup on
a typo instead of logging once and leaving vision quietly off.

Found in review of #72.
2026-08-01 14:21:46 +04:00
kami 694d9e4e45 rss: stop claiming "что нового" and stop re-noting the same items
"что нового?" is a greeting, and the feed matcher claimed it: "нового" was a
feed noun and "что" an ask. With no feeds block, which is what ships, the answer
to hello was "я пока не читаю ленты — они не настроены". A newness word now
needs a named topic or a real feed noun beside it. The topic prepositions lose
"о" for the same class of reason: one rune of filler produced a category of
whatever followed it, and then "по этой теме в лентах пока ничего".

An undated feed was re-noted in full on every boot. Dated items are deduped
against the durable mark, undated ones against a map that dies with the process,
so five items became five more on the next start, stamped now, at the top of the
recent-notes window. A crash loop made that a flood. The mark is now set for an
undated feed too, and its existence marks the first poll after a restart as a
resync: those items are recorded as seen rather than written.

A burst larger than max_items lost its middle. The poll walked the feed
newest-first, stopped at the cap, and marked the newest item written, which put
everything below the cap behind the mark forever. The cap now applies to the
oldest candidates and the mark follows what was written, so max_items paces
instead of dropping.

The category tag was read out loud: "Заголовок [технологии]" went through piper
brackets and all, because the answer path took the whole first line. The tag is
parsed off for reading and is now the only thing a topic is matched against.
Matching the whole note meant "что нового про погоду" hit any tech headline
whose link contained "pogod".

Also: the charset comment on dec.Strict described something Strict does not do,
and a skipped feed is named in the log.

Found in review of #66.
2026-08-01 14:21:36 +04:00
kami 4b052fb9d2 media: make retention and the disk budget true
Put wrote the blob and then the sidecar. A full disk or a crash between
the two left bytes on disk with no sidecar, and List walks sidecars, so
Prune could never see them: Put returned an error and an image nobody
knew about became permanent. The sidecar goes first, a failed write is
rolled back, and Prune also collects blob files that have no readable
sidecar and are past retention, which picks up whatever an older build
leaked.

The per-blob cap bounds one call and nothing bounded their sum. Content
addressing does not help, because one flipped pixel is a different
digest, so 64 MiB per call and an unlimited number of calls fills the
disk mavend's database lives on. The store now carries a whole-store
budget, seeded from disk at open so a restart does not begin at zero.

Found in review of #72.
2026-08-01 14:21:29 +04:00
kami 61ba58388f media: bound an image by pixels, not by compressed bytes
The only cap was 64 MiB of input, and a decode bomb is a small file. A
20000x20000 PNG of flat colour compresses to a few hundred kilobytes,
decodes to 400 million pixels, and flattenAndScale then allocated a
second buffer of the same dimensions before scaling anything. That is
3.2 GB of live heap from one request, on a laptop, in the process that
owns the database and the socket, and max_dim never got a chance to
help. The header is read first now and a source over forty megapixels is
refused. The scaler reads the source through At and allocates only the
destination, so flattening no longer doubles the peak.

Found in review of #72.
2026-08-01 14:21:29 +04:00
kami 5e52b55ee9 chore: keep worktree model symlinks out of the tree
The fix pass ran in git worktrees, which need models/ symlinked in from the
main checkout to build. .gitignore covered the embedder and llm symlinks but
not stt and tts, so those two were committed as absolute-path symlinks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:20:25 +04:00
kami 59cdcc4e19 Merge branch 'fix/g11' into fix/integrated
# Conflicts:
#	internal/store/migrations.go
2026-08-01 14:20:24 +04:00
kami 3588da9e28 Merge branch 'fix/g06' into fix/integrated
# Conflicts:
#	cmd/mavend/memoryeval.go
2026-08-01 14:20:04 +04:00
kami d3fcc1dfdb mavwaked: throw away the round-trip backlog before it becomes a turn
Nothing reads the microphone while Send is in flight, so the audio piles
up in arecord's pipe and arrives in a burst the moment dispatch returns.
A round-trip is p50 2.7s through the LLM router, which is about 90
frames of room, of him finishing his sentence, of the television.

The old code reset the VAD on the reply path only, and for a reason that
was not true: the comment said the VAD had been accumulating during the
round-trip, when its state is exactly what Feed left it as. The two
paths with no reset are the ones that mattered, because neither starts
playback and so neither is covered by the half-duplex gate. A text-only
turn fed the whole backlog into the VAD, and a Send error did the same
on every failed turn, so a dead socket drove a retry loop off backlog
alone.

The backlog was scored for barge-in too. Five frames delivered in
microseconds cut her off with audio recorded before she started
speaking, which is the opposite of what the five-frame guard is for.
Both are fixed by the same mechanism: measure the wall time the
round-trip took, convert it to frames, and discard that many before
anything looks at them.

Barge-in also threw away the 150ms that proved he was talking. The VAD
started from the next frame, so the first word of a short interruption
was clipped before whisper saw it. Those frames are kept in a small ring
and replayed after the reset.

A stuck aplay was worse than before this feature existed. Playing()
gates all capture, so a wedged child made her deaf rather than silent,
for the full 30s ceiling inherited from the fire-and-forget version. The
mute window is bounded by the reply's own duration plus a margin now.

Three smaller ones. "-barge-in -barge-in-rms 0" logged "barge-in on" and
then did nothing. The sent counter incremented before the error check,
so failed round-trips counted as shipped. And the threshold the operator
has to guess is now reported: mavwaked logs the mean energy of the
frames it suppressed while speaking, so he can set it from data.

Found in review of #76.
2026-08-01 14:19:35 +04:00
kami 89afe4ca99 Merge branch 'fix/g04' into fix/integrated
# Conflicts:
#	cmd/mavend/actions_query.go
#	cmd/mavend/dayplan_test.go
2026-08-01 14:19:15 +04:00
kami fa783cba8f Merge branch 'fix/g05' into fix/integrated 2026-08-01 14:18:11 +04:00
kami f8af9299dd Merge branch 'fix/g03' into fix/integrated 2026-08-01 14:18:10 +04:00
kami 5c17b2db06 Merge branch 'fix/g02' into fix/integrated 2026-08-01 14:18:10 +04:00
kami 6316354518 zenmoney: bound the day fact to its own day and stamp when it was read
The day total rolls over at midnight and the poller had nothing to write until
the first spend of the new day, so at 09:00 the latest money_today fact was
yesterday's spending and looked perfectly fresh. The value now carries the
first instant of the window it covers, and a today question that the stored
window does not cover is refused rather than answered with yesterday's number.
Staleness was measured off the fact timestamp, which only moved when the figure
moved, so a quiet month was reported as data from three days ago while being
current. The value now carries when it was last read and the poller writes on
every read.

Amounts in an instrument the window diff never named were spoken with a numeric
instrument id as the currency. Instruments are resolved from one cursor-zero
diff, cached for the process, and an amount still unnamed is dropped from
speech rather than recited wrongly. "сколько я потратил вчера" was answered
with the month total, a real number to a different question, and is now
refused by naming the two windows she keeps. Income questions led with the
spending.

Found in review of #62.
2026-08-01 14:16:56 +04:00
kami 88d25d31ac tasks: compare due dates in the caller's day, not in UTC
dayDelta truncated both instants to a UTC day. A task due at 02:00 Moscow time
tonight read as due tomorrow, and one due at 23:00 last night read as due
today, so the two classes that decide the whole order were assigned from the
wrong calendar. Both sides are now truncated in now's location. Dated work also
lost to age alone because the later-due score sat below the age cap, and the
tail said "и ещё 3" with no noun and no Russian plural agreement.

The page hardcoded time.Now, so none of this was testable from a fixed clock.
It now takes an injectable clock, parses the due date in that clock's location,
parses ids and weights with strconv instead of a hand-rolled scan, caps the
resolved table and says so, shows who resolved each row, and reports a
promotion as the confirmation it is.

Found in review of #61.
2026-08-01 14:16:56 +04:00
kami 708a69375f tasks: key derived captures by external id and record who resolved
A task extracted from mail deduped on the live-norm index only, so once he
finished it the row left the live set and the next poll of the same immutable
message re-extracted it as a fresh candidate. mavmaild is a read-only reader
and marks nothing read, so that repeats forever. Derived rows now carry an
ext_id built from the message uid and the extracted span, unique across every
status, while voice keeps live-only norm dedupe because saying an errand again
is the recurrence signal. A derived source can no longer capture straight to
open, and saying a task out loud that Maven had only proposed promotes the
candidate instead of answering that it is already in the list.

SetTaskStatus was classified AuthRead. Resolving a task is not additive, it
erases work off his list, so it is a write, and the row now records the caller
that moved it. ListTasks was unbounded. The list-query matcher claimed any
utterance with "что мне делать", including "с чем мне помочь", and the urgency
stripper matched inside words.

Found in review of #60.
2026-08-01 14:16:39 +04:00
kami d68708b5e1 stt: make the golden tests fail where they used to disappear
The file comment named four regressions caught here. Three were not.
Nothing on this path resamples, because PCMFromWAV refuses anything that
is not already 16 kHz mono s16. Nothing exercises language selection,
because the hint comes out of the manifest already correct. And a bad
model path was the one condition that made the whole test vanish behind
a skip nobody reads. The comment now claims the two things that are
real, an explicitly set MAVEN_WHISPER_MODEL that does not exist is a
failure, and a missing fixture is a failure rather than a skip.

looseWordMatch accepted a different word. Four retained runes of "воды"
is "вод", so whisper hearing "выпил водки" satisfied the ru_fact
keyword, and "dis" let display, distance and discuss all stand in for
"disk". A case ending adds a rune, not a syllable, so the hypothesis is
capped in length as well as matched on prefix.

The spoken text lived in the generator and in the manifest with nothing
tying them together. Editing one left the other describing audio that no
longer existed, and at a flat ceiling of 0.34 over a five-word reference
a one-word drift passed silently. The script reads text out of the
manifest now, and the ceilings are set just above what each case really
measures against ggml-small, with the measurement recorded beside them.

Also: the test carried its own copy of the PCM to float32 conversion, so
a regression in the daemon's copy left the silence-gate assertion green,
and the manifest was validated for keywords but not for text, where an
empty reference makes every hypothesis score a WER of 1.

Found in review of #75.
2026-08-01 14:16:02 +04:00
kami 3ff2a9340a phraser: gate every llm.Client call on the swap drain
The drain counted only the phrasing paths in internal/phraser. The router, the
replier, the mail extractor and the memory evaluator reach llama-server through
llm.Client, so quiesce could report zero requests in flight while the router was
mid-generation, and the old server was killed under it. The turn then finished
on the new model, which is the split turn the swap exists to prevent. llm.Client
now enters an optional Gate before every completion and LLMPhraser implements
it, so one counter covers every holder of the base URL.

A total failure also reported itself as a rollback. Swap set RolledBack on the
path where the rollback failed too, so the page rendered "rolled back to  — she
is still answering, with the old model" over an empty model name and a daemon
with no model at all. The total failure has its own flag now, LiveModel stops
naming a gguf that is not loaded, and the log says another attempt can recover
without a restart, which is true.

The swap also ran on the connection every other page shares. ipc.Client holds
its mutex for a whole roundtrip with no read deadline on either side, so a load
froze /dash, /history and /notifications for minutes. mavweb dials a second
connection for /models alone. POST /models joins the route table, and the load
settings no longer come off a form that renders no input for them.

Found in review of #68.
2026-08-01 14:15:58 +04:00
kami 617476772e test: make the ecosystem fault suite fail when the feature is deleted
Several assertions passed against code with the behaviour removed. The
independent-outage test shared no state to begin with, the capability
fixture used to prove read-only filtering was already mutating, and
route-level faults were simulated with a separate fake instead of the
shared one. The harness now takes per-route faults and a ticking clock,
so durations are measurable and one dead endpoint can be shown not to
mute a whole service. New cases cover a resolved reference with no
entity, a rejected credential, a malformed Praxis body, foreign items
in a scoped response, named truncation, traces staying out of facts,
and enrichment making progress while its oldest batch is backed off.

Found in review of #82.
2026-08-01 14:15:33 +04:00
kami 802d5961ac enrichment: scan past backed-off facts instead of stalling behind them
The worker took the oldest pending facts by id and attempted them. Once
the oldest batch entered backoff the worker kept selecting the same
rows, found none of them due, and did nothing. One unresolvable fact
at the head of the queue froze enrichment for every fact behind it, up
to the hour-long backoff cap, forever. The worker now scans up to a
thousand pending rows and attempts the first batch that is actually
due. Retry state for rows that left the queue is forgotten, a failed
store write backs off the same way a failed resolve does, and the
status counts pending, backed off and exhausted over the rows it saw.

Found in review of #83.
2026-08-01 14:15:33 +04:00
kami 252f773223 ecosystem: assign one correlation ID per action and fail closed on scope
The act path minted IDs per hop and trusted whatever Praxis returned
for a scoped attention query. A service that ignored the entity filter
would have had its unrelated items read back to the owner as his. The
handler now assigns one correlation ID at the top of the action and
passes it down, and drops any item the response did not tag with the
requested entity. Traces are written to the trace table with the
causation ID and HTTP status hoisted into columns, the duplicate
legacy Hexis trace is gone, truncated lists say so, and a rejected
credential gets its own reply instead of looking like an outage.

Found in review of #83 and #84.
2026-08-01 14:15:33 +04:00
kami f432eb0b25 ecosystem: let the client layer read correlation IDs, never mint them
setEcosystemHeaders minted a fresh correlation ID whenever the context
carried none. Every hop of one action therefore got a different ID, so
a trace could not be followed from resolve to attention to execute.
The header layer now only reads what the caller assigned. Praxis
requests are typed the same way Nexus ones already were, so a 401 from
Praxis reports as unauthorized instead of a generic failure, and a
"resolved" response with no entity is an error rather than a silent
empty result. Hexis refuses to wire at all when a token is configured,
because the vendored client cannot send one and starting anyway would
send unauthenticated calls under the belief they were authenticated.

Found in review of #84.
2026-08-01 14:14:19 +04:00
kami 5aaecd2a53 store: give ecosystem traces their own table
Traces were written as facts. A single Praxis action wrote several of
them, so machine-rate rows crowded out the bounded fact readers that
humans and evaluation consume. The habit profile window of 2000 facts
and the memeval snapshot both filled with call records instead of what
Maven learned about the owner. Traces now go to ecosystem_traces, with
correlation, causation, duration and HTTP status as columns, pruned to
the most recent 5000. The new reader is exposed over IPC and rendered
as the Calls card on the ecosystem page, so it is a table someone
actually looks at.

Found in review of #84.
2026-08-01 14:13:58 +04:00
kami ec5167de3a speaker: do not ship three methods that cannot work
The package comment, the embedder log and the startup line all said
enrolment was live and only recognition was blocked. Enroll embeds every
sample before it stores anything, so with no model on the box it fails
on the first sample with ErrDisabled and nothing is ever stored. List
then returns an empty list forever and Forget has nothing to delete. The
shipped state was three methods, all no-ops, announced as a working
half.

SpeakerConfig.Recognizes was written as the gate for this and never
called, so a block with enabled and no model_path wired everything and
skipped the one warning the operator needed. It is the gate now, and
that config shape logs why it stayed off.

Three smaller repairs. ErrDisabled had no case in speakerErr and reached
the surface as an opaque core failure, when it means the same thing
ErrUnknownMethod does. Forget read the row first and answered ErrNotFound
on a second call, so the layer documented as the one that must always
work reintroduced a failure for a voiceprint that was already gone.
And a row with unparsable metadata listed as a plausible profile named
after its own id with 0 samples, which is what a real minimal enrolment
looks like; it is reported as damaged now.

Found in review of #74.
2026-08-01 14:12:43 +04:00
kami f02f3b55b6 memory: keep voiceprints out of note and fact recall
Speaker profiles share the vector table with notes and facts. The doc
comment said reading them through Catalog is what keeps recall from
ranking a voiceprint. It is not. Catalog controls how speaker code reads
its own rows and says nothing about Search, which scanned every row.
What actually hid them was cosine returning 0 on a width mismatch, so a
192-dim ECAPA row scored 0 against a 384-dim query. Some x-vector
exports are 384-dim, and one of those would have surfaced speaker:kami
as a recall hit carrying the name of a person.

Both backends now skip the prefix in Search, and the prefix is one
constant in internal/memory so the store layer can filter on it without
importing internal/speaker.

Two more differences between the backends closed here. ByPrefix on the
in-memory store returned the stored metadata map by reference, so a
caller editing a returned Record edited the row, while the persistent
one unmarshals fresh. And the append to upsert change in Insert is a fix
in its own right, not only a speaker concern: any re-indexed id used to
leave a second stale copy searchable.

Found in review of #74.
2026-08-01 14:12:43 +04:00
kami da62a2f25e mcp: pin what a tool was when it was approved
An allowlist row stores cmd ["mcp", server, tool]. That is a late-bound
reference to a name the far end owns, so the row pins nothing about
behaviour: a server could redefine an enabled read-only list_tasks into
something that writes, and Maven would keep calling it with no confirm
turn and no second approval. Discovery now stores a fingerprint of the
declared shape, name, description, input schema and readOnlyHint, and
compares it on every refresh. A mismatch drops the row back to proposed
and, if it stopped claiming read-only, marks it destructive. destructive
is only ever raised. A row predating the column adopts its fingerprint
silently, because an upgrade is not a redefinition.

Nothing retracted a proposal either, so a tool a connected server no
longer offers stayed enabled and failed at call time with an internal
string. Those rows are withdrawn, with provenance saying why, and only
for servers that are actually connected so a restart does not disarm
what he approved.

Argument binding rested on readOnlyHint, which the same server writes.
A server advertising delete_project as read-only got an unconfirmed
argument-carrying call. Binding now also requires the tool be named in
allow_tools, something local, and refuses a required property the schema
never describes rather than guessing it is a string.

wireMCP dialled synchronously from run, and on the passkey path from
inside the unlock handler, so one black-holed endpoint delayed boot and
the answer to an unlock. The first dial happens on the refresh goroutine
under the daemon context. Two servers whose names flatten to one local
allowlist name no longer share a row.

Found in review of #71.
2026-08-01 14:11:57 +04:00
kami 52f56947bb tool: separate a tool that is off from a backend that is down
An enabled MCP or smarthome row with no backend returned ErrNotEnabled,
and actionAct reads ErrNotEnabled as "this is unknown, draft a
proposal". So a tool Kami had already approved, whose server happened to
be restarting, produced a second proposal row and an answer saying the
tool needs approval. The right answer is that the server is down.
ErrNotConnected carries that, and the act path maps it, ErrNoServer and
ErrToolGone to replies that say which of the three happened.

Found in review of #71.
2026-08-01 14:11:57 +04:00
kami 5e0417306b mcp: guard the connection, not just the first dial
Refresh called alive() with the manager lock held, so a slow health
check blocked every other server. It now snapshots the candidates and
asks outside the lock.

A server that cannot be dialled was retried every minute forever, which
for a misconfigured stdio block means re-exec'ing a process 1440 times a
day. Dials now back off from one minute to thirty.

An allow_private fetcher followed redirects. A LAN MCP endpoint could
answer a POST with a redirect to 169.254.169.254 and the guard would go
there, because allow_private is what turns the address check off.
Redirects are refused outright on that door.

The tool catalogue was trimmed by taking the first max_tools entries of
whatever order the server sent, so the server chose which of its tools
Maven proposed. Over the cap without allow_tools now contributes
nothing: refusing is honest, silently keeping the server's pick is not.
Descriptions are server-written text that lands in the router prompt and
on /tools, so they are capped too.

A server block with enabled false was skipped by validation, so a typo
in a block written dark surfaced only on the day it was switched on. All
blocks are shape-checked now. Configured static headers carry the bearer
token a real remote server needs, and host_interval bounds how fast one
endpoint is polled.

Found in review of #70.
2026-08-01 14:11:39 +04:00
kami 87d03cf8c6 mcp: bound and abandon transport reads
The stdio reader ran inline under the transport lock, and bufio never
observes a context. A server that accepted a request and then wrote
nothing held that lock forever. alive() takes the same lock and Refresh
calls alive() while holding the manager lock, so one mute python server
wedged Tools, Status and every Call, including turns that touch no MCP
tool at all. The read now runs on its own goroutine feeding a channel,
the call selects on the context, and a call that gives up drops the
connection so the manager re-dials.

The frame bound was measured after the line had been assembled, which is
not a bound. A server emitting 500 MB with no newline had all 500 MB in
mavend before the check could reject it, which on the deploy target is
an OOM kill of the core daemon. The scanner's own buffer limit enforces
it now.

The HTTP transport never checked the response id. A server request sent
mid-stream, sampling/createMessage or roots/list, unmarshalled into a
response with neither result nor error, so the call reported success
with an empty string. The act was logged as done and the tool never ran.
The id must match and the frame must carry a result or an error.

Found in review of #70.
2026-08-01 14:11:24 +04:00
kami 1c94df76b7 mavcaldav: reconcile the render collection on startup
Withdrawal read published, which is in-memory, so the second loop only
ever withdrew reminders this process had published. Fire a reminder,
restart mavcaldav, and its event stayed in the collection forever with
nothing left to revisit it. "Losing it costs nothing, the next tick
rebuilds it" holds for events that should be there and not for the ones
that should not.

The first tick now PROPFINDs the collection and reconciles what it finds
against what is pending. Only hrefs carrying ReminderUIDPrefix are read
back, so the pass can never propose deleting a file maven did not create.
A failed read is retried on the next tick rather than skipped for the
life of the process.

Two smaller things from the same review. checkRenderTarget takes the
whole read set, so a second calendar to read cannot quietly fall outside
the guarantee the package comment makes. writeIfChanged loses its
confidence parameter, which every caller passed 1.0 and nothing read.
Found in review of #56.
2026-08-01 14:11:07 +04:00
kami 9e383eb751 event: order the journal by notice time, and keep it to what arrived
The ring is insertion-ordered and the page called itself newest first
while printing OccurredAt, which is when the thing happened. A cold feed
read publishes a week of items in feed order and the ambient relay
stamps a 09:00 notification with an 18:00 meeting, so the timestamp
column ran forwards and backwards on the same page. Events now carry
NoticedAt, filled by the bus and not by the caller, and the page sorts
and labels by it while still showing when the thing itself happened.

Four writers on that page had not arrived from anywhere: the feed
watermark, the crawl hash, the praxis trace of an act she performed and
a quiet-hours toggle he pressed. On a cold start with a few feeds they
could evict real intake out of a 512-entry ring. The decorator now skips
Maven's own bookkeeping.

Priority was the only surviving trace of confidence, and it inverts:
a relayed meeting at 0.6 read as low while an rss watermark at 1.0 read
as normal. The fact's own kind, its confidence and the id it voids now
travel in Payload, which was unused. A retraction is marked as one and
scored low, instead of publishing an envelope indistinguishable from a
fresh reading of the same key.

Smaller: SourceKind no longer maps every email source to a task, so a
future fact under an email prefix is not journalled as one; newEventBus
is quiet when it is handed no config at all; and morningTmpl has its own
doc comment back.
Found in review of #78.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:10:36 +04:00
kami 8c6332f95c mavmaild: own its state volume, retire aged-out UIDs, stop restart-looping
The commented compose service mounted dbdata, the encrypted database volume,
read-write, for one JSON file of UIDs. The header of that same file says only
mavend holds the key and the db volume, and the whole argument for a separate
reader is that a compromise on either side does not reach the other. It gets its
own volume now, at its own path, so neither can be restored from a backup of the
other.

The high-water mark only advances through a contiguous run, and a failed ingest
is deliberately not marked. One message that never ingested therefore pinned the
mark forever: after the lookback window passed it could never be fetched again,
so the gap never closed, every UID above it stayed in the explicit set, and save
rewrote all of them every poll. FetchSince now reports the SEARCH window and the
poller retires everything below it, since a UID that can no longer be searched
for can never be read.

On ErrUnknownMethod the daemon logged "stopping" and then exited at the next
tick with status 0. The compose service inherits restart: unless-stopped, which
restarts a clean exit, so the real behaviour was a loop of four IMAP logins an
hour against a mailbox core would not accept anything from. It now stays up and
polls nothing.

The reader also sends the Junk verdict instead of counting bulk locally, which
is what the wire doc says it does. The verdict carries no mail content, since
nothing on the other side will read it. RunWith is gone, so the tests fake the
read rather than the transport.
Found in review of #65.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:08:32 +04:00
kami bddf52d1ee router: refuse a plan question about a day that is not today
IsDayPlanQuery only rejected the сегодня family, so "какие планы на
понедельник?" carried no other-day token, did carry "планы", and the
plan claimed it ahead of the calendar listing and recited today under
today's date. Weekday names, week, weekend and month join the refusal
list. This is a refusal and not a feature: it stands until the plan can
build a day other than the clock's own.

isRestOfDayQuery also lived in cmd/mavend and matched by substring while
IsDayPlanQuery tokenized, so the two predicates deciding one utterance
could disagree, and "проверь nextcloud" read as a request for the rest
of the day. It moves to the router and tokenizes.
Found in review of #58.
2026-08-01 14:07:43 +04:00
kami b3c2fad4ec morning: recite the day the store actually holds
Four defects in the plan, all of them in what it reads or how it prints
it. The checklist line was keyed on Status.Active, which Evaluate reports
only inside the window, so a morning routine skipped and asked about at
14:00 said nothing. Outstanding answers the question the plan asks, "what
did today still not get done", and the line stays placed at the nudge
time so it sorts to the top of the day. Nothing before the window opens
counts, so 06:00 is not a complaint.

The event text kept the "@ 14:00-14:30" tail FactValue writes, next to a
line that prints the hour itself, so every event said its time twice.
Reminders came off ListReminders, which orders by creation, so the 500
row cap dropped a reminder stated long ago for today and kept one stated
this morning for next year. PendingReminders bounds by fire time instead.
The pending filter used a string literal, one typo from matching nothing.

After now marks the plan it trimmed. "что дальше?" past the last item
answered "на 03.08.2026 ничего не запланировано", which denies a day he
just lived through.

The surface the plan belongs on is still open, tracked as Vikunja #431;
the comment in actions_query.go points at it.
Found in review of #58.
2026-08-01 14:07:43 +04:00
kami d62ba093f5 smarthome: keep the devices under the cap, and stop asserting what the house did not do
States sorted every entity by id and cut at MaxEntities. Entity ids sort
by domain prefix, so binary_sensor came first and forty slots went to
connectivity and update-available rows: propose found nothing
controllable, and homeSummary, reading the same list, said everything
was off with the lights on. The cap stays, because a tool name the 1.7B
half-remembers is a wrong act. What changes is which forty. Controllable
domains are taken first and round-robin, so every switch and light is in
before any sensor.

CallService reported done for a call that changed nothing. Home
Assistant answers a service call with the states it changed, and a
removed entity or an offline integration gets 200 and an empty array.
That is the one place Maven asserts something about the physical world,
so an empty array is now ErrUnknownEntity.

The confirm turn on a house row was a column, not an invariant. The
proposal is destructive, but /tools writes the checkbox through on
enable, so unticking it once made an unlock row that ran on first
hearing. Exec now demands the second turn for any smarthome row whatever
the column says, and lock is out of the default domain set so a bare
block does not propose an unlock for every door.

wireSmartHome enumerated the house synchronously, inside wireVoice,
before the socket was serving and inside the unlock handler. A box that
black-holes the connection held the daemon's start for the per-call
timeout. The first propose moved onto the ticker goroutine.

Smaller: an unreachable lamp is counted apart from an off one, a
truncated on-list says how many it left out, refresh has a floor of a
minute, and the http url is documented as a deliberate wg-only choice.
Found in review of #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:06:38 +04:00
kami c21d8fdcee router: let a habit question outrank the day plan, and know the weekend
IsDayPlanQuery fires on the token "планы" and its other-day list does not know
weekday names, so "какие у меня обычно планы по вторникам?" was claimed by the
day plan, which answered today's calendar stamped with today's date. The habit
source never ran. The matcher now declines any utterance ParseHabitQuery
claims, which keeps the decision out of the source table's ordering.

Two gaps in the same matcher. Sunday had only its dative plural listed, so "в
воскресенье" found no weekday. "по выходным" named days that no weekday word
matches, so it was answered with the whole-week profile. Both are recognised
now, and the weekend is read back as two days rather than pooled.

Found in review of #59.
2026-08-01 14:06:05 +04:00
kami 810076451f update: roll back what the restart actually deploys
On the deployment deploy/README.md documents, source_dir and install_dir are
the same tree and the restart command rebuilds the image from it. The
Dockerfile builds from cmd/ and internal/ and .dockerignore keeps the host
binaries out, so restoring the snapshotted binaries restored bytes nothing
reads. A bad commit therefore cost two health timeouts and two image builds
and ended in ErrRollbackFailed with an instruction to copy files back by hand,
which would not have helped either.

A deployment that rebuilds from source now has to say how the source is put
back. source_rollback "git" records the commit before the update and checks it
back out before the rollback restart. It refuses a dirty tree, because the
recorded commit does not describe one and a forced checkout would delete his
work. A build-from-source config that says nothing is refused by Validate, at
startup, rather than at the one rollback that mattered.

Also in this change, all from the same review:

  - MethodPing, the one method a locked daemon answers. Preflight passed on an
    unlocked daemon and the post-restart Presence read failed on a locked one,
    so a good update read as SHE IS PROBABLY DOWN once the env key is gone.
  - A dial failure is reported apart from a read failure. The documented
    socket is under /var/lib/docker, which a non-root operator cannot
    traverse, and "she is not answering" was the wrong diagnosis.
  - Verify refuses to run as root over a tree owned by someone else. It runs
    make build and make test in place, and root-owned artifacts break his next
    ordinary make.
  - A rollback no longer reverts config_files. That undid every config edit
    since the last apply, phraser.model_path among them.
  - The verify-failure path no longer reports rolled_back for a compile error.
  - waitHealthy caps each attempt at the remaining budget, so a 90s timeout
    cannot run to 99s.
  - tail cuts on a rune boundary. Russian test names showed the seam.
  - The claim that mavend does not import internal/update is replaced with
    what is enforced: mavend constructs no Updater and nothing can call Apply.
  - snapshot_dir inside source_dir is refused. It landed in the build context.

Found in review of #69.
2026-08-01 14:06:00 +04:00
kami fa799bc051 mavend: run the persona checks over the clarify prose, document the proposal cooldown
clarifyExpiredVariants and clarifyGaveUp are hand-written Russian that the
phrasing eval never sees, because they never pass through the phraser. They
carry feminine self-reference and a plain imperative, and they are the lines a
later edit reaches for a synonym in. A table test now runs the eval's own
feminine, his-gender, address and cringe checks over them and over
clarifyQuestions. The apology clause of the cringe check is skipped with its
reason written down: it exists so a greenlit nudge is not undercut, and a reply
to a request she failed to parse is the opposite case.

Also two notes and no behaviour change. announceProposal now says what its
cooldown does and does not do: detectAndPropose returns non-nil only for a newly
created row, so the first tick over a populated history announces one pattern
and silences the rest permanently, and the cooldown only spaces genuinely new
pairs found later. A queue would be needed for "one per day until each is
mentioned". The duplicated Cooldown default is explained as cover for a tickLoop
built in a test without going through Load. The -reembed flag help says the
daemon does not answer until the backfill finishes.

Found in review of #50, #54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:05:59 +04:00
kami 4757ff6d7b mavend: record which channel a quiet toggle arrived on
resolveQuietToggle runs inside runTurn, so mavweb /api/chat and telegram reach
it as well as the microphone. Every toggle was written with Source "tap:voice"
regardless, which left the facts table claiming a mic flipped a setting nobody
spoke to. This is the one function whose own doc comment calls it a
network-reachable way to change a daemon-wide setting, and provenance is the
first column read when asking why quiet mode is on.

runTurn now takes the channel it was entered from and the toggle writes it:
"tap:voice" from HandlePushToTalk, "tap:text" from handleText.

Found in review of #53.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:05:17 +04:00
kami 7ab9b48259 coldstart: recover v1 boxes, and make key wrapping an explicit act
Three ways the cold-start path could lose the database.

A box enrolled before the PRF change could never cold-start again. UnwrapKey
still read v1 blobs, but the only caller stopped supplying the v1 secret: the
assertion handler sends the PRF output and nothing looks up the credential
public key any more. On such a box the daemon read the blob, took the v1
branch, failed to decrypt, and stayed locked while a valid passkey was
asserted at it. The escape hatch was gone too, because WrapKeyFn was wired
only in env-key mode and a locked boot is by definition the mode with no env
key. The recovery was to put MAVEN_DB_KEY back in the environment, which is
the thing cold-start unlock exists to avoid. AssertFinish now retries a failed
PRF unwrap with the credential public key, and WrapKeyFn is wired in locked
mode too, so the box that came up on a v1 blob can be moved to v2.

Wrapping ran on every successful assertion. That made a routine step-up
rewrite the one file that opens the database, under whatever 32 bytes the page
posted. A compromised /auth/webauthn converted one legitimate touch into
permanent offline recovery of the at-rest key, and a second enrolled
authenticator silently locked out the first. Wrapping is now an act of its
own: a plain assertion may write the blob only when none exists, and replacing
one takes the rewrite button, which is the only caller that sets the new
explicit flag. The daemon still refuses to overwrite a v2 blob that does not
open under the presented secret.

The write was os.WriteFile, which truncates in place. A power cut between the
truncate and the write left a zero-length blob and no previous contents, on
the path of every step-up. It is now a temp file in the same directory, fsync,
rename, fsync of the directory.

Two smaller things on the same path. The v2 unwrap checked the secret length
but not the all-zero case the wrap side rejects, so the two ends disagreed
about what a valid secret is. And the handler logged "daemon unlocked via
credential" when an env-key daemon had answered unknown method, and again when
an already-unlocked daemon had done nothing.

Left alone deliberately: the PRF value is client-supplied and not covered by
the assertion signature. That is inherent to PRF key wrapping, since the salt
has to be fixed for the blob to open on the next boot. It is recorded as a
known property where the secret enters the handler.

Found in review of #77.
2026-08-01 14:05:13 +04:00
kami aee20a6abc llm: give voice turns priority on the single llama-server slot
llama-server is started without -np, so it serves one request at a time and
everything else queues. Mail extraction is allowed two minutes on a Thinking
1.7B, and the reader hands core up to 25 messages back to back. A turn arriving
mid-extraction therefore waited for whatever was left of that budget: the router
timed out into the classifier cascade and its 36.8% floor, and the phraser, which
has no floor, simply waited. Memory evaluation had the same shape with a five
minute budget.

llm.Gate is the bound. Foreground requests never wait. Background requests run
one at a time and yield while a foreground request is in flight, plus a quiet
window after it that covers the gap between the router call and the phraser call
of one turn. Clients get their priority from llmClientFor or
llmBackgroundClientFor, so which side a caller is on is decided at wiring time.
It gates only what goes through those clients, which the comment on Gate says.

mail intake: the extraction timeout no longer wraps the capture writes. A model
answering at 119 seconds of a 120 second budget left the first CaptureTask one
second and the third none, so candidates the model had already produced were
dropped with a deadline error. The mailbox name is validated before it becomes
provenance, since "email:" is not a source and neither is an arbitrary string
posted at the socket. The enable log prints the normalised candidate bound
rather than the configured one, which said "max 0" and then wrote three.
Found in review of #64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:05:07 +04:00
kami b2eb08bb51 mavend: take the clarify expiry notice before the confirm turn
runTurn computed the notice at step 2, after the confirm check had already
returned. So he could be asked a question, walk off until it expired, come back
and say "да" to a confirm that was still parked. The confirm answered and he
never heard that the older request had been let go, even though the store had
dropped it. Every other exit from runTurn carries the notice.

The notice is now taken first and every early return wraps in withNotice,
including the clarify answer path, where it is empty in practice because one
dialogue id holds one question.

Found in review of #50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:04:51 +04:00
kami ba33a677f8 memory: canonicalise habit keys, take the median on a clock, name the period
Three defects in how the counted profile is read back.

The counting unit was the key the LLM invented. There is no allowlist and no
normalization behind it, so "я выпил воду" and "попил воды" landed as different
keys, split one habit into two, and dropped both below the two-day threshold.
Keys now go through an alias table in behavior_ru.json before they are counted.
An unglossed key is quoted rather than recited as a verb, because "обычно ты
выпил_воды около 09:00" is not a sentence.

The typical time was a median of minutes since midnight, which is wrong for
anything that straddles midnight. Bedtimes of 23:40, 23:50, 00:10 and 00:20
gave 12:00, on the one activity most likely to cross the boundary. It is now a
circular median, and when the observations span more than half the clock she
names the habit without a time instead of inventing one.

The rest is wording. Profile.Since was computed and never spoken, so "обычно"
was an unfalsifiable claim; the overall read-back now says over how many days
of records it holds. The no-data weekday answer said "у меня пока нет ничего
постоянного" about a question concerning him. And "quiet" was a bare prefix in
the non-behavioural list, so any future self-fact key starting with those five
letters would have been dropped.

Found in review of #59.
2026-08-01 14:04:24 +04:00
kami f891a81ab2 clarify: ask about the second missing slot instead of failing on it
wantedSlots says a reminder needs both a subject and a time, but askClarify
parks only the first gap, because she asks about one thing per turn. When both
were missing the second gap was never revisited. "напомни" with no subject and
no time asked "О чём напомнить?", accepted "позвонить маме", then handed
applyAction a reminder with no time, which answered "не получилось разобрать
время напоминания." That is a parse error for a question she never asked.

A filled gap now re-enters the clarify loop for whatever wantedSlots still
names, one question per turn as before, spending the same attempt budget so the
exchange stays bounded. The answered subject is also folded into the raw
utterance, because actionReminder stores the utterance as the payload and a
reminder clarified out of a bare "напомни" would otherwise fire saying nothing.

Found in review of #50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:04:23 +04:00
kami 38b09ded95 store: return one calendar row per event
CalendarEvents range-scanned the key prefix and returned every historical
row, voided ones included. The facts table is append-only and the event
key is day plus summary, so moving a standup from 14:00 to 16:00 left two
rows under one key. The day plan prints a time per line, so it recited
both and told the owner he had two standups.

The query now drops voided rows, keeps the latest row within a source,
and prefers the best-evidenced source across them, so a notification
relay guessing at a meeting cannot displace the calendar read of it.
Found in review of #58.
2026-08-01 14:01:57 +04:00
kami 6c81df17ec email: drop the dead Gmail rule, fix nested MIME, decode windows-1251
The Gmail category rule matched X-GM-LABELS and X-Gmail-Labels against the
parsed header block. Neither is a header. X-GM-LABELS is a Gmail FETCH data
item and never appears in the message source, and X-Gmail-Labels only exists in
a Takeout export, so the branch could not fire against a real mailbox while its
doc comment promised a Promotions filter. Its test built the header by hand and
therefore asserted the matcher rather than the plumbing. The rule is removed and
the comment says what bringing it back would take.

multipartText folded a nested multipart's answer into one string, so HTML
derived text landed in the plain bucket and a real text/plain sibling later in
the message was discarded by the guard on plain being set. The two buckets now
stay separate through the recursion.

windows-1251 returned an unsupported-charset error and the message degraded to
subject only. That is the charset older Russian senders still use, so those
mails could never produce a task candidate. It is decoded from a 128 entry table
here rather than by vendoring x/text, for the body and for encoded words in the
subject. Every other unknown charset still degrades to subject only.
Found in review of #63.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:01:04 +04:00
kami d69a1f8076 email: bound the IMAP read and keep one bad message from blocking the poll
The literal size came off the wire with no cap, so the server chose the
allocation. A {2147483647} literal was a 2GB make before a byte arrived, and one
ordinary mail with a 60MB attachment was 60MB of peak RSS on a box already
holding a 1.7B model resident, all of it discarded afterwards by plaintextBody.
Literals are now capped at MaxMessageBytes, and a larger one is drained and
reported as ErrMessageTooLarge without being kept. Reads are chunked with a
deadline refresh, so the timeout is an idle timeout again rather than a budget
for the whole message.

FetchSince returned on the first fetch error, though its comment described a
continue. One oversized message at the top of the window hid every older message
behind it, on that poll and on every poll after it. Failures are now collected
and the rest of the mailbox is read. An oversized UID is retired as bulk, since
it will be the same size next time and the poller marks bulk seen.

Timeout zero was accepted and disabled the dial timeout and every socket
deadline, which parks the poller forever on a dead server with his credential
live in a TLS state. It is now rejected like an empty address.

A FETCH answered without a literal was indistinguishable from a vanished
message and dropped with no log line. Login now rejects a credential containing
a line break instead of stripping it and failing on the server's generic NO.
untagged matches the whole key, not a prefix. RunWith is gone: the dial seam is
an unexported field again, reachable only through export_test.go, so no code
outside the package can hand the reader a cleartext transport and the password.
Found in review of #63.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:01:04 +04:00
kami e4bfcd958f netscan: stop the scan wedging, and stop it overstating the LAN
The results channel was sized by the number of hosts while each worker
sends once per open port, so a subnet with more open ports than
addresses filled the buffer and blocked a worker forever. Nothing drains
the channel until wg.Wait returns and the sends have no ctx.Done case,
so the calling turn hung for the life of the process. Size it by probes.

Three more claims the scanner could not back. MaxHosts was spent in
order, so the second of two configured subnets got two addresses out of
254 with nothing logged. A run cut short by the cap or the deadline came
back indistinguishable from a complete one, and the shipped defaults
never fit the budget, so every scan was silently truncated at the top of
the range. Scan now reports truncation, targets are taken round-robin,
and the default rate and the budget are consistent with a /24.

The spoken reply read dotted quads out loud on the voice path. It now
says how many devices and what shape, and writes the address list as a
note, which is also the only record that Maven put packets on the LAN.
The network noun is matched whole so posetil is not a scan, the rate has
a stated ceiling, and a repeat question inside two minutes reuses the
answer.

Both query sources claimed the turn when the capability was off, which
let an unconfigured scanner and an unconfigured house swallow questions
that used to reach recall. Both now fall through.
Found in review of #81 and #80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 14:01:01 +04:00
kami 012bdcc1ae memory: count habits over self facts only, and skip retracted ones
The behaviour profile read the newest 2000 rows of the shared facts table and
then discarded everything that was not kind=self, so the length of the window
was set by the noisiest writer. mavpoll writes a wg_handshake row every time a
peer rehandshakes, about every two minutes per peer, which is enough to reduce
2000 rows to under three days. A weekday habit needs two distinct Tuesdays, so
that window can never hold one, and she answered that she knows no habits on a
store holding a year of taps.

RecentActiveFactsByKind filters kind in SQL, and also drops rows a later row
voids along with the void marker itself. The old read counted both a retracted
tap and its retraction, so a fact he explicitly took back still shaped what she
said he usually does. A correction still counts, because a correction is a value
he stands behind.

Found in review of #59.
2026-08-01 14:00:46 +04:00
kami 4f012e350c calendar: resolve DTSTART against its own TZID
parseDT stamped a zoned or floating DTSTART as UTC while every window
around it is built in local time, so the two sides of every comparison
were in different frames. On a +03 box a 22:00 local event parsed as
22:00Z, past the end of the local day, and the whole evening dropped out
of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04
box was recited at 17:00 next to its own printed 13:00.

DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from
the zone database, and a floating value is read in the caller's location.
tzdata is embedded because the deploy image carries none, and a silent
fallback to the box offset is the bug being fixed. FactKey and FactValue
stamp the owner's clock, so the key date the store range-scans is the
same day the plan asks for. FactSummary drops the time tail for callers
that print the hour themselves.
Found in review of #56 and #58.
2026-08-01 14:00:05 +04:00
kami 4e4c9170e3 calendar: date an ambient event by its day word, and refuse stale ones
EventFromNotification took the date from the notification's own day, on the
grounds that a meeting notification is about today or it would not be firing.
Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became
an event at 09:00 today, twelve hours in the past, and FactKey filed that
wrong meeting under today's date. Storing a wrong meeting is the one outcome
this parse works to avoid.

An explicit day word now moves the date: завтра, tomorrow, послезавтра,
сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра,
and stripped from the summary so the meeting is not named after the day.
Anything still landing more than two hours before the notification is refused,
which covers the cases with no day word at all. The grace keeps a repost for a
meeting already under way.

Also matches the bearer scheme with EqualFold. A phone sending "bearer <tok>"
fell through to the X-Maven-Token branch and got a 401 that looked like a
wrong token. A bare token with no scheme in Authorization is now rejected
rather than silently accepted. The route table in mavweb gains its /api/ambient
row, and the missing calendar_busy write is recorded as a known gap.

Found in review of #57.
2026-08-01 13:59:30 +04:00
kami 88c841cb0e memeval: scope the evaluator's note windows by source
Both windows the evaluator keeps over the notes table were row budgets over
every writer. The dedupe read 200 recent notes and kept the eval ones, so after
200 ordinary notes an old observation left the window and the next evaluation
wrote the same sentence again. The snapshot asked for MaxItems notes and then
discarded her own, so once hourly evaluation had run for a few weeks the model
saw almost no real notes. Both reads are now filtered in SQL, by
RecentNotesBySource and RecentNotesExcludingSource.

Two smaller things in the same area. The dedupe key stripped any trailing
bracketed clause, so an observation ending in one hashed differently from its
stored form; it now strips only the recorded action. The evaluation timeout was
five minutes on the one llama-server that also answers voice turns, which made
a collision a five-minute mute assistant, and is now sixty seconds.

Found in review of #55.
2026-08-01 13:57:26 +04:00
kami 0e83ddf3df deploy: stop mavweb becoming the default nginx server by file order
The maven block was first in nginx.conf, and nginx serves the first block for
a listen address when no server_name matches. Those two ports used to default
to nexus. After the maven block landed, a request with an unknown or absent
Host header reached mavweb instead, which is the one surface in the file that
can define and run argv. The ACL still held, so this was not an exposure, but
it is the wrong default to acquire by accident.

The nexus block is now marked default_server so the choice is explicit, and
the maven block moved last as a second guard. Also raises client_body_timeout
and proxy_send_timeout to match client_max_body_size 32m, since a slow
push-to-talk upload was cut at the 60s default on both while
proxy_read_timeout was already 300s.

Found in review of #52.
2026-08-01 13:56:44 +04:00
kami 49dfeb879e mavweb: gate the voice path on step-up like the text path
POST /api/ptt and /ws were listed as ungated on the grounds that mavend's
voice port is only reachable inside the deploy. mavweb is the thing proxying
into it from outside, so that argument does not hold. Audio posted to
/api/ptt runs the same router, the same LLM and the same applyAction that
POST /api/chat was gated on, which means speaking a light-switch act reached
the act path while typing it did not.

Both now take stepUpOK, so they fail open by default and deny under
-require-stepup exactly like the other four. Registration moved down next to
/api/chat because the gate needs stepUpSession. The route table records the
reason and names the session-scoped assertion the hands-free case wants as a
separate task. The SECURITY startup lines are one surface per line now.

Found in review of #51.
2026-08-01 13:55:20 +04:00
kami 7f42cc73be Address PR review comments on 50, 52, 53, 54, 59, 61
Seven fixes, each answering a line comment on the stack.

**Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned
the string "Moscow" when he named no city and voice.weather.default_location
was unset — a made-up answer presented as fact, which is the one thing maven
must never do. It returns "" now and the query path says it does not know.

**Digest statuses are a defined type** (PR 50). DigestStatus string plus the
three constants, so a rule name cannot reach the status column.

**Quiet-mode negation is not adjacency** (PR 53). The OFF list carried
{"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit
the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it
on. Negators are scanned over the whole utterance now, with the two ON phrases
that are themselves built on "не" excluded. "тихий режим выключи" works too,
which it did not before.

**Pattern stability uses a median band** (PR 54). max/min over the extremes
asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean
weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is
now tested against the median and 70% must be in band, and the reported
interval is the median of the in-band ones, so a holiday no longer drags a
weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected.

**The weekday profile stops reciting everyday habits** (PR 59). "What do I do
on Saturdays?" answered "you drink water" — true, and useless, because it is
equally true of every other day. Activities that are habits on six or more
weekdays move to Profile.Everyday and are read back as daily habits instead of
as an answer about that day.

**Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses
and weekday names, and the task capture/urgency/list vocabulary, are now
behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary
deploy is unchanged; wording edits are no longer source diffs.

**nginx template stops taking nginx down** (PR 52). Two host-side failure
modes, both plausible causes of today's crash. The $connection_upgrade map is
fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a
grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0
is not up yet, so nginx exits on a reboot that beats WireGuard — the header
now documents net.ipv4.ip_nonlocal_bind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 12:50:47 +04:00
kami 927e46bca3 Version, authenticate and fully trace ecosystem calls (#273)
Every Nexus and Praxis request now carries the contract version, an
X-Requested-By identifying Maven, a correlation ID (generated per request
when the call is not part of a traced action), and a bearer token when
one is configured. Nexus/Praxis/Hexis config blocks grew an optional
token field, env-expandable so the secret stays out of the committed
config; the vendored hexis client predates bearer auth, so a configured
Hexis token logs a loud warning instead of pretending to authenticate.

Client failures are now a typed *ecosystemError carrying service,
operation and HTTP status, classifying unauthorized, contract-mismatch
and unreachable without matching on message text.

Trace records are written for resolution, discovery, confirmation and
execution — on failure as well as success — with status, duration,
correlation and causation ids, HTTP status and failure class, and the
utterance redacted to its length. Traces were never actually persisted
before: both trace writers used fact kind "system", which the store's
CHECK constraint rejects, and the error was discarded.
2026-08-01 06:57:52 +04:00
kami 08f3db318f Query Praxis by canonical entity ref and back off enrichment retries (#272)
Add an entity-scoped attention capability: the subject is resolved to a
canonical Nexus entity_id, the id travels to Praxis as a query scope
instead of being dropped after resolution, and Maven's own facts already
tagged with the same id join the answer. Ambiguous, unknown, degraded and
no-Nexus cases each get a distinct reply and never a scoped query without
a scope.

Give the fact-enrichment worker per-fact exponential backoff capped at an
hour and a status report of pending/in-backoff/worst-attempt counts, so a
long Nexus outage shows as a visible backlog rather than facts that
silently never got tagged. Nothing is ever given up on.
2026-08-01 06:52:25 +04:00
kami 69e2800ef3 Cover ecosystem degraded modes with a shared fault-injection harness (#276)
Extend the fake Nexus/Praxis/Hexis harness with request header and query
capture, a malformed-body lever, a response delay lever, and a request
counter, then add a degraded-mode suite on top of it: independent outages,
malformed and drifted contracts, cancellation, execution failure vs
transport failure, ambiguous targets, no autonomous Praxis to Hexis
chaining, confirmation for mutating capabilities, and recovery without a
restart.
2026-08-01 06:47:55 +04:00
kami a8fcb404be Scan the LAN, bounded to configured subnets (#257)
internal/netscan/ discovers hosts on the network Maven is configured to look at:
a TCP-connect scan (net.DialTimeout, no raw sockets, no privileges) plus a read
of the kernel's ARP cache. Wired as a read-only query source, "network", so
"какие устройства в сети?" is answered by a scan instead of by whatever old note
happens to be nearest.

Scanning is a read, but an unbounded scanner on a home LAN is noisy and easy to
point somewhere it should not go, so the package is built around four bounds:

  - Scan takes NO target argument. The range comes from the config block and
    from nowhere else, so there is no exported way to scan an arbitrary prefix
    and nothing an utterance, the router, or a scanned host says can retarget
    it. That is asserted directly: the test watches every address handed to the
    dialer and fails if one falls outside the configured prefix. The ARP cache —
    the one input the network itself populates — is filtered to the configured
    range for the same reason.
  - Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and no
    larger than 1024 addresses. 8.8.8.0/24, 0.0.0.0/0 and 10.0.0.0/8 are refused
    at config load, not after the packets have left.
  - Rate-limited to a configured connections-per-second across the whole scan,
    so it looks like background traffic rather than a portscan.
  - Bounded in total by MaxHosts, a per-connection timeout, a 20s turn budget
    and the context; a canceled scan stops dialing immediately.

Off unless configured: dark without "enabled": true, and applyDefaults
normalises a disabled block to nil. deploy/mavend.json carries it disabled.

BLUETOOTH IS NOT SHIPPED, AND IS BLOCKED, NOT SKIPPED. The plan's other half
(internal/bluetooth/, RSSI presence probes) needs a bluez stack that is not
here: bluetoothctl and hcitool are not installed, bluetoothd is not installed,
the bluetooth unit is inactive, and org.bluez is not on the system bus. hci0
exists as a kernel device and nothing can talk to it. The docker deploy is
further away still — it would need host networking, the D-Bus system socket
passed in, and CAP_NET_ADMIN. Writing an exec wrapper around a binary that does
not exist, against an output format nothing here can produce, would be a guess
dressed as a feature. It needs a decision about privileging the container before
any of it is worth writing.

Vikunja #257
2026-08-01 06:35:10 +04:00
219 changed files with 16225 additions and 1765 deletions
+2
View File
@@ -52,3 +52,5 @@ coverage.out
# Agent worktrees and local agent state
.claude/
/models/stt
/models/tts
+7 -2
View File
@@ -149,8 +149,13 @@ eval-models:
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
# so this is the only way they should ever change. TestGoldenAudioTranscription
# then scores them against ggml-small; it self-skips when the model is absent.
# so this is the only way they should ever change. The spoken text is read out
# of testdata/golden_v1.json, so edit the transcript there and rerun this.
#
# test-stt-golden runs both golden tests: TestGoldenAudioTranscription, which
# scores the fixtures against ggml-small and self-skips when the model is
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
# and the manifest with no model at all.
stt-fixtures:
./scripts/gen-stt-fixtures.sh
+25 -13
View File
@@ -66,7 +66,7 @@ func run(args []string) error {
if *url == "" || *user == "" || *pass == "" {
return fmt.Errorf("-url, -user, -pass are required")
}
if err := checkRenderTarget(*url, *renderURL); err != nil {
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
return err
}
@@ -122,17 +122,26 @@ func run(args []string) error {
}
}
// checkRenderTarget refuses a render URL that is also a read URL. This is the
// structural half of #127's "cannot write to your work calendar": the write
// credential and the write URL are separate flags, and the one calendar maven
// is known to only read is rejected as a target at startup rather than trusted
// at runtime.
func checkRenderTarget(readURL, renderURL string) error {
// checkRenderTarget refuses a render URL that is also one of the read URLs.
// This is the structural half of #127's "cannot write to your work calendar":
// the write credential and the write URL are separate flags, and a calendar
// maven is known to only read is rejected as a target at startup rather than
// trusted at runtime.
//
// It takes the whole read set, not one URL. The guarantee in the package
// comment is about every calendar maven reads, and a second read target added
// later must not quietly fall outside the check.
func checkRenderTarget(readURLs []string, renderURL string) error {
if renderURL == "" {
return nil
}
if sameCollection(readURL, renderURL) {
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
for _, read := range readURLs {
if read == "" {
continue
}
if sameCollection(read, renderURL) {
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
}
}
return nil
}
@@ -163,7 +172,7 @@ func (p *poller) pollOnce(ctx context.Context) {
}
// Write calendar_busy on change.
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
log.Printf("mavcaldav: write calendar_busy: %v", err)
return
}
@@ -173,7 +182,7 @@ func (p *poller) pollOnce(ctx context.Context) {
// reaching back to Radicale.
for _, e := range events {
key := calendar.FactKey(e)
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
log.Printf("mavcaldav: write %s: %v", key, err)
}
}
@@ -206,7 +215,10 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
}
// writeIfChanged writes a fact only when the value differs from the latest.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
// Everything this poller writes is a calendar read, which is full confidence by
// definition; a source that is not, such as the notification relay, does not
// come through here.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == val:
@@ -220,7 +232,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
Key: key,
Value: val,
Source: source,
Confidence: confidence,
Confidence: 1.0,
})
if err != nil {
return fmt.Errorf("write %s: %w", key, err)
+10 -8
View File
@@ -62,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
t.Run("no previous fact writes", func(t *testing.T) {
fc := &fakeCore{}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -90,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
},
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -106,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
},
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -121,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -133,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
writeErr: fmt.Errorf("disk full"),
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -190,13 +190,15 @@ func TestPollOnce(t *testing.T) {
t.Errorf("calendar_busy ts is zero")
}
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM".
// The iCal states the event in UTC and the fact is stamped on the owner's
// clock, so the expected key date and times are the local reading of it.
eventReq := fc.writeLog[1]
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
if eventReq.Key != expectedKey {
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
}
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().Format("15:04")
if eventReq.Value != expectedVal {
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
}
+80 -3
View File
@@ -2,15 +2,18 @@ package main
import (
"context"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/store"
)
// renderer is the write half of maven's own local calendar (Vikunja #127).
@@ -38,6 +41,13 @@ type renderer struct {
// unchanged reminder costs nothing. Purely an optimisation: a restart
// re-publishes every reminder once, which is idempotent.
published map[int64]string
// reconciled — whether the collection has been read once since start. It
// has to be, because published is in-memory: withdrawal used to cover only
// the reminders THIS process published, so a reminder that fired while the
// daemon was down kept its event in the calendar forever, and nothing ever
// revisited it.
reconciled bool
}
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
@@ -64,7 +74,7 @@ func (r *renderer) renderOnce(ctx context.Context) {
live := make(map[int64]bool, len(reminders))
for _, rem := range reminders {
if rem.Status != "pending" {
if rem.Status != store.ReminderPending {
continue
}
live[rem.ID] = true
@@ -81,10 +91,28 @@ func (r *renderer) renderOnce(ctx context.Context) {
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
}
stale := make(map[int64]bool)
for id := range r.published {
if live[id] {
continue
if !live[id] {
stale[id] = true
}
}
if !r.reconciled {
remote, err := r.listPublished(ctx)
if err != nil {
// Try again next tick. A collection maven cannot read is not a
// reason to stop publishing to it.
log.Printf("mavcaldav: reconcile: %v", err)
} else {
r.reconciled = true
for _, id := range remote {
if !live[id] {
stale[id] = true
}
}
}
}
for id := range stale {
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
continue
@@ -94,6 +122,55 @@ func (r *renderer) renderOnce(ctx context.Context) {
}
}
// listPublished PROPFINDs the collection and returns the reminder ids maven has
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
// reported, so a reconciliation pass can never propose deleting a file maven
// did not create — the same bound every other path in this file has.
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
const body = `<?xml version="1.0" encoding="utf-8"?>` +
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(r.user, r.pass)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", "1")
resp, err := r.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
}
var ms struct {
Responses []struct {
Href string `xml:"href"`
} `xml:"response"`
}
if err := xml.Unmarshal(raw, &ms); err != nil {
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
}
var ids []int64
for _, resp := range ms.Responses {
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
if err != nil {
continue
}
if id, ok := calendar.ReminderIDFromPath(href); ok {
ids = append(ids, id)
}
}
return ids, nil
}
// renderMaxReminders bounds the read. Reminders past this count are older than
// anything a calendar view is useful for.
const renderMaxReminders = 200
+125 -10
View File
@@ -2,9 +2,11 @@ package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
@@ -27,12 +29,16 @@ func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, erro
return c.reminders, nil
}
// calSrv records what a CalDAV collection received.
// calSrv records what a CalDAV collection received. existing seeds resources
// that were already in the collection before this process started, which is
// what a restart looks like from the renderer's side.
type calSrv struct {
mu sync.Mutex
puts map[string]string
dels []string
status int
mu sync.Mutex
puts map[string]string
dels []string
existing []string
propfind int
status int
*httptest.Server
}
@@ -47,12 +53,43 @@ func newCalSrv() *calSrv {
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
case http.MethodDelete:
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
case "PROPFIND":
s.propfind++
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(http.StatusMultiStatus)
io.WriteString(w, s.multistatusLocked(r.URL.Path))
return
}
w.WriteHeader(s.status)
}))
return s
}
// multistatusLocked renders the collection listing. Caller holds the lock.
func (s *calSrv) multistatusLocked(base string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
names := append([]string{}, s.existing...)
for name := range s.puts {
names = append(names, name)
}
for _, name := range names {
if slices.Contains(s.dels, name) {
continue
}
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
}
b.WriteString("</D:multistatus>")
return b.String()
}
func (s *calSrv) deleted() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string{}, s.dels...)
}
func (s *calSrv) putCount() int {
s.mu.Lock()
defer s.mu.Unlock()
@@ -168,19 +205,97 @@ func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
read := "http://localhost:5232/kami/personal"
if err := checkRenderTarget(read, ""); err != nil {
if err := checkRenderTarget([]string{read}, ""); err != nil {
t.Fatalf("rendering off must be fine: %v", err)
}
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
t.Fatalf("a distinct collection must be accepted: %v", err)
}
if err := checkRenderTarget(read, read); err == nil {
if err := checkRenderTarget([]string{read}, read); err == nil {
t.Error("rendering into the read calendar must be refused")
}
if err := checkRenderTarget(read, read+"/"); err == nil {
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
t.Error("a trailing slash must not defeat the check")
}
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
t.Error("case must not defeat the check")
}
// Every read target is checked, not the first one. A second calendar to
// read must not fall outside the guarantee just by being added later.
work := "http://localhost:5232/kami/work"
if err := checkRenderTarget([]string{read, work}, work); err == nil {
t.Error("rendering into the second read calendar must be refused")
}
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
t.Fatalf("a collection maven owns must still be accepted: %v", err)
}
}
// Withdrawal has to survive a restart. published is in-memory, so a fresh
// process knows nothing about the events an earlier one wrote: fire a reminder,
// restart mavcaldav, and its event used to sit in the collection forever
// because nothing ever revisited it. The first tick reads the collection and
// reconciles what it finds against what is pending.
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
// Left behind by a previous process: 4 is still pending, 5 has fired.
// The third file is not maven's and must not be touched.
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
core := &reminderCore{reminders: []ipc.Reminder{
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
dels := srv.deleted()
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
}
// The collection is read once, not on every tick.
r.renderOnce(context.Background())
srv.mu.Lock()
n := srv.propfind
srv.mu.Unlock()
if n != 1 {
t.Errorf("PROPFIND ran %d times, want once per process", n)
}
}
// A collection maven cannot read is not a reason to stop publishing to it, and
// the reconciliation must be retried rather than skipped for the process.
func TestRenderOnceRetriesReconcile(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
srv.existing = []string{"maven-reminder-6.ics"}
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
core := &reminderCore{}
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
if got := srv.deleted(); len(got) != 0 {
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
}
if r.reconciled {
t.Fatal("a failed read must not count as reconciled")
}
r.http = srv.Client()
r.renderOnce(context.Background())
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
}
}
// propfindFailure fails PROPFIND and passes everything else through.
type propfindFailure struct{ base http.RoundTripper }
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method == "PROPFIND" {
return nil, errors.New("collection unreachable")
}
return f.base.RoundTrip(req)
}
+7
View File
@@ -53,6 +53,13 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec)
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
// The row is enabled and the backend is gone. Drafting a proposal
// for it (the ErrNotEnabled path) would be answering the wrong
// question.
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
case errors.Is(err, mcp.ErrToolGone):
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
case errors.Is(err, mcp.ErrNeedsArgs):
// An MCP tool that wants named arguments a spoken verb cannot
// supply. Guessing them would be a wrong act, so she says so
+31 -16
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"log"
"github.com/kami/maven/internal/ipc"
@@ -28,12 +29,18 @@ import (
// tracking is not connected". It never computes, estimates or rounds a total of
// its own — an invented number about his money is the worst thing this could do.
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
q, ok := router.ParseMoneyQuery(t.dec.Utterance)
if !ok {
return "", false
}
if q.Window == router.MoneyUnsupported {
// Two windows are stored and no others. Answering "сколько я потратил
// вчера?" with the month-to-date total answers a different question
// with a real number, which is the shape of a lie he cannot spot.
return "я храню только сегодняшние траты и за этот месяц.", true
}
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
if window == router.MoneyToday {
if q.Window == router.MoneyToday {
key, phrase = zenmoney.KeySpentToday, "сегодня"
}
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
@@ -51,28 +58,36 @@ func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: money fact: decode: %v", err)
return "не получилось прочитать траты.", true
}
now := h.now()
if q.Window == router.MoneyToday && !val.CoversDay(now) {
// The day window rolled over and the poller had nothing to write,
// because he has not spent anything yet today. The fact is fresh by ts
// and covers yesterday, so no staleness check can catch it — only the
// window stamp inside the value can.
return "сегодня пока ничего не вижу.", true
}
reply := val.FormatRU(phrase)
if q.Income {
reply = val.FormatIncomeRU(phrase)
}
if reply == "" {
return "по тратам пока нечего сказать.", true
}
// A stale fact is reported as stale rather than spoken as today's number.
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
// The age is measured from when the figure was last READ, not from when it
// last changed: a month with no spending in it does not go stale.
asOf := val.AsOf
if asOf.IsZero() {
asOf = fact.Ts
}
if now.Sub(asOf) > zenmoney.StaleAfter {
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
}
return reply, true
}
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
func isNoFactErr(err error) bool {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
return errors.Is(err, ipc.ErrNoFact)
}
+89
View File
@@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
}
}
// The day window rolls over at midnight and the poller writes nothing until the
// first spend of the new day, so the last money_today fact is fresh by ts and
// covers yesterday. No staleness check can catch that.
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
if !ok {
t.Fatal("want a fact value")
}
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, claimed := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
})
if !claimed {
t.Fatal("expected the source to claim it")
}
if strings.Contains(reply, "1749.5") {
t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
}
}
// Ts advances only when the number moves, so a quiet month used to be reported
// as stale while being current. The read stamp inside the value is what the
// staleness check means.
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
from, _ := zenmoney.MonthWindow(moneyNow())
sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
val, _ := sum.Value(moneyNow().Add(-time.Hour))
// The fact itself last CHANGED three days ago: nothing was spent since.
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
})
if strings.Contains(reply, "данные от") {
t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
}
}
// Two windows are stored and no others. Answering "вчера" with the
// month-to-date total answers a different question with a real number.
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
api := &moneyAPI{}
h := &reactiveHandler{api: api, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил вчера?"},
})
if !ok {
t.Fatal("a money question must be claimed, not passed to recall")
}
if !strings.Contains(reply, "только") {
t.Errorf("reply = %q, want her to say which windows she keeps", reply)
}
if api.callCnt != 0 {
t.Error("a window she does not keep must not read a fact")
}
}
// "сколько я заработал" reads the same fact and must lead with the income.
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
from, _ := zenmoney.MonthWindow(moneyNow())
sum := zenmoney.Summary{
From: from,
Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}},
Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
Count: 2,
}
val, _ := sum.Value(moneyNow())
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
Source: zenmoney.Source, Ts: moneyNow(),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
})
if strings.Index(reply, "3000") > strings.Index(reply, "100") {
t.Errorf("reply = %q, want the income he asked about first", reply)
}
}
+78 -34
View File
@@ -14,6 +14,7 @@ import (
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/weather"
)
@@ -81,16 +82,23 @@ var querySources = []querySource{
// wording, so "какая температура на улице?" still reaches the weather
// source.
{"home", (*reactiveHandler).queryHome},
// Next to "home" and for the same reason: "какие устройства в сети?" is a
// question about the LAN, and the recall pass would otherwise answer it
// from an old note about the router. Its matcher needs a network word plus
// an ask plus a device noun, so "интернет не работает" is untouched.
{"network", (*reactiveHandler).queryNetwork},
{"calendar", (*reactiveHandler).queryCalendar},
{"weather", (*reactiveHandler).queryWeather},
{"embed", (*reactiveHandler).queryEmbed},
{"memory", (*reactiveHandler).queryMemory},
{"notes", (*reactiveHandler).queryNotes},
// LAST before the model answers from memory, and that position is the whole
// design (Vikunja #259): local sources first. The model, his own notes and
// facts, and — once internal/kiwix is wired into this chain — the offline
// ZIMs all get their turn before anything touches the network. This source
// only claims a turn where he named a URL out loud, so it never competes
// design (Vikunja #259): local sources first. His memory, his notes and
// once internal/kiwix is wired into this chain — the offline ZIMs all get
// their turn before anything touches the network. The model does NOT: it
// answers after this, because a URL he said out loud is an instruction and
// a 1.7B guessing at a page it cannot read is how contents get invented.
// This source only claims a turn where he named a URL, so it never competes
// with a local answer.
{"web", (*reactiveHandler).queryWeb},
{"general-knowledge", (*reactiveHandler).queryGeneral},
@@ -134,11 +142,16 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
// reminders, and any morning checklist still outstanding.
// reminders, and every morning checklist item today still has no evidence for,
// including the ones whose window has closed.
//
// Read-only by construction — the plan is assembled and rendered core-side and
// nothing here schedules or announces. "что дальше?" asks for the rest of the
// day, so that phrasing trims what has already passed.
//
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
// surface: Maven holds the work board, runs the intake form, never argues").
// The spoken recital here is the current answer, not the decided one.
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
if !router.IsDayPlanQuery(t.dec.Utterance) {
return "", false
@@ -148,7 +161,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
log.Printf("voice: day plan: %v", err)
return "не получилось собрать план.", true
}
if !isRestOfDayQuery(t.dec.Utterance) {
if !router.IsRestOfDayQuery(t.dec.Utterance) {
return plan.Spoken, true
}
// Rebuild the pure plan so the rest-of-day rendering is the same code that
@@ -165,16 +178,19 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
return p.After(h.now()).FormatRU(), true
}
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
// that means "from now on" rather than "the whole day".
func isRestOfDayQuery(text string) bool {
s := strings.ToLower(text)
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
}
// habitFactWindow — how many recent facts the behaviour profile is counted
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
// over. Enough for a season of habits without scanning the whole store on every
// question; the profile is recomputed on read, so the bound is the cost control.
//
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
// was a plain recent-facts read the window was a row budget over every writer,
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
// 2000 rows was under three days of history. A weekday habit needs
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
// store with a year of taps in it. Self facts come from voice taps, and he does
// not tap seven hundred times a day.
const habitFactWindow = 2000
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
@@ -185,7 +201,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
if !ok {
return "", false
}
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
if err != nil {
log.Printf("voice: habits: recent facts: %v", err)
return "не получилось посмотреть записи.", true
@@ -198,10 +214,13 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
if q.HasWeekday {
return profile.FormatWeekdayRU(q.Weekday), true
}
if q.Weekend {
return profile.FormatWeekendRU(), true
}
return profile.FormatOverallRU(), true
}
// feedNoteWindow — how many recent notes are scanned for feed items, and
// feedNoteWindow — how many recent FEED notes are scanned, and
// feedReadOut — how many headlines she actually reads back. She summarises the
// top of the pile, she does not recite a river.
const (
@@ -226,25 +245,23 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
// news bulletin.
return "я пока не читаю ленты — они не настроены.", true
}
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
// By source, not the last 200 notes of any kind: a busy day of voice notes
// used to push the newest headline out of the window, and she answered "в
// лентах пока ничего нового" while the poller was working fine.
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
if err != nil {
log.Printf("voice: feeds: recent notes: %v", err)
return "не получилось посмотреть ленты.", true
}
var picked []string
for _, n := range notes {
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
continue
}
if !router.CategoryMatches(n.Text, q.Category) {
continue
}
// The note carries title, summary and link; she reads the title.
title := n.Text
if i := strings.IndexByte(title, '\n'); i > 0 {
title = title[:i]
}
picked = append(picked, strings.TrimSpace(title))
// The note carries title, summary, category tag and link; she reads the
// title alone. The tag is for the match above, and piper reads brackets
// out loud.
picked = append(picked, rss.NoteHeadline(n.Text))
if len(picked) == feedReadOut {
break
}
@@ -290,20 +307,45 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
return "", false
}
if h.home == nil {
// Claim the turn rather than fall through: "дом не подключён" is true,
// and letting general knowledge answer would be an invented house.
return "дом не подключён — я его не вижу.", true
// Fall through rather than claim the turn. A capability that is off
// must not change what an unconfigured box answers: "какая температура
// в доме?" on a Maven with no smarthome block reached recall before
// this source existed, and a stored fact is a better answer than
// "дом не подключён" from a house that was never configured. The
// unreachable case is different and homeSummary covers it.
return "", false
}
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return h.home.homeSummary(ctxH)
}
// queryNetwork answers a question about the LAN with a bounded scan. There is
// no confirm turn because nothing is changed, and no way to widen the range
// because Scan takes no target — the utterance selects the question, never the
// subnet.
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
if !isNetworkQuery(t.dec.Utterance) {
return "", false
}
if h.netscan == nil {
// Fall through, same as queryHome: an unconfigured scanner must not
// swallow "сколько устройств в сети?" before recall has looked.
return "", false
}
return h.netscan.scanSummary(ctx)
}
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
if !isWeatherQuery(t.dec.Utterance) {
return "", false
}
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
if loc == "" {
// He named no city and voice.weather.default_location is unset. Saying
// so is the only honest answer; picking a city would be inventing one.
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
}
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
@@ -420,10 +462,12 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
return "", false
}
if h.crawler == nil {
// Claim rather than fall through: he asked about a specific page, and
// letting the model answer from the URL's spelling alone is how a small
// model invents a page's contents.
return "я не читаю страницы — это не настроено.", true
// Fall through. Reading pages is off unless configured, and on a daemon
// where it was never turned on the older behaviour is right: the model
// answers the question as if the URL had not been said. Announcing a
// configuration status is for a capability that exists and failed, not
// for one he never asked for.
return "", false
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
+6
View File
@@ -42,6 +42,12 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
log.Printf("voice: capture task: %v", err)
return "не получилось записать задачу.", true
}
if resp.Promoted {
// It was a candidate Maven derived from something she read, and he has
// now said it himself. Saying "уже в списке" here would be answering a
// confirmation with a shrug.
return "поняла, беру в работу: " + cap.Text, true
}
if !resp.Created {
return "это уже в списке.", true
}
+28 -1
View File
@@ -19,6 +19,7 @@ type taskAPI struct {
captured []ipc.CaptureTaskReq
created bool
promoted bool
capErr error
tasks []ipc.Task
@@ -31,7 +32,7 @@ func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.Ca
if a.capErr != nil {
return ipc.CaptureTaskResp{}, a.capErr
}
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
}
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -225,3 +226,29 @@ func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
}
}
// Saying a task out loud that Maven had only proposed is a confirmation. She
// used to answer "это уже в списке" and then read it back, in the same
// conversation, as something he had not confirmed.
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
api := &taskAPI{promoted: true}
h := taskHandler(api)
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
})
if !ok {
t.Fatal("an explicit capture must claim the turn")
}
if strings.Contains(reply, "уже в списке") {
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
}
if !strings.Contains(reply, "продлить страховку") {
t.Errorf("reply = %q, want the task named back", reply)
}
// Persona: feminine, informal.
for _, bad := range []string{"рад ", "вы ", "ваш"} {
if strings.Contains(reply, bad) {
t.Errorf("reply %q contains %q", reply, bad)
}
}
}
+72 -22
View File
@@ -34,6 +34,7 @@ import (
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/kami/maven/internal/capture"
@@ -45,10 +46,12 @@ import (
"github.com/kami/maven/internal/store"
)
// captureSummaryTimeout — the budget for one Stop, which is a map-reduce over
// captureSummaryTimeout — the budget for one summary, which is a map-reduce over
// the whole meeting: one model call per transcript window plus a reduce, each of
// which is seconds on this box. Forty windows is the configured ceiling, so the
// budget has to be minutes, not the 60s the reply path uses.
// budget has to be minutes, not the 60s the reply path uses. It is spent on a
// background goroutine, never inside the capture_stop request: a client that
// asks Maven to stop recording gets the transcript back in seconds.
const captureSummaryTimeout = 20 * time.Minute
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
@@ -70,6 +73,12 @@ type captureWiring struct {
emb router.Embedder
cfg *config.CaptureConfig
now func() time.Time
// ctx and wg belong to the daemon, not to the request. Summarising happens
// after the reply has gone out, so it needs a lifetime that outlives the
// call and a shutdown that waits for it.
ctx context.Context
wg *sync.WaitGroup
}
// newCaptureWiring returns nil when the recorder should not exist: no media
@@ -79,7 +88,7 @@ type captureWiring struct {
// recording is still made, stored and transcribed, and the summary is simply
// absent — the honest degradation, and much better than refusing to record a
// meeting that is happening now.
func newCaptureWiring(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
func newCaptureWiring(ctx context.Context, wg *sync.WaitGroup, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
if keeper == nil || !cfg.Capture.Records() {
return nil
}
@@ -113,7 +122,7 @@ func newCaptureWiring(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring,
return nil
}
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now}
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now, ctx: ctx, wg: wg}
}
// start handles ipc.MethodCaptureStart.
@@ -127,6 +136,7 @@ func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.C
return ipc.CaptureStartResp{
Label: s.Label,
Started: s.Started,
Token: s.Token,
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
}, nil
}
@@ -135,7 +145,7 @@ func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.C
// response with Expired set rather than an error: the cap firing is the designed
// behaviour, and the client needs the flag to stop sending and call stop.
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
err := c.rec.Append(req.Audio)
err := c.rec.Append(req.Token, req.Audio)
st := c.rec.Status()
if errors.Is(err, capture.ErrExpired) {
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
@@ -150,21 +160,26 @@ func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc
// stop handles ipc.MethodCaptureStop.
//
// The error handling here mirrors vision's, and for the same reason: the audio is
// stored first, so a transcription or summary failure returns what exists rather
// than nothing. A response can carry a blob id with no transcript (STT failed,
// re-runnable), or a transcript with no summary (the model failed, the words are
// kept) — both are degraded successes and neither is an error to the caller.
// stored first, so a transcription failure returns what exists rather than
// nothing. A response can carry a blob id with no transcript (STT failed,
// re-runnable) — a degraded success, not an error to the caller.
//
// Summarising is NOT done here. A two-hour meeting is forty model calls, which
// on this box is minutes, and holding the IPC request open for them means the
// client that said "стоп" sits there with no answer while its own deadline runs
// out. Stop returns the transcript, and the summary note is written by a
// goroutine in the daemon's WaitGroup afterwards.
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
if req.Discard {
// "забудь, не записывай" — nothing is stored, transcribed or noted.
if !c.rec.Abort() {
if !c.rec.Abort(req.Token) {
return ipc.CaptureStopResp{}, capture.ErrNoSession
}
log.Printf("capture: session discarded on request")
return ipc.CaptureStopResp{Discarded: true}, nil
}
res, err := c.rec.Stop(ctx)
res, err := c.rec.Stop(ctx, req.Token)
resp := ipc.CaptureStopResp{
BlobID: res.BlobID,
Label: res.Label,
@@ -183,19 +198,47 @@ func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.C
log.Printf("capture: %q partially finished: %v", res.Label, err)
}
if id, werr := c.writeNotes(ctx, res); werr != nil {
log.Printf("capture: note write for %q failed: %v", res.Label, werr)
} else {
resp.NoteID = id
}
log.Printf("capture: finished %q — %s of audio, %d summary chunk(s)",
res.Label, res.Duration.Round(time.Second), res.Chunks)
c.summarizeLater(res)
log.Printf("capture: finished %q — %s of audio, %d bytes of transcript",
res.Label, res.Duration.Round(time.Second), len(res.Transcript))
return resp, nil
}
// summarizeLater runs the map-reduce and writes the notes after stop replied.
// The context is the daemon's, not the request's: the request is already
// answered, and cancelling the summary because the client hung up would throw
// away the only readable record of the meeting.
func (c *captureWiring) summarizeLater(res capture.Result) {
if res.Transcript == "" {
return
}
c.wg.Add(1)
go func() {
defer c.wg.Done()
ctx, cancel := context.WithTimeout(c.ctx, captureSummaryTimeout)
defer cancel()
if err := c.rec.Summarize(ctx, &res); err != nil {
// Not fatal: writeNotes falls back to the transcript, so a dead
// llama-server costs the summary and not the meeting.
log.Printf("capture: summary for %q failed: %v", res.Label, err)
}
if _, err := c.writeNotes(ctx, res); err != nil {
log.Printf("capture: note write for %q failed: %v", res.Label, err)
return
}
log.Printf("capture: summarised %q in %d chunk(s)", res.Label, res.Chunks)
}()
}
// writeNotes stores the summary as a note, and the transcript too when
// capture.save_transcript is set. Returns the summary note's id, or 0 when there
// was no summary to write.
// capture.save_transcript is set. Returns the id of the note that carries the
// meeting.
//
// With no summary the transcript is written instead, whatever save_transcript
// says. That flag is about keeping the verbatim record IN ADDITION to a summary,
// not about whether the meeting is remembered at all. Without this fallback a
// llama-server that was down at stop time meant an hour of recorded meeting left
// no note behind and nothing recalled it later.
//
// The note source carries the blob id, which is the only link back to the audio.
// When retention prunes the blob the note remains — words about a meeting are a
@@ -212,6 +255,13 @@ func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int
if err != nil {
return 0, fmt.Errorf("summary note: %w", err)
}
} else if res.Transcript != "" {
var err error
id, err = c.writeNote(ctx, res.Transcript, source+":transcript")
if err != nil {
return 0, fmt.Errorf("transcript note: %w", err)
}
return id, nil
}
if c.cfg.SaveTranscript && res.Transcript != "" {
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
@@ -251,8 +301,8 @@ func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error)
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
// opened: one blob store, one retention loop, images and audio side by side.
func wireCapture(srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
cw := newCaptureWiring(keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
func wireCapture(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
cw := newCaptureWiring(ctx, wg, keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
if cw == nil {
return
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/capture"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/media"
)
// silentTranscriber stands in for mavsttd: one fixed phrase per window, so the
// wiring can be tested without whisper.
type silentTranscriber struct{}
func (silentTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
return "решили купить насос", 1.0, nil
}
func testCaptureWiring(t *testing.T) (*captureWiring, *sync.WaitGroup) {
t.Helper()
blobs, err := media.Open(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
rec, err := capture.New(blobs, silentTranscriber{}, nil, capture.Config{})
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
return &captureWiring{
rec: rec,
st: newTestStore(t),
cfg: &config.CaptureConfig{},
now: time.Now,
ctx: context.Background(),
wg: &wg,
}, &wg
}
// A frame carrying the wrong token must not land in the running session. Append
// and stop used to address "whatever is running now", so a client whose session
// had already ended went on recording into somebody else's meeting, and any
// client could end a recording it never started.
func TestCaptureRefusesAnotherClientsToken(t *testing.T) {
c, _ := testCaptureWiring(t)
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "встреча"})
if err != nil {
t.Fatal(err)
}
if start.Token == "" {
t.Fatal("start handed back no session token")
}
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
Token: "not-mine",
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)},
}); err == nil {
t.Error("a frame with the wrong token was accepted")
}
if _, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: "not-mine"}); err == nil {
t.Error("a stop with the wrong token ended the session")
}
if st, _ := c.status(context.Background()); !st.Running {
t.Error("the session was ended by a client that does not own it")
}
}
// Stop answers with the transcript and does not wait for the summary. The
// summary is up to forty model calls, and holding the IPC request for them meant
// the client that said "стоп" sat with no answer for minutes.
//
// With no summariser wired the note still has to be written, from the transcript.
// save_transcript is about keeping the verbatim record IN ADDITION to a summary,
// not about whether the meeting is remembered at all — without this fallback a
// dead llama-server meant an hour of meeting left no note behind.
func TestStopReturnsTranscriptAndNotesItWithoutASummary(t *testing.T) {
c, wg := testCaptureWiring(t)
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "планёрка"})
if err != nil {
t.Fatal(err)
}
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
Token: start.Token,
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 32000)},
}); err != nil {
t.Fatal(err)
}
resp, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: start.Token})
if err != nil {
t.Fatalf("stop: %v", err)
}
if resp.Transcript == "" {
t.Fatal("stop returned no transcript")
}
if resp.Summary != "" {
t.Errorf("summary = %q, want none inside the request", resp.Summary)
}
wg.Wait()
notes, err := c.st.RecentNotes(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
var found bool
for _, n := range notes {
if strings.Contains(n.Text, "насос") {
found = true
}
}
if !found {
t.Fatalf("the meeting left no note behind: %+v", notes)
}
}
+70 -4
View File
@@ -17,7 +17,8 @@ import (
const clarifyTTL = 90 * time.Second
// wantedSlots — what each intent needs before she can act on it. First entry is
// the one she asks about; the rest are only used to decide act-vs-drop.
// the one she asks about this turn; the rest are asked about on later turns, one
// per turn, as each answer lands (see askRemainingGap).
//
// Intents not listed here are never worth a question: note and query act on the
// raw utterance, chat and system have nothing to fill in. For those a clarify
@@ -25,8 +26,7 @@ const clarifyTTL = 90 * time.Second
// is worse than admitting she missed it.
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
// not worth setting. Order here is the order she asks in — she still only asks
// about the first one missing.
// not worth setting. Order here is the order she asks in.
var wantedSlots = map[router.Intent][]dialogue.Slot{
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
router.IntentFact: {dialogue.SlotKey},
@@ -140,7 +140,8 @@ func missingFor(dec router.Decision) []dialogue.Slot {
// ("", false) when she has no idea what is missing.
//
// One question about one thing: if two slots are missing she asks about the
// first and lets the rest go. Two questions in a row is an interrogation.
// first only. Two questions in one breath is an interrogation. The second gap
// is picked up on the turn after the first one is answered (askRemainingGap).
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
missing := missingFor(dec)
if len(missing) == 0 {
@@ -199,11 +200,27 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
intent := router.Intent(q.Intent)
answer := h.extractor.Extract(ctx, intent, text, h.now())
merged := q.Answer(text, toDialogueSlots(answer))
// Fold a newly answered subject into the raw utterance. Downstream actions
// phrase from Utterance, not from the text slot — actionReminder stores it
// as the reminder payload — so a reminder clarified out of a bare "напомни"
// would fire at 11:00 saying "напомни" and nothing else.
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
return h.reaskOrGiveUp(q, merged, text), true
}
h.clarifyStore.Delete(voiceDialogueID)
// One gap filled is not the same as a complete request. askClarify parks
// only the first gap, because one question per turn is the rule, but a
// reminder wants both a subject and a time. "напомни" with neither used to
// ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction
// a reminder with no time, which answered "не получилось разобрать время
// напоминания." — an error for a request she never finished asking about.
// Re-enter the loop instead, one question at a time as before.
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
return reply, true
}
// Rebuild the decision as if it had routed cleanly, then run it down the
// normal path. Clarify is deliberately false and the intent is unchanged:
// filling in an argument never grants authority, so the completed decision
@@ -218,6 +235,55 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
return h.finishClarified(ctx, dec), true
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
// unless they already carry it. "напомни" + "позвонить маме" reads as the
// request he would have made in one breath. Nothing is appended when the
// subject is empty or already present, so re-asking the same question twice
// cannot grow the utterance.
func foldAnswerIntoUtterance(utterance, subject string) string {
subject = strings.TrimSpace(subject)
if subject == "" || strings.Contains(utterance, subject) {
return utterance
}
if strings.TrimSpace(utterance) == "" {
return subject
}
return strings.TrimSpace(utterance) + " " + subject
}
// askRemainingGap re-parks the request when the answer closed one gap and
// wantedSlots still names another. Returns ("", false) when the request is
// complete, when there is no question for what is left, or when she is out of
// attempts — in all three the caller runs the decision as it stands, which for
// the out-of-attempts case is the old behaviour and is the right one: she has
// already asked enough.
//
// The attempt budget is shared with the re-ask path on purpose. A second gap
// costs a question exactly like a second try at the first one does, so the cap
// still bounds how many times she can speak before acting or letting go.
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
if len(remaining) == 0 {
return "", false
}
question, ok := clarifyQuestions[remaining[0]]
if !ok || !q.CanAsk() {
return "", false
}
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
Intent: q.Intent,
Slots: merged,
Missing: []dialogue.Slot{remaining[0]},
Utterance: q.Utterance,
Asked: h.now(),
TTL: clarifyTTL,
Attempts: q.Attempts + 1,
MaxAttempts: q.MaxAttempts,
})
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
return question, true
}
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
// again while she has attempts left, otherwise say she did not understand and
// let the request go. Never returns "" — a mute give-up reads as "done".
+135
View File
@@ -10,6 +10,7 @@ import (
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser/eval"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
@@ -330,3 +331,137 @@ func TestNoPendingQuestionFallsThrough(t *testing.T) {
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
}
}
// TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a
// time. She asks about the subject, he gives it, and the request is still not
// complete. The old code handed applyAction a reminder with no time, which
// answered with a parse error for a question she never asked.
func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
if !handled {
t.Fatal("the answer must be consumed as an answer")
}
if reply != "Когда?" {
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
}
q := h.clarifyStore.Get(voiceDialogueID, h.now())
if q == nil {
t.Fatal("the second gap must leave a question armed")
}
if q.Slots.Text == "" {
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
}
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
}
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
if err != nil || len(reminders) != 1 {
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
}
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload)
}
}
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
// out of the same budget, so it cannot turn a capped exchange into an endless
// one. With one attempt allowed she acts on what she has instead of asking.
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
ctx := context.Background()
h, _, _ := newClarifyHandler(t)
h.clarifyMaxAttempts = 1
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
t.Fatal("expected the subject question")
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
if !handled {
t.Fatal("the answer must be consumed")
}
if reply == "Когда?" {
t.Fatal("out of attempts she must not ask a second question")
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Fatal("no question may stay armed past the cap")
}
}
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
// the phrasing eval never sees, because they never go through the phraser. They
// carry feminine self-reference ("ждала", "отпустила") and address him with a
// plain imperative, and they are exactly the kind of string someone later edits
// reaching for a synonym. Run the eval's own persona checks over them here.
func TestClarifyProseHoldsThePersona(t *testing.T) {
// Only the persona checks. Length and on-topic do not apply: these are not
// nudges, they have no rule to be on topic about, and the expiry lines are
// deliberately longer than a nudge ceiling.
want := map[string]bool{
eval.CheckFeminine: true,
eval.CheckHisGender: true,
eval.CheckAddress: true,
eval.CheckCringe: true,
}
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
for _, q := range clarifyQuestions {
lines = append(lines, q)
}
for _, line := range lines {
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
// The apology clause of the cringe check is scoped to nudges: it
// exists because apologising for a greenlit nudge undermines it.
// These lines are the opposite case. She did not understand him, or
// she let his request go, and "прости" there is ordinary speech
// rather than grovelling. Every other cringe rule still applies:
// pet names, emoji, exclamations, fake concern, praise.
// checkCringe returns the first break it finds, so this skip also
// hides a later one in the same line. Kept narrow on purpose: it
// only fires on a leading "apology (…)" detail.
if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") {
continue
}
if want[r.Name] && !r.Pass {
t.Errorf("%q fails %s: %s", line, r.Name, r.Detail)
}
}
}
}
// TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the
// question expires, he comes back and answers a confirm that is still parked.
// The confirm turn used to return before the notice was even computed, so he
// answered the confirm and never heard that the older request was let go.
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
ctx := context.Background()
h, _, now := newClarifyHandler(t)
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question")
}
// A confirm parked with a longer life than the question, so only the
// question is stale when he speaks.
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
*now = now.Add(clarifyTTL + time.Second)
reply := h.handleText(ctx, "нет")
if !isClarifyExpired(reply) {
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
}
if trimClarifyExpired(reply) == "" {
t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply)
}
if h.pending != nil {
t.Fatal("the confirm must still have been consumed")
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Fatal("the expired question must be gone")
}
}
+44 -9
View File
@@ -20,6 +20,8 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"time"
@@ -39,21 +41,36 @@ func newCrawler(cfg *config.Config) *crawl.Crawler {
return nil
}
cc := cfg.Crawl
// The WATCH crawler, and only it, reaches the watched hosts. webfetch reads
// a non-empty allow list as "these and nothing else", so folding the watch
// hosts in turned a single watch into an allowlist for everything: a config
// with one watch and on_demand true silently refused every other page he
// pasted, with "не получилось прочитать страницу." and no clue why.
return crawlerWithHosts(cc, crawlHosts(cc, true))
}
// crawlHosts — the allowlist for one of the two crawlers. forWatches adds the
// watched pages' own hosts, so a watch does not have to be allowlisted by hand.
//
// The on-demand crawler gets his allow_hosts and nothing else. webfetch reads a
// non-empty list as "these and nothing else", so adding the watch hosts there
// would silently narrow on-demand reading to the watched sites.
func crawlHosts(cc *config.CrawlConfig, forWatches bool) []string {
hosts := append([]string(nil), cc.AllowHosts...)
// A watched page's own host is always reachable; otherwise an allowlist and
// a watch list would have to be kept in sync by hand.
if !forWatches {
return hosts
}
for _, w := range cc.Watches {
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
hosts = append(hosts, u.Hostname())
}
}
// An allowlist plus on-demand is a contradiction worth logging rather than
// silently resolving: he asked for arbitrary pages AND for a fixed list.
// The allowlist wins, because it is the narrower instruction.
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
}
return hosts
}
// crawlerWithHosts builds a crawler over one allowlist. Two callers, two lists:
// see newCrawler and onDemandCrawler.
func crawlerWithHosts(cc *config.CrawlConfig, hosts []string) *crawl.Crawler {
ua := cc.UserAgent
if ua == "" {
ua = webfetch.DefaultUserAgent
@@ -81,7 +98,14 @@ func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
return nil
}
return newCrawler(cfg)
cc := cfg.Crawl
// His own allow_hosts, and nothing added behind his back. Empty means "any
// host that is not denied and not private", which is what on-demand reading
// of a URL he just said out loud has to mean.
if len(cc.AllowHosts) > 0 {
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those %d host(s)", len(cc.AllowHosts))
}
return crawlerWithHosts(cc, crawlHosts(cc, false))
}
// crawlWorker — ticker + watcher for the scheduled half.
@@ -137,9 +161,20 @@ func (w *crawlWorker) run(ctx context.Context) {
// net/http out of the crawler package.
type crawlFetcher struct{ f *webfetch.Fetcher }
// Get maps webfetch's sentinels onto crawl's. This adapter is the one place
// that imports both packages, so the mapping belongs here; the crawler used to
// match on three substrings of a message it could not see the definition of,
// and a reworded error would have quietly turned a blocked host into "there is
// no robots.txt here".
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
resp, err := a.f.Get(ctx, u)
if err != nil {
switch {
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
case errors.Is(err, webfetch.ErrStatus):
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
}
return nil, err
}
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
+63 -9
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -13,6 +14,7 @@ import (
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webfetch"
)
// The default config reads nothing. This is the whole "off unless configured"
@@ -123,16 +125,13 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
}
}
// Not configured is said out loud rather than falling through, so a small model
// never invents a page's contents from its URL.
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
// A daemon where page reading was never turned on — the default — answers the
// question the way it did before the capability existed. Claiming the turn to
// report a configuration status is for something that exists and failed.
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil)
reply, ok := askWeb(h, "посмотри https://example.org/page")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
if !strings.Contains(reply, "не настроено") {
t.Errorf("reply = %q, want the not-configured answer", reply)
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
}
}
@@ -184,3 +183,58 @@ func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, erro
}
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
}
// TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist — the on-demand crawler
// used to be built over allow_hosts PLUS every watched host. webfetch reads a
// non-empty allow list as "these and nothing else", so one watch on a config
// with no allow_hosts at all turned unrestricted on-demand reading into
// "the watched site only", and every other URL he pasted came back as
// "не получилось прочитать страницу." with nothing in the log to explain it.
func TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist(t *testing.T) {
cc := &config.CrawlConfig{
OnDemand: true,
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://watched.example/p"}},
}
if got := crawlHosts(cc, false); len(got) != 0 {
t.Errorf("on-demand allowlist = %v; a watch is not an allowlist entry, and an empty list is what means \"anything public\"", got)
}
if got := crawlHosts(cc, true); len(got) != 1 || got[0] != "watched.example" {
t.Errorf("watch allowlist = %v; want the watched host so a watch needs no hand-written entry", got)
}
// With allow_hosts set, his list is what on-demand gets, unchanged.
cc.AllowHosts = []string{"wiki.example"}
on := crawlHosts(cc, false)
if len(on) != 1 || on[0] != "wiki.example" {
t.Errorf("on-demand allowlist = %v; want exactly his allow_hosts", on)
}
if got := crawlHosts(cc, true); len(got) != 2 {
t.Errorf("watch allowlist = %v; want his hosts plus the watched one", got)
}
}
// TestCrawlFetcherReportsARefusalAsARefusal — internal/crawl cannot import
// webfetch, so it used to recognise a guard refusal by matching substrings of
// webfetch's message text. This adapter owns both packages and is where the
// translation belongs.
func TestCrawlFetcherReportsARefusalAsARefusal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusBadGateway)
}))
defer srv.Close()
blocked := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"wiki.example"}})}
if _, err := blocked.Get(context.Background(), "https://other.example/a"); !errors.Is(err, crawl.ErrFetchRefused) {
t.Errorf("a host outside allow_hosts = %v; want crawl.ErrFetchRefused", err)
}
if _, err := blocked.Get(context.Background(), "file:///etc/passwd"); !errors.Is(err, crawl.ErrFetchRefused) {
t.Errorf("a non-http scheme = %v; want crawl.ErrFetchRefused", err)
}
// A 5xx is a different thing: the server answered, badly. robots.txt over
// this must refuse the crawl rather than read it as "no rules".
open := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"127.0.0.1"}, AllowPrivate: true})}
if _, err := open.Get(context.Background(), srv.URL+"/robots.txt"); !errors.Is(err, crawl.ErrFetchStatus) {
t.Errorf("a 502 = %v; want crawl.ErrFetchStatus", err)
}
}
+129 -3
View File
@@ -2,13 +2,16 @@ package main
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// planAPI answers only DayPlan; every other call is unimplemented, which is
@@ -85,12 +88,40 @@ func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
}
}
// "что дальше?" after the last item of the day. The day was not empty, it is
// over, and the whole-day empty line says something false about a day he just
// lived through.
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
plan := samplePlan()
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
}}
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if strings.Contains(reply, plan.Date.Format("02.01.2006")) {
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
}
if reply != "на сегодня больше ничего не запланировано." {
t.Errorf("reply = %q", reply)
}
}
// A question that is not about the plan must fall through, or the plan buries
// the calendar listing and the weather behind it.
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
for _, q := range []string{
"что у меня сегодня?",
"какие планы на завтра?",
// The plan can only be built for the clock's own day. Naming another
// one has to fall through, not get answered with today.
"какие планы на понедельник?",
"какие планы на неделю?",
"какие планы на выходные?",
"what are my plans for friday?",
"когда планёрка?",
"какая погода?",
"",
@@ -142,17 +173,21 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
}
}
// habitAPI answers only RecentFacts — the whole input the behaviour profile
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
// habitAPI answers only the kind-filtered fact read — the whole input the
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
// profile must not read the mixed window, and a caller that does fails here.
type habitAPI struct {
ipc.UnimplementedCoreAPI
facts []ipc.Fact
err error
calls int
kind string
}
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
a.calls++
a.kind = kind
return a.facts, a.err
}
@@ -225,3 +260,94 @@ func TestHabitSourcePrecedesCalendar(t *testing.T) {
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
}
}
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
// so it must be spent on the rows the profile can use. Reading the mixed table
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
// every tap out of the window, and she then reported no habits on a store that
// held them.
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
now := planDay()
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
if _, ok := h.queryHabits(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
}); !ok {
t.Fatal("the habit source must claim a habit question")
}
if api.kind != string(store.KindSelf) {
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
}
}
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
// matchers: a habit question carrying "планы" used to be answered by the day
// plan with today's calendar, because day-plan sits above habits.
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
now := planDay()
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
reply := h.actionQuery(context.Background(), router.Decision{
Intent: router.IntentQuery,
Utterance: "какие у меня обычно планы по вторникам?",
})
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
t.Errorf("reply = %q, want %q", reply, want)
}
}
// The plan reads the store on the owner's clock: one line per event, the hour
// printed once, and reminders selected by fire time rather than by how
// recently they were stated.
func TestTickDayPlanReadsTheStore(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
ev := calendar.Event{
Summary: "Standup",
Start: day.Add(14 * time.Hour),
End: day.Add(14*time.Hour + 30*time.Minute),
}
// Rescheduled: same key, a second row.
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFact: %v", err)
}
moved := ev
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFact: %v", err)
}
// One reminder today, one next year. Both are pending; only today's is a
// plan for today.
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
plan := tl.dayPlan(ctx, now)
if len(plan.Items) != 2 {
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
}
ev0 := plan.Items[0]
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
t.Errorf("event = %+v, want the 16:00 one", ev0)
}
if ev0.Text != "Standup" {
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
}
if plan.Items[1].Text != "позвонить маме" {
t.Errorf("second item = %+v", plan.Items[1])
}
if strings.Contains(plan.Spoken, "страховку") {
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
}
}
+152 -57
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -31,18 +32,84 @@ func correlationIDFromCtx(ctx context.Context) string {
return id
}
// setEcosystemHeaders stamps the version and correlation headers common to
// every outgoing ecosystem request.
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader string) {
// ecosystemAPIVersion is the contract version Maven speaks to Nexus and
// Praxis. It is sent on every request so a service that has moved on can
// refuse or adapt explicitly instead of misreading an older payload.
const ecosystemAPIVersion = "v1"
// mavenRequester identifies the calling system on every ecosystem request, so
// a trace on the far side can attribute a call to Maven rather than to an
// anonymous HTTP client.
const mavenRequester = "maven"
// setEcosystemHeaders stamps the version, requester, auth and correlation
// headers common to every outgoing ecosystem request. token may be empty,
// which means the transport itself is trusted (loopback or unix socket).
//
// The correlation ID is read from the context and never minted here. Minting
// one per request sent the far side an ID that existed nowhere on this side,
// and gave a single multi-hop action as many unrelated IDs as it made calls.
// Callers that start an action assign the ID once (handleHexisAct,
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(versionHeader, "v1")
req.Header.Set(versionHeader, ecosystemAPIVersion)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Requested-By", mavenRequester)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if id := correlationIDFromCtx(ctx); id != "" {
req.Header.Set("X-Correlation-ID", id)
}
}
// ecosystemError is the typed failure every ecosystem client returns, so
// callers can tell a transport failure from a refusal from a contract
// mismatch without matching on message text. The distinction matters:
// "the service is down" and "the service rejected my version" degrade the
// same way to the user but not to whoever reads the trace.
type ecosystemError struct {
Service string // "nexus", "praxis", "hexis"
Op string // logical operation, e.g. "resolve"
Status int // HTTP status, 0 when the call never got an answer
Err error
}
func (e *ecosystemError) Error() string {
if e.Status != 0 {
return fmt.Sprintf("%s %s: http %d: %v", e.Service, e.Op, e.Status, e.Err)
}
return fmt.Sprintf("%s %s: %v", e.Service, e.Op, e.Err)
}
func (e *ecosystemError) Unwrap() error { return e.Err }
// Unauthorized reports a rejected or missing credential.
func (e *ecosystemError) Unauthorized() bool {
return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden
}
// ContractMismatch reports that the far side refused the version Maven speaks.
func (e *ecosystemError) ContractMismatch() bool {
return e.Status == http.StatusNotAcceptable || e.Status == http.StatusUpgradeRequired
}
// Unreachable reports a call that never produced an HTTP answer at all
// (connection refused, timeout, cancelled).
func (e *ecosystemError) Unreachable() bool { return e.Status == 0 }
// httpError builds an ecosystemError from a response status.
func httpError(service, op string, status int) *ecosystemError {
return &ecosystemError{
Service: service, Op: op, Status: status,
Err: errors.New(http.StatusText(status)),
}
}
type nexusClient struct {
baseURL string
token string
httpClient *http.Client
}
@@ -53,6 +120,13 @@ func newNexusClient(url string) *nexusClient {
}
}
// withToken sets the bearer token sent on every request. Returns the client so
// wiring reads as one expression.
func (c *nexusClient) withToken(token string) *nexusClient {
c.token = token
return c
}
type nexusEntity struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -107,22 +181,22 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode))
return nil, httpError("nexus", "resolve", resp.StatusCode)
}
var result nexusResolveResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
}
return &result, nil
}
@@ -130,16 +204,16 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
func (c *nexusClient) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return err
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
}
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode))
return httpError("nexus", "health", resp.StatusCode)
}
return nil
}
@@ -149,6 +223,7 @@ func (c *nexusClient) Health(ctx context.Context) error {
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
type praxisClient struct {
baseURL string
token string
httpClient *http.Client
}
@@ -159,27 +234,38 @@ func newPraxisClient(url string) *praxisClient {
}
}
// getJSON performs a GET and decodes the JSON body into out.
func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error {
func (c *praxisClient) withToken(token string) *praxisClient {
c.token = token
return c
}
// getJSON performs a GET and decodes the JSON body into out. op is the logical
// operation name for errors and traces: the path carries the query string, and
// after entity scoping that means an entity id in every log line built from the
// error, next to a trace that redacts far less than that.
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
return &ecosystemError{Service: "praxis", Op: op, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode))
return httpError("praxis", op, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
}
return nil
}
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
return out, err
}
@@ -189,13 +275,14 @@ func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[stri
// 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
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
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
}
func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
return out, err
}
@@ -221,24 +308,32 @@ type praxisItem struct {
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string) (*praxisItem, error) {
body, _ := json.Marshal(map[string]any{"item_id": itemID})
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
}
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
// Every failure is a *ecosystemError, including the transport and decode ones:
// these are the paths that mutate remote state, and the question worth
// answering afterwards is whether the call never left or was refused.
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode))
return nil, httpError("praxis", op, resp.StatusCode)
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
}
return &out, nil
}
@@ -247,46 +342,28 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/surface", itemID)
return c.postItemAction(ctx, "surface", "/api/v1/tools/surface", itemID)
}
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
return c.postItemAction(ctx, "acknowledge", "/api/v1/tools/acknowledge", itemID)
}
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
return c.postItemAction(ctx, "resolve", "/api/v1/tools/resolve", itemID)
}
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
}
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
if err != nil {
return nil, err
}
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode))
}
var out praxisItem
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
}
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
var out praxisItem
err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out)
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
if err != nil {
return nil, err
}
@@ -295,7 +372,7 @@ func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem,
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
var out []praxisItem
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
return out, err
}
@@ -311,7 +388,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
// Nexus identity service
if cfg.Nexus != nil && cfg.Nexus.URL != "" {
w.nexus = newNexusClient(cfg.Nexus.URL)
w.nexus = newNexusClient(cfg.Nexus.URL).withToken(cfg.Nexus.Token)
log.Printf("ecosystem: nexus at %s", cfg.Nexus.URL)
} else {
log.Printf("ecosystem: nexus not configured")
@@ -319,7 +396,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
// Hexis capability service
if cfg.Hexis != nil && cfg.Hexis.URL != "" {
w.hexis = hexisclient.New(cfg.Hexis.URL)
w.hexis = hexisclient.New(cfg.Hexis.URL).WithToken(cfg.Hexis.Token)
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
} else {
log.Printf("ecosystem: hexis not configured")
@@ -327,7 +404,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
// Praxis attention service (HTTP tools API — never the DB directly)
if cfg.Praxis != nil && cfg.Praxis.URL != "" {
w.praxis = newPraxisClient(cfg.Praxis.URL)
w.praxis = newPraxisClient(cfg.Praxis.URL).withToken(cfg.Praxis.Token)
log.Printf("ecosystem: praxis at %s", cfg.Praxis.URL)
} else {
log.Printf("ecosystem: praxis not configured")
@@ -352,7 +429,19 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
log.Printf("ecosystem: nexus resolve error: %v", err)
return "", "", nil, err
}
if result.Status == "resolved" && result.Entity != nil {
if result.Status == "resolved" {
// "resolved" with nothing to resolve to is a contract violation, not a
// miss. Treating it as "no such entity" let the caller fall straight
// through to the local executor with his verb intact, which is a
// dependency failure reaching execution.
if result.Entity == nil || result.Entity.ID == "" {
err := &ecosystemError{
Service: "nexus", Op: "resolve", Status: 200,
Err: errors.New("resolved status with no entity"),
}
log.Printf("ecosystem: %v", err)
return "", "", nil, err
}
return result.Entity.ID, result.Entity.DisplayName, nil, nil
}
if result.Status == "ambiguous" {
@@ -372,6 +461,12 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
// healthy and genuinely has nothing registered for this entity. Callers must
// not conflate the two: a dependency failure must not silently read as "no
// capabilities" and fall through to unrelated local execution.
//
// The correlation header is stamped in the client's do(), so discovery and
// execution can be joined on the Hexis side as long as both hops carry the
// same ID through ctx. (This used to say the header went out on Execute only;
// that was never true of the vendored code and is not true after the 2026-08-01
// re-vendor.)
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
if w == nil || w.hexis == nil || entityID == "" {
return nil, nil
+349 -27
View File
@@ -2,14 +2,15 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
@@ -72,6 +73,7 @@ var praxisCapabilities = []praxisCapability{
},
},
listChangesCapability{},
entityAttentionCapability{},
}
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
@@ -81,6 +83,12 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
if h.ecosystem == nil || h.ecosystem.praxis == nil {
return ""
}
// Every hop of this action shares one correlation ID, assigned here, so a
// digest that calls attention once and surface N times reads as one turn
// on the Praxis side instead of N+1 unrelated request ids.
if correlationIDFromCtx(ctx) == "" {
ctx = withCorrelationID(ctx, newCorrelationID())
}
px := h.ecosystem.praxis
for _, capability := range praxisCapabilities {
for _, alias := range capability.aliases() {
@@ -111,11 +119,14 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
if id == "" {
return a.ask
}
started := h.now()
if err := a.call(ctx, px, id); err != nil {
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
return a.failure
}
h.recordPraxisTrace(ctx, a.op, map[string]any{"item_id": id})
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
return a.success
}
@@ -127,15 +138,18 @@ 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)
if err != nil {
log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err))
return "не могу сейчас узнать, что требует внимания."
}
if len(items) == 0 {
return "ничего не требует внимания."
}
h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)})
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string
for _, item := range items {
title, _ := item["title"].(string)
@@ -172,15 +186,18 @@ func (listChangesCapability) aliases() []string {
}
func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
started := h.now()
changes, err := px.ListChanges(ctx, 20)
if err != nil {
log.Printf("ecosystem: praxis changes: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
started, traceErrorFields(err))
return "не могу сейчас узнать об изменениях."
}
if len(changes) == 0 {
return "нет изменений."
}
h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)})
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
var parts []string
for _, c := range changes {
title, _ := c["title"].(string)
@@ -190,25 +207,294 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
return "изменения: " + strings.Join(parts, "; ")
}
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
// The fact is stored with source "praxis:trace" so the proactive loop can
// reference it and the dashboard can display recent ecosystem activity.
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
now := h.now()
value := operation
if len(details) > 0 {
if b, err := json.Marshal(details); err == nil {
value = operation + " " + string(b)
// entityAttentionCapability answers "what's going on with X" by resolving X to
// a canonical Nexus entity and asking Praxis for that entity's attention items
// (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id
// travels to Praxis as a query parameter instead of Maven filtering an unscoped
// list client-side, which is what makes the ref canonical end to end.
//
// It also folds in what Maven herself knows about the same entity — facts the
// enrichment worker has already resolved to that entity_id — so one question
// gets one answer across both stores.
type entityAttentionCapability struct{}
// aliases are matched against Slots.Fn, which carries a function slot from the
// act grammar and never free Russian, so only grammar names belong here.
func (entityAttentionCapability) aliases() []string {
return []string{"entity_attention", "entity_status"}
}
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
subject := dec.Slots.Value
if subject == "" {
subject = dec.Slots.Text
}
if subject == "" {
return "про что именно спросить?"
}
if h.ecosystem == nil || h.ecosystem.nexus == nil {
// Without Nexus there is no canonical ref to scope by. Say so rather
// than quietly answering about something else.
return "не могу связать это с сущностью — Nexus не настроен."
}
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
if err != nil {
// The subject is his words, so the log gets the same redaction the
// trace gets. A trace that stores a rune count next to a log line
// storing the runes is not redacted at all.
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
return "экосистема недоступна, попробуй ещё раз."
}
if len(ambiguous) > 0 {
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
}
if entityID == "" {
return "не знаю такой сущности."
}
if displayName == "" {
displayName = subject
}
queried := h.now()
items, 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 "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
}
items, scoped := scopedToEntity(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 "по
// «X»: ..." is the exact fabrication the entity ref exists to prevent,
// so refuse the answer instead of relabelling someone else's items.
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
}
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
"entity_id": entityID, "count": len(items),
})
var parts []string
for _, item := range items {
title, _ := item["title"].(string)
if title == "" {
continue
}
parts = append(parts, title)
// Same surfaced != acknowledged rule as the unscoped digest.
if id, ok := item["id"].(string); ok && id != "" {
if _, err := px.Surface(ctx, id); err != nil {
log.Printf("ecosystem: praxis surface %s: %v", id, err)
}
}
}
_, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: now,
Kind: "system",
Key: "praxis:" + operation,
Value: value,
Source: "praxis:trace",
Confidence: 1.0,
})
if known := h.localFactsForEntity(ctx, entityID); known != "" {
parts = append(parts, known)
}
if len(parts) == 0 {
return "по «" + displayName + "» ничего нет."
}
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
}
// scopedToEntity drops items that carry an entity_id other than the one asked
// about, and reports whether the response can be trusted as scoped at all. An
// item without an entity_id is kept only when at least one sibling carries the
// matching id: a whole page with no entity_id is a Praxis that ignored the
// scope, not a page of untagged items.
func scopedToEntity(items []map[string]any, entityID string) ([]map[string]any, bool) {
if len(items) == 0 {
return items, true
}
var kept []map[string]any
var sawMatch, sawMismatch bool
for _, item := range items {
id, _ := item["entity_id"].(string)
switch {
case id == entityID:
sawMatch = true
kept = append(kept, item)
case id != "":
sawMismatch = true
default:
kept = append(kept, item)
}
}
if sawMatch {
return kept, true
}
if sawMismatch {
// Some items were tagged and none matched: the far side answered about
// other entities, so nothing here belongs to this one.
return nil, true
}
return nil, false
}
// localFactsForEntity summarises Maven's own facts already resolved to this
// canonical entity. Empty when the store is unavailable or nothing matched —
// entity-scoped memory is an enrichment of the answer, never a precondition.
func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string {
if h.dataStore == nil || entityID == "" {
return ""
}
const spoken = 3
// One over the spoken limit, so a truncation can be named rather than
// passed off as everything she knows.
facts, err := h.dataStore.FactsByEntity(ctx, entityID, spoken+1)
if err != nil {
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
return ""
}
more := false
if len(facts) > spoken {
facts, more = facts[:spoken], true
}
var parts []string
for _, f := range facts {
if f.Value != "" {
parts = append(parts, f.Value)
}
}
if len(parts) == 0 {
return ""
}
out := "я помню: " + strings.Join(parts, ", ")
if more {
out += ", и это не всё"
}
return out
}
// mergeFields overlays b onto a and returns a.
func mergeFields(a, b map[string]any) map[string]any {
for k, v := range b {
a[k] = v
}
return a
}
// recordPraxisTrace — records a completed Praxis call. Thin wrapper over
// recordEcosystemTrace so every ecosystem hop lands in one table with one
// shape.
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, started time.Time, details map[string]any) {
h.recordEcosystemTrace(ctx, "praxis", operation, traceOK, started, details)
}
// traceStatus classifies an ecosystem call for the trace record. Kept coarse
// on purpose: a trace is read to answer "did this hop work, and how long did
// it take", not to re-derive the error.
const (
traceOK = "ok"
traceFailed = "failed" // the call never got an answer
traceRefused = "refused" // the far side answered, and said no
traceAmbig = "ambiguous"
traceNotFound = "not_found"
tracePending = "pending" // deliberately not done yet, awaiting a confirm
)
// traceStatusForError distinguishes "I could not reach it" from "it answered
// and refused". Both degrade the same way for him and not at all the same way
// for whoever reads the trace: one is a network or a dead service, the other
// is a token, a version or a rejected argument.
func traceStatusForError(err error) string {
var ee *ecosystemError
if errors.As(err, &ee) && !ee.Unreachable() {
return traceRefused
}
return traceFailed
}
// redactSubject reduces a user utterance to something safe to persist in a
// trace: its length only. Traces are diagnostics, and his words are not
// diagnostics — the correlation ID is what ties a trace to the turn.
func redactSubject(s string) string {
return fmt.Sprintf("<%d chars>", len([]rune(s)))
}
// recordEcosystemTrace writes one hop of a cross-service call: which service,
// which operation, the outcome, how long it took, and the correlation ID that
// stitches the hops together. It is written for every outcome, not only
// success — an unrecorded failure is exactly the hop you need when something
// went wrong at 3am.
//
// Traces go to their own store table, never to facts. One act turn produces
// three or four of them, at machine rate, while facts arrive at human rate:
// sharing the table meant the habit profile's 2000-row window, memeval's
// prompt snapshot and the /dash and /history pages all filled with traces and
// stopped seeing his actual facts.
func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) {
if h.dataStore == nil {
return
}
tr := store.EcosystemTrace{
Ts: h.now(),
Service: service,
Operation: op,
Status: status,
DurationMs: h.now().Sub(started).Milliseconds(),
CorrelationID: correlationIDFromCtx(ctx),
Fields: map[string]any{},
}
for k, v := range fields {
switch k {
case "causation_id":
tr.CausationID, _ = v.(string)
case "http_status":
if n, ok := v.(int); ok {
tr.HTTPStatus = n
continue
}
tr.Fields[k] = v
default:
tr.Fields[k] = v
}
}
if _, err := h.dataStore.WriteEcosystemTrace(ctx, tr); err != nil {
log.Printf("ecosystem: record trace %s:%s: %v", service, op, err)
}
}
// unauthorizedEcosystemError reports a credential the far side rejected. It
// gets its own reply: a missing or wrong token looks exactly like an outage to
// him, and "try again" is advice that will never work.
func unauthorizedEcosystemError(err error) bool {
var ee *ecosystemError
return errors.As(err, &ee) && ee.Unauthorized()
}
// traceErrorFields describes an ecosystemError for a trace without leaking the
// payload: the HTTP status and the failure class, nothing else.
func traceErrorFields(err error) map[string]any {
fields := map[string]any{}
var ee *ecosystemError
if errors.As(err, &ee) {
fields["http_status"] = ee.Status
switch {
case ee.Unauthorized():
fields["class"] = "unauthorized"
case ee.ContractMismatch():
fields["class"] = "contract_mismatch"
case ee.Unreachable():
fields["class"] = "unreachable"
default:
fields["class"] = "error"
}
return fields
}
fields["class"] = "error"
return fields
}
// handleHexisAct — resolves entity references through Nexus and executes
@@ -219,10 +505,23 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
return ""
}
// Every hop of this action shares one correlation ID, assigned here so
// resolution and discovery are traceable even when execution never
// happens.
if correlationIDFromCtx(ctx) == "" {
ctx = withCorrelationID(ctx, newCorrelationID())
}
// Resolve the utterance text as an entity reference through Nexus. An
// ambiguous match must stop and clarify — never guess a mutation target.
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
// A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade
@@ -230,19 +529,33 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
return "экосистема недоступна, попробуй ещё раз."
}
if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
map[string]any{"candidates": len(ambiguous)})
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
map[string]any{"subject": redactSubject(dec.Slots.Text)})
return ""
}
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
map[string]any{"entity_id": entityID})
// Discover Hexis capabilities for this entity. A resolved entity with a
// genuine Hexis failure must not be treated as "no capabilities" and
// fall through to unrelated local execution.
discovered := h.now()
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
if err != nil {
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
return "экосистема недоступна, попробуй ещё раз."
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)})
if len(caps) == 0 {
return ""
}
@@ -287,6 +600,8 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
expiry: h.now().Add(confirmTTL),
}
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
map[string]any{"entity_id": entityID, "capability": matched.Name})
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
}
@@ -297,16 +612,23 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
// the correlation ID. It reports command success, never operational recovery
// (Praxis observes recovery independently).
func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string {
started := h.now()
causationID := correlationIDFromCtx(ctx)
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
traced := withCorrelationID(ctx, correlationID)
if err != nil {
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
h.recordEcosystemTrace(traced, "hexis", "execute", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
}))
return "не получилось выполнить команду для " + displayName + "."
}
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
"entity_id": entityID,
"entity_name": displayName,
"capability": capName,
"correlation_id": correlationID,
// One record per hop: the second write this used to make said the same
// thing under a different key, in a different shape.
h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{
"entity_id": entityID, "entity_name": displayName,
"capability": capName, "causation_id": causationID,
})
return "команда выполнена для " + displayName + "."
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/kami/maven/internal/config"
)
// TestWireEcosystem_HexisToken — a configured Hexis token reaches the wire.
//
// This is the regression that closes the 2026-08-01 re-vendor. The copy of
// github.com/kami/hexis checked into vendor/ used to predate Client.WithToken,
// so a configured token could not be sent at all; wireEcosystem refused to wire
// Hexis rather than execute unauthenticated. Both halves of that are gone. The
// test asserts the outcome the refusal was standing in for: the header goes
// out, so nobody has to trust a boot log to know auth is on.
func TestWireEcosystem_HexisToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL, Token: "s3cret"}}
w := wireEcosystem(cfg)
if w.hexis == nil {
t.Fatal("hexis not wired with a token configured")
}
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
t.Fatalf("discoverCapabilities: %v", err)
}
if want := "Bearer s3cret"; gotAuth != want {
t.Errorf("Authorization = %q; want %q", gotAuth, want)
}
}
// TestWireEcosystem_HexisNoToken — no token configured still wires, unauthed.
// Hexis without auth is a valid deployment on a trusted box, and the re-vendor
// must not have turned the token into a requirement.
func TestWireEcosystem_HexisNoToken(t *testing.T) {
var sawAuth bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization") != ""
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL}}
w := wireEcosystem(cfg)
if w.hexis == nil {
t.Fatal("hexis not wired without a token")
}
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
t.Fatalf("discoverCapabilities: %v", err)
}
if sawAuth {
t.Error("Authorization header sent with no token configured")
}
}
+468
View File
@@ -0,0 +1,468 @@
package main
import (
"context"
"strings"
"testing"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/store"
)
// Phase-5 hardening suite (Vikunja #276). Everything here drives the shared
// fake ecosystem (fakeecosystem_test.go) rather than one-off inline handlers,
// so the same fault levers — SetFault, SetBody, SetDelay — cover every
// service. What is asserted is the degraded-mode contract:
//
// - services degrade independently: one outage never mutes the others,
// - a degraded reply is never silent, never fabricated, never "success",
// - contract drift (old shape, unknown fields, garbage) is survivable,
// - Maven never acts on an ambiguous target and never chains
// Praxis observation into Hexis execution on its own.
// ecoHandler wires a handler against whichever of the three fakes is given
// (pass nil to leave a service unconfigured, which is a different state from
// "configured but down").
func ecoHandler(t *testing.T, nexus, praxis, hexis *fakeServer) *reactiveHandler {
t.Helper()
st := newTestStore(t)
clock := newTickingClock(time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), time.Millisecond)
w := &ecosystemWiring{}
if nexus != nil {
w.nexus = newNexusClient(nexus.URL)
}
if praxis != nil {
w.praxis = newPraxisClient(praxis.URL)
}
if hexis != nil {
w.hexis = hexisclient.New(hexis.URL)
}
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: clock.Now,
ecosystem: w,
}
}
// traces reads the ecosystem trace table. Traces live there and not in facts,
// so a bounded reader of facts never fills up with machine-rate rows.
func traces(t *testing.T, h *reactiveHandler) []store.EcosystemTrace {
t.Helper()
out, err := h.dataStore.RecentEcosystemTraces(context.Background(), 100)
if err != nil {
t.Fatalf("read traces: %v", err)
}
return out
}
// tracesFor returns the traces recorded for one service+operation.
func tracesFor(t *testing.T, h *reactiveHandler, service, op string) []store.EcosystemTrace {
t.Helper()
var out []store.EcosystemTrace
for _, tr := range traces(t, h) {
if tr.Service == service && tr.Operation == op {
out = append(out, tr)
}
}
return out
}
// restartCaps is a read-only capability. Restarting a service is a mutation,
// so the read-only one this suite runs through the happy paths is named for
// what it is; the mutating restart lives in the confirmation tests.
func restartCaps() string {
return fixtureHexisCapabilities(map[string]any{
"id": "cap_status", "name": "restart status", "read_only": true,
})
}
// TestEcosystem_OutagesLeaveNoSharedFailureState: the two act paths share a
// handler, a store and a clock, so what is worth asserting is that a failure
// on one leaves nothing behind that degrades the other. Faulting one disjoint
// call graph and exercising the other only tests the call graph.
func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
"id": "item_1", "title": "disk almost full", "importance": 3.0,
}))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, praxis, hexis)
// A Nexus outage during a Hexis act writes a failure trace, and a shared
// store is the one thing the Praxis path could inherit it through.
nexus.SetFault(503)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
t.Fatalf("nexus outage must not report success, got %q", reply)
}
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
t.Fatal("the failed resolve must be recorded")
}
nexus.SetFault(0)
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
if !strings.Contains(reply, "disk almost full") {
t.Fatalf("a recorded nexus failure must not degrade the praxis digest, got %q", reply)
}
if got := tracesFor(t, h, "praxis", "list_attention"); len(got) != 1 || got[0].Status != traceOK {
t.Fatalf("the praxis digest must trace its own success, got %+v", got)
}
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
praxis.SetFault(503)
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
t.Fatalf("praxis outage must not serve content, got %q", reply)
}
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
}
}
// TestEcosystem_OneEndpointDownDoesNotMuteTheService: real outages are usually
// partial. Attention answering while surface is down must still deliver.
func TestEcosystem_OneEndpointDownDoesNotMuteTheService(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
"id": "item_1", "title": "disk almost full", "importance": 3.0,
}))
h := ecoHandler(t, nil, praxis, nil)
praxis.SetRouteFault("/api/v1/tools/surface", 503)
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
if !strings.Contains(reply, "disk almost full") {
t.Fatalf("a downed surface endpoint must not mute the digest, got %q", reply)
}
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
t.Fatal("expected the surface attempt")
}
}
// TestEcosystem_ResolvedWithoutEntityFailsClosed: the contract violation that
// decodes cleanly. Nexus says "resolved" and delivers no entity; treating that
// as "no such entity" put the user's verb through to the local executor.
func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolvedEmpty())
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" {
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
}
if strings.Contains(reply, "выполнена") {
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
t.Fatal("hexis must not be contacted after a contract-violating resolve")
}
}
// TestEcosystem_RejectedCredentialSaysSo: 401 and 403 must not read as an
// outage. "Try again" is advice that never works for a misconfigured token.
func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
ctx := context.Background()
for _, status := range []int{401, 403} {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
nexus.SetFault(status)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !strings.Contains(reply, "токен") {
t.Fatalf("http %d must read as a credential problem, got %q", status, reply)
}
tr := tracesFor(t, h, "nexus", "resolve")
if len(tr) != 1 || tr[0].Status != traceRefused || tr[0].HTTPStatus != status {
t.Fatalf("http %d must trace as refused with its status, got %+v", status, tr)
}
}
}
// TestEcosystem_MalformedPraxisBodyDegrades: Praxis has the same decode path
// Nexus does, and a 200 carrying garbage there is a dependency failure too.
func TestEcosystem_MalformedPraxisBodyDegrades(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
"id": "item_1", "title": "disk almost full", "importance": 3.0,
}))
h := ecoHandler(t, nil, praxis, nil)
praxis.SetBody(`[{"title":`)
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
if reply == "" {
t.Fatal("a malformed praxis body must not answer with silence")
}
if strings.Contains(reply, "disk almost full") {
t.Fatalf("a malformed body must not produce content, got %q", reply)
}
}
// TestEcosystem_MalformedNexusResponseFailsClosed: a 200 carrying garbage is a
// dependency failure, not "no such entity". It must stop before Hexis.
func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
nexus.SetBody(`{"status":"resolved","entity":`)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" || strings.Contains(reply, "выполнена") {
t.Fatalf("malformed nexus body must degrade, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
t.Fatal("hexis must not be contacted after a malformed nexus response")
}
}
// TestEcosystem_UnknownContractFieldsTolerated: a newer Nexus adding fields
// must not break an older Maven. Same for the older flat resolve shape.
func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
ctx := context.Background()
for name, body := range map[string]string{
"future": fixtureNexusResolvedFuture("ent_muzick", "Muzick indexer", "service"),
"flat": fixtureNexusResolvedFlat("ent_muzick", "Muzick indexer", "service"),
} {
t.Run(name, func(t *testing.T) {
nexus := newFakeNexus(t, body)
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
}
})
}
}
// TestEcosystem_CancelledContextDegrades: a caller hanging up (turn abandoned,
// deadline hit) must surface as degradation, never as a fabricated result.
func TestEcosystem_CancelledContextDegrades(t *testing.T) {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
nexus.SetDelay(2 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" || strings.Contains(reply, "выполнена") {
t.Fatalf("cancelled resolve must degrade, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
t.Fatal("hexis must not be contacted after a cancelled resolve")
}
}
// TestEcosystem_ExecutionFailureIsNotSuccess: Hexis answering 200 with
// status=failed is a partial failure — the call worked, the command did not.
// Maven must report it as a failure and must not write a success trace.
func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if strings.Contains(reply, "выполнена") {
t.Fatalf("failed execution must not read as success, got %q", reply)
}
if reply == "" {
t.Fatal("failed execution must say something")
}
for _, tr := range tracesFor(t, h, "hexis", "execute") {
if tr.Status == traceOK {
t.Fatalf("failed execution must not write a success trace: %+v", tr)
}
}
}
// TestEcosystem_SuccessfulActionWritesATrace is the positive half the failure
// assertions above depend on: without it, "no success trace" passes with the
// trace writer deleted. It was, for a while — both writers used a fact kind the
// store's CHECK constraint rejects and the error was discarded.
func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
exec := tracesFor(t, h, "hexis", "execute")
if len(exec) != 1 || exec[0].Status != traceOK {
t.Fatalf("a successful execution must leave exactly one ok trace, got %+v", exec)
}
if exec[0].CorrelationID == "" {
t.Error("a trace with no correlation id cannot be stitched to anything")
}
}
// TestEcosystem_TracesStayOutOfFacts: traces are written at machine rate and
// facts at human rate. One act turn used to write four fact rows, which pushed
// his facts out of every bounded reader (the habit profile's window, memeval's
// prompt, /dash, /history).
func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
if len(traces(t, h)) == 0 {
t.Fatal("setup: expected traces")
}
facts, err := h.dataStore.RecentFacts(ctx, 100)
if err != nil {
t.Fatalf("read facts: %v", err)
}
if len(facts) != 0 {
t.Fatalf("an ecosystem act must write no facts at all, got %+v", facts)
}
}
// TestEcosystem_AmbiguousTargetBlocksExecution: ambiguity blocks mutation, and
// the clarification must name the candidates rather than pick one.
func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("muzick"))
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
t.Fatalf("ambiguous resolve must list candidates, got %q", reply)
}
if hexis.Count("POST", "/api/v1/execute") != 0 {
t.Fatal("ambiguous target must never execute")
}
}
// TestEcosystem_NoAutonomousPraxisToHexis: reading the attention digest is an
// observation. Maven must never turn an observed problem into a Hexis command
// by herself — she is not autonomous.
func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "muzick indexer is down", "importance": 4.0, "rule": "service_down"},
))
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, praxis, hexis)
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
if hexis.Count("", "/api/v1") != 0 {
t.Fatal("attention digest must not contact hexis on its own")
}
if nexus.Count("", "/api/v1/resolve") != 0 {
t.Fatal("attention digest must not resolve targets for autonomous action")
}
}
// TestEcosystem_MutatingCapabilityWaitsForConfirmation: a non-read-only
// capability parks for an explicit spoken confirm bound to capability+target.
func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("restart"))
if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") {
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
}
if hexis.Count("POST", "/api/v1/execute") != 0 {
t.Fatal("mutating capability must not execute before confirmation")
}
h.mu.Lock()
pending := h.pendingHexis
h.mu.Unlock()
if pending == nil || pending.capabilityID != "cap_restart" || pending.entityID != "ent_muzick" {
t.Fatalf("confirmation must be bound to capability+target, got %+v", pending)
}
}
// TestEcosystem_SurfaceFailureStillDelivers: surfacing is bookkeeping. If the
// surface call fails the digest must still be spoken — a partial failure
// downgrades bookkeeping, not the answer.
func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
))
praxis.SetRouteFault("/api/v1/tools/surface", 500)
h := ecoHandler(t, nil, praxis, nil)
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
if !strings.Contains(reply, "disk almost full") {
t.Fatalf("failed surface must not swallow the digest, got %q", reply)
}
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
t.Fatal("expected the surface attempt")
}
}
// TestEcosystem_TotalOutageSaysSoForEveryPath: with all three down, every
// entry point degrades explicitly instead of returning empty or inventing.
func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
for _, fs := range []*fakeServer{nexus, praxis, hexis} {
fs.SetFault(503)
}
h := ecoHandler(t, nexus, praxis, hexis)
for name, reply := range map[string]string{
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")),
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")),
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")),
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1")),
} {
if reply == "" {
t.Errorf("%s: total outage must not answer with silence", name)
}
if strings.Contains(reply, "выполнена") {
t.Errorf("%s: total outage must not claim success: %q", name, reply)
}
}
for _, tr := range traces(t, h) {
if tr.Status == traceOK {
t.Fatalf("a total outage must not leave success traces behind: %+v", tr)
}
}
if len(tracesFor(t, h, "praxis", "acknowledge")) == 0 {
t.Fatal("the acknowledge arm must reach praxis and record the refusal")
}
}
// TestEcosystem_RecoveryAfterOutageNeedsNoRestart: once the dependency comes
// back the very next turn works — no cached failure state, no restart.
func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
))
h := ecoHandler(t, nil, praxis, nil)
praxis.SetFault(503)
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
t.Fatalf("outage must not serve content, got %q", reply)
}
praxis.SetFault(0)
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
t.Fatalf("recovery must work on the next turn, got %q", reply)
}
}
+7
View File
@@ -19,6 +19,13 @@ func praxisActDec(fn string) router.Decision {
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
}
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
// in the value slot. Without one they answer "which item?" and never reach
// Praxis at all, which makes them useless for testing a Praxis outage.
func praxisItemDec(fn, itemID string) router.Decision {
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true, Value: itemID}}
}
func newPraxisTestHandler(t *testing.T, praxis *fakeServer) *reactiveHandler {
t.Helper()
st := newTestStore(t)
+5 -2
View File
@@ -59,8 +59,11 @@ func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reacti
}, executed
}
func actDec(text string) router.Decision {
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: text, Fn: "restart", HasFn: true}}
// actDec builds an act decision about subject. The verb is always "restart":
// the argument is the utterance the entity is resolved from, never the verb,
// so actDec("restart") reads as a verb and is not one.
func actDec(subject string) router.Decision {
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, Fn: "restart", HasFn: true}}
}
func TestHexisMutatingRequiresConfirm(t *testing.T) {
+316
View File
@@ -0,0 +1,316 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/store"
)
// Versioning, authentication and tracing of ecosystem calls (Vikunja #273).
func findTrace(t *testing.T, h *reactiveHandler, service, op string) *store.EcosystemTrace {
t.Helper()
for _, tr := range traces(t, h) {
if tr.Service == service && tr.Operation == op {
found := tr
return &found
}
}
return nil
}
// TestEcosystemHeaders_VersionRequesterAndAuth: every outgoing request carries
// the contract version, the requester, and the bearer token when configured.
func TestEcosystemHeaders_VersionRequesterAndAuth(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
h.ecosystem.nexus = newNexusClient(nexus.URL).withToken("nexus-secret")
h.ecosystem.praxis = newPraxisClient(praxis.URL).withToken("praxis-secret")
_, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil)
if err != nil {
t.Fatalf("resolve: %v", err)
}
// A bare client call carries whatever the caller assigned. Entry points
// assign the ID, the header layer only reads it, so mirror an action here.
if _, err := h.ecosystem.praxis.ListAttention(withCorrelationID(ctx, newCorrelationID()), 5); err != nil {
t.Fatalf("attention: %v", err)
}
for _, tc := range []struct {
fs *fakeServer
versionHeader string
token string
}{
{nexus, "X-Nexus-Version", "nexus-secret"},
{praxis, "X-Praxis-Version", "praxis-secret"},
} {
reqs := tc.fs.Requests()
if len(reqs) == 0 {
t.Fatalf("%s: no request captured", tc.versionHeader)
}
r := reqs[0]
if got := r.Header.Get(tc.versionHeader); got != ecosystemAPIVersion {
t.Errorf("%s = %q, want %q", tc.versionHeader, got, ecosystemAPIVersion)
}
if got := r.Header.Get("X-Requested-By"); got != mavenRequester {
t.Errorf("X-Requested-By = %q, want %q", got, mavenRequester)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+tc.token {
t.Errorf("Authorization = %q, want bearer %q", got, tc.token)
}
if r.Header.Get("X-Correlation-ID") == "" {
t.Errorf("%s: missing correlation ID", tc.versionHeader)
}
}
}
// TestEcosystemHeaders_NoTokenSendsNoAuth: an unconfigured token means the
// transport is trusted, not that a bogus header is sent.
func TestEcosystemHeaders_NoTokenSendsNoAuth(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
h := ecoHandler(t, nexus, nil, nil)
if _, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil); err != nil {
t.Fatalf("resolve: %v", err)
}
if got := nexus.Requests()[0].Header.Get("Authorization"); got != "" {
t.Fatalf("unauthenticated client must send no Authorization header, got %q", got)
}
}
// TestEcosystemError_ClassifiesRefusals: callers must be able to tell a
// rejected credential from a version refusal from an unreachable service
// without matching on message text.
func TestEcosystemError_ClassifiesRefusals(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
status int
check func(*ecosystemError) bool
wantCls string
}{
{"unauthorized", 401, (*ecosystemError).Unauthorized, "unauthorized"},
{"forbidden", 403, (*ecosystemError).Unauthorized, "unauthorized"},
{"contract", 426, (*ecosystemError).ContractMismatch, "contract_mismatch"},
} {
t.Run(tc.name, func(t *testing.T) {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
nexus.SetFault(tc.status)
c := newNexusClient(nexus.URL)
_, err := c.Resolve(ctx, "x", nil)
ee, ok := err.(*ecosystemError)
if !ok {
t.Fatalf("expected *ecosystemError, got %T (%v)", err, err)
}
if ee.Service != "nexus" || ee.Status != tc.status {
t.Fatalf("unexpected typed error %+v", ee)
}
if !tc.check(ee) {
t.Fatalf("%s not classified: %+v", tc.name, ee)
}
if got := traceErrorFields(err)["class"]; got != tc.wantCls {
t.Fatalf("trace class = %v, want %s", got, tc.wantCls)
}
})
}
}
func TestEcosystemError_UnreachableHasNoStatus(t *testing.T) {
c := newNexusClient("http://127.0.0.1:1")
_, err := c.Resolve(context.Background(), "x", nil)
ee, ok := err.(*ecosystemError)
if !ok {
t.Fatalf("expected *ecosystemError, got %T", err)
}
if !ee.Unreachable() || ee.Unauthorized() || ee.ContractMismatch() {
t.Fatalf("a refused connection must classify as unreachable only: %+v", ee)
}
}
// TestEcosystemTrace_SuccessfulActionTracesEveryHop: resolution, discovery and
// execution each leave a record sharing one correlation chain, with timing and
// status, and execution carries the causation link back to the resolve.
func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
var chain string
for _, want := range [][2]string{{"nexus", "resolve"}, {"hexis", "capabilities"}, {"hexis", "execute"}} {
d := findTrace(t, h, want[0], want[1])
if d == nil {
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces(t, h))
}
if d.Status != traceOK {
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d.Status)
}
if d.CorrelationID == "" {
t.Errorf("%s %s trace has no correlation id", want[0], want[1])
}
if want[1] != "execute" {
if chain == "" {
chain = d.CorrelationID
} else if d.CorrelationID != chain {
t.Errorf("%s %s left the correlation chain: %s != %s", want[0], want[1], d.CorrelationID, chain)
}
}
}
exec := findTrace(t, h, "hexis", "execute")
if exec.CausationID == "" {
t.Error("execute trace must carry the causation id of the turn that caused it")
}
if exec.CorrelationID == exec.CausationID {
t.Error("execute correlation and causation must be distinguishable")
}
}
// TestEcosystemTrace_OneCorrelationIDPerPraxisAction: a digest calls attention
// once and surface once per item. All of it is one turn, so the far side must
// see one ID and not N+1 unrelated ones.
func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
map[string]any{"id": "item_2", "title": "backup is stale", "importance": 2.0},
))
h := ecoHandler(t, nil, praxis, nil)
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
t.Fatalf("setup: expected the digest, got %q", reply)
}
reqs := praxis.Requests()
if len(reqs) < 3 {
t.Fatalf("expected attention plus one surface per item, got %d requests", len(reqs))
}
first := reqs[0].Header.Get("X-Correlation-ID")
if first == "" {
t.Fatal("every ecosystem request must carry a correlation id")
}
for _, r := range reqs {
if got := r.Header.Get("X-Correlation-ID"); got != first {
t.Fatalf("%s %s carried %q, want the action's id %q", r.Method, r.Path, got, first)
}
}
tr := findTrace(t, h, "praxis", "list_attention")
if tr == nil || tr.CorrelationID != first {
t.Fatalf("the trace must carry the id that was actually sent, got %+v", tr)
}
}
// TestEcosystemTrace_FailuresAreTracedToo: the whole point of the change —
// a failed hop is exactly the one worth having recorded.
func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
nexus.SetFault(401)
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
d := findTrace(t, h, "nexus", "resolve")
if d == nil {
t.Fatal("a failed resolve must still be traced")
}
if d.Status != traceRefused {
t.Errorf("status = %v, want refused: the far side answered", d.Status)
}
if d.Fields["class"] != "unauthorized" {
t.Errorf("class = %v, want unauthorized", d.Fields["class"])
}
if d.HTTPStatus != 401 {
t.Errorf("http_status = %v, want 401", d.HTTPStatus)
}
}
// TestEcosystemTrace_UnreachableIsNotRefused: never got an answer and answered
// with a refusal are different failures, and the trace must say which.
func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
ctx := context.Background()
h := ecoHandler(t, nil, nil, nil)
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
d := findTrace(t, h, "nexus", "resolve")
if d == nil {
t.Fatal("an unreachable resolve must still be traced")
}
if d.Status != traceFailed {
t.Errorf("status = %v, want failed", d.Status)
}
if d.Fields["class"] != "unreachable" {
t.Errorf("class = %v, want unreachable", d.Fields["class"])
}
}
// TestEcosystemTrace_RedactsTheUtterance: traces are diagnostics, his words
// are not. The subject must never be persisted verbatim.
func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusNotFound())
h := ecoHandler(t, nexus, nil, nil)
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
recorded := traces(t, h)
if len(recorded) == 0 {
t.Fatal("expected a not_found resolve trace")
}
for _, tr := range recorded {
for k, v := range tr.Fields {
if s, ok := v.(string); ok && strings.Contains(s, "кофемашину") {
t.Fatalf("trace leaked the utterance in %s: %q", k, s)
}
}
}
d := findTrace(t, h, "nexus", "resolve")
if d.Status != traceNotFound {
t.Errorf("status = %v, want not_found", d.Status)
}
if d.Fields["subject"] != redactSubject("перезапусти кофемашину") {
t.Errorf("subject = %v, want a redacted length", d.Fields["subject"])
}
}
// TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded: the two moments
// where Maven deliberately does not act still leave a trail.
func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
ctx := context.Background()
ambig := newFakeNexus(t, fixtureNexusAmbiguous(
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, ambig, nil, hexis)
_ = h.handleHexisAct(ctx, actDec("muzick"))
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
}
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
_ = h2.handleHexisAct(ctx, actDec("restart"))
d := findTrace(t, h2, "hexis", "confirmation")
if d == nil || d.Status != tracePending {
t.Fatalf("a parked confirmation must be traced, got %+v", d)
}
// The confirmation hop is measured from the top of the action, not from
// the instant it is recorded, which was always zero.
if d.DurationMs == 0 {
t.Error("the confirmation trace must report the time the action took to get there")
}
}
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"context"
"database/sql"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Entity-ref propagation, Maven side (Vikunja #272): the canonical Nexus
// entity_id must reach Praxis as a query scope rather than being resolved and
// then thrown away, and the enrichment that produces those ids must degrade
// visibly instead of silently.
func entityAttentionDec(subject string) router.Decision {
return router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: subject},
}
}
// TestEntityAttention_ScopesPraxisByCanonicalID: the resolved id must travel
// to Praxis in the request, not be used for client-side filtering.
func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionScoped("ent_muzick",
map[string]any{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0},
))
h := ecoHandler(t, nexus, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if !strings.Contains(reply, "indexer queue is backing up") {
t.Fatalf("expected the scoped item in the reply, got %q", reply)
}
var scoped bool
for _, r := range praxis.Requests() {
if r.Method == "GET" && strings.HasPrefix(r.Path, "/api/v1/tools/attention") &&
strings.Contains(r.Query, "entity_id=ent_muzick") {
scoped = true
}
}
if !scoped {
t.Fatalf("expected attention scoped by entity_id, got requests %+v", praxis.Requests())
}
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
t.Error("a spoken scoped item must be surfaced, like the unscoped digest")
}
}
// TestEntityAttention_FoldsInLocalFactsForSameEntity: facts the enrichment
// worker already tagged with the same canonical id join the same answer.
func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
"descaled", "the espresso machine", "descaled in june", "infer:pref", 0.8, sql.NullInt64{})
if err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
t.Fatalf("ResolveFactEntity: %v", err)
}
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
if !strings.Contains(reply, "descaled in june") {
t.Fatalf("expected entity-scoped local facts in the reply, got %q", reply)
}
}
// TestEntityAttention_UnscopedPraxisResponseIsRefused: a Praxis old enough to
// ignore the entity_id parameter answers the scoped question with the whole
// unscoped list. Relabelling those items "по «X»" is the same fabrication the
// canonical ref exists to prevent, arriving through a different door.
func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
))
h := ecoHandler(t, nexus, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if strings.Contains(reply, "disk almost full") {
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
}
if reply == "" {
t.Fatal("refusing the answer must still say something")
}
if praxis.Count("POST", "/api/v1/tools/surface") != 0 {
t.Error("items that were never spoken must not be surfaced")
}
}
// TestEntityAttention_ForeignItemsAreDropped: items tagged with another entity
// are dropped rather than spoken under this entity's name.
func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
mixed := []map[string]any{
{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0, "entity_id": "ent_muzick"},
{"id": "item_2", "title": "the kettle is descaling", "importance": 1.0, "entity_id": "ent_kettle"},
}
praxis := newFakePraxis(t, mustJSON(mixed))
h := ecoHandler(t, nexus, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if !strings.Contains(reply, "indexer queue is backing up") {
t.Fatalf("the matching item must be spoken, got %q", reply)
}
if strings.Contains(reply, "kettle") {
t.Fatalf("another entity's item must not be spoken here, got %q", reply)
}
}
// TestEntityAttention_TruncationIsNamed: reading three of many remembered
// facts must not be presented as everything she knows.
func TestEntityAttention_TruncationIsNamed(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
for i := 0; i < 5; i++ {
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
"note", "the espresso machine", "факт "+string(rune('а'+i)), "infer:pref", 0.8, sql.NullInt64{})
if err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
t.Fatalf("ResolveFactEntity: %v", err)
}
}
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
if !strings.Contains(reply, "и это не всё") {
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
}
}
// TestEntityAttention_AmbiguousAsksInsteadOfGuessing.
func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"))
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
t.Fatalf("ambiguous subject must ask, got %q", reply)
}
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
t.Fatal("an ambiguous subject must not be queried against praxis")
}
}
// TestEntityAttention_MissingAndDegradedAreDistinct: "no such entity" and
// "Nexus is down" must not produce the same answer.
func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusNotFound())
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
if missing == "" {
t.Fatal("an unknown entity must still get an answer")
}
nexus.SetFault(503)
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
if degraded == missing {
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
}
}
// TestEntityAttention_DelayedNexusDegradesNotHangs: a slow Nexus past the
// caller's deadline degrades and never queries Praxis with an empty scope.
func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nexus, praxis, nil)
nexus.SetDelay(2 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if reply == "" {
t.Fatal("a delayed resolve must still answer")
}
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
t.Fatal("praxis must not be queried without a resolved scope")
}
}
// TestEntityAttention_WithoutNexusSaysSo: no Nexus means no canonical ref, so
// the scoped query is refused rather than answered about something else.
func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
))
h := ecoHandler(t, nil, praxis, nil)
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
if strings.Contains(reply, "disk almost full") {
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
}
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
t.Fatal("no canonical ref means no scoped query at all")
}
}
// TestEnrichmentBackoff_HoldsAndReleases: repeated Nexus failures back the
// fact off instead of hammering, and the fact is retried once the window
// elapses. Nothing is ever given up on.
func TestEnrichmentBackoff_HoldsAndReleases(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
st := newTestStore(t)
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
w.now = clock.Now
nexus.SetFault(503)
w.tick(ctx)
failedCalls := nexus.Count("POST", "/api/v1/resolve")
if failedCalls != 1 {
t.Fatalf("expected one resolve attempt, got %d", failedCalls)
}
// Immediately after a failure the fact is in backoff: no second call.
w.tick(ctx)
if nexus.Count("POST", "/api/v1/resolve") != failedCalls {
t.Fatal("a fact in backoff must not be retried on the very next tick")
}
if s := w.status(ctx); s.Pending != 1 || s.InBackoff != 1 || s.MaxAttempts != 1 {
t.Fatalf("degradation must be reported, got %+v", s)
}
// Once the window elapses and Nexus recovers, the fact resolves.
clock.Advance(2 * time.Minute)
nexus.SetFault(0)
w.tick(ctx)
facts, err := st.FactsByEntity(ctx, "ent_espresso", 10)
if err != nil {
t.Fatalf("FactsByEntity: %v", err)
}
if len(facts) != 1 {
t.Fatalf("expected the fact resolved after recovery, got %+v", facts)
}
if s := w.status(ctx); s.Pending != 0 || s.MaxAttempts != 0 {
t.Fatalf("recovery must clear the degradation report, got %+v", s)
}
}
func TestEnrichmentBackoff_GrowsAndIsCapped(t *testing.T) {
if enrichmentBackoff(1) != time.Minute {
t.Fatalf("first retry should be a minute, got %v", enrichmentBackoff(1))
}
if enrichmentBackoff(3) != 4*time.Minute {
t.Fatalf("third retry should be four minutes, got %v", enrichmentBackoff(3))
}
if enrichmentBackoff(50) != time.Hour {
t.Fatalf("backoff must cap at an hour, got %v", enrichmentBackoff(50))
}
}
// TestEnrichment_BackedOffFactsDoNotStallTheQueue: the pending queue is ordered
// by id, so the oldest facts are pulled first whether or not they are eligible.
// A batch of facts in backoff at the head must not hold every slot and stop
// enrichment for everything younger.
func TestEnrichment_BackedOffFactsDoNotStallTheQueue(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
total := 5
for i := 0; i < total; i++ {
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
"subject-"+string(rune('a'+i)), `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
}
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
w.now = clock.Now
// A batch smaller than the queue, so with no scan the last fact never
// reaches the head while the first ones are backed off.
w.batch = total - 1
nexus.SetFault(503)
w.tick(ctx)
if got := nexus.Count("POST", "/api/v1/resolve"); got != total-1 {
t.Fatalf("expected the first batch attempted, got %d calls", got)
}
// Second tick with Nexus healthy: the backed-off head must be skipped and
// the fact behind it resolved, not the same batch pulled and dropped.
nexus.SetFault(0)
w.tick(ctx)
facts, err := st.FactsByEntity(ctx, "ent_x", 10)
if err != nil {
t.Fatalf("FactsByEntity: %v", err)
}
if len(facts) == 0 {
t.Fatal("a due fact behind a backed-off batch must still be resolved")
}
}
// TestEnrichment_StoreWriteFailureBacksOffToo: the one failure mode where the
// resolve worked and the write did not must be paced like any other, not
// retried at full rate forever.
func TestEnrichment_StoreWriteFailureBacksOffToo(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
st := newTestStore(t)
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFactAboutSubject: %v", err)
}
pending, err := st.PendingFactResolutions(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("setup: pending = %+v, %v", pending, err)
}
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
w.now = clock.Now
// Closing the store makes the resolution write fail while the Nexus call
// still succeeds — the split this path gets wrong.
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
if w.resolveOne(ctx, pending[0]) {
t.Fatal("a failed store write must not report success")
}
if w.due(pending[0].ID) {
t.Fatal("a failed store write must back the fact off like a failed resolve")
}
}
+169 -7
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"log"
"sync"
"time"
"github.com/kami/maven/internal/store"
@@ -24,10 +25,88 @@ type factEnrichmentWorker struct {
eco *ecosystemWiring
interval time.Duration
batch int // facts resolved per tick; keeps a single slow tick bounded
now func() time.Time
// Retry state for facts whose resolution failed transiently. Kept in
// memory rather than in the DB: a restart legitimately retries
// everything, and the backoff exists to spare a struggling Nexus, not
// to be durable. A fact is never given up on — degraded means slower,
// not dropped.
mu sync.Mutex
attempt map[int64]int // fact id → consecutive failures
nextTry map[int64]time.Time // fact id → earliest retry
}
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
// the pending queue looking for facts whose backoff has elapsed. The queue is
// ordered by id, so without a scan the oldest facts hold every batch slot
// whether or not they are eligible, and one permanently failing fact stalls
// every younger one behind it.
const enrichmentScanLimit = 1000
// enrichmentBackoff is the wait before retrying a fact after n consecutive
// failures, capped so a long Nexus outage still retries about hourly.
func enrichmentBackoff(n int) time.Duration {
d := time.Minute
for i := 1; i < n && d < time.Hour; i++ {
d *= 2
}
if d > time.Hour {
d = time.Hour
}
return d
}
func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval time.Duration) *factEnrichmentWorker {
return &factEnrichmentWorker{store: st, eco: eco, interval: interval, batch: 20}
return &factEnrichmentWorker{
store: st,
eco: eco,
interval: interval,
batch: 20,
now: time.Now,
attempt: map[int64]int{},
nextTry: map[int64]time.Time{},
}
}
// enrichmentStatus is what the worker reports about its own health: how many
// facts are waiting, how many of those are currently in backoff, and the worst
// retry count among them. Degradation is reported, never hidden — a Nexus that
// has been down all day must be visible as a backlog, not as facts that
// silently never got tagged.
//
// All three numbers describe the same set of rows, the first
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
// while counting InBackoff over the twenty that reached the head of a batch
// described two different populations under one struct.
type enrichmentStatus struct {
Pending int
InBackoff int
MaxAttempts int
Scanned int // rows the other three counts were taken over
}
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
var st enrichmentStatus
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
if err != nil {
log.Printf("factenrichment: status: %v", err)
return st
}
st.Pending = len(pending)
st.Scanned = len(pending)
w.mu.Lock()
defer w.mu.Unlock()
now := w.now()
for _, f := range pending {
if next, ok := w.nextTry[f.ID]; ok && now.Before(next) {
st.InBackoff++
}
if n := w.attempt[f.ID]; n > st.MaxAttempts {
st.MaxAttempts = n
}
}
return st
}
func (w *factEnrichmentWorker) run(ctx context.Context) {
@@ -52,22 +131,86 @@ func (w *factEnrichmentWorker) run(ctx context.Context) {
}
func (w *factEnrichmentWorker) tick(ctx context.Context) {
pending, err := w.store.PendingFactResolutions(ctx, w.batch)
// Scan past the facts that are still in backoff instead of letting them
// occupy the batch. The queue is ordered by id, so the oldest facts are
// pulled first whether or not they are eligible: twenty facts Nexus keeps
// rejecting would otherwise hold every slot forever and enrichment would
// stop with no error and no log line, because a tick that skips everything
// fails nothing.
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
if err != nil {
log.Printf("factenrichment: list pending: %v", err)
return
}
w.forgetDeparted(pending)
skipped, failed, attempted := 0, 0, 0
for _, f := range pending {
w.resolveOne(ctx, f)
if attempted >= w.batch {
break
}
if !w.due(f.ID) {
skipped++
continue
}
attempted++
if !w.resolveOne(ctx, f) {
failed++
}
}
if failed > 0 {
log.Printf("factenrichment: %d/%d resolutions failed this tick, %d held in backoff",
failed, attempted, skipped)
}
// Report the backlog every tick, not only when something failed: the
// stalled state worth seeing is the one where nothing failed because
// nothing was attempted.
if st := w.status(ctx); st.Pending > 0 {
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
}
}
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
// forgetDeparted drops retry state for facts that are no longer pending. A
// fact can leave the queue without ever resolving here — voided, or resolved
// by a later write — and its entries would otherwise live as long as the
// process does.
func (w *factEnrichmentWorker) forgetDeparted(pending []store.Fact) {
live := make(map[int64]struct{}, len(pending))
for _, f := range pending {
live[f.ID] = struct{}{}
}
w.mu.Lock()
defer w.mu.Unlock()
for id := range w.attempt {
if _, ok := live[id]; !ok {
delete(w.attempt, id)
}
}
for id := range w.nextTry {
if _, ok := live[id]; !ok {
delete(w.nextTry, id)
}
}
}
// due reports whether a fact's backoff window has elapsed.
func (w *factEnrichmentWorker) due(id int64) bool {
w.mu.Lock()
defer w.mu.Unlock()
next, ok := w.nextTry[id]
return !ok || !w.now().Before(next)
}
// resolveOne resolves one pending fact. It returns false when the attempt
// failed transiently: the fact stays pending and is retried on a backoff.
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) bool {
entityID, _, ambiguous, err := w.eco.resolveEntityReference(ctx, f.Subject, nil)
if err != nil {
// Transient (Nexus unreachable) — leave pending, retry next tick.
log.Printf("factenrichment: resolve fact %d subject %q: %v", f.ID, f.Subject, err)
return
// Transient (Nexus unreachable) — leave pending, back off, retry later.
// The subject is his words: log its length, the way the trace does.
log.Printf("factenrichment: resolve fact %d subject %s: %v", f.ID, redactSubject(f.Subject), err)
w.backOff(f.ID)
return false
}
state := store.ResolutionNotFound
switch {
@@ -77,6 +220,25 @@ func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
state = store.ResolutionAmbiguous
}
if err := w.store.ResolveFactEntity(ctx, f.ID, entityID, state); err != nil {
// A failed write leaves the fact pending exactly like a failed resolve
// does, so it gets the same pacing. Clearing the counters first meant
// this one path retried every tick, at full rate, with no ceiling.
log.Printf("factenrichment: record resolution for fact %d: %v", f.ID, err)
w.backOff(f.ID)
return false
}
w.mu.Lock()
delete(w.attempt, f.ID)
delete(w.nextTry, f.ID)
w.mu.Unlock()
return true
}
// backOff records one more consecutive failure for a fact and pushes its next
// attempt out accordingly.
func (w *factEnrichmentWorker) backOff(id int64) {
w.mu.Lock()
defer w.mu.Unlock()
w.attempt[id]++
w.nextTry[id] = w.now().Add(enrichmentBackoff(w.attempt[id]))
}
+154 -9
View File
@@ -14,7 +14,9 @@ import (
type capturedRequest struct {
Method string
Path string
Query string
Body []byte
Header http.Header
}
// fakeServer is the common shell behind fakeNexus/fakePraxis/fakeHexis: an
@@ -25,9 +27,12 @@ type capturedRequest struct {
type fakeServer struct {
*httptest.Server
mu sync.Mutex
requests []capturedRequest
fault int // non-zero: every request gets this HTTP status instead of routing
mu sync.Mutex
requests []capturedRequest
fault int // non-zero: every request gets this HTTP status instead of routing
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
delay time.Duration
}
// newFakeServer starts a server dispatching to routes keyed by "METHOD
@@ -47,14 +52,42 @@ func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer
}
}
fs.mu.Lock()
fs.requests = append(fs.requests, capturedRequest{Method: r.Method, Path: r.URL.Path, Body: body})
fs.requests = append(fs.requests, capturedRequest{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Body: body,
Header: r.Header.Clone(),
})
fault := fs.fault
if fault == 0 {
for prefix, status := range fs.routeFaults {
if hasPrefix(r.URL.Path, prefix) {
fault = status
break
}
}
}
garbage := fs.garbage
delay := fs.delay
fs.mu.Unlock()
if delay > 0 {
select {
case <-time.After(delay):
case <-r.Context().Done():
return
}
}
if fault != 0 {
http.Error(w, "injected fault", fault)
return
}
if garbage != "" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(garbage))
return
}
for key, handler := range routes {
method, prefix := splitRouteKey(key)
@@ -90,6 +123,52 @@ func (fs *fakeServer) SetFault(status int) {
fs.fault = status
}
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
// which is the shape most real outages take: attention answers and pin is
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.routeFaults == nil {
fs.routeFaults = map[string]int{}
}
if status == 0 {
delete(fs.routeFaults, pathPrefix)
return
}
fs.routeFaults[pathPrefix] = status
}
// SetBody makes every subsequent request answer 200 with the given body,
// bypassing the route table. Used to serve a malformed or contract-violating
// payload where the transport itself is healthy. Pass "" to clear it.
func (fs *fakeServer) SetBody(body string) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.garbage = body
}
// SetDelay stalls every subsequent request for d before answering, so callers
// can drive client timeouts and context cancellation deterministically. The
// delay is abandoned as soon as the client hangs up.
func (fs *fakeServer) SetDelay(d time.Duration) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.delay = d
}
// Count returns how many captured requests used the given method and path
// prefix. "" matches any method.
func (fs *fakeServer) Count(method, prefix string) int {
n := 0
for _, r := range fs.Requests() {
if (method == "" || r.Method == method) && hasPrefix(r.Path, prefix) {
n++
}
}
return n
}
// Requests returns a snapshot of captured requests, in arrival order.
func (fs *fakeServer) Requests() []capturedRequest {
fs.mu.Lock()
@@ -118,6 +197,40 @@ func fixtureNexusResolved(entityID, displayName, entityType string) string {
})
}
// fixtureNexusResolvedFlat is the flat resolve shape documented in
// ECOSYSTEM-SPEC.md §1.5 (entity_id/entity_type/display_name at the top
// level) rather than the nested "entity" object — the older of the two
// wire shapes Maven must keep accepting.
func fixtureNexusResolvedFlat(entityID, displayName, entityType string) string {
return mustJSON(map[string]any{
"status": "resolved",
"entity_id": entityID,
"entity_type": entityType,
"display_name": displayName,
})
}
// fixtureNexusResolvedFuture is a resolved response from a hypothetical newer
// Nexus: same required fields plus unknown ones. Decoding must ignore the
// extras, not fail — forward compatibility is what lets the ecosystem be
// upgraded one service at a time.
func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string {
return mustJSON(map[string]any{
"status": "resolved",
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType, "tenant": "home"},
"provenance": map[string]any{"resolver": "v3", "graph_epoch": 42},
"score_breakdown": []any{map[string]any{"signal": "alias", "weight": 0.9}},
})
}
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
// Nexus claims a resolve and delivers no entity. It must not read as "no such
// entity", which would let the caller fall through to local execution with the
// user's verb intact.
func fixtureNexusResolvedEmpty() string {
return `{"status":"resolved"}`
}
func fixtureNexusNotFound() string {
return `{"status":"not_found"}`
}
@@ -138,6 +251,23 @@ func fixtureHexisExecuted(id, status string) string {
return mustJSON(map[string]any{"id": id, "status": status})
}
// fixtureHexisExecutionFailed is a well-formed Hexis response reporting that
// the command itself failed: the call succeeded, the execution did not. Maven
// must distinguish this from a transport failure and from success.
func fixtureHexisExecutionFailed(id, message string) string {
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
}
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
// a Praxis that understands the entity_id query parameter returns. A Praxis
// that ignores it answers with untagged items from every entity.
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
for _, item := range items {
item["entity_id"] = entityID
}
return mustJSON(items)
}
func fixturePraxisAttentionItems(items ...map[string]any) string {
return mustJSON(items)
}
@@ -156,18 +286,28 @@ func mustJSON(v any) string {
// (e.g. asserting age-based digest ordering without sleeping).
type fakeClock struct {
mu sync.Mutex
t time.Time
mu sync.Mutex
t time.Time
step time.Duration // advanced on every read, so elapsed time is measurable
}
func newFakeClock(start time.Time) *fakeClock {
return &fakeClock{t: start}
}
// newTickingClock advances by step on every read. Durations measured across
// hops are then non-zero without sleeping, which is what lets a test tell a
// trace that measured something from one that measured nothing.
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
return &fakeClock{t: start, step: step}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
now := c.t
c.t = c.t.Add(c.step)
return now
}
func (c *fakeClock) Advance(d time.Duration) {
@@ -191,8 +331,13 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
// fault is injected via SetFault.
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
return newFakeServer(t, map[string]http.HandlerFunc{
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
"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, `{}`),
"POST /api/v1/tools/acknowledge": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/resolve": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/ignore": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/pin": jsonHandler(http.StatusOK, `{}`),
})
}
+21 -2
View File
@@ -88,12 +88,12 @@ func TestQueryFeedsByCategory(t *testing.T) {
// answered by the model inventing a bulletin.
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
off := buildFeedHandler(t, false)
reply, ok := askFeeds(t, off, "что нового?")
reply, ok := askFeeds(t, off, "что нового в лентах?")
if !ok || !strings.Contains(reply, "не настроены") {
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
}
on := buildFeedHandler(t, true)
reply, ok = askFeeds(t, on, "что нового?")
reply, ok = askFeeds(t, on, "что нового в лентах?")
if !ok || !strings.Contains(reply, "ничего нового") {
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
}
@@ -104,6 +104,25 @@ func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
t.Fatalf("claimed an unrelated question with %q", reply)
}
// The bare greeting is not a request for headlines. It used to be answered
// with a configuration status.
if reply, ok := askFeeds(t, h, "что нового?"); ok {
t.Fatalf("claimed a greeting with %q", reply)
}
}
// A busy day of his own notes must not push the newest headline out of the
// window the feed answer scans.
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
for i := 0; i < feedNoteWindow+10; i++ {
notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
}
h := buildFeedHandler(t, true, notes...)
reply, ok := askFeeds(t, h, "что нового в лентах?")
if !ok || !strings.Contains(reply, "ядра") {
t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
}
}
// The mark is what stops a restart from re-noting yesterday's headlines, so the
+79 -4
View File
@@ -40,6 +40,7 @@ package main
import (
"context"
"encoding/json"
"log"
"strings"
"time"
@@ -54,7 +55,12 @@ import (
// off (a negative config.intake_journal). nil is the "behave exactly as before"
// value all the way down: no decorator, no ring, no /events rows.
func newEventBus(cfg *config.Config) *event.Bus {
if cfg == nil || cfg.IntakeJournal < 0 {
if cfg == nil {
// No config at all is a test, not an operator decision. Saying "off"
// here was noise in every suite that passes nil.
return nil
}
if cfg.IntakeJournal < 0 {
log.Printf("intake journal: off (intake_journal < 0)")
return nil
}
@@ -85,6 +91,7 @@ func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
Body: e.Body,
Priority: e.Priority,
OccurredAt: e.OccurredAt,
NoticedAt: e.NoticedAt,
})
}
return out
@@ -120,18 +127,34 @@ func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64,
if err != nil {
return id, err
}
if selfWrite(req) {
// Maven's own bookkeeping is not something that arrived. The feed
// watermark, the crawl hash, the praxis trace of an act she performed
// and a quiet-hours toggle he pressed all used to sit on a page headed
// "everything that arrived", and on a cold start a handful of feeds
// could evict real intake behind their marks.
return id, nil
}
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
// instant and the ambient path carries the meeting's start. Flattening
// those to notice-time would make the journal lie about when things
// happened, which is the one thing it is for.
title := req.Key
if req.VoidsID != nil {
// A retraction is not a reading. Without this it published an envelope
// indistinguishable from a fresh value for the same key, on a page
// whose whole job is "what came in".
title = "отмена: " + req.Key
}
a.bus.Publish(event.Event{
Source: req.Source,
Kind: event.SourceKind(req.Source, event.KindFact),
Title: req.Key,
Title: title,
Body: req.Value,
Priority: factPriority(req),
OccurredAt: req.Ts,
EntityIDs: entityIDs(req.Subject),
Payload: factPayload(req),
}, a.now())
return id, nil
}
@@ -201,17 +224,69 @@ func publishableTask(t store.Task, now time.Time) event.Event {
}
}
// selfWrite reports whether a fact write is Maven describing her own state
// rather than something arriving from outside. The store's fact kinds are
// 'self', 'env' and 'config'; 'config' is where every watermark and toggle
// lands, and the praxis trace is an audit record of an act she performed, which
// is the same class of thing under an 'env' kind.
func selfWrite(req ipc.WriteFactReq) bool {
switch req.Kind {
case "config", "system":
return true
}
return strings.HasPrefix(req.Source, "praxis:trace")
}
// factPriority is the attention hint for a fact write. Deliberately crude:
// a low-confidence inference (the ambient notification path writes below 1.0)
// is worth less attention than a read he or a credentialled poller made, and
// nothing else is distinguishable from here.
// is worth less attention than a read he or a credentialled poller made, and a
// retraction is a correction rather than news.
//
// Confidence is NOT recoverable from this, which is why the number itself goes
// into Payload: three display buckets must not be the only surviving trace of
// the distinction internal/calendar went out of its way to keep.
func factPriority(req ipc.WriteFactReq) string {
if req.VoidsID != nil {
return event.PriorityLow
}
if req.Confidence > 0 && req.Confidence < 1.0 {
return event.PriorityLow
}
return event.PriorityNormal
}
// factDetail is the fact-shaped Payload: the fields the envelope's own flat
// shape cannot carry, kept so a reader can tell an inference from a
// credentialled read, and "nobody said" from "certain".
type factDetail struct {
// FactKind — the fact's own kind ('self', 'env', 'config'), a different
// taxonomy from Event.Kind.
FactKind string `json:"fact_kind,omitempty"`
// Confidence — the number itself, so an inference stays distinguishable
// from a credentialled read. nil when the writer set none, which the ipc
// layer rejects today; the pointer keeps "nobody said" and "certain" from
// collapsing into each other the way the priority bucket does.
Confidence *float64 `json:"confidence,omitempty"`
// VoidsID — the fact this one retracts.
VoidsID *int64 `json:"voids_id,omitempty"`
}
func factPayload(req ipc.WriteFactReq) json.RawMessage {
d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID}
if req.Confidence != 0 {
c := req.Confidence
d.Confidence = &c
}
if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil {
return nil
}
b, err := json.Marshal(d)
if err != nil {
return nil
}
return b
}
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
// already looks resolved. Intake runs BEFORE the fact enrichment worker
// resolves a subject against Nexus, so this is almost always empty — the slot
+109
View File
@@ -2,7 +2,9 @@ package main
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -176,3 +178,110 @@ func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
t.Errorf("got %d events, want none", len(got))
}
}
// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash,
// the praxis trace of an act she performed and the quiet-hours toggle he
// pressed all landed on a page headed "everything that arrived", and on a cold
// start a handful of feeds could evict real intake behind their marks.
func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) {
api, bus := newIntakeTestAPI(t)
ctx := context.Background()
for _, req := range []ipc.WriteFactReq{
{Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0},
{Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0},
{Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0},
{Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0},
} {
if _, err := api.WriteFact(ctx, req); err != nil {
t.Fatalf("WriteFact(%s): %v", req.Key, err)
}
}
if n := bus.Len(); n != 0 {
t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0))
}
// A real arrival under the same decorator still lands.
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200",
Source: "poll:zenmoney", Confidence: 1.0,
}); err != nil {
t.Fatal(err)
}
if bus.Len() != 1 {
t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0))
}
}
// Confidence is the distinction between an inference and a credentialled read,
// and the three-value priority bucket cannot carry it: unset and 1.0 land in
// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it.
func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) {
api, bus := newIntakeTestAPI(t)
ctx := context.Background()
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка",
Source: "ambient:notif", Confidence: 0.6,
}); err != nil {
t.Fatal(err)
}
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0,
}); err != nil {
t.Fatal(err)
}
got := bus.Recent(0)
if len(got) != 2 {
t.Fatalf("journal has %d entries, want 2", len(got))
}
var relayed, stated factDetail
if err := json.Unmarshal(got[1].Payload, &relayed); err != nil {
t.Fatalf("payload: %v", err)
}
if relayed.Confidence == nil || *relayed.Confidence != 0.6 {
t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence)
}
if relayed.FactKind != "env" {
t.Errorf("fact_kind = %q, want env", relayed.FactKind)
}
// Both writes land in PriorityNormal or PriorityLow buckets that cannot be
// told apart from the outside; the payload is where the two numbers stay
// distinguishable.
if err := json.Unmarshal(got[0].Payload, &stated); err != nil {
t.Fatalf("payload: %v", err)
}
if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" {
t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated)
}
}
// A retraction is not an observation. It used to publish an envelope
// indistinguishable from a fresh reading of the same key.
func TestIntakeMarksARetraction(t *testing.T) {
api, bus := newIntakeTestAPI(t)
ctx := context.Background()
id, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0,
})
if err != nil {
t.Fatal(err)
}
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web",
Confidence: 1.0, VoidsID: &id,
}); err != nil {
t.Fatal(err)
}
e := bus.Recent(1)[0]
if e.Priority != event.PriorityLow {
t.Errorf("priority = %q, want low for a correction", e.Priority)
}
if !strings.HasPrefix(e.Title, "отмена:") {
t.Errorf("title = %q, want it marked as a retraction", e.Title)
}
var d factDetail
if err := json.Unmarshal(e.Payload, &d); err != nil {
t.Fatal(err)
}
if d.VoidsID == nil || *d.VoidsID != id {
t.Errorf("voids_id = %v, want %d", d.VoidsID, id)
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
// Writing the wrapped-key blob (Vikunja #14).
//
// The blob is the only thing that opens the database on a cold-started box, so
// the two rules here are about not losing it.
//
// # It is rewritten on every assertion, so the write must be atomic
//
// mavweb calls StoreEncryptionKey after every successful assertion, not only
// after enrolment. os.WriteFile truncates in place: a power cut or an OOM kill
// between the truncate and the write left a zero-length blob and no previous
// contents, on the path of every routine step-up. Write to a temp file in the
// same directory, fsync it, rename over the target, then fsync the directory.
//
// # Only one authenticator can hold the cold-start key
//
// A blob is wrapped under one credential's PRF output and nothing else opens
// it. mavweb sends an empty allowCredentials list and the credential store
// keeps more than one passkey, so an unconditional rewrite meant the last
// authenticator to assert silently locked out every other one — including the
// backup hardware key enrolled for exactly the cold-start case. So: a blob
// that already opens under this secret and already wraps this key is left
// alone, a v1 blob is upgraded in place, and a v2 blob belonging to a
// different credential is refused rather than overwritten.
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/kami/maven/internal/webauthn"
)
// errForeignBlob — the wrapped key on disk belongs to another credential.
// Refusing is the point: overwriting would lock that authenticator out.
var errForeignBlob = errors.New("wrapped key belongs to a different credential")
// wrapKeyToFile wraps key under secret and persists it at path, unless the
// blob already there says not to. Reports whether it wrote anything.
func wrapKeyToFile(path string, key, secret []byte) (wrote bool, err error) {
existing, err := os.ReadFile(path)
switch {
case err == nil:
plain, version, uerr := webauthn.UnwrapKey(existing, secret)
switch {
case uerr == nil && version == webauthn.BlobV2 && bytes.Equal(plain, key):
// Already wrapped under this secret, around this key. The
// common case on every assertion after the first.
return false, nil
case uerr != nil && version == webauthn.BlobV2:
return false, fmt.Errorf("%w: %s does not open under this assertion's PRF output, so another passkey holds the cold-start key; delete it deliberately to re-wrap", errForeignBlob, path)
}
// A v1 blob (upgrade it), or a v2 blob wrapping a stale key under
// this same secret (the key was rotated). Both are rewrites.
case errors.Is(err, os.ErrNotExist):
// First wrap.
default:
return false, fmt.Errorf("read wrapped key: %w", err)
}
blob, err := webauthn.WrapKey(key, secret)
if err != nil {
return false, fmt.Errorf("wrap encryption key: %w", err)
}
if err := writeFileAtomic(path, blob, 0o600); err != nil {
return false, fmt.Errorf("write wrapped key: %w", err)
}
return true, nil
}
// writeFileAtomic writes data to path so that a reader sees either the whole
// new file or the whole old one, never a truncated blob.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp) // no-op once the rename succeeded
if err := f.Chmod(perm); err != nil {
f.Close()
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
return err
}
// The rename itself needs to reach the disk, or a crash can resurrect the
// old directory entry pointing at a file that is gone.
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/kami/maven/internal/webauthn"
)
func wrapPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "db_key.wrapped")
}
// The first wrap writes a v2 blob that opens under the same secret.
func TestWrapKeyToFileWritesAnOpenableBlob(t *testing.T) {
path := wrapPath(t)
key := bytes.Repeat([]byte{1}, 32)
secret := bytes.Repeat([]byte{2}, 32)
wrote, err := wrapKeyToFile(path, key, secret)
if err != nil || !wrote {
t.Fatalf("wrapKeyToFile = %v, %v; want a write", wrote, err)
}
blob, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read blob: %v", err)
}
plain, version, err := webauthn.UnwrapKey(blob, secret)
if err != nil || version != webauthn.BlobV2 || !bytes.Equal(plain, key) {
t.Fatalf("UnwrapKey = %x, %v, %v", plain, version, err)
}
if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v (%v), want 0600", fi.Mode().Perm(), err)
}
}
// A blob that already wraps this key under this secret is left alone. Without
// this every assertion rewrote the one file that opens the database.
func TestWrapKeyToFileSkipsAnIdenticalBlob(t *testing.T) {
path := wrapPath(t)
key := bytes.Repeat([]byte{3}, 32)
secret := bytes.Repeat([]byte{4}, 32)
if _, err := wrapKeyToFile(path, key, secret); err != nil {
t.Fatalf("first wrap: %v", err)
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
wrote, err := wrapKeyToFile(path, key, secret)
if err != nil {
t.Fatalf("second wrap: %v", err)
}
if wrote {
t.Error("rewrote a blob that already opens under this secret")
}
after, _ := os.ReadFile(path)
if !bytes.Equal(before, after) {
t.Error("the blob changed on a no-op wrap")
}
}
// Two enrolled authenticators, two PRF secrets, one blob. The second must not
// silently lock the first one out — the backup passkey enrolled for exactly
// the cold-start case is the one thing that used to stop working.
func TestWrapKeyToFileRefusesAnotherCredentialsBlob(t *testing.T) {
path := wrapPath(t)
key := bytes.Repeat([]byte{5}, 32)
phone := bytes.Repeat([]byte{6}, 32)
yubikey := bytes.Repeat([]byte{7}, 32)
if _, err := wrapKeyToFile(path, key, phone); err != nil {
t.Fatalf("first wrap: %v", err)
}
before, _ := os.ReadFile(path)
wrote, err := wrapKeyToFile(path, key, yubikey)
if !errors.Is(err, errForeignBlob) {
t.Fatalf("wrapKeyToFile = %v, %v; want errForeignBlob", wrote, err)
}
after, _ := os.ReadFile(path)
if !bytes.Equal(before, after) {
t.Fatal("the second authenticator overwrote the first one's blob")
}
if _, _, err := webauthn.UnwrapKey(after, phone); err != nil {
t.Fatalf("the first authenticator can no longer open the blob: %v", err)
}
}
// A v1 blob is the pre-#14 format. It is upgraded in place rather than
// refused, because that is the only way off a format that protects nothing.
func TestWrapKeyToFileUpgradesALegacyBlob(t *testing.T) {
path := wrapPath(t)
key := bytes.Repeat([]byte{8}, 32)
secret := bytes.Repeat([]byte{9}, 32)
// A v1 blob is a v2 blob with the magic stripped and the v1 info string;
// the package writes no v1, so build one the only way a test can: wrap
// v2 under a public key, then hand the file a body with no magic. What
// matters here is only that UnwrapKey classifies it as v1.
v2, err := webauthn.WrapKey(key, secret)
if err != nil {
t.Fatalf("WrapKey: %v", err)
}
legacy := v2[7:] // drop the magic
if err := os.WriteFile(path, legacy, 0o600); err != nil {
t.Fatalf("write legacy blob: %v", err)
}
if _, version, _ := webauthn.UnwrapKey(legacy, secret); version != webauthn.BlobV1 {
t.Fatalf("fixture is not read as v1 (got %v)", version)
}
wrote, err := wrapKeyToFile(path, key, secret)
if err != nil || !wrote {
t.Fatalf("wrapKeyToFile = %v, %v; want the legacy blob upgraded", wrote, err)
}
blob, _ := os.ReadFile(path)
if _, version, err := webauthn.UnwrapKey(blob, secret); err != nil || version != webauthn.BlobV2 {
t.Fatalf("after upgrade: version %v, err %v", version, err)
}
}
// A rotated at-rest key under the same credential is a rewrite, not a no-op.
func TestWrapKeyToFileRewritesARotatedKey(t *testing.T) {
path := wrapPath(t)
secret := bytes.Repeat([]byte{10}, 32)
old := bytes.Repeat([]byte{11}, 32)
fresh := bytes.Repeat([]byte{12}, 32)
if _, err := wrapKeyToFile(path, old, secret); err != nil {
t.Fatalf("first wrap: %v", err)
}
wrote, err := wrapKeyToFile(path, fresh, secret)
if err != nil || !wrote {
t.Fatalf("wrapKeyToFile = %v, %v; want the rotated key written", wrote, err)
}
blob, _ := os.ReadFile(path)
plain, _, err := webauthn.UnwrapKey(blob, secret)
if err != nil || !bytes.Equal(plain, fresh) {
t.Fatalf("blob still wraps the old key (%v)", err)
}
}
// The write never truncates the target in place, so a crash mid-write cannot
// leave a zero-length blob where the only copy of the wrapped key was.
func TestWriteFileAtomicLeavesNoTempFilesAndReplacesWhole(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "db_key.wrapped")
if err := os.WriteFile(path, bytes.Repeat([]byte{0xaa}, 67), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
// Hold the old inode. A rename gives it a new one; a truncating write
// would keep it.
oldInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
want := bytes.Repeat([]byte{0xbb}, 67)
if err := writeFileAtomic(path, want, 0o600); err != nil {
t.Fatalf("writeFileAtomic: %v", err)
}
got, err := os.ReadFile(path)
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("content = %x (%v)", got, err)
}
newInfo, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if os.SameFile(oldInfo, newInfo) {
t.Error("the target was written in place, not renamed over")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
if len(entries) != 1 {
t.Errorf("directory holds %d entries, want just the blob (a temp file leaked)", len(entries))
}
}
+87 -13
View File
@@ -22,7 +22,9 @@ import (
"context"
"fmt"
"log"
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/email"
@@ -37,6 +39,35 @@ import (
// list into a copy of his mailbox.
const evidenceMaxChars = 160
// captureTimeout — how long the capture writes get, separately from the
// extraction budget. A candidate the model already produced must not be lost
// because the model was slow.
const captureTimeout = 30 * time.Second
// maxMailboxChars — a mailbox name is an IMAP folder, not free text. It ends up
// in the provenance string, which is a small controlled vocabulary.
const maxMailboxChars = 64
// validMailbox checks the name this method is willing to write provenance for.
// Empty is refused: "email:" is not a source. So is anything with a control
// character or a space-only value, so the source string stays greppable and
// stays one token.
func validMailbox(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("mail intake: mailbox is required")
}
if len([]rune(s)) > maxMailboxChars {
return "", fmt.Errorf("mail intake: mailbox name too long")
}
for _, r := range s {
if r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
return "", fmt.Errorf("mail intake: mailbox name has whitespace or a control character")
}
}
return s, nil
}
// mailIntake — extraction + capture for one message at a time.
type mailIntake struct {
st *store.Store
@@ -64,15 +95,25 @@ func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus
}
lp, ok := phr.(*phraser.LLMPhraser)
if !ok {
log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled")
// The phraser is not an *LLMPhraser. Today that means there is no
// llama-server; if anything ever WRAPS the phraser it will mean that
// instead, so the line names the assertion rather than guessing why.
log.Printf("mail intake: configured but the phraser is not an *phraser.LLMPhraser (%T) — mail ingestion disabled", phr)
return nil
}
timeout := time.Duration(cfg.Email.Timeout)
if timeout <= 0 {
timeout = config.DefaultEmailTimeout
}
ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
// Background client: extraction is a job nobody is waiting on, and it shares
// one llama-server slot with the voice turn. Through the gate it yields to
// anything he is waiting for and only one extraction runs at a time, so a
// first poll of 25 unseen messages cannot queue 25 model calls in front of
// him. See llm.Gate.
ex := email.NewExtractor(llmBackgroundClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
// The NORMALISED bound, not the configured one: with "email": {} in
// mavend.json the configured value is 0 and the daemon allows three.
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", ex.Max(), timeout)
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
}
@@ -82,10 +123,19 @@ func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus
// reader's header filter is what keeps the resident model off newsletters.
//
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
// and the subject as Evidence. CaptureTask dedupes on normalised text among
// live rows, so a mailbox re-read after a restart produces Created=0 rather
// than a second copy of every task.
// and the subject as Evidence, under an ExternalID naming the message and the
// span it was extracted from. That key is unique over every row whatever its
// status, so a mailbox re-read after a restart produces Created=0 — and, more
// to the point, a task he already marked done is not re-proposed the next time
// the same unread message is read again.
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
// The mailbox name becomes provenance ("email:INBOX"), and the source
// vocabulary is what the loop's rules trust. An empty name gave "email:" and
// an arbitrary string gave an arbitrary source under that namespace.
mailbox, err := validMailbox(req.Mailbox)
if err != nil {
return ipc.IngestMailResp{}, err
}
msg := email.Message{
UID: req.UID,
From: req.From,
@@ -98,9 +148,15 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
return ipc.IngestMailResp{Skipped: true}, nil
}
ctx, cancel := context.WithTimeout(ctx, m.timeout)
defer cancel()
cands, err := m.ex.Extract(ctx, msg)
// The timeout scopes the EXTRACTION and nothing else. It used to wrap the
// capture writes too, so a model that answered at 119 seconds of a 120
// second budget left the first CaptureTask one second and the third none:
// the work was done, the answer was good, and it was dropped with a
// deadline error. Config calls this a per-message extraction budget, and now
// it is one.
exCtx, cancel := context.WithTimeout(ctx, m.timeout)
cands, err := m.ex.Extract(exCtx, msg)
cancel()
if err != nil {
// The error from internal/email never carries mail text; keep it that way
// by not adding the subject here.
@@ -110,7 +166,13 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
return ipc.IngestMailResp{}, nil
}
source := email.SourcePrefix + req.Mailbox
// A fresh budget for the writes, derived from the caller's context rather
// than from the extraction's. Encrypted-store writes are fast; what this
// bounds is a stuck store, not the model.
ctx, cancel = context.WithTimeout(ctx, captureTimeout)
defer cancel()
source := email.SourcePrefix + mailbox
evidence := truncateRunes(req.Subject, evidenceMaxChars)
now := m.now()
var resp ipc.IngestMailResp
@@ -124,15 +186,16 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
// something she read is a suggestion until he confirms it on /tasks.
Status: store.TaskCandidate,
}
t.ExternalID = mailExternalID(source, req.UID, c.Text)
if due, ok := email.ParseDue(c.Due); ok {
t.Due = &due
}
id, created, err := m.st.CaptureTask(ctx, t)
res, err := m.st.CaptureTask(ctx, t)
if err != nil {
return resp, fmt.Errorf("mail intake: capture: %w", err)
}
resp.TaskIDs = append(resp.TaskIDs, id)
if created {
resp.TaskIDs = append(resp.TaskIDs, res.ID)
if res.Created {
resp.Created++
// Only a row that was actually created. CaptureTask dedupes on
// normalised text among live rows, so a mailbox re-read after a
@@ -165,3 +228,14 @@ func truncateRunes(s string, n int) string {
}
return string(r[:n]) + "…"
}
// mailExternalID names the message and the span a candidate was extracted
// from. The mailbox and UID identify the message; the normalised text
// identifies which of the candidates in it this is, so a message yielding two
// tasks gets two keys and a re-read of it gets neither twice.
//
// UIDs are stable per mailbox, and a mailbox that renumbers (UIDVALIDITY
// changing) re-proposes its tasks once, which is the safe direction.
func mailExternalID(source string, uid uint32, text string) string {
return fmt.Sprintf("%s#%d:%s", source, uid, store.NormalizeTaskText(text))
}
+59
View File
@@ -180,3 +180,62 @@ func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
t.Error("without a llama-server phraser there is nothing to extract with")
}
}
// The mailbox name becomes the provenance string, which is the vocabulary the
// loop's rules trust. "email:" is not a source and neither is "email:anything
// he could post at the socket".
func TestIngestRejectsBadMailbox(t *testing.T) {
for _, name := range []string{"", " ", "IN BOX", "IN\nBOX", "IN\x00BOX", strings.Repeat("щ", maxMailboxChars+1)} {
mi, st, fake := newTestIntake(t, `[{"text":"дело","due":""}]`)
req := ingestReq()
req.Mailbox = name
if _, err := mi.ingest(context.Background(), req); err == nil {
t.Errorf("mailbox %q was accepted", name)
}
if fake.calls != 0 {
t.Errorf("mailbox %q reached the model", name)
}
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
t.Errorf("mailbox %q wrote %d tasks", name, len(tasks))
}
}
}
// slowLLM burns most of the extraction budget before answering, the way a
// Thinking 1.7B does on a long mail.
type slowLLM struct {
reply string
delay time.Duration
}
func (s *slowLLM) Complete(ctx context.Context, _ llm.Req) (string, error) {
select {
case <-time.After(s.delay):
return s.reply, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// The extraction budget must not also bound the writes. It used to be one
// context, so a model answering near the deadline lost the candidates it had
// just produced.
func TestIngestCapturesAfterASlowExtraction(t *testing.T) {
st := newTestStore(t)
mi := &mailIntake{
st: st,
ex: email.NewExtractor(&slowLLM{reply: `[{"text":"оплатить счёт","due":""}]`, delay: 90 * time.Millisecond}, 0, nil),
timeout: 100 * time.Millisecond,
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
}
resp, err := mi.ingest(context.Background(), ingestReq())
if err != nil {
t.Fatalf("ingest: %v", err)
}
if resp.Created != 1 {
t.Fatalf("resp = %+v, want the candidate captured", resp)
}
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 1 {
t.Errorf("got %d tasks, want 1", len(tasks))
}
}
+81 -29
View File
@@ -25,7 +25,7 @@
// When a passkey credential is enrolled AND no env key is set, the daemon
// starts in LOCKED mode: the IPC server runs but rejects all store methods
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
// by MethodUnlock (with that credential's WebAuthn PRF output) unwraps the at-rest
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
// the encrypted store. After unlock, the daemon wires voice, loop, and
// delivery and runs normally.
@@ -34,10 +34,13 @@
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
// persist the wrapped blob — enabling cold-start unlock on the next boot
// after the env key is removed.
// after the env key is removed. That write happens once, when no blob
// exists; replacing an existing one takes an explicit request, see
// cmd/mavend/keyfile.go.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -48,6 +51,7 @@ import (
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -122,7 +126,7 @@ func main() {
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)")
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)")
flag.CommandLine.Parse(args)
reembedOnStart = *reembed
cfg, err := config.Load(*cfgPath)
@@ -164,11 +168,28 @@ func run(args []string) error {
var st *store.Store
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
// dbKey — the plaintext at-rest key, once the daemon has one. Set at boot
// in env-key mode and inside UnlockFn after a cold start. WrapKeyFn reads
// it from an IPC goroutine, hence the atomic: srv's function fields are
// installed before Serve and must not be reassigned afterwards.
var dbKey atomic.Pointer[[]byte]
// wrappedPath resolves the blob location the same way for both the read
// at boot and every write, so a default-path deployment cannot wrap to
// one file and unwrap from another.
wrappedPath := func() string {
if *wrappedKeyPath != "" {
return *wrappedKeyPath
}
return cfg.DefaultWrappedKeyPath()
}
if !locked {
// Normal boot: env key or plaintext (dev/CI)
if envKey != nil {
envKeyBytes = make([]byte, len(envKey))
copy(envKeyBytes, envKey)
dbKey.Store(&envKeyBytes)
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey)
} else {
st, err = store.Open(ctx, cfg.DBPath)
@@ -356,7 +377,11 @@ func run(args []string) error {
if locked {
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
switch m {
case ipc.MethodAssertStepUp, ipc.MethodUnlock:
case ipc.MethodAssertStepUp, ipc.MethodUnlock, ipc.MethodPing:
// Ping is allowed for the same reason the two unlock methods
// are: it never reaches CoreAPI. It answers "she is up and
// locked", which is what mavupdate needs to tell a daemon
// waiting for a passkey apart from one that failed to start.
return nil // allowed in locked mode
default:
return errLocked
@@ -367,6 +392,12 @@ func run(args []string) error {
}
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
srv.LockedFn = dl.isLocked
// wg is declared here rather than next to srv.Serve because the media
// retention loop starts on this path too, and shutdown has to wait for a
// prune in flight: it deletes files.
var wg sync.WaitGroup
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
// configured and there is a llama-server to extract with, in which case
@@ -376,38 +407,55 @@ func run(args []string) error {
wireModelSwap(srv, phr, cfg)
// Vision + the media blob store (Vikunja #252). Both stay dark without a
// media block; MethodDescribeImage answers ErrUnknownMethod then.
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
// The meeting recorder (Vikunja #253) shares that blob store and its
// retention loop. Off unless a capture block enables it, in which case
// all four capture methods answer ErrUnknownMethod.
wireCapture(srv, keeper, st, voiceW, phr, cfg)
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
// Voice identification (Vikunja #255). Enrolment plumbing only until a
// speaker-embedding model exists on disk; off entirely without a speaker
// block, so no wire path takes a voiceprint on a default box.
wireSpeaker(srv, st, cfg)
}
// WrapKeyFn — wraps the env key under the passkey PRF secret and persists
// the wrapped blob. Only wired when the daemon has the key in memory (env
// key mode). Called by mavweb after passkey enrollment.
// WrapKeyFn — wraps the at-rest key under the passkey PRF secret and
// persists the wrapped blob. Called by mavweb after every assertion.
//
// It is wired in locked mode too, not only in env-key mode, and that is
// what makes a v1 blob recoverable. A box enrolled before Vikunja #14
// cold-starts through the legacy public-key retry in mavweb, and the
// StoreEncryptionKey that follows rewrites the blob as v2. Without this
// the only escape from a v1 blob was putting MAVEN_DB_KEY back in the
// environment, which is the thing cold-start unlock exists to avoid.
//
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
// an authenticator without PRF support produces no wrapped file at all
// rather than a file that looks protected and is not.
if envKeyBytes != nil {
srv.WrapKeyFn = func(ctx context.Context, secret []byte) error {
blob, err := webauthn.WrapKey(envKeyBytes, secret)
if envKeyBytes != nil || locked {
srv.WrapKeyFn = func(ctx context.Context, secret []byte, explicit bool) error {
kp := dbKey.Load()
if kp == nil {
return errors.New("wrap encryption key: the daemon is locked and has no key yet (unlock first)")
}
wp := wrappedPath()
// Asserting a passkey is not a request to rewrite the cold-start
// key. Without this an assertion carrying a substituted PRF value
// re-wrapped the real database key under it, and a second
// authenticator silently replaced the first one's blob.
if !explicit {
if _, err := os.Stat(wp); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("check wrapped key: %w", err)
}
}
wrote, err := wrapKeyToFile(wp, *kp, secret)
if err != nil {
return fmt.Errorf("wrap encryption key: %w", err)
return err
}
wp := *wrappedKeyPath
if wp == "" {
wp = cfg.DefaultWrappedKeyPath()
if wrote {
log.Printf("mavend: wrapped encryption key under this passkey's PRF output → %s", wp)
}
if err := os.WriteFile(wp, blob, 0o600); err != nil {
return fmt.Errorf("write wrapped key: %w", err)
}
log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob))
return nil
}
}
@@ -428,15 +476,17 @@ func run(args []string) error {
return nil // already unlocked; the caller does not need to know
}
// The wire cannot authenticate its caller — the socket is
// same-uid — so the unlock path requires a passkey assertion
// that mavweb verified cryptographically first. Without this,
// MethodUnlock is reachable by anything on the box.
// Depth, not a boundary. MethodAssertStepUp is AuthRead, so
// anything that can open the same-uid socket can flip the
// session and reach MethodUnlock. What actually stops a local
// attacker is the 32-byte PRF output they do not have, and that
// was true before this check. What this check stops is an
// accidental unlock attempt from an unrelated local caller.
if !passkeySess.IsStepUp() {
return errors.New("unlock: no verified passkey assertion (assert first)")
}
wp := *wrappedKeyPath
wp := wrappedPath()
blob, err := os.ReadFile(wp)
if err != nil {
return fmt.Errorf("read wrapped key: %w", err)
@@ -446,8 +496,11 @@ func run(args []string) error {
return fmt.Errorf("unwrap key: %w", err)
}
if version == webauthn.BlobV1 {
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Re-enroll the passkey on an authenticator that supports the PRF extension to rewrite it as v2.", wp, version)
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Use the \"rewrite cold-start key\" button on /auth/webauthn with a PRF-capable authenticator to replace it with a v2 blob.", wp, version)
}
// WrapKeyFn needs the key to be able to rewrite the blob later.
keyCopy := bytes.Clone(key)
dbKey.Store(&keyCopy)
// Open the store with the unwrapped key.
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
if err != nil {
@@ -555,8 +608,8 @@ func run(args []string) error {
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
wireMailIntake(srv, st, phr, cfg, evBus)
wireModelSwap(srv, phr, cfg)
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
wireCapture(srv, keeper, st, voiceW, phr, cfg)
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
// Voice identification (Vikunja #255). Enrolment plumbing only until a
// speaker-embedding model exists on disk; off entirely without a speaker
// block, so no wire path takes a voiceprint on a default box.
@@ -622,7 +675,6 @@ func run(args []string) error {
}
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
+125 -20
View File
@@ -13,8 +13,11 @@ import (
"github.com/kami/maven/internal/webfetch"
)
// mcpRefreshInterval — how often the manager re-dials a server that is down.
// The manager applies its own backoff on top, so this being short is cheap.
// mcpRefreshInterval — how often the manager is asked to re-dial servers that
// are down. It is a tick, not a retry rate: mcp.Manager holds a per-server
// backoff that starts at DefaultReconnectEvery and doubles to
// MaxReconnectEvery, so a permanently misconfigured stdio server is not
// re-exec'd once a minute forever.
const mcpRefreshInterval = time.Minute
// mcpWiring — the MCP client, when the `mcp` block configures at least one
@@ -29,9 +32,16 @@ type mcpWiring struct {
st *store.Store
}
// wireMCP builds the manager, connects, and proposes what it found. It never
// fails the daemon: a server that is unreachable at boot is logged and retried,
// because Maven starting is not contingent on someone else's process.
// wireMCP builds the manager. It does NOT dial: run does that, on its own
// goroutine, which is what makes "Maven starting is not contingent on someone
// else's process" true rather than merely intended.
//
// Dialing here used to be synchronous with a 30s budget, from wireVoice, from
// run. Connect dials serially and each HTTP dial is three requests against
// that server's timeout, so one black-holed endpoint cost 15s of boot and two
// cost the whole budget. On the passkey path wireVoice runs inside the unlock
// handler, so it delayed the answer to an unlock as well. Not failing and not
// blocking are different properties and only the first one held.
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
servers := cfg.MCPServers()
if len(servers) == 0 {
@@ -43,6 +53,7 @@ func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
limits.DenyHosts = cfg.MCP.DenyHosts
limits.MaxBytes = cfg.MCP.MaxBytes
limits.Timeout = time.Duration(cfg.MCP.Timeout)
limits.HostInterval = time.Duration(cfg.MCP.HostInterval)
}
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
if err != nil {
@@ -52,30 +63,58 @@ func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
log.Printf("mcp: not wired: %v", err)
return nil
}
w := &mcpWiring{mgr: mgr, st: st}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
mgr.Connect(ctx)
w.propose(ctx)
return w
return &mcpWiring{mgr: mgr, st: st}
}
// propose writes a 'proposed' allowlist row for every discovered tool. It does
// NOT enable anything: a configured server is a place Maven may look, not a
// capability she has. Kami enables what he wants on /tools, behind step-up,
// which is the same gate a shell tool goes through.
// connect dials every server and reconciles what came back. Called from run,
// under the daemon's context, so a shutdown during a slow dial is observed.
func (w *mcpWiring) connect(ctx context.Context) {
if w == nil {
return
}
w.mgr.Connect(ctx)
w.propose(ctx)
}
// propose writes a 'proposed' allowlist row for every discovered tool, and
// reconciles the rows that already exist against what the server offers today.
// It does NOT enable anything: a configured server is a place Maven may look,
// not a capability she has. Kami enables what he wants on /tools, behind
// step-up, which is the same gate a shell tool goes through.
//
// Re-running on every boot is idempotent — ProposeMCPTool never touches an
// existing row, so a tool he disabled stays disabled and one he enabled keeps
// the cmd he enabled it with.
// Three things happen per discovered tool.
//
// A name not in the store becomes a proposal, carrying the tool's fingerprint.
//
// A name already in the store is reconciled against that fingerprint. A tool
// whose description, schema or readOnlyHint changed since it was approved drops
// back to 'proposed' and, if it stopped claiming read-only, to destructive=1.
// Insert-or-skip was not enough on its own: the cmd is a late-bound reference
// to a name the far end owns, so the server can redefine list_tasks into
// something that writes without the row changing at all.
//
// A row whose server is connected and no longer offers the tool is withdrawn.
func (w *mcpWiring) propose(ctx context.Context) {
if w == nil {
return
}
now := time.Now()
fresh := 0
fresh, changed := 0, 0
seen := map[string]string{} // local name → "server/tool", for collisions
for _, t := range w.mgr.Tools() {
name := mcp.LocalName(t.Server, t.Name)
remote := t.Server + "/" + t.Name
// Two different tools can flatten to one local name: server "vik" with
// tool "list_tasks" and server "vik_list" with tool "tasks" both give
// "vik_list_tasks". The store keys rows by name, so the second would
// land on the first one's row. Config-controlled and therefore rare,
// but silently reusing a row is the wrong way to lose that race.
if prev, dup := seen[name]; dup {
log.Printf("mcp: %s and %s both map to the allowlist name %q — skipping the second, rename a server",
prev, remote, name)
continue
}
seen[name] = remote
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
// in this direction only costs a question.
destructive := !t.ReadOnly
@@ -83,19 +122,82 @@ func (w *mcpWiring) propose(ctx context.Context) {
if t.Description != "" {
provenance += ": " + t.Description
}
fp := mcp.Fingerprint(t)
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
mcp.Cmd(t.Server, t.Name), destructive, provenance, now)
mcp.Cmd(t.Server, t.Name), destructive, provenance, fp, now)
if err != nil {
log.Printf("mcp: propose %s: %v", name, err)
continue
}
if ok {
fresh++
continue
}
// The row already existed. Its provenance is whatever the server said
// the first time; reconciling rewrites it, so what /tools shows is what
// the server says now.
ch, err := w.st.ReconcileMCPTool(ctx, name, fp, destructive, provenance, now)
if err != nil {
log.Printf("mcp: reconcile %s: %v", name, err)
continue
}
if !ch.Changed {
continue
}
changed++
switch {
case ch.Demoted && ch.Escalated:
log.Printf("mcp: %s changed on the server and no longer claims read-only — disabled and marked destructive, re-approve it on /tools", name)
case ch.Demoted:
log.Printf("mcp: %s changed on the server since it was enabled — disabled, re-approve it on /tools", name)
default:
log.Printf("mcp: %s changed on the server; the proposal now shows the new description", name)
}
}
w.withdrawGone(ctx, seen, now)
if fresh > 0 {
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
}
if changed > 0 {
log.Printf("mcp: %d tool(s) changed since approval and need another look", changed)
}
}
// withdrawGone disarms rows whose tool the server stopped offering. Only
// servers that are CONNECTED are considered: a tool missing because its server
// is down is not a tool that was withdrawn, and disabling a capability every
// time a process restarts would be worse than the problem.
func (w *mcpWiring) withdrawGone(ctx context.Context, seen map[string]string, now time.Time) {
live := map[string]bool{}
for _, name := range w.mgr.Connected() {
live[name] = true
}
if len(live) == 0 {
return
}
rows, err := w.st.ListTools(ctx, "")
if err != nil {
log.Printf("mcp: list tools: %v", err)
return
}
for _, row := range rows {
server, remote, ok := mcp.ParseCmd(row.Cmd)
if !ok || !live[server] {
continue
}
if _, still := seen[row.Name]; still {
continue
}
note := fmt.Sprintf("mcp %s/%s: no longer offered by the server", server, remote)
wasEnabled, err := w.st.WithdrawTool(ctx, row.Name, note, now)
if err != nil {
log.Printf("mcp: withdraw %s: %v", row.Name, err)
continue
}
if wasEnabled {
log.Printf("mcp: %s was enabled but %s no longer offers it — disabled", row.Name, server)
}
}
}
// run re-dials downed servers and picks up tools that appeared, until ctx is
@@ -104,6 +206,9 @@ func (w *mcpWiring) run(ctx context.Context) {
if w == nil {
return
}
// The first dial happens here rather than at wiring time, so boot never
// waits on someone else's process.
w.connect(ctx)
t := time.NewTicker(mcpRefreshInterval)
defer t.Stop()
for {
+19
View File
@@ -31,6 +31,23 @@ func TestWireMCPOffWhenUnconfigured(t *testing.T) {
}
}
// Wiring must not dial. Boot used to block for the whole per-server timeout
// budget on a black-holed endpoint, and on the passkey path that delay landed
// inside the unlock handler.
func TestWireMCPDoesNotDial(t *testing.T) {
st := newTestStore(t)
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
}}}}, st)
if w == nil {
t.Fatal("a configured server should wire")
}
defer w.close()
if s := w.status(); len(s) != 1 || s[0].Err != "" {
t.Fatalf("wireMCP dialled: %+v", s)
}
}
// An unreachable server must not stop the daemon, must be reported as down, and
// must propose nothing.
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
@@ -42,6 +59,7 @@ func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
t.Fatal("a configured server should still wire")
}
defer w.close()
w.connect(context.Background())
st2 := w.status()
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
t.Fatalf("status = %+v", st2)
@@ -67,6 +85,7 @@ func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
t.Fatal("should wire")
}
defer w.close()
w.connect(context.Background())
s := w.status()[0]
if s.Connected {
t.Fatal("a loopback server must not connect without allow_private")
+17 -3
View File
@@ -21,6 +21,20 @@ import (
"github.com/kami/maven/internal/store"
)
// memoryEvalTimeout — the per-request deadline on one evaluation.
//
// It used to be five minutes, on the grounds that nobody waits for the answer.
// Nobody waits for the evaluation, but there is ONE resident model behind one
// llama-server, so a voice turn arriving mid-evaluation waited behind it: five
// minutes of evaluation was five minutes of a mute assistant.
//
// The background client now yields the slot while a turn is in flight, so the
// collision is handled where it belongs and this is a prompt budget again.
// Sixty seconds is long enough for a Thinking model here, and an evaluation cut
// off costs nothing, because it is retried at the next interval. Raise it if
// observations start truncating.
const memoryEvalTimeout = 60 * time.Second
// memoryEvalWorker — ticker + evaluator.
type memoryEvalWorker struct {
eval *memeval.Evaluator
@@ -47,9 +61,9 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
if interval <= 0 {
interval = config.DefaultMemoryEvalInterval
}
// A generous per-request timeout: this is a long prompt to a Thinking model
// and nobody is waiting on the answer.
client := llmClientFor(lp, 5*time.Minute)
// Background: nobody is waiting on an observation, and it must not sit in
// front of a voice turn on the single llama-server slot.
client := llmBackgroundClientFor(lp, memoryEvalTimeout)
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
MaxItems: cfg.MemoryEval.MaxItems,
MinConfidence: cfg.MemoryEval.MinConfidence,
+36
View File
@@ -58,6 +58,7 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
ModelPath: res.ModelPath,
BaseURL: res.BaseURL,
RolledBack: res.RolledBack,
NoBackend: res.NoBackend,
TookMs: res.Took.Milliseconds(),
}
if err != nil {
@@ -106,8 +107,43 @@ func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
// the port of a server that no longer exists, and the daemon would degrade to
// the classifier permanently after the first swap. The client is re-pointed, not
// rebuilt, so nothing that holds it has to know a swap happened.
// SetSwapGate is the other half, and on the deploy shape it is the load-bearing
// one:
// llama-server is relaunched on the same fixed port, so SetBaseURL is usually a
// no-op, while the gate is what makes the swap's drain count these callers at
// all. Without it a swap can kill the server mid-routing-decision.
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
c := llm.New(lp.BaseURL(), timeout)
c.SetGate(residentGate, false)
c.SetSwapGate(lp)
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
return c
}
// backgroundQuiet — how long background work stays off the resident model after
// a foreground request. Long enough to cover the gap between the router call and
// the phraser call of one turn (router p50 is ~2.7s on this box), short enough
// that a quiet mailbox is still read promptly.
const backgroundQuiet = 10 * time.Second
// residentGate — the priority gate on the one llama-server slot, shared by every
// client llmClientFor builds. Package level because the daemon owns exactly one
// llama-server: two gates would be two opinions about one queue.
//
// The problem it solves: llama-server runs a single slot, so requests queue. Mail
// extraction is allowed two minutes, and a first poll can hand core 25 messages
// back to back. Without a gate a voice turn arriving mid-extraction waits for
// whatever is left of that budget, the router times out into the classifier
// cascade at its 36.8% floor, and the phraser just waits.
var residentGate = llm.NewGate(backgroundQuiet)
// llmBackgroundClientFor is llmClientFor for work nobody is waiting on: mail
// extraction and memory evaluation. Same swap-following client, but it yields
// to voice turns and only one such request runs at a time.
func llmBackgroundClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
c := llm.New(lp.BaseURL(), timeout)
c.SetGate(residentGate, true)
c.SetSwapGate(lp)
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
return c
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/netscan"
)
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
// longer than this has already failed as a turn, so the scan returns whatever
// it found rather than keeping him waiting.
//
// It has to be consistent with the shipped defaults or every scan is truncated:
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
// second is a little over ten seconds plus the tail dials. 30s leaves room for
// that without pretending a slower rate would fit.
const scanBudget = 30 * time.Second
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
// to be two full sweeps of the LAN, up to a thousand connections each. The
// network does not change on the scale of a follow-up question, and the cheapest
// packet is the one not sent.
const scanCacheTTL = 2 * time.Minute
// scanReadOut — how many hosts go into the written record's first lines before
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
const scanReadOut = 20
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
// never puts a discovery packet on the network.
//
// Unlike the house, a scan is a READ, so it is a query source rather than an
// act: there is no allowlist row and no confirm turn, because nothing changes.
// What makes that safe is that the range is not an argument — see
// internal/netscan's package comment.
type netWiring struct {
scanner *netscan.Scanner
subnets []string
// api — where the address list is WRITTEN. The spoken answer is a count
// and a shape, so the detail has to land somewhere readable; a note under
// source "scan:lan" puts it on /history and, through the intake decorator,
// on /events. It is also the only record that Maven put packets on the LAN
// at all. nil ⇒ nothing is written, which is what the tests use.
api ipc.CoreAPI
now func() time.Time
mu sync.Mutex
cached netscan.Result
cachedAt time.Time
}
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
nc, ok := cfg.NetScanner()
if !ok {
return nil
}
if err := netscan.Validate(nc); err != nil {
// config.validate already ran this, so reaching here is a programming
// error rather than a config one. Not fatal: the scanner off is a
// working Maven.
log.Printf("netscan: not wired: %v", err)
return nil
}
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
}
// scan runs a scan, or reuses one younger than scanCacheTTL.
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := w.now()
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
return w.cached, nil
}
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
defer cancel()
res, err := w.scanner.Scan(scanCtx)
if err != nil {
return res, err
}
w.cached, w.cachedAt = res, now
// Written on a fresh scan only: the record is a trace of packets going out,
// so a cached answer must not forge a second one.
w.writeScanRecord(ctx, res)
return res, nil
}
// scanSummary answers "какие устройства в сети?" in one spoken line.
//
// It does NOT read addresses out. This is the query path, so the reply goes to
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
// a digit stream nobody can follow through a speaker. She says how many and
// what shape they are; the addresses go into a note (see writeScanRecord).
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
if w == nil {
return "", false
}
res, err := w.scan(ctx)
if err != nil {
log.Printf("netscan: scan: %v", err)
return "не получилось просканировать сеть.", true
}
// A truncated run is not a statement about the LAN. Saying "нашла 6
// устройств" after stopping two thirds of the way through the range is a
// false claim, and the addresses at the end are the ones that go missing.
tail := ""
if res.Truncated {
tail = ", но успела посмотреть не всю сеть"
}
if len(res.Hosts) == 0 {
return "в сети никого не нашла" + tail + ".", true
}
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape
}
out += tail
if w.api != nil {
out += ". список записала"
}
return out + ".", true
}
// scanShape describes the hosts by what they answer on, which is the part of
// the answer that carries meaning out loud: "два с вебом" says more about the
// flat than four octets do.
func scanShape(hosts []netscan.Host) string {
var web, ssh, quiet int
for _, h := range hosts {
hasWeb, hasSSH := false, false
for _, p := range h.Ports {
switch p {
case 80, 443, 8080:
hasWeb = true
case 22:
hasSSH = true
}
}
if hasWeb {
web++
}
if hasSSH {
ssh++
}
// No open port at all: seen only through the ARP cache.
if len(h.Ports) == 0 {
quiet++
}
}
var parts []string
if web > 0 {
parts = append(parts, fmt.Sprintf("%d с вебом", web))
}
if ssh > 0 {
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
}
if quiet > 0 {
parts = append(parts, fmt.Sprintf("%d молча", quiet))
}
if len(parts) == 0 {
return ""
}
return "из них " + strings.Join(parts, ", ")
}
// writeScanRecord stores the address list as a note. This is both where the
// detail becomes readable and the only trace that a scan happened at all: a
// scan is a read, but "when did she last put packets on the LAN" deserves an
// answer.
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil {
return
}
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
shown := res.Hosts
if len(shown) > scanReadOut {
shown = shown[:scanReadOut]
}
for _, h := range shown {
s := h.Addr
if len(h.Ports) > 0 {
ps := make([]string, 0, len(h.Ports))
for _, p := range h.Ports {
ps = append(ps, fmt.Sprintf("%d", p))
}
s += " (" + strings.Join(ps, ", ") + ")"
}
if h.MAC != "" {
s += " " + h.MAC
}
lines = append(lines, s)
}
if len(res.Hosts) > len(shown) {
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
}
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
log.Printf("netscan: write scan note: %v", err)
}
}
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
// 5+ устройств, and the teens are all the last form.
func hostWord(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "устройств"
}
switch n % 10 {
case 1:
return "устройство"
case 2, 3, 4:
return "устройства"
default:
return "устройств"
}
}
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
// bounds exist to prevent.
func isNetworkQuery(u string) bool {
s := strings.ToLower(strings.TrimSpace(u))
if s == "" {
return false
}
// Whole tokens for the network nouns: the bare substring "сети" is inside
// "посетил", so "сколько машин я посетил?" used to read as a request to
// scan the LAN. The prefix forms below are stems that have no such
// collisions.
network := false
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
if homeWord(s, w) {
network = true
break
}
}
if !network {
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
if strings.Contains(s, w) {
network = true
break
}
}
}
if !network {
return false
}
// An explicit ask to scan, or a phrase that can only be about the LAN.
// "кто в сети" carries no device noun but means nothing else.
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
if strings.Contains(s, w) {
return true
}
}
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
if !ask {
return false
}
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
if strings.Contains(s, w) {
return true
}
}
return false
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"context"
"net"
"strconv"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
)
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
for name, cfg := range map[string]*config.Config{
"no block": {},
"written but dark": {NetScan: &config.NetScanConfig{
Subnets: []string{"192.168.1.0/24"},
}},
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
"enabled but public": {NetScan: &config.NetScanConfig{
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
}},
"enabled but far too wide": {NetScan: &config.NetScanConfig{
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
}},
} {
t.Run(name, func(t *testing.T) {
if w := wireNetScan(cfg, nil); w != nil {
t.Fatal("the scanner must not wire for this config")
}
})
}
var w *netWiring
if _, ok := w.scanSummary(context.Background()); ok {
t.Fatal("a nil wiring must not claim a query")
}
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
}}, nil)
if ok == nil {
t.Fatal("a valid enabled block should wire")
}
}
// A loopback /32 with nothing listening on the scanned port: the summary must
// come back honest rather than inventing a host. This also exercises the real
// dialer end to end without touching anything outside this box.
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
// Port 1 on loopback: nothing listens and the connection is refused
// immediately, so the scan is fast and touches only this machine.
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
}}, nil)
if w == nil {
t.Fatal("wireNetScan returned nil")
}
out, claimed := w.scanSummary(context.Background())
if !claimed {
t.Fatal("the summary did not claim the turn")
}
if out == "" {
t.Fatal("empty summary")
}
// Persona: feminine self-reference, informal address, no pet names.
low := strings.ToLower(out)
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
if strings.Contains(low, bad) {
t.Errorf("persona violation %q in %q", bad, out)
}
}
}
func TestHostWordAgreesWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := hostWord(n); got != want {
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
}
}
}
func TestIsNetworkQuery(t *testing.T) {
yes := []string{
"какие устройства в сети?",
"кто в сети?",
"просканируй сеть",
"покажи устройства в локальной сети",
"сколько машин в сети",
}
no := []string{
"",
"интернет не работает",
"сеть какая-то медленная",
"я в сети инстаграма",
"что включено дома?",
"напомни оплатить интернет",
}
for _, u := range yes {
if !isNetworkQuery(u) {
t.Errorf("isNetworkQuery(%q) = false, want true", u)
}
}
for _, u := range no {
if isNetworkQuery(u) {
t.Errorf("isNetworkQuery(%q) = true, want false", u)
}
}
}
// notingAPI counts the notes a scan writes, and remembers the last one.
type notingAPI struct {
ipc.CoreAPI
n int
last string
}
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
a.n++
a.last = text
return int64(a.n), nil
}
// The spoken answer must not be a list of IP addresses. It goes to piper as
// well as to /chat, and six dotted quads read out as a digit stream is not an
// answer anybody can use. The addresses belong in the written record.
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
api := &notingAPI{}
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
}}, api)
if w == nil {
t.Fatal("wireNetScan returned nil")
}
out, claimed := w.scanSummary(context.Background())
if !claimed {
t.Fatal("the summary did not claim the turn")
}
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
t.Errorf("the spoken reply reads addresses out loud: %q", out)
}
if !strings.Contains(out, "нашла 1 устройство") {
t.Errorf("reply = %q, want a count", out)
}
if api.n != 1 {
t.Fatalf("wrote %d notes, want 1", api.n)
}
if !strings.Contains(api.last, "127.0.0.1") {
t.Errorf("the written record has no addresses: %q", api.last)
}
// A follow-up question inside the TTL reuses the answer: two questions in
// a row must not be two sweeps of the LAN.
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
}
}
+70 -8
View File
@@ -19,7 +19,14 @@ import (
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
// so a false positive here is a network-reachable way to flip a daemon-wide
// setting. See classifyQuietToggle for the matching rule.
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
//
// src is the channel the utterance arrived on, and it is written straight into
// the fact. Every toggle used to be stored as "tap:voice", including the ones
// typed into the web UI, which left the facts table claiming a microphone flipped
// a setting nobody spoke to. This is the one function where that matters most:
// when he goes looking at why quiet mode is on, provenance is the first column
// he reads.
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, src turnSource) (string, bool) {
on, off := classifyQuietToggle(text)
if !on && !off {
return "", false
@@ -35,7 +42,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (
Kind: "config",
Key: "quiet_hours",
Value: val,
Source: "tap:voice",
Source: string(src),
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
@@ -110,11 +117,18 @@ func quietPhrase(tokens, pattern []string) bool {
}
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
//
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
// and negation is not an adjacency phenomenon. "не надо тихий режим" put two
// tokens between "не" and "тих", so the OFF pattern missed, the ON pattern
// {"тих","режим"} matched, and asking for quiet mode to stop turned it on.
// Negation is handled by quietNegators below, over the whole utterance.
var (
quietOffPhrases = [][]string{
{"quiet", "off"}, {"quiet", "end"},
{"громк", "режим"}, {"шумн", "режим"},
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
{"отмен", "тих"}, {"выключ", "тих"},
}
quietOnPhrases = [][]string{
{"quiet", "on"}, {"quiet", "mode"},
@@ -123,11 +137,44 @@ var (
}
)
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
// resolved before ON for the same reason classifyConfirm checks negatives
// first: the OFF phrases are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them and "выключи
// тихий режим" would turn quiet mode on. Negation wins.
// quietNegatorWords — negators that are whole words with no useful stem.
var quietNegatorWords = map[string]bool{
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
}
// quietNegatorStems — negators that inflect. Matched through quietStem, the
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
// and "выключай" all count and "выключатель" does not.
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
// quietNegated reports whether the utterance carries a negator. Two ON phrases
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
// requests FOR quiet, so they are excluded before the scan: a negator only
// counts when it is not part of the phrase that matched.
func quietNegated(tokens []string, matched []string) bool {
if len(matched) > 0 && matched[0] == "не" {
return false
}
for _, t := range tokens {
if quietNegatorWords[t] {
return true
}
for _, stem := range quietNegatorStems {
if quietStem(t, stem) {
return true
}
}
}
return false
}
// classifyQuietToggle reads an utterance as a quiet-mode command.
//
// Explicit OFF phrases resolve first, for the same reason classifyConfirm
// checks negatives first: they are built out of the ON words ("выключи тихий"
// contains "тихий"), so scanning ON first would shadow them. An ON phrase that
// matches is then checked for negation across the whole utterance, so any way
// of saying "not quiet mode" turns it off rather than on.
func classifyQuietToggle(text string) (on, off bool) {
tokens := quietTokens(text)
for _, p := range quietOffPhrases {
@@ -137,8 +184,23 @@ func classifyQuietToggle(text string) (on, off bool) {
}
for _, p := range quietOnPhrases {
if quietPhrase(tokens, p) {
if quietNegated(tokens, p) {
return false, true
}
return true, false
}
}
// No ON phrase matched, but he negated a quiet word: "не тихо", "хватит
// тихого режима". The ON vocabulary cannot see these — bare "тих" only
// matches a one-token utterance, by design, so the negator pushes the token
// count past it — and reading them as "no command" would leave quiet mode
// on after he asked for it to stop.
if quietNegated(tokens, nil) {
for _, t := range tokens {
if quietStem(t, "тих") {
return false, true
}
}
}
return false, false
}
+55 -1
View File
@@ -79,7 +79,7 @@ func TestResolveQuietToggle(t *testing.T) {
t.Run(tc.text, func(t *testing.T) {
api := &quietFakeAPI{}
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
reply, handled := h.resolveQuietToggle(context.Background(), tc.text)
reply, handled := h.resolveQuietToggle(context.Background(), tc.text, sourceVoice)
if tc.want == quietNone {
if handled || reply != "" {
@@ -112,3 +112,57 @@ func TestResolveQuietToggle(t *testing.T) {
})
}
}
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
// the quiet word made the ON pattern win and asking for quiet mode to STOP
// turned it on. Negation is scanned over the whole utterance now.
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
off := []string{
"не надо тихий режим",
"не хочу тихий режим",
"тихий режим выключи",
"убери тихий режим",
"хватит тихого режима",
"прекрати тихий режим",
"тихий режим отмени пожалуйста",
}
for _, text := range off {
t.Run(text, func(t *testing.T) {
on, isOff := classifyQuietToggle(text)
if on || !isOff {
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
}
})
}
// The two ON phrases that are themselves built on "не" must stay ON: they
// are requests FOR quiet, not negations of one.
for _, text := range []string{"не шуми", "не беспокоить"} {
t.Run(text, func(t *testing.T) {
on, isOff := classifyQuietToggle(text)
if !on || isOff {
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
}
})
}
}
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
// mavweb /api/chat and telegram, not only the microphone. Every write used to
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
// it and the provenance column lied about a daemon-wide setting.
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
for _, src := range []turnSource{sourceVoice, sourceText} {
t.Run(string(src), func(t *testing.T) {
api := &quietFakeAPI{}
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
t.Fatal("expected the toggle to match")
}
if api.got.Source != string(src) {
t.Errorf("source = %q, want %q", api.got.Source, src)
}
})
}
}
+292 -48
View File
@@ -55,8 +55,10 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
@@ -102,9 +104,22 @@ type scenario struct {
Nexus string `json:"nexus_resolve,omitempty"`
Hexis string `json:"hexis_capabilities,omitempty"`
// Tools — allowlist rows to enable before the first step. An act is only
// dispatched when its verb is on the enabled allowlist, so a scenario that
// wants to exercise one has to say which rows Kami had enabled.
Tools []toolRow `json:"tools,omitempty"`
Steps []step `json:"steps"`
}
// toolRow — one enabled allowlist row. Cmd is empty for an ecosystem verb,
// which is intercepted before the process executor is ever reached.
type toolRow struct {
Name string `json:"name"`
Cmd []string `json:"cmd,omitempty"`
Destructive bool `json:"destructive,omitempty"`
}
// scriptEntry — one canned model answer. Match is a substring of the user
// message; the first entry whose Match is contained in it wins, and an entry
// with an empty Match is the catch-all.
@@ -125,6 +140,16 @@ type scriptEntry struct {
// and then asserts. Assertions are evaluated against everything recorded since
// the run began, except expect_no_send and expect_no_call, which are scoped to
// this step — "nothing was sent because of THIS" is the useful question.
//
// The asymmetry is worth stating plainly, because it changes what a scenario
// author is writing. expect_sent_contains, expect_called and expect_events are
// RUN-scoped: they pass if the thing ever happened, at any earlier step. So
// repeating expect_events: ["rss:tech"] on a later step asserts nothing new,
// it just re-checks the earlier arrival. The negatives — expect_no_send,
// expect_not_called, expect_no_events — are STEP-scoped, and are the ones that
// say something about this moment. expect_reply_contains and
// expect_reply_lacks read the most recent reply only, so a step with no
// utterance re-checks the previous one.
type step struct {
At string `json:"at"`
Note string `json:"note,omitempty"`
@@ -176,6 +201,13 @@ type signalStep struct {
Value string `json:"value"`
Source string `json:"source"`
Kind string `json:"kind,omitempty"`
// Confidence — 0 ⇒ 1.0, an observation Maven made herself. A relayed
// notification is not that: the ambient path writes
// calendar.AmbientConfidence, 0.6, and factPriority in intake.go branches
// on exactly that difference. The field exists so a replay can reach the
// low branch, which it could not while write() hardcoded 1.0.
Confidence float64 `json:"confidence,omitempty"`
}
type arriveStep struct {
@@ -225,6 +257,28 @@ type simWorld struct {
// broken scenario is diagnosable without a debugger.
transcript []string
replies []string
// fatalf — the abort seam. Defaults to t.Fatalf. It exists so a test can
// reach the harness's own refusals (a backwards step, a missing WAV) and
// assert on them instead of dying with the scenario.
fatalf func(format string, args ...any)
// published — how many events the bus accepted, counted through a
// subscriber. bus.Len() saturates at the ring capacity and cannot answer
// "did anything arrive during this step" once a long scenario has filled
// it.
mu sync.Mutex
published int
// audio — golden_v1.json, parsed once. A scenario with twenty audio steps
// used to read and parse the manifest twenty times.
audio map[string]string
}
func (w *simWorld) publishCount() int {
w.mu.Lock()
defer w.mu.Unlock()
return w.published
}
// recordingSink captures every send, mutex-guarded (the tick loop dispatches
@@ -324,6 +378,25 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
t: t, clock: clock, loc: start.Location(), start: start,
store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted,
}
w.fatalf = t.Fatalf
bus.Subscribe(func(event.Event) {
w.mu.Lock()
w.published++
w.mu.Unlock()
})
// Allowlist rows the scenario asked for, enabled before the first step. An
// act only reaches a dispatch if a verb is on the enabled list, so without
// this a scenario cannot script one at all.
for _, tr := range sc.Tools {
cmd := tr.Cmd
if len(cmd) == 0 {
cmd = []string{"true"}
}
if err := st.EnableTool(context.Background(), tr.Name, cmd, tr.Destructive, "sim", start); err != nil {
t.Fatalf("scenario %q: enabling tool %q: %v", sc.Name, tr.Name, err)
}
}
// Ecosystem fakes, wired only when the scenario supplies a body — a box
// with no praxis block has no praxis client, and a scenario must be able to
@@ -345,7 +418,10 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
// classifier in is deliberate; it is the failure floor, and a scenario that
// scripts no route for an utterance exercises it.
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(nil)
// The matcher reads the live enabled allowlist, same as the daemon's. It
// used to be built on a nil API, which meant any scenario that produced an
// act panicked the moment the matcher was consulted.
matcher := tool.NewMatcher(api)
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
w.handler = &reactiveHandler{
@@ -355,6 +431,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
embedder: emb,
api: api,
matcher: matcher,
tools: tool.NewExecutor(api, 5*time.Second),
phraser: phraser.NewStub(),
replier: newLLMReplier(scripted, nil),
now: clock.Now,
@@ -416,8 +493,9 @@ func (w *simWorld) advanceTo(at string) {
target := w.timeOf(at)
now := w.clock.Now()
if target.Before(now) {
w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order",
w.fatalf("step at %s goes backwards from %s — scenario steps must be in order",
at, now.In(w.loc).Format("15:04:05"))
return
}
w.clock.Advance(target.Sub(now))
}
@@ -446,8 +524,8 @@ func (w *simWorld) run(sc scenario) {
w.logf("# %s", s.Note)
}
sendsBefore := w.sink.count()
callsBefore := w.callCount()
eventsBefore := w.bus.Len()
callsBefore := w.callMark()
eventsBefore := w.publishCount()
w.stimulate(ctx, s)
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
@@ -466,7 +544,7 @@ func (w *simWorld) stimulate(ctx context.Context, s step) {
switch {
case s.Say != "":
reply := w.handler.runTurn(ctx, s.Say)
reply := w.handler.runTurn(ctx, s.Say, sourceText)
w.replies = append(w.replies, reply)
w.logf("он: %s", s.Say)
w.logf("она: %s", reply)
@@ -503,10 +581,14 @@ func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) {
if kind == "" {
kind = "env"
}
conf := sig.Confidence
if conf == 0 {
conf = 1.0
}
if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0,
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: conf,
}); err != nil {
w.t.Fatalf("write fact %s: %v", sig.Key, err)
w.fatalf("write fact %s: %v", sig.Key, err)
}
}
@@ -552,29 +634,39 @@ func (w *simWorld) arrive(ctx context.Context, a arriveStep) {
// known to contain, by reading cmd/mavsttd's golden manifest (#288's format,
// reused rather than duplicated). An unknown reference fails the scenario
// rather than quietly transcribing to "".
//
// The manifest is read and parsed once per world, not once per step: a
// scenario with twenty audio steps should cost one file read.
func (w *simWorld) audioText(ref string) string {
w.t.Helper()
manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json")
raw, err := os.ReadFile(manifest)
if err != nil {
w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err)
}
var m struct {
Cases []struct {
Name string `json:"name"`
WAV string `json:"wav"`
Text string `json:"text"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &m); err != nil {
w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
}
for _, c := range m.Cases {
if c.Name == ref || c.WAV == ref {
return c.Text
if w.audio == nil {
raw, err := os.ReadFile(manifest)
if err != nil {
w.fatalf("audio step %q: reading %s: %v", ref, manifest, err)
return ""
}
var m struct {
Cases []struct {
Name string `json:"name"`
WAV string `json:"wav"`
Text string `json:"text"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &m); err != nil {
w.fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
return ""
}
w.audio = make(map[string]string, len(m.Cases)*2)
for _, c := range m.Cases {
w.audio[c.Name] = c.Text
w.audio[c.WAV] = c.Text
}
}
w.t.Fatalf("audio step %q: no such case in %s", ref, manifest)
if text, ok := w.audio[ref]; ok && ref != "" {
return text
}
w.fatalf("audio step %q: no such case in %s", ref, manifest)
return ""
}
@@ -582,35 +674,62 @@ func voicePTT() voice.PushToTalkReq {
return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}}
}
// callCount — how many requests every wired ecosystem fake has seen.
func (w *simWorld) callCount() int {
n := 0
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
// fakes — every ecosystem fake, in a fixed order. Both the mark and the paths
// walk this same order, which is the whole point: they have to agree.
func (w *simWorld) fakes() []*fakeServer { return []*fakeServer{w.praxis, w.nexus, w.hexis} }
// callMark takes a PER-SERVER snapshot of how many requests each fake has
// seen. It is not a total.
//
// A total cannot be used to slice the concatenated path list, and the harness
// used to do exactly that. callPaths concatenates praxis, then nexus, then
// hexis; a total counts arrivals across all three. With praxis on 3 requests
// and nexus on 1, the total is 4 and the list is [p1 p2 p3 n1]. A step that
// calls praxis once makes the list [p1 p2 p3 p4 n1], and paths[4:] is [n1].
// The new praxis call sits at index 3 and is never looked at, so
// expect_not_called on praxis passed on a step that called praxis. The same
// slice reported the stale nexus call as new, so expect_not_called on
// "/resolve" failed on a step that resolved nothing.
func (w *simWorld) callMark() []int {
mark := make([]int, len(w.fakes()))
for i, fs := range w.fakes() {
if fs != nil {
n += len(fs.Requests())
mark[i] = len(fs.Requests())
}
}
return n
return mark
}
func (w *simWorld) callPaths() []string {
// callPathsSince returns the calls each fake took after its own mark. A nil
// mark means "everything, from the beginning of the run".
func (w *simWorld) callPathsSince(mark []int) []string {
var out []string
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
for i, fs := range w.fakes() {
if fs == nil {
continue
}
for _, r := range fs.Requests() {
reqs := fs.Requests()
from := 0
if mark != nil && i < len(mark) {
from = mark[i]
}
if from > len(reqs) {
from = len(reqs)
}
for _, r := range reqs[from:] {
out = append(out, r.Method+" "+r.Path)
}
}
return out
}
func (w *simWorld) callPaths() []string { return w.callPathsSince(nil) }
// ---------------------------------------------------------------------------
// Assertions
// ---------------------------------------------------------------------------
func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) {
func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) {
w.t.Helper()
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
if s.Note != "" {
@@ -654,9 +773,10 @@ func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore
fail("no ecosystem call matches %q; calls so far: %v", want, paths)
}
}
since := w.callPathsSince(callsBefore)
for _, unwanted := range s.ExpectNotCalled {
if anyContains(paths[callsBefore:], unwanted) {
fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:])
if anyContains(since, unwanted) {
fail("an ecosystem call matched %q and must not have: %v", unwanted, since)
}
}
@@ -666,8 +786,12 @@ func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore
fail("no intake event matches %q; journal: %v", want, eventLines(evs))
}
}
if s.ExpectNoEvents && w.bus.Len() > eventsBefore {
fail("expected nothing to arrive, journal grew to %d", w.bus.Len())
// Counted publishes, not bus.Len(): the ring saturates at its capacity, so
// a long scenario that filled it made every later expect_no_events pass
// unconditionally.
if s.ExpectNoEvents && w.publishCount() > eventsBefore {
fail("expected nothing to arrive, %d event(s) were published",
w.publishCount()-eventsBefore)
}
}
@@ -682,7 +806,10 @@ func sendableTexts(sends []delivery.Sendable) []string {
func eventLines(evs []event.Event) []string {
out := make([]string, 0, len(evs))
for _, e := range evs {
out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body))
// Priority is in the line so a scenario can assert on it. It is the one
// field factPriority derives from confidence, and without it a replay
// could set a confidence but never see what the journal did with it.
out = append(out, fmt.Sprintf("%s/%s pri=%s %s %s", e.Source, e.Kind, e.Priority, e.Title, e.Body))
}
return out
}
@@ -773,26 +900,143 @@ func TestSimulatorIsDeterministic(t *testing.T) {
if first != second {
t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second)
}
// And the transcript's own timestamps must be the scenario's, not today's.
if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) {
t.Error("transcript carries the wall clock — something in the replay path read time.Now()")
// And every transcript timestamp must lie inside the scenario's own span.
//
// This used to compare the transcript against time.Now().Format("15:04"),
// which failed whenever the suite happened to run during the half hour the
// scenario covers: morning_missed logs 08:30 through 09:00, sc.Start
// contains only 08:30, so a run at 08:35 reported a wall-clock read that
// had not happened. A determinism test that depends on the time of day is
// the bug it is looking for.
start, err := time.Parse(time.RFC3339, sc.Start)
if err != nil {
t.Fatalf("bad start: %v", err)
}
last := start
for _, s := range sc.Steps {
if at := stepInstant(t, start, s.At); at.After(last) {
last = at
}
}
// Only the lines logf stamped. A note or a feed item can carry its own
// newlines, and those continuation lines have no timestamp.
stamp := regexp.MustCompile(`^(\d\d:\d\d:\d\d) `)
for _, line := range strings.Split(first, "\n") {
m := stamp.FindStringSubmatch(line)
if m == nil {
continue
}
at := stepInstant(t, start, m[1])
if at.Before(start) || at.After(last) {
t.Errorf("transcript line %q is stamped outside the scenario span %s..%s — "+
"something in the replay path read time.Now()",
line, start.Format("15:04:05"), last.Format("15:04:05"))
}
}
}
// TestCallsSinceAreScopedPerServer pins the ordering bug that made
// expect_not_called unsound. The mark is per server; a total cannot slice a
// list that is concatenated per server.
func TestCallsSinceAreScopedPerServer(t *testing.T) {
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
Praxis: fixturePraxisAttentionItems(), Nexus: fixtureNexusResolved("ent_1", "thing", "device"),
Steps: []step{{At: "08:30"}}}
w := newSimWorld(t, sc)
hit := func(fs *fakeServer, path string) {
t.Helper()
resp, err := http.Get(fs.URL + path)
if err != nil {
t.Fatalf("hitting %s: %v", path, err)
}
resp.Body.Close()
}
// Praxis runs ahead of nexus, so the concatenated list already has a nexus
// call sitting after three praxis ones.
hit(w.praxis, "/api/v1/tools/attention")
hit(w.praxis, "/api/v1/tools/attention")
hit(w.praxis, "/api/v1/tools/attention")
hit(w.nexus, "/api/v1/resolve")
mark := w.callMark()
hit(w.praxis, "/api/v1/tools/attention")
since := w.callPathsSince(mark)
if !anyContains(since, "attention") {
t.Errorf("the praxis call made after the mark is missing from %v", since)
}
if anyContains(since, "resolve") {
t.Errorf("a nexus call from before the mark was reported as new: %v", since)
}
}
// TestPublishCountDoesNotSaturateWithTheRing pins expect_no_events on a bus
// that has already wrapped. bus.Len() stops at the capacity, so it can no
// longer answer "did anything arrive".
func TestPublishCountDoesNotSaturateWithTheRing(t *testing.T) {
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
Steps: []step{{At: "08:30"}}}
w := newSimWorld(t, sc)
for i := 0; i < event.DefaultCapacity+5; i++ {
w.bus.Publish(event.Event{
Source: "sim:test", Kind: event.KindFact, Title: fmt.Sprintf("f%d", i),
}, w.clock.Now())
}
if got := w.bus.Len(); got != event.DefaultCapacity {
t.Fatalf("ring holds %d, expected it to be saturated at %d", got, event.DefaultCapacity)
}
before := w.publishCount()
w.bus.Publish(event.Event{Source: "sim:test", Kind: event.KindFact, Title: "one more"}, w.clock.Now())
if w.publishCount() != before+1 {
t.Errorf("publish count went %d → %d on a full ring, expected it to keep counting",
before, w.publishCount())
}
}
// stepInstant resolves "HH:MM" or "HH:MM:SS" against the scenario's start day.
func stepInstant(t *testing.T, start time.Time, at string) time.Time {
t.Helper()
layout := "15:04"
if strings.Count(at, ":") == 2 {
layout = "15:04:05"
}
hm, err := time.Parse(layout, at)
if err != nil {
t.Fatalf("bad step time %q: %v", at, err)
}
return time.Date(start.Year(), start.Month(), start.Day(),
hm.Hour(), hm.Minute(), hm.Second(), 0, start.Location())
}
// TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake
// that would silently produce a meaningless run.
//
// It used to advance to 09:00 and then to 09:30 and assert the clock had
// moved, which is the forwards case: the backwards branch it is named after
// was never reached, because reaching it ended the test. The fatalf seam is
// what makes it testable.
func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
// Not table-driven through run() because advanceTo calls t.Fatalf; this
// checks the ordering arithmetic directly.
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
Steps: []step{{At: "09:00"}}}
w := newSimWorld(t, sc)
var refusal string
w.fatalf = func(format string, args ...any) { refusal = fmt.Sprintf(format, args...) }
w.advanceTo("09:00")
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
t.Fatalf("clock at %s after advancing to 09:00", got)
}
w.advanceTo("09:30")
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" {
t.Fatalf("clock at %s after advancing to 09:30", got)
if refusal != "" {
t.Fatalf("a forwards step was refused: %s", refusal)
}
w.advanceTo("08:45")
if refusal == "" {
t.Fatal("a step going backwards to 08:45 was accepted")
}
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
}
}
+42 -9
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"log"
"strings"
"time"
@@ -44,9 +45,12 @@ func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
st: st,
refresh: time.Duration(cfg.SmartHome.Refresh),
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
w.propose(ctx)
// No first propose here. This runs inside wireVoice, inside run, before the
// IPC socket is serving, and on the locked path inside the passkey unlock
// handler. A Home Assistant box that is powered off but still on a routed
// subnet black-holes the connection rather than refusing it, so a
// synchronous enumeration held the daemon's start for the per-call timeout.
// run does the first propose off the ticker instead.
return w
}
@@ -113,6 +117,9 @@ func (w *homeWiring) run(ctx context.Context) {
}
t := time.NewTicker(iv)
defer t.Stop()
// The first enumeration, off the daemon's start path. wireSmartHome used to
// do it synchronously and a dead house delayed the socket coming up.
w.propose(ctx)
for {
select {
case <-ctx.Done():
@@ -140,28 +147,54 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
}
var on []string
var sensors []string
dark := 0
for _, e := range ents {
switch {
case e.Domain == "sensor" || e.Domain == "binary_sensor":
if len(sensors) < 3 && e.State != "" && e.State != "unavailable" {
if e.State == "" || e.State == "unavailable" {
dark++
continue
}
if len(sensors) < 3 {
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
}
case e.State == "unavailable" || e.State == "unknown" || e.State == "":
// A lamp that is not reachable is not a lamp that is off. Counting
// it as neither used to make "всё выключено" and "one device is
// unreachable" read identically.
dark++
case e.State == "on" || e.State == "open" || e.State == "unlocked":
on = append(on, e.Name)
}
}
var parts []string
if len(on) > 0 {
if len(on) > 5 {
on = on[:5]
switch {
case len(on) > 0:
shown, rest := on, 0
if len(shown) > 5 {
rest = len(shown) - 5
shown = shown[:5]
}
parts = append(parts, "включено: "+strings.Join(on, ", "))
} else {
// Silent truncation on a status read is the same failure as the cap
// one layer up: she has to say the list is not the whole list.
line := "включено: " + strings.Join(shown, ", ")
if rest > 0 {
line += fmt.Sprintf(" и ещё %d", rest)
}
parts = append(parts, line)
case dark > 0 && len(sensors) == 0:
// Nothing is on and everything she can see is unreachable. "всё
// выключено" would be a claim about the house she cannot make.
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
default:
parts = append(parts, "всё выключено")
}
if len(sensors) > 0 {
parts = append(parts, strings.Join(sensors, ", "))
}
if dark > 0 {
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
}
return strings.Join(parts, "; ") + ".", true
}
+86
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -77,6 +78,12 @@ func TestProposeOnlyProposesControllableDevices(t *testing.T) {
if w == nil {
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
}
// Wiring alone must not have touched the house: enumeration happens off
// the ticker, not on the daemon's start path.
if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 {
t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err)
}
w.propose(context.Background())
tools, err := st.ListTools(context.Background(), "")
if err != nil {
@@ -188,3 +195,82 @@ func TestIsHomeQuery(t *testing.T) {
}
}
}
// A house that black-holes the connection must not hold the daemon's start.
// wireSmartHome used to enumerate synchronously with a 30s context, inside
// wireVoice, inside run, before the IPC socket was serving — and on the locked
// path, inside the passkey unlock handler.
func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) {
// A handler that never answers: the client's own timeout is the only way
// out, and it is ten seconds.
block := make(chan struct{})
defer close(block)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-block
}))
defer srv.Close()
done := make(chan *homeWiring, 1)
go func() {
done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
URL: srv.URL, Token: "t", Enabled: true,
}}, newTestStore(t))
}()
select {
case w := <-done:
if w == nil {
t.Fatal("a configured house should still wire")
}
case <-time.After(2 * time.Second):
t.Fatal("wireSmartHome waited on the house")
}
}
// A lamp that is unreachable is not a lamp that is off, and a list she cut
// short has to say so. Both used to read as plain statements about the house.
func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) {
const fixture = `[
{"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}},
{"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}}
]`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(fixture))
}))
defer srv.Close()
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
URL: srv.URL, Token: "t", Enabled: true,
}}, newTestStore(t))
out, ok := w.homeSummary(context.Background())
if !ok {
t.Fatal("summary did not claim the turn")
}
if strings.Contains(out, "всё выключено") {
t.Errorf("two unreachable lamps were reported as off: %q", out)
}
if !strings.Contains(out, "не отвечают") {
t.Errorf("the unreachable devices are not mentioned: %q", out)
}
}
func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) {
var b strings.Builder
b.WriteString("[")
for i := 0; i < 8; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i)
}
b.WriteString("]")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(b.String()))
}))
defer srv.Close()
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
URL: srv.URL, Token: "t", Enabled: true,
}}, newTestStore(t))
out, _ := w.homeSummary(context.Background())
if !strings.Contains(out, "и ещё 3") {
t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out)
}
}
+33 -17
View File
@@ -3,14 +3,17 @@
//
// # What is actually wired here, and what is not
//
// The enrolment plumbing is real: profiles are stored, listed and deleted, and
// the wire methods exist as soon as a speaker block is configured. The
// recognising half is NOT, and cannot be on this box, because there is no
// speaker-embedding model on disk — no ECAPA, no x-vector, no titanet, no
// wespeaker, nothing in /mnt/hdd1/llms but text ggufs. Until one is downloaded,
// newSpeakerEmbedder returns nil, internal/speaker falls back to
// speaker.Disabled, and every Identify answers ErrDisabled. The daemon logs
// which half is off at startup rather than pretending.
// Nothing is, on this box. There is no speaker-embedding model on disk — no
// ECAPA, no x-vector, no titanet, no wespeaker, nothing in /mnt/hdd1/llms but
// text ggufs. Until one is downloaded, newSpeakerEmbedder returns nil.
//
// Without an embedder the capability has no runnable half. This comment used to
// say enrolment was real and only recognition was blocked, and the startup log
// said the same. Both were wrong: Recognizer.Enroll embeds every sample before
// it stores anything, so with no model it fails on the first sample and nothing
// is ever stored, which leaves List empty forever and Forget with nothing to
// delete. So the gate is cfg.Speaker.Recognizes() — enabled AND a model path —
// and a box without one gets no speaker methods, not three no-ops.
//
// This is deliberately not papered over with a hand-rolled MFCC floor. A
// biometric that is confidently wrong writes false claims about named people
@@ -53,17 +56,25 @@ type speakerWiring struct {
// starts working with no change to the store, the protocol, the auth table or
// the handlers. See the plan document for what to download.
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
if cfg == nil || cfg.ModelPath == "" {
return nil
}
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet; "+
"enrolment and deletion work, recognition does not (Vikunja #255)", cfg.ModelPath)
_ = cfg
return nil
}
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
if cfg == nil || cfg.Speaker == nil || !cfg.Speaker.Enabled {
if cfg == nil || cfg.Speaker == nil {
return nil
}
if !cfg.Speaker.Recognizes() {
// Recognizes() was written as the gate and documented as one, and then
// never called. "enabled": true with no model_path used to wire all
// three methods and log "enrolment on", which is the one config shape
// where the operator most needs to be told otherwise.
if cfg.Speaker.Enabled {
log.Print("speaker: enabled but no model_path, so there is nothing to embed with; " +
"enrol, list and forget would all be no-ops, staying off " +
"(see docs/plans/10-speaker-recognition.md)")
}
return nil
}
if st == nil {
@@ -81,8 +92,8 @@ func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
if rec.Enabled() {
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
} else {
log.Print("speaker: enrolment on, recognition BLOCKED — no speaker-embedding model " +
"on this box (see docs/plans/10-speaker-recognition.md)")
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet, "+
"so enrol, list and forget are all no-ops (Vikunja #255)", cfg.Speaker.ModelPath)
}
return &speakerWiring{rec: rec}
}
@@ -114,7 +125,7 @@ func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) er
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
// not hand the biometric back out over the socket.
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples}
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples, Damaged: p.Damaged}
}
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
@@ -123,6 +134,11 @@ func speakerErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, speaker.ErrDisabled):
// Not a core failure. The capability is present on the wire but has no
// embedding model behind it, which is the same thing an unconfigured
// method says, so say it the same way.
return ipc.ErrUnknownMethod
case errors.Is(err, speaker.ErrNotFound):
return ipc.ErrNoFact
case errors.Is(err, speaker.ErrBadID),
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"errors"
"testing"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/speaker"
)
// "enabled": true with no model_path used to wire all three methods and log
// "enrolment on". Nothing behind them works without an embedder, so the
// capability stays off and the socket answers "no such method".
func TestSpeakerStaysOffWithoutAModelPath(t *testing.T) {
srv := &ipc.Server{}
cfg := &config.Config{Speaker: &config.SpeakerConfig{Enabled: true}}
wireSpeaker(srv, nil, cfg)
if srv.EnrollSpeakerFn != nil || srv.ListSpeakersFn != nil || srv.ForgetSpeakerFn != nil {
t.Error("speaker methods were wired with nothing to embed with")
}
}
// The gate is Recognizes(), so a disabled block with a model path is off too.
func TestSpeakerStaysOffWhenDisabled(t *testing.T) {
srv := &ipc.Server{}
cfg := &config.Config{Speaker: &config.SpeakerConfig{ModelPath: "/nope/ecapa.onnx"}}
wireSpeaker(srv, nil, cfg)
if srv.EnrollSpeakerFn != nil {
t.Error("speaker methods were wired for a disabled block")
}
}
// ErrDisabled is "this capability is off", not "core broke". It used to fall
// through speakerErr's default and reach the surface as an opaque failure.
func TestSpeakerErrMapsDisabledToUnknownMethod(t *testing.T) {
if got := speakerErr(speaker.ErrDisabled); !errors.Is(got, ipc.ErrUnknownMethod) {
t.Errorf("speakerErr(ErrDisabled) = %v, want ErrUnknownMethod", got)
}
if got := speakerErr(speaker.ErrNotFound); !errors.Is(got, ipc.ErrNoFact) {
t.Errorf("speakerErr(ErrNotFound) = %v, want ErrNoFact", got)
}
if got := speakerErr(nil); got != nil {
t.Errorf("speakerErr(nil) = %v", got)
}
}
+58
View File
@@ -0,0 +1,58 @@
{
"schema_version": 1,
"name": "act_degraded",
"description": "The act path against a Praxis that goes down and comes back. This is the case the harness promised and did not have: the other two scenarios never produce an act, so the ecosystem fakes saw zero requests and the fault lever was inert. Here a scripted act reaches an enabled allowlist row, the row is a Praxis verb, and the same utterance runs healthy, then at 503, then healthy again. The degraded turn must say she cannot reach it and must not send anything at him off the back of it.",
"start": "2026-08-01T09:00:00+03:00",
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
"tools": [{ "name": "list_attention" }],
"script": [
{
"match": "требует внимания",
"route": "[{\"intent\":\"act\",\"verb\":\"list_attention\"}]"
},
{
"match": "",
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
}
],
"steps": [
{
"at": "09:00",
"note": "a healthy act reaches Praxis and speaks what it found",
"say": "что требует внимания?",
"expect_reply_contains": ["medicine not taken"],
"expect_called": ["/api/v1/tools/attention"],
"expect_no_send": true
},
{
"at": "09:05",
"note": "the ecosystem goes down",
"fault": 503
},
{
"at": "09:10",
"note": "the same act against a 503. She says she cannot reach it. She does not invent an answer and she does not push anything at him.",
"say": "что требует внимания?",
"expect_reply_contains": ["не могу сейчас узнать"],
"expect_reply_lacks": ["medicine not taken"],
"expect_no_send": true
},
{
"at": "09:15",
"note": "a tick while the ecosystem is down touches nothing out there — the proactive loop has no business calling Praxis",
"tick": true,
"expect_not_called": ["/api/v1"],
"expect_no_send": true,
"expect_no_events": true
},
{
"at": "09:20",
"note": "recovery: the same act works again, so the degraded turn left no sticky state",
"clear_fault": true,
"say": "что требует внимания?",
"expect_reply_contains": ["medicine not taken"],
"expect_no_send": true
}
]
}
+1 -1
View File
@@ -25,7 +25,7 @@
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
"audio": "ru_fact",
"expect_reply_contains": ["записала"],
"expect_reply_lacks": ["записал,", "милый", "ваш"],
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
"expect_events": ["water"]
},
{
+7 -6
View File
@@ -54,15 +54,16 @@
},
{
"at": "08:40",
"note": "the work calendar signal — a relayed notification, below full confidence",
"note": "the work calendar signal — a relayed notification, at the ambient path's own 0.6 rather than an observation she made herself. That is the branch factPriority takes, so the journal must file it low.",
"arrive": {
"source": "ambient:notif",
"fact": {
"key": "calendar_event_20260801_планёрка",
"value": "10:00-11:00 планёрка"
"value": "10:00-11:00 планёрка",
"confidence": 0.6
}
},
"expect_events": ["ambient:notif", "планёрка"],
"expect_events": ["планёрка", "ambient:notif/fact pri=low"],
"expect_no_send": true
},
{
@@ -73,17 +74,17 @@
},
{
"at": "08:50",
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it.",
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
"say": "что я пропустил?",
"expect_reply_contains": ["не знаю"],
"expect_reply_lacks": ["рад ", "милый", "ваш"]
},
{
"at": "08:55",
"note": "stating a fact writes it and says so, in the feminine",
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
"say": "я выпил воды",
"expect_reply_contains": ["записала"],
"expect_reply_lacks": ["записал,", "милый"],
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
"expect_events": ["water"]
},
{
+25 -9
View File
@@ -19,6 +19,7 @@ import (
"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"
@@ -417,6 +418,8 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop
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
}
@@ -437,6 +440,21 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop
// 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.
@@ -808,8 +826,10 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
Text: f.Value,
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.
@@ -818,12 +838,12 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
}
var reminders []morning.PlanEntry
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: list reminders: %v", err)
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != "pending" {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
@@ -856,10 +876,6 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
return out
}
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
// pending queue longer than this is a bug elsewhere, not a plan to recite.
const dayPlanMaxReminders = 500
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
// rule:
+35 -10
View File
@@ -7,11 +7,14 @@
// media.dir, prepares a downscaled JPEG, and asks a local vision server what it
// is. The description comes back as words; nothing about the image is echoed.
//
// Off unless configured twice over: no `media` block ⇒ nowhere to keep the
// bytes, so the method does not exist; no `vision` block with enabled + a local
// endpoint ⇒ the store is wired but the describing half refuses, and the method
// still does not exist. A surface cannot make Maven look at pictures by merely
// sending one.
// Off unless configured: no `media` block ⇒ nowhere to keep the bytes, so the
// method does not exist and a surface cannot make Maven accept a photo by
// merely sending one. A `media` block with no `vision` block is a real state,
// the one this box is in today: the store is wired, the method exists, the
// bytes are kept and the reply says she cannot read the picture yet. That reply
// is re-runnable by id on the day a vision model lands, which is the reason to
// keep the bytes at all. Saving the description as a note needs more than the
// read rung — see the scope check on auth.ImageNoteSource.
//
// Two things this file deliberately does not do:
//
@@ -29,6 +32,7 @@ import (
"fmt"
"log"
"path/filepath"
"sync"
"time"
"github.com/kami/maven/internal/config"
@@ -64,12 +68,14 @@ func openMediaStore(cfg *config.Config) *mediaKeeper {
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
dir = filepath.Join(cfg.StateDir, dir)
}
st, err := media.Open(dir, cfg.Media.MaxBytes, time.Duration(cfg.Media.Retention))
st, err := media.OpenWithBudget(dir, cfg.Media.MaxBytes, cfg.Media.MaxTotalBytes,
time.Duration(cfg.Media.Retention))
if err != nil {
log.Printf("media: %v — image and audio intake disabled", err)
return nil
}
log.Printf("media: blob store at %s, retention %s", st.Dir(), st.Retention())
log.Printf("media: blob store at %s, retention %s, %d of %d bytes used",
st.Dir(), st.Retention(), st.Total(), st.Budget())
return &mediaKeeper{store: st}
}
@@ -159,6 +165,12 @@ func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (
if len(req.Data) == 0 && req.ID == "" {
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: neither data nor id")
}
if len(req.Data) > 0 && req.ID != "" {
// The contract says exactly one. Taking the ID branch and dropping the
// bytes silently is the worst of the three possible answers: the caller
// believes it sent a new image and nothing says otherwise.
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: both data and id given, send one")
}
var (
res vision.Result
@@ -205,6 +217,12 @@ func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (
return resp, nil
}
// noteMarker prefixes a stored description. Without it the note reads exactly
// like something he told her, and it is not: it is a small VLM's guess about a
// picture, embedded and recalled as if it were his own words. Four characters
// of provenance in the text are cheaper than believing it later.
const noteMarker = "Со снимка: "
// writeNote stores the description as an ordinary note so it is recallable. The
// note carries the blob id in its source, which is the only link back to the
// bytes — the note text is words about the picture, never the picture.
@@ -221,7 +239,7 @@ func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64,
}
}
source := "media:image:" + res.Blob.ID[:12]
return v.st.WriteNote(ctx, v.now(), res.Description, vec, source)
return v.st.WriteNote(ctx, v.now(), noteMarker+res.Description, vec, source)
}
// sourceOrDefault labels a blob whose sender did not say where it came from.
@@ -241,12 +259,19 @@ func sourceOrDefault(s string) string {
// with one retention loop holds both the images and the audio, which is the
// whole point of internal/media being a shared package. nil ⇒ no media block,
// and neither capability exists.
func wireVision(ctx context.Context, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
func wireVision(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
keeper := openMediaStore(cfg)
if keeper == nil {
return nil
}
go keeper.runPrune(ctx)
// In the daemon's WaitGroup like every other loop in run: a prune deletes
// files, and shutting down in the middle of one was the single loop nobody
// waited for.
wg.Add(1)
go func() {
defer wg.Done()
keeper.runPrune(ctx)
}()
vi := newVisionIntake(keeper, st, emb, cfg)
if vi == nil {
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"context"
"image"
"image/png"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/media"
"github.com/kami/maven/internal/vision"
)
func testIntake(t *testing.T) *visionIntake {
t.Helper()
st := newTestStore(t)
blobs, err := media.Open(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
return &visionIntake{
in: vision.NewIntake(blobs, vision.Disabled{}, 0),
st: st,
now: time.Now,
}
}
// The contract says exactly one of Data or ID. Taking the ID branch and
// dropping the bytes silently is the worst of the three possible answers: the
// caller believes it sent a new image and nothing says otherwise.
func TestDescribeRefusesBothDataAndID(t *testing.T) {
v := testIntake(t)
_, err := v.describe(context.Background(), ipc.DescribeImageReq{
Data: []byte("bytes"), ID: strings.Repeat("a", 64),
})
if err == nil {
t.Fatal("both data and id must be refused")
}
if !strings.Contains(err.Error(), "send one") {
t.Fatalf("err = %v, want it to name the contract", err)
}
}
// Vision being off does not remove the method: the bytes are stored and the
// answer says she cannot read the picture yet, which is re-runnable by id. That
// is the state this box is in today, and three doc comments used to claim the
// opposite.
func TestVisionOffStillStores(t *testing.T) {
v := testIntake(t)
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
t.Fatal(err)
}
resp, err := v.describe(context.Background(), ipc.DescribeImageReq{Data: buf.Bytes(), Source: "web:upload"})
if err != nil {
t.Fatalf("storing must succeed even with no vision model: %v", err)
}
if len(resp.ID) != 64 {
t.Fatalf("no blob id came back: %+v", resp)
}
if resp.Description != "" {
t.Errorf("description = %q, want none", resp.Description)
}
// And with no media block at all the method does not exist.
if vi := newVisionIntake(nil, nil, nil, &config.Config{}); vi != nil {
t.Fatal("no media block must leave the method nonexistent")
}
}
+40 -14
View File
@@ -98,6 +98,11 @@ type reactiveHandler struct {
// act allowlist and tool.Executor, like every other mutating act.
home *homeWiring
// netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the
// default. A scan is a read, so it has no allowlist row; what keeps it
// safe is that its range comes from config and from nowhere else.
netscan *netWiring
weatherProvider weather.Provider
weatherLocation string // default location for weather queries
@@ -167,7 +172,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
// action → replier), identical to the text path.
replyText := h.runTurn(ctx, text)
replyText := h.runTurn(ctx, text, sourceVoice)
// 6. tts — synthesise the reply text; return to the voice server which
// ships it back on the conn.
@@ -179,44 +184,65 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
// HandlePushToTalk so text channels share the same routing logic.
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
log.Printf("voice: handleText: %q", text)
return h.runTurn(ctx, text)
return h.runTurn(ctx, text, sourceText)
}
// turnSource — which channel this utterance arrived on, in the same provenance
// vocabulary facts use (internal/event). It is threaded through runTurn because
// a turn can write a fact, and a fact that lies about where it came from is
// worse than no fact: provenance is the first column read when asking why a
// daemon-wide setting is the way it is.
type turnSource string
const (
sourceVoice turnSource = "tap:voice" // HandlePushToTalk, a real microphone
sourceText turnSource = "tap:text" // handleText: mavweb /api/chat, telegram
)
// runTurn — the reactive turn pipeline shared by the voice and text entry
// points: confirm answer → expired-clarify notice → clarify answer → quiet
// points: expired-clarify notice → confirm answer → clarify answer → quiet
// toggle → route → dialogue merge → clarify question → action → replier.
// Takes the already-transcribed utterance, returns the reply text; the voice
// path wraps it in stt/tts, the text path returns it as-is.
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string) string {
// 1. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
// get classified as some other intent.
if reply, handled := h.resolveConfirm(ctx, text); handled {
return reply
}
// 2. expired clarify — a question was parked but its TTL ran out, so the
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
// 1. expired clarify — a question was parked but its TTL ran out, so the
// request behind it is gone. Say that out loud (see clarify.go) and carry
// on: these words are still routed as a fresh utterance below, with the
// notice glued in front of whatever the fresh routing answers. Checked
// BEFORE the answer path: reading a parked question drops an expired one.
//
// Taken before the confirm check, not after, because a confirm turn returns
// early. He can be asked a question, walk off, come back and say "да" to a
// confirm that is still parked; computing the notice after that return meant
// he answered the confirm and never heard that the older request was let go.
expiredNotice := h.clarifyExpiredNotice()
// 2. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
// get classified as some other intent.
if reply, handled := h.resolveConfirm(ctx, text); handled {
return withNotice(expiredNotice, reply)
}
// 3. clarify answer — if she asked a live question last turn, this
// utterance is its answer, not a fresh command. After the confirm check: a
// y/n gate is armed by her own prompt and is the narrower claim on the
// utterance.
// A live question and an expired one cannot both exist for one dialogue id,
// so the notice is empty here in practice. withNotice anyway: every exit
// from runTurn carries it, and that is what stops the next one from
// forgetting.
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
return reply
return withNotice(expiredNotice, reply)
}
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
// "тихий режим" / "quiet on" would route through the classifier
// unreliably (it's a command, not a free-form query), so we match it
// before routing. Same pattern as the confirm turn above.
if reply, handled := h.resolveQuietToggle(ctx, text); handled {
if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
return withNotice(expiredNotice, reply)
}
+7
View File
@@ -53,6 +53,9 @@ type voiceWiring struct {
// enabled (Vikunja #256). Its devices land in the same allowlist as every
// other act, so nothing else here has to know about it.
home *homeWiring
// netscan — the LAN scanner, nil unless the `netscan` block is enabled
// (Vikunja #257).
netscan *netWiring
}
// close releases the listener + worker conns. Safe to call on nil (when
@@ -161,6 +164,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
if w.home != nil {
exec = exec.WithHome(w.home.caller())
}
// The LAN scanner (Vikunja #257): a read, bounded to the configured
// subnets and rate-limited. Off unless the `netscan` block is enabled.
w.netscan = wireNetScan(cfg, coreAPI)
matcher := tool.NewMatcher(coreAPI)
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
@@ -245,6 +251,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
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),
+30 -22
View File
@@ -19,32 +19,40 @@ func isWeatherQuery(u string) bool {
strings.Contains(lower, "temperature")
}
// extractWeatherLocation parses a location from the utterance, or falls back
// to the configured default. Very basic: just checks for known city names.
// weatherCities — the city names an utterance may name explicitly, as
// lowercase substrings mapped to the provider's spelling. This is a
// convenience for "какая погода в Лондоне", NOT a source of default truth:
// nothing here is used unless he actually said it.
var weatherCities = map[string]string{
"москв": "Moscow",
"moscow": "Moscow",
"питер": "Saint Petersburg",
"spb": "Saint Petersburg",
"петербур": "Saint Petersburg",
"лондон": "London",
"london": "London",
"париж": "Paris",
"paris": "Paris",
"берлин": "Berlin",
"berlin": "Berlin",
"нью-йорк": "New York",
"new york": "New York",
}
// extractWeatherLocation returns the city he named, or the configured default
// when he named none. It returns "" when he named none AND no default is
// configured — the caller must then say it does not know.
//
// It used to return "Moscow" in that case. That is a made-up answer presented
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
// wrong in exactly the way maven must never be wrong. voice.weather
// .default_location is the only source of an unstated location.
func extractWeatherLocation(u, defaultLoc string) string {
lower := strings.ToLower(u)
cities := map[string]string{
"москв": "Moscow",
"moscow": "Moscow",
"питер": "Saint Petersburg",
"spb": "Saint Petersburg",
"петербур": "Saint Petersburg",
"лондон": "London",
"london": "London",
"париж": "Paris",
"paris": "Paris",
"берлин": "Berlin",
"berlin": "Berlin",
"нью-йорк": "New York",
"new york": "New York",
}
for substr, name := range cities {
for substr, name := range weatherCities {
if strings.Contains(lower, substr) {
return name
}
}
if defaultLoc != "" {
return defaultLoc
}
return "Moscow"
return defaultLoc
}
+90 -17
View File
@@ -61,6 +61,11 @@ func run(args []string) error {
mailbox := fs.String("mailbox", "INBOX", "mailbox to read, read-only")
interval := fs.Duration("interval", 15*time.Minute, "how often to read the mailbox")
lookback := fs.Duration("lookback", 72*time.Hour, "how far back to search on each poll")
// -max and -interval are one decision, not two. Every non-bulk message in a
// poll is one serialized llama-server call on core's side, and core gates
// mail extraction behind voice turns (llm.Gate), so a large batch does not
// mute Maven, it just takes a while. Raise -max only alongside whatever
// bound core is running.
max := fs.Int("max", 25, "most messages to fetch in one poll")
timeout := fs.Duration("timeout", 30*time.Second, "IMAP network timeout")
statePath := fs.String("state", "", "file remembering which UIDs were read (default: none — every poll re-reads the window)")
@@ -125,11 +130,18 @@ func run(args []string) error {
log.Printf("mavmaild: bye")
return nil
case <-t.C:
// Core told us mail ingestion is not configured. Nothing will change
// without a core restart, and a restart restarts us too, so the
// daemon stays up and does nothing at all.
//
// It does NOT exit. The compose service inherits restart:
// unless-stopped, which restarts a clean exit as readily as a crash,
// so exiting here produced a loop: log in to IMAP, get refused by
// core, exit, restart, log in again. Four IMAP logins an hour
// against a mailbox that has nothing to give, and Gmail and Yandex
// both rate-limit exactly that.
if r.disabled {
// Core told us mail ingestion is not configured. Nothing will change
// without a core restart, and a restart restarts us too.
log.Printf("mavmaild: core does not accept mail — idling")
return nil
continue
}
r.pollOnce(ctx, password)
}
@@ -153,10 +165,17 @@ type reader struct {
timeout time.Duration
state *seenState
// dial — connection seam for the tests; nil ⇒ implicit TLS.
dial func(addr string, timeout time.Duration) (*email.Conn, error)
// fetchMail — the read seam, nil ⇒ the real IMAP read. The tests replace
// the whole read rather than the transport: internal/email keeps its dialer
// unexported so that no code outside that package can point the reader at a
// cleartext socket and hand it the password, and this daemon is code
// outside that package.
fetchMail func(password string) ([]email.Message, error)
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
// Written in pollOnce and read in the ticker loop, both on the one
// goroutine that run() drives, so it needs no atomic. If a second caller of
// pollOnce ever appears, this becomes a race and has to change.
disabled bool
}
@@ -179,23 +198,25 @@ func (r *reader) pollOnce(ctx context.Context, password string) {
if ctx.Err() != nil {
return
}
if m.Junk {
junk++
// Marked seen without a model call: the header filter already decided,
// and re-classifying it every quarter hour would be pure waste.
r.state.mark(m.UID)
continue
}
resp, err := r.core.IngestMail(ctx, ipc.IngestMailReq{
req := ipc.IngestMailReq{
Mailbox: r.mailbox,
UID: m.UID,
From: m.From,
Subject: m.Subject,
Date: m.Date,
Body: m.Body,
})
}
if m.Junk {
junk++
// Core is TOLD, which is what its wire doc says: it counts the bulk
// message and answers Skipped without spending the model. The header
// filter already decided, so no content is sent with the verdict —
// nothing will read it.
req = ipc.IngestMailReq{Mailbox: r.mailbox, UID: m.UID, Junk: true}
}
resp, err := r.core.IngestMail(ctx, req)
if errors.Is(err, ipc.ErrUnknownMethod) {
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; stopping")
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; idling until a restart")
r.disabled = true
return
}
@@ -217,6 +238,9 @@ func (r *reader) pollOnce(ctx context.Context, password string) {
// fetch reads the mailbox. Messages already in the seen-set are not fetched at
// all, so a steady mailbox costs one SEARCH per poll and nothing else.
func (r *reader) fetch(password string) ([]email.Message, error) {
if r.fetchMail != nil {
return r.fetchMail(password)
}
f := email.FetchSince{
Addr: r.addr,
User: r.user,
@@ -225,8 +249,24 @@ func (r *reader) fetch(password string) ([]email.Message, error) {
Since: time.Now().Add(-r.lookback),
Max: r.max,
Skip: r.state.seen,
// Everything below the oldest searchable UID has aged out of the
// lookback window and can never be read again. Retiring it is what keeps
// one permanently failing message from pinning the high-water mark
// forever. See seenState.retire.
OnSearch: func(uids []uint32) {
if len(uids) == 0 {
return
}
low := uids[0]
for _, u := range uids {
if u < low {
low = u
}
}
r.state.retire(low)
},
}
return f.RunWith(password, r.dial)
return f.Run(password)
}
// ---- seen state ------------------------------------------------------------
@@ -242,6 +282,14 @@ func (r *reader) fetch(password string) ([]email.Message, error) {
// UIDs are per-mailbox and monotonic, so the set is kept as a high-water mark
// plus the stragglers above it. If the server ever changes UIDVALIDITY, UIDs
// reset and the window is simply re-read once — dedupe absorbs it.
//
// The high-water mark only advances through a CONTIGUOUS run, so a UID that
// never ingests successfully would pin it forever: everything above stays in
// the explicit set, and save rewrites all of it every poll. A year of that is
// a few hundred thousand entries written every quarter hour, which breaks
// nothing loudly and is exactly why it is worth catching. retire is the answer:
// a UID that has fallen out of the SEARCH SINCE window can never be fetched
// again, so there is nothing left to wait for.
type seenState struct {
path string
high uint32
@@ -280,6 +328,31 @@ func (s *seenState) mark(uid uint32) {
}
}
// retire records that no UID below floor is reachable any more — they have
// aged out of the lookback window, so no poll will ever fetch them. The
// high-water mark can jump past the gap they were holding open, and the
// stragglers below it leave the explicit set.
//
// It never moves backwards, so a UIDVALIDITY reset (UIDs restarting low) makes
// this a no-op rather than a way to un-see a mailbox.
func (s *seenState) retire(floor uint32) {
if floor == 0 || floor-1 <= s.high {
return
}
s.high = floor - 1
for u := range s.set {
if u <= s.high {
delete(s.set, u)
}
}
// The run above the new mark may now be contiguous with it.
for s.set[s.high+1] {
delete(s.set, s.high+1)
s.high++
}
s.dirty = true
}
func (s *seenState) load() error {
if s.path == "" {
return nil
+112 -51
View File
@@ -1,13 +1,10 @@
package main
import (
"bufio"
"context"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -16,7 +13,11 @@ import (
"github.com/kami/maven/internal/ipc"
)
// ---- a scripted IMAP server, same shape internal/email's tests use ---------
// ---- a fake mailbox -------------------------------------------------------
//
// It fakes the READ, not the protocol: internal/email owns the IMAP tests, and
// its dialer is unexported precisely so this package cannot substitute a
// transport.
type fakeIMAP struct {
msgs map[uint32]string
@@ -24,52 +25,45 @@ type fakeIMAP struct {
cmds []string
}
func (f *fakeIMAP) serve(c net.Conn) {
defer c.Close()
fmt.Fprint(c, "* OK fake ready\r\n")
r := bufio.NewReader(c)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
parts := strings.SplitN(strings.TrimRight(line, "\r\n"), " ", 2)
if len(parts) != 2 {
return
}
tag, cmd := parts[0], parts[1]
f.cmds = append(f.cmds, cmd)
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
fmt.Fprintf(c, "%s OK\r\n", tag)
case strings.HasPrefix(upper, "UID SEARCH"):
var ids []string
for _, u := range f.uids {
ids = append(ids, strconv.FormatUint(uint64(u), 10))
// fetch is the read seam the reader exposes: the daemon cannot reach
// internal/email's dialer (it is unexported so nothing outside that package can
// point the reader at a cleartext transport), so a test fakes the whole read.
// The IMAP protocol itself is covered by internal/email's own tests.
func (f *fakeIMAP) fetch(r *reader) func(string) ([]email.Message, error) {
return func(string) ([]email.Message, error) {
var out []email.Message
var low uint32
for _, uid := range f.uids {
if low == 0 || uid < low {
low = uid
}
fmt.Fprintf(c, "* SEARCH %s\r\n%s OK\r\n", strings.Join(ids, " "), tag)
case strings.HasPrefix(upper, "UID FETCH"):
uid, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
if raw, ok := f.msgs[uint32(uid)]; ok {
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n%s)\r\n", uid, len(raw), raw)
}
fmt.Fprintf(c, "%s OK\r\n", tag)
case strings.HasPrefix(upper, "LOGOUT"):
fmt.Fprintf(c, "* BYE\r\n%s OK\r\n", tag)
return
default:
fmt.Fprintf(c, "%s BAD\r\n", tag)
}
if low > 0 {
r.state.retire(low)
}
for i := len(f.uids) - 1; i >= 0; i-- {
uid := f.uids[i]
if r.state.seen(uid) {
continue
}
raw, ok := f.msgs[uid]
if !ok {
continue
}
f.cmds = append(f.cmds, fmt.Sprintf("UID FETCH %d", uid))
msg, err := email.ParseMessage(uid, []byte(raw))
if err != nil {
continue
}
out = append(out, msg)
if r.max > 0 && len(out) >= r.max {
break
}
}
return out, nil
}
}
func (f *fakeIMAP) dial(_ string, timeout time.Duration) (*email.Conn, error) {
cli, srv := net.Pipe()
go f.serve(srv)
return email.NewConn(cli, timeout)
}
// ---- a fake core -----------------------------------------------------------
type fakeCore struct {
@@ -96,12 +90,13 @@ func mail(subject, body string, extraHeaders ...string) string {
func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
t.Helper()
return &reader{
r := &reader{
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
state: newSeenState(statePath),
dial: f.dial,
}
r.fetchMail = f.fetch(r)
return r
}
func TestPollHandsMessagesToCore(t *testing.T) {
@@ -116,11 +111,26 @@ func TestPollHandsMessagesToCore(t *testing.T) {
r := newTestReader(t, f, core, "")
r.pollOnce(context.Background(), "secret")
// The newsletter is filtered before core is asked: only the real mail crosses.
if len(core.got) != 1 {
t.Fatalf("core saw %d messages, want 1 (the bulk one must not cross): %+v", len(core.got), core.got)
// Two calls: the real mail with its text, and the newsletter as a verdict
// with no content at all. Core is told about bulk rather than asked, so it
// can count it without spending the model.
if len(core.got) != 2 {
t.Fatalf("core saw %d messages, want 2: %+v", len(core.got), core.got)
}
var got, bulk ipc.IngestMailReq
for _, r := range core.got {
if r.Junk {
bulk = r
} else {
got = r
}
}
if bulk.UID != 2 || !bulk.Junk {
t.Errorf("bulk req = %+v, want uid 2 flagged junk", bulk)
}
if bulk.Subject != "" || bulk.Body != "" || bulk.From != "" {
t.Errorf("a bulk verdict must carry no mail content: %+v", bulk)
}
got := core.got[0]
if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
t.Errorf("ingest req = %+v", got)
}
@@ -252,3 +262,54 @@ func TestRunRejectsEmptyPasswordFile(t *testing.T) {
t.Errorf("an empty password file must be refused before dialling; err = %v", err)
}
}
// A UID that never ingests pinned the high-water mark forever, because the mark
// only advances through a contiguous run. Once that UID falls out of the
// lookback window it can never be fetched again, so there is nothing left to
// wait for and everything above it can leave the explicit set.
func TestSeenStateRetiresAgedOutUIDs(t *testing.T) {
s := newSeenState("")
s.mark(1000) // 999 failed and was deliberately not marked
s.mark(1001)
if s.high != 0 || len(s.set) != 2 {
t.Fatalf("high = %d, set = %v; want the mark pinned below the gap", s.high, s.set)
}
// The next SEARCH window starts at 1000: 999 has aged out.
s.retire(1000)
if s.high != 1001 {
t.Errorf("high = %d, want 1001 once the gap is unreachable", s.high)
}
if len(s.set) != 0 {
t.Errorf("explicit set = %v, want empty", s.set)
}
if !s.seen(999) || !s.seen(1001) || s.seen(1002) {
t.Errorf("seen(999)=%v seen(1001)=%v seen(1002)=%v", s.seen(999), s.seen(1001), s.seen(1002))
}
}
// retire never moves the mark backwards: a UIDVALIDITY reset restarts UIDs low,
// and that must not un-see a mailbox or re-see one.
func TestSeenStateRetireNeverGoesBackwards(t *testing.T) {
s := newSeenState("")
s.mark(1)
s.mark(2)
s.retire(1)
if s.high != 2 {
t.Errorf("high = %d, want 2 unchanged", s.high)
}
}
// A poll must not leave the state file growing with UIDs that are already
// covered by the high-water mark.
func TestPollRetiresThroughTheSearchWindow(t *testing.T) {
f := &fakeIMAP{uids: []uint32{100, 101}, msgs: map[uint32]string{100: mail("a", "b"), 101: mail("c", "d")}}
core := &fakeCore{}
r := newTestReader(t, f, core, "")
r.pollOnce(context.Background(), "secret")
if r.state.high != 101 {
t.Errorf("high = %d, want 101 — everything below the search window is unreachable", r.state.high)
}
if len(r.state.set) != 0 {
t.Errorf("explicit set = %v, want empty", r.state.set)
}
}
+34 -16
View File
@@ -28,6 +28,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
@@ -193,6 +194,14 @@ func (p *poller) pollOnce(ctx context.Context) {
//
// Both windows are read from one diff call each. Two calls an hour against an
// API whose whole job is this is not worth caching.
//
// The write is UNCONDITIONAL, unlike every other poll in this file. The
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
// for money that made ts mean "last changed" while the reader was asking it "as
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
// that was current. The value now carries its own read stamp, so it differs
// every poll anyway and there is nothing left for the dedupe to catch.
// moneyWindow — one fact key and the period it covers.
type moneyWindow struct {
key string
@@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
}
continue
}
val, ok := sum.Value()
val, ok := sum.Value(now)
if !ok {
// Nothing read. Silence, not a zero.
// Nothing read. Silence, not a zero. The last good fact stays, and
// the window stamp inside it is what stops core reciting yesterday's
// day total as today's after midnight.
continue
}
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
firstErr = err
}
}
@@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check; keep a helper so the switch above reads clean.
func isNoFact(err error) bool {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
// writeMoneyFact writes a money fact every poll, with no value comparison. See
// the comment above pollZenmoney for why this one does not go through
// writeIfChangedRaw.
//
// The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
return false
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check.
func isNoFact(err error) bool {
return errors.Is(err, ipc.ErrNoFact)
}
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
+86 -18
View File
@@ -2,10 +2,22 @@ package main
// Golden-audio STT tests (Vikunja #288).
//
// These push real audio through the real whisper.cpp binding, so a bad model
// path, a wrong language hint, a broken resample or a regressed silence gate
// is caught by `make test` rather than by the owner talking to a daemon that
// mishears him.
// These push real audio through the real whisper.cpp binding. What that
// covers, precisely, is two things: the model still transcribes known speech
// well enough for the router to act on it, and the silence gate still lets real
// speech through. A regression in either shows up in `make test` rather than in
// the owner talking to a daemon that mishears him.
//
// It is worth being exact about what is NOT covered, because this comment used
// to claim more. Nothing here resamples: audio.PCMFromWAV refuses anything that
// is not 16 kHz mono s16, the fixtures arrive at 16 kHz from ffmpeg, and there
// is no conversion step between the WAV and whisper_full. Nothing here
// exercises language selection either: the hint comes out of the manifest
// already correct and goes straight into the request, so how mavsttd chooses a
// language is untested. And a wrong model path is not caught when it is the
// default one, because a box without the model skips; an explicitly set
// MAVEN_WHISPER_MODEL that does not exist is a failure, since that is a
// mistake and not an absence.
//
// The fixtures are piper-synthesised, not recorded — see
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
@@ -47,7 +59,10 @@ type goldenCase struct {
Lang string `json:"lang"`
Text string `json:"text"`
Keywords []string `json:"keywords"`
MaxWER float64 `json:"max_wer"`
// MeasuredWER is what this case scored when the ceiling was last set, so
// a model swap is a diff to a recorded number rather than silence.
MeasuredWER float64 `json:"measured_wer"`
MaxWER float64 `json:"max_wer"`
}
type goldenManifest struct {
@@ -152,6 +167,14 @@ func containsSeq(hyp, want []string) bool {
return false
}
// looseWordMatch reports whether got is want, or an inflection of it.
//
// A shared prefix alone is not enough. "воды" retains three runes, so "водка"
// used to satisfy the ru_fact keyword and the test passed on whisper hearing
// "выпил водки". "disk" retains "dis", which "display", "distance" and
// "discuss" all match. So the hypothesis is also capped in length: a case
// ending adds a rune or two, it does not add a syllable. Short words get no
// slack at all, because there is nothing left of them after a prefix cut.
func looseWordMatch(got, want string) bool {
if got == want {
return true
@@ -166,6 +189,13 @@ func looseWordMatch(got, want string) bool {
if n < 3 || len(g) < n {
return false
}
extra := 2
if len(w) <= 4 {
extra = 0
}
if len(g) > len(w)+extra {
return false
}
return string(g[:n]) == string(w[:n])
}
@@ -176,6 +206,11 @@ func TestGoldenAudioTranscription(t *testing.T) {
model := goldenModelPath()
if _, err := os.Stat(model); err != nil {
// An explicit override that points at nothing is a mistake, not a box
// without the model. Skipping there made a typo look like a pass.
if os.Getenv("MAVEN_WHISPER_MODEL") != "" {
t.Fatalf("MAVEN_WHISPER_MODEL=%s does not exist: %v", model, err)
}
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
}
@@ -192,7 +227,9 @@ func TestGoldenAudioTranscription(t *testing.T) {
path := filepath.Join("testdata", c.WAV)
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
// Not a skip. A fixture the generator failed to write is a
// broken checkout, and skipping made `make test` green on one.
t.Fatalf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
}
format, pcm, err := audio.PCMFromWAV(raw)
if err != nil {
@@ -221,9 +258,12 @@ func TestGoldenAudioTranscription(t *testing.T) {
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
}
if wer := wordErrorRate(ref, hyp); wer > c.MaxWER {
t.Errorf("%s: WER %.2f > %.2f\n want: %q\n got: %q", c.WAV, wer, c.MaxWER, c.Text, resp.Text)
wer := wordErrorRate(ref, hyp)
if wer > c.MaxWER {
t.Errorf("%s: WER %.2f > %.2f (measured %.2f when the ceiling was set)\n want: %q\n got: %q",
c.WAV, wer, c.MaxWER, c.MeasuredWER, c.Text, resp.Text)
}
t.Logf("%s: WER %.2f (ceiling %.2f, was %.2f)", c.WAV, wer, c.MaxWER, c.MeasuredWER)
})
}
}
@@ -253,27 +293,29 @@ func TestGoldenFixturesAreCanonical(t *testing.T) {
}
// The fixture must clear mavsttd's own silence gate, otherwise the
// model test below would be asserting on a gated empty string.
if reason := gateReason(pcmToF32(pcm), whisperSampleRate, 300, 0.01); reason != "" {
if reason := gateReason(pcmSamples(pcm), whisperSampleRate, 300, 0.01); reason != "" {
t.Errorf("%s: would be gated as %s", path, reason)
}
if len(c.Keywords) == 0 {
t.Errorf("%s: manifest case has no keywords", c.Name)
}
// An empty reference makes wordErrorRate return 1 for every
// hypothesis, so the WER assertion fires with nothing useful to say.
if len(normalizeTranscript(c.Text)) == 0 {
t.Errorf("%s: manifest case has no reference text", c.Name)
}
if c.Lang != "ru" && c.Lang != "en" {
t.Errorf("%s: lang %q is not one of the two languages mavsttd is run with", c.Name, c.Lang)
}
if c.MaxWER <= 0 || c.MaxWER > 1 {
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
}
if c.MeasuredWER > c.MaxWER {
t.Errorf("%s: measured_wer %v is above max_wer %v, so the ceiling was never met", c.Name, c.MeasuredWER, c.MaxWER)
}
}
}
func pcmToF32(b []byte) []float32 {
out := make([]float32, len(b)/2)
for i := range out {
s := int16(b[i*2]) | int16(b[i*2+1])<<8
out[i] = float32(s) / 32768.0
}
return out
}
// --- matcher unit tests (no model, no fixtures) ----------------------------
func TestNormalizeTranscript(t *testing.T) {
@@ -320,4 +362,30 @@ func TestMissingKeywords(t *testing.T) {
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
}
// A prefix is not a word. These are different words that share one, and
// each of them used to satisfy the keyword it is paired with.
different := [][2]string{
{"воды", "Я выпил водки."},
{"disk", "check the display"},
{"disk", "we should discuss it"},
{"server", "a serverless function"},
}
for _, d := range different {
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 1 {
t.Errorf("keyword %q was satisfied by %q", d[0], d[1])
}
}
// And the inflections still pass, which is the whole point of the loose
// match.
same := [][2]string{
{"воды", "выпил воду"},
{"напомни", "напомните мне"},
{"календарю", "по календаре"},
{"restart", "restarted the server"},
}
for _, d := range same {
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 0 {
t.Errorf("keyword %q was not matched by %q", d[0], d[1])
}
}
}
+10 -5
View File
@@ -1,5 +1,6 @@
{
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh. Regenerate with that script; do not hand-edit `wav`.",
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh, which reads `text` from this file and synthesises from it. This is the only source of the spoken words; regenerate with that script and do not hand-edit `wav`.",
"wer_note": "max_wer is set just above what each case actually measures against ggml-small, recorded in `measured_wer` on 2026-08-01. A flat 0.34 over a five-word reference tolerated two wrong words and left most of the range unguarded. A model swap should show up as a diff to these numbers, not as silence: rerun `make test-stt-golden`, read the logged transcript, and move both fields together.",
"cases": [
{
"name": "ru_reminder",
@@ -7,7 +8,8 @@
"lang": "ru",
"text": "напомни мне через час позвонить маме",
"keywords": ["напомни", "час", "позвонить"],
"max_wer": 0.34
"measured_wer": 0.0,
"max_wer": 0.1
},
{
"name": "ru_fact",
@@ -15,7 +17,8 @@
"lang": "ru",
"text": "отметь что я выпил воды",
"keywords": ["отметь", "воды"],
"max_wer": 0.34
"measured_wer": 0.2,
"max_wer": 0.25
},
{
"name": "ru_query",
@@ -23,7 +26,8 @@
"lang": "ru",
"text": "что у меня сегодня по календарю",
"keywords": ["сегодня", "календарю"],
"max_wer": 0.34
"measured_wer": 0.0,
"max_wer": 0.1
},
{
"name": "en_act",
@@ -31,7 +35,8 @@
"lang": "en",
"text": "restart the web server and check the disk space",
"keywords": ["restart", "server", "disk"],
"max_wer": 0.34
"measured_wer": 0.0,
"max_wer": 0.1
}
]
}
+15 -7
View File
@@ -66,6 +66,19 @@ func gateReason(samples []float32, rate, minMs int, minRMS float64) string {
return ""
}
// pcmSamples converts canonical s16le little-endian PCM to the float32 range
// whisper wants. Shared with the golden tests: they used to carry their own
// copy, so a regression here (a /32767 divisor, a byte order slip) left the
// assertion that the fixtures clear the silence gate green.
func pcmSamples(b []byte) []float32 {
out := make([]float32, len(b)/2)
for i := range out {
s := int16(b[i*2]) | int16(b[i*2+1])<<8
out[i] = float32(s) / 32768.0
}
return out
}
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
if err := ctx.Err(); err != nil {
return worker.TranscribeResp{}, fmt.Errorf("whisper: context done before transcribe: %w", err)
@@ -75,12 +88,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
return worker.TranscribeResp{}, fmt.Errorf("whisper: empty audio")
}
nSamples := len(a.Bytes) / 2
samples := make([]float32, nSamples)
for i := 0; i < nSamples; i++ {
s := int16(a.Bytes[i*2]) | int16(a.Bytes[i*2+1])<<8
samples[i] = float32(s) / 32768.0
}
samples := pcmSamples(a.Bytes)
// Silence gate: drop non-speech before whisper hallucinates on it.
if reason := gateReason(samples, whisperSampleRate, h.minMs, h.minRMS); reason != "" {
@@ -111,7 +119,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
ch := make(chan result, 1)
cSamples := (*C.float)(unsafe.Pointer(&samples[0]))
go func() {
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(nSamples)))}
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(len(samples))))}
}()
select {
case r := <-ch:
+12 -3
View File
@@ -12,9 +12,12 @@
// the gate that guards the tool allowlist. That is deliberate and it is the
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
//
// Consequently: mavend does not import internal/update, nothing runs on a timer,
// nothing checks a release server, and no act, intent, tool or LLM output can
// reach any of this. She cannot update herself. She can be updated, by him.
// Consequently: mavend never constructs an update.Updater and nothing in the
// daemon can call Apply, nothing runs on a timer, nothing checks a release
// server, and no act, intent, tool or LLM output can reach any of this. The
// package is linked into mavend through internal/config, which validates the
// update block at startup; the guarantee is the absent caller, not an absent
// import. She cannot update herself. She can be updated, by him.
//
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
@@ -158,6 +161,12 @@ func cmdRollback(ctx context.Context, u *update.Updater, id string) {
res, err := u.Rollback(ctx, id)
report(res.Steps)
summarize(res)
// The standalone rollback is what he reaches for when something is already
// wrong, so a failed one needs the loud paragraph more than apply does, not
// less.
if errors.Is(err, update.ErrRollbackFailed) {
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
}
if err != nil && !errors.Is(err, update.ErrRolledBack) {
die("\n%v", err)
}
+15 -4
View File
@@ -14,9 +14,12 @@
//
// While a reply is playing the capture side is muted (half-duplex): without
// it, Maven's own voice comes back in through the mic and she answers
// herself. -barge-in punches one hole in that gate — sustained energy well
// above the speaker's leak level cuts playback so he can talk over her. It is
// off by default because the threshold is room-specific; see playback.go.
// herself. -barge-in punches one hole in that gate — sustained energy above
// -barge-in-rms cuts playback so he can talk over her. It is off by default
// because the threshold is room-specific; see playback.go. The threshold is a
// raw frame RMS and has no reference to what the speaker actually leaks, so
// the daemon logs the mean energy of the frames it suppressed while speaking.
// Set -barge-in-rms from those numbers rather than by guessing.
//
// usage:
// mavwaked # default ALSA device, 127.0.0.1:9100
@@ -130,7 +133,15 @@ func run(args []string) error {
var barge bargeInConfig
if *bargeIn {
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
if barge.Enabled() {
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
} else {
// The log used to say "barge-in on (rms 0.0000 x 5)" here and then
// nothing happened, because Enabled needs a positive threshold.
log.Printf("mavwaked: -barge-in was passed but rms %.4f x %d frames disables it; "+
"both must be above zero, so barge-in is OFF",
barge.RMS, barge.Frames)
}
}
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
+15 -2
View File
@@ -33,6 +33,11 @@ import (
"github.com/kami/maven/internal/audio"
)
// playbackMargin is the slack over the reply's own duration before a stuck
// aplay is killed. Enough for ALSA to open the device and drain its buffer,
// short enough that a busy device does not cost her a turn.
const playbackMargin = 2 * time.Second
// player plays one reply at a time and can be cut off mid-utterance.
type player interface {
// Play starts playback of a, replacing anything already playing, and
@@ -87,6 +92,13 @@ func (p *aplayPlayer) Play(a audio.Audio) {
p.playing = true
p.mu.Unlock()
// Bound the mute window by the reply itself. Playing() gates all capture
// now, so a wedged aplay does not merely go silent, it makes her deaf for
// as long as the flag is set. The old ceiling was a flat 30s inherited
// from the fire-and-forget version, where it only bounded a leaked
// goroutine. A reply cannot legitimately take longer than it lasts.
limit := time.Duration(a.Duration()*float64(time.Second)) + playbackMargin
go func() {
if _, err := stdin.Write(a.Bytes); err != nil {
// Broken pipe is the expected outcome of Stop().
@@ -101,8 +113,9 @@ func (p *aplayPlayer) Play(a audio.Audio) {
if err != nil {
log.Printf("mavwaked: aplay: %v", err)
}
case <-time.After(30 * time.Second):
log.Printf("mavwaked: aplay timeout, killing")
case <-time.After(limit):
log.Printf("mavwaked: aplay did not finish %.1fs of audio within %s, killing (capture was muted the whole time)",
a.Duration(), limit)
if pr := cmd.Process; pr != nil {
_ = pr.Kill()
}
+118 -8
View File
@@ -7,6 +7,7 @@ package main
import (
"context"
"log"
"time"
"github.com/kami/maven/internal/audio"
)
@@ -42,6 +43,20 @@ type session struct {
lang string
barge bargeInConfig
// now is the clock, swapped in tests. The round-trip backlog is measured
// in wall time, because that is the only thing that says how much room
// went into the pipe while the daemon was thinking.
now func() time.Time
// discard is how many buffered frames still have to be thrown away
// before capture means anything again. See dispatch.
discard int
// recent holds the last few frames seen during playback, so the ones
// that proved he was interrupting can be replayed into the VAD after the
// barge-in reset instead of being clipped off the front of his sentence.
recent [][]byte
// loudFrames counts consecutive over-threshold frames seen while she is
// speaking. Reset whenever a frame falls back under the threshold, and
// whenever playback ends.
@@ -49,14 +64,28 @@ type session struct {
// counters, read by tests and logged on the way out.
suppressed int // frames dropped because she was speaking
dropped int // frames dropped as round-trip backlog
bargeIns int // times playback was cut because he spoke over her
sent int // utterances shipped to the daemon
// loudSum and loudSeen accumulate the energy of suppressed frames, so
// the operator can read what the room actually measures and set
// -barge-in-rms from data instead of guessing.
loudSum float64
loudSeen int
}
func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session {
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge}
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge, now: time.Now}
}
// frameDuration is the wall time one captured frame represents.
const frameDuration = defaultFrameMs * time.Millisecond
// suppressLogEvery — how many suppressed frames between energy reports. 200
// frames is six seconds of her talking, so this is roughly one line per reply.
const suppressLogEvery = 200
// feed processes one 30ms PCM frame.
//
// While the player is running the capture side is muted: the VAD is not fed
@@ -65,26 +94,52 @@ func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeI
// energy well above the speaker's leak level cuts playback, and capture
// resumes on the very next frame with a clean VAD.
func (s *session) feed(ctx context.Context, frame []byte) error {
// Backlog first, before anything looks at this frame. These are frames
// the microphone captured while the round-trip blocked; they arrive in a
// burst at pipe speed and they are not a command, not an answer and not
// an interruption.
if s.discard > 0 {
s.discard--
s.dropped++
return nil
}
if s.player.Playing() {
s.suppressed++
rms := frameRMS(PCMToI16(frame))
s.loudSum += rms
s.loudSeen++
if s.loudSeen >= suppressLogEvery {
// The doc comment asks for energy "well above the speaker's leak
// level" and never says what that is. This is what it is.
log.Printf("mavwaked: suppressed %d frames while speaking, mean rms %.4f (barge-in threshold %.4f)",
s.loudSeen, s.loudSum/float64(s.loudSeen), s.barge.RMS)
s.loudSum, s.loudSeen = 0, 0
}
if !s.barge.Enabled() {
return nil
}
if frameRMS(PCMToI16(frame)) < s.barge.RMS {
if rms < s.barge.RMS {
s.loudFrames = 0
s.recent = s.recent[:0]
return nil
}
s.loudFrames++
s.keepRecent(frame)
if s.loudFrames < s.barge.Frames {
return nil
}
// He is talking over her. Cut her off, drop the VAD state that
// accumulated from the echo, and start listening for real.
// accumulated from the echo, and start listening for real — starting
// with the frames that proved he was talking. Those used to be
// thrown away, which clipped the first 150ms off his interruption,
// and on a short one that is the whole first word.
s.player.Stop()
s.bargeIns++
s.loudFrames = 0
s.vad.Reset()
log.Printf("mavwaked: barge-in — stopped playback")
s.replayRecent()
return nil
}
@@ -102,22 +157,77 @@ func (s *session) feed(ctx context.Context, frame []byte) error {
return s.dispatch(ctx, utt)
}
// keepRecent stores a copy of one barge-in trigger frame, keeping at most
// barge.Frames of them.
func (s *session) keepRecent(frame []byte) {
if len(s.recent) >= s.barge.Frames {
copy(s.recent, s.recent[1:])
s.recent = s.recent[:len(s.recent)-1]
}
s.recent = append(s.recent, append([]byte(nil), frame...))
}
// replayRecent feeds the trigger frames back into the freshly reset VAD, so
// his interruption starts where he started it.
//
// Feed cannot complete an utterance here: closing one needs silenceMs of
// trailing quiet and these frames are all above the barge-in threshold, which
// is far above the VAD floor. Any utterance it did return would be a fragment
// of a sentence he is still speaking, so it is not dispatched.
func (s *session) replayRecent() {
for _, f := range s.recent {
s.vad.Feed(PCMToI16(f))
}
s.recent = s.recent[:0]
}
// dispatch ships a complete utterance and plays whatever comes back.
//
// Every return path here has to deal with the backlog. Nothing reads the
// microphone while Send is in flight, so the audio piles up in arecord's pipe
// and the kernel buffer, and it arrives in a burst the moment this returns. A
// round-trip is p50 2.7s through the LLM router, which is around 90 frames of
// room, of him finishing his sentence, of the television.
//
// This used to reset the VAD on the reply path only, and for the wrong reason:
// the comment said the VAD had been accumulating during the round-trip, when
// in fact its state is exactly what Feed left it as. The two paths that had no
// reset are the ones that mattered, because neither of them starts playback
// and so neither is covered by the half-duplex gate. A text-only turn fed the
// whole backlog straight into the VAD, and a Send error did the same on every
// failed turn, so a dead socket drove a retry loop off nothing but backlog.
func (s *session) dispatch(ctx context.Context, utt audio.Audio) error {
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes))
start := s.now()
reply, err := s.sender.Send(ctx, utt, s.lang)
s.sent++
defer s.dropBacklog(start)
if err != nil {
return err
}
// Count what was shipped, not what was attempted. This used to run
// before the error check, so failed round-trips counted as sent.
s.sent++
if len(reply.Bytes) == 0 {
log.Printf("mavwaked: empty reply audio (text only)")
return nil
}
// The VAD has been accumulating from the buffered mic stream while the
// round-trip blocked. None of it is a command — reset before the
// speaker opens, so the first post-reply frame starts clean.
s.vad.Reset()
s.player.Play(reply)
return nil
}
// dropBacklog resets the VAD and arranges for the frames captured during the
// round-trip to be thrown away as they arrive.
//
// Discarding them is also what keeps barge-in honest. The Frames guard is
// documented as "long enough that a door or a cough does not cut her off",
// which assumes the frames are real time. Draining a backlog delivers five
// frames in microseconds, so without this she could be cut off by audio
// recorded before she started speaking.
func (s *session) dropBacklog(start time.Time) {
s.vad.Reset()
s.loudFrames = 0
s.recent = s.recent[:0]
if elapsed := s.now().Sub(start); elapsed > 0 {
s.discard = int(elapsed / frameDuration)
}
}
+145
View File
@@ -5,6 +5,7 @@ import (
"errors"
"math"
"testing"
"time"
"github.com/kami/maven/internal/audio"
)
@@ -280,3 +281,147 @@ func TestBargeInConfigEnabled(t *testing.T) {
}
}
}
// slowSender models the real thing: a round-trip takes wall-clock time, and
// the microphone keeps recording into a pipe nobody is reading.
type slowSender struct {
fakeSender
clock *time.Time
took time.Duration
}
func (s *slowSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
*s.clock = s.clock.Add(s.took)
return s.fakeSender.Send(ctx, utt, lang)
}
// newSlowSession wires a session whose round-trip takes took of wall time.
func newSlowSession(barge bargeInConfig, reply audio.Audio, err error, took time.Duration) (*session, *fakePlayer, *slowSender) {
now := time.Unix(0, 0)
p := &fakePlayer{}
snd := &slowSender{fakeSender: fakeSender{reply: reply, err: err}, clock: &now, took: took}
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", barge)
sess.now = func() time.Time { return now }
return sess, p, snd
}
// A text-only turn starts no playback, so the half-duplex gate does not cover
// it. The backlog captured during the round-trip has to be dropped anyway, or
// three seconds of room arrives at pipe speed and becomes a command.
func TestSessionDropsBacklogAfterAnEmptyReply(t *testing.T) {
sess, p, snd := newSlowSession(bargeInConfig{}, audio.Audio{Format: audio.PCM16kMono}, nil, 3*time.Second)
speakThenPause(t, sess)
if p.plays != 0 || len(snd.sent) != 1 {
t.Fatalf("plays = %d, sent = %d; want one text-only turn", p.plays, len(snd.sent))
}
// The tail of speakThenPause already spent a couple of them.
if want := int(3 * time.Second / frameDuration); sess.discard+sess.dropped != want {
t.Fatalf("discard %d + dropped %d frames, want %d (3s of backlog)", sess.discard, sess.dropped, want)
}
// The burst: the whole backlog, all of it him still talking.
loud := frameAt(0.35)
for i := 0; i < sess.discard; i++ {
if err := sess.feed(context.Background(), loud); err != nil {
t.Fatal(err)
}
}
if len(snd.sent) != 1 {
t.Errorf("the backlog was sent as a second utterance (sent = %d)", len(snd.sent))
}
if sess.dropped == 0 {
t.Error("no frames were counted as backlog")
}
}
// Same on the error path. A dead daemon used to seed the next spurious trigger
// on every failed turn, so a dead socket drove a retry loop off backlog alone.
func TestSessionDropsBacklogAfterASendError(t *testing.T) {
sess, _, _ := newSlowSession(bargeInConfig{}, audio.Audio{}, errors.New("boom"), 3*time.Second)
// Not speakThenPause: the dispatch returns the send error, which that
// helper treats as fatal.
loud := frameAt(0.35)
for i := 0; i < (defaultSpeechMs+defaultFrameMs-1)/defaultFrameMs+5; i++ {
_ = sess.feed(context.Background(), loud)
}
for i := 0; i < (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs+2; i++ {
_ = sess.feed(context.Background(), silentBytes())
}
if sess.discard == 0 {
t.Fatal("a failed round-trip left the backlog to be fed into the VAD")
}
}
// Barge-in must not be triggerable by the backlog. Those frames are him
// finishing the sentence he started before she answered, delivered in
// microseconds, and the five-frame guard assumes real time.
func TestSessionBacklogCannotBargeIn(t *testing.T) {
sess, p, _ := newSlowSession(bargeInConfig{RMS: 0.12, Frames: 5}, replyAudio(), nil, 3*time.Second)
speakThenPause(t, sess)
if p.plays != 1 || !p.Playing() {
t.Fatalf("plays = %d, playing = %v; want the reply playing", p.plays, p.Playing())
}
loud := frameAt(0.35)
backlog := sess.discard
if backlog < 5 {
t.Fatalf("discard = %d, want a real backlog", backlog)
}
for i := 0; i < backlog; i++ {
if err := sess.feed(context.Background(), loud); err != nil {
t.Fatal(err)
}
}
if p.stops != 0 {
t.Fatalf("she was cut off by audio recorded before she started speaking (stops = %d)", p.stops)
}
// Real-time speech after the backlog still interrupts her.
for i := 0; i < 5; i++ {
if err := sess.feed(context.Background(), loud); err != nil {
t.Fatal(err)
}
}
if p.stops != 1 {
t.Fatalf("stops = %d, want 1 — barge-in must still work after the backlog", p.stops)
}
}
// The frames that proved he was interrupting are replayed into the VAD, so his
// first word is not clipped. Five trigger frames plus five real ones reach the
// 300ms speech threshold; without the replay the first five are lost and no
// utterance is produced at all.
func TestSessionReplaysTheBargeInTriggerFrames(t *testing.T) {
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
speakThenPause(t, sess)
veryLoud := frameAt(0.35)
for i := 0; i < 5; i++ {
_ = sess.feed(context.Background(), veryLoud)
}
if p.stops != 1 {
t.Fatalf("expected barge-in, stops = %d", p.stops)
}
if p.Playing() {
t.Fatal("fake player still playing after Stop")
}
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
for i := 0; i < speechFrames-5; i++ {
if err := sess.feed(context.Background(), veryLoud); err != nil {
t.Fatal(err)
}
}
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
for i := 0; i < silenceFrames; i++ {
if err := sess.feed(context.Background(), silentBytes()); err != nil {
t.Fatal(err)
}
}
if len(snd.sent) != 2 {
t.Fatalf("sent %d utterances, want 2 — the 150ms that triggered barge-in was clipped", len(snd.sent))
}
}
+16 -1
View File
@@ -30,6 +30,13 @@ import (
// A notification with no recognisable clock reading stores NOTHING. Maven is
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
// is worse than a gap.
//
// 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.
// ambientMaxBody bounds the request. A notification is two short lines.
const ambientMaxBody = 8 << 10
@@ -120,8 +127,16 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
// header, compared in constant time.
//
// The scheme is matched case-insensitively. RFC 7235 says it is, and a phone
// client sending "bearer <tok>" used to fall through to the X-Maven-Token
// branch and get a silent 401 with nothing to see from the phone's side.
func ambientAuthorized(r *http.Request, token string) bool {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
got := ""
if authz := strings.TrimSpace(r.Header.Get("Authorization")); len(authz) >= len("Bearer") &&
strings.EqualFold(authz[:len("Bearer")], "Bearer") {
got = strings.TrimSpace(authz[len("Bearer"):])
}
if got == "" {
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
}
+25 -1
View File
@@ -55,7 +55,10 @@ func meetingNotification() calendar.Notification {
Package: "com.google.android.gm",
Title: "Планёрка",
Text: "10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
// Local, like a phone relaying from the box's own timezone: the fact
// key and value are stamped on the owner's clock, so a UTC reading
// here would only be testing the offset of the test machine.
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local),
}
}
@@ -166,6 +169,27 @@ func TestHandleAmbientAuth(t *testing.T) {
}
})
// RFC 7235 says the scheme is case-insensitive. A phone sending
// "bearer <tok>" used to fall through to the X-Maven-Token branch and get a
// 401 that looked, from the phone's side, like a wrong token.
t.Run("lowercase bearer scheme accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusCreated {
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
}
})
// A bare token with no scheme is not a bearer header. Accepting it made the
// Authorization branch a second, undocumented X-Maven-Token.
t.Run("bare token in Authorization rejected", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("Authorization", ambientTestToken), &ambientCore{}, ambientTestToken)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
})
t.Run("X-Maven-Token accepted", func(t *testing.T) {
rr := httptest.NewRecorder()
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
+16 -1
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"sync"
"time"
"github.com/kami/maven/internal/ipc"
)
// The three sibling services Maven coordinates are headless JSON APIs (no web UI
@@ -84,9 +86,13 @@ type ecoData struct {
Nexus ecoPanel[ecoEntity]
Praxis ecoPanel[ecoItem]
Hexis ecoPanel[ecoCap]
Calls ecoPanel[ipc.EcosystemTrace]
}
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
// handleEcosystem renders the three sibling panels plus Maven's own log of the
// calls she made to them. The call log comes from core, not from the siblings:
// it is what Maven saw, including the hops that never got an answer.
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core ipc.CoreAPI) {
ctx := r.Context()
var d ecoData
var wg sync.WaitGroup
@@ -102,6 +108,15 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }()
wg.Wait()
if core == nil {
d.Calls.Err = "not configured"
} else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil {
log.Printf("ecosystem traces: %v", err)
d.Calls.Err = "core read failed"
} else {
d.Calls.Rows = rows
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ecosystemTmpl.Execute(w, d); err != nil {
log.Printf("ecosystem render: %v", err)
+14 -1
View File
@@ -41,11 +41,24 @@
{{end}}
</section>
<section class=card id=eco-calls>
<div class=section-header>
<h2>Calls <span class=card-sub>what Maven asked them</span></h2>
</div>
{{with .Calls}}
{{if .Err}}<div class=empty>calls — {{.Err}}</div>
{{else if not .Rows}}<div class=empty>no ecosystem calls yet.</div>
{{else}}<div class=scroll><table class=mono><tr><th>when<th>service<th>operation<th>status<th>ms<th>http<th>correlation</tr>
{{range .Rows}}<tr><td>{{ago .Ts}}<td><span class=badge>{{.Service}}</span><td class=en>{{.Operation}}<td>{{if eq .Status "ok"}}<span class="badge badge-ok">ok</span>{{else}}<span class="badge badge-warn">{{.Status}}</span>{{end}}<td>{{.DurationMs}}<td>{{if .HTTPStatus}}{{.HTTPStatus}}{{else}}—{{end}}<td class=key>{{.CorrelationID}}</tr>{{end}}
</table></div>{{end}}
{{end}}
</section>
{{template "shellBottom"}}
<script>
setInterval(() => fetch('/ecosystem').then(r => r.text()).then(html => {
const d = new DOMParser().parseFromString(html, 'text/html');
for (const id of ['eco-nexus', 'eco-praxis', 'eco-hexis']) {
for (const id of ['eco-nexus', 'eco-praxis', 'eco-hexis', 'eco-calls']) {
const old = document.getElementById(id), nu = d.getElementById(id);
if (old && nu) old.replaceWith(nu);
}
+9 -5
View File
@@ -1,17 +1,21 @@
{{template "shellTop" "events"}}
<h1>Intake</h1>
<div class=hint>Everything that arrived, newest first — a relayed notification, a mail candidate, a feed
item, a changed page, a spend, a presence probe. One envelope per write; the durable row is still the
fact, note or task itself. Held in memory only, so a restart empties this.</div>
<div class=hint>Everything that arrived, most recently noticed first — a relayed notification, a mail
candidate, a feed item, a changed page, a spend, a presence probe. Maven's own bookkeeping writes (feed
watermarks, crawl hashes, act traces, settings he toggled) are not here: nothing arrived. <b>noticed</b>
is when the journal saw it, <b>happened</b> is when the thing itself did, and those differ by days on a
cold feed read. One envelope per write; the durable row is still the fact, note or task itself. Held in
memory only, so a restart empties this.</div>
{{if .Err}}<div class=hint>journal unavailable: {{.Err}}</div>{{end}}
{{if and (not .Events) (not .Err)}}
<div class=hint>nothing has arrived yet</div>
{{end}}
{{if .Events}}
<div class=scroll><table class=mono>
<tr><th>when<th>source<th>kind<th>pri<th>what<th>detail</tr>
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
{{range .Events}}<tr>
<td>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.Source}}</td>
<td class=gray>{{.Kind}}</td>
<td class=gray>{{.Priority}}</td>
+79
View File
@@ -1172,3 +1172,82 @@ func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
t.Error("expected the empty-state copy")
}
}
// --- voice-path step-up gate (Vikunja #317) ---
//
// POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs
// the same router, LLM and act path as POST /api/chat. They used to be
// ungated on the grounds that the voice port is only reachable inside the
// deploy, but mavweb is the thing proxying into it from outside. Speaking
// "выключи свет" is not a smaller act than typing it.
// unreachableVoice is a closed port: a request that clears the gate fails at
// the dial with 503, which is how these tests tell "passed" from "denied".
const unreachableVoice = "127.0.0.1:1"
func pttReq() *http.Request {
return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes"))
}
func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandlePTT_UnassertedSession_Denied(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true)
if rr.Code == http.StatusForbidden {
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
}
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
}
}
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk
// keeps working, resting on the transport-level auth in front of mavweb.
func TestHandlePTT_FailOpenByDefault(t *testing.T) {
rr := httptest.NewRecorder()
handlePTT(rr, pttReq(), unreachableVoice, nil, false)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) {
rr := httptest.NewRecorder()
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
func TestHandleWS_UnassertedSession_Denied(t *testing.T) {
rr := httptest.NewRecorder()
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
}
}
// Past the gate the handshake itself fails (httptest's recorder cannot be
// hijacked), which is not a 403. That is all this asserts: the gate let it by.
func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
rr := httptest.NewRecorder()
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true)
if rr.Code == http.StatusForbidden {
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
}
}
+123 -40
View File
@@ -18,6 +18,7 @@ import (
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"time"
@@ -318,14 +319,14 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel
// 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))
// 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.
// 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))
// 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))
func noCache(h http.Handler) http.Handler {
@@ -351,7 +352,7 @@ func main() {
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
@@ -363,6 +364,11 @@ func main() {
flag.Parse()
var core ipc.CoreAPI
// swapConn — a second connection, for /models and nothing else. A model swap
// is a multi-minute IPC call and ipc.Client serialises everything on one
// mutex, so sharing the connection would freeze every other page for the
// length of the load. See handleModels.
var swapConn modelController
if *coreSock != "" {
c, err := ipc.DialWait(*coreSock, 60*time.Second)
if err != nil {
@@ -370,6 +376,12 @@ func main() {
}
defer c.Close()
core = c
if sc, err := ipc.Dial(*coreSock); err != nil {
log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err)
} else {
defer sc.Close()
swapConn = sc
}
}
mux := http.NewServeMux()
@@ -387,12 +399,9 @@ func main() {
handleVoice(w, r)
}))
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
handleWS(w, r, *voiceAddr)
})
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
handlePTT(w, r, *voiceAddr)
})
// /ws and /api/ptt are registered further down, next to /api/chat: they
// carry the same step-up gate and so need stepUpSession, which is only
// built once the passkey endpoints are wired.
mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
@@ -443,7 +452,7 @@ func main() {
})
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
handleEcosystem(w, r, ecoURLsCfg)
handleEcosystem(w, r, ecoURLsCfg, core)
})
// ----- passkey (WebAuthn) endpoints -----
// Wired when both -core and a configured origin are present. The origin
@@ -475,10 +484,29 @@ func main() {
mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish)
}
if stepUpSession == nil {
// One surface per line: these are read in a terminal at the moment
// someone is deciding whether the box is safe to expose.
surfaces := []string{
"POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES",
"POST /routines accepting schedules recurring firing",
"POST /models chooses the resident model that routes and words every turn",
"POST /api/revert voids the latest fact for a key",
"POST /api/chat reaches the router, the LLM and, through applyAction, the act path",
"POST /api/ptt the same, from audio",
"GET /ws the same, streamed",
}
if *requireStepUp {
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):")
} else {
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:")
}
for _, s := range surfaces {
log.Printf("SECURITY: %s", s)
}
if *requireStepUp {
log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
} else {
log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
}
}
@@ -499,22 +527,36 @@ func main() {
// /tools, and for a comparable reason: which model is loaded decides how every
// utterance is routed and how every reply is worded. GET is read-only.
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
handleModels(w, r, core, stepUpSession, *requireStepUp)
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
})
// State-changing routes on this server, and their gate (Vikunja #317):
//
// POST /tools step-up — defines argv that internal/tool executes
// POST /routines step-up — accepting schedules recurring firing
// POST /models step-up — replaces the model that routes and phrases
// POST /api/revert step-up — voids the latest fact for a key
// POST /api/chat step-up — reaches the router, LLM and the act path
// POST /api/ptt step-up — audio into runTurn, so the same router,
// LLM and act path as /api/chat
// GET /ws step-up — same, streamed
// POST /api/signal none — appends a presence fact, no argv, no act
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
// is itself only reachable inside the deploy
// POST /api/ambient shared secret — notification relay, constant-time
// token compare, poster is a phone service
// and not a browser, so step-up cannot apply
//
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
// otherwise fail-open unless -require-stepup, which denies.
//
// /api/ptt and /ws used to be ungated, justified by mavend's voice port
// being reachable only inside the deploy. That argument does not hold:
// mavweb is the thing proxying into it from outside. Speaking "выключи
// свет" is not a smaller act than typing it (Vikunja #317).
//
// The gate here is per-request, which costs the hands-free case a passkey
// assertion per turn whenever WebAuthn is configured. A session-scoped
// assertion covering a run of turns is the right shape and is its own task.
//
// GET /chat only renders the page and echoes back the q/r query params the
// POST redirect set — nothing to gate.
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
@@ -526,6 +568,12 @@ func main() {
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
handleRevert(w, r, core, stepUpSession, *requireStepUp)
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp)
})
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp)
})
srv := &http.Server{Addr: *addr, Handler: mux}
@@ -543,7 +591,11 @@ func main() {
}
}
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string) {
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
@@ -865,17 +917,29 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
// now — the wall clock, indirected so the task page can be rendered at a fixed
// instant in a test. internal/tasks is pure and the daemon path already ranks
// through a clock it is handed; the page had no reason to be the one surface
// that could only be tested at whatever time it happened to run.
var now = time.Now
// resolvedShown — how many finished tasks the page renders. The list is
// history, it only grows, and the rows below the first screen are read by
// nobody.
const resolvedShown = 50
// taskRow is one line on /tasks, with every timestamp already formatted so the
// template holds no date logic.
type taskRow struct {
ID int64
Text string
Source string
Evidence string
Status string
Due string
Created string
Resolved string
ID int64
Text string
Source string
Evidence string
Status string
Due string
Created string
Resolved string
ResolvedBy string
// Why — the ranker's reason for this row's position (Vikunja #129), in
// Russian, empty when nothing distinguished the task. Blank is the honest
// rendering: he never said this one mattered more.
@@ -923,6 +987,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
// rows keep store order (newest first) — ranking finished work is pointless.
var live []tasks.Item
var resolved []taskRow
resolvedTotal := 0
for _, t := range all {
switch t.Status {
case "candidate", "open":
@@ -931,10 +996,18 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
})
default:
resolvedTotal++
// Finished work is history, and the history only grows. The page
// showed every row that ever existed, which is a page that gets
// slower every month for a section nobody reads past the top of.
if len(resolved) >= resolvedShown {
continue
}
resolved = append(resolved, taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
ResolvedBy: t.ResolvedBy,
})
}
}
@@ -943,7 +1016,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
byID[t.ID] = t
}
var cands, open []taskRow
for _, r := range tasks.Rank(live, time.Now()) {
for _, r := range tasks.Rank(live, now()) {
t := byID[r.ID]
row := taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
@@ -962,11 +1035,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tasksTmpl.Execute(w, struct {
Msg, Err string
Candidates []taskRow
Open []taskRow
Resolved []taskRow
}{msg, errMsg, cands, open, resolved}); err != nil {
Msg, Err string
Candidates []taskRow
Open []taskRow
Resolved []taskRow
ResolvedMore bool
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
log.Printf("tasks render: %v", err)
}
}
@@ -981,12 +1055,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if text == "" {
return "", errors.New("empty task text")
}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
// Importance is his, stated on the form. Out-of-range values are
// clamped rather than rejected — a bad select is not worth a 400.
if v := r.FormValue("weight"); v != "" {
var wgt int
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
// form value is not a place to accept trailing garbage.
wgt, err := strconv.Atoi(v)
if err != nil || wgt < 0 {
return "", fmt.Errorf("bad weight %q", v)
}
if wgt > tasks.MaxWeight {
@@ -995,7 +1071,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
req.Weight = wgt
}
if d := r.FormValue("due"); d != "" {
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
if err != nil {
return "", fmt.Errorf("bad due date %q", d)
}
@@ -1005,14 +1081,17 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if err != nil {
return "", err
}
if resp.Promoted {
return "confirmed a candidate maven had found", nil
}
if !resp.Created {
return "already on the list", nil
}
return "added task", nil
}
var id int64
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
return "", errors.New("invalid id")
}
var status, msg string
@@ -1026,7 +1105,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
default:
return "", fmt.Errorf("unknown action %q", action)
}
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
return "", err
}
return msg, nil
@@ -1449,11 +1528,15 @@ func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
}
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) {
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", 405)
return
}
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), 400)
+23 -7
View File
@@ -36,6 +36,7 @@ var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
<p class=hint>swapping requires step-up <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon including the one <code>mavupdate</code> does comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
{{if .Off}}
@@ -80,12 +81,22 @@ type modelsPage struct {
// A failed swap is reported as a failure with the model that is still serving
// named, because that is the state the operator needs: the daemon rolled back
// and is answering turns, it just is not answering them with what he asked for.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
// swapConn, when non-nil, is a SECOND connection to the same core, used for
// nothing but this page. ipc.Client holds its mutex for a whole roundtrip and
// neither side sets a read deadline, so a swap on the shared connection blocks
// /dash, /history, /notifications and everything else for as long as the load
// takes: a 90s drain plus a 60s launch plus a 30s probe, doubled if it rolls
// back. No browser timeout frees them, because the server side keeps reading
// the reply. On its own connection the swap only blocks the swap.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swapConn modelController, session *webauthn.PasskeySession, requireStepUp bool) {
if core == nil {
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
return
}
mc, ok := core.(modelController)
mc, ok := swapConn, swapConn != nil
if !ok {
mc, ok = core.(modelController)
}
if !ok {
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
return
@@ -103,11 +114,13 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
http.Error(w, "model_path required", http.StatusBadRequest)
return
}
req := ipc.SwapModelReq{ModelPath: path}
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
req.NCtx = v
}
res, err := mc.SwapModel(ctx, req)
// Only the path comes off the form. n_ctx and n_gpu_layers are load
// settings the daemon keeps from what is live, and the resident model is
// a Thinking variant whose 4096-token window is sized for reasoning
// tokens (CLAUDE.md). A field nothing renders, that a hand-crafted POST
// could use to shrink the window under the router, is not worth having.
// Changing them is a config edit and a restart.
res, err := mc.SwapModel(ctx, ipc.SwapModelReq{ModelPath: path})
switch {
case err == nil:
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
@@ -118,6 +131,9 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sess
case errors.Is(err, ipc.ErrUnknownMethod):
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
return
case res.NoBackend:
page.Err = "swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed."
log.Printf("models: swap to %s failed and the rollback failed, no model loaded: %v", path, err)
case res.RolledBack:
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
+58 -2
View File
@@ -36,7 +36,7 @@ func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ip
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, nil, false)
return w
}
@@ -45,7 +45,7 @@ func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, session, requireStepUp)
handleModels(w, r, core, nil, session, requireStepUp)
return w
}
@@ -148,3 +148,59 @@ func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
type errBrokenModel struct{}
func (errBrokenModel) Error() string { return "llm: server did not start" }
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
// The load failed and so did the rollback: nothing is loaded. The page used
// to branch on RolledBack first and render "rolled back to — she is still
// answering, with the old model" over an empty model name.
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{NoBackend: true},
swapErr: errBrokenModel{},
status: ipc.ModelStatusResp{Model: "unknown"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models after a total failure = %d; want 200 with the failure rendered", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "still answering") {
t.Errorf("the page claims she is still answering while no model is loaded:\n%s", body)
}
if !strings.Contains(body, "no model is loaded") {
t.Errorf("the page does not name the state the operator is in:\n%s", body)
}
}
func TestModels_POSTIgnoresLoadSettingsOffTheForm(t *testing.T) {
// n_ctx was read off a form that renders no such input, so only a
// hand-crafted POST could set it. The resident model is a Thinking variant
// whose window is sized for reasoning tokens; shrinking it from the wire is
// not a capability this page offers.
core := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf&n_ctx=512&n_gpu_layers=0"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, nil, nil, false)
if len(core.swapped) != 1 {
t.Fatalf("SwapModel calls = %v; want one", core.swapped)
}
if got := core.swapped[0]; got.NCtx != 0 || got.NGpuLayers != 0 {
t.Errorf("swap request = %+v; want the load settings left to the daemon", got)
}
}
func TestModels_SwapUsesItsOwnConnection(t *testing.T) {
// A swap is a multi-minute IPC call and ipc.Client serialises everything on
// one mutex, so it must not run on the connection every other page shares.
shared := &fakeModelCore{status: ipc.ModelStatusResp{Model: "qwen3"}}
swapConn := &fakeModelCore{swapResp: ipc.SwapModelResp{Model: "qwen3-cpt"}}
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path=/m/cpt.gguf"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleModels(httptest.NewRecorder(), r, shared, swapConn, nil, false)
if len(shared.swapped) != 0 {
t.Errorf("the swap went out on the shared connection: %v", shared.swapped)
}
if len(swapConn.swapped) != 1 {
t.Errorf("the swap did not use the dedicated connection: %v", swapConn.swapped)
}
}
+119 -3
View File
@@ -32,17 +32,28 @@ type fakeKeyIPC struct {
unlockCalls int
wrapCalls int
unlockErr error
wrapExplicit bool
// opensWith, when set, is the only secret Unlock accepts. It stands in
// for a wrapped blob on disk: everything else gets unlockErr.
opensWith []byte
}
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
f.unlockCalls++
f.unlockSecret = bytes.Clone(secret)
if f.opensWith != nil {
if bytes.Equal(secret, f.opensWith) {
return nil
}
return errors.New("unwrap key: decrypt failed (wrong credential?)")
}
return f.unlockErr
}
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte, explicit bool) error {
f.wrapCalls++
f.wrapSecret = bytes.Clone(secret)
f.wrapExplicit = explicit
return nil
}
@@ -137,6 +148,13 @@ func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
// assert drives POST /assert/finish with a valid assertion and the given
// base64url PRF result.
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
t.Helper()
return a.assertExplicit(t, h, prf, false)
}
// assertExplicit is assert with control over the explicit flag the rewrite
// button sets.
func (a *prfAuthenticator) assertExplicit(t *testing.T, h *PasskeyHandle, prf string, explicit bool) *httptest.ResponseRecorder {
t.Helper()
_, chal, err := h.rp.AssertionOptions()
if err != nil {
@@ -152,6 +170,7 @@ func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *h
body, _ := json.Marshal(map[string]any{
"challenge": chal,
"prf": prf,
"explicit": explicit,
"credential": map[string]any{
"id": b64u(a.credID),
"type": "public-key",
@@ -253,8 +272,8 @@ func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if key.unlockCalls != 1 {
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
if key.unlockCalls == 0 {
t.Error("unlock was never attempted")
}
}
@@ -291,3 +310,100 @@ func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
}
}
}
// A box enrolled before Vikunja #14 has a v1 blob wrapped under the credential
// PUBLIC key. The PRF secret cannot open it, and this handler is the only
// caller of Unlock, so without the legacy retry that box stays locked forever
// while a perfectly good passkey is asserted at it.
func TestLegacyV1BlobStillColdStarts(t *testing.T) {
key := &fakeKeyIPC{}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
pub, _, err := h.store.Lookup(b64u(auth.credID))
if err != nil {
t.Fatalf("lookup: %v", err)
}
// The daemon only opens under the public key — a v1 blob.
key.opensWith = pub
secret := bytes.Repeat([]byte{9}, 32)
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if key.unlockCalls != 2 {
t.Fatalf("unlock attempted %d times, want 2 (PRF, then the legacy public key)", key.unlockCalls)
}
if !bytes.Equal(key.unlockSecret, pub) {
t.Fatal("the legacy retry did not send the credential public key, so a v1 box can never cold-start again")
}
}
// The PRF secret is tried first and, when it works, the public key is never
// sent. The legacy retry is a one-way door out of v1, not a fallback offered
// to every assertion.
func TestPRFUnlockNeverFallsBackWhenItWorks(t *testing.T) {
secret := bytes.Repeat([]byte{7}, 32)
key := &fakeKeyIPC{opensWith: secret}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if key.unlockCalls != 1 {
t.Fatalf("unlock attempted %d times, want 1", key.unlockCalls)
}
}
// Wrapping the at-rest key is an explicit act, never a side effect of a
// step-up. A page POSTing a substituted prf on a routine assertion must not
// make the daemon re-wrap the database key under it.
func TestPlainAssertionAsksForNoRewrite(t *testing.T) {
key := &fakeKeyIPC{}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
if w := auth.assert(t, h, b64u(bytes.Repeat([]byte{5}, 32))); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if key.wrapCalls != 1 {
t.Fatalf("wrapCalls = %d, want 1", key.wrapCalls)
}
if key.wrapExplicit {
t.Fatal("a plain step-up asked the daemon to rewrite the cold-start key")
}
}
// The rewrite button, and only the rewrite button, sets explicit.
func TestRewriteButtonAsksForAnExplicitWrap(t *testing.T) {
key := &fakeKeyIPC{}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
if w := auth.assertExplicit(t, h, b64u(bytes.Repeat([]byte{6}, 32)), true); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if !key.wrapExplicit {
t.Fatal("the explicit flag did not reach the daemon, so the rewrite button cannot work")
}
}
// The page is the only place the explicit flag originates. If the button or
// the field goes away, rewriting a cold-start key becomes impossible with
// nothing failing.
func TestPasskeyPageHasTheRewriteButton(t *testing.T) {
for _, want := range []string{
"rewrite cold-start key",
"explicit:!!explicit",
"async function rewrapKey()",
} {
if !strings.Contains(passkeyPageHTML, want) {
t.Errorf("the passkey page no longer contains %q", want)
}
}
}
+7 -2
View File
@@ -9,6 +9,9 @@
<input type=hidden name=action value=add>
<input type=text name=text placeholder="что нужно сделать" size=44 required>
<input type=date name=due title="due date (optional)">
<!-- weight 1 is skipped on purpose: the two rungs here are the two words she
recognises out loud ("важно", "срочно"), so the form and the spoken markers
mean the same thing. -->
<select name=weight title="importance (optional)">
<option value=0>normal</option>
<option value=2>важно</option>
@@ -43,7 +46,7 @@
<section class=card>
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
{{if .Open}}<div class=scroll><table>
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
{{range .Open}}<tr>
@@ -72,12 +75,14 @@
<section class=card>
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
<div class=scroll><table>
<tr><th>task</th><th>status</th><th>when</th></tr>
<tr><th>task</th><th>status</th><th>when</th><th>by</th></tr>
{{range .Resolved}}<tr>
<td class=text-max>{{.Text}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=muted>{{.Resolved}}</td>
<td class=hint>{{.ResolvedBy}}</td>
</tr>{{end}}</table></div>
{{if .ResolvedMore}}<div class=hint>only the {{len .Resolved}} most recent are shown.</div>{{end}}
</section>
{{end}}
{{template "shellBottom"}}
+119 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -27,7 +28,10 @@ type fakeTaskCore struct {
statusID int64
statusVal string
statusBy string
statusErr error
promoted bool
}
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -42,11 +46,11 @@ func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (i
if f.captureErr != nil {
return ipc.CaptureTaskResp{}, f.captureErr
}
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil
}
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
f.statusID, f.statusVal = id, status
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
f.statusID, f.statusVal, f.statusBy = id, status, by
return f.statusErr
}
@@ -223,3 +227,115 @@ func TestApplyTaskPostClampsWeight(t *testing.T) {
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
}
}
// The page ranked with the wall clock while the daemon path ranked with a clock
// it was handed, so this was the one surface that could only be tested at
// whatever time it happened to run.
func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) {
fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600))
old := now
now = func() time.Time { return fixed }
t.Cleanup(func() { now = old })
// Due tomorrow, local time, stored the way the store hands it back: UTC.
due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC()
core := &fakeTaskCore{tasks: []ipc.Task{
{ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due},
}}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
body := rec.Body.String()
if !strings.Contains(body, "завтра") {
t.Errorf("why column does not say завтра: %q", why(body))
}
if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") {
t.Error("a task due tomorrow was ranked as today's or overdue")
}
}
// why is a crude excerpt of the rendered why column, for a readable failure.
func why(body string) string {
i := strings.Index(body, "<td class=hint>")
if i < 0 {
return body
}
j := i + 200
if j > len(body) {
j = len(body)
}
return body[i:j]
}
// The resolved section rendered every row that ever existed.
func TestHandleTasksBoundsResolved(t *testing.T) {
base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
var rows []ipc.Task
for i := 0; i < resolvedShown+10; i++ {
ts := base.Add(time.Duration(i) * time.Minute)
rows = append(rows, ipc.Task{
ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
CreatedTs: ts, Resolved: &ts,
})
}
core := &fakeTaskCore{tasks: rows}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
body := rec.Body.String()
if n := strings.Count(body, "задача "); n != resolvedShown {
t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
}
if !strings.Contains(body, "most recent are shown") {
t.Error("the page must say it is showing only part of the history")
}
}
// A capture over a candidate is a confirmation, not a duplicate.
func TestHandleTasksAddSaysPromoted(t *testing.T) {
core := &fakeTaskCore{promoted: true}
form := url.Values{"action": {"add"}, "text": {"продлить страховку"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
if !strings.Contains(rec.Body.String(), "confirmed a candidate") {
t.Error("a promoted capture must not read as a duplicate")
}
}
// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
core := &fakeTaskCore{created: true}
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
if len(core.captured) != 0 {
t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
}
if !strings.Contains(rec.Body.String(), "bad weight") {
t.Error("error not surfaced on the page")
}
core = &fakeTaskCore{}
form = url.Values{"action": {"done"}, "id": {"42junk"}}
req = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if core.statusID != 0 {
t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
}
}
// A resolution says what resolved it: resolved_ts recorded when and never by
// what.
func TestHandleTasksRecordsTheCaller(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{"action": {"done"}, "id": {"42"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if core.statusBy != "tap:web" {
t.Errorf("resolved by %q, want tap:web", core.statusBy)
}
}
+91 -25
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -23,7 +24,7 @@ type assertIPC interface {
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
// StoreEncryptionKey and Unlock are silently skipped.
type keyIPC interface {
StoreEncryptionKey(ctx context.Context, secret []byte) error
StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error
Unlock(ctx context.Context, secret []byte) error
}
@@ -86,8 +87,10 @@ const passkeyPageHTML = `{{template "shellTop" "passkey"}}
<div class=flex gap-2>
<button class=btn onclick=enroll()>enroll passkey</button>
<button class=btn onclick=assert()>assert (step-up)</button>
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
<a href=/tools><button class=btn-primary> tools</button></a>
</div>
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
<div id=msg></div>
{{template "shellBottom"}}
<script>
@@ -112,7 +115,7 @@ async function enroll(){try{
say(prfOK?'enrolled now assert once to write the cold-start key':
'enrolled but this authenticator has no PRF: cold-start unlock unavailable',true);
}catch(e){say('enroll error: '+e,false);}}
async function assert(){try{
async function assert(explicit){try{
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
options.challenge=ub64(options.challenge);
const c=await navigator.credentials.get({publicKey:options});
@@ -121,13 +124,20 @@ async function assert(){try{
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,prf,credential:{id:c.id,type:c.type,response:{
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
signature:b64u(c.response.signature)}}})});
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
say(prf?'stepped up enable tools now':
'stepped up no PRF from this authenticator, so cold-start unlock stayed unavailable',true);
if(!prf){say('stepped up no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
say(explicit?'stepped up cold-start key now points at this passkey':
'stepped up enable tools now',true);
}catch(e){say('assert error: '+e,false);}}
// Rewriting the wrapped key is a separate gesture, never a side effect of a
// step-up. Only this button sets explicit, and only explicit lets the daemon
// replace a blob that already exists.
async function rewrapKey(){
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
await assert(true);}
</script>`
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
@@ -201,7 +211,20 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
// getClientExtensionResults(). Empty when the authenticator has no
// PRF extension: cold-start unlock is then unavailable and we say so
// rather than falling back to something weaker.
//
// Known property, accepted deliberately: this value is supplied by
// the client and is NOT covered by the assertion signature. WebAuthn
// client extension outputs never are, and binding one would need a
// per-assertion salt, which would make the wrapped blob unopenable on
// the next boot. Nothing here can tell a real PRF output from 32
// bytes a compromised page chose. What limits the damage is that the
// daemon refuses to rewrite an existing blob unless the operator
// asked for it — see Explicit below and cmd/mavend/keyfile.go.
PRF string `json:"prf"`
// Explicit marks the "rewrite cold-start key" button rather than a
// plain step-up. Only then may the daemon replace a blob that is
// already on disk.
Explicit bool `json:"explicit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
@@ -235,32 +258,22 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
}
}
// Cold-start unlock and key wrapping, both keyed on the PRF secret that
// this assertion just produced. The secret is used here and dropped; it is
// never stored on this side.
// Cold-start unlock and key wrapping, both keyed on the PRF secret this
// assertion just produced. The secret is used here and dropped; it is
// never stored on this side, and it must never be logged — unlike a
// signature it does not expire, so one copy in a proxy log or a HAR file
// is permanent access to the wrapped blob.
//
// Order matters: unlock first (if the daemon is locked there is nothing to
// wrap yet), then re-wrap, which writes the blob on the first assertion
// after enrolment and is a harmless rewrite afterwards. Both are
// best-effort — the assertion itself is valid either way.
// wrap yet), then wrap. Both are best-effort, because the assertion itself
// is valid either way.
if h.encryptFn != nil {
secret, err := webauthn.DecodePRFResult(body.PRF)
switch {
case err != nil:
if secret, err := webauthn.DecodePRFResult(body.PRF); err != nil {
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
default:
} else {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.encryptFn.Unlock(ctx, secret); err != nil {
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
} else {
log.Printf("webauthn: daemon unlocked via credential %s", credID)
}
if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil {
log.Printf("webauthn: wrap encryption key: %v", err)
} else {
log.Printf("webauthn: encryption key wrapped for credential %s", credID)
}
h.coldStart(ctx, credID, secret, body.Explicit)
}
}
@@ -272,3 +285,56 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
log.Printf("webauthn: asserted credential %s", credID)
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
}
// coldStart unlocks a locked daemon with this assertion's PRF output and then
// asks it to wrap the at-rest key. Never fatal: a locked or unreachable daemon
// does not invalidate the step-up.
//
// # The legacy retry
//
// A box enrolled before Vikunja #14 has a v1 blob, wrapped under the
// credential PUBLIC key. The PRF secret cannot open it, and this handler is
// the only caller of Unlock, so without a second attempt that box could never
// cold-start again: it would sit locked while a perfectly good passkey was
// asserted, and the only way back in would be putting MAVEN_DB_KEY into the
// environment — the exact thing cold-start unlock exists to avoid.
//
// So a failed PRF unlock is retried with the public key from the credential
// store. That is not a weaker fallback being offered to new deployments:
// nothing writes v1 any more, and a v2 blob does not open under a public key
// either. It is a one-way door out of the old format, and the operator is told
// to walk through it.
func (h *PasskeyHandle) coldStart(ctx context.Context, credID string, secret []byte, explicit bool) {
legacy := false
err := h.encryptFn.Unlock(ctx, secret)
if err != nil && !errors.Is(err, ipc.ErrUnknownMethod) {
if pub, _, lerr := h.store.Lookup(credID); lerr == nil && len(pub) > 0 {
if err2 := h.encryptFn.Unlock(ctx, pub); err2 == nil {
err, legacy = nil, true
}
}
}
switch {
case errors.Is(err, ipc.ErrUnknownMethod):
// Env-key mode: the daemon was never locked and has no UnlockFn. Not
// a failure, and the old code logged it as one on every assertion.
case err != nil:
log.Printf("webauthn: unlock via credential %s failed: %v", credID, err)
case legacy:
log.Printf("SECURITY: webauthn: daemon unlocked from a LEGACY v1 wrapped key using credential %s. That blob is derived from the credential public key, which sits in passkeys.json beside it, so it protects nothing. Press \"rewrite cold-start key\" on this page to replace it with a v2 blob.", credID)
default:
log.Printf("webauthn: daemon reports unlocked, credential %s", credID)
}
// explicit=false means "write the blob only if there is none". The daemon
// enforces that; sending the flag is the whole of this side's part in it.
switch err := h.encryptFn.StoreEncryptionKey(ctx, secret, explicit); {
case err == nil && explicit:
log.Printf("webauthn: cold-start key rewritten under credential %s", credID)
case err == nil:
case errors.Is(err, ipc.ErrUnknownMethod):
// No key to wrap: a plaintext dev store, or a daemon still locked.
default:
log.Printf("webauthn: wrap encryption key: %v", err)
}
}
+70 -11
View File
@@ -65,7 +65,13 @@ What it does and does not do:
response at 2 MiB and redirects at 3, and makes at most one request per host
per second. See `internal/webfetch`;
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
a restart does not re-note yesterday's headlines.
a restart does not re-note yesterday's headlines. A feed whose items carry no
dates gets the same mark, and the first poll after a restart takes those items
as already read rather than writing them all again;
- `max_items` paces, it does not drop: a burst larger than the cap arrives over
the following polls, oldest first;
- feed notes are **not** part of recall. "что я говорил про X" searches what he
said; headlines are read back only by asking about the feeds.
### Reading a page (`crawl`, also off by default)
@@ -89,13 +95,23 @@ switched:
a fallback and not a habit;
- `watches` re-reads a fixed list on its interval and writes a note when the
text changed. Like the feeds, it announces nothing;
- the answer path sits **last** in the query chain, behind his memory, his notes
and (once wired) the local Kiwix ZIMs. A local read costs nothing;
- the answer path sits behind his memory and his notes, and ahead of the model
answering from what it remembers. Kiwix is not wired into the chain yet. A
local read costs nothing, so anything local goes first;
- `robots.txt` is fetched first and obeyed with no override; a `Disallow` is a
refusal she says out loud. `Crawl-delay` is honoured;
refusal she says out loud. `Crawl-delay` is waited out before the page is
fetched, and a delay longer than the turn fails the read instead of hanging
it. A `robots.txt` that answers 5xx refuses the crawl — a broken server is
not permission;
- `allow_hosts` limits on-demand reading to those hosts and nothing else.
Watched pages' hosts are reachable by the scheduled crawler whether listed or
not, but a watch does **not** widen what he may ask her to read;
- same guarded fetcher as the feeds: allowlist/denylist, no private addresses,
size cap, redirect cap, timeout, one request per host per second;
- dedup state is the config fact `crawl:hash:<name>`.
- dedup state is the config fact `crawl:hash:<name>`;
- like feed notes, watch notes are kept out of recall (`store.ReadSourcePrefixes`).
Text from someone else's page is not something he said, so it must not come
back as an answer to a question about him. Watch notes are visible on `/dash`.
## Not yet verified / host-dependent
@@ -132,18 +148,61 @@ it), with paths as they exist **on the host**, not inside a container:
"source_dir": "/home/kami/apps/Maven",
"install_dir": "/home/kami/apps/Maven",
"snapshot_dir": "/var/lib/maven-snapshots",
"source_rollback": "git",
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
"mavenclient", "mavpoll", "mavcaldav", "mavmaild", "mavupdate"],
"config_files": ["deploy/mavend.json"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
"health_socket": "/run/maven-host/mavend.sock",
"health_timeout_sec": 120
}
```
`snapshot_dir` must be outside `install_dir` (a restore must not read from what
the install writes) and `health_socket` is required: an update that cannot check
its own result cannot roll itself back, so the config is refused without one.
`snapshot_dir` must be outside both `install_dir` and `source_dir` (a restore
must not read from what the install writes, and a snapshot dir inside the tree
lands in the docker build context). `health_socket` is required: an update that
cannot check its own result cannot roll itself back, so the config is refused
without one.
**`source_rollback` is what makes a rollback real on this deployment.** Compose
builds the image from the tree — the Dockerfile copies `cmd/` and `internal/`
and runs the build in the builder stage, and `.dockerignore` keeps the host
binaries out — so `install_dir` is the tree, `install` is a no-op, and putting
the old binaries back puts back bytes nothing reads. A rollback that only did
that would rebuild the same bad image and burn a second health timeout proving
it. With `"source_rollback": "git"` the commit is recorded before the update and
checked back out before the restart, so the restore is of the thing that
actually gets deployed. It requires a clean tree: `apply` refuses to start with
uncommitted changes, because the recorded commit would not describe what is
deployed and the forced checkout on the way back would delete the work. It also
means a rollback moves every tracked file, `deploy/mavend.json` included, so on
this deployment a config edit belongs in a commit.
Leaving `source_rollback` out is only valid when `install_dir` holds what
actually runs. `Validate` refuses the combination of "same dir" and "no way to
put the source back" at startup rather than at the one rollback that mattered.
**The socket has to be one the account running `mavupdate` can open.** The
compose stack keeps IPC in a named volume, whose host path
(`/var/lib/docker/volumes/maven_sockets/_data`) is under a `drwx--x--- root
root` directory, and the socket itself is 0600 owned by the container's uid
10001. A non-root `mavupdate` gets EACCES on the dial, which reports as
`update: cannot open the health socket` rather than as a daemon that will not
answer. Bind-mount the socket dir to a host path he owns and run the daemon
under his uid instead:
```yaml
mavend:
user: "1000:1000"
volumes:
- /run/maven-host:/run/maven
```
Do **not** work around it with `sudo mavupdate apply`. `verify` runs `make
build` and `make test` in `source_dir`, and as root that leaves root-owned
binaries, object files and a build cache in the working tree, so the next
ordinary `make` fails. `Verify` refuses to run as root over a tree owned by
someone else for exactly that reason.
Then:
+22
View File
@@ -0,0 +1,22 @@
# $connection_upgrade — WebSocket upgrade helper for the maven.<domain> block
# in nginx.conf. Install this ONLY if your nginx does not already define
# $connection_upgrade somewhere in the http context.
#
# It is a separate file because nginx treats a duplicate `map` directive as a
# fatal configuration error, not a warning: if this block were inside
# nginx.conf and your setup already had one (nginx-panel and most WebSocket
# recipes ship one), the next `nginx -s reload` would fail the config test and
# nginx would refuse to come back up — taking every other site on the box down
# with it, not just maven.
#
# Check before installing:
# grep -rn 'connection_upgrade' /etc/nginx/
# Nothing? Drop this in /etc/nginx/conf.d/ and reload. Something already there?
# Skip this file entirely; nginx.conf works as-is.
#
# Verify either way before reloading:
# nginx -t
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
+78 -45
View File
@@ -12,54 +12,43 @@
# On a different box, replace both addresses with that box's wg and LAN IPs.
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces)
# that removes the only access control these containers have.
# maven.<domain> mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
# /routines, /api/revert and /api/chat (Vikunja #317). Without
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
# allow/deny anyway belt and braces on an RCE surface.
#
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
# Upgrade/Connection headers below are required, not decoration. The map keeps
# `Connection: upgrade` off plain requests; it sits in the http context, which
# is where sites-available files are included if your nginx already defines
# $connection_upgrade, drop this block.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# BEFORE YOU RELOAD two ways this file takes nginx down, both host-side.
# These blocks are for the HOST nginx, not for anything inside the compose;
# the containers publish on 127.0.0.1 and have no nginx of their own.
#
# 1. Binding an address that does not exist yet. `listen 10.42.0.1:80` fails
# with EADDRNOTAVAIL if wg0 is down, and nginx exits rather than starting
# without it so a reboot that brings nginx up before WireGuard leaves the
# box with no web at all. Allow the bind to succeed regardless:
# sysctl -w net.ipv4.ip_nonlocal_bind=1
# echo 'net.ipv4.ip_nonlocal_bind = 1' > /etc/sysctl.d/99-nginx-bind.conf
# Ordering nginx after the wg interface works too, but only until the next
# time the tunnel restarts.
#
# 2. A duplicate $connection_upgrade map. That is a fatal config error, so the
# map now lives in nginx-upgrade-map.conf and is installed separately
# read the note at the top of that file first.
#
# `nginx -t` catches the second and not the first. Run it anyway, every time.
#
# BLOCK ORDER IS LOAD-BEARING. nginx serves the first block for a given listen
# address when no server_name matches, so whichever block comes first here
# answers requests with an unknown or absent Host header. That must not be
# mavweb: it is the one surface in this file that can define and run argv. The
# nexus block is marked default_server so the choice is explicit rather than a
# consequence of file order, and the maven block sits last as a second guard.
# If you add a block, do not put it above nexus. If another site file already
# claims default_server on 10.42.0.1:80 or 192.168.1.104:80, nginx refuses to
# start with "a duplicate default server" drop the two keywords here and rely
# on the block order instead.
#
# Every server_name below is a literal for kvmx.ru even though the comments
# write maven.<domain>. This file reads like a template and is not one.
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
server_name maven.kvmx.ru;
allow 10.42.0.0/24;
allow 192.168.1.0/24;
deny all;
# push-to-talk uploads raw PCM; the default 1m is enough for a short
# utterance but not for a long one.
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:9201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
}
}
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
listen 10.42.0.1:80 default_server;
listen 192.168.1.104:80 default_server;
server_name nexus.kvmx.ru;
allow 10.42.0.0/24;
@@ -110,3 +99,47 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# maven.<domain> mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
# /routines, /api/revert, /api/chat, /api/ptt and /ws (Vikunja #317). Without
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
# allow/deny anyway belt and braces on an RCE surface.
#
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
# Upgrade/Connection headers below are required, not decoration. They reference
# $connection_upgrade, which this file does NOT define see
# nginx-upgrade-map.conf and point 2 above.
server {
listen 10.42.0.1:80;
listen 192.168.1.104:80;
server_name maven.kvmx.ru;
allow 10.42.0.0/24;
allow 192.168.1.0/24;
deny all;
# push-to-talk uploads raw PCM; the default 1m is enough for a short
# utterance but not for a long one.
client_max_body_size 32m;
# A 32m upload over a slow link outlives the 60s default on the two body
# timeouts, and nginx cuts it at exactly 60s with a 408 or a 504 that looks
# like the turn failed. Raise them with the size, not just proxy_read_timeout.
client_body_timeout 300s;
location / {
proxy_pass http://127.0.0.1:9201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_send_timeout 300s; # pushing the PCM upstream, same reason
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
}
}
+9
View File
@@ -56,6 +56,15 @@
"enabled": false
},
"netscan": {
"subnets": ["192.168.1.0/24"],
"ports": [22, 80, 443, 8080],
"timeout": "400ms",
"rate": 100,
"max_hosts": 256,
"enabled": false
},
"nexus": { "url": "http://nexus:9740" },
"praxis": { "url": "http://praxis:8989" },
"hexis": { "url": "http://hexis:9741" },
+9 -2
View File
@@ -131,16 +131,23 @@ services:
# "-password-file", "/run/secrets/imap.password",
# "-mailbox", "INBOX",
# "-interval", "15m",
# "-state", "/var/lib/maven/mail-seen.json"]
# "-state", "/var/lib/mavmaild/mail-seen.json"]
# depends_on: [mavend]
# volumes:
# - sockets:/run/maven
# - dbdata:/var/lib/maven
# # Its OWN volume, not dbdata. The whole point of a separate reader is
# # that a compromise on either side does not reach the other, and dbdata
# # is the encrypted database. The reader needs one JSON file of UIDs and
# # gets a volume that holds nothing else, so neither can be restored from
# # a backup of the other.
# - maildata:/var/lib/mavmaild
# - ./deploy/imap.password:/run/secrets/imap.password:ro
volumes:
dbdata:
sockets:
# maildata — the mail reader's seen-UID file, and nothing else. See mavmaild.
maildata:
networks:
default:
+23 -4
View File
@@ -94,14 +94,33 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
// header so the result can be written to disk and played with `aplay`.
// Used by the reference client to write the TTS reply; not on the wire.
func WAVFromPCM(format Format, pcm []byte) ([]byte, error) {
hdr, err := WAVHeader(format, len(pcm))
if err != nil {
return nil, err
}
out := make([]byte, wavHeaderSize+len(pcm))
copy(out, hdr)
copy(out[wavHeaderSize:], pcm)
return out, nil
}
// WAVHeaderSize is the fixed size of the header WAVHeader writes. A caller
// spooling audio to a file reserves this many bytes up front and rewrites them
// once it knows the length.
const WAVHeaderSize = wavHeaderSize
// WAVHeader builds just the 44-byte canonical header for n bytes of PCM. It
// exists so a long recording can be written straight to a file: holding the
// whole meeting in memory to prepend 44 bytes is what the streaming path is
// avoiding.
func WAVHeader(format Format, n int) ([]byte, error) {
if !format.IsValid() {
return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
}
out := make([]byte, wavHeaderSize+len(pcm))
copy(out[wavHeaderSize:], pcm)
out := make([]byte, wavHeaderSize)
// RIFF header
copy(out[0:4], []byte("RIFF"))
binary.LittleEndian.PutUint32(out[4:8], uint32(36+len(pcm)))
binary.LittleEndian.PutUint32(out[4:8], uint32(36+n))
copy(out[8:12], []byte("WAVE"))
// fmt chunk
copy(out[12:16], []byte("fmt "))
@@ -116,6 +135,6 @@ func WAVFromPCM(format Format, pcm []byte) ([]byte, error) {
binary.LittleEndian.PutUint16(out[34:36], uint16(format.SampleBits))
// data chunk
copy(out[36:40], []byte("data"))
binary.LittleEndian.PutUint32(out[40:44], uint32(len(pcm)))
binary.LittleEndian.PutUint32(out[40:44], uint32(n))
return out, nil
}
+49
View File
@@ -417,6 +417,28 @@ func TestRequirement_SwapModel(t *testing.T) {
}
}
// TestRequirement_ListMutation — the three methods that change what is on his
// lists sit on one rung. IngestMail joined them on 2026-08-01; it used to be
// AuthRead, which made it disagree with SetTaskStatus about the same question.
// CaptureTask stays a read: it puts one line on a list he asked for.
func TestRequirement_ListMutation(t *testing.T) {
for _, m := range []ipc.Method{
ipc.MethodIngestMail, ipc.MethodSetTaskStatus,
} {
if got := Requirement(m); got != AuthWrite {
t.Errorf("%s authority = %v; want AuthWrite", m, got)
}
}
if got := Requirement(ipc.MethodCaptureTask); got != AuthRead {
t.Errorf("CaptureTask authority = %v; want AuthRead", got)
}
// mavmaild keeps working: AuthWrite outside WriteFact only needs enrollment.
maild := Scope{Surface: SurfaceCoreProcess, Module: "mavmaild", SourceScope: []string{"mail:*"}}
if err := Can(ipc.MethodIngestMail, maild, nil); err != nil {
t.Errorf("mavmaild ingesting mail = %v; want allowed", err)
}
}
// TestRequirement_Capture — recording other people is a write, not a read: it
// puts audio of them on disk. The read side, "что ты записываешь?", is not.
//
@@ -472,3 +494,30 @@ func TestRequirement_Speaker(t *testing.T) {
t.Errorf("voice listing speakers = %v; want allowed", err)
}
}
// Describing an image is a read. Saving the description is a write of recall
// corpus under a source no enrollment owns, so it is held to the same
// source-scope rule WriteFact is. Before this, any AuthRead caller could put a
// small VLM's guess into what Maven knows.
func TestCan_DescribeImage_SaveNoteNeedsScope(t *testing.T) {
poller := Scope{Surface: SurfaceTelegram, Module: "poll", SourceScope: []string{"poll:healthcheck"}}
web := Scope{Surface: SurfaceAuthedPage, Module: "web", SourceScope: []string{"*"}}
plain, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x")})
if err != nil {
t.Fatal(err)
}
noting, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x"), SaveNote: true})
if err != nil {
t.Fatal(err)
}
if err := Can(ipc.MethodDescribeImage, poller, plain); err != nil {
t.Errorf("describing without saving must stay a read: %v", err)
}
if err := Can(ipc.MethodDescribeImage, poller, noting); !errors.Is(err, ErrForbidden) {
t.Errorf("save_note out of scope = %v, want ErrForbidden", err)
}
if err := Can(ipc.MethodDescribeImage, web, noting); err != nil {
t.Errorf("a module scoped to everything must still be allowed: %v", err)
}
}
+57 -7
View File
@@ -93,6 +93,28 @@ func Requirement(m ipc.Method) Authority {
return AuthWrite
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodIngestMail:
// Mail ingestion (Vikunja #246). Moved up from AuthRead on 2026-08-01,
// for consistency with SetTaskStatus rather than for a new threat: both
// answer the same question — may this module change what is on his
// lists? — and they were answering it differently. The old argument was
// that ingestion is additive and can only produce candidate tasks, which
// is still true; it is the weaker half of the argument, because a
// compromised mail reader that can fill the review page indefinitely is
// not a read.
//
// No caller loses anything: AuthWrite outside WriteFact only requires
// enrollment, which mavmaild already has, and the method does not exist
// unless the operator wired a mail block.
return AuthWrite
case ipc.MethodSetTaskStatus:
// Resolving a task is NOT additive, which is what separates it from
// capture. Capture at AuthRead can only put a line on a list he reads
// himself; SetTaskStatus at AuthRead would let any enrolled module —
// mavpoll, mavsttd — mark every open task done and clear the list out
// from under him. Same reasoning as WriteFact: a module gets to add to
// its own corner, not to erase his.
return AuthWrite
case ipc.MethodAssertStepUp:
return AuthRead
case ipc.MethodLatestFact,
@@ -109,15 +131,11 @@ func Requirement(m ipc.Method) Authority {
// module write, not an allowlist mutation and not a new standing reason
// for Maven to speak — nothing in the tick loop reads tasks. It stays
// at AuthRead, the same rung as CreateReminder, which is the closest
// existing analogue.
// existing analogue. SetTaskStatus is NOT here: see the AuthWrite case
// above, because resolving is the one task move that destroys
// something.
ipc.MethodCaptureTask,
ipc.MethodListTasks,
ipc.MethodSetTaskStatus,
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
// produce: candidate tasks and nothing else. It cannot write a fact, set a
// reminder, or touch the tool allowlist, so a compromised mail reader can
// at worst put junk on a review page he clears in one click.
ipc.MethodIngestMail,
// Looking at one image (Vikunja #252). AuthRead because of what it can
// produce: words about a picture, and optionally a note. It cannot write
// a fact, set a reminder, or touch the tool allowlist. The invasive part
@@ -178,6 +196,18 @@ func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
switch Requirement(m) {
case AuthRead:
// Describing an image is a read. Saving the description as a note is
// not: writeNote embeds it, so it comes back in a later turn as
// something Maven knows, under the source media:image:<id>, which no
// enrollment owns. The rung's own argument was that the method "cannot
// write a fact, set a reminder, or touch the tool allowlist" — it can
// write recall corpus, and that is what AuthWrite exists to scope. So
// the note half is held to the same source-scope rule WriteFact is.
if m == ipc.MethodDescribeImage && wantsNote(params) {
if !SourceAllowed(scope.SourceScope, ImageNoteSource) {
return fmt.Errorf("%w: source %q out of scope", ErrForbidden, ImageNoteSource)
}
}
// Any enrolled module may read. Reads through the surface level the
// Enrollment set (voice-L0 wouldn't be enrolled to write at all).
return nil
@@ -214,6 +244,26 @@ func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
return nil
}
// ImageNoteSource is the source scope a caller needs to turn a described image
// into a note. The note itself is stored under "media:image:<id-prefix>"; the
// scope is checked against this stem, because the id is not known until the
// bytes arrive and no enrollment could name it in advance.
const ImageNoteSource = "media:image"
// wantsNote reports whether a DescribeImage call asked for the description to
// be remembered. Malformed params read as no: dispatch rejects them a moment
// later with a better error.
func wantsNote(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var p ipc.DescribeImageReq
if json.Unmarshal(raw, &p) != nil {
return false
}
return p.SaveNote
}
// SourceAllowed — true iff src is in scope (the wildcard "*" matches all).
// Empty scope ⇒ fail closed. The function is pure; we keep it exported so a
// future enrollment table can call into the same matching logic.
+61 -5
View File
@@ -36,13 +36,38 @@ type Notification struct {
Posted time.Time `json:"posted_at"`
}
// ambientPastGrace — how far before the notification a derived start may sit
// before the event is refused.
//
// The date is not in the clock reading, so it is inferred, and the inference is
// only safe while the event is still roughly now. A 21:00 reminder reading
// "Tomorrow at 09:00" would otherwise land at 09:00 TODAY, twelve hours in the
// past, and FactKey would file that wrong meeting under today's date. Storing a
// wrong meeting is the one outcome this file exists to avoid, so anything this
// stale is dropped instead. The grace covers the ordinary case of a phone
// reposting a notification for a meeting already under way.
const ambientPastGrace = 2 * time.Hour
// dayWords maps the words that move a notification off Posted's day. Only
// explicit ones: an offset is a claim about which day, and guessing which day
// is exactly the guess this parse refuses to make.
var dayWords = map[string]int{
"завтра": 1,
"tomorrow": 1,
"сегодня": 0,
"today": 0,
"tonight": 0,
"послезавтра": 2,
}
// EventFromNotification turns a notification into the event it describes, or
// reports false when it does not clearly describe one.
//
// It needs two things: a clock reading, and a summary that is not just that
// clock reading. Everything else is defaulted — the date is Posted's day (a
// meeting notification is about today or it would not be firing now), and a
// bare start time gets DefaultReminderDuration.
// clock reading. The date comes from Posted's day, shifted by an explicit day
// word ("завтра", "tomorrow") when the notification carries one, and the result
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
func EventFromNotification(n Notification) (Event, bool) {
if n.Posted.IsZero() {
return Event{}, false
@@ -57,9 +82,14 @@ func EventFromNotification(n Notification) (Event, bool) {
return Event{}, false
}
y, m, d := n.Posted.Date()
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
loc := n.Posted.Location()
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.
if s.Before(n.Posted.Add(-ambientPastGrace)) {
return Event{}, false
}
var e time.Time
if end != nil {
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
@@ -73,12 +103,38 @@ func EventFromNotification(n Notification) (Event, bool) {
return Event{Summary: summary, Start: s, End: e}, true
}
// dayOffset reports how many days off Posted's day the notification puts the
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
func dayOffset(line string) int {
for _, f := range strings.Fields(strings.ToLower(line)) {
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
if off, ok := dayWords[f]; ok {
return off
}
}
return 0
}
// stripDayWords removes the day word from a summary candidate. It named the
// date, which now lives in Start, and leaving it in makes "Завтра Планёрка"
// the name of the meeting.
func stripDayWords(s string) string {
out := make([]string, 0, 8)
for _, f := range strings.Fields(s) {
if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok {
continue
}
out = append(out, f)
}
return strings.Join(out, " ")
}
// notificationSummary picks the text that names the meeting: the title when it
// carries words, otherwise the body. The clock reading is stripped out — it
// already lives in the times, and FactValue renders it again.
func notificationSummary(n Notification) string {
for _, cand := range []string{n.Title, n.Text} {
s := strings.TrimSpace(stripClock(cand))
s := strings.TrimSpace(stripDayWords(stripClock(cand)))
s = strings.Trim(s, " \t-–—,;:@|·")
s = strings.Join(strings.Fields(s), " ")
if hasLetters(s) {
+91
View File
@@ -110,6 +110,97 @@ func TestEventFromNotification(t *testing.T) {
}
}
// A notification is not always about today. A 21:00 reminder reading
// "Tomorrow at 09:00" used to be dated to the notification's own day, which put
// the meeting twelve hours in the past and filed it under today in FactKey. A
// wrong meeting stored is worse than nothing stored.
func TestEventFromNotificationDayWords(t *testing.T) {
loc := time.FixedZone("+04", 4*3600)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
tests := []struct {
name string
title, text string
posted time.Time
wantOK bool
wantDay int // day of month
wantSummary string
}{
{
name: "tomorrow in english", title: "Standup", text: "Tomorrow at 09:00",
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Standup",
},
{
name: "завтра in russian", title: "Планёрка", text: "завтра в 09:00",
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
},
{
name: "завтра in the title, summary in the body", title: "Завтра в 09:00", text: "Планёрка",
posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
},
{
name: "послезавтра is two days, not one", title: "Ретро", text: "послезавтра 11:00",
posted: evening, wantOK: true, wantDay: 5, wantSummary: "Ретро",
},
{
name: "сегодня stays on the posted day", title: "Созвон", text: "сегодня 21:30",
posted: evening, wantOK: true, wantDay: 3, wantSummary: "Созвон",
},
// No day word: the 09:00 is twelve hours behind the notification, so the
// inferred day is wrong and there is nothing honest to store.
{
name: "stale morning time with no day word", title: "Standup", text: "at 09:00",
posted: evening, wantOK: false,
},
// Inside the grace: a phone reposting the notification for a meeting
// already under way must still store it.
{
name: "meeting already running", title: "Планёрка", text: "20:30-22:00",
posted: evening, wantOK: true, wantDay: 3, wantSummary: "Планёрка",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Package: "com.google.android.calendar",
Title: tt.title, Text: tt.text, Posted: tt.posted,
})
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
}
if !ok {
return
}
if got := ev.Start.Day(); got != tt.wantDay {
t.Errorf("start day = %d, want %d (start %v)", got, tt.wantDay, ev.Start)
}
if ev.Summary != tt.wantSummary {
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
}
if ev.Start.Before(tt.posted.Add(-ambientPastGrace)) {
t.Errorf("start %v is stale against posted %v", ev.Start, tt.posted)
}
})
}
}
// The day word named the date, which now lives in Start. Leaving it in the
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
// that into the key.
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Title: "Завтра Планёрка 09:00",
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
})
if !ok {
t.Fatal("expected an event")
}
if ev.Summary != "Планёрка" {
t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка")
}
}
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
t.Error("a notification with no posted_at has no date to sit on")
+47 -6
View File
@@ -72,15 +72,56 @@ type Event struct {
// across polls so re-reading an unchanged calendar rewrites nothing.
//
// The date prefix is load-bearing — store.CalendarEvents selects a day range
// by key prefix, not by a timestamp column.
func FactKey(e Event) string {
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
// by key prefix, not by a timestamp column — and it is the OWNER's day, taken
// on the box clock. An event carries the zone its server stated it in, so
// keying off the event's own location would file a 21:00 Moscow meeting under
// a different date than the day plan asks for.
func FactKey(e Event) string { return FactKeyIn(e, time.Local) }
// FactKeyIn is FactKey against an explicit location.
func FactKeyIn(e Event, loc *time.Location) string {
return fmt.Sprintf("calendar_event_%s_%s", e.Start.In(loc).Format("20060102"), safeKey(e.Summary))
}
// FactValue is the human-readable rendering stored as the fact value, and the
// string the day plan and the query path read back.
func FactValue(e Event) string {
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
// string the day plan and the query path read back. Times are the owner's wall
// clock, for the same reason the key date is.
func FactValue(e Event) string { return FactValueIn(e, time.Local) }
// FactValueIn is FactValue against an explicit location.
func FactValueIn(e Event, loc *time.Location) string {
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.In(loc).Format("15:04"), e.End.In(loc).Format("15:04"))
}
// FactSummary strips the "@ HH:MM-HH:MM" tail FactValue appends, for a caller
// that prints the time itself. The day plan does: without this it renders
// "14:00 — Standup @ 14:00-14:30" and says the hour twice.
func FactSummary(value string) string {
i := strings.LastIndex(value, " @ ")
if i < 0 {
return value
}
tail := value[i+len(" @ "):]
if len(tail) != len("15:04-15:04") {
return value
}
for j, r := range tail {
switch j {
case 2, 8:
if r != ':' {
return value
}
case 5:
if r != '-' {
return value
}
default:
if r < '0' || r > '9' {
return value
}
}
}
return value[:i]
}
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
+74 -11
View File
@@ -78,11 +78,13 @@ func TestParseICalDayUsesOwnersDay(t *testing.T) {
func TestParseVEVENT(t *testing.T) {
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
e, ok := parseVEVENT(block)
e, ok := parseVEVENT(block, time.UTC)
if !ok {
t.Fatal("expected a parsed event")
}
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
// 13:00 Moscow is 10:00Z. Reading it as 13:00Z is the bug that put the
// event three hours late in the day plan.
if !e.Start.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
t.Errorf("start = %v", e.Start)
}
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
@@ -93,26 +95,34 @@ func TestParseVEVENT(t *testing.T) {
}
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
if _, ok := parseVEVENT(allDay); ok {
if _, ok := parseVEVENT(allDay, time.UTC); ok {
t.Error("all-day event should be rejected")
}
}
func TestParseDT(t *testing.T) {
plus4 := time.FixedZone("+04", 4*60*60)
tests := []struct {
name string
line string
loc *time.Location
want time.Time
wantOK bool
}{
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
{"garbage", "DTSTART:garbage", time.Time{}, false},
{"UTC", "DTEND:20260703T100000Z", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
{"tzid", "DTSTART;TZID=Europe/Moscow:20260703T130000", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
{"tzid quoted", `DTSTART;TZID="Europe/Moscow":20260703T130000`, plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
{"tzid with other params", "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260703T130000", plus4, time.Date(2026, 7, 3, 4, 0, 0, 0, time.UTC), true},
// An unloadable zone falls back to the reader's own clock, not to UTC.
{"unknown tzid", "DTSTART;TZID=Mars/Olympus:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true},
// Floating: no Z, no TZID. Local to whoever reads it.
{"floating", "DTSTART:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true},
{"all-day", "DTSTART;VALUE=DATE:20260703", plus4, time.Time{}, false},
{"garbage", "DTSTART:garbage", plus4, time.Time{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseDT(tt.line)
got, ok := parseDT(tt.line, tt.loc)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
@@ -143,20 +153,73 @@ func TestFactKeyAndValue(t *testing.T) {
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
}
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
if got, want := FactKeyIn(e, time.UTC), "calendar_event_20260703_Team-sync"; got != want {
t.Errorf("FactKey = %q, want %q", got, want)
}
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
if got, want := FactValueIn(e, time.UTC), "Team sync @ 14:00-15:00"; got != want {
t.Errorf("FactValue = %q, want %q", got, want)
}
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
}
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
if !strings.HasPrefix(FactKeyIn(e, time.UTC), KeyPrefixForDay(e.Start)) {
t.Error("FactKey must start with the day prefix the store range-scans on")
}
}
// The key date and the printed time are the owner's, not the calendar
// server's. A 23:00 Moscow event read on a +04 box belongs to the next local
// day, and filing it under the Moscow day would hide it from the day plan the
// store range-scans for.
func TestFactKeyAndValueUseTheOwnersClock(t *testing.T) {
msk := time.FixedZone("MSK", 3*60*60)
plus4 := time.FixedZone("+04", 4*60*60)
e := Event{
Summary: "Late sync",
Start: time.Date(2026, 7, 3, 23, 30, 0, 0, msk),
End: time.Date(2026, 7, 4, 0, 30, 0, 0, msk),
}
if got, want := FactKeyIn(e, plus4), "calendar_event_20260704_Late-sync"; got != want {
t.Errorf("FactKeyIn = %q, want %q", got, want)
}
if got, want := FactValueIn(e, plus4), "Late sync @ 00:30-01:30"; got != want {
t.Errorf("FactValueIn = %q, want %q", got, want)
}
}
func TestFactSummaryDropsTheTimeTail(t *testing.T) {
if got, want := FactSummary("Standup @ 14:00-14:30"), "Standup"; got != want {
t.Errorf("FactSummary = %q, want %q", got, want)
}
// Nothing that is not the exact tail FactValue writes is touched.
for _, in := range []string{"Coffee @ home", "Standup", "Standup @ 14:00-14:3", "Standup @ 1a:00-14:30"} {
if got := FactSummary(in); got != in {
t.Errorf("FactSummary(%q) = %q, want it unchanged", in, got)
}
}
}
// Regression for the mirror image of the window bug: the window is local, so
// the event must be a real instant too. A 22:00 event stated in the poller's
// own zone used to parse as 22:00Z, which on a +03 box is past the end of the
// local day, and the whole evening dropped out of both the busy gate and the
// day plan.
func TestParseICalDayKeepsTheEveningInAZonedCalendar(t *testing.T) {
plus3 := time.FixedZone("+03", 3*60*60)
now := time.Date(2026, 8, 1, 12, 0, 0, 0, plus3)
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
"DTSTART;TZID=Europe/Moscow:20260801T220000\nDTEND;TZID=Europe/Moscow:20260801T230000\n" +
"SUMMARY:Evening call\nEND:VEVENT\nEND:VCALENDAR")
events := ParseICalDay(body, now)
if len(events) != 1 {
t.Fatalf("got %d events, want the evening one", len(events))
}
if got := events[0].Start.In(plus3).Format("15:04"); got != "22:00" {
t.Errorf("start reads %s locally, want 22:00", got)
}
}
func TestBusyAndOverlapping(t *testing.T) {
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
events := []Event{
+62 -19
View File
@@ -4,12 +4,22 @@ import (
"fmt"
"strings"
"time"
// The TZID of a DTSTART names an IANA zone, and resolving it needs the zone
// database. The deploy image has no system tzdata, so embed it: without it
// every zoned event would silently fall back to the box's own offset, which
// is the bug this package had before.
_ "time/tzdata"
)
// ParseICal scans iCal text for VEVENT components and returns the events
// overlapping [from, to). All-day events are skipped: parseDT reports no time
// for a VALUE=DATE value, and an event with no clock reading answers neither
// the busy gate nor the day plan.
//
// from's location is the fallback zone for a floating DTSTART — one with
// neither a Z suffix nor a TZID. RFC 5545 says a floating time is local to
// wherever it is read, and here that is the box the poller runs on.
func ParseICal(body []byte, from, to time.Time) []Event {
var events []Event
text := string(body)
@@ -26,7 +36,7 @@ func ParseICal(body []byte, from, to time.Time) []Event {
block := text[:j]
text = text[j+len("END:VEVENT"):]
e, ok := parseVEVENT(block)
e, ok := parseVEVENT(block, from.Location())
if !ok {
continue
}
@@ -40,11 +50,12 @@ func ParseICal(body []byte, from, to time.Time) []Event {
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
// location — the window cmd/mavcaldav polls.
//
// The location matters. The old inline version took the day number off a local
// clock reading but built the boundaries in UTC, so east of Greenwich the
// window was shifted by the offset and part of the evening fell outside
// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
// owner's day is the day the day plan and the busy gate mean.
// The location matters, on both sides of the comparison. An older version took
// the day number off a local clock reading but built the boundaries in UTC, so
// east of Greenwich the window was shifted by the offset and part of the
// evening fell outside "today". Building the window locally is only half the
// fix: parseDT used to stamp a zoned DTSTART as UTC, which lost the mirror
// image of the same evening. Both sides are real instants now.
func ParseICalDay(body []byte, now time.Time) []Event {
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
@@ -53,17 +64,17 @@ func ParseICalDay(body []byte, now time.Time) []Event {
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
// Reports false for all-day events and parse failures.
func parseVEVENT(block string) (Event, bool) {
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
var e Event
for _, line := range strings.Split(block, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "DTSTART"):
if t, ok := parseDT(line); ok {
if t, ok := parseDT(line, loc); ok {
e.Start = t
}
case strings.HasPrefix(line, "DTEND"):
if t, ok := parseDT(line); ok {
if t, ok := parseDT(line, loc); ok {
e.End = t
}
case strings.HasPrefix(line, "SUMMARY"):
@@ -85,16 +96,21 @@ func afterColon(line string) string {
return ""
}
// parseDT parses a DTSTART/DTEND value:
// parseDT parses a DTSTART/DTEND value into a real instant:
//
// - UTC: DTEND:20260703T100000Z
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
// - Zoned: DTSTART;TZID=Europe/Moscow:20260703T130000
// - Floating: DTSTART:20260703T130000 (read in loc)
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
//
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
// CalDAV server and the poller run in the same timezone, and the busy gate only
// needs busy/not-busy to be right.
func parseDT(line string) (time.Time, bool) {
// A zoned value is resolved against its own TZID, not stamped as UTC. The old
// behaviour was "the server and the poller share a timezone, and the busy gate
// only needs busy/not-busy to be right", and that stopped being enough when the
// day plan started reciting the wall clock: a 13:00 Moscow meeting read as
// 13:00Z was recited at 17:00 on a +04 box, and a 21:00 one fell out of the day
// altogether. An unknown or unloadable TZID falls back to loc, which is the
// closest thing to the reader's own wall clock we have.
func parseDT(line string, loc *time.Location) (time.Time, bool) {
if strings.Contains(line, "VALUE=DATE:") {
return time.Time{}, false
}
@@ -102,12 +118,39 @@ func parseDT(line string) (time.Time, bool) {
if i < 0 {
return time.Time{}, false
}
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
t, err := time.Parse("20060102T150405", val)
if loc == nil {
loc = time.UTC
}
raw := strings.TrimSpace(line[i+1:])
if strings.HasSuffix(raw, "Z") {
t, err := time.ParseInLocation("20060102T150405", strings.TrimSuffix(raw, "Z"), time.UTC)
if err != nil {
return time.Time{}, false
}
return t, true
}
if tz := tzidOf(line[:i]); tz != "" {
if l, err := time.LoadLocation(tz); err == nil {
loc = l
}
}
t, err := time.ParseInLocation("20060102T150405", raw, loc)
if err != nil {
return time.Time{}, false
}
return t.UTC(), true
return t, true
}
// tzidOf pulls the TZID out of a property's parameter list ("DTSTART;TZID=..."
// up to the value colon). The value may be quoted, per RFC 5545 param syntax.
func tzidOf(params string) string {
for _, p := range strings.Split(params, ";")[1:] {
if !strings.HasPrefix(strings.ToUpper(p), "TZID=") {
continue
}
return strings.Trim(strings.TrimSpace(p[len("TZID="):]), `"`)
}
return ""
}
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
+24
View File
@@ -2,6 +2,7 @@ package calendar
import (
"fmt"
"strconv"
"strings"
"time"
)
@@ -43,3 +44,26 @@ func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration)
func ReminderPath(id int64) string {
return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id)
}
// ReminderIDFromPath reads back what ReminderPath wrote, given an href out of a
// PROPFIND. It reports false for anything that is not a resource maven
// published, which is what keeps a reconciliation pass from touching a file it
// did not create.
func ReminderIDFromPath(href string) (int64, bool) {
name := href
if i := strings.LastIndex(name, "/"); i >= 0 {
name = name[i+1:]
}
if !strings.HasPrefix(name, ReminderUIDPrefix) || !strings.HasSuffix(name, ".ics") {
return 0, false
}
digits := name[len(ReminderUIDPrefix) : len(name)-len(".ics")]
if digits == "" {
return 0, false
}
id, err := strconv.ParseInt(digits, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
+303 -88
View File
@@ -16,8 +16,13 @@
// plan document and is refused: it requires listening in order to notice the
// keyword, which is the exact behaviour this capability must not have.
// - A session that is not stopped stops itself. MaxDuration is a hard cap
// checked on every Append, not a suggestion; a forgotten recording is a
// recording that ends, not one that runs until the disk is full.
// checked on every Append AND against the wall clock in Start and Status,
// so a client that simply stops sending frames — a browser tab closed, wifi
// gone — does not leave the one session slot occupied until mavend
// restarts.
// - A session belongs to whoever started it. Start returns a token and Append
// and Stop require it, so a second surface at the same authority rung
// cannot feed or harvest a recording it did not begin.
// - Audio is stored under internal/media, which means retention prunes it and
// it never leaves the box. Both the audio blob and the transcript stay
// local; only the summary is written where he will read it.
@@ -37,16 +42,20 @@
//
// There is exactly one STT in Maven and this package does not add a second: it
// takes an stt.Transcriber, which in deploy is the whisper.cpp worker behind
// cmd/mavsttd. Long audio is transcribed in windows too (see chunkAudio), for
// cmd/mavsttd. Long audio is transcribed in windows too (see transcribeFile), for
// the same reason whisper itself works in 30s windows — handing a worker an hour
// of PCM in one call is a request that either times out or blocks everything
// else for minutes.
// else for minutes. The windows are read back off the stored WAV one at a time,
// so the meeting is never in memory whole.
package capture
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
@@ -58,12 +67,17 @@ import (
// DefaultMaxDuration — how long one capture may run before it stops itself.
// Two hours covers a long meeting and bounds the damage of a forgotten session:
// at 16 kHz mono that is about 230 MB of PCM, which is over media's default
// per-blob cap, so a session at the limit is stored truncated rather than
// refused. That trade is deliberate — a partial recording of a meeting he asked
// for beats an error after two hours.
// at 16 kHz mono that is about 230 MB of WAV, which is under media's
// DefaultMaxAudioBytes of 512 MiB. The two constants used to disagree — a
// 64 MiB blob cap is 35 minutes of audio against a 120 minute session cap — so
// the meeting that hit the limit was the one that failed to store.
const DefaultMaxDuration = 2 * time.Hour
// StaleGrace — how long past MaxDuration a session may sit before Start and
// Status reap it. A frame in flight when the cap fires should not race the
// reaper, and a minute of slack costs nothing against a two-hour cap.
const StaleGrace = time.Minute
// DefaultSTTWindow — how much audio goes to the transcriber in one call. Five
// minutes of 16 kHz mono is under 10 MB, transcribes in well under whisper's
// own timeout on this box, and keeps the worker responsive to the voice path
@@ -87,6 +101,9 @@ var (
// ErrExpired — the session hit MaxDuration and was closed. Returned from
// Append so the caller stops sending; the audio collected so far is kept.
ErrExpired = errors.New("capture: session reached its time limit")
// ErrWrongSession — the token does not match the running session. The
// recording belongs to the surface that started it.
ErrWrongSession = errors.New("capture: that is not your session")
)
// Session — one recording in progress. Not created directly; Recorder.Start
@@ -95,11 +112,53 @@ var (
type Session struct {
Label string
Started time.Time
// Token identifies this session to its owner. Append and Stop need it: the
// rung Append sits on is shared by every writing module, and a rung is not
// an owner. Without it any AuthWrite surface could call capture_stop on a
// meeting it did not start and be handed the verbatim transcript.
Token string
mu sync.Mutex
pcm []byte
spool *os.File // the WAV being written, header first
path string
n int64 // PCM bytes written, header excluded
format audio.Format
expired bool
closed bool
}
// write appends one frame to the spool file.
func (s *Session) write(b []byte) error {
if s.spool == nil {
return errors.New("capture: session has no spool file")
}
n, err := s.spool.Write(b)
s.n += int64(n)
if err != nil {
return fmt.Errorf("capture: spool write: %w", err)
}
return nil
}
// finish closes the spool file and stamps the real WAV header over the
// placeholder Start wrote.
func (s *Session) finish() error {
if s.closed {
return nil
}
s.closed = true
if s.spool == nil {
return nil
}
defer s.spool.Close()
hdr, err := audio.WAVHeader(s.format, int(s.n))
if err != nil {
return err
}
if _, err := s.spool.WriteAt(hdr, 0); err != nil {
return fmt.Errorf("capture: spool header: %w", err)
}
return s.spool.Sync()
}
// Duration is how much audio has been collected, from the bytes rather than the
@@ -112,15 +171,23 @@ func (s *Session) Duration() time.Duration {
}
func (s *Session) duration() time.Duration {
a := audio.Audio{Format: s.format, Bytes: s.pcm}
return time.Duration(a.Duration() * float64(time.Second))
return pcmDuration(s.format, s.n)
}
// pcmDuration is how long n bytes of PCM lasts in the given format.
func pcmDuration(f audio.Format, n int64) time.Duration {
per := int64(f.SampleRate) * int64(f.Channels) * int64(f.SampleBits) / 8
if per <= 0 {
return 0
}
return time.Duration(float64(n) / float64(per) * float64(time.Second))
}
// Bytes is how much PCM has been collected. For a status line.
func (s *Session) Bytes() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.pcm)
return int(s.n)
}
// Status — what a "что записываешь?" answer needs, and what /dash shows. It is
@@ -141,6 +208,7 @@ type Recorder struct {
sum *Summarizer
maxDuration time.Duration
sttWindow time.Duration
staleGrace time.Duration
now func() time.Time
mu sync.Mutex
@@ -180,6 +248,7 @@ func New(blobs *media.Store, tr stt.Transcriber, sum *Summarizer, cfg Config) (*
sum: sum,
maxDuration: maxDur,
sttWindow: window,
staleGrace: StaleGrace,
now: time.Now,
}, nil
}
@@ -195,19 +264,84 @@ func (r *Recorder) MaxDuration() time.Duration { return r.maxDuration }
func (r *Recorder) Start(label string) (*Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.reapLocked()
if r.current != nil {
return nil, fmt.Errorf("%w: %q since %s", ErrBusy, r.current.Label,
r.current.Started.Format(time.Kitchen))
}
f, err := r.blobs.SpoolFile("capture")
if err != nil {
return nil, err
}
format := audio.PCM16kMono
hdr, err := audio.WAVHeader(format, 0)
if err != nil {
f.Close()
return nil, err
}
// The header is written first and rewritten at Stop with the real length,
// so the spool file is a playable WAV rather than headerless PCM that has
// to be copied to gain 44 bytes.
if _, err := f.Write(hdr); err != nil {
f.Close()
_ = os.Remove(f.Name())
return nil, fmt.Errorf("capture: spool header: %w", err)
}
token, err := newToken()
if err != nil {
f.Close()
_ = os.Remove(f.Name())
return nil, err
}
s := &Session{
Label: strings.TrimSpace(label),
Started: r.now().UTC(),
format: audio.PCM16kMono,
Token: token,
spool: f,
path: f.Name(),
format: format,
}
r.current = s
return s, nil
}
// newToken mints a session token. Sixteen random bytes: it is a capability
// handed back over the same socket the call came in on, not a secret at rest.
func newToken() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("capture: token: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
// reapLocked drops a session whose wall clock ran past MaxDuration. The
// frame-driven check in Append only fires while frames arrive, so a client that
// simply stopped sending — a phone whose browser tab was closed, wifi gone —
// left the slot occupied and every later Start answering ErrBusy with a meeting
// from last Tuesday. r.mu must be held.
func (r *Recorder) reapLocked() {
s := r.current
if s == nil {
return
}
if r.now().UTC().Sub(s.Started) < r.maxDuration+r.staleGrace {
return
}
s.mu.Lock()
s.expired = true
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
// The audio goes with it. A recording nobody stopped is one nobody is
// waiting for, and keeping it would mean storing a meeting on the
// strength of a dropped connection.
_ = os.Remove(path)
}
r.current = nil
}
// Append adds one frame to the running session. ErrNoSession when nothing is
// running, which is the guard that makes an ambient path impossible: a stream
// arriving at a Recorder nobody started is refused frame by frame.
@@ -215,25 +349,39 @@ func (r *Recorder) Start(label string) (*Session, error) {
// ErrExpired once the session is at MaxDuration. The audio collected so far is
// kept and Stop still works — the cap ends the recording, it does not throw it
// away.
func (r *Recorder) Append(a audio.Audio) error {
func (r *Recorder) Append(token string, a audio.Audio) error {
if !a.Format.IsValid() {
return fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
}
r.mu.Lock()
r.reapLocked()
s := r.current
r.mu.Unlock()
if s == nil {
return ErrNoSession
}
if token != s.Token {
return ErrWrongSession
}
s.mu.Lock()
defer s.mu.Unlock()
if s.expired {
return ErrExpired
}
s.pcm = append(s.pcm, a.Bytes...)
// The session fixed its format at Start. A client that switches sample rate
// mid-session used to have its frames concatenated into the same buffer:
// duration() then read the whole thing at the original rate, the stored WAV
// header lied, and the cap fired at the wrong length.
if a.Format != s.format {
return fmt.Errorf("%w: session is %+v, frame is %+v", ErrBadFormat, s.format, a.Format)
}
if err := s.write(a.Bytes); err != nil {
return err
}
if s.duration() >= r.maxDuration {
s.expired = true
_ = s.finish()
return ErrExpired
}
return nil
@@ -242,6 +390,7 @@ func (r *Recorder) Append(a audio.Audio) error {
// Status reports the running session, or Running=false.
func (r *Recorder) Status() Status {
r.mu.Lock()
r.reapLocked()
s := r.current
r.mu.Unlock()
if s == nil {
@@ -273,6 +422,10 @@ type Result struct {
// Chunks — how many windows the transcript was summarised in. 1 means it fit
// in one prompt. Reported so a suspiciously vague summary can be explained.
Chunks int
// StoreErr — why the audio was not kept, when it was not. The transcript is
// still produced in that case, so this is the difference between "no blob
// because storing failed" and "no blob because nothing was recorded".
StoreErr error
}
// Stop ends the session and produces the result: store the audio, transcribe it
@@ -280,11 +433,18 @@ type Result struct {
// the slow work starts, so a stuck model cannot block the next recording.
//
// The order matters and is the same as vision's: the audio is stored FIRST. If
// transcription or summarisation fails, the recording is still on disk and can
// be run again; a meeting that happened once must not be lost to a model error.
func (r *Recorder) Stop(ctx context.Context) (Result, error) {
// transcription fails, the recording is still on disk under media.retention, so
// the meeting is not lost to a model error. Note that re-running it is a manual
// job today: no method takes a blob id back, unlike vision's Rerun, and the blob
// prunes on the media retention like any other.
func (r *Recorder) Stop(ctx context.Context, token string) (Result, error) {
r.mu.Lock()
r.reapLocked()
s := r.current
if s != nil && token != s.Token {
r.mu.Unlock()
return Result{}, ErrWrongSession
}
r.current = nil
r.mu.Unlock()
if s == nil {
@@ -292,113 +452,168 @@ func (r *Recorder) Stop(ctx context.Context) (Result, error) {
}
s.mu.Lock()
pcm := s.pcm
err := s.finish()
path := s.path
format := s.format
n := s.n
s.mu.Unlock()
res := Result{Label: s.Label, Started: s.Started}
if len(pcm) == 0 {
if err != nil {
_ = os.Remove(path)
return res, err
}
if n == 0 {
_ = os.Remove(path)
return res, ErrEmptyCapture
}
full := audio.Audio{Format: format, Bytes: pcm}
res.Duration = time.Duration(full.Duration() * float64(time.Second))
res.Duration = pcmDuration(format, n)
// Stored as WAV, not headerless PCM: a blob on disk that `aplay` and whisper
// can both open without being told the format is worth 44 bytes.
wav, err := audio.WAVFromPCM(format, pcm)
if err != nil {
return res, fmt.Errorf("capture: wav: %w", err)
// The audio is stored first, as vision does, so a transcription or summary
// failure leaves something to run again. It moves rather than being read
// into memory: a two-hour meeting is a couple of hundred megabytes, and
// this is the process that owns the database and the resident model.
audioPath := path
blob, perr := r.blobs.PutFile(media.KindAudio, "audio/wav", "capture:meeting", path)
if perr == nil {
res.BlobID = blob.ID
audioPath = blob.Path
} else {
// Over the cap, or the store is full. Report it and KEEP GOING: this
// used to return, so the one case the audio cap actually fires on — a
// very long meeting — produced no transcript, no summary and no note,
// which is the whole point of the capability. The spool file stays
// until the transcript has been read off it.
res.StoreErr = perr
defer os.Remove(audioPath)
}
blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav)
if err != nil {
// Over the per-blob cap is the expected case for a very long meeting.
// Report it and keep going: a transcript without the audio still beats
// nothing, and the words are what he will read.
return res, fmt.Errorf("capture: store audio: %w", err)
}
res.BlobID = blob.ID
text, err := r.transcribe(ctx, full)
if err != nil {
return res, fmt.Errorf("capture: transcribe: %w", err)
}
text, terr := r.transcribeFile(ctx, audioPath, format, n)
res.Transcript = text
if terr != nil {
return res, fmt.Errorf("capture: transcribe: %w", terr)
}
if strings.TrimSpace(text) == "" {
return res, ErrEmptyCapture
}
if r.sum == nil {
return res, nil
if perr != nil {
return res, fmt.Errorf("capture: store audio: %w", perr)
}
summary, chunks, err := r.sum.Summarize(ctx, s.Label, text)
res.Chunks = chunks
if err != nil {
// Degraded success: the transcript is real and stored, only the summary
// is missing. The caller writes the transcript note and says so.
return res, fmt.Errorf("capture: summarize: %w", err)
}
res.Summary = summary
return res, nil
}
// Summarize runs the map-reduce over a transcript. It is separate from Stop so
// the daemon can answer the stop quickly and do the model work afterwards: a
// full map-reduce is up to forty model calls, and a voice turn that says
// "хватит" should not wait minutes for the reply.
//
// The salvaged text a failed reduce returns is assigned before the error is
// checked. Summarize hands back the per-chunk summaries with its error
// precisely so they are not lost, and the caller used to throw them away.
func (r *Recorder) Summarize(ctx context.Context, res *Result) error {
if r.sum == nil || strings.TrimSpace(res.Transcript) == "" {
return nil
}
summary, chunks, err := r.sum.Summarize(ctx, res.Label, res.Transcript)
res.Chunks = chunks
res.Summary = summary
if err != nil {
return fmt.Errorf("capture: summarize: %w", err)
}
return nil
}
// Abort throws the running session away without transcribing or storing it.
// This is what "забудь, не записывай" must map to: a recording someone changed
// their mind about leaves nothing behind, not a blob with a note saying it was
// abandoned. Returns whether anything was running.
func (r *Recorder) Abort() bool {
func (r *Recorder) Abort(token string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.current == nil {
r.reapLocked()
s := r.current
if s == nil || token != s.Token {
return false
}
r.current = nil
s.mu.Lock()
_ = s.finish()
path := s.path
s.mu.Unlock()
if path != "" {
_ = os.Remove(path)
}
return true
}
// transcribe runs the transcriber over the audio in windows and joins the text.
// A window that fails is fatal: a summary of a meeting with a silent hole in the
// middle is a summary that misleads.
func (r *Recorder) transcribe(ctx context.Context, a audio.Audio) (string, error) {
windows := chunkAudio(a, r.sttWindow)
parts := make([]string, 0, len(windows))
for i, w := range windows {
text, _, err := r.tr.Transcribe(ctx, w)
// transcribeFile runs the transcriber over the stored WAV in windows and joins
// the text, reading one window at a time off disk so the meeting is never in
// memory whole.
//
// A window that fails is no longer fatal. It used to be, on the argument that a
// silent hole misleads — but the cost was 24 good windows thrown away for one
// whisper hiccup at minute 100. The hole is marked in the text instead, which
// keeps the words and stays honest about the gap.
func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio.Format, n int64) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open audio: %w", err)
}
defer f.Close()
per := windowBytes(format, r.sttWindow)
if per <= 0 || per > n {
per = n
}
total := int((n + per - 1) / per)
buf := make([]byte, per)
parts := make([]string, 0, total)
failed := 0
for i, off := 0, int64(0); off < n; i, off = i+1, off+per {
size := per
if off+size > n {
size = n - off
}
// Never cut mid-sample: a split inside an int16 shifts every following
// sample by a byte and turns the tail of the window into noise.
if bps := int64(format.SampleBits / 8 * format.Channels); bps > 0 {
size -= size % bps
}
if size <= 0 {
break
}
if _, err := f.ReadAt(buf[:size], int64(audio.WAVHeaderSize)+off); err != nil {
return strings.Join(parts, " "), fmt.Errorf("window %d/%d: %w", i+1, total, err)
}
text, _, err := r.tr.Transcribe(ctx, audio.Audio{Format: format, Bytes: buf[:size]})
if err != nil {
return "", fmt.Errorf("window %d/%d: %w", i+1, len(windows), err)
if ctx.Err() != nil {
return strings.Join(parts, " "), fmt.Errorf("window %d/%d: %w", i+1, total, err)
}
failed++
parts = append(parts, gapMarker)
continue
}
if t := strings.TrimSpace(text); t != "" {
parts = append(parts, t)
}
}
if failed == total {
return "", fmt.Errorf("every one of %d window(s) failed", total)
}
return strings.Join(parts, " "), nil
}
// chunkAudio splits audio into windows of at most window duration, cut on
// sample boundaries. A window shorter than one sample is impossible; audio
// shorter than one window comes back as a single element, so the caller never
// special-cases the short case.
func chunkAudio(a audio.Audio, window time.Duration) []audio.Audio {
bytesPerSample := a.Format.SampleBits / 8 * a.Format.Channels
if bytesPerSample <= 0 || a.Format.SampleRate <= 0 || window <= 0 {
return []audio.Audio{a}
// gapMarker stands in for a window whisper could not read. Russian, because it
// is read by him in a note next to the words around it.
const gapMarker = "[…не разобрала…]"
// windowBytes is how many PCM bytes one STT window holds.
func windowBytes(f audio.Format, window time.Duration) int64 {
bps := int64(f.SampleBits / 8 * f.Channels)
if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
return 0
}
per := int(window.Seconds()) * a.Format.SampleRate * bytesPerSample
if per <= 0 || len(a.Bytes) <= per {
return []audio.Audio{a}
}
var out []audio.Audio
for off := 0; off < len(a.Bytes); off += per {
end := off + per
if end > len(a.Bytes) {
end = len(a.Bytes)
}
// Never cut mid-sample: a split inside an int16 shifts every following
// sample by a byte and turns the tail of the window into noise.
end -= (end - off) % bytesPerSample
if end <= off {
break
}
out = append(out, audio.Audio{Format: a.Format, Bytes: a.Bytes[off:end]})
}
return out
per := int64(window.Seconds()) * int64(f.SampleRate) * bps
return per - per%bps
}
+61 -48
View File
@@ -90,7 +90,7 @@ func TestNewRequiresStoreAndTranscriber(t *testing.T) {
// is refused. There is no ambient path in.
func TestAppendWithoutStartIsRefused(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if err := r.Append(frame(1)); !errors.Is(err, ErrNoSession) {
if err := r.Append("no-token", frame(1)); !errors.Is(err, ErrNoSession) {
t.Fatalf("got %v, want ErrNoSession", err)
}
if r.Status().Running {
@@ -100,20 +100,21 @@ func TestAppendWithoutStartIsRefused(t *testing.T) {
func TestStopWithoutStartIsRefused(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrNoSession) {
if _, err := r.Stop(context.Background(), "no-token"); !errors.Is(err, ErrNoSession) {
t.Fatalf("got %v, want ErrNoSession", err)
}
}
func TestOneSessionAtATime(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Start("встреча"); err != nil {
s, err := r.Start("встреча")
if err != nil {
t.Fatal(err)
}
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
t.Fatalf("got %v, want ErrBusy", err)
}
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrEmptyCapture) {
if _, err := r.Stop(context.Background(), s.Token); !errors.Is(err, ErrEmptyCapture) {
t.Fatalf("empty stop: %v", err)
}
// The slot is free again after a stop, even a failed one.
@@ -127,18 +128,22 @@ func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil)
r, blobs := testRecorder(t, tr, sum, Config{})
if _, err := r.Start("встреча с подрядчиком"); err != nil {
s, err := r.Start("встреча с подрядчиком")
if err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := r.Append(frame(2)); err != nil {
if err := r.Append(s.Token, frame(2)); err != nil {
t.Fatal(err)
}
}
res, err := r.Stop(context.Background())
res, err := r.Stop(context.Background(), s.Token)
if err != nil {
t.Fatalf("stop: %v", err)
}
if err := r.Summarize(context.Background(), &res); err != nil {
t.Fatalf("summarize: %v", err)
}
if res.BlobID == "" {
t.Error("no audio blob stored")
}
@@ -171,21 +176,22 @@ func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
tr := &fakeTranscriber{}
r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second})
if _, err := r.Start("длинная"); err != nil {
s, err := r.Start("длинная")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(3)); err != nil {
if err := r.Append(s.Token, frame(3)); err != nil {
t.Fatalf("first frame: %v", err)
}
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
if err := r.Append(s.Token, frame(3)); !errors.Is(err, ErrExpired) {
t.Fatalf("got %v, want ErrExpired", err)
}
// Further frames keep being refused, so a client that ignores the error
// cannot grow the recording past the cap.
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
if err := r.Append(s.Token, frame(3)); !errors.Is(err, ErrExpired) {
t.Fatalf("post-expiry frame: %v", err)
}
res, err := r.Stop(context.Background())
res, err := r.Stop(context.Background(), s.Token)
if err != nil {
t.Fatalf("stop after expiry: %v", err)
}
@@ -196,11 +202,12 @@ func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
func TestAppendRejectsWrongFormat(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Start("x"); err != nil {
s, err := r.Start("x")
if err != nil {
t.Fatal(err)
}
bad := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 100)}
if err := r.Append(bad); !errors.Is(err, ErrBadFormat) {
if err := r.Append(s.Token, bad); !errors.Is(err, ErrBadFormat) {
t.Fatalf("got %v, want ErrBadFormat", err)
}
}
@@ -209,13 +216,14 @@ func TestAppendRejectsWrongFormat(t *testing.T) {
func TestAbortLeavesNothing(t *testing.T) {
tr := &fakeTranscriber{}
r, blobs := testRecorder(t, tr, nil, Config{})
if _, err := r.Start("зря начали"); err != nil {
s, err := r.Start("зря начали")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(5)); err != nil {
if err := r.Append(s.Token, frame(5)); err != nil {
t.Fatal(err)
}
if !r.Abort() {
if !r.Abort(s.Token) {
t.Fatal("Abort reported nothing running")
}
if r.Status().Running {
@@ -231,7 +239,7 @@ func TestAbortLeavesNothing(t *testing.T) {
if tr.calls != 0 {
t.Errorf("Abort transcribed anyway (%d calls)", tr.calls)
}
if r.Abort() {
if r.Abort(s.Token) {
t.Error("second Abort reported a session")
}
}
@@ -241,10 +249,11 @@ func TestStatusReportsTheRunningSession(t *testing.T) {
if got := r.Status(); got.Running {
t.Error("idle recorder reports running")
}
if _, err := r.Start("планёрка"); err != nil {
s, err := r.Start("планёрка")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(10)); err != nil {
if err := r.Append(s.Token, frame(10)); err != nil {
t.Fatal(err)
}
st := r.Status()
@@ -264,13 +273,14 @@ func TestStatusReportsTheRunningSession(t *testing.T) {
func TestLongAudioIsTranscribedInWindows(t *testing.T) {
tr := &fakeTranscriber{}
r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second})
if _, err := r.Start("длинная"); err != nil {
s, err := r.Start("длинная")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(9)); err != nil {
if err := r.Append(s.Token, frame(9)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
res, err := r.Stop(context.Background(), s.Token)
if err != nil {
t.Fatalf("stop: %v", err)
}
@@ -282,18 +292,19 @@ func TestLongAudioIsTranscribedInWindows(t *testing.T) {
}
}
// A hole in the middle of a meeting summary would mislead, so a failed window is
// fatal — but the audio is already stored and re-runnable.
// Every window failing is a transcription failure — but the audio is already
// stored and re-runnable.
func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
tr := &fakeTranscriber{err: errors.New("whisper is down")}
r, blobs := testRecorder(t, tr, nil, Config{})
if _, err := r.Start("встреча"); err != nil {
s, err := r.Start("встреча")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(2)); err != nil {
if err := r.Append(s.Token, frame(2)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
res, err := r.Stop(context.Background(), s.Token)
if err == nil {
t.Fatal("transcription failure was not reported")
}
@@ -309,16 +320,20 @@ func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
// error.
func TestNoSummarizerStillProducesATranscript(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Start("встреча"); err != nil {
s, err := r.Start("встреча")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(1)); err != nil {
if err := r.Append(s.Token, frame(1)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
res, err := r.Stop(context.Background(), s.Token)
if err != nil {
t.Fatalf("stop: %v", err)
}
if err := r.Summarize(context.Background(), &res); err != nil {
t.Fatalf("summarize with no summarizer: %v", err)
}
if res.Transcript == "" {
t.Error("no transcript")
}
@@ -331,14 +346,18 @@ func TestNoSummarizerStillProducesATranscript(t *testing.T) {
func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil)
r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{})
if _, err := r.Start("встреча"); err != nil {
s, err := r.Start("встреча")
if err != nil {
t.Fatal(err)
}
if err := r.Append(frame(1)); err != nil {
if err := r.Append(s.Token, frame(1)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
if err == nil {
res, err := r.Stop(context.Background(), s.Token)
if err != nil {
t.Fatalf("stop: %v", err)
}
if err := r.Summarize(context.Background(), &res); err == nil {
t.Fatal("summary failure was not reported")
}
if res.Transcript == "" {
@@ -346,18 +365,12 @@ func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
}
}
func TestChunkAudioNeverCutsMidSample(t *testing.T) {
a := audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000*2*5+1)}
for _, w := range chunkAudio(a, 2*time.Second) {
if len(w.Bytes)%2 != 0 {
t.Fatalf("window of %d bytes cuts an int16 in half", len(w.Bytes))
}
}
}
func TestChunkAudioShortInputIsOneWindow(t *testing.T) {
a := frame(1)
if got := chunkAudio(a, time.Minute); len(got) != 1 {
t.Errorf("got %d windows, want 1", len(got))
func TestWindowBytesNeverCutsMidSample(t *testing.T) {
if got := windowBytes(audio.PCM16kMono, 2*time.Second); got%2 != 0 || got != 2*16000*2 {
t.Fatalf("windowBytes = %d", got)
}
odd := audio.Format{SampleRate: 16000, Channels: 1, SampleBits: 16, Encoding: "pcm_s16le"}
if got := windowBytes(odd, 0); got != 0 {
t.Fatalf("a zero window must produce zero, got %d", got)
}
}
+154
View File
@@ -0,0 +1,154 @@
package capture
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/media"
)
// A frame for a session that already ended must not land in the next one. The
// recorder used to be addressed as "whatever is running now", so a client whose
// session was reaped went on appending its microphone into a meeting somebody
// else had started.
func TestAppendWithTheWrongTokenIsRefused(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
s, err := r.Start("первая")
if err != nil {
t.Fatal(err)
}
if err := r.Append("someone-elses-token", frame(1)); !errors.Is(err, ErrWrongSession) {
t.Fatalf("append = %v, want ErrWrongSession", err)
}
if _, err := r.Stop(context.Background(), "someone-elses-token"); !errors.Is(err, ErrWrongSession) {
t.Fatalf("stop = %v, want ErrWrongSession", err)
}
if r.Abort("someone-elses-token") {
t.Fatal("Abort discarded a session it does not own")
}
if err := r.Append(s.Token, frame(1)); err != nil {
t.Fatalf("the owner is still refused: %v", err)
}
}
// A client that simply stops sending — a phone whose tab was closed — used to
// hold the single session slot forever, and every later Start answered ErrBusy
// with a meeting from last week.
func TestStaleSessionIsReapedByTheWallClock(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{MaxDuration: time.Minute})
now := time.Now().UTC()
r.now = func() time.Time { return now }
s, err := r.Start("брошенная")
if err != nil {
t.Fatal(err)
}
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
t.Fatalf("start = %v, want ErrBusy", err)
}
now = now.Add(time.Minute + StaleGrace + time.Second)
next, err := r.Start("вторая")
if err != nil {
t.Fatalf("a stale session was not reaped: %v", err)
}
if next.Token == s.Token {
t.Fatal("the new session reused the stale token")
}
if err := r.Append(s.Token, frame(1)); !errors.Is(err, ErrWrongSession) {
t.Fatalf("the reaped client can still write: %v", err)
}
// The abandoned recording is not kept: nobody is waiting for it, and storing
// it would mean keeping a meeting on the strength of a dropped connection.
if _, err := os.Stat(s.path); !os.IsNotExist(err) {
t.Fatalf("the reaped spool file survived: %v", err)
}
}
// One window failing used to fail the whole transcription, which threw away
// every other window of an hour-long meeting. The hole is marked instead, so the
// summary cannot silently read as if nothing was missing.
func TestOneFailedWindowIsMarkedNotFatal(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{STTWindow: time.Second})
r.tr = &windowTranscriber{failOn: 2}
s, err := r.Start("встреча")
if err != nil {
t.Fatal(err)
}
if err := r.Append(s.Token, frame(3)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background(), s.Token)
if err != nil {
t.Fatalf("stop: %v", err)
}
if !strings.Contains(res.Transcript, gapMarker) {
t.Errorf("no gap marker in %q", res.Transcript)
}
if !strings.Contains(res.Transcript, "окно1") || !strings.Contains(res.Transcript, "окно3") {
t.Errorf("the surviving windows were dropped: %q", res.Transcript)
}
}
// The audio not fitting the store is not a reason to lose the words. Stop used
// to return early on a store failure, so a recording over the blob cap produced
// neither a blob nor a transcript.
func TestStoreFailureStillTranscribes(t *testing.T) {
blobs, err := media.OpenWithBudget(t.TempDir(), 512, 1024, 0)
if err != nil {
t.Fatal(err)
}
tr := &fakeTranscriber{}
r, err := New(blobs, tr, nil, Config{})
if err != nil {
t.Fatal(err)
}
s, err := r.Start("длинная встреча")
if err != nil {
t.Fatal(err)
}
if err := r.Append(s.Token, frame(2)); err != nil {
t.Fatal(err)
}
// The store failure is reported, but as a degraded success: the Result is
// filled in, and the caller keeps it rather than treating the error as
// nothing having happened.
res, err := r.Stop(context.Background(), s.Token)
if err == nil {
t.Fatal("the store failure was not reported")
}
if res.BlobID != "" {
t.Errorf("blob id = %q, want none", res.BlobID)
}
if !errors.Is(res.StoreErr, media.ErrTooLarge) {
t.Errorf("StoreErr = %v, want ErrTooLarge", res.StoreErr)
}
if res.Transcript == "" {
t.Fatal("the words were lost with the audio")
}
// The spool file is cleaned up even on the failure path.
glob, _ := filepath.Glob(filepath.Join(blobs.Dir(), "spool", "*"))
if len(glob) != 0 {
t.Errorf("spool leaked: %v", glob)
}
}
// windowTranscriber answers per window and fails a chosen one, which is what a
// whisper timeout in the middle of a meeting looks like.
type windowTranscriber struct {
calls int
failOn int
}
func (w *windowTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
w.calls++
if w.calls == w.failOn {
return "", 0, errors.New("whisper timed out")
}
return fmt.Sprintf("окно%d", w.calls), 1.0, nil
}

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