Commit Graph

208 Commits

Author SHA1 Message Date
claude 990a4a99e9 ecosystem: ask Nexus for the name he actually said (V-476)
Two defects in one logged line, both of which put a working capability out
of reach of every utterance.

The resident model rewrites as it routes, and on the way it transliterates:
"перезапусти muzick indexer" came back as "перезагрузить музик индексер", so
Nexus was asked to resolve a service nobody has ever named. entityReferenceText
takes the longest Latin run out of his own words, but only when the Text slot
has lost every Latin letter the utterance had — an English turn and a Russian
entity name are both left alone, and reversing the transliteration is not
attempted.

The second half: the stage-3 gate thins an act that matched no allowlisted fn,
and that question was the whole turn, so handleHexisAct never ran. Hexis is
where an act with no local fn belongs, so it gets one chance before she asks,
and a "" back still leaves her asking. With no ecosystem wired nothing changes.
Capability matching reads the phrase as the haystack when there is no fn,
because no capability name contains "restart status muzick indexer".

Authority is untouched: ambiguity still stops, a mutating capability still
goes through the spoken confirm.
2026-08-04 03:38:20 +04:00
claude 5b622389c5 dialogue: a restart expires the parked question (V-385)
The decision, not a behaviour change: ClarifyStore stays in memory, and she
does not announce the loss either.

The TTL and the attempt count measure a pause in one conversation. A restart
is a gap of unknown length, so a restored question is either dead already or
lying about its age, and the request behind it is one he has likely given up
on. Announcing it would mean storing a marker that outlives the thing it
describes, to say one sentence in the rare window where he speaks within 90s
of a restart. His next words route fresh, which is right either way.

Written down in docs/design.md, pinned at both ends by a comment, and held by
a test that builds a second handler over the same store.
2026-08-04 03:33:19 +04:00
claude ea9c746852 weather: any city he names, not the six in a table (V-421)
The hand-written table understood "какая погода в X" for six values of X.
Ask about Kazan or Tbilisi and the city was dropped silently and answered for
the default location — a correct-sounding answer about the wrong place.

The table is gone. internal/weather already calls Open-Meteo's geocoding
endpoint on every lookup, so the place he named goes straight there and any
place it knows is a place he can ask about. He speaks the prepositional case,
so locationCandidates reverses the two endings that cover most of it: a final
"е" is a nominative "а" or nothing, a final "и" is a soft sign. A wrong
candidate finds no city; it never invents one.

A place the geocoder does not have now reads as "не знаю такого города"
rather than as a provider outage or, worse, as the default city's weather.
ErrLocationUnknown is what carries that apart.

"в" followed by a room or a day word is still the default location. Those
questions are answered by the house sensors and the calendar, not by
Open-Meteo, and they must not be read as a city.
2026-08-04 03:25:42 +04:00
claude 2815adee03 morning: an item can be optional, so a skipped stretch is not a skipped pill (V-473)
Item carried only Key, FactKey and Label, so every checklist entry was
implicitly required and behaviour 1 of #280 could not hold at all. It was not
thin config — there was no field to set.

Item.Optional, `"optional": true` in the routine config, default false, so a
routine written before today behaves exactly as it did. Due now fires on a
missing required item and not on an optional one, and the optional stragglers
still travel in Missing so the one message per day per routine can name them
after the required ones, in softer words.

Evidence, the window and the day plan treat both kinds alike. A missing
optional item is still missing — it just does not earn a nudge, because a
checklist where everything is mandatory is one he learns to ignore.
2026-08-04 03:17:29 +04:00
claude 9f51596e2f mavend: the simulator routes on the seeds the deploy loads (V-465)
The seed path was relative to the working directory, which is cmd/mavend
under `go test`. Every open failed, and the three scenarios replayed a whole
scripted day against a classifier holding zero examples. They passed. A green
simulator was proving something other than the routing the box runs, and a
regression in the seed set could not have surfaced there.

seedPath walks up to five levels to find models/seeds, so the daemon started
from the repo root behaves exactly as before and a test started anywhere
inside the tree finds the same files. All three scenarios still pass with 339
seeds loaded, so the outcome was not resting on the empty classifier.

