Commit Graph

601 Commits

Author SHA1 Message Date
claude 06576b406c Pass the ambient boolean as one flag argument
Go flag parsing stops at the separate boolean value before ambient-token. Use -ambient-enabled=value and pin the deployed argv contract discovered during live V-691 verification. Owner explicitly requested direct commits to master.
2026-08-13 03:07:52 +04:00
claude 28c2ffb84f Make aggregate ONNX gates execute for real
Reference-count the process-global ONNX Runtime across embedder and routing-head sessions, make close idempotent, and require named proof that both aggregate routing gates executed rather than self-skipped (V-716). Owner explicitly requested direct commits to master.
2026-08-13 03:03:25 +04:00
claude 8015fdbb79 Harden semantic boundaries and repair dialogue state
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
2026-08-13 03:00:31 +04:00
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00
claude da9114b623 Preserve context across conversation intents (V-542)
Owner explicitly requested direct commits to master; bypass the branch-only hook.
2026-08-13 02:14:46 +04:00
claude de61b753ac Unblock TCP Accept on listener close (V-679)
The owner explicitly requested direct commits on master; --no-verify bypasses the branch-only workflow hook for that instruction.
2026-08-13 02:03:32 +04:00
claude 7d0250a30b Reject incomplete Open-Meteo responses (V-676)
The owner explicitly requested direct commits on master; --no-verify bypasses the branch-only workflow hook for that instruction.
2026-08-13 02:01:40 +04:00
claude 459fe7a903 Fall back on invalid remote transcripts (V-675)
The owner explicitly requested direct commits on master; --no-verify bypasses the branch-only workflow hook for that instruction.
2026-08-13 01:59:56 +04:00
claude d7e8804db5 Bound LLM completion responses (V-608)
The owner explicitly requested direct commits on master; --no-verify bypasses the branch-only workflow hook for that instruction.
2026-08-13 01:58:28 +04:00
claude 240d53a96a Give the stage 0 grammar set one home (V-693)
buildRouter held the real set and baselineGrammars in eval_test.go restated it
by hand, in the daemon's order, with its own comment saying so. Three test files
score against the fixture and nothing compared the two lists. They had already
drifted: BareCaptureGrammar went into the daemon with V-557 and never into the
fixture, so every routing measurement since has scored a set nobody runs. That
is the failure CLAUDE.md warns about by name, and a diff test would have caught
it one grammar late.

The list moves to router.StageZeroGrammars in internal/router/stagezero.go, with
the ordering comments, which are the load-bearing part. buildRouter and the
fixture both call it. One list cannot drift from itself.

Measured before and after on the 96-case fixture: classifier+onnx 72/96, 75.0%
intent, 33.3% destination, identical either way, and the deterministic claim and
reach hash ratchets do not move. So the missing grammar cost no measurable
accuracy. That is the point rather than a reprieve: the fixture had been scoring
the wrong set for four days and nothing could say so.

The invariants caveat is deleted, both entries, since V-692 landed the other
guard in the previous commit. The reasoning for both now sits in docs/routing.md
beside the subsystem, which is where a fix's durable record belongs.

Unrelated and pre-existing: TestONNXPersonalBoundary fails on "я рассказывал
тебе про байкал?" (personal 0.9068, world 0.9413) at the merge base too.
2026-08-11 21:02:31 +04:00
claude d8efb667c7 Refuse a heads_path that is the embedder's own model file (V-692)
CLAUDE.md, internal/config/voice.go and docs/routing.md all say the routing
heads graph is a fine-tuned copy of the embedder, never the embedder's own file.
Nothing enforced it. The daemon loaded whatever the key pointed at, so pointing
both keys at one file cost recall with no error and no log line, which reads as
ordinary drift rather than as a misconfiguration.

validateVoice now refuses it at load. Both paths are cleaned and made absolute
first, so "./m.onnx" and "$PWD/m.onnx" are one path, and then compared with
os.SameFile, which catches a copy that is a symlink or a hard link. A path that
does not stat is left to the loader, whose error message is better than this
check can give.

Refusing to start is deliberate and it differs from the loader's treatment of a
broken weights file, which logs and leaves the heads nil on purpose. That case
is a missing accelerator. This one is a working file in the wrong role, and a
daemon that cannot route well should say so rather than answer worse.

