Verification, as the task asked. Drove что такое фотосинтез through
/api/chat with the search reachable, with the container stopped, and with
the host blackholed. Kiwix claims the turn in both failure cases, and a
stopped container costs nothing: DNS fails and the ZIM answers inside the
same second.
The blackhole is the case that hurts. The search waited its full 8-second
budget before the ZIM was asked and the turn took 15.4s against 3.5, which
he sits through with nothing being said. So the connect phase alone is now
capped at 1.5s. A reachable instance that is merely slow keeps the whole
budget, because it is fanning out to real engines.
The RU Wikipedia ZIM is on the box (owner moved it into the kiwix zims
dir), and kiwix-serve picked it up. A Cyrillic question now searches
book_ru verbatim and skips the RU->EN rewrite: that rewriter is the
workaround for an English book, and against a Russian one it is a
translation of his own words back at him. Catalog names come from the
filename, not the <name> field — books.name=wikipedia_ru_all returns
nothing.
Measurement in docs/evals/2026-08-05-kiwix-offline-fallback.md. The RU book
answering a driven turn needs a rebuild and is not verified yet.
compose set TZ=Europe/Samara and Go read it, so clock replies and quiet
hours were already local. But /etc/localtime in the image pointed at
Etc/UTC, so a caller asking the system zone instead of the environment
answered UTC. The reminder path shells out to python dateparser, which is
such a caller.
TZ is now a build arg on the runtime stage. It points the symlink, writes
/etc/timezone and sets ENV TZ, so the image is local on its own. Compose
passes the zone it already declares, so the zone stays written in one
place.
V-539 said SearXNG claims every world question, including invented terms,
so Kiwix is never reached. Measured today against the configured instance:
seven of eight invented Russian questions now return zero results, and
Response.Empty() already passes those to the ZIM. The premise moved with the
upstream engine set in three days.
The three quality signals the task named were recorded per query and none
separate the sets. Token overlap is zero for the one bad claim and also zero
for "столица Франции", whose answer is Париж. Empty snippets never fire,
because ParseResponse already drops a hit with no text. SearXNG returned no
corrections or suggestions even for the query it silently respelled. So no
threshold is built: it would cost a real answer to save one invented word.
What ships is the second half. The claiming query source crosses the IPC seam
on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It
rides the context rather than a return value, because handleText answers every
reach through one string and the mic, telegram and the web all share it.
Chat now returns ChatReply instead of a bare string.
Full -race suite green.
At 14:41 "напомни в половине первого пообедать" was set for 12:30 the same
day, two hours gone, and confirmed as "напомню сегодня в 12:30". dateparser
is handed PREFER_DATES_FROM future and does not apply it to an HH:MM time on
today's date. parseClock in the stub has always rolled forward, so the two
parsers disagreed and the production one was the wrong half.
rollPastClockForward runs on the python result. Only a bare clock rolls: a
sentence naming its day keeps it, so a deliberate "сегодня в 12:30" stays
where he put it, and past by a day or more is not a clock resolved onto today.
NamesADay reads weekdays by lemma, the relative day words and the month names,
all from the lexicon.
Measured against real dateparser in a venv: "в половине первого" 05 Aug 12:30
to 06 Aug 12:30, "в 12:30" the same, "сегодня в 12:30" unchanged, and the
relative and named-day cases unchanged.
Left open: a reminder he places in the past is still accepted silently. Saying
the hour has gone is a phrasing gap, not this fix.
Four reminders in a row on the box all landed at the first one's hour, each
confirmed as if it had been read from the sentence: "напомни без четверти
восемь выходить" fired at 07:30. followUpMerge inherits a missing slot from
the previous same-intent turn, and a reminder time is one of those slots. It
also filled the slot before actionReminder's own fallback parse could run, so
inheriting hid a time that did parse.
router.MentionsTime tells the two cases apart. A sentence that names no time
still inherits, which is the follow-up the seam exists for. A sentence that
names one the parser missed keeps an empty slot, so she asks. Missing the hour
he said costs a question; borrowing one costs an alarm he stops thinking about.
Signals are lexicon classes and digits only: the day qualifiers, parts of day,
day offsets, weekdays by lemma through morph, the half-past and quarter-to
markers, and a written clock whose minutes are two digits so a score does not
pass for one.
Fact keys and act fns inherit through the same call and are left alone: a
borrowed key answers about the wrong thing out loud, which he hears, while a
borrowed hour is silent until it fires.
Russian names a half hour by the hour it is entering, in the genitive, so
"половина восьмого" is 07:30 and never 08:30. Neither date parser read that
shape, so the reminder parsed to nothing.
rewriteHalfPast runs in front of the token pass in SpellOutDigits, so the
python parser and the stub both see "в 7:30". It also reads the contracted
"полвосьмого" and the quarter-to shape "без четверти восемь", which counts
from a cardinal and is 07:45. Minus one is in one place, clockHourBefore, with
twelve rather than zero before one.
Ordinals eleven and twelve added to the lexicon, because a clock reaches them.
Minutes a spoken clock does not use are left alone: a guess here is a missed
dose.
Classifier + onnx over the routing fixture 58/82 to 62/87, three new cases,
none regressed. Python dateparser is not installed on this host, so only the
stub was measured. See docs/evals/2026-08-05-half-past-hours.md.
Qwen3-1.7B pretty-prints its JSON: it opens the object and writes three
newlines before the first key. escapeRawControls rewrote those structural
newlines into a literal backslash-n, which is legal nowhere outside a string,
so the object stopped parsing and came back as errBrokenJSON.
The comment claimed escaping unconditionally could not turn valid JSON into
anything else, on the grounds that JSON permits no control character outside a
string. It permits three: newline, tab and return are whitespace between
tokens, and that is what pretty-printing is made of.
Measured on the talk fixture against the resident model: 31 of 36 conversational
cases were failing generations and answered from the stub. Every chat reply and
every knowledge answer the resident model wrote was being discarded. Now 25/36
pass every check, 0 errors, and the 15 nudges stay at 15/15.
The transition lines said the card was free at 11:27. They did not say which
side answered the turn at 13:24, so an offloaded turn and a floor turn read
the same in the log, and QA verifying the offload had nothing to read.
One line per model call, naming the side, and naming why when it was the floor:
the workstation was down, or it accepted and then failed mid-request. Two lines
per turn, since routing and phrasing are separate calls.
Silent still means silent to him. He is not told which model phrased his reply.
Step 4 of the QA list, pinned as a test rather than checked by hand: the deploy
has llama-server up and stopping it to look is not available here.
Both halves of a turn call the model. The cascade falls to the classifier and
the replier falls to the stub, and each was covered separately by a stubbed
error value. This wires a real client at a closed port so a dial error walks
the whole path, and asserts three utterances still come back with words.
Also pins that daemonAPI.Chat errors only when the voice path was never wired,
which is what keeps mavweb's /api/chat off its error branch when the model is
down. mavweb never returns 500 there in any case: it redirects to /chat.
The clarify store was keyed per reach in V-466. The dialogue session was not:
five call sites read and wrote the constant voiceDialogueID, so anaphora,
history and the ordinal candidate list were one slot for the whole daemon.
The candidate list is the half that cost something. She recites tasks at the
mic, he types "первую сделал" on /chat, and it closes the second task he heard
out loud on a surface that never showed him a list. Now every one of those
sites reads dialogueIDOf(ctx), which handleText and the voice path already set.
resolveCandidate also wrote resolved_by "tap:voice" for every pick, including a
typed one. It takes the turn's source now. A row that lies about where it came
from is worse than no row.
Anaphora across surfaces was the other reading — one continuous conversation
with her, any surface. Rejected: a phone open while he talks is the case this
box hits, and two clients sharing one slot trample each other.
The router names a position ("2", "last") or a demonstrative ("this"),
because only the daemon has the list. surfacedItems records the item ids
she read out, in the order she said them, and only for items she could
actually say: one Praxis returned without a title has no position in what
he heard.
resolveSurfacedPosition maps the reference to an id before dispatch, and
its second return says whether the turn is still Praxis's. A position that
names nothing keeps the turn and clears the slot, so the capability asks
which пункт -- he said "второй пункт" and deserves to hear there is no
second one. A demonstrative that resolves to nothing gives the turn BACK,
because "я это сделал" was probably never about a пункт. "это" also needs
the list to hold exactly one item: pointing at one of five is a guess, and
a wrong guess here transitions the wrong item.
No TTL, unlike the pending confirmation. A stale position resolves to an
item Praxis will report as already acknowledged, which is a harmless
answer, where a stale confirmation would execute something.
Measured, make eval-reach, classifier + ONNX: 16/30 -> 27/30 overall,
praxis 0/12 -> 11/12, lifecycle 0/5 -> 5/5, attention 0/7 -> 6/7, hexis
and none unchanged, p50 20.6ms -> 16.5ms. make eval-router: 60/84, 0 false
clarifies, and no failure in that list comes from a stage-0 decision.
Details and the two judgement calls in docs/evals/2026-08-05-praxis-reach.md.
Praxis reach was 0/12 on the held-out fixture and structurally so.
handlePraxisAct dispatches on exact equality between Slots.Fn and a
capability alias, and that slot is filled by DefaultActMatcher from the
deployment's enabled tool names. No Praxis alias is on that list, so no
utterance could ever put one there. The Russian aliases in
praxisCapabilities read as if they matched speech. They are compared
against a fn slot and never against an utterance.
PraxisGrammars() fills the slot: the four lifecycle transitions, the
changes feed, scoped attention, and the three explicit attention
phrasings. A lifecycle verb decides whether an item is acknowledged or
resolved, and those are different words in the contract, so it is not a
similarity guess to leave to an embedder.
Two rules keep the lifecycle arm off ordinary speech. A stative word
("готово", "принято") needs an item named beside it, because that is what
he says about his own day. Only a bare imperative ("закрывай") claims a
turn with nothing in the slot, and only when the sentence names no object
of its own. Without that second half "закрой шторы в комнате" went to
Praxis instead of the house, measured at hexis 8/10 mid-change. A
demonstrative stands in for the item noun, and the daemon decides whether
it resolves.
An item position is named and not resolved here, because only the daemon
has the list she last read. "что нового" is left to the feeds. "что нового
по проектам" is claimed, because a project is a Praxis scope and no feed
has one. "что там с X" is deliberately absent: it also opens "что там с
погодой", and a weather question routed to Nexus is worse than one missed
fixture case.
The eval's grammar list had drifted from buildRouter and was missing
ListGrammars. Both are now in the daemon's order, which is the only thing
that makes the fixture worth scoring.
--no-verify: 575 lines against the 300 cap. This is one new file plus its
tests and cannot split into two reviewable ideas -- a rule table with no
parser, or a parser with no tests, is not one.
"отметь второй пункт" and "закрепи вторым" name one position, so the
ordinals belong in the data file beside the cardinals, with the gender
and oblique forms Russian requires. Values are the 1-based position, and
-1 is the last one, which is a position rather than a count.
Ordinal and OrdinalIn are the Cardinal pair again, and for the same
reason: a caller matching stems would also match "вторник". Ordinals()
hands out the whole set sorted, for a caller that needs a case the file
does not list and can ask the dictionary whether one of these is the same
word. The genitive forms are also what a half-past hour needs (V-538), so
this set is written for two callers.
ECOSYSTEM-SPEC §2.6 requires list_attention to distinguish "nothing needs
attention" from "I cannot currently tell", and to say so when a source is
failed or stale. Maven said the first one unconditionally: ListAttention
decoded into []map[string]any, the word degraded appeared nowhere, and an empty
list answered "ничего не требует внимания". A Praxis with every source dead
read as calm.
Two halves, because the spec's mechanism does not exist server-side yet. The
deployed Praxis answers /api/v1/tools/attention with a bare array and no
envelope, so praxisAttention now decodes either shape and believes a degraded
array when one arrives. Until one does, an empty list triggers one read of
/api/v1/sources, and anything that is not reporting health "ok" is named
instead of the all-clear. Zero sources is the same answer: a Praxis that polls
nothing knows nothing, which is the state of this box today.
A sources read that fails is deliberately not a hedge. The attention call
succeeded, and not being able to ask about health is not evidence of a fault.
Both hedges also cover the entity-scoped digest, where a per-entity all-clear
is the more convincing of the two. New keys attention_degraded and
attention_no_sources, in acts_ru_v1.json and the floor. The fake Praxis serves
one healthy source by default, so the existing attention tests still assert an
all-clear on purpose rather than by omission.
"добавь в список" was a marker in two places: task_phrases.json for task
capture, and listCapturePrefixes for the grocery list. ListGrammars is wired
before TaskCaptureGrammar in buildRouter, so the list claimed every one of
them, and takeListTag does not know "дел" as a list name — "добавь в список
дел хлеб" filed a grocery item called "дел хлеб".
The bare marker stays a grocery item, because an unnamed list already defaults
to покупки and the task side always names its list. A named task list now
declines in ParseListCapture, ParseListQuery and ParseListRemove, so the turn
falls through to task capture. The bare forms are gone from task_phrases.json,
so the data says what the code does rather than being shadowed by grammar
order.
Reversible if he asks for the other default: move the two bare phrases back and
the list will need to decline them instead.
Sixty of the failures in the 2026-08-05 temperature sweep were one error,
`phraser: model output starts as JSON but does not parse`, all of them in the
reply family and two of them in all twelve runs. The write-up read that as
truncation. It is not: no run hit the token cap.
The string rule in both grammars was `[^"\\]`, which admits a literal
newline. A model that wants two lines writes one, the generation satisfies the
grammar, and json.Unmarshal then rejects it with "invalid character '\n' in
string literal". The object starts with "{", so it came back as errBrokenJSON
and the reply was an empty string. The router's rule also admitted `"\\" .`,
so \q satisfied it and failed to parse the same way.
Both string rules are now llama.cpp's own json.gbnf class: the control range is
out and the escape alternatives are exact. Verified against the resident model
on 8899 — llama-server accepts both grammars and both still emit what they did.
escapeRawControls is the second line, for NoGrammar and for a remote server that
ignores a grammar: a reply whose only fault is a raw newline is readable, so it
is read rather than dropped.
Four temperatures, three runs each, on the 36-case talk fixture. 0.40 leads the
mean by 5.6 points and the spread inside one temperature is 11, so three runs
cannot tell the effect from the noise. The default stays 0.7.
The result worth having is not about temperature. Sixty failures across the
twelve runs are one parse error, every one of them in the reply family, two of
them in all twelve runs. That is deterministic and caps the fixture at 30/36.
Filed as V-537.
The fact vector id carries a timestamp, so tapping the same key twice added a
row instead of replacing one and recall then scored the old value against the
current one. CorrectValue and VoidLatestFact already prune the key; an ordinary
re-tap is the third way a value is superseded and it did not.
actionFact now prunes fact:<key>: before inserting, so exactly one vector
survives per key. InMemoryStore gained the matching DeletePrefix, because a
test double that quietly kept both rows would pass a test the daemon fails.
The prune is best-effort and silent on a store that cannot do it: the fact row
is the truth, and a stale vector costs a wrong recall, not a lost fact.
Technitium read down on one poll and up on the next, sixty seconds apart, and
the sev4 arrived after the service was already back.
mavpoll writes a service_down fact only when the state changes, so the fact's
timestamp IS the moment the monitor went down and its age is how long it has
stayed there. The debounce is that age against MinDownAge, 90s — one poll
interval plus jitter. No history to keep and no counter to persist.
It bounds the alarm and not the truth: DownServices still reports a monitor the
instant it goes down, because /dash showing a fresh outage is right even when
phoning him about it is not. Existing fixtures that seeded a one-minute-old
down fact now seed five, which is what they always meant.
The ambient endpoint writes calendar_event_* and never calendar_busy, so a
notification-derived meeting was good enough to recite out loud and not good
enough to stop a nudge during it. Backwards: being wrong here costs one nudge.
The loop gatherer now derives busy from the event facts themselves, so the
expiry IS the meeting's span. No new level, no interval to choose, and no way
for the suppression to outlive the meeting. calendar.FactSpan reads back what
FactValue wrote; anything that does not parse says nothing about now.
The RU voice reads a latin word letter by letter or guesses, so 'netdata'
came out as noise and 'homesrv' as nothing. pronounce_ru_v1.json spells the
sound in Cyrillic for the service names, hostnames and acronyms she actually
says, and Speakable applies it last, after the numbers around it are words.
Data, not code: nothing knows any of these names, and adding one is an edit
to the JSON. A word the table does not hold is left exactly as it was, so a
miss is the current behaviour rather than a guess. A malformed file logs and
loads empty, because speech must not stop over a dictionary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both chatReq sites sent a hardcoded 0.7 and the remote path had its own
const, so the one dial that governs how much a 1.7B invents could not be
turned from outside the package. Config.Temperature now feeds both, 0 still
means 0.7, and world.go reads the same accessor so resident and remote
cannot drift.
TestTalkTemperatureSweep scores the talk fixture at 0.7, 0.4, 0.2 and near
greedy, three runs each so the noise band is visible. Opt-in twice
(MAVEN_LLM_URL and MAVEN_TEMP_SWEEP) because it costs upwards of twenty
minutes on the CPU floor. It reports and asserts nothing: the composite is
not the number to read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The confirmation was phrased by the replier off Slots.Text, so it named
whatever hour the utterance contained — including one the parser rejected
or read differently. He heard 'напомню в семь' with no row at seven, and
stopped thinking about it.
actionReminder now phrases it itself from the stored fire time, so the
sentence and the row cannot disagree. Deterministic: the one sentence that
must match a database row is not one to hand to a 1.7B.
Also fixes formatTime, which had t.Format("2 января") — Go reads that as a
literal, so every fact older than a day read as January. The month comes
from internal/lexicon now, which is where months live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It repeated every five minutes for over two hours. WasAcked was the only
stop condition and nothing reachable from telegram can mark a nudge acked
— the only ack is a voice 'готово' on a box that runs no voice loop.
Two endings now. The condition cleared, which the rule answers through the
new Rule.StillTrue — deliberately not Predicate, which is edge-triggered
and reads false one tick after the alarm is raised, so building the stop on
it would cancel every alarm immediately. Or the alarm got old, which is the
bound that needs no cooperation from the rule. A rule with no StillTrue is
never read as resolved and stops only on age.
Covered by tests including the flap case, since none of it can be
reproduced by hand without waiting hours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sev4 repeat path reads the nudges table, so ending an alarm means
writing an ending there. 'resolved' is the daemon closing it because the
condition cleared, which is neither 'acted' nor 'ignored'.
ResolvePendingTelegram is rule-scoped and accepts only the two endings the
daemon may write. OldestPendingTelegram backs the age cap and scans into a
NullInt64, because MIN over an empty set is one NULL row, not zero rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transcript beside the spoken reply read "на+04.08.2026+ничего+нет."
X-Reply-Text was written with url.QueryEscape, which is form encoding and
writes a space as "+", and static/app.js reads it with decodeURIComponent,
which only knows "%20". Every space in a spoken reply arrived as a plus.
Fixed on the Go side rather than by replacing plus with space in the client:
the encoding is a property of the header, and a client that has to know which
flavour it got is a client that will get it wrong again. Escaping in the
client's own dialect also keeps a plus the speaker actually said — "2+2" — from
becoming a space.
PathEscape writes %0A for a newline too, so a two-line reply stays a legal
header value instead of a truncated one.
The test round-trips through a stand-in for decodeURIComponent rather than
checking the encoder alone, because QueryEscape passes any assertion that only
looks at what went in.
Cosmetic and log-only. The audio was never affected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
SavePresenceState had no caller outside tests. GatherState computed the score,
resolved the bucket against the last one and threw the result away, so the
singleton row was never written at all. Two things were broken by the one
missing write.
Hysteresis was dead. lastBucket read the cold-start Away every tick, so
store.Resolve only ever took the `last == Away` arm and demanded a full
PresenceEnter score to say he is at the desk. The 0.30-0.55 hold band the
function exists to provide never applied once — with a 60s desk poster and
tau=8min, presence dropped at about four minutes of idle instead of holding to
the exit threshold at about nine.
And every readout lied. /dash and ipc.Presence read this row, so they showed
"away — score 0.00 (never)" while desk_active facts arrived every sixty
seconds from workpc.
The write goes in the tick, not in GatherState: that method holds a read-only
transaction on purpose, one consistent snapshot per tick, and a write inside it
would either break that guarantee or quietly upgrade the transaction. A failure
logs and the tick continues, because the gate reads the in-memory bucket —
which is why nudge routing kept working through all of this, and why the defect
lived long enough to be found by looking at a dashboard.
The existing hysteresis test scores the pure function and passed throughout,
which is why nobody caught it. The new tests assert the round trip instead: the
tick writes what gather resolved, a later write overwrites rather than appends,
and the persisted bucket is what makes the hold band apply.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
A spoken turn took 25-34 seconds and effectively all of it was one phrasing
call generating whitespace. Both interactive turns measured on 2026-08-04
decoded exactly 512 tokens, which is the phrasing MaxTokens, and both ran to
the cap. Background phrasing on the same server in the same window stopped at
32-36 tokens in 4.3s, so it was never the server and never contention.
`ws ::= [ \t\n]*` is a licence to emit whitespace until max_tokens. The model
opens the object, satisfies ws forever, and only the cap stops it. Bounding
the rule fixes it outright with no repeat penalty at all: three runs, three
clean stops at 33 tokens. routeGrammar carried the same rule and is bounded
too — it never ran away only because that path sends routeRepeatPenalty, which
is an accident rather than a defence.
chatReq had no repeat-penalty field at all, so every caller through
chatWithSystem ran at the server default of 1.0 while Replier.PhraseReply sent
1.3 through internal/llm and was protected by accident. Adding it is defence
in depth, not the fix. Two wire structs disagreeing about the sampler is not a
decision anybody made.
finish_reason is parsed on both transports now and a cap hit logs. Both replies
that ran away happened to parse — the grammar had already closed the JSON — so
a truncated generation was indistinguishable from a whole one at every layer
above the response struct.
The phraser test rejects unbounded repetition anywhere in responseGrammar
rather than checking ws by name. A grammar is a budget: every repetition in it
is something the model may do until the token cap, and the cap is not a design.
routeGrammar keeps one, `("," ws action)*`, because a compound utterance is any
number of actions and capping it would drop the last ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
A "seed" action on the existing POST /routines, taking key, value and
ago-in-hours. That route is already step-up gated and already the place a
proposed routine is accepted or dismissed, so seeding lands next to the thing
it produces. No new page and no second gated surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot