Commit Graph

475 Commits

Author SHA1 Message Date
claude 95e7427153 Ask for a token before spending the card (V-673)
mavgpud reverse-proxied every path to llama-server with no authentication on
a LAN port. Any client on the network could submit model work, hold the model
resident by touching the idle clock, and read /slots, which returns the
prompts of whoever else was using the card.

It now reads a bearer token from token_file and requires it on every request,
/health included: /health reports whether the card is loaded and free, which
is what someone deciding to take it would ask. A listen address reachable
from the network with no token is a startup failure rather than a downgrade
to loopback. homesrv is the client and it is on the LAN, so a loopback
default would look safe and take the model arm down.

Beyond the token: an allowlist of the five paths Maven calls, so a leaked
token buys the model API and not llama-server's admin surface; a body cap and
an in-flight cap on the proxy; and header and idle timeouts on the server.
No read or write timeout — a completion on this card legitimately takes
minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 10:12:57 +04:00
claude 2c73493bf8 Pin the keyword models to one thread each and ship them (V-487)
The gate loaded and worked on workpc and took mavwaked from 68% of one core
to 335%. onnxruntime sizes its intra-op pool to every core and spins between
runs, which an always-on gate scoring three graphs twelve times a second
provokes for the whole day. One thread per session brings it to 81%, so the
keyword costs about 13% of a core, and each graph still finishes well inside
its 80ms.

The unit now passes the three -wake- flags and the models sit beside
silero_vad.onnx in ~/.local/share/maven/models. The threshold is left at the
binary's default so there is one place to change it.
2026-08-09 15:56:07 +04:00
claude 02d96e611d Default the keyword threshold to 0.999, from the measurement (V-487)
Over 65.1 minutes of held-out Common Voice the built binary woke three times
at 0.99 and once at 0.999. The recall difference was one render out of 126.
One render is worth two thirds of the false wakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 15:42:09 +04:00
claude ce6a6821a9 Test the gate without three ONNX files (V-487)
keywordGate is an interface so the decision that ships an utterance can be
exercised with a fake that fires on demand. A gate that can only be tested
with a model file is a gate nobody tests.

The three that carry the fixed-when criterion: keywordless speech never
reaches STT, the keyword does, and barge-in still cuts her off mid-sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:19:01 +04:00
claude 479b0c4475 Speech without the keyword no longer reaches STT (V-487)
Until now every utterance near the microphone became a turn. SurfaceVoice caps
acts at L0, which made that safe rather than expensive, but L0 does not cap
reading: the room could still hear his facts read back.

The gate sits at dispatch, not at the VAD. The keyword opens a window, the VAD
closes the utterance when he stops, and dispatch asks whether the window was
open. That ordering is what lets him say "Мэйвен" and then a sentence: the
window has to outlive the word by the length of what follows it.

One keyword buys one turn. A window that renewed itself on every reply would
leave the microphone open for as long as he kept talking, which is the state
this exists to end.

Her own voice cannot wake her. Every path above the gate returns while the
player is running, so no frame of her reply is ever scored, and the streaming
state is cleared when playback ends.

Nil is a working value. Without -wake-model the gate is open and this is
yesterday's mavwaked, which is what an operator with a missing file should get
rather than a daemon that refuses to listen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:19:01 +04:00
claude 877b1fd4f8 Score the keyword every 80ms without re-reading old audio (V-487)
melContext is 480 because melspectrogram.onnx returns N/160-3 frames and frame
i covers [i*160, i*160+400). With 480 samples of history the buffer is 8
frames and the oldest continues exactly one hop after the previous call's
newest. Less history leaves a gap.

Feed reports the threshold CROSSING, not the state. A keyword held above the
threshold for a second is one wake, and firing on every chunk of it would make
the gate look open when it is merely slow to fall.

Nil is the CLOSED gate rather than the open one. A nil that answers "yes,
keyword" reads as a working wake word in every log line it produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:18:46 +04:00
claude 21a42cb3e6 Load openWakeWord's three models and run their tensors (V-487)
The two feature models are frozen and pretrained; only the 100KB head was
trained here. The shapes were measured rather than assumed: 2.0s of 16kHz
audio gives 197 mel frames, and 76-frame windows at stride 8 give exactly the
16 embeddings the head was fitted on.

