Commit Graph

241 Commits

Author SHA1 Message Date
kami 92d2629001 Merge pull request 'Voice cannot accept a routine (V-367); the last three prompts are Russian (V-404)' (#89) from fix/367-voice-parks-routine-accept into master 2026-08-02 11:39:16 +02:00
claude bb8cb8d014 Voice parks a routine proposal, it never accepts it (V-367)
Accepting a proposed routine gives the tick loop a standing new reason to
speak. DESIGN.md § "surface caps authority" puts that at layer 3, and says
voice is structurally incapable of layer 3 because a room mic is reachable
by anyone in the room. The /routines button was gated at step-up; the voice
path accepted outright. The two surfaces disagreed, so one of them was wrong.

A spoken "да" now leaves the row 'proposed' and sends him to /routines,
where the gated button is. A spoken "нет" still dismisses: declining does
not move the boundary outward, so voice keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc
2026-08-02 09:58:07 +04:00
claude 5e66aa8f22 fix: the slots converter was still dropping the fact Value (V-365)
dialogue.Slots gained Value in 925ce22, but toDialogueSlots never copied
it, so a clarifying answer carrying a fact payload still landed nowhere:
clarify.go:202 sends the answer through the converter, and the SlotValue
arm reads answer.Value.

Both converters now carry every field. TestSlotsParity compares the two
field sets by name and type; TestSlotsRoundTrip populates every router
field and checks the round trip, and fails the fixture itself when a new
field is left zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QChoBS5qJSrCV98oNUnHNU
2026-08-02 09:35:47 +04:00
claude 4f34a232d4 docs: retire the two root queues, archive the ranking (V-447)
PROGRESS.md and 20-07-2026-BACKLOG.md were state snapshots that git log and
the Vikunja board already carry. Everything PROGRESS.md claimed as shipped is
a QA task. The backlog's only untracked item, bounded follow-up state, is now
V-448.

maven-feature-ranking.md moves to docs/archive/2026-07-03-feature-ranking.md
instead of dying. Its mandatory and easy tiers became V-449 through V-458; the
doable and epic tiers are reasoning about why things are not worth doing yet,
which no task captures.

Four code comments and the design.md ledger pointed at the deleted files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xwomr2cT93KMSz9u3digX5
2026-08-02 09:11:11 +04:00
claude 93987f2dfc docs: tier the tree by lifetime, so staleness shows in the path (V-446)
Seventeen markdown files at the repo root, twelve of them dated one-shot
reports sitting next to CLAUDE.md. That is why stale docs read as
current: nothing in the path said which was which.

Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/
and carry a Last verified line. Dated measurements move to docs/evals/
ISO-prefixed, and are never edited after the day, so a newer number is
a new file. The senior review moves to docs/archive/.

Every reference was rewritten across markdown, Go comments, the Makefile
and the recall fixture. The touched Go packages still build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:28:49 +04:00
kami 612ca8cf1b grammar: the last two model calls that were still free text
The replier and the meeting summariser were the two call sites without a
GBNF. Both are exactly the shape that makes a Thinking variant answer with
its reasoning as prose, and neither had anything downstream that could
remove it.

The replier already parses {"response","mood"}, so it now sends the phraser's
grammar for that contract, exported once as phraser.ResponseGrammar so the
two definitions cannot drift.

The summariser stays text-in/text-out. The JSON wrapper is attached and
unwrapped in the daemon's Completer, so internal/capture is unchanged and a
Completer without a grammar still works.

The simulator told routing from phrasing by "has a grammar", which stopped
being true here; it now looks for the intent enum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:28:53 +04:00
kami 14e98334ad query: let her search the live web before she reads the ZIMs
The offline encyclopedia was the only world source, and it reads what was true
when the ZIM was built. A self-hosted SearXNG now asks first and Kiwix is the
fallback for an empty result, an unreachable instance or no line out. Owner's
ruling, 2026-08-02.

internal/websearch is deliberately thin: no rewriter (SearXNG ranks through
real engines, so the Russian question goes out as he asked it), no page fetch,
no cache. It cannot read the store, so only the query string can leave the box.

The personal boundary is unchanged and still sits above this source, so a
question about him is never searched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaeSbLMEVG5ey8tejU3y2
2026-08-02 02:24:36 +04:00
kami a103708a08 memeval: five minutes again, now that the gate keeps a turn from waiting
The budget was cut to 60s because a five-minute evaluation held the single
llama-server slot, and a voice turn arriving mid-evaluation waited behind it.
That collision is now solved where it belongs: the background client yields the
slot while a turn is in flight.

With the gate in place the short budget only truncates a Thinking model
mid-synthesis, which costs an observation and saves no latency on any real turn.
Kami's call, 2026-08-02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaeSbLMEVG5ey8tejU3y2
2026-08-02 02:16:45 +04:00
kami b8227295b8 query: stop asking the world about him
"во сколько у меня встреча" fell past the calendar, reached Kiwix, matched
an article on the 2015 CPISRA World Games and came back phrased as his
meeting. Inventing is worse than refusing, and as `system` this used to say
"пока не умею".

A new source sits between the notes pass and Kiwix: a question carrying a
possession marker ("у меня", "мой", "my", "do i have", "did i") that his own
data did not answer has no answer outside it either, so the walk stops there.
The markers are possession, not first person, so "как мне сварить борщ" still
reaches the encyclopedia.

This is also where CLAUDE.md's privacy line lands: only the utterance may
leave the box, and a question about him carries his life in the utterance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 23:27:04 +04:00
kami b35151418a mavend: give the voice handler a CoreAPI that can serve the day plan
wireVoice runs before the tick loop exists, so it could only be handed
the bare store adapter — and that adapter answers DayPlan with "not
available via direct store API", because a day plan is assembled by the
tick loop and is not a table to read. So queryDayPlan, which the query
chain reaches for "какие у меня планы на сегодня", failed for every
caller on the deployed daemon.

main already back-patches the other direction (daemonAPI.chatFn =
handler.handleText). This is the same seam in reverse, at both wiring
sites. No recursion risk: nothing in the voice path calls api.Chat.

With the plan reachable, it recited its reminders as literal JSON. The
payload unwrapper existed but was private to the phraser, so the day
plan had its own non-unwrapping copy. One owner now, store.ReminderText,
with the phraser delegating to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 23:17:10 +04:00
kami 17964d1162 dialogue: keep the remembered topic on the latest turn
rememberTurn runs after followUpMerge, which has already inherited a
Text slot from the previous same-intent turn, so the fill-if-empty rule
pinned the first topic of a run of query turns and never released it.
"во сколько у меня встреча", then "какие у меня планы", then "а завтра?"
continued the meeting — two turns stale.

Overwrite for system and query, where Text is a topic and not a payload.
A continuation is the exception and keeps what it inherited: its own
utterance is the ellipsis, and the topic it carries is the real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:57:08 +04:00
kami 079cf689aa router: agenda questions belong to query, not to system
"что у меня сегодня" and "что у меня в календаре сегодня" both routed
IntentSystem on the deployed daemon, and replySystem has no agenda arm,
so both answered "пока не умею". The calendar source that can answer
them lives in the query chain and was never reached. The fixture has
said query since ru-query-019 was written; the daemon disagreed with the
fixture and the daemon was wrong.

AgendaQueryGrammars routes them at stage 0, after the clock rules so
"какой сегодня день" keeps reaching replySystem. Intent only — which
source claims the turn stays the query chain's decision.

This is what made the follow-up continuation look like it only worked
for "what day is it". It did: the query half inherited an intent whose
handler could not answer, so both halves came back "пока не умею".

Measured on the 77-case RU fixture: full accuracy 70.1% → 72.7%,
intent-only 75.3% → 77.9%, calendar 0/2 → 2/2, clarify counts unchanged.
The eval harness wires the new grammars too, or the fixture would stop
being a measurement of the daemon.

Go's \b is ASCII-only and never fires after a Cyrillic letter, which the
first version of the pattern learned the hard way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:53:01 +04:00
kami 9397f9e5f6 query: only a source that knows the day may answer "а завтра?"
The follow-up continuation re-aimed Slots.Time, but every query source
matches on dec.Utterance and nothing in the chain reads it. So the
inheritance bought almost nothing: "какие планы на сегодня" then "а
завтра?" missed day-plan's matcher and fell to the calendar, which had
parsed the day out of the raw utterance anyway.

Worse than nothing in one place. queryFactByKey runs first and claims on
HasKey plus HasTime, both of which the continuation sets, so a keyed
query continued with "а вчера?" answered "я записала это <the fact's own
timestamp>" and dropped the day entirely.

Widening the topic for every source would have made it worse still, not
better: CalendarEvents is the only CoreAPI call that takes a date, so
ten date-blind sources would have answered a question about tomorrow
with today's data. Gate instead of widen — a continuation is only
offered to a source that reads the day, and when none claims it she says
so rather than "не знаю", which reads as "nothing tomorrow" when she
never looked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:32:37 +04:00
kami 3f98a99f44 continuation: only an ellipsis may widen the keyword match
Deployed check, second round: "привет" after "какой сегодня день" answered
with the date. followUpMerge fills an empty Text from the previous
same-intent turn, so the topic-widening added a minute earlier was reading
an inherited topic on turns that had nothing to do with it.

Decision gains Continued, set only by continuation.go and never by the
router. replySystem widens on that and nothing else, so an inherited Text
is back to being invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:21:28 +04:00
kami 53616836db continuation: an ellipsis names the day, not the topic
Deployed check: "какой сегодня день" then "а завтра?" answered "пока не
умею". The intent was inherited correctly, but replySystem keyword-matches
the utterance, and "а завтра?" contains no topic word — that is the whole
nature of an ellipsis.

So the topic travels with the session. rememberTurn keeps the raw utterance
in Slots.Text for system and query turns that have no Text slot of their
own (a stage-0 grammar fills none), and replySystem matches keywords
against utterance + Slots.Text. Dates keep parsing from the utterance
alone, which is the part the ellipsis actually restates.

Only system and query: everywhere else Text is a payload and must stay
exactly what the router put in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:16:58 +04:00
kami cf40f13573 continuation: a reminder's day is written into its payload, not just its slot
Live check on the deployed daemon: "напомни сегодня о событиях" then "а
завтра?" fires tomorrow with the text still reading "сегодня". Re-aiming
Time is not enough when the day word is also inside the payload, and
rewriting the payload needs the date's span in the string, which
ParseCalendarDate does not report.

Reminder comes out of continuableIntents until that exists. query and
system are unaffected: their Time slot IS the whole question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:13:25 +04:00
kami 7d08d27efb mavend: answer "а завтра?" from the previous turn, not from the model
An elliptical follow-up carries no intent of its own. followUpMerge cannot
help — it inherits slots once the intent is known, and here the intent is
the missing part. So "а завтра?" went to the router, which on a 1.7B is
close to a coin flip, and the guess cost ~2.7s.

continuationDecision runs before the router and rebuilds the turn from the
previous one: same intent, same key, new day. Deterministic and free.

Three guards, all narrow on purpose. A parseable date is required, which is
what separates an ellipsis from an ordinary short utterance. Four tokens
max. And only query, system and reminder may be inherited: fact and note
would write something he did not say, and act would let a two-word
utterance re-run an allowlisted fn, which is a way to fire a destructive
command nobody typed.

A continuation is still remembered, so "а завтра?" then "а послезавтра?"
chains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 22:09:05 +04:00
kami d0d0021659 mavend: let a fact close the nudge that asked for it
The snooze wire had one end. "готово" and "выпил воды" both left the nudge
pending, so the auto-tuner only ever learned from deferrals and from
silence — never from the rule working.

Two entry points, because the two utterances are different acts. A bare
"готово" carries no content and is intercepted before the router, sharing
pendingNudge and the twenty-minute window with the snooze. "выпил воды" IS
content: it routes normally, writes its fact, and only then closes the
nudge (ackFromFact, after applyAction). Folding the second into a pre-route
intercept would have thrown away the thing he actually said.

ackFromFact is silent. The fact reply stands; "отлично, отметила" on top
would be her congratulating him for obeying, which is the nag she is not.

Which fact answers which rule comes from the rule's own InertWhenNoData, so
a rule added later is covered the day it lands. "да" and "ок" stay out of
the ack vocabulary: the clarify gate upstream has the stronger claim on
them, and a stray "да" must not rewrite the feedback signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 21:43:28 +04:00
kami 79c3b994cf mavend: let him say "потом" to a nudge
A nudge could only be deferred from Telegram or the web UI. The voice path
had no route to store.ResolveNudge at all, so the channel she nudges on
hardest was the one he could not answer out loud.

resolveSnooze runs pre-route, right after the quiet toggle, and writes the
same `snoozed` outcome the buttons write — which also drops the row out of
RepeatUnacked, so a deferred sev4 stops re-sending every five minutes.

The window is what makes this safe to run before the router. "потом" is an
ordinary word; it only counts as a deferral when a pending nudge was sent
in the last twenty minutes, and otherwise the turn routes normally. Single
word patterns still match single-word utterances only, so "потом схожу за
водой" reports a plan instead of silencing the rule that prompted it.

Channel is not filtered: a nudge that went to Telegram is still what he is
answering when he says "потом" at the microphone.

QA-PLAN gains the two new manual checks and drops the 319 warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 21:40:06 +04:00
kami ba1d8e3f44 quiet: the noun form and the comparative are commands too
The pre-route toggle knew "тихий режим" and every negation of it, but not
the two phrasings that get spoken most: "включи режим тишины" (the setting
named as a noun) and "сделай потише". Both fell through to the router,
which has no quiet intent, so the command did nothing at all.

Adds those as stem pairs, plus a quietWordStems list so the
negated-but-unmatched fallback recognises "хватит тишины" the way it
already recognised "хватит тихого режима".

Locks the English phrasings from the routing fixture in the test table
("turn quiet mode back on", "turn off quiet mode", "stop quiet mode") —
all three already behaved, none were covered.

"на улице стало потише" and "в тишине лучше думается" stay inert: a
single-word pattern still only matches a single-word utterance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 21:35:58 +04:00
kami 47dda97226 tick: a disabled rule must not keep repeating its last alarm
`disabled_rules` stopped the loop from creating new nudges and did nothing
about the ones already sent. The sev4 repeat path does not consult the rule
set at all: RepeatUnacked re-sends any telegram nudge still at outcome=pending
every repeat_interval (5m by default), driven by store.UnackedTelegramRules.
So service_down kept arriving on a five-minute cadence after being switched
off, from a row written hours earlier — two messages after the deploy, which
is how it was found.

That cadence, not the unsealed database, is what "she keeps spamming me"
always was. The seal bug erased the acks that would have stopped it.

Filter the repeat keys against the wired rule set. Filtering on wired rather
than on the disabled list also silences a rule deleted from the code: nothing
can ack what the UI no longer lists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 20:43:27 +04:00
kami 8fdb9e5cd1 loop: let config turn a nudge rule off
The kuma service_down nudge cannot name the service. mavpoll folds the whole
monitor_status gauge into one boolean fact keyed `service_down`, and the
phraser names a service only when the fact key is the service name, so the
message is always the generic "a service on homesrv is down". Every fifteen
minutes, with nothing to act on. A fact per monitor is the real fix and it is
filed as Vikunja #444; this is what to do until then.

`disabled_rules` in mavend.json subtracts from loop.DefaultRules by name.
Config only subtracts — rules stay code, the set stays canonical and ordered
as written. A disabled rule is not gathered for either, since the gatherer
derives its key set from the rules it was given. Unknown names are ignored so
deleting a rule cannot brick a config that still lists it, and the boot log
says what was dropped, because a rule that vanishes silently looks exactly
like a rule that is broken.

Deploy turns service_down off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 20:20:32 +04:00
kami f1a809121b shutdown: close the sockets, or the database never gets sealed
mavend seals its encrypted database in `defer st.Close()` when run() returns.
It had not returned since 2026-07-21. Every restart since then decrypted the
same eleven-day-old ciphertext and rolled back everything written in between:
the Telegram nudge that kept firing was a fact being un-written on each boot.

The goroutine dump named it. main → srv.Close() → ipc.(*Server).Close →
wg.Wait(), waiting on per-connection goroutines parked in readFrame. Close
shut the listener and nothing else, so the idle persistent sockets held by
mavweb, mavpoll, mavcaldav and mavmaild blocked shutdown forever. `docker
compose stop -t 60` spent the whole sixty seconds and then took a SIGKILL.

So: track the accepted conns and close them, in ipc and in voice, which had
the identical defect. Bound all three waits — the two per-server ones and the
worker wait in main — because the seal matters more than any single in-flight
call. A dropped RPC costs one reply; a missed seal costs a session.

The regression test leaves a client connected and idle, which is the case the
old tests avoided by closing the client first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 20:05:52 +04:00
kami 79893d646b kiwix: read the article, not the snippet, and state the persona as morphology
Two things that were each half-done.

The prompts handed the model copyable examples. chatSystemPrompt lost its
openers this morning and the "Я подумала, что" tic went with them, but "не
забыл ли я" appeared in its place: the removed example had been suppressing
the masculine self-reference by accident. Two predicatives are not enough
signal, so the rule is now stated as morphology (-ла) rather than as a pair of
words — a suffix rule generalises where an example only gets copied.
querySystemPrompt had the same defect and gets the same treatment; its "вот что
я нашла: " opener is deliberate and stays.

internal/kiwix had no caller. actions_query.go said "once internal/kiwix is
wired into this chain" and that never happened. It is wired now, between the
notes pass and the web source: everything of his answers first, and only what
is left over is looked up. Off unless a `kiwix` block names a server and a book.

Reading the search snippet does not work. Kiwix builds it from wherever the
keyword matched, which on Wikipedia is the navigation box at the foot of the
page — the first version of this answered "что такое фотосинтез?" by reciting
"Ecological economics Ecological footprint Ecological forecasting …". Client
grows an Article method; the head of the article is the lead paragraph, which
is the definition the snippet was meant to be. Verified on the box: the same
question now answers correctly off the ZIM.

Only the rewritten query leaves the process. A test asserts it: a turn carrying
a stored note must not put that note in the search string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 19:46:39 +04:00
kami 0db31d21b9 hexis: re-vendor the client so a configured token is actually sent
The vendored copy of github.com/kami/hexis predated Client.WithToken:
no token field, no setter, no header hook, and an unexported httpClient,
so there was no way to attach auth from outside the package. wireEcosystem
handled that by refusing to wire Hexis at all when a token was configured,
which was the honest reading of the code but left the deployment silently
without its executing service.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Found in review of #66.
2026-08-01 14:21:36 +04:00
kami 59cdcc4e19 Merge branch 'fix/g11' into fix/integrated
# Conflicts:
#	internal/store/migrations.go
2026-08-01 14:20:24 +04:00
kami 3588da9e28 Merge branch 'fix/g06' into fix/integrated
# Conflicts:
#	cmd/mavend/memoryeval.go
2026-08-01 14:20:04 +04:00
kami d3fcc1dfdb mavwaked: throw away the round-trip backlog before it becomes a turn
Nothing reads the microphone while Send is in flight, so the audio piles
up in arecord's pipe and arrives in a burst the moment dispatch returns.
A round-trip is p50 2.7s through the LLM router, which is about 90
frames of room, of him finishing his sentence, of the television.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Found in review of #83 and #84.
2026-08-01 14:15:33 +04:00