deploy/mavend.json points the two keys at different files, so the live config
still starts.
2026-08-11 21:02:16 +04:00
claude 5596cdddbc Sign the completion and the probe with the same token (V-673)
llm.Client carries a bearer credential and sets it on the completion, and
Pair signs the /health probe with it too. An unsigned probe would answer 401,
Pair would read that as a card that is busy, and every workstation turn would
fall back to the resident model with nothing naming why.

The token comes from workstation.token, expanded from MAVEN_GPU_TOKEN like
every other secret in that file. Missing, and voicewire says so at startup:
the fallback is silent by design and this failure would otherwise be
invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz
2026-08-11 10:13:10 +04:00
claude 1f1e002789 One reader goroutine per voice conn, so a client can send and listen (V-671)
SendRequest and RunPushReceiver each read the conn, so a client that
wanted both raced for every frame. A second listening conn is not the
fix: it never sends a request, so its lastActive never moves and
PushToMostRecent never picks it. mavwaked needs both on one conn.

The reader now owns the socket for the life of the conn. It hands each
Response to whichever SendRequest waits on that id, and each Push to the
handler. SendRequest waits on its own channel, on the conn dying, on its
context, or on a timeout, and forgets its slot on every path that leaves
without an answer. RunPushReceiver just wires the handler and blocks.

Connect opens the conn without sending anything, for a client that must
hold a session before it has spoken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 13:44:52 +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 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 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 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 a1e97c94ac The workstation transcribes, homesrv is the floor (V-486)
Same arrangement as llm.Pair and for the same reason. The microphone is at
workpc, the card there has 16GB, and CrisperWhisper 2.0 turbo scores 10.4%
WER in Russian against 27.5% for the ggml-small.bin homesrv loads. The
workstation is never assumed up: it sleeps, and the card is often held.

Admission is a cached atomic written only by the prober, so no voice turn
ever waits on a machine that may be asleep.

Speech-to-text has only the silent half of the degradation rule. A worse
transcript is still a turn, so there is nothing to name a gap about and
Transcribe always falls back. That is the whole difference from llm.Pair,
which also carries CompleteRemote for callers that must refuse instead. A
remote that dies mid-request corrects the cache and falls back in the same
turn, which is what TestPairFallsBackWhenRemoteFails pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 00:42:05 +04:00
claude c7f59e48f4 CrisperWhisper reads audio over HTTP, not a socket (V-486)
mavsttd is whisper.cpp linked into a Go daemon and reached over a unix
socket. CrisperWhisper 2.0 cannot be reached that way. whisper.cpp derives
its language count from the vocabulary size, and CW2's 51897 tokens shift
seven special token ids, so it never loads at all.

So it runs under transformers on workpc and this is the client. Same
stt.Transcriber interface and one method, a second transport rather than a
second seam. The body is the PCM itself, because a minute of 16kHz mono is
under 2MB raw and the format is fixed by audio.PCM16kMono.

Audio is the most sensitive thing that crosses this seam, so the client
carries a bearer token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-09 00:41:55 +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 68a3c85186 Wire the heads between stage 0 and the resident model (V-664)
They run before the model because they are two orders of magnitude
faster and score better on both halves of the route. They decline
rather than clarify, so a declined turn carries on to the model and
then the classifier, which is what a box with no weights file does on
every turn. Nil heads are byte-for-byte the cascade that shipped
before this.

Measured on the 96-case fixture, classifier+ONNX either way:

  intent       76.0% -> 96.9%
  destination  36.4% -> 75.8%
  false clarify   0 -> 1
  missed clarify  8 -> 1
  p50          24.5ms -> 27.9ms

That beats the gemma-4-12b cascade on both halves, 84.4% and 72.7%, at
a twelfth of its 329ms. The four remaining destination misses are all
calendar, which is the stage 0 trade V-660 flagged and the owner has
not called yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:24:37 +04:00
claude 88c086482e Load the routing heads and read three of the four (V-664)
The heads trained in V-661 ran nowhere. This loads the exported graph
and reads intent, destination and clarify off one forward pass. It
declines below 0.6 max softmax rather than clarifying, so a declined
turn reaches whatever is behind it.

The slot head is exported and deliberately not read: slots already
come from the stage-2 extractor, and mapping BIO tags back to text
needs character offsets the tokenizer does not keep.

The clarify head decides on its own and decides first. It answers a
different question from the intent head, so a low intent confidence is
no reason to discard it. Reading it only above the intent threshold
cost 6 of the 8 ambiguous cases on the fixture: the word for water
reads as intent act at 0.23 and clarify at 0.98.