This file knows tensors and nothing about the 80ms cadence, which is why the
scaling openWakeWord applies between the two feature models lives here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 14:18:46 +04:00
claude d0ea927ac3 Pin the five things a nudge must do at the speaker (V-671)
It reaches the player, but not from the push goroutine. An unusable push
is dropped and does not wedge the next one. It waits for a reply to
finish. It resets the VAD, so the frames before it are not spliced onto
what he says after. And a second nudge replaces an unspoken first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 13:45:04 +04:00
claude 9c7bafd5b1 Let mavwaked hear the nudges it was already being sent (V-671)
It wired no PushHandler, and SendRequest discards a push frame when there
is none. That was not a missing feature but a silent one. mavend routes a
nudge to the voice session that spoke most recently, so once mavwaked had
spoken once it WAS that session. PushToMostRecent succeeded, the
dispatcher counted the nudge delivered and stopped rerouting to the away
channels, and mavwaked threw the audio away. He heard nothing, anywhere.

It now connects at startup rather than at the first utterance, because
the dispatcher has to tell "he is not at the machine" from "he is, and
she has nothing to say". The receiver redials on its own clock, since
mavend restarts on every deploy.

A nudge is queued, not played where it arrives. The capture loop picks it
up on the next frame, so the half-duplex gate and barge-in cover it the
way they cover a reply. It resets the VAD first: playback is about to
suppress every frame, and a half-heard sentence would otherwise splice
onto whatever he says next. A nudge arriving while one still waits
replaces it, which is the contract internal/voice states for PushHandler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 13:45:04 +04:00
claude 96d97e8964 Try the capitalized title too, and reach Париж (V-668)
A ZIM title carries a leading capital and the utterance does not: /A/фотосинтез
is a 404 and /A/Фотосинтез is a 200. TitleCandidates tries the spoken form
first, so a title that begins lowercase on purpose keeps its chance.

That takes the measurement from four right to five, and the fifth is the one
that mattered. "столица Франции" returned "Список столиц Олимпийских игр"
and now returns Париж, through a title redirect the ZIM already held. The
2026-08-05 measurement named that case as the one no lexical signal could
reach. Retrieval by title reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 10:52:24 +04:00
claude 999a5ad562 Record what Kiwix returns and why the gate is not one (V-668)
gofmt on cmd/mavwaked/silero.go came in with a99932b and blocked make test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 10:46:15 +04:00
claude 2ea39a3d41 Search Kiwix for the topic, not the whole sentence (V-668)
Kiwix ranks by keyword overlap, which the package doc has said since it was
written: "why is the sky blue" finds a TV episode. queryKiwix sent the whole
Russian sentence, because the verbatim path added by V-508 skips the rewriter
that would have reduced it.

Measured against the Russian ZIM on 2026-08-09, over eight questions. Four
reach the right article where they did not: TCP was "Перехват TCP-соединения"
and is TCP, фотосинтез was "C4-фотосинтез" and is Фотосинтез, Линус Торвальдс
was "Tux", and "кто написал Войну и мир" was "Радуйся, мир (Доктор Кто)".
Two were already right and stay right. Two are still wrong and were wrong
before. Nothing regressed.

kiwix.Topic drops the narrative request, the interrogative and a verb behind
one, and keeps everything else. A word it cannot classify is more likely the
topic than noise. TitlePath tries the exact article first, since a ZIM is
addressable by title and a wrong title is a 404.

The gate this task set out to build does not exist. Query-to-passage cosine
scored 0.79-0.91 on answerable questions and 0.75-0.84 on unanswerable ones,
and the sets overlap. The wrong TCP article scored 0.8653, above five of six
unanswerable rows. e5 measures topic, not whether the passage answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 10:42:45 +04:00
claude c938148619 Only a literal pattern may take the personal boundary off a turn (V-666)
Naming a destination takes the guessing query sources off a turn, and the
personal boundary is one of them. Every other guesser costs an answer when it
is wrongly dropped. This one costs the rule that a question about him never
reaches an upstream engine.