The new test asserts the count rather than logging it. A silent zero is the
failure that hid here.
2026-08-04 03:14:52 +04:00
claude 87d176153a router: stage 0 claims the task marker before the model renames it (V-467)
Spoken capture was dead. "добавь в задачи купить молоко" routed act, so the
gate found no allowlisted fn and asked "Что сделать?", and the list stayed
empty. Capture rides the note intent by design (#130, no eighth intent), and
nothing under actionNote was reached any more. The model also rewrote the
payload on the way — "купить молоко" came back as "сделать покупку молока",
and a task must read as the words he said.

TaskCaptureGrammar answers it at stage 0, the same place the agenda rules
went. It matches any utterance and lets ParseTaskCapture refuse, so the
marker list stays data. Three phrasings he used are added to that list:
"запиши в список дел" and the two next to it were missing.

The other deterministic matchers were checked for the same exposure. They
are all question-shaped — money, habit, feed, day plan, task list, calendar —
and a question lands on query, which is where they already sit. Capture was
the only imperative among them, which is why only it was taken.

ru-note-006 is the fixture case. The classifier alone cannot pass it, and the
hash baseline drops by that one case; the daemon answers it at stage 0.
2026-08-04 03:12:47 +04:00
claude 7d4b4ad736 clarify: a parked question belongs to the conversation that was asked (V-466)
The clarify store had one key for the whole daemon, so a question asked in
the web chat and never answered captured the next three utterances from any
source — telegram, or the mic — and answered them against a request the
speaker never made.

The reach now supplies a conversation id on the IPC Chat call, and the
daemon carries it on the context the way it already carries the correlation
id, so the six clarify call sites read it instead of a constant. The mic has
no id of its own and keeps the key it had, so voice behaves exactly as
before. mavweb has no per-browser session, so every tab is one conversation:
right for a single-owner box, and still distinct from telegram and the mic.

Dialogue sessions stay global on purpose — they are what she remembers about
him, not what she is waiting for from one channel.
2026-08-04 03:08:09 +04:00
claude 6d3f5b5b01 router: a reminder with no subject asks instead of guessing (V-383)
Slots.Text was the raw utterance for every intent, so a reminder could not
have an empty subject. StillMissing never reported SlotText, the question
"О чём напомнить?" was unaskable, and the branch in PendingQuestion.Answer
that fills a text slot could only overwrite the whole request.

The LLM path now keeps the model's own text, empty included, and the gate
turns a subjectless reminder into a question. The classifier path is
unchanged: it has no subject parser, so the utterance is the only signal it
has.
2026-08-04 02:49:02 +04:00
claude 8833a9c76b mavend: keep only the stub floor in llmReplier (V-396)
The prompt, the call and the output parsing now live in internal/phraser. What is
left here is the one thing the daemon adds: a clarify, a model error and an
unusable generation all answer from voice.StubReplier, so a turn never breaks on
the model. The duplicated stripThink and parseResponseMood copies are gone;
capture.go uses phraser.StripThink.
2026-08-04 00:33:30 +04:00
claude f9b2391a8b phraser: cap llama-server's prompt cache at 512 MiB (V-499)
The forwarded log named the cause in one line: the prompt cache limit
defaults to 8192 MiB. llama-server saves the full KV state of every idle
slot it evicts, 112 kiB per token, so RSS climbed about 170MB per
distinct prompt until the deployed server held 7.9GB for a 1.1GB model.

Measured on homesrv today, uncapped versus `--cache-ram 512`: RSS
plateaus at 932MB from the fourth distinct prompt instead of climbing.
The task's leading guess was wrong. `-ngl 99` costs almost no RSS,
because RADV keeps device memory outside the process. Numbers and method
in docs/evals/2026-08-03-llama-prompt-cache.md.

`-c 4096` is untouched. The knob is `phraser.cache_ram_mib`, unset means
512, negative passes no flag for a llama-server too old to know it.

The deploy still runs the old image, so the box keeps its 8 GiB default
until mavend is rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:20:25 +04:00
claude 86817d6d06 memory: score the personal boundary on seeds, not word lists (V-495)
"что я говорил про бэкапы?" is his data by definition, and nothing outside the
box has ever heard him say anything. The boundary matched possession words only,
so the question walked past it into SearXNG and came back answered out of a Habr
article about somebody else's backups.

A speech-verb marker class was written first and dropped. Russian gives every
verb a dozen surface forms and the "как я говорил, ..." preamble list has no end,
so each form the lexicon missed was one more question reaching the world, and a
missing verb looks exactly like no bug.

The boundary now embeds two frozen seed sets and scores the turn's own query
vector, already computed upstream, against both. Nearest side wins. The
possession markers stay as the offline floor for a handler with no embedder.

19/19 held-out utterances correct against multilingual-e5-small; see
docs/evals/2026-08-03-personal-boundary.md. The live probe on the deployed box is
not done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:57:11 +04:00
claude ad60e10e95 mavend: run the fact vector repair on start, and test what it does (V-493)
Automatic rather than a flag, unlike -reembed: only voice-tapped facts are in
this index, so it is tens of embeddings rather than thousands of notes. And
waiting for an operator to know the repair exists is the failure being fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:54 +04:00
claude dbdab2d570 store, mavend: a fact is indexed as the fact, not as the utterance (V-493)
queryMemory returns a fact's stored text verbatim, so the text the write path
indexed is what he hears. It was the utterance, which made recall of any
voice-tapped fact answer with the sentence he said: go_version = 1.20 was
indexed as "какая последняя версия языка Go?", and that question came back.

FactRecallText renders the fact instead, and the utterance stays in meta as
provenance. Correcting a value now drops the key's vectors the way voiding one
does, since the superseded value was still answering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:34 +04:00
claude 62c2e92ec0 mavend, recalleval: wire the topic veto into both recall sources (V-470)
queryMemory and queryNotes both gate on score alone, so both needed it. The
eval keeps its own copy of bestRecall — package main is not importable — and a
fixture that measures a weaker gate than the daemon runs flatters it, so the copy
moves in step and its test pins the new rule.

Measured on the held-out recall fixture with the real embedder: 17/32 cases pass
→ 22/32, false recall 1/5 → 0/5, answered after gate 18/27 → 17/27. The one true
recall lost is en-hard-024, an English question against a Russian note, where no
lexical test can help.
2026-08-03 13:51:04 +04:00
claude 4dfe106fe3 mavend: a question is never a fact about him (V-470)
IntentFact used to persist whatever the model invented for a question-shaped
utterance, at confidence 1.00, and index it for recall under the question's own
text. Two such rows then claimed seven unrelated world questions and silently
disabled world answering.

A question now goes down the query chain, which is what he asked for. The second
half is confidence: a value grounded in what he said stays 1.00, a value the model
supplied for words he never said drops to 0.60 and says so in the log. Same
reasoning as 'LLM output is not authorization' on the act path.
2026-08-03 13:40:33 +04:00
claude 12530c8a95 mavend: world questions ask the workstation, and name the gap when it is asleep (V-490)
queryGeneral has nothing fetched to fall back on, so it is the sharp case:
with a workstation configured and asleep he is told that, rather than told
something false in a confident voice. The 1.7B answering a world question is
where "Война и мир" got Левитан as its author.

The sources that already hold a passage — a live search, a ZIM article, a
page he named — go through the world model too, but read the passage back
when it is not there instead of naming a gap. A real quote beats "не могу
сейчас", and nothing is invented on either path.

The Stub and every test double keep the Phraser interface they have.
PhraseWorld is reached by assertion, and a phraser without it is the
no-workstation case.
2026-08-03 12:27:40 +04:00
claude 92d5fd580c mavend: route and reply through the workstation when its card is free (V-485)
modelSeam builds an llm.Pair when a workstation is configured and hands it to
the router and the replier. Both are the silent half of the degradation rule:
the big model is only better there, and he is never told which model answered.
No block, no probe, and the box behaves exactly as it did.
2026-08-03 10:12:21 +02:00
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
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