0.6 is the knee measured on the intent fixture: every higher value up
to 0.9 drops right answers and keeps the same two wrong ones.

The body is a fine-tuned COPY of the resident embedder and must never
replace it, because memory recall depends on that file scoring what it
scored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:24:37 +04:00
claude feabf9f350 The tokenizer read every long word backwards (V-664)
encodeWord backtracks the Viterbi path from the end of the word and
prepends each piece, which puts them back in reading order. A second
reverse after that loop undid it. So "query: вода" tokenized to
[0 12 1294 41 12489 2] where the reference tokenizer gives
[0 41 1294 12 12489 2], and every multi-piece Russian word reached the
model with its pieces in the wrong order.

Measured on the recall fixture, same 27 cases either way:

  recall@1  70.4% -> 77.8%
  recall@3  85.2% -> 96.3%
  answered after gate  63.0% -> 66.7%
  false recall  0/5 -> 1/5

The classifier barely moves, 76.0% to 75.0% on the routing fixture,
because seeds and queries were mangled the same way and cosine survived
it. Recall is where it cost, because a stored passage and a live query
are different lengths and break differently.

The embedder id now names a tokenizer revision. Stored vectors were
written under rev 1 and no longer sit in the same space as a query
embedded now, and the model file's name never moved, so nothing would
have triggered ReembedAll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 22:23:56 +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 e470435cf1 Dump the router prompt where the labeler can read it (V-661)
The training workspace labels with routeSystem and routeGrammar, and it held
its own copies. V-660 changed both. A retyped prompt drifts silently, which is
the problem llm/check_prompt_parity.py exists for on the other side.

Inert unless MAVEN_DUMP_PROMPT names a directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:44:47 +04:00
claude 3513e508b7 Give the router prompt a destination to write (V-660)
V-659 measured the destination at 12/33 on the classifier cascade and named
the gap: recall 0/15, because nothing anywhere names it. The model could not
help, for a structural reason rather than a capability one. Nothing in
routeSystem mentioned a Source and routeGrammar could not emit one, so there
was no string for it to write. Same shape as the Praxis reach V-517
measured at 0/12.

routeGrammar grows a source rule, closed over router.Sources plus the empty
floor. A grammar cannot emit a destination that does not exist, which is the
guarantee V-546 wants from a softmax and gets here for free. The prompt
lists the twelve in Russian, one line each, and says plainly that "" is a
normal answer to give often: two sources that can both answer means the
chain walks, and guessing is the failure mode this whole field exists to
stop.

The read-back goes through ValidSource and runs on IntentQuery alone. The
grammar already bounds the enum, but it is a request to a server that may be
running another build, and only a query reaches queryWalk.

Measured against gemma-4-12b on the workstation, same fixture, cascade with
a hash fallback: destination 24/33 (72.7%) against the classifier's 12/33,
and intent 81/96 (84.4%) which is where it already was. Recall is the whole
move, 0/15 to 14/15. The model alone scores 26/33.

Four cases the cascade loses and llm-only wins are calendar. The possessive
agenda rules claim them at stage 0 and deliberately name nothing, because
"что у меня в списке покупок" matches the same rule and naming the calendar
would take the list source off the turn. So stage 0's caution now costs four
destination points it did not cost before. That is a real trade and it wants
its own argument, not a quiet edit here.

The resident Qwen3-1.7B is unmeasured: it binds --port 0 inside the
container and no host process can reach it.

llm/check_prompt_parity.py in the training workspace compares its copy of
routeSystem to this one and will fail until that copy gets the same edit.
V-362 covers the catch-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:22:00 +04:00
claude b6eaa704a2 Label the destination on 33 fixture cases (V-659)
Twenty-eight existing query cases get a want_source and five new ones
arrive with theirs. Every label is the destination that SHOULD claim the
turn, which on the five new cases is not the one that did: they were
observed failing on the box on 2026-08-07, so the fixture fails on the day
it is written.

Seven cases assert the SourceUnknown floor, and six of those are homelab
operations. They cluster because SourceRecall, SourceNetwork and
SourceAttention overlap on every question about the box: mavpoll writes its
netdata and uptime-kuma observations into the fact store recall reads.
Naming one destination there takes the other two off a turn that needs
them. That is a finding about the enum, not a gap in the labelling.