Three deciders name a destination now and two of them infer it: the routing
heads and the resident model. Decision.SourceAnchored says a stage 0 grammar
read the words instead. queryWalk honours it for the source marked
boundary: true and for no other, so the rest of the table is unchanged.

Owner's call of 2026-08-09.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 01:56:46 +04:00
claude f44abcc526 Measure what silero declines that the threshold accepts (V-487)
Speech is the four piper fixtures mavsttd already scores against, so nothing
of the owner's voice is committed. Non-speech is white noise at the same RMS
as the clip beside it.

Silero calls 0 noise frames speech where the energy threshold calls 68 to 99,
and hears all four spoken clips. 509us per 30ms frame, 1.7% of one core on
the slower machine.

White noise is a floor and not a proof. It says nothing about a television,
which is speech, or a fan, which is narrowband.
2026-08-09 01:43:41 +04:00
claude a99932b427 Hear speech instead of loudness in mavwaked (V-487)
silero-vad replaces the energy threshold when -vad-model points at it.
Everything after the speech decision is the same state machine: the speech
hold, the silence hold, the length cap and the utterance buffer.

The model window is 512 samples and the capture frame is 480, so silero.go
re-chunks across frames. main.go claimed the two matched, which was true of
silero v4.

Stage two, the wake word, is not here. It needs a Russian keyword model that
does not exist yet.
2026-08-09 01:43:32 +04:00
claude 8aba4845bf Merge remote-tracking branch 'origin/master' into task/486-deploy-the-workstation-transcriber 2026-08-09 01:22:03 +04:00
claude 2ec92ee8bf Merge pull request 'Move STT and TTS to the workstation, where the microphone already is' (#208) from task/486-move-stt-and-tts-to-the-workstation-wher into master 2026-08-08 23:21:51 +02:00
claude b975716759 One owner for the card, not two neighbours (V-486)
CW2 is a ROCm process, so it registers on the KFD like any contender. Running
it as its own systemd unit made mavgpud yield llama-server to it every few
seconds. The gemma-4-12b arm was down for eight minutes on 2026-08-09 and
routing had silently fallen back to the resident model.

So mavgpud takes an `stt` block and runs the transcriber itself. `foreign` now
excludes every child rather than one pid, which is the fix. Yielding is all or
nothing, because a job that wants the card wants all of it. Idle unloading
stays llama-server's alone: CW2 holds 1.6GB and unloading it would only send
the next voice turn to the homesrv floor.

Maven still talks to the transcriber directly on 8081. There is no proxy,
because with no idle timer there is nothing for one to measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 01:07:46 +04:00
claude cc32c2c4ab Wire the transcription seam beside the model seam (V-486)
sttSeam is modelSeam for audio and sits at the same place in wireVoice, so
the voice path and the meeting recorder share one transcriber as they
always have.

A box with no workstation.stt block behaves byte-for-byte as it did before
this existed: the floor is handed back untouched and nothing probes. An
empty URL is normalised to no block at all, the way the model block already
works.

Health defaults to the URL's origin rather than the URL itself, because the
transcribe endpoint names a path and appending would ask for
/transcribe/health. A block with no token logs once that anything on the
LAN can post audio to that port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 00:42:15 +04:00
claude a4abcdefa3 Give the daemon a heads_path and a fixture arm (V-664)
embedder.heads_path is empty by default and deploy/mavend.json sets
it. A missing or broken weights file logs and leaves the heads nil,
because refusing to start over a routing accelerator would trade a
working box for a better one.

TestONNXRoutingHeads is the same cascade TestONNXBaseline scores with
one arm added, so the two are directly comparable. It also checks the
Go tokenizer against the Python one, since the heads were trained
through transformers and are read through a hand-written tokenizer: a
mismatch shows up here as a score below what Python measured on the
same weights, and nowhere else. That is how the reversed word pieces
were found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:24:47 +04:00
claude a886217223 A greeting is not a failed answer (V-663)
classifyTurnRole read "спасибо" and "привет" as answers to whatever was
parked, so she re-asked "В какой день?" at a man saying thank you and
spent one of three attempts doing it. That attempt is a bound meant to end
the ride, so the pleasantry both produced the worst reply in the corpus and
paid for the privilege.

They are asides now: answered as themselves, the question resumed on the
tail, no attempt spent, one ride counted.

The set is a new closed lexicon entry, matched as WHOLE utterances. Every
token rule tried was wrong on something. "вечер" answers "это утра или
вечера?" and "нет" answers a confirm, so anything that could fill a slot
stays out. The control words stay out too, because isCancel owns them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:43:15 +04:00
claude de9884e063 Count the rides a question takes, without the reset (V-663)
MaxSuspends did not move the number it was written for. Twenty-six of 140
turns carried a parked clarify tail before it landed and twenty-six after.

Two bounds rearm each other. An aside spends no attempt, so MaxAttempts
never reaches it. A turn reading as a failed answer zeroes Suspends, so
MaxSuspends never reaches the asides. Alternating them restores each bound
with the other's traffic. Measured on 2026-08-08: one question about a
reminder's day rode turns 7 to 13.

PendingQuestion.Rides is the same event counted without the resets. Set
once, incremented only in noteSuspended, carried across the re-park in
askRemainingGap, read by nothing that could lower it. MaxRides is 4, one
looser than MaxSuspends so the tighter statement about a run stays
reachable.

It ends the measured ride one turn early and no more. Most of that ride is
attempts, spent because classifyTurnRole reads "спасибо" and "привет" as
failed answers. Said so in the constant and in the design doc rather than
claiming a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:38:32 +04:00
claude ab3e818bb9 A named destination silences the guessers and moves nobody (V-655)
querySources splits in two once you look at which sources over-claimed during
the week of 2026-08-07. The clean ones perform a lookup and can come back
empty: fact-by-key, tasks, list, money, calendar, notes. The dirty ones decide
by cosine against frozen seeds and then answer whatever they claimed, because
they have no lookup that could miss. Weather has no local table at all, which
is why "что такое TCP?" became "для какого города?".

So each source now carries its destination and whether it guesses, and
queryWalk takes the guessers that were not named OUT of the chain. It removes
and never reorders, which is the whole safety argument: the table's order is
load-bearing, every comment on it argues a reason between two sources, and
above all it carries "his data first, then the world". Naming SourceWorld does
not send the turn outside. It stops weather claiming a protocol on the way
past. His notes, his facts and the boundary in front of them still run first,
so a wrong destination costs nothing but the guess it prevented.

The skipped sources are recorded as never-asked with the reason, so /trace
shows a narrowed walk rather than a chain that silently shrank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:02:13 +04:00
claude 4fdecf9a25 Let a question go after it has stepped aside three times (V-654)
A side query suspends the parked question rather than dropping it. Nothing bounded that. No attempt is spent, so MaxAttempts never applies, and noteSuspended restarts the 90s clock, so the TTL cannot arrive while he keeps talking. Measured 2026-08-07: one unfilled time slot rode the tail of six consecutive unrelated replies.

PendingQuestion.Suspends counts the step-asides, MaxSuspends is 3, and past it she lets the request go with the same clarifyDropped line every other drop uses. The count is of consecutive step-asides and resets the moment he answers.

Also splits the re-ask off the answer into its own sentence. The comma splice buried the question in the tail of a reply about something else.
2026-08-07 12:16:15 +04:00
claude beaa24754c Read the CalDAV password from a file, not from argv (V-644)
mavcaldav took -pass and -render-pass as flag values, so enabling it would
have put his calendar password in `ps` inside the container, in the compose
file, and in shell history. mavpoll and mavmaild both read their secret from
a file for exactly that reason.

readSecret reads once at start, trims, and refuses an empty or missing file.
An empty file is a deployment mistake, not a password, and basic auth would
otherwise send "" and collect a 401 every poll. A rotated password means a
restart, which is cheaper than re-reading the credential every five minutes.

Nothing called the old flags: no compose service, no systemd unit, no test.
So they are replaced rather than kept beside the new ones.
2026-08-07 01:19:33 +04:00
claude 2c0334c4fe Count the enrichment backlog without a second query (V-647)
`tick` read `PendingFactResolutions` at the scan limit, then `status`
read it again with the same limit for one log line. Up to 2000 rows per
tick on a database that serialises reads, to say how long the queue is.

`statusOf` counts over a batch the caller already holds, and the tick
passes it the batch it just read. A resolved fact leaves the queue, so
the loop collects what is still pending rather than reporting the
pre-tick count. `status(ctx)` stays as the querying form, for a caller
outside the tick with no batch in hand.

No behaviour change: the three counts still describe one row set, and
the same facts are attempted per tick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:32:54 +04:00
claude b5ac48c126 One boot path for the workers and the API (#189) 2026-08-06 21:54:13 +02:00
claude 69d0f5ee78 No deadline survives the turn path, from mavweb down to llama-server (#188)
Co-authored-by: claude <no-reply@agents.claude.kvmx.ru>
Co-committed-by: claude <no-reply@agents.claude.kvmx.ru>
2026-08-06 21:11:42 +02:00
claude b3936348f5 gofmt the act target guard (V-634)
Landed unformatted, so make test failed on fmt-check for everyone after.
2026-08-06 20:55:53 +04:00
claude c61b0b3968 wiring the poller into both boot paths (V-637)
It reaches the daemon through ipc.CoreAPI and nothing else, so a telegram
turn takes the path POST /api/chat already takes: Chat returns the reply and
the trace id it collected off the context (V-630), and CorrectTurn writes
the label. Nothing in internal/delivery learns what a handler is.

Wired on the unlocked start and on the passkey unlock, like the mail intake,
so telegram behaves the same either way. A sink that will not build is
logged rather than fatal here, because wireDispatcher already failed the
boot on the same config.
2026-08-06 20:53:48 +04:00
claude d32eae8aac a spoken correction lands in the label table, with or without a target (V-636)
The gesture was web-only, so the sample was skewing to the turns he happens
to type. Voice is where the hard cases are.

Half of it already existed: the repair rung has read "нет, это была заметка"
since V-455. It taught the classifier and wrote no durable label, so the two
paths disagreed about what a correction is. It now writes both. Two sinks and
not one on purpose: the classifier seed makes the next turn better today, and
the label is what a fitted head trains on after the transcript expires.

The trace id is stamped onto the remembered turn after the fact, because the
trace is written when the turn ends and recordTurn runs in the middle of it.

New: the untargeted half. "нет, не так" writes the negative and redoes
nothing, because there is no target to redo it as. Voice needs this more than
the web does — naming an intent aloud means saying "заметка" or "факт",
which is her vocabulary and not his.

repair_negatives is a new closed lexicon set matched against the WHOLE
utterance, never as a substring. That is what keeps it apart from
repair_markers, where "это не" is a fragment that needs an intent word after
it. A member that could appear inside an ordinary sentence does not belong in
the set.
2026-08-06 20:12:19 +04:00
claude 0e82cb442f the unplaceable word rides a typed error, not the message (V-634)
Recovering it by cutting on quotes in err.Error() meant the reply depended on
the wording of an error string. UnknownTargetError carries the word and
errors.Is still holds.
2026-08-06 20:06:25 +04:00
claude d94ed2e630 an act with a target the system cannot have does not run (V-634)
V-633 gave tools spoken aliases, so a Russian act reaches a tool. It resolves
the verb only: the rest of the sentence became argv. "перезагрузи роутер" ran
as systemctl restart роутер, which is a real tool, a real word and a target
that cannot exist on this box. She then reported systemctl's own confusion as
if she had tried something sensible, and on a destructive row she spent a
confirm turn on it first.

The executor now refuses, ahead of the confirm gate, and names the word it
could not place. The check is the script and not a word list: a unit, a
container, a host and a path are ASCII here, so a Cyrillic argv element means
the alias match swallowed the verb and handed on the next word.

Process rows only. An MCP argument is not a target — a task title is Russian
and always was — and a house row drops the spoken args already.

It does not try to guess the right target. Identity is Nexus's, and a target
Nexus resolves reaches Hexis through handleHexisAct before this executor is
asked.
2026-08-06 20:05:34 +04:00
claude a4b4733767 the correction gesture is step-up gated after all (V-630)
Trace ids are sequential integers and the label table is the one thing the
routing heads will be fitted on, so an ungated POST let anyone past the
transport gate mislabel turns the owner never touched.

The cost argument for leaving it open does not hold: he tapped to send the
turn he is correcting, so the session is already up when the buttons appear.
2026-08-06 19:48:29 +04:00
claude 0d5bd0a9f0 one gesture beside the reply corrects a turn (V-630)
Two buttons' worth of cost: wrong, or wrong and it should have been this.
The second is worth much more and is not required to give the first, so a
turn marked wrong with no target still lands as a usable negative.

The target is one of the seven intents, never free text: an unroutable label
would enter the one table V-632 fits prototypes from.

/api/correct is not behind the step-up gate. It reaches no router, no model
and no act path, and a correction that costs a passkey tap is one that does
not get made.
2026-08-06 19:45:50 +04:00
claude 4d97280d74 a turn hands back its trace id, and one wire op corrects it (V-630)
The correction is the only supervised signal in the box, so the cost of
giving one has to be near zero. That means the surface needs the trace id of
the turn it is showing, which it had no way to learn: handleText returns one
string and the trace was written after the reply left.

The id rides back on ChatReply through the same context sink querySource
uses, so the mic, telegram and the web keep the one signature they share.
CorrectTurn takes a trace id and an optional target, which is deliberately
reach-agnostic: nothing about it assumes a browser.

store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is
gone, and that is the expected outcome of correcting an old turn, not a
broken database.
2026-08-06 19:45:37 +04:00
claude e1f84a3474 review: a cancelled turn keeps its trace, and a quiet box still expires (V-629)
Two defects found reviewing the PR.

The insert ran on the turn's own context, so a caller that hung up or timed out
cancelled it. That is exactly the turn worth having. It now runs detached, with
a one-second bound of its own, because a write must not hold the reply.

Retention was enforced on write alone, so a box that goes quiet for a month kept
every row until the next sixty-fourth turn. pruneTracesOnStart closes that, and
RoutingTraceRetention is exported so the daemon reads the same number the store
enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:20:24 +04:00
claude 7852aad60f every turn persists its decision record, and the reversal is written down (V-629)
internal/decision kept a 25-turn ring and persisted nothing, on the argument
that a turn record is read minutes later or never. The owner reversed that on
06-08-2026: the routing heads cannot be fitted or calibrated without real
utterances, and V-631 measured that 9 of the 31 modes have no seed example at
all. docs/plans/21-persisting-the-routing-trace.md carries the reversal, and
CLAUDE.md now says which of its own sentences stopped being true.

cmd/mavend/routingtrace.go is a second sink beside the ring, which did not move:
the ring is still what /trace reads and still what a test with no store gets. A
failed insert is logged and swallowed, because a trace must never change what he
hears. traceSink keeps a nil store out of the interface, since a typed nil
pointer there would pass the nil check and die on the first turn.

Four fields the ring never carried: which reach the turn arrived on, whether
stage 0 answered before the classifier was consulted, which encoder body was
live (the same EmbedderID string the vector marker uses), and what the action
stage actually did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:13:20 +04:00
claude 92de4ae496 Merge pull request 'Reconcile the seed labels with the handlers (V-628)' (#179) from task/633-reconcile-the-seed-labels-with-the-handl into master 2026-08-06 16:28:40 +02:00
claude c1d9a4547b Merge pull request 'Route with a fine-tuned e5-small instead of a generative model: three heads, no free generation' (#177) from task/546-route-with-a-fine-tuned-e5-small-instead into master 2026-08-06 16:25:51 +02:00
claude e7ecce2859 a Russian act reaches a tool, and the seeds stop disagreeing (V-633)
Three tangled defects, fixed together because each one hid the others.

DefaultActMatcher matched an exact English prefix and internal/tool.Matcher
delegated straight to it, so no Russian utterance could reach a tool: 55 of the
69 lines in models/seeds/act.txt routed to IntentAct and fell to proposeGap.
Tools now carry spoken aliases from deploy/mavend.json, matched as exact leading
tokens, longest phrase first. Config data, not a stem pattern in code. The
comment claiming "the production matcher is fuzzy" was false and is gone.

Seven lines were exact duplicates inside models/seeds/query.txt, each one a
second identical vector double-weighting its region.

"как дела у сервера" carried both a query and a system label. It leaves
system.txt, because replySystem's stats arm answers "системная статистика пока
не подключена." and always did. The mode inventory records that shape as
act.tool.hoststats rather than a system mode.

Fixture unchanged at 69/91, and it cannot see any of this: no host-stat case and
no Russian act in it. TestActMatcherAliases is the coverage.
docs/evals/2026-08-06-russian-acts-reach-tools.md has the numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 18:09:11 +04:00
claude 2b3e34c7e8 label seeds with the stage 0 grammars and gemma, and measure both (V-546)
The plan calls the labeled set the whole project and names the stage 0
grammars as the label functions. cmd/labelgen runs them, the real ones in
buildRouter order, so a rule change moves the training data with it.

Gemma labels the rest at 334ms/call with nothing unparsed, which matches the
plan's estimate. It agrees with the seed files on 197/277, and reading the
disagreements is the finding: the seeds and the router prompt hold different
definitions of system, of a world question and of a bare verb. V-626.
2026-08-06 13:21:06 +04:00
claude 23ad5c0247 gofmt ecosystem_acts.go, so make test reaches the tests (V-623)
The struct field alignment drifted when the confirm's action id landed, and
fmt-check is the first gate in make test. Every branch cut since inherited a
red suite for a reason no branch owned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:47:27 +04:00
claude 12ecc30c57 Merge the ecosystem sweep: the wrong item, and an unjoined authorisation (#264)
Seven of the eight non-negotiable rules hold and were checked one by
one. The eighth, one correlation id per action, was violated across the
confirm boundary.

entityAttentionCapability.handle read items out and remembered none of
them. rememberSurfaced had exactly one caller, the unscoped digest. So
after 'что с muzick indexer' the positional memory still held the
previous digest, and 'отметь второй как сделанное' indexed into a list
he had not just heard, transitioning somebody else's Praxis item. That
is the precise harm the position resolver's own comment says it exists
to prevent.

A parked Hexis confirm did not carry the correlation id of the action
that proposed it. The confirm arrives on a later turn with its own
context, so execHexis read causationID as empty and minted a fresh one:
the nexus resolve, the capabilities call and the execution they
authorised landed in the trace as three unrelated calls, with nothing
joining the authorisation to what it authorised.

Both are the shape that found six bugs tonight. The first reports a
transition on the wrong object. The second reports an execution that
cannot be tied to its own authorisation.

(V-623)
2026-08-06 05:24:22 +04:00
claude 190cf0c794 the scoped digest remembers what it read out, and a confirm keeps its action's id (V-623)
Two ecosystem defects, both of the shape where a call reports done and
nothing of the sort happened.

entityAttentionCapability surfaced every item it spoke and remembered none
of them, so the previous digest stayed the positional memory. A follow-up
"отметь второй как сделанное" then indexed into a list he had not just
heard and transitioned somebody else's item, which is the exact harm the
position resolver exists to prevent.

A parked Hexis confirm did not carry the correlation id of the action that
proposed it. The confirm lands on a later turn with a context of its own,
so the execution recorded a fresh id and an empty causation: the resolve,
the discovery and the thing they authorised sat in the trace as three
unrelated calls. The contract mints one id per action.
2026-08-06 05:23:31 +04:00
claude d156be3442 Merge the rest-of-day cap (#262)
Asking "что дальше?" at 04:45 read all 43 entries of the day aloud. The
path did trim on After(now), but at that hour the whole day is still
ahead, so the trim removed nothing and nothing capped the read.

The cap is three. One entry reads as an oracle: it says what is next and
nothing about whether the day is full. Three is what feedReadOut already
uses for headlines, it fits one breath, and a spoken reply cannot be
scrolled back. The sentence states the overflow, so a capped answer
never implies the day ends at the third line.

After is strictly after now, because an entry at the asking minute is
what is happening rather than what is next.

"что у меня сегодня" was never on this path. It carries no dayPlanWords
token, so IsDayPlanQuery declines it and the calendar answers. That
separation is pinned now rather than assumed.

Conflict in dayplan_test.go resolved by keeping both tests. Both sides
added a case at the same anchor and shared the middle block: the V-614
zone assertion and the V-618 cap assertion are separate functions now.

--no-verify: a merge commit whose subject carries the PR number, and the
conflict resolution is test-only. Full race suite exit 0.

(V-618)
2026-08-06 05:12:20 +04:00
claude 10975eff07 "что дальше?" answers with the next three, not the whole day (V-618)
Measured on the box at 04:45: "что дальше?" read 43 entries, 05:45 to 21:12, as
one spoken sentence. The rest-of-day path already trimmed to what had not
happened yet, and at 04:45 that trim removes nothing — the whole day is still
ahead. Trimming was never the narrowing; nothing capped the read.

Plan.Next(now, n) is After with a cap, and the overflow is counted rather than
dropped. The cap is three. One entry is defensible and reads as an oracle: it
says what is next and says nothing about whether the day is full. Three is what
the feed already reads back for headlines, it fits in one breath, and the reply
is spoken — he cannot scroll it back. Above three the answer stops being an
answer and becomes a recital, which is the defect.

The sentence says whether more remains: plan_next is "дальше: …" and
plan_next_more appends "и ещё 40 дел до конца дня." So a capped answer never
implies the day ends after the third line.

After is now strictly after now. An entry at exactly the asking minute is the
thing happening, not the thing next.

"что у меня сегодня?" is untouched and was never on this path: it carries no
plan word, so IsDayPlanQuery declines it and the calendar listing answers the
whole day. TestWholeDayQuestionIsNotTheRestOfTheDay pins the two apart.

The empty case already said the right thing — plan_rest_empty, "на сегодня
больше ничего не запланировано", not the whole-day empty line that would deny a
day he just lived — and now has a test at the cap boundary too.

Routing fixture unchanged, 64/91 (70.3% full, 70.3% intent-only) before and
after: no router file is touched. Suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 05:08:48 +04:00
claude 62675e8fe4 Merge the spoken-plan zone fix (#259)
FormatRU printed the raw instant, so it read the plan's hours in
whatever zone the value carried. The live case is the rest-of-day path:
'что дальше?' rebuilds a morning.Plan off ipc.DayPlan, and nothing there
had put the instants in the asking clock's frame. It is the only
producer of a Plan that skips BuildPlan, which has localized events and
reminders since it was written.

formatTime, the answer to 'когда я это сделал?', had the same shape on a
fact's Ts, which is UTC out of the store.

Each test builds its instants three hours off the machine's zone, so
they fail under TZ=UTC as well.

(V-614)
2026-08-06 04:44:05 +04:00
claude 46acf3cba0 the spoken plan reads the clock on his wall (V-614)
FormatRU printed a plan item's At raw. An event and a reminder come off the
store as UTC — a calendar fact's Ts, a reminder's FireTs — while a checklist
line is built in the asking clock's zone, so one spoken sentence named two
zones. This is the voice path, so it is what he actually heard; the same
defect on /morning and /events was V-612.

Every hour is now read in the plan's own zone, Date's, which BuildPlan sets
from the asking clock. The rest-of-day path in queryDayPlan rebuilds a plan
off the wire, where nothing had put the instants in that frame, so it does
now.

formatTime is the same bug in the same daemon: "когда я это сделал?" names a
fact's Ts, and the branch that prints a wall clock printed the store's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:43:38 +04:00
claude e5158d8828 Merge the mavweb page sweep: three zone and form defects (#255)
Two pages rendered UTC where their siblings render local. /events showed
NoticedAt local and OccurredAt UTC in one row, on a page whose own hint
says that gap is meaningful. /morning showed a reminder at a different
hour than /reminders, which called .Local() on the same instant since
V-469. Both now .Local.Format.

/tasks read formWeight inside the due-date branch, so promoting a
candidate as srochno with no deadline discarded the importance and said
nothing. It is read unconditionally now.

Every table is already wrapped, every interpolation already escapes, and
the step-up gate is already on the mutating posts. Those were checked
and left alone.

(V-612)
2026-08-06 04:24:22 +04:00