The fixture's grammar mirror had drifted. WorldQueryGrammars went into
buildRouter with V-655 and never into baselineGrammars, so the fixture was
scoring a grammar set the daemon does not run — the exact thing the comment
above that function forbids. Adding it moved the destination number 9/33 to
12/33 and moved nothing else.

Measured classifier+onnx: intent 73/96 (76.0%), was 69/91 (75.8%). Four of
the five new cases pass and no existing case moved. Destination 12/33
(36.4%), and the split is the point. World is 5/5, because a stage 0 rule
names it. Calendar is 2/6, because the possessive agenda rules deliberately
do not. Recall is 0/15, because nothing anywhere names it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:05:49 +04:00
claude 2597a7b34a Score the destination apart from the intent (V-659)
The fixture measured the first half of a route and stopped. V-655 split a
routing decision in two, and the second half arrived with no fixture, so
Decision.Source had no accuracy number at all.

want_source is a pointer because the destination has three states and a
bare string has two. Absent is every intent but query, which never reaches
queryWalk. Present and empty is the SourceUnknown contract: name nothing
and let the daemon walk the chain, which is right whenever two destinations
can both answer and the utterance does not choose. Present and named is a
destination the route must produce.

A destination miss does not fail the case. It goes in SourceReason, never
in Reasons, so Accuracy and IntentAccuracy stay the numbers they were and
69/91 still means what it meant. SourceAccuracy is the second number, over
the labelled cases only, because a percentage of the whole fixture would be
a percentage of turns that never ask a query source.

A clarified or mis-routed case still counts in the denominator. It named no
destination and that is a miss, not a case to skip, or the denominator drops
every turn the route already lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 18:00:37 +04:00
claude b5500a5be8 Say where the answer lives, not just that it is a question (V-655)
A question was sorted twice. The cascade picked one of seven intents with
stage 0 rules, the resident model and the classifier behind it, a 91-case
fixture measuring it and the decision trace recording it. Then IntentQuery
handed the turn to a second dispatch in the daemon, twenty-two branches
deciding by seed similarity in a fixed order, with none of that. The careful
sorter did the easy half.

Decision grows a Source: twelve destinations, not twenty-two, because the
recall passes are one destination from the outside and so are the three world
sources. Empty is a real value and it is the floor — nothing names one, the
daemon walks its whole chain, and that is exactly what shipped before.

Stage 0 fills it where a deterministic rule already knows. Two new world rules
for the shapes measured failing on the box on 2026-08-07: "что такое TCP?" and
"кто такой Линус Торвальдс?" were answered by weather and by the personal
boundary, and "сколько будет 17 на 23?" was answered "для какого города?".
The calendar noun rule and the closed event-noun rule name the calendar. The
possessive agenda rules deliberately do not: "что у меня в списке покупок"
matches agenda-query, and naming the calendar there would take the list off
the turn.

Fixture unchanged at 69/91, which is the point — it scores intent, and none of
these cases changes intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:01:59 +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 08889cad88 Give the box a second reach (V-649)
Telegram was the only way off this box, and it is not a direct path: it
needs api.telegram.org, a socks relay on the host and a matching ufw rule.
Each of those three has failed once, and when they do a sev4 nudge has
nowhere to go. ntfy shares none of them.

The spare is the smaller half of it. The routing table already sends
sev3-away nudges and away reminders to ntfy and to nothing else, so with no
block configured those two routes hit a nil sink in DispatchNudge and
DispatchReminder and are skipped — no log line, no delivery_attempts row.
An away reminder is worse than dropped: out stays empty, so MarkReminder
never runs and it re-fires every tick without ever being delivered.

Owner's call, 07-08-2026: ntfy.kvmx.ru, topic maven.

The sink now takes a bearer token, which is what that server wants and what
it could not do before. ntfy scopes a token to one topic and to write-only,
so a popped sink can push to the maven topic and cannot read it back. Basic
auth stays for a server with no tokens; configuring both is refused rather
than resolved by guessing.

Config keys got json tags. docs/operations.md has documented this block as
base_url/topic since before it existed, and the untagged struct would only
have answered to BaseURL/Topic — the documented config would have parsed
into an empty one.

The token is a ${NTFY_TOKEN} expansion from the gitignored
deploy/telegram.env, beside the telegram secrets. TestDeployConfigLoads now
fails if the block goes missing, because deleting it is how you turn the
reach off and the two silent routes are what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 02:16:18 +04:00
claude 65ee0f9c61 Score every row, pay for only the ten that survive (V-643)
Search decoded the vector blob into a []float32 and JSON-unmarshalled the
meta map for every row, then sorted all N and threw away everything past
topK. Meta only ever matters for a survivor, and the sort answered a
question a bounded heap answers cheaper.

The scan still visits every row — that is what picks the winners. What it
no longer does is allocate for a row it is about to discard. dotBlob reads
the vector out of its stored bytes, so scoring costs nothing; a row is
copied and its meta unmarshalled only once it has entered the topK.

At 10000 rows and topK 10: 70.6ms to 26.8ms, 58MB to 17.5MB, 240k allocs
to 60k.

Recall is unchanged where it is measured. recall+onnx scores 22/32 with
recall@1 70.4% and recall@3 85.2%, identical to before.
TestMemoryStoreSearchMatchesNaive pins the ranking against the full-sort
implementation it replaced, and TestDotBlobMatchesDot pins bit-identical
scores, which the 0.008 gate margin demands.

One behaviour did move: ties. sort.Slice is not stable, so equal scores
were ordered arbitrarily; the heap now keeps the earliest. Under the real
embedder an exact tie is a duplicate vector and nothing moved. Under the
hash embedder the eval's floor uses, everything ties at 0 and that run's
recall@3 went 74.1% to 81.5% — a number that measures tie order, not
retrieval. recall@1 and false recall, the two the eval asserts, are
unchanged on both runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:05 +04:00
claude 76938e206d Put a number on the recall scan before changing it (V-643)
MemoryStore.Search is on the per-turn recall path and had no benchmark, so
any claim about its cost was an argument rather than a measurement.

Seeds a store with rows the shape recall actually stores — 384-wide
vectors, the resident embedder's width, and a meta blob carrying the note
text — at 1000 and 10000 rows. 10000 is the ceiling the type doc claims a
full scan is fine at.

Measured as it stands: 5.3ms and 24k allocs at 1000 rows, 70.6ms and 240k
allocs at 10000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:05 +04:00
claude 4be6852b94 Drop host rate-limit entries that can no longer delay anything (V-641)
webfetch.Fetcher.last held one entry per distinct host the crawler ever
dialed, never pruned. Bounded in practice by how many hosts get crawled, but
crawl.on_demand is true in deploy, so the host set is whatever he names out
loud.

An entry older than HostInterval cannot delay a request — waitTurn would let
the next one straight through — so it is dropped. The sweep runs on write and
only once the map passes 64 entries, below which walking it costs more than
the entries do.

Rate limiting is unchanged: a host dialed inside the interval is kept, which
the test asserts, because pruning one would hand out a free turn.
2026-08-07 01:32:26 +04:00
claude f7b76c572f Bound the undated-item set per feed (V-641)
rss.Poller.seen held every undated item ever seen, one entry per id, for as
long as mavend ran. fresh() added and nothing removed. A feed that ships items
with no <pubDate> grew it forever.

seenIDs is the same set with a bound: the map answers the lookup, a slice
remembers insertion order, and the oldest id falls out past 512. The cap has
to stay above any one feed's front page or an item still listed there would be
written a second time, and a few hundred covers the largest page anyone
publishes. The set only ever had to span one poll window plus the resync
guard, not all of history.

Dedupe behaviour is unchanged. The comment at fresh() explains why the set
does not survive a restart; it never bounded it within one run.
2026-08-07 01:32:15 +04:00
claude af4eeceb6a Keep the store's one connection, delete the seam it cannot survive (V-642)
`SetMaxOpenConns(1)` under WAL gives up concurrent reads, and the task
asked whether that costs anything. Measured over a fixed two-second
window, a paced writer against a read loop, three runs per cap:
reads do not queue. Four connections buy 70µs at p50 on a turn that
spends 1.19s in the resident model, and write throughput more than
halves. A 19ms worst case also cannot be the source of the 2.7s router
figure, so that line of enquiry is closed.

What the cap cannot survive is a long-lived transaction. It holds the
only connection, so a second read never completes: two seconds and
`context deadline exceeded`, against 1ms at a cap of four.

`Store.DB` handed out exactly that transaction. It had been there since
the initial commit with no production caller, and its comment described
a loop that never materialised. Its one user was a test helper reading
`delivery_attempts` by raw SQL, which `ListDeliveryAttempts` has covered
since V-390. So the cap stays and the seam goes, and the hazard is gone
by construction rather than by documentation.

`internal/store/conncap_test.go` stays as the standing measurement,
skipped under -short. The comment at the cap and the one in
`internal/ipc/server.go` that leans on it now state the invariant and
cite the numbers.

Measurement: docs/evals/2026-08-07-store-connection-cap.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:01:27 +04:00
claude 9d58922462 Refuse a telegram intake chat id the poller cannot match (V-646)
The push half accepts an @channelusername and the intake half cannot: an
inbound update names its chat by number, so an @-name matches nothing. The
check lived in NewPoller, which wireTelegramIntake logs and returns from, so a
box configured that way booted clean with a dead intake half and a working push
half. Nothing looked broken from the chat.

ValidateIntakeChatID moves the rule where config validation can reach it, the
same shape validateNetScan uses. It is stricter than the old prefix test: any
non-digit is refused, not just a leading @. An empty token or chat id still
means telegram is not wired, because an unset ${TELEGRAM_*} expands to empty
and that must not fail a box with no bot.

deploy/mavend.json turns intake on. The chat id on this box is numeric.

The onCallback comment claimed every path answers the callback. The fromOwner
early return does not, and silence toward a stranger is correct, so the comment
was what was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:14:42 +04: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 06c1cf247e the intake allowlist has to be a numeric chat id (V-637)
Two defects my own review found.

The push half accepts @channelusername as a destination. The intake half
cannot: an inbound update names its chat by numeric id, so that config would
read the chat, match nothing, and answer none of it. Refused at NewPoller,
which turns a dead reach into a line in the log.

And getUpdates returns at most 100 updates per call, so one call was not the
backlog. The skip loops, bounded at ten rounds rather than until empty, so
an instance that keeps handing back a full batch cannot spin.
2026-08-06 21:01:16 +04: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 0a5211b038 tests for the inbound telegram poller (V-637)
The cases that matter: the turn runs with the chat as its dialogue id, the
reply carries the gesture, a turn nothing persisted carries no buttons, a
stranger gets no answer at all, the first tap writes nothing, and a write
that failed says so on the button instead of going quiet.
2026-08-06 20:53:48 +04:00
claude 38be702188 a fake bot API to test the poller against (V-637)
An httptest server that hands out one batch of updates per getUpdates call
and records everything else, plus a recorder for what the poller asked the
daemon to do.
2026-08-06 20:53:48 +04:00
claude d42372e996 the poller reads one chat and answers in it (V-637)
Long-poll getUpdates rather than a webhook: the box takes no inbound
connections and reaches telegram through a relay, so the direction has to
stay outbound. A failed poll waits and retries, because the relay going
down is the normal cause and it comes back on its own.

The backlog is discarded on start. Telegram holds undelivered updates for
24 hours, so a daemon that was down overnight would otherwise answer every
question in order, and a reminder set from an eight-hour-old message lands
at the wrong time. Missing it is the safe direction.

ChatID is the only accepted sender and anything else is dropped without a
reply, because a reply confirms the bot exists and whose it is. Chat ids are
not guessable but they are not secret either, so that is the whole
authorisation and it is an allowlist of one.
2026-08-06 20:52:34 +04:00
claude 45231ba69e the bot API calls the inbound half makes (V-637)
getUpdates, sendMessage, answerCallbackQuery and editMessageReplyMarkup,
plus the inbound shapes cut to what the poller reads. Every error goes
through the sink's redaction: the token is in the URL path because telegram
accepts it nowhere else, and net/http prints that URL on a transport
failure.

Only ok=true is a success, the same rule the push half already applies. A
relay that is up but cannot reach api.telegram.org answers 200 with an HTML
page of its own, and reading that as a batch of updates would be silent.

A chat id arrives as a number for a user and a string for a channel, so it
is held as json.Number and never converted.
2026-08-06 20:52:34 +04:00
claude 42c7b8b927 the correction gesture, as two taps in a chat (V-637)
Config gains an intake flag, off by default, and sendMessageReq gains the
inline keyboard the intake half hangs under a reply. The gesture itself is
the web's, ported: one button says the turn was wrong, and it opens the
seven intents rather than writing the negative straight away, because the
target is worth much more and he must still be able to decline naming one.

Button data comes off the wire, so parseCallback refuses an id it cannot
parse and a target that is not one of the seven. A label nothing can score
is worse than no label.
2026-08-06 20:52:19 +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