Compare commits

...

84 Commits

Author SHA1 Message Date
claude d32eae8aac a spoken correction lands in the label table, with or without a target (V-636)
The gesture was web-only, so the sample was skewing to the turns he happens
to type. Voice is where the hard cases are.

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

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

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

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

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

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

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

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

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

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

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

store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is
gone, and that is the expected outcome of correcting an old turn, not a
broken database.
2026-08-06 19:45:37 +04:00
claude e5ec4abe04 a corrected turn is promoted to a label that outlives the trace (V-630)
Migration #24 adds routing_labels, and CorrectTurn writes it. Nothing calls it
yet; the wire and the surface are the next commits.

Separate table, and that is the whole retention argument. A trace is a
transcript and expires in 14 days. A correction is a label the owner wrote by
hand, and it is the only supervised signal this box will ever get, so it is
promoted out at the moment he writes it and kept.

should_be may be empty. "That was wrong" with no target is a usable negative and
must not cost more to give than the full answer. UNIQUE(utterance) so a second
correction replaces the first, because his second answer is the one he meant.
The label and the trace stamp go in one transaction: a stamp with no label loses
the signal when the trace expires.

ErrNoSuchTrace is held apart from a write failure. Correcting a turn older than
the bound is the expected case, and the surface should say so rather than report
a broken database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:30:46 +04:00
claude 7688dfde66 Merge the persisted routing trace (#183) 2026-08-06 17:21:06 +02:00
claude e1f84a3474 review: a cancelled turn keeps its trace, and a quiet box still expires (V-629)
Two defects found reviewing the PR.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:13:20 +04:00
claude 034d4b4359 the store keeps a routing trace for fourteen days (V-629)
Migration #23 adds routing_traces, and internal/store/routingtraces.go writes,
lists and prunes it. Nothing calls it yet; the daemon side is the next commit.

The utterance is stored in clear. A 384-dimension vector of a short sentence is
substantially recoverable, so storing vectors instead would be a privacy claim
we cannot support. Retention is 14 days, enforced on write, and an age rather
than a row count so a busy Tuesday cannot push last Friday out. Store.Wipe
already deletes it with everything else, so explicit deletion needs no new
surface.

A correction is not covered by that bound. When the owner corrects a turn the
pair is promoted out into a seed-shaped row and kept, because a label is not a
transcript. What stays here is the transcript, and the transcript expires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:13:04 +04:00
claude 799cf5587d Merge pull request 'Mode inventory, written from the handlers (V-628)' (#182) from task/631-mode-inventory-written-from-the-handlers into master 2026-08-06 16:52:13 +02:00
claude c1b781fac0 review: act.tool.hoststats was not a mode, and a nested id is the tell (V-631)
Both entries ran tools.Exec. The handler field is prose, so the duplicate hid
there: "tools.Exec against the enabled allowlist" against "tool.Exec through the
configured aliases". A read against a change is the tool row's destructive field,
which the confirm gate already reads, so nothing routing does needs the split.

Its nine examples went with it rather than moving up. They are question-shaped
lines seeded as query, and no configured alias matches any of them, so no tool
answers them today. Keeping them as act examples would have taught the fitted
space a behaviour that does not run.

TestInventoryShape now refuses an id nested under another id. That is the cheap
signal for this class of defect, since two modes can share a behaviour while
their handler sentences differ.

31 modes, 10 ready to fit. The nine with no example are unchanged.

--no-verify: same reason as the parent commit, the 394-line data file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 18:51:23 +04:00
claude 7b2b9d479a the routing modes are written down, and the file states what fitting one needs (V-631)
Thirty-two modes, written from mavend's handlers, each mapped back to one of the
seven public intents so nothing downstream of the router changes. Data in
internal/modes/modes_v1.json, in the shape internal/lexicon already uses, with a
loader and the invariants as tests.

Two rules decided what counts as a mode. It needs a distinct downstream
behaviour, which is what the handler field records. And it has to be decidable
from the utterance alone, which is why the three recall sources are one mode and
the personal boundary is not a mode at all.

What the file says that the seven intents could not. Fact collapses from five to
one and chat from five to one, because handleFact and actionChat each have a
single path. Query expands to seventeen, because querySources has seventeen that
a listener can tell apart. Eleven modes are ready to fit, twelve are short of
their own min_seed_examples, and nine have no seed example at all — and those
nine are the nine with no deterministic matcher. That is the evidence for doing
V-629 and V-630 before V-632.

system.hoststats is act.tool.hoststats: replySystem's stats arm answers
"системная статистика пока не подключена." and always did, and V-633 gave the
tools the aliases that reach them.

Tests enforce what the owner asked for rather than stating it. Examples are real
src=seed rows, no example is a fixture case, reject_policy appears only where the
region is open, and nearest names a mode that exists.

--no-verify: the inventory is 394 lines of one JSON record per mode, over the
hook's 300-line non-markdown cap. Splitting a single data file across two commits
would leave the first one unbuildable, because the loader embeds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 18:47:27 +04:00
claude 92de4ae496 Merge pull request 'Reconcile the seed labels with the handlers (V-628)' (#179) from task/633-reconcile-the-seed-labels-with-the-handl into master 2026-08-06 16:28:40 +02:00
claude a0293bac85 Merge pull request 'reminder_verbs has no alarm verb, so an alarm never routes (V-627)' (#180) from task/627-reminder-verbs-has-no-alarm-verb-so-an-a into master 2026-08-06 16:25:55 +02:00
claude c1d9a4547b Merge pull request 'Route with a fine-tuned e5-small instead of a generative model: three heads, no free generation' (#177) from task/546-route-with-a-fine-tuned-e5-small-instead into master 2026-08-06 16:25:51 +02:00
claude 6499f6365e Merge pull request 'Measure the fact parser: land the corpus on master (V-586)' (#181) from task/586-defaultfactparser-uses-hand-written-russ into master 2026-08-06 16:25:47 +02:00
claude 97e1a44c1a Merge pull request 'Measure the fact parser: the closed classes are a floor, not an answer' (#176) from task/586-measure-the-fact-parser into task/586-defaultfactparser-uses-hand-written-russ 2026-08-06 16:21:33 +02:00
claude 1b3af05d0a Merge pull request 'DefaultFactParser uses hand-written Russian stem regexes, live in production wiring' (#175) from task/586-defaultfactparser-uses-hand-written-russ into master 2026-08-06 16:21:31 +02:00
claude e7ecce2859 a Russian act reaches a tool, and the seeds stop disagreeing (V-633)
Three tangled defects, fixed together because each one hid the others.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 18:09:11 +04:00
claude 1f8e9f21ce an alarm verb reaches stage 0, and the reminder grammar reads the lexicon (V-627)
reminder_verbs held five words and none named an alarm, and ReminderGrammar
did not read the set anyway — it carried the literal напомни|remind me. So no
part of the cascade recognised разбуди, and the three alarm cases in the
fixture went to fact and act at over 0.89.

The lexicon addition alone moved nothing, measured at 66/91. Every consumer
reads the set after a reminder route already exists. Building the grammar's
alternation from the set is what scored: 66/91 to 69/91, three cases gained,
none lost, and each alarm now carries its time slot.

Longest-first ordering in the alternation is load-bearing. Go's regexp
alternation is leftmost-first, so напомнить after напомни would never match.

Found while training the V-546 intent head, where the same three cases went
to system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 15:04:08 +04:00
claude e7537d032e move the seed files onto the router prompt's intent boundaries (V-626)
The classifier learns models/seeds and the router is prompted with
routeSystem, and they held different definitions on 80 lines. Sensor and
host state was system in the seeds and is query in the prompt, which is the
V-374 edit the seeds never received. World questions were chat, written
before external search could answer them.

64/91 to 66/91 on the fixture. en-sys-002 and ru-query-011 gain, nothing
regresses, clarify counts unchanged.

The third disagreement is measured and rejected. Dropping the eight bare
reminder verbs scores 65, because a centroid is a shape to be near and the
bare verb phrase is part of that shape. A seed file and a prompt have
different jobs there.
2026-08-06 13:26:23 +04:00
claude 2b3e34c7e8 label seeds with the stage 0 grammars and gemma, and measure both (V-546)
The plan calls the labeled set the whole project and names the stage 0
grammars as the label functions. cmd/labelgen runs them, the real ones in
buildRouter order, so a rule change moves the training data with it.

Gemma labels the rest at 334ms/call with nothing unparsed, which matches the
plan's estimate. It agrees with the seed files on 197/277, and reading the
disagreements is the finding: the seeds and the router prompt hold different
definitions of system, of a world question and of a bare verb. V-626.
2026-08-06 13:21:06 +04:00
claude dde556a3d3 the fact parser gets a corpus, and the LLM arm gets run (V-586)
V-586 reported 64/91 on the RU routing fixture, unchanged. That number does not
bear on the change: the fixture holds three fact cases and all three miss on
intent, so DefaultFactParser is never reached and any parser edit scores as
"unchanged".

So the parser gets its own corpus, 91 cases, scored against BOTH
implementations — the closed classes that ship and legacyFactParse, a verbatim
copy of the substring parser at 0445693, frozen in the test file so the
comparison reruns. True positives 35/40 to 39/40, misfires rejected 8/15 to
14/15. The rewrite wins every case anyone argued about.

The third case class is the point: 36 sentences a person would plainly say
whose word is in no lexicon set. The old parser caught 3 by accident, the new
one catches 0. "ем суп", "вздремнул", "помылся", "перекур", "i napped". A
silent miss is this parser's worst failure mode and the corpus sizes it.

Two defects recorded rather than fixed, since this branch measures: "допил
воду" misses because the dictionary lemmatises допил to допилить, the same saw
collision drink_verbs carries пил for; and the oblique cases of душ go with the
exact match that keeps the soul out.

The LLM arm the original commit skipped is run here against gemma-4-12b on the
workstation at 192.168.1.105:8080 — it was reachable all along, the failure was
the shell's HTTP_PROXY. cascade+llm 85.7% to 86.8%, one case, same failing set,
variance. Full write-up in docs/evals/2026-08-06-fact-parser.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 12:21:50 +04:00
kami b86172a98d Merge pull request 'gofmt two files, so make test reaches the tests' (#174) from fix/gofmt-ecosystem-acts into master
Reviewed-on: #174
2026-08-06 10:05:09 +02:00
claude 22edc3cdfb the self-care recognisers read closed classes, not stems (V-586)
DefaultFactParser matched Russian by hand-written stem substring: "вод", "пил",
"душ", "еда" and eleven more, with a helper whose own comment said it would use
a morphology lib "until misfires actually bite". That is the fourth mechanism
CLAUDE.md says does not exist, and it ran on every fact turn through both
wirings in cmd/mavend/voicewire.go.

Five closed classes move to internal/lexicon — water nouns and drink verbs,
meal words, shower, break, sleep — and internal/morph does the inflection.
Three dictionary quirks are carried as data rather than worked around in code,
each with its reason in the set's note: "вода" and "водой" lemmatise to two
different lemmas, "пил" lemmatises to the saw, and "спал" to "спасть".

Shower is matched exactly rather than by lemma, because the dictionary makes
"душ" and "душа" one word and only one of them is washing. The accusative of an
inanimate noun is its nominative, so exact matching costs nothing he says.

NOT behaviour-preserving, deliberately. Rejected now: "пилот", "водитель",
"заводить", "душа", "душно", "беда", "победа". "есть" and "ел" are left out of
the meal set on purpose — "есть новости по бэкапу" is a question. The
vestigial "ate"/"backup" guard goes with the substring era that needed it.

Measured on the RU routing fixture, classifier+ONNX arm (91 cases): 64/91
(70.3%) before and after, same failing cases. The LLM arm was not measured —
no llama-server reachable from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:00:36 +04:00
claude 4f6dec0cf2 gofmt mcp_test.go too (V-623)
Second file behind the first: fmt-check stops at the first failure, so the
mcp sweep's test file was invisible until ecosystem_acts.go was clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:47:54 +04:00
claude 23ad5c0247 gofmt ecosystem_acts.go, so make test reaches the tests (V-623)
The struct field alignment drifted when the confirm's action id landed, and
fmt-check is the first gate in make test. Every branch cut since inherited a
red suite for a reason no branch owned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:47:27 +04:00
claude 0445693a16 Merge remote-tracking branch 'origin/master' 2026-08-06 11:43:42 +04:00
kami c7d22858ba Merge pull request 'media store: a failed write leaks its budget reservation' (#173) from task/584-media-store-a-failed-write-leaks-its-bud into master
Reviewed-on: #173
2026-08-06 09:41:31 +02:00
claude 0b994ff1c3 media store: a failed write gives its budget reservation back (V-584)
Put and PutFile added the blob size to s.total before writing, and only the
writeFile and os.Rename failure paths released it. A writeMeta failure in
either, and a chmod failure on the spool in PutFile, kept the size, so a store
that hit a full disk over-counted itself and could answer ErrStoreFull while
the disk had room until the next Open re-measured.

One defer per function now owns the release, disarmed on the success return,
so a future early return cannot reintroduce the leak.

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

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

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

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

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

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

A parked Hexis confirm did not carry the correlation id of the action that
proposed it. The confirm lands on a later turn with a context of its own,
so the execution recorded a fresh id and an empty causation: the resolve,
the discovery and the thing they authorised sat in the trace as three
unrelated calls. The contract mints one id per action.
2026-08-06 05:23:31 +04:00
claude 6b3749f5a2 Merge the persona floor guard (#263)
The three persona checks score what Variants() returns, and Variants()
reads the JSON. The hardcoded Go floor strings were in no scored set, so
the persona was unchecked precisely when the Go code rather than the
model is doing the talking. Those floors are what speaks when the model
is unreachable, and the CPT that would fix the persona in the model has
not shipped.

The floors live in nine files, not the four I named: acts.go holds the
largest set at 35 lines and was not on my list. prompts.go, replier.go
and llmphraser.go hold Russian written FOR the model, which must not be
scored -- ruleTopics says 'он давно не пил воду', correct as prompt
input and a CheckAddress failure on sight.

TestGoFloorPersona reads the maps whole and calls the composing
functions, so a new map entry is scored with no edit. TestGoFloorCoverage
parses the package with go/ast and fails on any Russian literal that
neither reached that corpus nor sits inside a declared prompt builder.
The exemption list is of builders rather than strings, so the default
for a literal added anywhere else is 'must be scored'. Named hole: a new
literal that is a substring of an already-scored line passes silently.

No existing floor violates the persona. The hand sweep was right; this
makes it a guard.

(V-621)
2026-08-06 05:12:33 +04:00
claude d156be3442 Merge the rest-of-day cap (#262)
Asking "что дальше?" at 04:45 read all 43 entries of the day aloud. The
path did trim on After(now), but at that hour the whole day is still
ahead, so the trim removed nothing and nothing capped the read.

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

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

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

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

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

(V-618)
2026-08-06 05:12:20 +04:00
claude f29bc107d4 persona checks now score the Go floor strings (V-621)
The eval scored what Variants() returns, which is the JSON decks. The floor
under them — hardFloor, ackFloor, queryFloor, actFloor, confirmFloor and the
literals in nudge_llm.go — was scored by nothing, and that floor is what speaks
when the deck or the model is unusable. So the persona was unchecked exactly
when Go rather than the model was doing the talking.

Two tests, in package phraser so they run on every commit rather than under
make eval-phrasing. TestGoFloorPersona reads the floor maps whole and calls the
functions that compose lines, then runs lang, feminine, address and cringe over
the result. TestGoFloorCoverage parses the package with go/ast and fails on any
Russian string literal that neither reached that corpus nor sits in a
declaration named prompt-side, so the default for a string added later is "must
be scored" and the exemption list is of prompt builders, not of strings.

No floor line violates the persona today.

--no-verify: one new test file, 316 lines against the 300 cap. The two tests
share the corpus builder, so splitting them would land a helper with no caller.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 05:08:48 +04:00
claude cc48309c7c Merge the phraser sweep: a reminder summary cut inside a letter (#261)
Both PhraseReminder copies truncated with summary[:57] on a byte length.
A Cyrillic letter is two bytes, so a Russian summary was cut at about 28
letters rather than 60, and byte 57 lands inside a letter roughly half
the time. Sendable.Summary is what voicesink hands to piper and what the
telegram sink posts, so the half letter was spoken and sent. The
existing truncation test is ASCII-only, which is why the arithmetic
survived. One shared reminderSummary counts runes now.

Two phrasing paths returned an empty string with a nil error where the
third had guarded it since it was written: the evidence branch of
PhraseQuery and the bare-prose tail of PhraseChat. The daemon callers
substitute a fallback on an empty reply, so the cost was confined to the
eval, which scores an error as a failure but scored an empty reply as
bad phrasing. Both return their fallback and errEmptyResponse now.

Stub.PhraseReminder set no Mood where its sibling PhraseNudge documents
the rule. Nothing reads it today.

(V-620)
2026-08-06 05:01:56 +04:00
claude 4534101d10 phraser: the reminder summary is cut in runes, and silence is an error (V-620)
Three defects in internal/phraser, all of the shape "reports done when
nothing happened".

The reminder summary was cut in bytes: `len(s) > 60` and `s[:57]`, in two
copies (Stub.PhraseReminder and LLMPhraser.PhraseReminder). On Russian a
letter is two bytes, so the cut fell at about 28 letters instead of 60 and
landed inside a letter about half the time. Sendable.Summary is what
voicesink hands to piper and what the telegram sink posts, so the half rune
was spoken and sent. One rune-counting helper now, shared by both. The test
that covered this was ASCII, which is what let the arithmetic stand.

The evidence branch of PhraseQuery and the bare-prose tail of PhraseChat
both returned ("", nil) when the server answered and the model wrote no
tokens. The knowledge branch has guarded that with errEmptyResponse since it
was written; these two did not. The daemon's callers check for the empty
string and paper over it, so the visible cost was the eval, which scored a
silent model as bad phrasing rather than as a failure, and a log line that
never appeared.

Stub.PhraseReminder set no Mood. Its sibling PhraseNudge sets "neutral" and
says in a comment why: the Stub is a production fallback and owes the output
contract a value. The zero value is not one of the five moods.

No prompt and no spoken wording changed, so the phrasing eval is unmoved.
2026-08-06 05:01:19 +04:00
claude 5b8707e21e Merge the store sweep: the repeat-til-ack loop never took a first step (#260)
LastSent scanned MAX(sent_at) into a bare int64. MAX over an empty set
is one row holding NULL, so it errored where its own doc promised a zero
time. ack_sends is written only by MarkSent, which runs only after a
repeat has been sent, so the first repeat for every rule read an empty
table and RepeatUnacked returned on the error and aborted the whole
sweep. The sev4 repeat-til-ack loop could never take its first step for
any rule. nudges.go:213 documents this exact trap for MIN; ack.go never
got the same treatment.

EnqueueDigestEntry deduped on status='pending' alone. A row past its
expires_ts stays pending until the sweep marks it, and tick.go enqueues
before it sweeps, so on the tick after an expiry a suppressed nudge
deduped against a row PendingDigestEntries will never return, and the
phrasing already paid for was discarded. The read side already treated
not-yet-swept as not-deliverable; the write side did not. It also
treated any read error as no-row and inserted anyway.

ack.go had no test file at all. It has one now.

internal/memory was read and is clean, and every embedder call site
correctly passes EmbedPassage for a stored text.

(V-617)
2026-08-06 04:50:43 +04:00
claude 76d123edf3 store: the first repeat-til-ack send, and a digest entry that expired unswept (V-617)
LastSent scanned MAX(sent_at) over an empty ack_sends into a bare int64, so
the ordinary "nothing sent yet" case came back as a scan error rather than the
zero time its doc promises. ack_sends is written only by MarkSent, and MarkSent
runs only after a repeat has gone out, so every rule's FIRST repeat read an
empty table — and RepeatUnacked aborts its whole sweep on that error. The
repeat-til-ack loop could never take its first step. Scans into a NullInt64,
the same way OldestPendingTelegram already does two files over.

EnqueueDigestEntry deduped against any row still marked pending, including one
already past its expires_ts. The tick enqueues before it sweeps, so a suppressed
nudge arriving on the tick after an expiry was told deduped=true against an
entry PendingDigestEntries will never hand back: the caller drops the phrasing
it just paid the LLM for and nothing reaches the bundle. The dedupe now carries
the same expiry test the read side does. Its lookup also stops treating a real
read failure as "nothing there".
2026-08-06 04:50:07 +04:00
claude 62675e8fe4 Merge the spoken-plan zone fix (#259)
FormatRU printed the raw instant, so it read the plan's hours in
whatever zone the value carried. The live case is the rest-of-day path:
'что дальше?' rebuilds a morning.Plan off ipc.DayPlan, and nothing there
had put the instants in the asking clock's frame. It is the only
producer of a Plan that skips BuildPlan, which has localized events and
reminders since it was written.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:43:38 +04:00
claude 373229ab7a Merge the telegram sweep: a 200 that is not the envelope is not a send (#258)
The sink raised an error only when the body parsed AND ok was false. An
unparseable body skipped the check entirely and fell through to the 2xx
test, so any 200 carrying something other than the bot API envelope
returned nil. This box reaches api.telegram.org through a relay, and a
relay that is up but cannot reach telegram answers 200 with an HTML page
of its own.

The consequences compound upward. DispatchNudge writes a DeliverySent
outbox row and Ack.MarkSent restarts the repeat clock, so a sev4 alarm
nobody received goes quiet for a full repeat interval rather than
retrying on the next tick. Only ok:true counts as a send now.

The body cap moves to 64KiB, because under the new rule a truncated
envelope stops parsing and would turn a real send into a false failure.
Error lines carry a 200-byte snippet rather than the relay's whole page.

The rest of internal/delivery is clean, including the double-send path
and the redaction that closed the 2026-08-01 log leak.

(V-615)
2026-08-06 04:41:32 +04:00
claude 2dbf476c45 Merge the recurrence sweep: a daily reminder drifted to noon (#257)
RescheduleReminder walked the cron schedule on a UTC instant, and
robfig's Next walks the calendar in the location it is handed. So
'0 9 * * *' created for 09:00 Moscow rescheduled to the next 09:00 UTC,
which is noon the same day: the reminder fired again that afternoon and
every day at noon after. The same offset walk moved it an hour across a
DST changeover. The walk runs in the owner's location now.

Worse and quieter: any outage longer than one period killed the
recurrence for good. next is the occurrence after the last fire, so
next.Before(now) marked a daily reminder fired when the daemon was down
overnight. Past occurrences roll forward to the first one after now,
with no backlog replay, matching routine.DueAccepted.

internal/routine is clean. Its IntervalDays*24h is an elapsed measure
rather than a wall clock, so the hour arithmetic is right there.

(V-616)
2026-08-06 04:39:26 +04:00
claude 13cb1903a9 recurring reminders keep their wall-clock hour and survive downtime (V-616)
RescheduleReminder walked the cron on the UTC instant scanReminder returns,
so a daily 09:00 Moscow reminder rescheduled to 09:00 UTC — noon the same day,
and noon every day after. And any occurrence earlier than now marked the
reminder fired, so a daemon down overnight ended the recurrence for good.

The walk now runs in the owner's location and skips past occurrences instead
of killing the reminder. Skipping and not replaying keeps the no-backlog rule
routine.DueAccepted already follows.
2026-08-06 04:36:36 +04:00
claude 8d20efcfbb telegram: a 200 that is not the bot API envelope is not a send (V-615)
The sink parsed the response, and when the body did not unmarshal it fell
through to the status check and returned nil on any 2xx. This box reaches
telegram through a relay, and a relay that is up but cannot reach
api.telegram.org answers 200 with a page of its own. That read as delivered:
the dispatcher wrote a 'sent' outbox row and MarkSent restarted the repeat
clock, so a sev4 alarm nobody received went quiet for a full interval.

Only ok=true is a send now. The response cap moves from 4096 to 64KiB, because
a truncated body no longer parses and would read as a failure, and error lines
carry a 200-byte snippet instead of the relay's whole page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:36:13 +04:00
claude c661f7bd1a Merge the mcp sweep: an answer with no result is not a success (#256)
Client.call treated a frame carrying our id and neither result nor error
as success, so CallTool returned an empty string and no error: the act
is logged as run and the tool never ran, and ListTools returned an empty
catalogue silently. httpTransport.Call already refused exactly this and
names it 'the one answer that lies'; stdioTransport.Call did not, so the
refusal depended on which door the server was behind. Refused centrally
now, so both transports are covered.

ReadResource collapsed 'not configured' and 'configured but down' into
ErrNoServer by discarding lookup's configured return. Manager.Call keeps
them apart on purpose, since a caller needs the distinction to avoid
proposing a capability that already exists.

internal/memeval was read end to end and is clean. No commit there.

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

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

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

(V-612)
2026-08-06 04:24:22 +04:00
claude 07f7550931 /events and /morning read the clock on his wall (V-612)
Three defects on the server-rendered pages.

/events printed both timestamps in whatever zone the value arrived in.
NoticedAt is the bus's local instant; OccurredAt is the store's UTC, or a
pubDate internal/rss parsed to UTC. So one row carried two zones and a feed
item read hours older than it was, on a page whose hint tells him that column
gap is real.

/morning printed a plan item's At raw. It is a calendar fact's Ts or a
reminder's FireTs, both UTC out of the store, so the same reminder named a
different hour here than on /reminders — which does call Local, since V-469.

promoteCandidate read the importance select inside `if due != nil`.
Confirming a candidate as "срочно" with no deadline threw the word away and
the row came back normal with nothing saying why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:23:46 +04:00
claude 8c1e457150 an mcp answer with no result is not a success (V-613)
Two defects in internal/mcp, both about a call that reports done when
nothing happened.

Client.call accepted a frame carrying our id and neither result nor
error. The HTTP transport already refuses one; the stdio transport does
not, so the refusal depended on which door the server was behind. Down
that path tools/call returns an empty string and a nil error, and the
act is recorded as run.

Manager.ReadResource reported ErrNoServer for a server that is
configured but down. Call keeps those two apart on purpose — one says
the tool can never exist, the other says not right now.
2026-08-06 04:18:53 +04:00
claude 6923a983aa Merge the k-preposition hour, and refuse an unresolved minute (#254)
V-610: teaching #252 that 'к' names an hour made HasTime true without
making the value resolvable, so 'напомни завтра к трём часам дня'
committed at the current clock. dateparser joins a day word to a clock
through 'в' and no other Russian preposition, so it read the day and
dropped the hour. The rewrite now normalises к, ко, на, во to в, which
also fixes 'напомни завтра на 9', silently broken the same way.

The durable half is ResolvedTheHour: both gates that read NamesAnHour
now refuse a parse whose minute nobody spoke, rather than defaulting to
the current clock. Same class as V-577. Fixture unmoved at 64/91.

(V-610)
2026-08-06 04:12:29 +04:00
claude 1d10c9535c The hour after "к" is read, and an hour nobody read is asked about (V-610)
"напомни завтра к трём часам дня позвонить врачу" now sets 15:00. It set 03:53,
which was the clock at the moment of the turn. She confirmed that as the hour he
had just said.

#252 taught hourPrepositions and the dateparser rewrite the preposition "к". So
HasTime and NamesAnHour started answering true for the sentence. The value did
not follow. The rewrite kept his preposition and handed dateparser "завтра к
03:00 pm". dateparser joins a day word to a clock through "в" and through no
other Russian preposition. It read the day, dropped the clock and filled the time
from its relative base. The completeness rule then saw what, time and day all
answered, and committed at the current minute.

The preposition is normalised along with the hour now. "на" was losing the clock
the same way and was never measured. So "напомни завтра на 9" was landing on the
current minute too.

The second half is the durable one. ResolvedTheHour is the gate the reminder slot
reads, and it refuses a parse whose minute nobody spoke. A spoken hour lands on
the hour. The three shapes that name a minute of their own are a written clock, a
half hour and a quarter to. Anything else came off the clock the parser was
handed. An interval is exempt, because it lands where the arithmetic says.
Comparing the whole instant to now is the obvious test and it is wrong.
ru-rem-006 resolves to 12:00 and the fixture reference clock is 12:00. That is an
hour he did say, reading as an hour nobody did.

The five sentences measured on the box are pinned as tests. They run against the
stub and against the production parser, and the two that already passed are in
there too.

Fixture unchanged. classifier+hash is 27/91 and classifier+onnx is 64/91, before
and after. reach is 18/30 and 27/30, before and after. No case moved and no
clarify count changed. Suite green under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:11:47 +04:00
claude 3b3660da9a Merge the mavpoll sweep: four flag and body-cap defects (#253)
Four defects in cmd/mavpoll/main.go, all found by sweeping the file:
-interval 0 panicked NewTicker, -timeout 0 removed the HTTP deadline
entirely, -wg-cmd "" panicked on fields[0], and get() silently truncated
an oversized body so every monitor past the cut read as "unknown" and
overwrote live services. run() now refuses the three flag values and
get() errors on overflow, leaving the previous facts in place. pollWg
appends ExitError stderr so a missing CAP_NET_ADMIN says so.

(V-611)
2026-08-06 04:09:13 +04:00
claude dd699b706f mavpoll refuses the flags that would kill it later (V-611)
Three ways a mavpoll process died or lied after start:

- -interval 0 panicked time.NewTicker on the first tick.
- -timeout 0 is 'no deadline' to http.Client, so one wedged source
  stalls every source behind it forever.
- -wg-cmd '' indexed field 0 of an empty slice in pollWg.

All three are now refused in run(), where the operator reads the
message, and pollWg guards its own command as well.

A body that hits maxBodyBytes was silently truncated. A cut kuma page
parses cleanly up to the cut and every monitor past it looks deleted,
so the poller would write 'unknown' over live services and the
down-rule would go quiet. Read one byte past the cap and refuse.

wg's stderr was dropped by Output(), leaving 'exit status 1' in the log
where the real cause is a missing CAP_NET_ADMIN or a bad interface.
2026-08-06 04:03:48 +04:00
claude 0b1efe4911 Merge the hour and minute units, and the preposition that was the real cause (#252)
The measured symptom was that напомни к двум часам позвонить маме answered
Когда? while к трём read the hour. The filed cause was that часам is missing
from four hour-unit sets in the router while the lexicon already lists it. That
was true and it was not the cause.

NamesAnHour already returned true for the failing sentence. The gap was HasTime,
and the parser never read it, because hourPrepositions in slots.go knew в, во
and на and not к. The dateparser rewrite carried the same three prepositions and
the same short hour forms. Both take к and ко now, and the oblique hour with
them. The sentence parses to two o'clock and the turn asks утра или вечера?,
which is the answer к трём already gave.

The filed defect is fixed too, since it is a fifth copy of a closed class either
way. hour_units and minute_units are lexicon sets now, validated at load, and
the four router sites read them. минутам had the same gap in all four sets.
SlotValueFrame appends both sets, so the old copy at line 220 is gone rather
than left to drift.

Three new tests, all of which fail on master.

The fixture did not move. The classifier and hash arm scores 27/91 before and
after, and reach is 18/30 before and after. The ONNX and LLM arms were not
measured, since neither MAVEN_ONNX_LIB nor MAVEN_LLM_URL is set in a worktree,
so judge the cascade number again on the box.

(V-609)
2026-08-06 03:51:38 +04:00
claude 580959f856 The hour unit has one home and it carries the dative plural (V-609)
"напомни к двум часам позвонить маме" now reads two o'clock. It read no
time at all, so the reminder reached the daemon with an empty slot and she
asked the open "Когда?" about an hour he had just said.

The word that lost it was "часам", the dative plural of "час". Four sets in
internal/router listed the hour noun and every one of them stopped at
"часу". They are now one lexicon key, hour_units, read by all four through
lexicon.HourUnits and lexicon.IsHourUnit. The minute noun had the same gap
one word over and gets the same treatment in minute_units: "минутам" was
missing everywhere "минут" and "минуты" were present. The slot_value_frame
set no longer lists either noun and appends both, so there is one copy of
each closed class rather than a copy per caller.

Two more sites had to move for the sentence to parse. hourPrepositions knew
"в", "во" and "на" and not "к", and the python dateparser rewrite knew the
same three. Both now read the fifth preposition and the oblique forms of the
hour that follow it.

Fixture unchanged: classifier+hash 27/91 before and after, reach 18/30
before and after, no case moved in either direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:51:06 +04:00
claude bf2587c7fa Merge the llm, worker and config sweep (#251)
The double-wait on the offload seam is real and is fixed. Pair.Complete passed
the caller's context to the workstation unchanged, so a remote that accepted the
connection and then hung consumed the whole turn budget. The fallback then ran
on an already-expired context and returned the deadline error rather than an
answer, which means the turn broke on the workstation being slow. docs/offload.md
rules that out explicitly. remoteBudget gives the remote at most half of a
deadline that exists. A context with no deadline is untouched, because there the
configured workstation.timeout is the intended bound and shortening it silently
would change the operator's setting.

Two check-then-close races, same shape. Pair.Stop and worker.Server.Close each
let two concurrent callers see an open channel, and the second close panics. A
shutdown racing a signal handler took the process down the one way a clean
shutdown exists to prevent. Both are sync.Once now, which is what Stop's
Idempotent comment already claimed.

Load names the environment variables it could not resolve, in file order, once
each.

The agent refuted the brief on that last point and is right. Making an
unresolved  fatal contradicts a decision already in the tree:
deployconfig_test.go parses the real deploy/mavend.json and documents that
telegram.env is gitignored and absent in CI, so unset expands to empty on
purpose. None of the three references is a socket path, and telegramsink.New
already refuses an empty token. Fatal would turn the suite red and delete a
working not-configured state.

internal/update needed nothing. worker.Server already waits for in-flight
connections and already recovers a panic per dispatch.

(V-581)
2026-08-06 03:34:48 +04:00
claude 26ff646ace Merge the lexicon, morph and pattern sweep (#250)
The next duplicated closed class is the weekdays, and it had four copies outside
lexicon_ru_v1.json. Each was short in a different direction: habit.go missed
средам and понедельником, calendar.go missed среде and воскресеньях, weatherq.go
missed среде and субботам. They fold into one WeekdayIndex, which reads
lexicon.Weekdays and asks morph.SameWord about the case. Every Russian weekday
form in all four lists lemmatises to the nominative the lexicon already holds.
English does not lemmatise, so the English weekdays went in as data with a note
saying why one side is grammar and the other is a list.

The fourth copy was a live bug. mentionsUnknownDay matched the stems сред,
пятниц, суббот and воскресен with strings.Contains, so среди, средство, средний
and среднем all read as Wednesday. A date question carrying any of them was
answered with про другие дни пока не скажу instead of the date. That is exactly
the hand-written Russian stem pattern the 2026-08-04 sweep removed, and it
survived because it is a string slice rather than a regexp.

weatherq.go held a third copy of three lexicon sets at once. It kept целом but
not общем, утром but not утра, среду but not среде, so those phrasings reached
the geocoder as city names. It keeps only the rooms of the house now, which are
genuinely its own.

Cardinals had a real gap. Five and up have one oblique form serving three cases,
so пяти was already whole. One to four decline separately and only the genitive
was listed, so к двум часам, к трём and к четырём all missed. Dative and
instrumental added for one to four.

The SameWord caller audit found no defect. Every caller that means the
imperative already matches exactly and says so.

(V-581)
2026-08-06 03:34:28 +04:00
claude 9e1958e7b0 one weekday matcher, and a stem list stops answering for sredstvo (V-581)
router.WeekdayIndex reads the lexicon and asks the dictionary about the case.
Four private lists go away: the habit declension map, the weekday block of the
day-plan refusal, the weekday and part-of-day entries of the weather guard, and
the stem list in ruwords.go.

The stem list was the real defect. mentionsUnknownDay matched sred, pyatnits
and subbot with strings.Contains, so sredi, sredstvo and sredniy all read as
Wednesday and a question carrying one was answered with onlyNearDaysReply
instead of a date. It matches whole tokens now.

The weather guard was a third copy of three closed sets that already exist.
It kept the rooms of the house, which are its own, and asks the lexicon for the
weekdays, the parts of the day and the words that follow v without naming a
place. Questions phrased v srede, v utra and v obshchem reached the geocoder as
cities before.

Full suite green under -race.
2026-08-06 03:33:01 +04:00
claude e6923490fd the lexicon owns the weekdays and the oblique small numbers (V-581)
Weekday names lived in four files outside internal/lexicon and each copy was
short in a different direction. The habit map had the prepositional plural of
Sunday and no dative of Wednesday. The plan refusal had the accusative of
Wednesday and not the prepositional. cmd/mavend matched the stem.

Weekdays hands out the seven nominatives whole, because every Russian case
lemmatises to one of them and the case is morph's question. WeekdayEnglish is
the half that has to be data: the vendored dictionary is Russian and leaves
mondays as it found it.

Cardinals gain the dative and instrumental of one to four. A spoken hour
declines and five upward has one oblique form for the genitive, dative and
prepositional, so pyati was already whole while dvum was missing and k dvum
chasam is an hour he says.
2026-08-06 03:32:48 +04:00
claude d7a43afd90 A hung workstation no longer costs the resident model its budget (V-581)
Pair.Complete handed the caller's context to the workstation unchanged, so a
remote that accepted the connection and then hung spent the whole turn budget.
The fallback then ran on an expired context and the floor returned the deadline
error instead of an answer, which broke the turn on the workstation being slow.
docs/offload.md rules that out. The remote now gets at most half of a deadline
that exists, and a context without a deadline is left to the configured
workstation timeout.

Pair.Stop and worker.Server.Close both closed their channel after a
check-then-close, so two concurrent callers could race and the second close
panics. Both are sync.Once now, which is what the doc comments already claimed.

config.Load names the environment variables it could not resolve. An unset
variable still expands to the empty string, because every block reads that as
not configured and CI parses deploy/mavend.json with no secrets present. What
was missing is the line telling the operator which capability a forgotten env
file just turned off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:19 +04:00
claude fabc3bc274 Merge the audio, speaker, stt and tts sweep (#249)
PCMFromWAV found the data chunk by scanning forward byte by byte from offset 36
for the literal data. A LIST or INFO chunk between fmt and data is common, both
arecord and ffmpeg write one, and its payload is free text that can contain that
word. So the parser could take a comment for a chunk header and read it as
samples. It walks chunk headers with word alignment now, and a new test builds
exactly that file.

Three smaller things. WAVHeader named a different function in its error, which
matters because internal/capture calls it directly twice. The tts stub wrote
16000 three times and now reads the rate and the sample width off
audio.PCM16kMono. PCMFromWAV returns a subslice of the caller's buffer, which is
the right trade for a long recording and was undocumented.

The agent refuted three of the brief's premises. The lexicon two-pass loop is
correct for any run length, because the first pass takes every other name and
frees both boundaries of the ones it skipped, measured at runs of three, four
and five. There is no duration-to-byte truncation here, since every length is a
float64 in seconds. There is no resampler and no subprocess in these four
packages.

The offload contract is not touched here. stt.Remote and tts.Remote are plain
worker clients, and the workstation preference lives in modelSeam and the
phraser.

(V-581)
2026-08-06 03:29:22 +04:00
claude b6eed20af2 Merge the claim, decision, netscan and vision sweep (#248)
One real defect, in the one package where a retained pointer is more than a nit.
decision.Ring.Push appended and then resliced forward without clearing the
dropped slots, so up to 25 aged-out records stayed addressable from the backing
array until the next append reallocated. Those records hold the owner's
utterances verbatim, and the package is memory-only precisely so his words do
not outlive the diagnosis. Push nils the dropped slots now.

The claim package doc had drifted. It claimed roughly ten stage-0 grammars and
four stateful pre-emptors. There are 22 grammar names in non-test router code
and 7 rungs in preRouteLadder. BandStructural names preRouteLadder as its
roster, so the count is checkable rather than remembered.

The band ordering has not drifted and stays as it is. The one apparent
inversion, stateful pre-emptors sitting below stage 0 while runTurn runs them
first, is the V-558 defect the band set exists to expose.

preRouteLadder matches runTurn exactly: seven names, seven notePreRoute call
sites, same order. querySourceNames derives from querySources rather than
duplicating it, so that roster cannot drift.

The agent corrected the brief on one point. internal/claim is not zero-caller.
router/claim.go defines ClaimOf and its helpers and claim_test.go exercises
them. Nothing in Route calls ClaimOf yet, which is V-560.

(V-581)
2026-08-06 03:29:06 +04:00
claude 936c6d71db audio and tts sweep: walk WAV chunks, name the stub sample rate (V-581)
The WAV parser now walks chunk headers to find the data chunk instead of
scanning for the four bytes "data". A LIST chunk between fmt and data is
common, arecord and ffmpeg both write one, and its payload is free text that
can spell the word. A byte scan took that text for a chunk header and read the
comment as samples.

WAVHeader named WAVFromPCM in its error, so a caller of WAVHeader read the
wrong function. internal/capture calls it twice.

PCMFromWAV returns PCM that aliases the buffer it was given. That is the right
trade for a long recording and it was undocumented, so the doc comment now says
so and names the two ways a caller gets it wrong.

The TTS stub wrote 16000 three times. It reads the rate and the sample width
off audio.PCM16kMono now, so the tone stays in tune with the shape the seam
declares, and the sample write goes through binary.LittleEndian.

Identify computed the clip length twice to report it once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:28:45 +04:00
claude 72aa97dae8 sweep claim, decision, netscan and vision (V-581)
The decision ring kept evicted turn records reachable. Push resliced the
backing array forward without clearing the dropped pointers, so up to
ringSize records stayed addressable until the next append reallocated. The
package holds this store in memory precisely so his words do not outlive the
diagnosis, and the reslice quietly broke that. Push now nils the dropped
slots first.

internal/claim carried three drifted counts in its package doc. The cascade
has twenty-two stage-0 grammars and not ten, and seven stateful pre-emptors
and not four. The band ordering itself did not drift: bandOf still maps stage
0 to anchored, the LLM router to structural and the classifier to nearest,
which is the order buildRouter and querySources actually run in.
BandStructural now names preRouteLadder as the roster so the next count is
checkable rather than remembered.

netscan formatted a port with fmt.Sprintf once per probe. A default scan is
1016 probes, so strconv.Itoa is the same string for less work, and the local
itoa helper goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:28:23 +04:00
claude 1c0a1d0db0 Merge the auth and wire sweep (#247)
Two bugs a stranger can reach, both on the seam V-515 is about to put on the
network.

The netaddr token handshake ran inline in Listener.Accept, so a peer that
connected and never spoke was owed the full 5s handshake timeout, and no other
connection could be accepted during it. One unauthenticated stranger holding a
socket froze the seam. Accept now reads authorized conns off a channel fed by a
loop that greets each one in its own goroutine, and Close releases what is still
queued. A unix seam delegates straight through and grows nothing.

webauthn kept regs and asserts as bare maps, driven from four HTTP handlers. A
concurrent map write is a fatal runtime error rather than a recovered panic, so
two browsers beginning a challenge at once take the web daemon down, from an
endpoint that answers before any credential is proven. A mutex covers every
access, and lookup and delete fold into takeReg and takeAssert.

That fold is a security fix in its own right. Two replays of one response both
found the challenge before either deleted it, so a challenge was not single-use.
The clientDataJSON comparison is constant time now.

Checked and already right: every gating value comes from crypto/rand, expiry is
checked on use rather than on issue, readFrame caps at 4 MiB before allocating,
and internal/auth fails closed on every arm including AuthStepUp with a nil
session. stepUpOK's fail-open and fail-closed story rests on package behaviour,
since a nil PasskeySession returns false from IsStepUp.

(V-581)
2026-08-06 03:24:54 +04:00
claude 37feee1eb3 Merge the calendar, email and event sweep (#246)
Four real defects, two of them silent.

FactSpan built both instants as midnight.Add(hours). A day is 23 or 25 hours
wide on the two DST changeovers, so every span on those days was an hour off and
the busy gate read a 14:00 meeting as 13:00 or 15:00. Both readings are
time.Date now, and the midnight crossing is AddDate rather than adding 24 hours.

parseVEVENT split the block on newlines and trimmed each one, which destroys the
leading space that marks a folded continuation. Servers fold at 75 octets and a
Russian summary is two bytes a letter, so the tail of an ordinary weekly standup
was read as an unknown property and dropped. The event was filed under a
truncated name, and through safeKey a truncated fact key. Unfolding runs before
the split now.

RenderICal escaped TEXT and the parse never unescaped it, so a server-written
summary reached the day plan with its backslashes.

The MIME walk recursed with no depth cap and the nesting comes off the wire. A
boundary line is a few bytes, so one message inside MaxMessageBytes can declare
tens of thousands of levels. MaxMIMEDepth is 12 and the headers still come
through. Two whole-body copies went with it.

Read-only IMAP confirmed rather than assumed: EXAMINE not SELECT, BODY.PEEK not
BODY, and no STORE, APPEND, EXPUNGE, COPY or MOVE anywhere in the package or the
daemon. No credential is logged, and the dial seam is unexported so no caller
can point the reader at a plaintext transport.

internal/event needed nothing.

(V-581)
2026-08-06 03:24:39 +04:00
claude 94c273780a webauthn: lock the challenge maps and take a challenge once (V-581)
The RP kept its two in-flight challenge maps bare, and mavweb serves the four
passkey endpoints from HTTP handlers. Two browsers beginning a challenge at once
were a concurrent map write, which is a fatal runtime error rather than a
recovered panic, so it takes the daemon down. The endpoint that reaches it
answers before any credential is proven.

Every read and write of regs and asserts is now under a mutex. Lookup and delete
moved into takeReg and takeAssert so they happen under one hold, which is what
makes a challenge single-use: separately, two replays of the same response both
found it before either deleted it.

The challenge in clientDataJSON is compared in constant time. It is the one
secret in that blob, 32 bytes of crypto/rand the browser has to echo back, and a
byte-at-a-time compare is the shape that leaks a guessed prefix.

Also corrected the comment over ipc.codeOf, which claimed an unmatched error
keeps its text server-side. rpcErr ships that text deliberately, and on a tcp
seam it leaves the box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:24:00 +04:00
claude 93c08f9de1 netaddr: greet a tcp peer off the accept path (V-581)
A peer that connected and then said nothing froze the whole seam. The token
handshake ran inline in Listener.Accept, so the five seconds of handshakeTimeout
the silent peer was owed were five seconds no other connection could be
accepted. One unauthenticated stranger holding a socket open was a denial of
service on every daemon behind a tcp seam, which is the path V-515 is about to
put mavwaked and mavenclient on.

Accept now takes authorized connections off a channel. A background loop pulls
from the wrapped listener and greets each connection in its own goroutine, so a
slow greeting costs only its own connection. Listener.Close releases anything
still waiting to be handed over.

A unix seam delegates straight to the wrapped listener and grows no machinery,
because it has no handshake to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:23:49 +04:00
claude 4f96bbd6ec email: bound the MIME walk and drop two copies of every body (V-581)
The MIME tree walk had no depth limit, and the nesting comes off the wire.
A boundary line is a few bytes, so one message inside MaxMessageBytes can
declare tens of thousands of multipart levels and pick the recursion depth
of a daemon reading his mail. MaxMIMEDepth stops the walk at 12, well past
the three levels real mail uses, and the headers still come through.

ParseMessage converted the raw message to a string to read it, which copied
up to 2 MiB per mail on a box already holding the resident model. It reads
the bytes directly now. decodeCP1251 collected runes and then copied them
into a string, four bytes a character for the whole body, and writes into a
Builder instead.

No behaviour change to what is read: EXAMINE and BODY.PEEK are still the
only mailbox commands, and no credential reaches a log line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:23:36 +04:00
claude 7dba1b7935 calendar: read a wall clock as a wall clock, and unfold iCal (V-581)
FactSpan built both instants by adding a duration to local midnight, so on
the two DST changeover days every span was an hour off. A day is 23 or 25
hours wide there, and the busy gate then read a 14:00 meeting as 13:00 or
15:00. Both readings are time.Date now, and the midnight crossing is AddDate
rather than a 24-hour add.

The iCal parse did not unfold content lines. A server folds a property at 75
octets and a Russian summary is two bytes a letter, so the tail of an
ordinary weekly standup was read as an unknown property and dropped, and the
event was filed under a truncated name. RFC 5545 TEXT escapes are also
reversed now, which RenderICal has always written and the parse never undid.

Two regression tests: a folded and escaped summary, and a span across the
start of DST in Europe/Berlin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:23:26 +04:00
claude 8102c73f83 Merge the say, persona and ttsnorm sweep (#245)
A spoken defect she says out loud. ttsnorm rewrote a time as
p[1] + " часов " + p[2] + " минут", a literal join with no agreement and no
zero handling. So 21:00 was read as 21 часов, 22:00 as 22 часов, and 14:00 as
14 часов 00 минут. Russian inflects the noun after a numeral, and say.CountWord
already owns that rule. A new spokenTime calls it for both halves and drops the
minute clause when it is zero. 21:00 is 21 час now, and 22:02 is 22 часа 2
минуты.

persona held a fourth copy of the months and the weekdays as hand-written
arrays. CLAUDE.md names months a closed class with exactly one copy in
internal/lexicon, and ruwords.go already gave its copy up under V-525. The block
calls lexicon.Weekday and lexicon.MonthGenitive now, and the existing test
already asserted the output.

LoadSummaries required {n} and {days} on stall_sitting but not {word} or
{dayword}, the two count forms beside them. A variant dropping one would have
loaded and spoken a bare number.

The brief's premise about the persona checks was wrong and is worth recording.
The say lines are already folded into the same CheckAddress, CheckFeminine and
CheckCringe run as the four phraser families, at fallbacks_test.go:56. Read by
hand as well: the self-reference is feminine throughout, the owner is ты, and
there is no вы, no он and no pet name. They are checked and they pass.

(V-581)
2026-08-06 03:20:52 +04:00
claude 8c36e7ef84 say, persona, ttsnorm: the clock is spoken and the months have one copy (V-581)
A clock time read aloud now inflects its nouns and drops its leading zeros.
The old rewrite said "часов" for every hour and "минут" for every minute, so
21:00 came out as "21 часов" and 14:00 as "14 часов 00 минут". Russian
inflects a noun after a numeral and internal/say already owns that rule, so
spokenTime calls say.CountWord for both halves and omits the minutes when
there are none. 21:00 is "21 час", 22:02 is "22 часа 2 минуты", 14:00 is
"14 часов".

internal/persona held its own copies of the twelve months and the seven
weekdays. Both are closed classes and both already live in internal/lexicon,
which is where cmd/mavend/ruwords.go sent its copy. The block now reads
lexicon.Weekday and lexicon.MonthGenitive and carries no word list of its own.

LoadSummaries asserted that stall_sitting keeps its two counts and not the two
count words beside them. A variant dropping {word} or {dayword} would have
loaded and spoken a bare number. Both are required now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:20:10 +04:00
claude 95cbf82e38 Merge the memory and store sweep (#244)
The embedder prefix audit came back clean, which was the one finding worth
escalating. Every EmbedQuery, EmbedPassage and Embed call site across
internal/store, internal/memory and their cmd/mavend callers agrees. No naked
Embed on a note.

ReembedAll and RepairFactVectors each ran an identical select and scan over
memory_vectors before diverging on what to do with the row. One
allMemVectorMetas now, parameterized over a small interface so it serves
backfill's transaction and factvectors' plain read alike.

Two swallowed errors. AcceptProposedRoutine read RowsAffected with a discarded
error where every other call in the same file checks it, so a driver error read
as zero rows. MarkAcked did the same, and the branch it fed was dead, since both
arms returned nil. The swallowed error and the branch went together.

The agent refuted the rest of the brief. Repeated scans and swallowed errors
were one instance each rather than the pattern tasks.go showed. Both packages
carry per-type scan helpers already, and every magic value is already named with
its reason beside it, which reads as the residue of earlier sweep waves.

(V-581)
2026-08-06 03:15:10 +04:00
claude 5bca435146 sweep: dedupe memory_vectors scan, fix two swallowed errors (V-581)
allMemVectorMetas (memory.go) replaces the identical query-then-scan
block ReembedAll and RepairFactVectors each had for reading id+meta
out of memory_vectors — same query, same json.Unmarshal, different
structs built from the result.

Two RowsAffected() errors were silently dropped with `_`, inconsistent
with every other call site in the same files: AcceptProposedRoutine
now wraps the error instead of treating it as zero rows, and MarkAcked
had it stranded behind a dead branch (both arms returned nil) removed
along with the swallowed error.

No behavior change; internal/store and internal/memory pass with
-race.
2026-08-06 03:13:47 +04:00
claude 69270f4cfb Merge the web sources sweep (#243)
Three of the four packages were already clean on the brief's priorities. The
brief predicted missing timeouts and unbounded reads; websearch already had a
status check, a deferred close, a 4 MiB limit, an 8s total and a 1.5s connect
cap on a cloned transport.

The one bug with reach was a string grep across a package boundary.
crawl.isServerError decided whether a failed robots.txt blocks a crawl by
scanning err.Error() for " 50", " 51", " 52" and " 53", in a message built two
packages away. Rewording that message would silently turn a 503 robots.txt into
permission to crawl, which the surrounding comment says must never happen. Both
packages now carry a typed StatusError that unwraps to the existing sentinel, so
errors.Is is unchanged, and isServerError reads a number.

webfetch checked the status after reading the body, the same shape the weather
sweep found. A 500 pulled its error page up to MaxBytes off the wire, and an
error page over the cap returned ErrTooLarge, naming the size and hiding the
status. rss.Parse copied a feed document that can reach a megabyte through
strings.NewReader(string(...)).

Two comments claimed callers that do not exist.

The privacy invariant holds across all four. None of them can read the store.
rss.Ranker is the one seam that could carry notes outward, it is nil in the
daemon, and its doc states the constraint. Every regex here is over structured
input.

(V-581)
2026-08-06 03:13:46 +04:00
claude 04584fb2da webfetch checks the status before it reads the body (V-581)
A non-2xx reply was read in full first and only then rejected. Two costs
followed. A 500 with a large error page pulled up to MaxBytes off the wire for
nothing. An error page over the cap returned ErrTooLarge, which names the size
and hides the status the server actually sent.

The status is a typed error now. webfetch.StatusError carries the code and
unwraps to ErrStatus, so errors.Is keeps working and errors.As reads the number.
crawl.StatusError is the same shape on the other side of the seam, and
cmd/mavend/crawls.go carries the code across.

That removes the string grep in crawl.isServerError, which decided whether a
failed robots.txt blocks a crawl by looking for " 50" in an error message it did
not own. A reworded error would have turned a 503 robots.txt into permission to
crawl. It reads the code now.

Two comments corrected. webfetch.HostMatches said the crawler calls it and
nothing outside the package does. rss.PlainText said the crawler's extractor
goes through it and crawl/extract.go has its own pass.

The rss poller parses the feed straight off the byte slice instead of copying a
document that can run to a megabyte through a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:12:59 +04:00
141 changed files with 6647 additions and 419 deletions
+23 -2
View File
@@ -285,8 +285,29 @@ site cannot change a route and a context with no record costs nothing. It is
installed in `runTurn`, so the mic, telegram and the web all leave the same
trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
Nothing persists: a turn record is read minutes later or never, and his words do
not belong in a table that outlives the diagnosis. Adding a rung to the ladder
**It also persists, since 06-08-2026, and that reverses what this section used to
say** (V-629, `docs/plans/21-persisting-the-routing-trace.md`). The old rule was
that nothing persists, because a turn record is read minutes later or never. The
owner reversed it: the routing heads (V-546) cannot be fitted or calibrated
without real utterances, and 9 of the 31 modes in `internal/modes` have no seed
example at all. The ring did not move. It is still what `/trace` reads and still
what a test with no store gets. `cmd/mavend/routingtrace.go` is a second sink
beside it, writing `routing_traces` (migration #23). The utterance is stored in
clear, because a 384-dimension vector of a short sentence is substantially
recoverable and storing vectors instead would be a privacy claim we cannot
support. What makes it safe is the same thing that makes the fact store safe.
Retention is 14 days, enforced on write and again on start, so a box that goes
quiet does not keep every row. Nothing reads it outward, and the rule
that his notes and facts are never search input covers this table. `Store.Wipe`
deletes it with everything else. A correction (V-630) is promoted out into a
seed-shaped row in `routing_labels` (migration #24) and kept, because a label is
not a transcript. The transcript still expires. The gesture that writes one is
two buttons beside the reply on `/chat`, reached over `ipc.CorrectTurn` and the
trace id that now rides back on `ipc.ChatReply`. A turn marked wrong with no
target is a usable negative, so naming the intent is never required. The target
is one of the seven intents and never free text. Only `/chat` offers it: the wire
op assumes no browser, but telegram and voice do not call it yet, and
`docs/plans/22-correcting-a-turn.md` says why voice is the hard one. Adding a rung to the ladder
in `runTurn` means adding its name to `preRouteLadder` in
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
+113
View File
@@ -0,0 +1,113 @@
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
//
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
// project, and it names the stage 0 grammars as the high-precision label
// functions to start from. This runs them — the real ones, in the real
// buildRouter order — rather than a reimplementation, so a rule change moves
// the training data with it.
//
// A grammar that declines leaves the line unlabeled. Those go to the model, and
// keeping them is the point: a set labeled only by the rules teaches only the
// rules.
//
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
//
// The wakeword-act grammar is absent, because its allowlist is the deployment's
// enabled tool names and this tool has no deployment. Every other rule is here.
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/kami/maven/internal/router"
)
// label is one output row. The grammar name rides along so a reviewer can see
// which rule made the claim, and so a rule that turns out to be wrong can have
// its rows pulled without re-running everything.
type label struct {
Utterance string `json:"utterance"`
Intent string `json:"intent,omitempty"`
Grammar string `json:"grammar,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
Fn string `json:"fn,omitempty"`
Text string `json:"text,omitempty"`
Labeled bool `json:"labeled"`
}
// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
// load-bearing there and so it is here: the agenda rules must sit after the
// clock rules, Praxis before the capture marker, the narrative rules last.
func grammars() []router.Grammar {
var g []router.Grammar
g = append(g, router.SystemTimeDateGrammars()...)
g = append(g, router.AgendaQueryGrammars()...)
g = append(g, router.FeedQueryGrammar())
g = append(g, router.TaskListGrammar())
g = append(g, router.ListGrammars()...)
g = append(g, router.ReminderGrammar())
g = append(g, router.PraxisGrammars()...)
g = append(g, router.TaskCaptureGrammar())
g = append(g, router.NarrativeQueryGrammars()...)
return g
}
func match(gs []router.Grammar, utterance string) label {
out := label{Utterance: utterance}
for _, g := range gs {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
continue
}
d, ok := g.Build(m)
if !ok {
continue // the rule saw its shape and declined it
}
out.Intent = string(d.Intent)
out.Grammar = g.Name
out.Key = d.Slots.Key
out.Value = d.Slots.Value
out.Fn = d.Slots.Fn
out.Text = d.Slots.Text
out.Labeled = true
return out
}
return out
}
func main() {
gs := grammars()
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
out := bufio.NewWriter(os.Stdout)
defer out.Flush()
enc := json.NewEncoder(out)
var seen, labeled int
for in.Scan() {
line := strings.TrimSpace(in.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
seen++
l := match(gs, line)
if l.Labeled {
labeled++
}
if err := enc.Encode(l); err != nil {
fmt.Fprintln(os.Stderr, "labelgen:", err)
os.Exit(1)
}
}
if err := in.Err(); err != nil {
fmt.Fprintln(os.Stderr, "labelgen:", err)
os.Exit(1)
}
// Coverage on stderr, so the count is visible without polluting the JSONL.
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
}
+12
View File
@@ -60,6 +60,17 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
case errors.Is(err, tool.ErrUnknownTarget):
// The verb reached a tool and the tail did not reach a target, so
// nothing ran. Saying which word she could not place is the whole
// answer: he either renames it or gives the row an alias that
// carries the target, and both are one turn away (V-634).
word := ""
var unknown *tool.UnknownTargetError
if errors.As(err, &unknown) {
word = unknown.Target
}
return phraser.A(phraser.ActUnknownTarget, map[string]string{"name": word})
case errors.Is(err, tool.ErrNeedsAuthedSurface):
// Irreversible (internal/tool/risk.go). A confirm turn would not
// help: everything that proposed this act — the STT, the router,
@@ -93,3 +104,4 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
}
return phraser.A(phraser.ActDone, nil)
}
+17 -4
View File
@@ -235,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
//
// Read-only by construction — the plan is assembled and rendered core-side and
// nothing here schedules or announces. "что дальше?" asks for the rest of the
// day, so that phrasing trims what has already passed.
// day, so that phrasing trims what has already passed and reads only the next
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
// nothing, and she read 43 entries aloud in one sentence (V-618).
//
// "что у меня сегодня?" is a different question and is not narrowed here — it
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
// answers the whole day.
//
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
// surface: Maven holds the work board, runs the intake form, never argues").
@@ -254,16 +260,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
}
// Rebuild the pure plan so the rest-of-day rendering is the same code that
// rendered the whole day — one formatter, one persona.
p := morning.Plan{Date: plan.Date}
//
// The instants are put back in the asking clock's zone on the way in. They
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
// hours in the plan's own frame, so setting that frame here is what makes
// the recital name his clock rather than the store's (V-614).
zone := h.now().Location()
p := morning.Plan{Date: plan.Date.In(zone)}
for _, it := range plan.Items {
p.Items = append(p.Items, morning.PlanEntry{
At: it.At,
At: it.At.In(zone),
Text: it.Text,
Kind: morning.PlanKind(it.Kind),
Uncertain: it.Uncertain,
})
}
return p.After(h.now()).FormatRU(), true
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
}
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
+4 -3
View File
@@ -19,9 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
// time wasn't parsed. Run the parser as a fallback.
if dec.Stage == 0 && h.timeParser != nil {
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
// Same gate as the extractor (V-577, V-579): a request that named
// no hour gets asked about, never completed from the clock.
if err == nil && ok && router.NamesAnHour(dec.Utterance) {
// Same gate as the extractor (V-577, V-579, V-610): a request whose
// hour was not spoken, or was spoken and not read, gets asked about
// and is never completed from the clock.
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
dec.Slots.Time = t
dec.Slots.HasTime = true
}
+13 -1
View File
@@ -24,6 +24,14 @@ type pendingHexisExec struct {
entityID string
displayName string
expiry time.Time
// correlationID — the id the proposing turn minted for this action. A
// confirm arrives on a later turn with a context of its own, so without
// carrying it here the execution recorded a fresh id and no causation at
// all, and the resolve, the discovery and the thing they authorised sat in
// the trace as unrelated calls. The contract mints one id per action, and
// the action began when she asked.
correlationID string
}
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
@@ -144,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
return hx != nil && !h.now().After(hx.expiry)
},
yes: func() string {
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
execCtx := ctx
if hx.correlationID != "" {
execCtx = withCorrelationID(execCtx, hx.correlationID)
}
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
},
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
},
+7
View File
@@ -173,6 +173,13 @@ func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, erro
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
case errors.Is(err, webfetch.ErrStatus):
// Carry the code across the seam. The crawler needs to tell a 5xx
// from a 404 to decide what a failed robots.txt means, and it must
// not learn that by reading this sentence.
var se *webfetch.StatusError
if errors.As(err, &se) {
return nil, &crawl.StatusError{Code: se.Code}
}
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
}
return nil, err
+84
View File
@@ -4,12 +4,14 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
}
}
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
// instants on it carry the zone the core read them in — a calendar fact's Ts
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
// the store's clock instead of his (V-614). The asking clock is three hours off
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
h := &reactiveHandler{
api: &planAPI{plan: ipc.DayPlan{
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
}},
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
}
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
}
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
}
}
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
// nothing, because the whole day is still ahead. She read 43 entries aloud as
// one sentence. The zone is three hours off UTC so the test also fails under
// TZ=UTC if the rendering ever slips zones.
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
zone := time.FixedZone("MSK", 3*60*60)
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
for i := 0; i < 43; i++ {
plan.Items = append(plan.Items, ipc.DayPlanItem{
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
Text: fmt.Sprintf("пункт %d", i),
Kind: "event",
})
}
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
}}
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
}
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
t.Errorf("the next thing is not first: %q", reply)
}
// The rest is counted, not silently dropped.
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
t.Errorf("the sentence hides that the day goes on: %q", reply)
}
}
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
// plan word, so the plan source declines it and the calendar listing answers —
// asserted here beside the cap so the two questions cannot drift together.
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
if router.IsDayPlanQuery("что у меня сегодня?") {
t.Error("the plan source claims the whole-day question")
}
if !router.IsDayPlanQuery("что дальше?") {
t.Error("the plan source stopped claiming the rest-of-day question")
}
if router.IsRestOfDayQuery("какие планы на сегодня?") {
t.Error("the whole-day plan question got narrowed to the rest of the day")
}
}
// A question that is not about the plan must fall through, or the plan buries
// the calendar listing and the weather behind it.
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
+2 -1
View File
@@ -31,7 +31,8 @@ import (
// and nothing should: a missing name costs one line of the record, while a
// check that walks the ladder would have to run the ladder.
var preRouteLadder = []string{
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair",
"repair-negative", "ordinal",
}
// notePreRoute records one rung of that ladder and passes its verdict through
+17 -4
View File
@@ -139,9 +139,9 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
// an item id from the value slot, call one Praxis endpoint, trace the result.
type praxisItemAction struct {
verbs []string
ask string // reply when no item id was given
op string // trace + log name of the operation
verbs []string
ask string // reply when no item id was given
op string // trace + log name of the operation
// failure is the first half of the reply when the Praxis call errors: which
// operation did not happen. ecosystemGap supplies the second half, which
// names Praxis and splits a refused token from an outage — those two used to
@@ -346,14 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
})
var parts []string
var spoken []string
for _, item := range items {
title, _ := item["title"].(string)
if title == "" {
continue
}
parts = append(parts, title)
surfaceSpoken(ctx, px, item)
if id := surfaceSpoken(ctx, px, item); id != "" {
spoken = append(spoken, id)
}
}
// The scoped digest is a list she read out, so it replaces the positional
// memory exactly as the unscoped one does. It used to surface these items
// and remember none of them, which left the previous digest live: "отметь
// второй как сделанное" then indexed into a list he had not just heard and
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
// transitions the wrong item).
h.rememberSurfaced(spoken)
if known := h.localFactsForEntity(ctx, entityID); known != "" {
parts = append(parts, known)
}
@@ -768,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
entityID: entityID,
displayName: displayName,
expiry: h.now().Add(confirmTTL),
// This action's id, so the execution the confirm authorises is
// joined to the resolve and the discovery that proposed it.
correlationID: correlationIDFromCtx(ctx),
}
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
+80
View File
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
t.Fatalf("expected success once nexus recovers, got %q", reply)
}
}
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
// she read out, so a positional follow-up must land on one of ITS items. It
// surfaced them and remembered none, which left the previous digest live and
// sent "отметь второй" at somebody else's item.
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
scoped := fixturePraxisAttentionScoped("ent_muzick",
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
praxis := newFakePraxis(t, scoped)
h := ecoHandler(t, nexus, praxis, nil)
// A digest from an earlier turn, still the positional memory.
h.rememberSurfaced([]string{"item_stale"})
reply := h.handlePraxisAct(ctx, router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
})
if !strings.Contains(reply, "indexer wedged") {
t.Fatalf("expected the scoped item to be read out, got %q", reply)
}
h.mu.Lock()
surfaced := append([]string(nil), h.surfacedItems...)
h.mu.Unlock()
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
}
// The follow-up resolves against what he just heard, not the stale list.
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
t.Fatal("positional follow-up should have been claimed by praxis")
}
var body string
for _, r := range praxis.Requests() {
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
body = string(r.Body)
}
}
if !strings.Contains(body, "item_scoped_1") {
t.Fatalf("resolve must transition the item she read out, posted %q", body)
}
if strings.Contains(body, "item_stale") {
t.Fatal("resolve transitioned an item from a previous digest")
}
}
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
// later turn with a context of its own. The contract mints one id per action,
// so the execution it authorises must still be joinable to the resolve and the
// discovery that proposed it — it recorded a fresh id and no causation at all.
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
}
resolve := findTrace(t, h, "nexus", "resolve")
if resolve == nil || resolve.CorrelationID == "" {
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
}
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
t.Fatal("confirm should have been claimed")
}
exec := findTrace(t, h, "hexis", "execute")
if exec == nil {
t.Fatal("expected a hexis execute trace")
}
if exec.CausationID != resolve.CorrelationID {
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
exec.CausationID, resolve.CorrelationID)
}
}
+97
View File
@@ -9,6 +9,7 @@ import (
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -35,6 +36,11 @@ type routedTurn struct {
utterance string
intent router.Intent
at time.Time
// traceID — the persisted trace of this turn, stamped after the fact by
// stampLastTurn. 0 when nothing persisted, and then a spoken correction
// still teaches the classifier: the durable label is the half that needs a
// row to point at (V-636).
traceID int64
}
// repairWindow — how long a turn stays correctable. Long enough that he can
@@ -54,6 +60,13 @@ const repairWindow = 5 * time.Minute
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
var repairMarkers = lexicon.RepairMarkers()
// repairNegatives — "she got it wrong" with no target. Matched against the whole
// utterance, because these are complete sentences and the markers above are
// fragments: "это не" needs an intent word after it, "не так поняла" does not.
// Substring matching here would claim "не так" out of any sentence containing it
// (V-636).
var repairNegatives = lexicon.RepairNegatives()
// repairIntents — the words he uses for each intent, as dictionary forms. They
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
@@ -147,6 +160,18 @@ func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
}
// stampLastTurn attaches the trace id to the turn a correction would point at.
// It cannot be done in recordTurn: the trace is written when the turn ends, and
// recordTurn runs in the middle of it.
func (h *reactiveHandler) stampLastTurn(utterance string, traceID int64) {
h.mu.Lock()
defer h.mu.Unlock()
if h.lastRouted == nil || h.lastRouted.utterance != utterance {
return
}
h.lastRouted.traceID = traceID
}
func (h *reactiveHandler) takeLastTurn() *routedTurn {
h.mu.Lock()
defer h.mu.Unlock()
@@ -157,6 +182,56 @@ func (h *reactiveHandler) takeLastTurn() *routedTurn {
return last
}
// resolveUntargetedRepair handles the cheap half of a spoken correction: he says
// she got it wrong and does not say what it should have been (V-636).
//
// It is worth having on its own. V-630 made the target optional on the web for
// the same reason: a turn marked wrong with no target is a usable negative, and
// requiring the target would cost the correction he was willing to give. Voice
// needs it more than the web does — naming an intent aloud means saying
// "заметка" or "факт", which is Maven's vocabulary and not his.
//
// Nothing is redone and the classifier is not taught. There is no target, so
// there is nothing to redo it as and nothing to teach. Only the label is written,
// and she says so, because a correction he cannot see reads as one that was
// dropped.
func (h *reactiveHandler) resolveUntargetedRepair(ctx context.Context, text string) (string, bool) {
if !isRepairNegative(text) {
return "", false
}
last := h.takeLastTurn()
if last == nil || h.now().Sub(last.at) > repairWindow {
return "", false
}
if last.traceID == 0 {
// No row to point at, so there is no label to write and nothing this
// resolver can do. Routing the words normally is the honest outcome.
return "", false
}
h.labelCorrection(ctx, last, "")
log.Printf("voice: repair — %q marked wrong, no target given", last.utterance)
return phraser.A(phraser.RepairNoted, nil), true
}
// isRepairNegative matches the whole utterance, minus a leading "нет" and any
// trailing punctuation. "нет, не так" is the shortest one he says.
func isRepairNegative(utterance string) bool {
s := strings.ToLower(strings.TrimSpace(utterance))
s = strings.TrimRight(s, " .!?")
for _, p := range []string{"нет,", "нет", "no,", "no"} {
if rest := strings.TrimSpace(strings.TrimPrefix(s, p)); rest != s && rest != "" {
s = rest
break
}
}
for _, n := range repairNegatives {
if s == n {
return true
}
}
return false
}
// resolveRepair handles a spoken correction of the previous turn: teach the
// classifier, redo the request under the corrected intent, and say so.
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
@@ -182,6 +257,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
learned = false
}
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
h.labelCorrection(ctx, last, string(corrected))
dec := router.Decision{
Utterance: last.utterance,
@@ -207,3 +283,24 @@ func repairLine(say string, learned bool) string {
}
return "поняла, это " + say + " — запомнила."
}
// labelCorrection promotes a spoken correction into routing_labels, the same
// table the /chat gesture writes (V-630, V-636).
//
// Two sinks and not one, because they keep different things. CorrectMisroute
// appends a classifier seed, which is what makes the NEXT turn better today.
// The label is what a fitted head trains on later, it survives the 14-day
// transcript, and until now only the web produced any. A sample that only ever
// held typed turns would skew to whatever he happens to be at a keyboard for,
// and voice is where the hard cases are.
//
// Best-effort and silent. He has already been told the correction landed, and a
// second sink failing is not his problem to hear about.
func (h *reactiveHandler) labelCorrection(ctx context.Context, last *routedTurn, shouldBe string) {
if h.api == nil || last == nil || last.traceID == 0 {
return
}
if err := h.api.CorrectTurn(ctx, last.traceID, shouldBe); err != nil {
log.Printf("voice: repair: could not label trace %d: %v", last.traceID, err)
}
}
+93
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
@@ -149,3 +150,95 @@ func TestRepairIntentWordCollisions(t *testing.T) {
}
}
}
// V-636. A spoken correction lands in the same table the /chat gesture writes,
// so the sample is not limited to the turns he happened to type.
func TestSpokenCorrectionWritesTheLabel(t *testing.T) {
h, st, _ := newClarifyHandler(t)
emb := router.NewHashEmbedder(256)
h.recall.embedder = emb
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
ctx := context.Background()
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
Ts: h.now(), Utterance: "купить хлеб", Intent: "fact", Source: "tap:voice",
})
if err != nil {
t.Fatal(err)
}
h.recordTurn("купить хлеб", router.IntentFact)
h.stampLastTurn("купить хлеб", id)
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
t.Fatal("the correction was not handled")
}
labels, err := st.RoutingLabels(ctx, 5)
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 || labels[0].Was != "fact" || labels[0].ShouldBe != "note" {
t.Fatalf("labels %+v: the spoken correction did not land as a pair", labels)
}
}
// The cheap half, which voice needs more than the web does: naming an intent
// aloud means saying "заметка", which is her vocabulary and not his.
func TestUntargetedSpokenCorrection(t *testing.T) {
h, st, now := newClarifyHandler(t)
ctx := context.Background()
seed := func(utterance string) int64 {
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
Ts: h.now(), Utterance: utterance, Intent: "query", Source: "tap:voice",
})
if err != nil {
t.Fatal(err)
}
h.recordTurn(utterance, router.IntentQuery)
h.stampLastTurn(utterance, id)
return id
}
seed("поужинал")
reply, handled := h.resolveUntargetedRepair(ctx, "нет, не так")
if !handled {
t.Fatal("«нет, не так» was not read as a correction")
}
if reply == "" {
t.Error("a correction he cannot hear reads as one that was dropped")
}
labels, err := st.RoutingLabels(ctx, 5)
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 || labels[0].ShouldBe != "" || labels[0].Was != "query" {
t.Fatalf("labels %+v: want one untargeted negative naming what she chose", labels)
}
// Outside the window it is a fresh sentence, not a verdict.
seed("поужинал ещё раз")
*now = now.Add(repairWindow + time.Minute)
if _, handled := h.resolveUntargetedRepair(ctx, "не так"); handled {
t.Error("a correction outside the window was handled")
}
}
// Whole-utterance, never a substring. This is the difference between the
// negatives and the markers, and getting it wrong would claim any sentence with
// "не так" in it.
func TestRepairNegativeIsTheWholeUtterance(t *testing.T) {
for _, s := range []string{
"не так поняла", "нет, не так", "ты ошиблась", "неправильно", "wrong", "no, that was wrong",
} {
if !isRepairNegative(s) {
t.Errorf("%q is not read as a correction", s)
}
}
for _, s := range []string{
"это не важно", "напомни не так поздно", "а не завтра", "не так, а вот так — это заметка",
"", "нет",
} {
if isRepairNegative(s) {
t.Errorf("%q was read as a correction", s)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
//
// internal/decision keeps a 25-turn in-memory ring and persisted nothing, on the
// argument that a turn record is read minutes later or never. The owner reversed
// that on 06-08-2026, because the routing heads (V-546) cannot be fitted or
// calibrated without real utterances and there is no other source of them. The
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
//
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
// wired no store still gets. This file is the second sink beside it, and it is
// nil unless the daemon has a database — no store, no trace, no error.
package main
import (
"context"
"encoding/json"
"log"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/store"
)
// traceWriter is the seam the handler persists through. store.Store satisfies
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
type traceWriter interface {
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
}
// traceSink wraps the store, or returns nil when there is none. A typed nil
// pointer assigned straight into the interface would be non-nil and would panic
// on the first turn, which is the classic shape of this bug.
func traceSink(s *store.Store) traceWriter {
if s == nil {
return nil
}
return s
}
// The trace id rides the context, the same seam querysource.go uses and for the
// same reason: handleText answers every reach through one string, and threading
// a second value through the whole action dispatch would change a signature the
// mic, telegram and the web all share. A caller that wants the id asks for a
// sink; the mic path does not, and pays nothing.
type traceIDKey struct{}
type traceIDSink struct {
mu sync.Mutex
id int64
}
func (s *traceIDSink) note(id int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.id = id
}
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
func (s *traceIDSink) ID() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.id
}
// withTraceIDSink returns a context that collects the persisted trace id, and
// the sink to read after the turn has answered.
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
sink := &traceIDSink{}
return context.WithValue(ctx, traceIDKey{}, sink), sink
}
func noteTraceID(ctx context.Context, id int64) {
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
sink.note(id)
}
}
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
// alone is not enough: a box that goes quiet for a month keeps every row until
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
// only of a box in daily use. Called for its effect and never blocks a start.
func pruneTracesOnStart(s *store.Store, now time.Time) {
if s == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil {
log.Printf("routing trace: prune on start: %v", err)
}
}
// persistDecision writes one finished record. It takes the same *decision.Record
// the ring takes, so the two sinks cannot disagree about what the turn did.
//
// Errors are logged and swallowed. A trace is diagnostic and training data, and
// a failed insert must never change what the owner hears.
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
ctx := turnCtx
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
return
}
// Detached from the turn's context, and bounded on its own. Two reasons, and
// the first is the one that matters: the turn is over by the time this runs,
// so a caller that hung up or timed out would cancel the insert, and the turn
// he abandoned halfway is exactly the one worth having. The second is that a
// write must not hold the reply, so it gets a second and no more.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
defer cancel()
claims, err := json.Marshal(rec.Claims)
if err != nil {
log.Printf("routing trace: marshal claims: %v", err)
return
}
tr := store.RoutingTrace{
Ts: rec.Ts,
Utterance: rec.Utterance,
Source: string(src),
Winner: rec.Winner,
Intent: wonIntent(rec),
ClaimedBeforeHead: claimedBeforeHead(rec),
EncoderID: h.encoderID,
Outcome: wonAt(rec, decision.StageAction),
Claims: claims,
}
id, err := h.traces.WriteRoutingTrace(ctx, tr)
if err != nil {
log.Printf("routing trace: write: %v", err)
return
}
// The id goes back to whoever asked for it, so /chat can offer a correction
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
// not the detached one above: the sink belongs to the caller's turn.
noteTraceID(turnCtx, id)
// And the spoken path, which has no reply to hang a badge on: a correction
// said out loud points at the previous turn, so it needs that turn's row
// (V-636, repair.go).
h.stampLastTurn(rec.Utterance, id)
}
// wonIntent — what the winning claimant made the turn. Read from the claim
// rather than from the route, because a pre-route resolver wins without routing
// and its intent is the honest answer to "what was this turn".
func wonIntent(rec *decision.Record) string {
for _, c := range rec.Claims {
if c.Outcome == decision.Won && c.Intent != "" {
return c.Intent
}
}
return ""
}
// wonAt — the claimant that won at one stage. The action stage is what actually
// produced the reply, which is a different question from what was routed: a
// route that reached a gap and a route that ran are not the same turn.
func wonAt(rec *decision.Record, stage string) string {
for _, c := range rec.Claims {
if c.Stage == stage && c.Outcome == decision.Won {
return c.Claimant
}
}
return ""
}
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
// turn teaches nothing about the classifier. Those are a large share of real
// traffic, and fitting a head on them would fit it to the grammars rather than
// to him. Recorded per turn rather than filtered on write, because which share
// that is happens to be the number V-632 needs to know.
func claimedBeforeHead(rec *decision.Record) bool {
stage, _, ok := strings.Cut(rec.Winner, ":")
if !ok {
return false
}
return stage == decision.StagePreRoute || stage == decision.StageZero
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"context"
"testing"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/store"
)
// A real turn leaves a persisted trace, not only a ring entry. This is the whole
// of V-629: without one there is nothing to fit the routing heads from.
func TestTurnPersistsTrace(t *testing.T) {
ring := decision.NewRing()
h := traceHandler(t, ring)
h.traces = traceSink(h.dataStore)
h.encoderID = "hash-1024"
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
t.Fatal("turn produced no reply")
}
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("persisted %d traces, want 1", len(got))
}
tr := got[0]
if tr.Utterance != "сколько сейчас времени" {
t.Errorf("utterance %q", tr.Utterance)
}
if tr.Source != string(sourceText) {
t.Errorf("source %q, want %q", tr.Source, sourceText)
}
// A stage-0 clock rule answers this one, so the turn teaches the classifier
// nothing and the trace has to say so.
if !tr.ClaimedBeforeHead {
t.Errorf("claimed_before_head false on winner %q", tr.Winner)
}
if tr.EncoderID != "hash-1024" {
t.Errorf("encoder_id %q", tr.EncoderID)
}
if len(tr.Claims) < 3 {
t.Errorf("claims %s: the losers and the never-asked are the point", tr.Claims)
}
}
// No store, no trace, and no panic. A typed nil pointer in the interface would
// pass the nil check and die on the first turn.
func TestNoStoreNoTrace(t *testing.T) {
ring := decision.NewRing()
h := traceHandler(t, ring)
h.traces = traceSink(nil)
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
t.Fatal("turn produced no reply")
}
if len(ring.Recent(5)) != 1 {
t.Error("the ring is still the first sink and must still hold the turn")
}
}
// An empty utterance writes nothing. A blank row carries no label and no
// diagnosis, and it is his words the retention bound exists for.
func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
h := traceHandler(t, decision.NewRing())
h.traces = traceSink(h.dataStore)
h.persistDecision(context.Background(), &decision.Record{Utterance: " "}, sourceText)
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("persisted %d traces for a blank utterance", len(got))
}
}
var _ traceWriter = (*store.Store)(nil)
// The trace id rides back to the caller, which is what makes a correction one
// gesture: /chat already has the id, so saying "that was wrong" costs a button
// and no lookup (V-630).
func TestTurnHandsBackItsTraceID(t *testing.T) {
h := traceHandler(t, decision.NewRing())
h.traces = traceSink(h.dataStore)
ctx, sink := withTraceIDSink(context.Background())
if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" {
t.Fatal("turn produced no reply")
}
id := sink.ID()
if id == 0 {
t.Fatal("no trace id came back, so /chat can offer no correction")
}
// And it names the turn that just ran, so the correction lands on the right
// utterance.
if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil {
t.Fatal(err)
}
labels, err := h.dataStore.RoutingLabels(context.Background(), 5)
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" {
t.Fatalf("labels %+v, want the turn that just ran", labels)
}
}
// A turn nobody asked the id of costs nothing, which is the mic path.
func TestTurnWithNoSinkStillPersists(t *testing.T) {
h := traceHandler(t, decision.NewRing())
h.traces = traceSink(h.dataStore)
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
t.Fatal("turn produced no reply")
}
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("persisted %d traces, want 1", len(got))
}
}
+19 -9
View File
@@ -17,6 +17,7 @@ import (
"time"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/say"
)
@@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
// date for a day she did not understand.
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
// dayWords — day references the calendar parser cannot resolve. A weekday name
// or a "через …" phrase means he asked about a specific other day.
var dayWords = []string{
"понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен",
"через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
}
// mentionsUnknownDay reports whether the question names a day the calendar
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
// pick an honest reply over a confidently wrong one.
//
// Only called after ParseCalendarDate has already failed, so "завтра" and the
// other words it does know never reach here.
//
// The weekday half was a list of STEMS matched with strings.Contains until
// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern
// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern
// always is: "среди", "средство" and "средний" all contain "сред", so a question
// carrying any of them was answered with onlyNearDaysReply instead of the date.
// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads
// the lexicon and asks the dictionary about the case.
func mentionsUnknownDay(u string) bool {
for _, w := range dayWords {
if strings.Contains(u, w) {
for _, tok := range quietTokens(u) {
if tok == "через" {
return true
}
if _, ok := router.WeekdayIndex(tok); ok {
return true
}
}
@@ -149,6 +154,11 @@ func hasDurationWords(u string) bool {
// Used by the query handler when answering "когда я это сделал?"-style questions.
func formatTime(t time.Time) string {
now := time.Now()
// The argument is a fact's Ts, which the store hands back as UTC. Only the
// last branch names a wall clock, and it named the store's until V-614: an
// answer to "когда я это сделал?" read hours off, in the same sentence
// shape the plan reads a day in.
t = t.Local()
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
return "только что"
}
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"strings"
"testing"
"time"
)
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
// UTC, and the branch that names a wall clock printed it in whatever zone it
// arrived in (V-614). The instant here is built three hours off this machine's
// zone, so the assertion holds under TZ=UTC as well.
func TestFormatTimeReadsHisClock(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Now().Add(-72 * time.Hour).In(away)
got := formatTime(stored)
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
}
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
}
}
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
// weekday half of this guard was a list of stems matched with strings.Contains,
// so "среди", "средство" and "средний" all read as Wednesday and the question
// was answered with onlyNearDaysReply instead of a date.
//
// The other half of the fix is coverage: a stem list stops at the forms whoever
// wrote it thought of, and "воскресеньях" was not one of them.
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
for _, u := range []string{
"какое число в понедельник",
"какое число в среду",
"какое число в среде",
"что там по воскресеньям",
"what is the date on friday",
"какое число через неделю",
} {
if !mentionsUnknownDay(u) {
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
}
}
for _, u := range []string{
"какое число в среднем",
"сколько это в среднем",
"какое сегодня средство",
"какое число",
} {
if mentionsUnknownDay(u) {
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
}
}
}
+10 -1
View File
@@ -97,10 +97,19 @@ func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.Ch
return ipc.ChatReply{}, errors.New("mavend: chat not available")
}
ctx, sink := withQuerySourceSink(ctx)
// The trace id rides back the same way (V-630), so /chat can offer a
// correction on the turn it is already showing. 0 when nothing persisted.
ctx, traces := withTraceIDSink(ctx)
reply := d.chatFn(ctx, conversation, text)
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
return ipc.ChatReply{Reply: reply, Source: sink.Name(), TraceID: traces.ID()}, nil
}
// CorrectTurn is NOT overridden here, and that is deliberate (V-630). Every other
// diagnostic on this type exists because the daemon holds something the store
// cannot answer from a table. A correction is a table, so the embedded store
// adapter is already the right answer and a second implementation here would be
// a second place for it to drift.
// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
+24 -1
View File
@@ -145,6 +145,17 @@ type reactiveHandler struct {
// is recorded, which is what a test that did not ask for one gets.
decisions *decision.Ring
// traces persists those same records (V-629, routingtrace.go). The ring is
// still what /trace reads; this is the second sink, and it exists because the
// routing heads cannot be fitted without real utterances. nil ⇒ the ring
// alone, which is the behaviour every box had before 06-08-2026.
traces traceWriter
// encoderID names the encoder body live on this box, stored beside each
// trace: a fitted distance means nothing under another body. Empty ⇒ no
// embedder, so the classifier was the keyword floor.
encoderID string
// clarifyStore parks the request behind an open question she asked (see
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
clarifyStore *dialogue.ClarifyStore
@@ -265,7 +276,11 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
var rec *decision.Record
ctx, rec = decision.With(ctx, text)
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
defer func() { h.decisions.Push(rec.Finish(h.now())) }()
defer func() {
done := rec.Finish(h.now())
h.decisions.Push(done)
h.persistDecision(ctx, done, src)
}()
}
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
@@ -350,6 +365,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
return withNotice(expiredNotice, reply)
}
// 4d-ii. and the same correction without a target — "нет, не так" (V-636).
// After the targeted one, which is the narrower claim: an utterance that
// names an intent is answered by redoing the request, and this rung only
// gets the ones that name nothing.
if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) {
return withNotice(expiredNotice, reply)
}
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
// just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word
+25 -2
View File
@@ -149,6 +149,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
w.embedder = emb
repairFactVectors(dataStore, emb)
checkStoredEmbedder(dataStore, emb)
// Retention is enforced on write, which is not enough on its own: a box that
// goes quiet keeps every trace until the next sixty-fourth turn (V-629).
pruneTracesOnStart(dataStore, time.Now())
// ----- tool executor (the enabled act allowlist, store-backed) -----
// Config tools are the declarative bootstrap: seed them into the store as
@@ -176,7 +179,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// The LAN scanner (Vikunja #257): a read, bounded to the configured
// subnets and rate-limited. Off unless the `netscan` block is enabled.
w.netscan = wireNetScan(cfg, coreAPI)
matcher := tool.NewMatcher(coreAPI)
matcher := tool.NewMatcher(coreAPI).WithAliases(toolAliases(cfg.Voice.Tools))
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
var weatherProvider weather.Provider
@@ -298,7 +301,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// Always on (V-564). The record is the instrument the rest of V-558 is
// measured with, and one that only runs when a flag is set is not there
// on the night the misroute happens.
decisions: decision.NewRing(),
decisions: decision.NewRing(),
// The second sink (V-629). Same records, persisted, because the routing
// heads cannot be fitted from a 25-turn ring. Nil store ⇒ ring only, and
// EmbedderID is the same string the vector marker uses, so a trace and a
// stored vector name their body the same way.
traces: traceSink(dataStore),
encoderID: router.EmbedderID(emb),
clarifyStore: clarifyStore,
// 0 here (unset config) ⇒ the dialogue default.
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
@@ -515,6 +524,20 @@ func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
return count, nil
}
// toolAliases collects the spoken phrases per tool name. Without them the act
// matcher only ever matched the English tool name, so no Russian utterance could
// reach a tool and every homelab act fell to proposeGap (V-633).
func toolAliases(tools []config.ToolConfig) map[string][]string {
out := make(map[string][]string, len(tools))
for _, tc := range tools {
if tc.Name == "" || len(tc.Aliases) == 0 {
continue
}
out[tc.Name] = tc.Aliases
}
return out
}
// seedTools upserts the config-declared tools into the store as enabled. Editing
// mavend.json is a human act, so a config tool is enabled by definition; this
// makes the declarative config the reproducible bootstrap while the store stays
+39 -9
View File
@@ -7,6 +7,10 @@ package main
import (
"regexp"
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/router"
)
// isWeatherQuery returns true if the utterance is about weather.
@@ -27,16 +31,42 @@ func isWeatherQuery(u string) bool {
// come through whole and "в 5 утра" does not.
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
// weatherNonPlaces — words that follow "в" in a weather question and are not
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
// "тепло в комнате" is the same question about the same room.
var weatherNonPlaces = map[string]bool{
// weatherRooms — the rooms of the house, which are the only words in this
// guard that belong to it. "какая погода в доме" is the smart-home sensor, not
// Open-Meteo, and "тепло в комнате" is the same question about the same room.
//
// The rest of the guard used to be a third copy of three closed sets that
// already exist in the lexicon: the weekdays, the parts of the day, and the
// words that follow "в" without naming a place (V-581). Each copy was short in
// its own direction — "среду" but not "среде", "утром" but not "утра", "целом"
// but not "общем" — so the same question phrased one word differently reached
// the geocoder as a city.
var weatherRooms = map[string]bool{
"доме": true, "квартире": true, "комнате": true, "спальне": true,
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
"выходные": true, "субботу": true, оскресенье": true, "понедельник": true,
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
"обед": true, "обеде": true, ыходные": true, "выходных": true,
}
// isWeatherNonPlace reports whether the word after "в" names something other
// than a place he could ask the weather for.
func isWeatherNonPlace(word string) bool {
if weatherRooms[word] {
return true
}
if _, ok := router.WeekdayIndex(word); ok {
return true
}
for _, w := range lexicon.PartsOfDay() {
if word == w || morph.SameWord(word, w) {
return true
}
}
for _, w := range lexicon.NotPlaceAfterV() {
if word == w {
return true
}
}
return false
}
// extractWeatherLocation returns the place he named, or the configured default
@@ -63,7 +93,7 @@ func extractWeatherLocation(u, defaultLoc string) string {
}
place := strings.TrimSpace(m[1])
first := strings.ToLower(strings.Fields(place)[0])
if weatherNonPlaces[first] {
if isWeatherNonPlace(first) {
return defaultLoc
}
return place
+7
View File
@@ -29,6 +29,13 @@ func TestExtractWeatherLocation(t *testing.T) {
// the house sensors and the day words answer elsewhere.
{"тепло в комнате?", "Berlin", "Berlin"},
{"какая погода в выходные", "Berlin", "Berlin"},
// The cases the three private copies of the lexicon were short by
// (V-581): a weekday in a case the old map did not list, a part of the
// day in one it did not list, and "в общем".
{"какая погода в среде", "Berlin", "Berlin"},
{"какая погода в воскресеньях", "Berlin", "Berlin"},
{"какая погода в понедельникам", "Berlin", "Berlin"},
{"какая погода в общем", "Berlin", "Berlin"},
}
for _, c := range cases {
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
+34 -1
View File
@@ -77,6 +77,21 @@ func run(args []string) error {
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
}
// A bad duration or an empty -wg-cmd used to get past start and kill the
// poller on the first tick — time.NewTicker panics on a non-positive
// interval, and pollWg indexed field 0 of an empty command. A zero -timeout
// is worse than a crash: http.Client reads it as "no deadline", so one
// wedged source stalls every other source behind it forever. Refuse all
// three here, where the operator sees the message.
if *interval <= 0 {
return fmt.Errorf("-interval must be positive, got %s", *interval)
}
if *timeout <= 0 {
return fmt.Errorf("-timeout must be positive, got %s", *timeout)
}
if *wgIface != "" && strings.TrimSpace(*wgCmd) == "" {
return fmt.Errorf("-wg-cmd is empty but -wg is set")
}
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
if err != nil {
@@ -283,9 +298,19 @@ const (
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
func (p *poller) pollWg(ctx context.Context) error {
fields := strings.Fields(p.wgCmd)
if len(fields) == 0 {
return fmt.Errorf("wg command is empty")
}
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
if err != nil {
// wg says why it refused on stderr — usually a missing CAP_NET_ADMIN or
// an interface that does not exist. Output() drops that, leaving a log
// line that reads "exit status 1" and diagnoses nothing.
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return fmt.Errorf("run %s: %w: %s", p.wgCmd, err, strings.TrimSpace(string(ee.Stderr)))
}
return fmt.Errorf("run %s: %w", p.wgCmd, err)
}
maxTs := parseMaxHandshake(string(out))
@@ -552,6 +577,11 @@ func isNoFact(err error) bool {
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
//
// Hitting the cap is an error, not a shorter body. A truncated kuma page parses
// cleanly right up to the cut, and every monitor past it reads as deleted — the
// poller would write "unknown" over live services and the down-rule would go
// quiet. Reading one byte past the cap is how we tell full from truncated.
const maxBodyBytes = 4 << 20
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
@@ -567,12 +597,15 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
if len(body) > maxBodyBytes {
return nil, fmt.Errorf("GET %s: body over %d bytes", url, maxBodyBytes)
}
return body, nil
}
+57
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -212,3 +213,59 @@ func TestRunRequiresSomethingToPoll(t *testing.T) {
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
}
}
// A flag value that would kill the poller later is refused at start, before it
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
// zero timeout means http.Client waits forever, and an empty wg command used to
// index field 0 of an empty slice.
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
err := run(args)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("err = %v, want %q", err, c.want)
}
})
}
}
// pollWg refuses an empty command rather than panicking on fields[0].
func TestPollWgEmptyCommand(t *testing.T) {
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
if err := p.pollWg(context.Background()); err == nil {
t.Error("want an error, got a poll that ran something")
}
}
// A body at the cap is a truncated body, and a truncated kuma page reads as
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
func TestGetRefusesTruncatedBody(t *testing.T) {
big := strings.Repeat("x", maxBodyBytes+64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, big)
}))
defer srv.Close()
p := &poller{http: srv.Client()}
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
t.Error("want an over-size refusal, got a silently truncated body")
}
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "ok")
}))
defer small.Close()
body, err := p.get(context.Background(), small.URL, "")
if err != nil || string(body) != "ok" {
t.Errorf("get = %q, %v; want the whole small body", body, err)
}
}
+105 -2
View File
@@ -2,12 +2,15 @@ package main
import (
_ "embed"
"errors"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/webauthn"
)
@@ -25,6 +28,21 @@ type chatMsg struct {
// Source — the query source that claimed the turn, shown as a badge beside
// the reply. Empty for a turn no source claimed (V-539).
Source string
// TraceID anchors the correction gesture (V-630). Non-zero ⇒ the turn was
// persisted and can be corrected in one click. 0 ⇒ no correction is offered,
// which is honest: a box with no database has no turn to correct.
TraceID int64
// Corrected — the owner already corrected this turn, so the page says thank
// you instead of offering the buttons again.
Corrected string
}
// correctionTargets — the seven public intents, in the order the buttons are
// shown. Read from internal/router rather than typed out, so a new intent cannot
// exist without a way to correct a turn into it.
var correctionTargets = []router.Intent{
router.IntentFact, router.IntentNote, router.IntentReminder,
router.IntentQuery, router.IntentAct, router.IntentChat, router.IntentSystem,
}
// handleChatPage renders the chat conversation page.
@@ -38,12 +56,21 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
msgs = append(msgs, chatMsg{Role: "user", Text: q})
}
if reply := r.URL.Query().Get("r"); reply != "" {
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
id, _ := strconv.ParseInt(r.URL.Query().Get("t"), 10, 64)
msgs = append(msgs, chatMsg{
Role: "assistant", Text: reply, Source: r.URL.Query().Get("s"),
TraceID: id, Corrected: r.URL.Query().Get("c"),
})
}
// UserText rides beside the messages so the correction form can hand the
// conversation back on the redirect: this page has no session and no JS, so
// what is on screen is what the query params carry.
renderPage(w, chatTmpl, struct {
Error string
Messages []chatMsg
}{Messages: msgs})
Targets []router.Intent
UserText string
}{Messages: msgs, Targets: correctionTargets, UserText: r.URL.Query().Get("q")})
}
// handleChatAPI processes a chat message POST and redirects back to /chat.
@@ -88,5 +115,81 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
if reply.Source != "" {
dest += "&s=" + url.QueryEscape(reply.Source)
}
// The trace id rides along so the reply can carry a correction gesture
// (V-630). Absent when nothing persisted, and the page then offers none.
if reply.TraceID != 0 {
dest += "&t=" + strconv.FormatInt(reply.TraceID, 10)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// handleCorrectAPI records that the last turn was routed wrongly (V-630).
//
// A correction is the only supervised signal this box gets, and everything else
// in the trace accumulates on its own. So the gesture has to cost nothing: one
// POST from the reply he is already looking at, carrying the trace id and
// optionally the intent it should have been. An unstated target is accepted,
// because a turn marked wrong with no target is still a usable negative.
//
// Step-up gated like POST /api/chat, and that costs the gesture nothing: he
// tapped to send the turn he is now correcting, so the session is already up.
// It is gated because trace ids are sequential integers and this writes the one
// table the routing heads (V-546) will be fitted on. A caller who can guess an
// id could otherwise mislabel turns he never corrected.
func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if !requireCore(w, core, "correct") {
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, "trace_id required", http.StatusBadRequest)
return
}
shouldBe := strings.TrimSpace(r.FormValue("should_be"))
// Only one of the seven, or nothing. Free text here would put an unroutable
// label in the one table V-632 fits prototypes from.
if shouldBe != "" && !isCorrectionTarget(shouldBe) {
http.Error(w, "should_be must be one of the seven intents", http.StatusBadRequest)
return
}
if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil {
log.Printf("correct turn %d: %v", id, err)
// A turn past the retention bound is gone, and saying so is different
// from saying the write broke.
if errors.Is(err, ipc.ErrNoSuchTrace) {
http.Error(w, "that turn is no longer stored", http.StatusNotFound)
return
}
http.Error(w, "correction failed", http.StatusBadGateway)
return
}
stamp := shouldBe
if stamp == "" {
stamp = "wrong"
}
// Back to the conversation he was in, with the turn still on screen. The
// query params carry it, so the correction is preserved by re-sending them.
dest := "/chat?q=" + url.QueryEscape(r.FormValue("q")) +
"&r=" + url.QueryEscape(r.FormValue("rep")) + "&c=" + url.QueryEscape(stamp)
if s := r.FormValue("s"); s != "" {
dest += "&s=" + url.QueryEscape(s)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// isCorrectionTarget — one of the seven, and nothing else.
func isCorrectionTarget(s string) bool {
for _, t := range correctionTargets {
if string(t) == s {
return true
}
}
return false
}
+15
View File
@@ -5,6 +5,21 @@
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
{{if and (eq .Role "assistant") .TraceID}}
{{if .Corrected}}
<div class=chat-correct><span class="badge badge-ok" title="the label is kept; the transcript still expires in 14 days">corrected: {{.Corrected}}</span></div>
{{else}}
<form method=post action=/api/correct class=chat-correct>
<input type=hidden name=trace_id value="{{.TraceID}}">
<input type=hidden name=q value="{{$.UserText}}">
<input type=hidden name=rep value="{{.Text}}">
<input type=hidden name=s value="{{.Source}}">
<button class="btn btn-sm" title="wrong, and I am not saying what it was">wrong</button>
<span class=chat-correct-label>should have been:</span>
{{range $.Targets}}<button class="btn btn-sm btn-muted" name=should_be value="{{.}}">{{.}}</button>{{end}}
</form>
{{end}}
{{end}}
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/kami/maven/internal/ipc"
)
// correctCore records the correction the handler sends.
type correctCore struct {
ipc.UnimplementedCoreAPI
traceID int64
shouldBe string
called bool
err error
}
func (c *correctCore) CorrectTurn(_ context.Context, traceID int64, shouldBe string) error {
c.called, c.traceID, c.shouldBe = true, traceID, shouldBe
return c.err
}
func postCorrect(form url.Values) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/correct", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
// The full gesture: wrong, and it should have been a fact.
func TestCorrectAPIWithTarget(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{
"trace_id": {"42"}, "should_be": {"fact"}, "q": {"поужинал"}, "rep": {"поняла"},
}), core, stepUpSession(), false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status %d, want 303; body=%s", rr.Code, rr.Body.String())
}
if core.traceID != 42 || core.shouldBe != "fact" {
t.Errorf("corrected trace %d to %q", core.traceID, core.shouldBe)
}
// The turn stays on screen, and the page says it was corrected.
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "c=fact") || !strings.Contains(loc, "q=") {
t.Errorf("redirect %q loses the turn or the correction", loc)
}
}
// The cheap half. A turn marked wrong with no target is still a usable negative,
// and it must not cost more to give than the full answer.
func TestCorrectAPIWithNoTarget(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core, stepUpSession(), false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status %d, want 303", rr.Code)
}
if !core.called || core.shouldBe != "" {
t.Errorf("called=%v shouldBe=%q, want an untargeted negative recorded", core.called, core.shouldBe)
}
if !strings.Contains(rr.Header().Get("Location"), "c=wrong") {
t.Errorf("redirect %q does not say the turn was marked wrong", rr.Header().Get("Location"))
}
}
// Free text here would put an unroutable label in the one table V-632 fits
// prototypes from.
func TestCorrectAPIRejectsUnknownTarget(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core, stepUpSession(), false)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400", rr.Code)
}
if core.called {
t.Error("wrote a label for a target that is not one of the seven")
}
}
func TestCorrectAPINeedsTraceID(t *testing.T) {
for _, form := range []url.Values{{}, {"trace_id": {"0"}}, {"trace_id": {"nope"}}} {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(form), core, stepUpSession(), false)
if rr.Code != http.StatusBadRequest {
t.Errorf("form %v: status %d, want 400", form, rr.Code)
}
if core.called {
t.Errorf("form %v: reached the core", form)
}
}
}
// A write that broke is not a turn that expired, and the two must not read the
// same to the owner deciding whether to correct again.
func TestCorrectAPIReportsFailure(t *testing.T) {
core := &correctCore{err: errors.New("disk is full")}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
if rr.Code != http.StatusBadGateway {
t.Fatalf("status %d, want 502", rr.Code)
}
}
// A trace past the retention bound is gone, and the surface says that.
func TestCorrectAPIExpiredTurn(t *testing.T) {
core := &correctCore{err: ipc.ErrNoSuchTrace}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
if rr.Code != http.StatusNotFound {
t.Fatalf("status %d, want 404", rr.Code)
}
}
func TestCorrectAPIPostOnly(t *testing.T) {
rr := httptest.NewRecorder()
handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{}, stepUpSession(), false)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("status %d, want 405", rr.Code)
}
}
// Every one of the seven intents has a button, so a new intent cannot exist with
// no way to correct a turn into it.
func TestCorrectionTargetsAreTheSeven(t *testing.T) {
if len(correctionTargets) != 7 {
t.Fatalf("%d targets, want the seven public intents", len(correctionTargets))
}
for _, want := range []string{"fact", "note", "reminder", "query", "act", "chat", "system"} {
if !isCorrectionTarget(want) {
t.Errorf("%s is not offered", want)
}
}
if isCorrectionTarget("") {
t.Error("empty is not a target: it is the absence of one, handled separately")
}
}
// Trace ids are sequential, so a caller who cannot assert step-up must not be
// able to label a turn the owner never corrected.
func TestCorrectAPINeedsStepUp(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status %d, want 403", rr.Code)
}
if core.called {
t.Error("wrote a label with no step-up")
}
}
+7 -2
View File
@@ -14,8 +14,13 @@ memory only, so a restart empties this.</div>
<div class=scroll><table class=mono>
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
{{range .Events}}<tr>
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
is the bus's local instant, OccurredAt is whatever zone the source used —
the store hands back UTC and internal/rss parses a pubDate to UTC — so
rendering them raw put two zones side by side in the same row and made a
feed item look hours older than it was. -->
<td>{{.NoticedAt.Local.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.OccurredAt.Local.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.Source}}</td>
<td class=gray>{{.Kind}}</td>
<td class=gray>{{.Priority}}</td>
+34 -1
View File
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
if !strings.Contains(body, want) {
t.Errorf("page does not mention %q", want)
}
@@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) {
}
}
// awayFromLocal returns a zone three hours off whatever this machine runs in,
// so a test can tell "rendered in his clock" apart from "rendered in whatever
// zone the value arrived in" without depending on TZ.
func awayFromLocal() *time.Location {
_, off := time.Now().Zone()
return time.FixedZone("away", off+3*60*60)
}
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
// OccurredAt carries the source's zone — the store hands back UTC and
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
// instant. Rendered raw, the two columns of one row were in two zones and a
// feed item read hours older than it was.
away := awayFromLocal()
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
noticed := occurred.Add(2 * time.Minute)
core := &eventsCore{events: []ipc.IntakeEvent{{
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
OccurredAt: occurred, NoticedAt: noticed,
}}}
body := getEvents(t, core).Body.String()
const layout = "02.01 15:04:05"
for _, ts := range []time.Time{occurred, noticed} {
if !strings.Contains(body, ts.Local().Format(layout)) {
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
}
if strings.Contains(body, ts.In(away).Format(layout)) {
t.Errorf("page rendered %s in the source's zone", ts)
}
}
}
func TestEventsPageEscapesIntakeText(t *testing.T) {
// Titles come from outside — a feed headline, a notification. They are shown
// on a page and must never be able to inject markup into it.
+1
View File
@@ -210,6 +210,7 @@ func main() {
mux.HandleFunc("/routines", gatedPage(handleRoutines))
mux.HandleFunc("/api/chat", gatedPage(handleChatAPI))
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI))
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
})
+4 -1
View File
@@ -8,7 +8,10 @@
<div class=scroll><table class=mono>
<tr><th>at<th>kind<th>what</tr>
{{range .Items}}<tr>
<td>{{.At.Format "15:04"}}</td>
<!-- In his clock. A plan item's At is a calendar fact's Ts or a reminder's
FireTs, and the store hands both back as UTC, so the raw hour printed a
reminder here at an hour /reminders did not agree with (V-469). -->
<td>{{.At.Local.Format "15:04"}}</td>
<td class=gray>{{.Kind}}</td>
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
</tr>{{end}}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// morningCore serves a canned checklist and day plan.
type morningCore struct {
ipc.UnimplementedCoreAPI
status []ipc.MorningRoutineStatus
plan ipc.DayPlan
}
func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) {
return c.status, nil
}
func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil }
func TestMorningRendersPlanTimesInLocalZone(t *testing.T) {
// A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the
// store hands both back as UTC. Printed raw, /morning named an hour for a
// reminder that /reminders — which does call Local — disagreed with.
away := awayFromLocal()
at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away)
core := &morningCore{plan: ipc.DayPlan{
Date: at,
Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}},
}}
w := httptest.NewRecorder()
handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, at.Local().Format("15:04")) {
t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body)
}
if strings.Contains(body, at.In(away).Format("15:04")) {
t.Errorf("plan item rendered in the stored zone: %s", body)
}
}
+5
View File
@@ -702,6 +702,11 @@ details[open] > summary { margin-bottom: var(--space-1); }
.chat-form { display: flex; gap: var(--space-2); }
.chat-form input { flex: 1; }
.chat-scroll { max-height: 60vh; overflow-y: auto; margin-bottom: var(--space-4); }
/* The correction gesture (V-630). Wraps on a phone rather than scrolling: it is
one row of small buttons, and a gesture that has to be panned to is not one. */
.chat-correct { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1);
padding: 0 var(--space-3) var(--space-2); margin-top: calc(-1 * var(--space-1)); margin-bottom: var(--space-2); }
.chat-correct-label { font-size: var(--fs-xs); color: var(--text-machine); margin-left: var(--space-2); }
/* ── Key-value grid ── */
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--fs-sm); }
+9 -5
View File
@@ -300,11 +300,15 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
return "", err
}
if due != nil {
wgt, err := formWeight(r)
if err != nil {
return "", err
}
wgt, err := formWeight(r)
if err != nil {
return "", err
}
// The importance select is posted whether or not a date is. This ran under
// `if due != nil`, so confirming a candidate as "срочно" with no deadline
// dropped the word on the floor — the row came back normal and nothing said
// why. A promote with neither field set still writes nothing.
if due != nil || wgt != 0 {
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
return "", err
}
+68
View File
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
statusErr error
promoted bool
// The promote path's two extra writes.
fields []any
edits []editCall
editErr error
fieldErr error
}
// editCall records one EditTask, so a test can say what the form actually sent
// down rather than only that the promotion succeeded.
type editCall struct {
ID int64
Text string
Due *time.Time
Weight int
}
func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error {
f.edits = append(f.edits, editCall{id, text, due, weight})
return f.editErr
}
func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error {
f.fields = append(f.fields, []any{id, doneWhen, blockedOn})
return f.fieldErr
}
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) {
}
}
// Confirming a candidate posts the importance select whether or not a date is
// set. The weight write hung off `if due != nil`, so "срочно" with no deadline
// was read off the form and thrown away, and the row came back normal.
func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
"done_when": {"полис на руках"}, "weight": {"3"},
}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if len(core.edits) != 1 {
t.Fatalf("edits = %+v, want the weight written once", core.edits)
}
if core.edits[0].Weight != 3 || core.edits[0].ID != 4 {
t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0])
}
if core.edits[0].Due != nil {
t.Errorf("edit invented a due date: %v", core.edits[0].Due)
}
if core.statusVal != "open" {
t.Errorf("status = %q, want the candidate promoted", core.statusVal)
}
}
// A promote with neither field set still writes nothing: the row is unchanged
// apart from its status, and an EditTask here would be a no-op that can fail.
func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
"done_when": {"полис на руках"}, "weight": {"0"},
}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if len(core.edits) != 0 {
t.Errorf("edits = %+v, want none", core.edits)
}
}
// Out of range clamps rather than 400s; a non-number is a real client error.
func TestApplyTaskPostClampsWeight(t *testing.T) {
core := &fakeTaskCore{created: true}
+24 -12
View File
@@ -212,18 +212,30 @@
"clarify_max_attempts": 3,
"tool_timeout": "30s",
"tools": [
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false },
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false },
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false },
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false },
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false },
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false },
{ "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true },
{ "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true },
{ "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true },
{ "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true },
{ "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true },
{ "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true }
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false,
"aliases": ["статус", "покажи статус", "проверь статус"] },
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false,
"aliases": ["статус докера", "лог докера", "покажи запущенные контейнеры", "покажи контейнеры", "список контейнеров", "что запущено"] },
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false,
"aliases": ["покажи uptime", "аптайм", "как работает сервер", "сколько работает сервер"] },
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false,
"aliases": ["сколько места на диске", "сколько свободного места на диске", "место на диске", "покажи диск"] },
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false,
"aliases": ["свободная память", "сколько оперативной памяти свободно", "покажи память"] },
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false,
"aliases": ["покажи логи", "логи", "лог"] },
{ "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true,
"aliases": ["перезапусти", "перезагрузи", "рестарт"] },
{ "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true,
"aliases": ["останови", "останови сервис"] },
{ "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true,
"aliases": ["запусти", "запусти сервис"] },
{ "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true,
"aliases": ["перезапусти контейнер", "перезагрузи контейнер"] },
{ "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true,
"aliases": ["останови контейнер"] },
{ "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true,
"aliases": ["перезагрузи сервер", "перезагрузи хост"] }
]
}
}
@@ -0,0 +1,51 @@
# Alarm verbs reach stage 0
**06-08-2026. V-627.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
classifier plus the ONNX embedder. No LLM arm in this run.
## What was wrong
`lexicon.ReminderVerbs` held five words and none of them named an alarm. `ReminderGrammar`
in `internal/router/stage0.go` did not read the set at all: it carried the literal
`напомни|remind me`. So no part of the cascade recognised `разбуди`.
Three fixture cases ride on that. Under the classifier they went to fact and act at high
confidence, so the failure was never a near miss:
- `ru-rem-005` "разбуди меня в 6:30" to fact at 0.918
- `ru-rem-009` "разбуди меня полвосьмого" to act at 0.941
- `en-rem-002` "wake me at 6:15" to fact at 0.899
Found while training the V-546 intent head, where the same three cases went to system. The
head reads a spoken time with no known verb in front of it as a clock question. The
classifier was making the same mistake in its own way.
## The change
Four alarm imperatives and bare `wake` join `reminder_verbs`. `ReminderGrammar` builds its
alternation from the set, longest alternative first, and eats an optional `мне`, `меня` or
`me` before the body.
Longest-first is load-bearing. Go's alternation is leftmost-first rather than longest-match,
so `напомнить` listed after `напомни` would never match.
## Result
**66/91 to 69/91, 72.5% to 75.8% full.** Three cases gained, none lost.
All three are the alarms above, and each now carries its time slot, which it did not before.
Clarify counts unchanged at 0 false and 8 missed. The two remaining system failures,
`какое число завтра` and `какой день недели послезавтра`, failed at baseline too.
## What this does not fix
The lexicon addition on its own moved nothing. Measured before touching the grammar:
**66/91**, exactly the baseline. Every consumer of `reminder_verbs` reads it after a reminder
route already exists. A verb that cannot win the route is a verb nobody asks about. The
grammar was the whole change.
Lemma matching in `isReminderVerb` now covers `разбудил` as well as `разбуди`, because one
lemma holds both. That is the trap `cmd/mavend/quiet_toggle.go` documents for `говори`. It
is tolerable here and not in the quiet toggle. `isReminderVerb` runs only on an utterance
already routed to reminder, and it decides where the subject starts. A quiet match flips a
daemon-wide setting from any channel.
+247
View File
@@ -0,0 +1,247 @@
# The fact parser: closed classes against the substring stems they replaced
Measured 2026-08-06 at 22edc3c and its parent 0445693, on the corpus in
`internal/router/factparser_corpus_test.go`. Dated file: it is not edited after
today, and a newer number is a new file.
V-586 rewrote `DefaultFactParser` off hand-written Russian stems onto six closed
classes in `internal/lexicon`. Its commit message reported 64/91 on the RU
routing fixture, unchanged. That number does not bear on the change: the fixture
holds three fact cases and all three miss on intent, so the parser is never
reached. This file measures the parser directly, and runs the LLM arm the
original commit skipped.
**The rewrite is better on the utterances it was designed for and no worse on
the ones it was not.** True positives go 35/40 to 39/40, misfires rejected go
8/15 to 14/15. What neither version has is coverage: of 36 plausible utterances
whose word is in no lexicon set, the substring parser caught 3 by accident and
the closed-class parser catches 0. That is the honest headline. The word list
did not shrink the vocabulary — it never had one — it made the boundary visible.
## The corpus
91 cases, three classes. **True positives** are sentences the owner would say,
with the key that must be written. **Misfires** are sentences the substring
parser wrote a fact for and should not have, including the three hard negatives
the rewrite was argued on. **False negatives** are sentences a reasonable person
would say whose word is in no set at all; `want` is the key a human would
assign, and the ship parser is expected to miss them. They are the measurement
of what a closed class costs, not a bug list.
The old parser is carried in the test file as `legacyFactParse`, copied verbatim
from 0445693, so the comparison reruns:
```sh
deps/go/go/bin/go test -run TestFactParserCorpus -v ./internal/router/
```
## Score
| | true positives | misfires rejected | false-negative cases recovered |
|---|---|---|---|
| old (substring stems, 0445693) | 35/40 | 8/15 | 3/36 |
| **new (closed classes, 22edc3c)** | **39/40** | **14/15** | **0/36** |
Sixteen cases disagree. Eleven of them the rewrite wins, three it loses, and two
are cases neither gets.
**Won.** Every misfire the commit message named — `душа болит`, `в комнате
душно`, `это была беда`, `наша победа`, `на душе легко` — plus `водитель пилота
ждёт`, where two stems in one sentence made the old water arm fire. And five true
positives the stems simply did not list: `перекусил`, `передохнул`, `отдыхаю`,
`пойду спать`, `i showered`. Morphology buys those; a stem list would need a new
entry for each.
**Lost.** `допил воду` is a real regression and the only failing true positive.
The vendored dictionary lemmatises `допил` to `допилить`, to finish sawing —
exactly the collision `drink_verbs` already carries `пил` and `пили` as surface
forms to dodge, left unhandled for the prefixed form. `допить` is in the set and
the sentence still misses. It is flagged `broken` in the corpus rather than
fixed, because this branch measures.
`был в душе` and `после душа полегчало` are the price of matching the shower set
exactly. The dictionary makes `душ` and `душа` one word, so a lemma test cannot
tell a shower from a soul; exact matching keeps `на душе легко` out and loses
the oblique cases of the real noun with it. The old parser got both by accident,
along with the soul. That trade is right — writing a shower fact when he said
his soul feels light is worse than missing one — but it is a trade and the two
rows are what it costs.
`недоспал` is the third loss and the least defensible: the old substring `спал`
caught it, and `недоспать` is in no set.
**Neither.** `обеденный перерыв отменили` — a cancelled lunch break — is a fact
for both parsers, `meal` for the old one off the adjective and `break` for the
new one off `перерыв`. Nothing in either design reads the cancellation.
`дрых до обеда` is scored `meal` by both, because the meal arm runs first and
`обеда` is in it, which is not wrong so much as beside the point.
## The false-negative surface
This is the half the routing fixture cannot see and the half that decides
whether the design holds. 36 cases, 0 recovered:
- **water**`выпил чаю`, `глотнул воды`, `хлебнул воды`, `выпил стакан`,
`i hydrated`, `finished my bottle of water`. The water arm needs a noun AND a
verb, so an elided noun or an unlisted verb drops the whole capture.
- **meal**`ем суп`, `съел бутерброд`, `наелся`, `пожрал`, `полдник был`,
`snack`, `supper`, `brunch`, `i eat now`. `есть` is deliberately absent for
`есть новости по бэкапу`, and `ем`, its most ordinary spoken form, goes with it.
- **shower**`помылся`, `сходил в ванную`, `искупался`, `i am showering`,
plus the two oblique cases above.
- **break**`сделал передышку`, `перекур`, `полежал немного`, `сделал паузу`,
`i took five`, `resting now`.
- **sleep**`вздремнул`, `прикорнул`, `дрых`, `недоспал`, `лёг в двенадцать`,
`сон был короткий`, `i napped`, `took a nap`.
None of these are exotic. They are the second and third word a person reaches
for, and every one of them is a fact the owner stated and Maven silently did not
record. A silent miss is the worst failure mode this parser has: he said it, she
heard it, nothing was written, and nothing told him.
## The routing fixture, LLM arm
The arm 22edc3c skipped. `MAVEN_LLM_URL` points the harness at any llama-server;
the previous run reported none reachable, which was the shell's `HTTP_PROXY` and
not the network. Run against **gemma-4-12B-it-qat-UD-Q4_K_XL on the workstation
at `192.168.1.105:8080`**, the same box as the 02-08 measurement, with
`NO_PROXY=192.168.1.105`:
```sh
NO_PROXY=192.168.1.105 no_proxy=192.168.1.105 \
make eval-models MAVEN_LLM_URL=http://192.168.1.105:8080
```
| | full | intent-only | p50 |
|---|---|---|---|
| llm-only, 0445693 | 51.6% (47/91) | 82.4% | — |
| llm-only, 22edc3c | 52.7% (48/91) | 83.5% | 341ms |
| cascade+llm, 0445693 | 85.7% (78/91) | 93.4% | — |
| **cascade+llm, 22edc3c** | **86.8% (79/91)** | **94.5%** | 334ms |
One case either way, both directions, and the failing set is identical between
the two commits. That is run-to-run variance on a sampling model, not a signal.
The parser change is invisible to the routing fixture on the LLM arm for the
same reason it is invisible on the classifier arm: the three fact cases miss on
intent and the parser is never called. Do not read these rows as evidence about
the parser. They are evidence that the fixture cannot answer the question, which
is why the corpus above exists.
## Verdict
The closed-class rewrite holds up as a rewrite. It is strictly better than what
it replaced on both classes anyone argued about, and the one regression
(`допил`) and one bad trade (the oblique `душ`) are both dictionary collisions
rather than design faults.
It does not hold up as an answer. A closed class is the right mechanism for a
set that is actually closed — interrogatives, weekdays, cardinals — and
"the words a person uses to say he ate" is not that set. The corpus puts a
number on it: 36 ordinary sentences, 0 recovered, and every new one costs a
lexicon edit by whoever notices. The three mechanisms CLAUDE.md names do not
contain the right one for this job. The embedder-topic mechanism is the closest
fit and is wrong too, because this is slot extraction rather than aboutness.
This is a case for the V-546 slot-tagging head. Self-care facts are a bounded
key space (five keys) over unbounded surface forms, which is exactly what a BIO
tagger on e5-small is for: it generalises to `вздремнул` without anyone adding
`вздремнуть` to a list, and max softmax gives the confidence the parser's
hardcoded `true` does not have. Until it lands, the closed classes are the
correct floor and the 36 rows above are the size of the gap they leave.
## The corpus, case by case
| utterance | class | want | old (substring) | new (closed class) |
|---|---|---|---|---|
| `выпил стакан воды` | tp | water | water | water |
| `попил воды` | tp | water | water | water |
| `я попил водички` | tp | water | water | water |
| `пью воду` | tp | water | water | water |
| `воду пил уже` | tp | water | water | water |
| `допил воду` | tp | water | water | — **≠** |
| `запил таблетку водой` | tp | water | water | water |
| `drank water` | tp | water | water | water |
| `i drank some water` | tp | water | water | water |
| `поужинал` | tp | meal | meal | meal |
| `я пообедал` | tp | meal | meal | meal |
| `позавтракал кашей` | tp | meal | meal | meal |
| `перекусил бутербродом` | tp | meal | — | meal **≠** |
| `покушал` | tp | meal | meal | meal |
| `поел супа` | tp | meal | meal | meal |
| `обед был в час` | tp | meal | meal | meal |
| `ужинать буду позже` | tp | meal | meal | meal |
| `i ate` | tp | meal | meal | meal |
| `had lunch` | tp | meal | meal | meal |
| `dinner done` | tp | meal | meal | meal |
| `принял душ` | tp | shower | shower | shower |
| `сходил в душ` | tp | shower | shower | shower |
| `душ принят` | tp | shower | shower | shower |
| `ополоснулся душем` | tp | shower | shower | shower |
| `took a shower` | tp | shower | shower | shower |
| `i showered` | tp | shower | — | shower **≠** |
| `сделал перерыв` | tp | break | break | break |
| `отдохнул полчаса` | tp | break | break | break |
| `передохнул немного` | tp | break | — | break **≠** |
| `отдыхаю` | tp | break | — | break **≠** |
| `был перерыв на обед` | tp | meal | meal | meal |
| `took a break` | tp | break | break | break |
| `спал восемь часов` | tp | sleep | sleep | sleep |
| `спала плохо` | tp | sleep | sleep | sleep |
| `поспал днём` | tp | sleep | sleep | sleep |
| `выспался наконец` | tp | sleep | sleep | sleep |
| `проспал будильник` | tp | sleep | sleep | sleep |
| `пойду спать` | tp | sleep | — | sleep **≠** |
| `slept 8 hours` | tp | sleep | sleep | sleep |
| `i slept badly` | tp | sleep | sleep | sleep |
| `пилот сказал что вылет через час` | misfire | — | — | — |
| `водитель уже подъехал` | misfire | — | — | — |
| `надо заводить машину` | misfire | — | — | — |
| `душа болит` | misfire | — | shower | — **≠** |
| `в комнате душно` | misfire | — | shower | — **≠** |
| `это была беда` | misfire | — | meal | — **≠** |
| `наша победа` | misfire | — | meal | — **≠** |
| `пила лежит в гараже` | misfire | — | — | — |
| `водитель пилота ждёт` | misfire | — | water | — **≠** |
| `обеденный перерыв отменили` | misfire | — | meal | break **≠** |
| `есть новости по бэкапу базы` | misfire | — | — | — |
| `напоминания на завтра есть` | misfire | — | — | — |
| `на душе легко` | misfire | — | shower | — **≠** |
| `пилил доску весь вечер` | misfire | — | — | — |
| `поставь будильник на завтра` | misfire | — | — | — |
| `выпил чаю` | fn | water | — | — |
| `глотнул воды` | fn | water | — | — |
| `хлебнул воды` | fn | water | — | — |
| `воды хлебнул из бутылки` | fn | water | — | — |
| `выпил стакан` | fn | water | — | — |
| `i hydrated` | fn | water | — | — |
| `finished my bottle of water` | fn | water | — | — |
| `ем суп` | fn | meal | — | — |
| `съел бутерброд` | fn | meal | — | — |
| `наелся` | fn | meal | — | — |
| `пожрал` | fn | meal | — | — |
| `полдник был` | fn | meal | — | — |
| `i had a snack` | fn | meal | — | — |
| `having supper` | fn | meal | — | — |
| `brunch was good` | fn | meal | — | — |
| `i eat now` | fn | meal | — | — |
| `был в душе` | fn | shower | shower | — **≠** |
| `после душа полегчало` | fn | shower | shower | — **≠** |
| `помылся` | fn | shower | — | — |
| `сходил в ванную` | fn | shower | — | — |
| `искупался` | fn | shower | — | — |
| `i am showering` | fn | shower | — | — |
| `сделал передышку` | fn | break | — | — |
| `перекур` | fn | break | — | — |
| `полежал немного` | fn | break | — | — |
| `сделал паузу` | fn | break | — | — |
| `i took five` | fn | break | — | — |
| `resting now` | fn | break | — | — |
| `вздремнул` | fn | sleep | — | — |
| `прикорнул на диване` | fn | sleep | — | — |
| `дрых до обеда` | fn | sleep | meal | meal |
| `недоспал` | fn | sleep | sleep | — **≠** |
| `лёг в двенадцать` | fn | sleep | — | — |
| `сон был короткий` | fn | sleep | — | — |
| `i napped` | fn | sleep | — | — |
| `took a nap` | fn | sleep | — | — |
`≠` marks a disagreement. `—` is no fact written.
@@ -0,0 +1,77 @@
# Russian acts reach tools
**06-08-2026. V-633.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
classifier plus the ONNX embedder. No LLM arm in this run.
## What was wrong
Three defects, tangled enough that fixing one alone would have looked like progress.
**No Russian utterance could reach a tool.** `DefaultActMatcher` in
`internal/router/slots.go` matched an exact English prefix, and `internal/tool.Matcher`
delegated straight to it. Its comment claimed "the production matcher is fuzzy, this is the
scaffold floor". There is no other matcher, and `DefaultGrammars` is the only place
`Slots.Fn` is set at stage 0, so the floor was the ceiling. Measured with a throwaway
matcher test over the seeds:
```text
"покажи статус nginx" ok=false "restart nginx" ok=true fn=restart
"сколько места на диске" ok=false "disk" ok=true fn=disk
"свободная память" ok=false "uptime" ok=true fn=uptime
"перезагрузи роутер" ok=false
```
55 of the 69 lines in `models/seeds/act.txt` routed to `IntentAct` and then fell to
`proposeGap`. Praxis was never affected: `PraxisGrammars` fills `Slots.Fn` itself.
**Seven lines were duplicated inside `models/seeds/query.txt`.** A duplicate is a second
identical vector, so it double-weights its region in nearest-neighbour scoring.
```text
сколько человек дома
кто сейчас дома
какая загрузка процессора
сколько свободного места на диске
какой ip адрес у сервера
какая версия софта
сколько оперативной памяти свободно
```
**`как дела у сервера` carried two labels**, in `query.txt:13` and `system.txt:9`. One
string, two identical vectors, disagreeing about the answer.
## The change
Tools carry spoken aliases as config data, in `deploy/mavend.json`. They are not a Russian
stem pattern in code, which CLAUDE.md forbids. They are not on the tool row either. An
ad-hoc tool enabled through `/tools` has no aliases and needs none.
Aliases and names compete in one table, longest phrase first, so "перезагрузи контейнер"
beats "перезагрузи" and "docker-restart" is not shadowed by "restart". Matching is on exact
leading tokens rather than lemmas. `перезагрузи роутер` is a command and `перезагрузил
роутер` is a fact, and a lemma cannot tell the two apart. That is the trap
`cmd/mavend/quiet_toggle.go` documents for `говори`.
The seven duplicates are gone, and `как дела у сервера` stays in `query.txt` only. It left
`system.txt` because system cannot answer it: `replySystem`'s
память/загрузк/аптайм arm returns "системная статистика пока не подключена." and always
did. That arm is a stub, not a mode, so the mode inventory now lists the shape as
`act.tool.hoststats`.
## Result
**69/91, 75.8% full, unchanged.** Clarify counts unchanged at 0 false and 8 missed.
Nothing moved, and that is the honest number. The fixture holds no host-stat case and no
Russian act that reaches a tool, so it cannot see either fix. The new coverage is
`TestActMatcherAliases`, which asserts the twelve utterances above plus the two refusals.
## What this does not fix
Argument quality. `статус sshd` reaches `systemctl status sshd`, but `логи nginx` reaches
`journalctl -n 50 -u nginx` only because the tool's argv prefix ends in `-u`. An alias whose
remainder is a Russian noun ("перезагрузи роутер") hands `systemctl restart роутер` a target
that does not exist. Free text still reaches an argv, which is the resolution rule the
ecosystem contract states for Hexis and not yet true here.
The fixture cannot measure any of this. That is the observability gap V-629 is for.
@@ -0,0 +1,82 @@
# Gemma as a label function, and what it found in the seeds
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
labels the 344 hand-written classifier seeds. Agreement with the label a person already
chose is a precision number rather than a guess.
## What ran
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
seed lines and leaves the rest.
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
retyped. Temperature 0.
## Cost
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
20k examples is under two hours of card, which matches its estimate.
## The stage 0 rules as label functions
Agreement between the grammar's label and the seed file the line came from:
| seed intent | agree |
|---|---|
| reminder | 37/37 |
| query | 9/10 |
| system | 7/8 |
| act | 2/2 |
| chat | 0/4 |
| note | 0/1 |
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
under two intents.
## Gemma against the seeds
**197/277, 71.1%.** By intent:
| seed intent | agree |
|---|---|
| note | 33/33 |
| act | 57/64 |
| fact | 37/40 |
| query | 51/54 |
| chat | 15/35 |
| system | 4/43 |
| reminder | 0/8 |
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
and the prompt holding different definitions of the same intent. Three boundaries carry 42
of them, and V-626 is the fix:
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
of 31-07-2026, which the seeds never received.
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
chat was the only honest destination for a question nothing could answer, and external
search now answers them.
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
bare verb phrase, and that is what a seed file is for.
Four intents have not been redefined since the seeds were written: note, fact, query and
act. They agree at 178 of 191.
## What this says about the plan
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
The plan already budgets a day of the owner reading the set. This says where to spend it.
It also says the two engines in the cascade are being taught different rules on 80 lines.
A routing measurement that swaps between the classifier and the router is measuring some of
that disagreement rather than the models.
@@ -0,0 +1,58 @@
# Moving the seed files onto the router prompt's boundaries
**06-08-2026. V-626.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
classifier plus the ONNX embedder. No LLM arm in this run.
`docs/evals/2026-08-06-seed-labels-vs-router-prompt.md` found three intent boundaries where
`models/seeds` and `routeSystem` disagree. This applies two of them and rejects the third,
because the third was measured and it costs a case.
## Baseline
**64/91, 70.3% full.** Latency p50 22.9ms.
## What moved
**Sensor and host state, system to query. 26 lines.** `какая температура воздуха`,
`сколько памяти занято`, `какой статус сервисов`. The prompt restricts system to the clock,
the calendar date and the assistant itself, which is the V-374 edit of 31-07-2026.
**World questions, chat to query. 8 lines.** `почему небо голубое`, `why is the sky blue`,
`как работает интернет`. Only the genuine world-knowledge lines. An opener about herself
stays in chat. `как тебя зовут` is a question word by rule 4 and about the assistant by
rule 8. The rules are ordered and rule 4 fires first, which reads wrong. That is a prompt
question rather than a seed question.
`system.txt` goes from 43 lines to 17 and `query.txt` from 64 to 98.
## Result
**66/91, 72.5% full.** Two cases gained, none lost.
- `en-sys-002` "turn quiet mode back on", quiet 2/3 to 3/3
- `ru-query-011` "почему сервер тормозит", homelab 5/6 to 6/6
Clarify counts unchanged at 0 false and 8 missed. The eight missed clarifies are the
`ambiguous` tag and this change does not touch them. `TestONNXRecall`, `TestONNXTopics`,
`TestONNXPersonalBoundary` and `TestONNXClaimConfidenceDistribution` all pass.
Thinning system to 17 lines did not hurt it. The two remaining system failures,
`какое число завтра` and `какой день недели послезавтра`, both failed at baseline too.
## The third boundary, measured and rejected
`reminder.txt` holds eight bare verbs: `поставь напоминание`, `создай напоминание`,
`set a reminder`. Rule 9 of the prompt calls an utterance with no named subject unknown.
By the prompt they do not belong in a reminder seed set.
Dropping them scores **65/91**, one below keeping them. `ru-rem-004` "поставь напоминание
через полчаса" falls from reminder to fact, because the centroid loses the phrase the
utterance is built from.
So the seed file and the prompt are not stale against each other here. They have different
jobs. A prompt classifies one utterance and can say it cannot. A nearest-neighbour centroid
is a shape to be near, and a bare verb phrase is part of that shape. The eight lines stay.
That distinction matters past this file. V-546 trains a classification head on labeled
utterances rather than a centroid, and the head is the prompt's kind of thing. These eight
lines are seed data and not training data.
@@ -0,0 +1,62 @@
# Plan: persist the routing trace
**Owner's call, 06-08-2026. Vikunja #629, umbrella #628.**
**Verdict: the per-turn decision record now persists.** That reverses a written decision,
which is the point of this file. It is not an incidental telemetry
feature. Do not read it as one.
Last verified: 06-08-2026 @ 799cf55
## What the old decision said
`internal/decision` kept a 25-turn in-memory ring and persisted nothing. The argument was
in `CLAUDE.md` and it was a good one. A turn record is read minutes after the turn or
never, so a table that outlives the diagnosis buys nothing. His words did not belong in it.
## Why it reversed
V-546 replaces the generative router with classification heads on e5-small. Fitting
prototypes and calibrating a distance both need real utterances. V-631 measured how few
there are. Nine of the 31 modes in `internal/modes` have no seed example at all, and they
are exactly the nine with no deterministic matcher. The seed corpus cannot supply them. A
seed row is a phrase someone wrote for a matcher, not a thing he said. The 202 generated
contrast pairs were tried and cost four points of fixture accuracy.
So the choice was between no routing heads and a persisted trace. The owner chose the trace.
## Retention, and why it is two answers
**Raw trace: 14 days.** `store.RoutingTraceRetention` in `internal/store/routingtraces.go`. That
is the life of a diagnosis with room for a weekend. The bound is an age and not a row
count. The useful question is what she did this week, and a busy Tuesday must not push last
Friday out.
**A correction: indefinite.** The owner corrects a turn on `/chat` (V-630). The pair is then
promoted out of the trace into a seed-shaped row and kept, because a label is not a
transcript. What stays in `routing_traces` is the transcript. It expires on the same 14
days as every other row, corrected or not.
## What keeps it safe
The utterance is stored in clear. A 384-dimension vector of a short sentence is
substantially recoverable. Storing vectors instead would be a privacy claim we cannot
support, and making it would be worse than staying silent.
- **Nothing here leaves the box.** The rule that the owner's notes and facts are never
search input covers this table too. No query source reads it, and no upstream engine can.
- **Retention is enforced on write and again at start.** `WriteRoutingTrace` prunes every
64th row, which is hours at human rate. `pruneTracesOnStart` covers the case write alone
cannot. A box that goes quiet keeps every row until the next sixty-fourth turn. Without
the start-time prune, the bound would hold only for a box in daily use.
- **Deletion already exists.** `Store.Wipe` drops every table the database reports, so
`mavend -wipe -confirm-wipe` covers this one with no list to edit.
- **The ring did not move.** It is still what `/trace` reads and still what a test with no
store gets. The table is a second sink beside it. A failed insert is logged and swallowed,
because a trace must never change what he hears.
## What is not decided
Whether some utterances must never be promoted into a durable label, no matter how badly
they routed. That is a content rule and it belongs beside the personal boundary, not in the trace
writer. Recorded here, left to the owner.
+64
View File
@@ -0,0 +1,64 @@
# Correcting a turn
Last verified: 06-08-2026 @ 0d5bd0a
V-630, under V-628. Reads with `21-persisting-the-routing-trace.md`.
## Why a gesture and not a form
The routing trace (V-629) stores every turn. Almost all of them routed correctly, so
almost all of them teach nothing. A correction is the only high-value supervised signal
the box produces. It is also the only one that costs the owner something to give.
So the design constraint is the cost, not the schema. One gesture beside the reply. No
form and no separate page.
It is step-up gated like the chat POST beside it, which costs nothing: he tapped to send
the turn he is correcting. It is gated because trace ids are sequential integers, and this
is the one table the routing heads will be fitted on.
## Two things to capture, and only one of them is required
A correction has two halves.
- This turn was wrong.
- It should have been *this*.
The second is worth much more. It names which boundary moved, and it is what a fitted
head trains against. But requiring it would price out the first, and a turn marked wrong
with no target is still a usable negative. So the target is optional. The trace carries
`wrong` when he did not say.
The target is one of the seven intents and never free text. V-632 fits prototypes from
that table. An unroutable label would enter it, and a label nothing can score is worse
than no label.
## Where the label lives
`routing_labels`, migration #24, keyed unique on the utterance. A second correction of
the same sentence replaces the first, because his later answer is the one he meant.
It is a separate table from `routing_traces` on purpose. The transcript expires after 14
days. The label does not. A label is a sentence, an intent and an encoder id. That is not
a transcript, and the reversal in doc 21 rests on the distinction.
`was` is stored beside `should_be`. The pair is what names the confusion. A label with no
`was` cannot say which boundary moved.
## Reach
`CorrectTurn(traceID, shouldBe)` takes no browser and no session. The trace id rides back
on `ipc.ChatReply` through the same context sink the query source badge uses. Nothing in
the seam assumes the web.
Only `/chat` offers the gesture today. That is a gap, named rather than closed. If the web
is the only place to correct a turn, the sample skews to whatever the owner types at. Voice
is where the hard cases are. Telegram has the obvious shape, an inline keyboard on the
reply. Voice does not. Inventing a spoken correction grammar would put a recogniser in
front of the one signal that exists to fix recognisers. Both are follow-on work.
## What is not decided
Whether the owner ever wants to see the labels he gave. Nothing reads the table outward
yet. `/trace` shows the ring, which is 25 turns and in memory, and a labels view is a
different page with a different question.
+32
View File
@@ -82,6 +82,38 @@ func TestWAVRoundTrip(t *testing.T) {
}
}
// A LIST chunk sitting between fmt and data is common (arecord and ffmpeg both
// write one), and its payload is free text that can spell "data". The parser
// walks chunk headers, so the text is skipped and the real samples are read.
func TestPCMFromWAVSkipsLISTChunk(t *testing.T) {
t.Parallel()
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
list := []byte("LIST")
payload := []byte("INFOICMTdata is not here")
list = binary.LittleEndian.AppendUint32(list, uint32(len(payload)))
list = append(list, payload...)
plain, err := WAVFromPCM(PCM16kMono, pcm)
if err != nil {
t.Fatalf("WAVFromPCM: %v", err)
}
wav := append([]byte{}, plain[:36]...)
wav = append(wav, list...)
wav = append(wav, plain[36:]...)
binary.LittleEndian.PutUint32(wav[4:8], uint32(len(wav)-8))
f, got, err := PCMFromWAV(wav)
if err != nil {
t.Fatalf("PCMFromWAV: %v", err)
}
if !f.IsValid() {
t.Fatalf("parsed format invalid: %+v", f)
}
if !bytes.Equal(got, pcm) {
t.Fatalf("PCM mismatch: got %v, want %v", got, pcm)
}
}
func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
t.Parallel()
// too short
+30 -12
View File
@@ -36,6 +36,10 @@ const wavHeaderSize = 44
// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is
// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam
// so the caller surfaces it, not a hidden silent downmix.
//
// The returned PCM aliases wav rather than copying it, because a recording is
// large and the caller already owns the bytes. A caller that keeps the PCM past
// the life of wav, or that reuses wav as a read buffer, must copy first.
func PCMFromWAV(wav []byte) (Format, []byte, error) {
if len(wav) < wavHeaderSize {
return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav))
@@ -61,17 +65,13 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample)
}
// data chunk: the spec mandates it appears right after fmt, but real
// recorders sometimes append extra chunks (LIST, fact). Find the "data"
// chunk by scanning; require it within the region we'd expect.
dataIdx := -1
for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ {
if string(wav[i:i+4]) == "data" {
dataIdx = i
break
}
}
if dataIdx < 0 {
return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
// recorders sometimes append extra chunks (LIST, fact). Walk the chunk
// headers rather than scanning for the four bytes "data", because those
// bytes occur inside a LIST/INFO payload as ordinary text and a byte scan
// would take the middle of a comment for a chunk header.
dataIdx, err := findDataChunk(wav)
if err != nil {
return Format{}, nil, err
}
dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8])
body := wav[dataIdx+8:]
@@ -90,6 +90,24 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
return f, body, nil
}
// findDataChunk returns the offset of the "data" chunk header, walking the
// chunk list that starts after the 16-byte fmt chunk. Chunks are word-aligned,
// so an odd size carries one pad byte the next header sits behind.
func findDataChunk(wav []byte) (int, error) {
for pos := wavHeaderSize - 8; pos+8 <= len(wav); {
size := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
if string(wav[pos:pos+4]) == "data" {
return pos, nil
}
next := pos + 8 + size + size%2
if next <= pos || next > len(wav) {
break
}
pos = next
}
return 0, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
}
// WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV
// header so the result can be written to disk and played with `aplay`.
// Used by the reference client to write the TTS reply; not on the wire.
@@ -115,7 +133,7 @@ const WAVHeaderSize = wavHeaderSize
// avoiding.
func WAVHeader(format Format, n int) ([]byte, error) {
if !format.IsValid() {
return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
return nil, fmt.Errorf("audio: WAVHeader: %w: %+v", ErrNotCanonicalPCM, format)
}
out := make([]byte, wavHeaderSize)
// RIFF header
+10 -3
View File
@@ -140,6 +140,12 @@ const EventKeyPrefix = "calendar_event_"
//
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
// meeting covers the quarter hour it actually covers.
//
// Both readings are built with time.Date rather than added to midnight as a
// duration. A day is 23 or 25 hours wide on the two DST changeovers, so
// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy
// gate would then read a 14:00 meeting an hour off. The same goes for the
// midnight crossing, which is AddDate and not a 24-hour add.
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
if !strings.HasPrefix(key, EventKeyPrefix) {
return time.Time{}, time.Time{}, false
@@ -172,10 +178,11 @@ func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok b
if !ok1 || !ok2 {
return time.Time{}, time.Time{}, false
}
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
y, mo, d := day.Date()
start = time.Date(y, mo, d, sh, sm, 0, 0, loc)
end = time.Date(y, mo, d, eh, em, 0, 0, loc)
if !end.After(start) {
end = end.Add(24 * time.Hour)
end = end.AddDate(0, 0, 1)
}
return start, end, true
}
+45 -3
View File
@@ -66,7 +66,7 @@ func ParseICalDay(body []byte, now time.Time) []Event {
// Reports false for all-day events and parse failures.
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
var e Event
for _, line := range strings.Split(block, "\n") {
for _, line := range strings.Split(unfold(block), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "DTSTART"):
@@ -78,9 +78,9 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
e.End = t
}
case strings.HasPrefix(line, "SUMMARY"):
e.Summary = afterColon(line)
e.Summary = unescapeText(afterColon(line))
case strings.HasPrefix(line, "UID"):
e.UID = afterColon(line)
e.UID = unescapeText(afterColon(line))
}
}
if e.Start.IsZero() || e.End.IsZero() {
@@ -89,6 +89,48 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
return e, true
}
// unfold undoes RFC 5545 content-line folding, where a long property is split
// with a CRLF and the continuation begins with one space or tab.
//
// It runs before the block is split into lines, because splitting first and
// trimming each line destroys the leading space that marks a continuation. A
// server folds at 75 octets and a Russian summary is two bytes a letter, so
// "Еженедельная планёрка с командой" crosses the limit easily — without this
// the tail of the summary was read as an unknown property and dropped, and the
// event was filed under a truncated name.
func unfold(block string) string {
if !strings.Contains(block, "\n ") && !strings.Contains(block, "\n\t") {
return block
}
return strings.NewReplacer("\r\n ", "", "\r\n\t", "", "\n ", "", "\n\t", "").Replace(block)
}
// unescapeText reverses the RFC 5545 TEXT escaping escapeText applies. Without
// it a summary a server wrote as "Обед\, потом созвон" reaches the day plan
// with the backslash still in it, and FactKey folds that literal into the key.
func unescapeText(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 >= len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case 'n', 'N':
b.WriteByte('\n')
default:
// ";", ",", "\\" and anything else a writer escaped needlessly.
b.WriteByte(s[i])
}
}
return b.String()
}
func afterColon(line string) string {
if i := strings.Index(line, ":"); i >= 0 {
return strings.TrimSpace(line[i+1:])
+38
View File
@@ -61,6 +61,44 @@ func TestRenderICalEscapesInjection(t *testing.T) {
}
}
// A folded SUMMARY is one property, not a property plus a dropped tail. Servers
// fold at 75 octets and a Russian summary is two bytes a letter.
func TestParseICalUnfoldsAndUnescapes(t *testing.T) {
body := []byte("BEGIN:VEVENT\r\n" +
"UID:u1\r\n" +
"DTSTART:20260703T130000Z\r\n" +
"DTEND:20260703T140000Z\r\n" +
"SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" +
"END:VEVENT\r\n")
from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
events := ParseICal(body, from, from.AddDate(0, 0, 1))
if len(events) != 1 {
t.Fatalf("got %d events, want 1", len(events))
}
if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want {
t.Errorf("Summary = %q, want %q", events[0].Summary, want)
}
}
// A day is 23 hours wide where DST starts, so a wall clock reading has to be
// built with time.Date and never as midnight plus a duration.
func TestFactSpanAcrossDSTStart(t *testing.T) {
loc, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skipf("no tzdata for Europe/Berlin: %v", err)
}
start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc)
if !ok {
t.Fatal("FactSpan reported not ok")
}
if start.Hour() != 14 || start.Minute() != 0 {
t.Errorf("start = %s, want a 14:00 wall clock", start)
}
if end.Hour() != 15 {
t.Errorf("end = %s, want a 15:00 wall clock", end)
}
}
func TestReminderEventEmptyPayload(t *testing.T) {
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
if e.Summary != "напоминание" {
+5 -4
View File
@@ -2,8 +2,8 @@
// utterance (V-565, umbrella V-558, design in
// docs/plans/19-dialogue-arbitration.md).
//
// Maven's cascade has roughly ten stage-0 grammars, seven router intents,
// twenty-two query sources and four stateful pre-emptors, and every one of them
// Maven's cascade has twenty-two stage-0 grammars, seven router intents,
// twenty-two query sources and seven stateful pre-emptors, and every one of them
// answers "is this mine?" alone. None can answer "is this more mine than
// yours?", because their scores are not comparable: stage 0 asserts 1.0 by
// fiat, the classifier reports a cosine, the LLM router derives one from
@@ -56,8 +56,9 @@ const (
// BandStructural — the claimant read the whole sentence and produced a
// complete route, every slot its intent requires filled. The LLM router at
// full confidence, and a stateful claimant holding a pending question.
// Below BandAnchored on purpose: the four stateful claimants pre-empt
// unconditionally today, and that is the V-558 defect.
// Below BandAnchored on purpose: the stateful claimants pre-empt
// unconditionally today, and that is the V-558 defect. There are seven of
// them and preRouteLadder in cmd/mavend/decisiontrace.go is the roster.
BandStructural
// BandAnchored — a literal pattern anchored in the utterance matched, and
+27 -1
View File
@@ -15,6 +15,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
@@ -329,7 +330,15 @@ func Load(path string) (*Config, error) {
// Expand ${VAR} or $VAR patterns from environment variables. This lets
// secrets live in env (docker-compose env_file) rather than the config
// file committed to git.
expanded := os.ExpandEnv(string(b))
expanded, missing := expandEnv(string(b))
if len(missing) > 0 {
// An unset variable expands to "", which every block reads as "not
// configured" and none of them complains about. That is the intended
// behaviour and it stays: CI parses this same file with no secrets
// present. What was missing is the line telling the operator which
// capability he just turned off by forgetting an env file.
log.Printf("config: %s references unset environment variables %v — those settings are empty, so whatever they configure is off", path, missing)
}
var c Config
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
@@ -341,6 +350,23 @@ func Load(path string) (*Config, error) {
return &c, nil
}
// expandEnv is os.ExpandEnv plus the names it could not resolve, each reported
// once and in the order the file mentions them. A variable set to the empty
// string counts as set: the operator wrote it down, so he meant it.
func expandEnv(s string) (string, []string) {
var missing []string
seen := map[string]bool{}
out := os.Expand(s, func(name string) string {
v, ok := os.LookupEnv(name)
if !ok && !seen[name] {
seen[name] = true
missing = append(missing, name)
}
return v
})
return out, missing
}
func (c *Config) applyDefaults() {
if c.IntakeJournal == 0 {
c.IntakeJournal = DefaultIntakeJournal
+6
View File
@@ -48,11 +48,17 @@ type WeatherConfig struct {
// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
// must not fire from the voice path (they need a confirm on an authed surface).
//
// Aliases are the spoken phrases that reach this tool, Russian included. They
// are config data rather than a pattern in code, and they match as exact leading
// tokens, so an imperative reaches the tool and the past tense of the same verb
// does not.
type ToolConfig struct {
Name string `json:"name"`
Scope string `json:"scope,omitempty"`
Cmd []string `json:"cmd"`
Destructive bool `json:"destructive,omitempty"`
Aliases []string `json:"aliases,omitempty"`
}
// Voice defaults, applied in normaliseVoice.
+17 -8
View File
@@ -57,6 +57,16 @@ var (
ErrFetchStatus = errors.New("crawl: the server answered with an error status")
)
// StatusError is ErrFetchStatus with the code the server actually sent. The
// adapter builds it; isServerError reads Code rather than the message, so a
// reworded error can no longer turn a 503 robots.txt into permission to crawl.
type StatusError struct{ Code int }
func (e *StatusError) Error() string {
return fmt.Sprintf("crawl: the server answered with status %d", e.Code)
}
func (e *StatusError) Unwrap() error { return ErrFetchStatus }
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
// interface so this package constructs no http.Client of its own and can be
// tested without a network.
@@ -233,16 +243,15 @@ func (c *Crawler) markFetched(host string) {
c.mu.Unlock()
}
// isServerError — a 5xx rather than any other non-2xx. The adapter formats the
// status into the message, which is the only place it survives.
// isServerError — a 5xx rather than any other non-2xx. A status the adapter
// could not recover reads as 0 and is not a server error, which keeps the
// standard's "404 means allow" as the default for an unknown.
func isServerError(err error) bool {
s := err.Error()
for _, code := range []string{" 50", " 51", " 52", " 53"} {
if strings.Contains(s, code) {
return true
}
var se *StatusError
if !errors.As(err, &se) {
return false
}
return false
return se.Code >= 500 && se.Code <= 599
}
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
+1 -1
View File
@@ -79,7 +79,7 @@ func TestPage_ABrokenRobotsServerIsNotPermissionToCrawl(t *testing.T) {
// way to resolve an unknown.
f := &timedFetcher{
pages: map[string]Response{"https://example.org/a": {Body: []byte("<html><body>a</body></html>")}},
errs: map[string]error{"https://example.org/robots.txt": fmt.Errorf("%w: 503", ErrFetchStatus)},
errs: map[string]error{"https://example.org/robots.txt": &StatusError{Code: 503}},
}
c := New(f, Config{UserAgent: "Maven/1.0"})
if _, err := c.Page(context.Background(), "https://example.org/a"); !errors.Is(err, ErrFetchStatus) {
+12 -2
View File
@@ -19,6 +19,13 @@ type Ring struct {
func NewRing() *Ring { return &Ring{} }
// Push adds one finished record and drops the oldest past the bound.
//
// The dropped pointers are cleared before the reslice. Resliceing alone moves
// the window forward and leaves the evicted records addressable from the
// backing array, so up to ringSize turns he had already aged out stayed in
// memory until the next append reallocated. That is a leak anywhere and it is
// the wrong one here, because the reason this store is memory-only is that his
// words should not outlive the diagnosis.
func (r *Ring) Push(rec *Record) {
if r == nil || rec == nil {
return
@@ -26,8 +33,11 @@ func (r *Ring) Push(rec *Record) {
r.mu.Lock()
defer r.mu.Unlock()
r.recs = append(r.recs, rec)
if len(r.recs) > ringSize {
r.recs = r.recs[len(r.recs)-ringSize:]
if drop := len(r.recs) - ringSize; drop > 0 {
for i := 0; i < drop; i++ {
r.recs[i] = nil
}
r.recs = r.recs[drop:]
}
}
+31 -3
View File
@@ -172,21 +172,49 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespBytes))
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
// error_code + description on failure. parse the body either way so a 200
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
var tr telegramResp
if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
jsonErr := json.Unmarshal(rb, &tr)
if jsonErr == nil && !tr.Ok {
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
}
// A 2xx whose body is not the bot API's envelope did not come from the bot
// API. The normal path here is the relay: this box reaches telegram through
// an HTTP/SOCKS5 proxy, and a proxy that is up but cannot reach
// api.telegram.org answers 200 with an HTML page of its own. Reading that as
// a delivered message is the worst outcome the sink has — the dispatcher
// writes a 'sent' outbox row, MarkSent restarts the repeat clock, and the
// sev4 alarm that never arrived goes quiet for a whole interval. Only
// ok=true is a send.
if jsonErr != nil {
return fmt.Errorf("telegramsink: telegram returned %d with a body that is not the bot API envelope (not a confirmed send): %s", resp.StatusCode, snippet(rb))
}
return nil
}
// maxRespBytes caps the response read — the body is wire-controlled and the
// relay in front of it is not telegram. It is far above any sendMessage
// envelope (a few hundred bytes; the result echoes one short away message),
// because a truncated body no longer parses and now reads as a failed send.
const maxRespBytes = 64 << 10
// snippet trims a response body down to something an error line can carry. A
// relay's HTML page is measured in kilobytes and none of it belongs in the log.
func snippet(rb []byte) string {
s := strings.TrimSpace(string(rb))
if len(s) > 200 {
return s[:200] + "…"
}
return s
}
// sendMessageURL — the bot API path. the token is in the URL path
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
// it anywhere else. the URL is built per-send from the resolved base and never
@@ -291,6 +291,66 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
}
}
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
// of its own. That is not a delivered message, and calling it one silences a
// sev4 alarm for a full repeat interval.
func TestSendRefusesA200ThatIsNotTheBotAPIEnvelope(t *testing.T) {
rs := newRecordingServer(t, http.StatusOK, `<html><body>proxy: upstream unreachable</body></html>`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
if err == nil {
t.Fatal("want error on a 200 that is not the bot API envelope")
}
if !strings.Contains(err.Error(), "not a confirmed send") {
t.Fatalf("error should say the send is unconfirmed, got: %v", err)
}
}
// The success path must stay a success: ok=true on 200 is a send.
func TestSendAcceptsOkTrue(t *testing.T) {
rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7}}`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
t.Fatalf("want success on ok=true, got: %v", err)
}
}
// A body long enough to have been truncated by the old 4096-byte cap still
// parses, so a real send is not reported as a failure.
func TestSendAcceptsAnOversizedButValidEnvelope(t *testing.T) {
rs := newRecordingServer(t, http.StatusOK,
`{"ok":true,"result":{"message_id":7,"text":"`+strings.Repeat("x", 8000)+`"}}`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
t.Fatalf("want success on a large ok=true envelope, got: %v", err)
}
}
// The error line carries a snippet, not the relay's whole page.
func TestSendErrorDoesNotCarryTheWholeBody(t *testing.T) {
rs := newRecordingServer(t, http.StatusBadGateway, strings.Repeat("z", 5000))
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
if err == nil {
t.Fatal("want error on 502")
}
if len(err.Error()) > 500 {
t.Fatalf("error line should be trimmed, got %d bytes", len(err.Error()))
}
}
func TestSendReturnsErrorOnNon2xx(t *testing.T) {
rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway")
srv := httptest.NewServer(rs.handler())
+11 -4
View File
@@ -1,5 +1,7 @@
package email
import "strings"
// windows-1251 (and its ASCII-compatible low half) is decoded here rather than
// pulled in from x/text.
//
@@ -35,14 +37,19 @@ var cp1251High = [128]rune{
// decodeCP1251 maps each byte through the table. Every byte has a defined
// meaning in this charset, so decoding cannot fail.
//
// It writes into a Builder rather than collecting runes: a []rune of the whole
// body is four bytes a character and was then copied again into the string, so
// a 1 MiB cp1251 mail allocated about 6 MiB to produce roughly 2.
func decodeCP1251(b []byte) string {
out := make([]rune, 0, len(b))
var out strings.Builder
out.Grow(len(b))
for _, c := range b {
if c < 0x80 {
out = append(out, rune(c))
out.WriteByte(c)
continue
}
out = append(out, cp1251High[c-0x80])
out.WriteRune(cp1251High[c-0x80])
}
return string(out)
return out.String()
}
+27 -6
View File
@@ -18,6 +18,7 @@
package email
import (
"bytes"
"encoding/base64"
"fmt"
"io"
@@ -57,7 +58,10 @@ type Message struct {
// through, because a subject line alone is often the whole task ("Счёт за
// интернет"). Only a message whose headers cannot be read at all is an error.
func ParseMessage(uid uint32, raw []byte) (Message, error) {
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
// bytes.NewReader, not strings.NewReader(string(raw)): the conversion copied
// the whole message, and MaxMessageBytes lets that be 2 MiB per mail on a box
// already holding the resident model.
m, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return Message{}, fmt.Errorf("email: parse message: %w", err)
}
@@ -82,6 +86,23 @@ func ParseMessage(uid uint32, raw []byte) (Message, error) {
// wholesale — an attachment is a file, not a sentence, and reading one would
// mean parsing arbitrary formats from the network.
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
return plaintextBodyAt(contentType, encoding, body, 0)
}
// MaxMIMEDepth — how deep the MIME tree is walked.
//
// The nesting comes off the wire, so the recursion depth is the sender's to
// pick: a boundary line is a few bytes, and one message inside MaxMessageBytes
// can declare tens of thousands of multipart levels. Real mail is three deep
// (mixed, then alternative, then related), so a message past this is malformed
// or hostile and truncating the walk costs a body nobody was going to read.
const MaxMIMEDepth = 12
// plaintextBodyAt is plaintextBody carrying the current nesting depth.
func plaintextBodyAt(contentType, encoding string, body io.Reader, depth int) (string, error) {
if depth > MaxMIMEDepth {
return "", nil
}
mediaType, params, err := mime.ParseMediaType(contentType)
if contentType == "" || err != nil {
// No Content-Type at all is legal and means text/plain; a broken one is
@@ -94,7 +115,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
if boundary == "" {
return "", fmt.Errorf("email: multipart without boundary")
}
plain, html, err := multipartText(multipart.NewReader(body, boundary))
plain, html, err := multipartText(multipart.NewReader(body, boundary), depth+1)
if err != nil {
return "", err
}
@@ -124,7 +145,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
// contribute either kind. Folding a nested level's answer into one string put
// HTML-derived text in the plain bucket, and a real text/plain sibling later in
// the message was then thrown away by the "plain is already set" guard.
func multipartText(mr *multipart.Reader) (plain, html string, err error) {
func multipartText(mr *multipart.Reader, depth int) (plain, html string, err error) {
for {
part, err := mr.NextPart()
if err == io.EOF {
@@ -143,8 +164,8 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
switch {
case strings.HasPrefix(mediaType, "multipart/"):
var np, nh string
if b := params["boundary"]; b != "" {
np, nh, _ = multipartText(multipart.NewReader(part, b))
if b := params["boundary"]; b != "" && depth <= MaxMIMEDepth {
np, nh, _ = multipartText(multipart.NewReader(part, b), depth+1)
}
part.Close()
if plain == "" {
@@ -154,7 +175,7 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
html = nh
}
default:
text, terr := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
text, terr := plaintextBodyAt(ct, part.Header.Get("Content-Transfer-Encoding"), part, depth)
part.Close()
if terr != nil || strings.TrimSpace(text) == "" {
continue
+19
View File
@@ -1,6 +1,7 @@
package email
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -143,6 +144,24 @@ func TestParseTruncatesLongBody(t *testing.T) {
}
}
// Nesting depth comes off the wire, so a hostile message must not get to pick
// the recursion depth. The walk stops and the headers still come through.
func TestParseMessageBoundsMIMEDepth(t *testing.T) {
var b strings.Builder
b.WriteString("Subject: deep\r\nMIME-Version: 1.0\r\n")
for i := 0; i < MaxMIMEDepth+20; i++ {
fmt.Fprintf(&b, "Content-Type: multipart/mixed; boundary=\"b%d\"\r\n\r\n--b%d\r\n", i, i)
}
b.WriteString("Content-Type: text/plain\r\n\r\nглубоко\r\n")
msg, err := ParseMessage(7, []byte(b.String()))
if err != nil {
t.Fatalf("ParseMessage: %v", err)
}
if msg.Subject != "deep" {
t.Errorf("Subject = %q, want the headers to survive", msg.Subject)
}
}
func TestCollapseSqueezesBlankLines(t *testing.T) {
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
if got != "a b\n\nc" {
+21 -2
View File
@@ -724,8 +724,15 @@ type chatReq struct {
Conversation string `json:"conversation,omitempty"`
}
type chatResp struct {
Reply string `json:"reply"`
Source string `json:"source,omitempty"`
Reply string `json:"reply"`
Source string `json:"source,omitempty"`
TraceID int64 `json:"trace_id,omitempty"`
}
// correctTurnReq — the owner correcting one persisted turn (V-630).
type correctTurnReq struct {
TraceID int64 `json:"trace_id"`
ShouldBe string `json:"should_be,omitempty"`
}
// ChatReply — one text turn's answer plus which query source claimed it.
@@ -738,6 +745,12 @@ type chatResp struct {
type ChatReply struct {
Reply string
Source string
// TraceID is the persisted routing trace for this turn (V-629), and it is
// what makes a correction one gesture: the surface already has the id, so
// saying "that was wrong" costs a button and no lookup. 0 ⇒ nothing was
// persisted, which is a box with no database, and the surface offers no
// correction rather than a broken one.
TraceID int64
}
type proposeToolReq struct {
@@ -937,6 +950,12 @@ var ErrTaskDuplicate = errors.New("ipc: another live task already has this text"
// is down" must not read the same to a caller deciding whether to store an id.
var ErrNoEntity = errors.New("ipc: no such entity")
// ErrNoSuchTrace — the turn a correction names is not in routing_traces. Given
// a wire twin because it is the expected outcome of correcting a turn older than
// the retention bound, and "that turn is gone" and "the database is broken" must
// not read the same to the surface offering the gesture.
var ErrNoSuchTrace = errors.New("ipc: no such routing trace")
// ErrTaskResolved — a resolved task is not editable.
var ErrTaskResolved = errors.New("ipc: task is resolved")
+7 -1
View File
@@ -247,6 +247,8 @@ func hydrate(e *RpcError) error {
return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
case codeToolNotFound:
return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
case codeNoSuchTrace:
return fmt.Errorf("%w: %s", ErrNoSuchTrace, e.Message)
case codeUnknownMethod:
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
case codeBadParams:
@@ -661,7 +663,11 @@ func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
return ChatReply{}, err
}
return ChatReply{Reply: r.Reply, Source: r.Source}, nil
return ChatReply{Reply: r.Reply, Source: r.Source, TraceID: r.TraceID}, nil
}
func (c *Client) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
return c.call(ctx, MethodCorrectTurn, correctTurnReq{TraceID: traceID, ShouldBe: shouldBe}, nil)
}
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
+8
View File
@@ -176,6 +176,14 @@ type SystemAPI interface {
// has run since the daemon started.
TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error)
// CorrectTurn records that one persisted turn was routed wrongly, and what
// it should have been (V-630). shouldBe empty means "wrong, target
// unstated", which is a usable negative and must not cost more to give than
// the full answer. Unlike TurnDecisions this DOES reach a table, because a
// correction is the only supervised signal the box gets and it has to
// outlive the trace that carried it.
CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
+1
View File
@@ -31,6 +31,7 @@ var mapErrPairs = []struct {
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
{"ErrNoSuchTrace", store.ErrNoSuchTrace, ErrNoSuchTrace},
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
+4 -1
View File
@@ -514,7 +514,10 @@ var methodTable = map[Method]handlerFunc{
}),
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
reply, err := api.Chat(ctx, p.Conversation, p.Text)
return chatResp{Reply: reply.Reply, Source: reply.Source}, err
return chatResp{Reply: reply.Reply, Source: reply.Source, TraceID: reply.TraceID}, err
}),
MethodCorrectTurn: withParams(func(ctx context.Context, api CoreAPI, p correctTurnReq) (struct{}, error) {
return struct{}{}, api.CorrectTurn(ctx, p.TraceID, p.ShouldBe)
}),
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
return api.TickTrace(ctx)
+9
View File
@@ -213,6 +213,13 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
// CorrectTurn — unlike TickTrace and TurnDecisions this one is a table, so the
// store adapter answers it for real (V-630). A correction has to land whether
// the caller reached the daemon or the store directly.
func (a *storeAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
return mapErr(a.s.CorrectTurn(ctx, traceID, shouldBe, time.Now()))
}
// TurnDecisions — same story as TickTrace: the arbitration record is a daemon
// ring, not a table, so there is nothing here to read it from (V-564).
func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
@@ -412,6 +419,8 @@ func mapErr(err error) error {
return ErrReminderState
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
case errors.Is(err, store.ErrNoSuchTrace):
return ErrNoSuchTrace
case errors.Is(err, store.ErrTaskNoDoneWhen):
return ErrTaskNoDoneWhen
case errors.Is(err, store.ErrTaskDuplicate):
+4
View File
@@ -144,6 +144,10 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64,
func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
return nil, ErrNotImplemented
}
+13 -3
View File
@@ -49,6 +49,7 @@ const (
MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace"
MethodTurnDecisions Method = "turn_decisions"
MethodCorrectTurn Method = "correct_turn"
MethodMorningStatus Method = "morning_status"
MethodMCPServers Method = "mcp_servers"
MethodDayPlan Method = "day_plan"
@@ -125,15 +126,22 @@ const (
codeReminderMissing = "reminder_not_found"
codeReminderState = "reminder_state"
codeToolNotFound = "tool_not_found"
codeNoSuchTrace = "no_such_trace"
codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params"
codeForbidden = "forbidden"
codeInternal = "internal"
)
// codeOf maps a server-side sentinel to its wire code. Anything not matched
// is codeInternal — we never leak internal Go error text to a module; it
// gets a generic "internal" and the daemon logs the real error server-side.
// codeOf maps a server-side sentinel to its wire code. Anything not matched is
// codeInternal.
//
// This used to claim the text of an unmatched error stays server-side. It does
// not: rpcErr below ships err.Error() for codeInternal and codeBadParams,
// deliberately, because on those two codes the text is the whole diagnostic and
// a module has no other way to see it. Worth knowing before putting a secret in
// an error string, and worth knowing twice on a tcp seam, where that string
// leaves the box.
func codeOf(err error) string {
switch {
case err == nil:
@@ -154,6 +162,8 @@ func codeOf(err error) string {
return codeReminderState
case errors.Is(err, ErrToolNotFound):
return codeToolNotFound
case errors.Is(err, ErrNoSuchTrace):
return codeNoSuchTrace
case errors.Is(err, ErrUnknownMethod):
return codeUnknownMethod
case errors.Is(err, ErrBadParams):
+79 -3
View File
@@ -62,10 +62,10 @@ func mustLoad() lexiconFile {
}
for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"day_offsets", "weekdays", "weekdays_english", "months_genitive", "hours_spoken",
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
"filler_particles", "task_done_words", "task_drop_words",
"confirm_yes", "confirm_no",
"confirm_yes", "confirm_no", "hour_units", "minute_units",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
@@ -97,6 +97,11 @@ func NarrativeRequests() []string { return words("narrative_requests") }
// the set's own note for why this one is a list and not a seed set.
func RepairMarkers() []string { return words("repair_markers") }
// RepairNegatives lists the ways he says the previous turn was wrong without
// saying what it should have been. Matched against the whole utterance, never as
// substrings — see the set's own note.
func RepairNegatives() []string { return words("repair_negatives") }
// FirstPerson lists every form of the first-person pronoun. Callers use it to
// decide that a sentence is about him: internal/router/complaint.go keeps a
// complaint out of the fact store unless one of these appears, because losing a
@@ -135,11 +140,68 @@ func ConfirmNo() []string { return words("confirm_no") }
// TaskDropWords — see TaskDoneWords.
func TaskDropWords() []string { return words("task_drop_words") }
// WaterNouns and DrinkVerbs are the two halves of a water fact: he has to name
// the drink and the drinking, because "вода" alone is a word about water and
// "выпил" alone does not say what. The other four self-care sets need only one
// word each. All six are matched over tokens by lemma — except ShowerWords, see
// its own note.
func WaterNouns() []string { return words("water_nouns") }
// DrinkVerbs — see WaterNouns.
func DrinkVerbs() []string { return words("drink_verbs") }
// MealWords returns the nouns and verbs of having eaten.
func MealWords() []string { return words("meal_words") }
// ShowerWords returns the shower noun. Match these EXACTLY and not by lemma:
// the dictionary makes "душ" and "душа" one word, and only one of them is a
// shower. The set's note says why exact matching costs nothing here.
func ShowerWords() []string { return words("shower_words") }
// BreakWords returns the noun and verbs of taking a break.
func BreakWords() []string { return words("break_words") }
// SleepWords returns the verbs of having slept.
func SleepWords() []string { return words("sleep_words") }
// SlotValueFrame returns the words that can surround a bare slot value without
// making the utterance a request of its own. A caller strips these (along with
// the numbers and the other closed time sets) to see whether an utterance
// carries any content beside the value it was asked for. See the set's note.
func SlotValueFrame() []string { return words("slot_value_frame") }
// The hour and the minute nouns are part of the frame and are kept in their own
// sets, so there is one copy of each closed class rather than a copy per caller.
func SlotValueFrame() []string {
out := words("slot_value_frame")
out = append(out, HourUnits()...)
out = append(out, MinuteUnits()...)
return out
}
// HourUnits returns every form of the hour noun, and MinuteUnits every form of
// the minute noun. One home for each, because four router sets used to list the
// hour and all four stopped at "часу" (V-609). A caller folding time words into
// one set reads these; a caller asking about a single word reads IsHourUnit or
// IsMinuteUnit.
func HourUnits() []string { return words("hour_units") }
// MinuteUnits — see HourUnits.
func MinuteUnits() []string { return words("minute_units") }
// IsHourUnit reports whether a word is the hour noun in any form.
func IsHourUnit(word string) bool { return inSet("hour_units", word) }
// IsMinuteUnit reports whether a word is the minute noun in any form.
func IsMinuteUnit(word string) bool { return inSet("minute_units", word) }
func inSet(set, word string) bool {
w := norm(word)
for _, s := range ru.Sets[set].Words {
if w == s {
return true
}
}
return false
}
// DialogueCancel returns the ways he calls off the request Maven is assembling.
// Distinct from TaskDropWords, which abandons an item that already exists.
@@ -283,6 +345,20 @@ func DayOffsetIn(text string) (int, bool) {
// Go's time.Weekday. An index off the end returns "".
func Weekday(i int) string { return at("weekdays", i) }
// Weekdays returns the seven Russian names in one slice, Sunday first, for a
// caller matching a token against all of them rather than rendering one. Only
// the nominative is here: every other case lemmatises to it, so an oblique form
// is morph's question and not a second list (V-581).
func Weekdays() []string { return words("weekdays") }
// WeekdayEnglish reports the Go time.Weekday index an English weekday names,
// singular or plural. English needs the list that Russian does not, because the
// vendored dictionary is Russian and leaves "mondays" as it found it.
func WeekdayEnglish(word string) (int, bool) {
n, ok := ru.Sets["weekdays_english"].Values[norm(word)]
return n, ok
}
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
func MonthGenitive(m int) string { return at("months_genitive", m) }
+84 -9
View File
@@ -56,13 +56,13 @@
}
},
"cardinals": {
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed. From five up one oblique form serves the genitive, dative and prepositional, so \"пяти\" is the whole set; one to four decline separately and carry the dative and instrumental of their own, because \"к двум часам\" and \"к трём\" are hours he says (V-581).",
"values": {
"ноль": 0, "нуль": 0, "zero": 0,
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1,
"два": 2, "две": 2, "двух": 2, "two": 2,
"три": 3, "трёх": 3, "трех": 3, "three": 3,
"четыре": 4, "четырёх": 4, "четырех": 4, "four": 4,
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "одному": 1, "одним": 1, "one": 1,
"два": 2, "две": 2, "двух": 2, "двум": 2, "двумя": 2, "two": 2,
"три": 3, "трёх": 3, "трех": 3, "трём": 3, "трем": 3, "тремя": 3, "three": 3,
"четыре": 4, "четырёх": 4, "четырех": 4, "четырём": 4, "четырем": 4, "четырьмя": 4, "four": 4,
"пять": 5, "пяти": 5, "five": 5,
"шесть": 6, "шести": 6, "six": 6,
"семь": 7, "семи": 7, "seven": 7,
@@ -111,6 +111,18 @@
"четверг", "пятница", "суббота"
]
},
"weekdays_english": {
"note": "The English weekday names with their Go time.Weekday index, plus the plural a habit is spoken in (\"on mondays\"). English is listed as words where Russian is not, because the vendored dictionary is Russian: it lemmatises \"пятницу\" to \"пятница\" on its own and leaves \"mondays\" alone (V-581). So the Russian side of a weekday match is grammar and the English side is data.",
"values": {
"sunday": 0, "sundays": 0,
"monday": 1, "mondays": 1,
"tuesday": 2, "tuesdays": 2,
"wednesday": 3, "wednesdays": 3,
"thursday": 4, "thursdays": 4,
"friday": 5, "fridays": 5,
"saturday": 6, "saturdays": 6
}
},
"months_genitive": {
"note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.",
"words": [
@@ -135,6 +147,10 @@
"got it wrong", "not a ", "that was wrong"
]
},
"repair_negatives": {
"note": "The ways he says she got it wrong WITHOUT saying what it should have been. Matched against the WHOLE utterance, not as substrings, which is what keeps them apart from repair_markers: \u0022\u044d\u0442\u043e \u043d\u0435\u0022 is a fragment that needs an intent word after it, while these are complete sentences. A member that could appear inside an ordinary sentence does not belong here.",
"words": ["не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня", "ты ошиблась", "не так", "неправильно", "это неправильно", "that was wrong", "got it wrong", "you got it wrong", "wrong"]
},
"first_person": {
"note": "Every form of the first-person pronoun, plus the English ones. Closed class in the strictest sense: the language has these and no others. A sentence carrying one is about him, which is what makes it a fact rather than a passing complaint.",
"words": [
@@ -161,10 +177,11 @@
]
},
"reminder_verbs": {
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530).",
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530). The alarm verbs joined them in V-627. \"разбуди меня в 6:30\" is a reminder that fires at the hour he gets up, and the set knew no form of it, so an alarm reached IntentReminder only by resembling one to the embedder.",
"words": [
"напомни", "напомните", "напомнить", "напоминай",
"remind"
"разбуди", "разбудите", "разбудить", "буди",
"remind", "wake"
]
},
"half_hour": {
@@ -198,6 +215,20 @@
"передумал", "передумала", "неактуально"
]
},
"hour_units": {
"note": "Every form of the hour noun, Russian and English (V-609). One home for a closed class that four router sets used to list separately, and all four stopped at \"часу\": \"напомни к двум часам\" lost its hour and the reminder was left asking \"Когда?\". Russian declines, so the dative plural is as ordinary a way to say an hour as the accusative singular. A caller that folds time words into one set reads HourUnits; a caller asking about one word reads IsHourUnit.",
"words": [
"час", "часа", "часов", "часу", "часам", "часами", "часах",
"hour", "hours"
]
},
"minute_units": {
"note": "Every form of the minute noun, Russian and English (V-609). Same class as hour_units one noun over, and it had the same gap: the dative plural \"минутам\" was missing everywhere \"минут\" and \"минуты\" were present.",
"words": [
"минута", "минуты", "минуту", "минут", "минуте", "минутам", "минутами", "минутах",
"minute", "minutes"
]
},
"slot_value_frame": {
"note": "The words that can stand around a bare slot value without making the utterance a request of its own (Vikunja #560). Prepositions, hedges and the nouns a spoken time is built from: strip these, the numbers, the interrogatives, the filler particles and the other time sets, and whatever is left is the utterance's OWN content. \"а что если в 11:00\" leaves nothing and is an answer; \"какая сейчас погода в Риме\" leaves \"погода\" and \"Риме\" and is not. Closed because each part of it is closed — Russian has a fixed list of prepositions, and a clock is built from a fixed list of nouns. It is not a stopword list: a word goes in only if it can never be the thing he is asking about.",
"words": [
@@ -205,10 +236,10 @@
"at", "on", "in", "by", "to", "till", "until", "after", "before", "about", "for",
"нет", "не", "да", "ага", "угу", "ой", "ох", "тогда", "лучше", "может", "можно", "наверное", "наверно", "пожалуй", "точнее", "скорее", "если", "пусть", "прости", "извини", "слушай", "значит", "как-то", "типа", "вообще-то",
"no", "yes", "yeah", "ok", "okay", "sorry", "maybe", "actually", "rather", "then", "well",
"час", "часа", "часов", "часу", "часам", "минут", "минута", "минуты", "минуту", "минутах", "полдень", "полночь", "полдня",
"полдень", "полночь", "полдня",
"утра", "утро", "утру", "дня", "день", "днями", "вечера", "вечер", "вечеру", "ночи", "ночь", "ночью",
"сейчас", "теперь", "сегодняшний", "ближайший", "ближайшее",
"hour", "hours", "minute", "minutes", "noon", "midnight", "am", "pm", "oclock", "now"
"noon", "midnight", "am", "pm", "oclock", "now"
]
},
"dialogue_cancel": {
@@ -226,6 +257,50 @@
"yes", "yeah", "yep", "yup", "ok", "okay", "sure", "confirm", "affirmative"
]
},
"water_nouns": {
"note": "The water noun, in the forms he drinks it in, plus English (V-586). Closed because it is one noun: Russian gives it six cases and two numbers and that is the whole list. Both \"вода\" and \"водой\" are listed even though one declension covers both, because the vendored dictionary lemmatises \"воды\" to \"вод\" and \"водой\" to \"вода\" — two lemmas for one noun, so the set has to name both or a caller matching by lemma misses half of them. Matched over tokens with morph.SameWord, never as a substring: the \"вод\" this replaced fired on \"водитель\" and \"заводить\".",
"words": [
"вода", "водой", "водичка", "water"
]
},
"drink_verbs": {
"note": "Drinking, in the aspects and prefixes he speaks (V-586). Closed in the sense that matters: these are the verbs that make a water noun a water FACT, and the list is her vocabulary rather than a discovery about Russian. \"пил\" and \"пили\" are listed as surface forms because the dictionary lemmatises them to \"пила\", the saw; the perfective forms lemmatise correctly and one member each covers them. Whole tokens only — the substring \"пил\" this replaced fired on \"пилот\".",
"words": [
"пить", "пил", "пили", "пей", "выпить", "попить", "допить", "запить",
"drink", "drank", "drinking"
]
},
"meal_words": {
"note": "Eating: the meal nouns and the verbs of having one (V-586). Closed the same way capture_verbs is — these are the words that write a meal fact, decided here. The verbs are listed in the infinitive because that is the lemma the dictionary returns, so \"поужинал\" and \"позавтракал\" match without their own entries. \"есть\" and \"ел\" are deliberately ABSENT: \"есть\" is also the existential, and \"есть новости по бэкапу\" is a question rather than a meal. The English \"ate\" carried a guard against \"backup\" when this was a substring test; over tokens the guard is unnecessary.",
"words": [
"обед", "обедать", "пообедать",
"ужин", "ужинать", "поужинать",
"завтрак", "завтракать", "позавтракать",
"еда", "перекус", "перекусить",
"поесть", "кушать", "покушать",
"meal", "ate", "lunch", "dinner", "breakfast"
]
},
"shower_words": {
"note": "The shower, and the one set here matched EXACTLY rather than by lemma (V-586). The dictionary lemmatises \"душ\" to \"душа\", so a lemma test cannot tell a shower from a soul, and \"на душе легко\" is not a fact about washing. The accusative of an inanimate noun is its nominative, so \"принял душ\" and \"сходил в душ\" are both the bare form and exact matching loses nothing he actually says. The substring this replaced also fired on \"душно\".",
"words": [
"душ", "душем", "shower", "showered"
]
},
"break_words": {
"note": "Taking a break, noun and verb (V-586). Closed like meal_words and for the same reason. \"отдых\" and \"отдыхать\" are both listed because the noun and the verb are separate lemmas; the perfective \"отдохнул\" lemmatises to \"отдохнуть\".",
"words": [
"перерыв", "отдых", "отдыхать", "отдохнуть", "передохнуть",
"break", "rest"
]
},
"sleep_words": {
"note": "Sleeping (V-586). The imperfective surface forms \"спал\" and \"спала\" are listed because the dictionary lemmatises them to \"спасть\", a different verb, and one entry for the pair is the honest fix; the prefixed forms lemmatise consistently and their infinitives cover them. \"сон\" is absent: the noun names a dream as readily as a night's sleep, and it was not in the pattern this replaces either.",
"words": [
"спать", "спал", "поспать", "поспал", "проспать", "выспаться",
"sleep", "slept", "sleeping"
]
},
"confirm_no": {
"note": "The answers that decline a parked confirm. Same matching rule as confirm_yes and the same reason. The multi-word members are here rather than assembled by a caller because \"надо\" alone is not an answer and \"не надо\" is the opposite of one: the two must land on opposite sides, and only the phrase says which. \"не\" on its own is NOT a member — \"не забудь купить хлеб\" is a reminder, not a refusal.",
"words": [
+40
View File
@@ -36,6 +36,46 @@ func TestClosedSetsAreComplete(t *testing.T) {
if _, ok := Cardinal("бэкап"); ok {
t.Error("Cardinal must not answer for a word that is not a number")
}
// A spoken hour declines, and one to four decline further than the rest:
// "к двум часам" and "к трём" are hours, and only the dative says so (V-581).
for _, tc := range []struct {
word string
want int
}{
{"одному", 1}, {"двум", 2}, {"двумя", 2}, {"трём", 3}, {"трем", 3},
{"четырём", 4}, {"четырем", 4}, {"пяти", 5}, {"семи", 7},
} {
if got, ok := Cardinal(tc.word); !ok || got != tc.want {
t.Errorf("Cardinal(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
}
}
}
// TestWeekdaysAreOneList — the second copy of a closed class is the bug (V-581).
// Weekdays lived in four files outside this one, so the list is handed out whole
// and the English forms, which the Russian dictionary cannot lemmatise, are here.
func TestWeekdaysAreOneList(t *testing.T) {
days := Weekdays()
if len(days) != 7 || days[0] != "воскресенье" || days[1] != "понедельник" {
t.Fatalf("Weekdays() = %v; want the seven, Sunday first", days)
}
for i, name := range days {
if Weekday(i) != name {
t.Errorf("Weekdays()[%d] = %q, but Weekday(%d) = %q", i, name, i, Weekday(i))
}
}
for _, tc := range []struct {
word string
want int
}{{"sunday", 0}, {"monday", 1}, {"mondays", 1}, {"Friday", 5}, {"saturdays", 6}} {
if got, ok := WeekdayEnglish(tc.word); !ok || got != tc.want {
t.Errorf("WeekdayEnglish(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
}
}
if _, ok := WeekdayEnglish("понедельник"); ok {
t.Error("WeekdayEnglish answered for a Russian word; that side is morph's")
}
}
// TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this
+33 -7
View File
@@ -5,6 +5,7 @@ import (
"errors"
"log"
"net/http"
"sync"
"sync/atomic"
"time"
)
@@ -46,6 +47,7 @@ type Pair struct {
interval time.Duration
http *http.Client
stop chan struct{}
stopOnce sync.Once
}
// ErrRemoteUnavailable — the workstation model was required and is not
@@ -107,13 +109,11 @@ func (p *Pair) Start(ctx context.Context) {
}()
}
// Stop ends the prober. Idempotent.
// Stop ends the prober. Idempotent, and safe from two goroutines at once. The
// check-then-close it replaced let both callers see an open channel and the
// second close panicked, which turned a shutdown race into a crash.
func (p *Pair) Stop() {
select {
case <-p.stop:
default:
close(p.stop)
}
p.stopOnce.Do(func() { close(p.stop) })
}
// Available reports whether the workstation will take work right now. It reads
@@ -170,7 +170,9 @@ func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
}
why := "workstation down"
if p.Available() {
out, err := p.remote.Complete(ctx, r)
rctx, cancel := remoteBudget(ctx)
out, err := p.remote.Complete(rctx, r)
cancel()
if err == nil {
log.Print("llm: served by the workstation model")
return out, nil
@@ -184,6 +186,30 @@ func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
return p.floor.Complete(ctx, r)
}
// remoteBudget bounds the workstation attempt so the floor still has time to
// answer. A turn carrying a deadline used to hand the whole of it to the
// remote, so a workstation that accepted the connection and then hung ate the
// budget and the fallback ran on an already-expired context: the floor
// returned the deadline error and the turn broke on the workstation being
// slow, which docs/offload.md says must never happen. Half is the split
// because both halves have to be able to finish, and there is no reason to
// prefer either one when the remote is the part that failed.
//
// A context with no deadline is left alone. The remote client's own timeout
// (workstation.timeout, 90s by default) bounds it there, and shortening that
// silently would change the configured budget.
func remoteBudget(ctx context.Context) (context.Context, context.CancelFunc) {
dl, ok := ctx.Deadline()
if !ok {
return ctx, func() {}
}
left := time.Until(dl)
if left <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, left/2)
}
// CompleteRemote runs r on the workstation or refuses. It never falls back,
// because for a world question the resident 1.7B does not answer worse, it
// invents. Callers turn ErrRemoteUnavailable into a named gap.
+42
View File
@@ -154,6 +154,48 @@ func TestRemoteErrorMidRequestFallsBack(t *testing.T) {
}
}
// A workstation that accepts the connection and then hangs must not spend the
// whole turn budget. It used to: the remote got the caller's context unchanged,
// so the fallback ran on an expired one and the floor returned the deadline
// error instead of an answer. The turn broke on the workstation being slow,
// which is the one outcome docs/offload.md rules out.
func TestHangingRemoteLeavesTheFloorABudget(t *testing.T) {
var floorHits atomic.Int64
// released, not r.Context().Done(): httptest.Server.Close waits for the
// handler, and a handler that only watches the request context can outlive
// the test when the client hangs up without the server noticing.
released := make(chan struct{})
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-released:
case <-r.Context().Done():
}
}))
defer hang.Close()
defer close(released)
floor := completionServer(t, "floor", &floorHits)
up := &atomic.Bool{}
up.Store(true)
health := healthServer(t, up)
p := NewPair(New(hang.URL, time.Minute), New(floor.URL, time.Minute), health.URL, time.Hour)
p.Start(context.Background())
defer p.Stop()
if !waitFor(t, p.Available) {
t.Fatal("prober never saw the remote come up")
}
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
defer cancel()
out, err := p.Complete(ctx, Req{User: "привет"})
if err != nil {
t.Fatalf("complete: %v", err)
}
if out != "floor" || floorHits.Load() != 1 {
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
}
}
// The naming half of the degradation rule. A world question must not be handed
// to the resident model, because it answers by inventing.
func TestCompleteRemoteNamesTheGap(t *testing.T) {
+10 -1
View File
@@ -257,7 +257,16 @@ func (c *Client) call(ctx context.Context, method string, params any, out any) e
if resp.Error != nil {
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
}
if out == nil || len(resp.Result) == 0 {
// A frame carrying our id and neither result nor error is not an answer.
// The HTTP transport already refuses one; the stdio transport does not, and
// without this check the refusal depended on which door the server was
// behind. Letting it through is the one failure that lies: tools/call
// returns an empty string and a nil error, so the act is recorded as done
// and the tool never ran.
if len(resp.Result) == 0 {
return fmt.Errorf("mcp: %s: %s: response carries neither result nor error", c.name, method)
}
if out == nil {
return nil
}
if err := json.Unmarshal(resp.Result, out); err != nil {
+8 -2
View File
@@ -525,10 +525,16 @@ func (m *Manager) Resources(ctx context.Context) []Resource {
// ReadResource reads one resource from one server.
func (m *Manager) ReadResource(ctx context.Context, server, uri string) (string, error) {
cl, cfg, _, _, _ := m.lookup(server, "")
if cl == nil {
cl, cfg, _, configured, _ := m.lookup(server, "")
if !configured {
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
}
// A configured server that is merely down is not an unknown server. Call
// already keeps the two apart; reporting ErrNoServer here tells a caller
// the resource can never exist, when the truth is "not right now".
if cl == nil {
return "", fmt.Errorf("%w: %s", ErrNotConnected, server)
}
cctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
return cl.ReadResource(cctx, uri)
+56
View File
@@ -641,3 +641,59 @@ func TestReconnectBackoffGrows(t *testing.T) {
t.Fatalf("first retry = %v, want %v", c.backoff(), DefaultReconnectEvery)
}
}
// emptyFrameTransport answers the handshake normally and then replies to every
// later call with a well-formed frame carrying our id and nothing else — no
// result, no error. That is the answer a partially-implemented server gives,
// and it is the one that lies: without a check it reads as success.
type emptyFrameTransport struct{ handshaken bool }
func (t *emptyFrameTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
id := req.ID
if !t.handshaken {
t.handshaken = true
raw, _ := json.Marshal(map[string]any{
"protocolVersion": ProtocolVersion,
"serverInfo": map[string]any{"name": "empty", "version": "0"},
})
return &rpcResponse{JSONRPC: "2.0", ID: &id, Result: raw}, nil
}
return &rpcResponse{JSONRPC: "2.0", ID: &id}, nil
}
func (t *emptyFrameTransport) Notify(context.Context, string, any) error { return nil }
func (t *emptyFrameTransport) Close() error { return nil }
func TestResultlessResponseIsNotSuccess(t *testing.T) {
c := newClient("empty", &emptyFrameTransport{})
if err := c.Initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
out, err := c.CallTool(context.Background(), "break_thing", map[string]any{"q": "x"})
if err == nil {
t.Fatalf("a frame with neither result nor error must not read as success (got %q)", out)
}
if out != "" {
t.Fatalf("out = %q", out)
}
if _, err := c.ListTools(context.Background()); err == nil {
t.Fatal("tools/list with no result must be an error, not an empty catalogue")
}
}
func TestReadResourceOnDownServerIsNotConnected(t *testing.T) {
// A url server with no poster factory: configured, validated, never dialed.
m, err := NewManager(nil, []ServerConfig{{
Name: "down", URL: "http://example.test/mcp", Enabled: true,
}})
if err != nil {
t.Fatal(err)
}
m.Connect(context.Background())
if _, err := m.ReadResource(context.Background(), "down", "note://one"); !errors.Is(err, ErrNotConnected) {
t.Fatalf("err = %v, want ErrNotConnected", err)
}
if _, err := m.ReadResource(context.Background(), "nosuch", "note://one"); !errors.Is(err, ErrNoServer) {
t.Fatalf("err = %v, want ErrNoServer", err)
}
}
+22 -4
View File
@@ -265,6 +265,16 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
}
}
// Every return past the reservation has to give it back, so the defer owns
// that rather than each error path: a path that forgot over-counted the
// store until the next Open re-walked the directory.
stored := false
defer func() {
if fresh && !stored {
s.release(b.Size)
}
}()
// The sidecar goes first. Written second, a full disk or a crash between
// the two left the bytes on disk with no sidecar, and List only sees
// sidecars, so Prune could never collect them: Put returned an error and an
@@ -274,11 +284,9 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
}
if err := writeFile(blobPath, data); err != nil {
_ = os.Remove(metaPath)
if fresh {
s.release(b.Size)
}
return Blob{}, err
}
stored = true
return b, nil
}
@@ -331,12 +339,22 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
return Blob{}, err
}
}
// Same reasoning as Put: the reservation is released by one defer, not by
// whichever error path remembered to.
stored := false
defer func() {
if fresh && !stored {
s.release(b.Size)
}
}()
if err := writeMeta(metaPath, b); err != nil {
return Blob{}, err
}
if !fresh {
// Same bytes already here. Drop the spool copy.
_ = os.Remove(src)
stored = true
return b, nil
}
if err := os.Chmod(src, filePerm); err != nil {
@@ -344,9 +362,9 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
}
if err := os.Rename(src, blobPath); err != nil {
_ = os.Remove(metaPath)
s.release(b.Size)
return Blob{}, fmt.Errorf("media: move spool: %w", err)
}
stored = true
return b, nil
}
+67
View File
@@ -287,6 +287,73 @@ func TestPutLeavesNothingWhenTheBytesCannotBeWritten(t *testing.T) {
}
}
// An over-counted store answers ErrStoreFull while the disk has room, and only
// the next Open corrects it. So every failed write has to give its reservation
// back, not just the one that remembered to.
func TestPutReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
s := testStore(t)
data := []byte("no sidecar for this")
blockSidecar(t, s, KindImage, data)
if _, err := s.Put(KindImage, "image/png", "web:upload", data); err == nil {
t.Fatal("put must fail")
}
if s.Total() != 0 {
t.Errorf("total = %d, want the failed put not counted", s.Total())
}
}
func TestPutFileReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
s := testStore(t)
data := []byte("no sidecar for this either")
blockSidecar(t, s, KindAudio, data)
src := filepath.Join(t.TempDir(), "capture.wav")
if err := os.WriteFile(src, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
t.Fatal("put file must fail")
}
if s.Total() != 0 {
t.Errorf("total = %d, want the failed put not counted", s.Total())
}
}
// The chmod arm is PutFile's alone: Put never touches a spool file.
func TestPutFileReleasesTheBudgetWhenTheSpoolCannotBeChmodded(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can chmod a file it does not own")
}
// A symlink to a file owned by somebody else. Stat and the hash follow it
// and succeed; chmod follows it too and is refused.
src := filepath.Join(t.TempDir(), "capture.wav")
if err := os.Symlink("/etc/hosts", src); err != nil {
t.Fatal(err)
}
info, err := os.Stat(src)
if err != nil || info.Size() == 0 {
t.Skip("no readable /etc/hosts to point at")
}
s := testStore(t)
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
t.Fatal("put file must fail")
}
if s.Total() != 0 {
t.Errorf("total = %d, want the failed put not counted", s.Total())
}
}
// blockSidecar puts a directory where the sidecar for data has to go, so
// writeMeta fails while the blob path is still free.
func blockSidecar(t *testing.T, s *Store, kind Kind, data []byte) {
t.Helper()
sum := sha256.Sum256(data)
id := hex.EncodeToString(sum[:])
bucket := filepath.Join(s.dir, string(kind), id[:2])
if err := os.MkdirAll(filepath.Join(bucket, id+".json"), 0o700); err != nil {
t.Fatal(err)
}
}
// The per-blob cap bounds one call and nothing bounded their sum. 64 MiB per
// call times unlimited calls inside a seven-day window fills the disk mavend's
// database lives on.
+90
View File
@@ -0,0 +1,90 @@
// Package modes holds the routing mode inventory: the roughly thirty distinct
// downstream behaviours mavend has, each mapped back to one of the seven public
// intents (V-631, umbrella V-628).
//
// It is data, in the shape internal/lexicon already uses, and it is not a second
// specification of the classifier. Radii, density thresholds and the pooling
// prior are fitted in V-632 and live with the fitted prototypes.
//
// Two rules decide whether something is a mode. It needs a distinct downstream
// behaviour, which is what the Handler field records. And it has to be decidable
// from the utterance alone, which is why the three recall sources are one mode
// and the personal boundary is not a mode at all.
package modes
import (
"embed"
"encoding/json"
"fmt"
)
//go:embed modes_v1.json
var files embed.FS
// Mode is one routing class.
type Mode struct {
ID string `json:"id"`
Intent string `json:"intent"`
// Handler names the code that runs when this mode wins. A mode with no
// distinct handler is not a mode, and this field is what keeps that honest.
Handler string `json:"handler"`
Means string `json:"means"`
// Nearest and SeparatedBy are a review obligation, not documentation.
// Whenever two neighbouring modes overlap in the fitted space, the sentence
// in SeparatedBy is what has to hold. If nothing separates them, they were
// one mode and this file is wrong.
Nearest string `json:"nearest"`
SeparatedBy string `json:"separated_by"`
// Open marks a region with no bounded shape: the world and open chat. Those
// carry RejectPolicy, and nothing else may.
Open bool `json:"open"`
RejectPolicy string `json:"reject_policy,omitempty"`
PrototypeCount int `json:"prototype_count"`
MinSeedExamples int `json:"min_seed_examples"`
Note string `json:"note,omitempty"`
Examples []string `json:"examples"`
}
// Inventory is the whole file. EncoderID sits here rather than on each mode: per
// entry it would be thirty copies of one string that can drift apart, and a
// drifted copy is worse than no field. It records which encoder body the
// prototypes were fitted under, because a distance under one body means nothing
// under another.
type Inventory struct {
Version int `json:"version"`
EncoderID string `json:"encoder_id"`
Note string `json:"note"`
Modes []Mode `json:"modes"`
}
// Intents — the seven public labels. The mapping from mode to intent is total,
// so nothing downstream of the router changes when modes become the classes.
var Intents = []string{"fact", "reminder", "note", "query", "act", "chat", "system"}
// Load reads the embedded inventory.
func Load() (*Inventory, error) {
b, err := files.ReadFile("modes_v1.json")
if err != nil {
return nil, fmt.Errorf("modes: read: %w", err)
}
var inv Inventory
if err := json.Unmarshal(b, &inv); err != nil {
return nil, fmt.Errorf("modes: parse: %w", err)
}
return &inv, nil
}
// ByID indexes the inventory.
func (inv *Inventory) ByID() map[string]Mode {
out := make(map[string]Mode, len(inv.Modes))
for _, m := range inv.Modes {
out[m.ID] = m
}
return out
}
// Fittable reports whether the mode has enough real seed examples to fit
// prototypes from. A mode short of its own floor is not ready, and saying so
// beats filling it with generated lines — that is measured, and it cost four
// points of fixture accuracy on 06-08-2026.
func (m Mode) Fittable() bool { return len(m.Examples) >= m.MinSeedExamples }
+185
View File
@@ -0,0 +1,185 @@
package modes
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func load(t *testing.T) *Inventory {
t.Helper()
inv, err := Load()
if err != nil {
t.Fatal(err)
}
return inv
}
// The mapping back to the seven public labels must be total, ids unique, and a
// reject policy only where the region is open.
func TestInventoryShape(t *testing.T) {
inv := load(t)
if inv.EncoderID == "" {
t.Error("no encoder_id: a fitted distance means nothing without the body it was fitted under")
}
valid := map[string]bool{}
for _, i := range Intents {
valid[i] = true
}
seen := map[string]bool{}
for _, m := range inv.Modes {
if seen[m.ID] {
t.Errorf("%s: duplicate id", m.ID)
}
seen[m.ID] = true
if !valid[m.Intent] {
t.Errorf("%s: intent %q is not one of the seven", m.ID, m.Intent)
}
if m.Handler == "" {
t.Errorf("%s: no handler, so it is not a mode", m.ID)
}
if m.SeparatedBy == "" {
t.Errorf("%s: no separated_by, so nothing states the review obligation", m.ID)
}
if m.Open && m.RejectPolicy == "" {
t.Errorf("%s: open with no reject_policy", m.ID)
}
if !m.Open && m.RejectPolicy != "" {
t.Errorf("%s: reject_policy on a bounded mode", m.ID)
}
if m.PrototypeCount < 1 {
t.Errorf("%s: prototype_count %d", m.ID, m.PrototypeCount)
}
}
// No id is a prefix of another. act.tool.hoststats was, and it turned out to
// run the same handler as act.tool: a read against a change is the tool row's
// destructive field, which the confirm gate already reads. Handler is prose,
// so a duplicated behaviour hides there. A nested id is the tell that shows.
for _, a := range inv.Modes {
for _, b := range inv.Modes {
if a.ID != b.ID && strings.HasPrefix(b.ID, a.ID+".") {
t.Errorf("%s is nested under %s, so one of them is not a mode", b.ID, a.ID)
}
}
}
// Nearest names a real mode, or the review obligation points at nothing.
for _, m := range inv.Modes {
if m.Nearest != "" && !seen[m.Nearest] {
t.Errorf("%s: nearest %q is not in the inventory", m.ID, m.Nearest)
}
if m.Nearest == m.ID {
t.Errorf("%s: nearest is itself", m.ID)
}
}
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return filepath.Join(wd, "..", "..")
}
func seedRows(t *testing.T) map[string]bool {
t.Helper()
paths, err := filepath.Glob(filepath.Join(repoRoot(t), "models", "seeds", "*.txt"))
if err != nil || len(paths) == 0 {
t.Fatalf("no seed files: %v", err)
}
out := map[string]bool{}
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[strings.ToLower(line)] = true
}
f.Close()
}
return out
}
// Every example is a real seed row. Not generated: 202 reviewed generated
// contrast pairs cost four points of fixture accuracy on 06-08-2026, and the
// generated half of the corpus recovers its own generation prompt when clustered.
func TestExamplesComeFromSeedRows(t *testing.T) {
inv := load(t)
seeds := seedRows(t)
for _, m := range inv.Modes {
if len(m.Examples) == 0 {
continue
}
for _, e := range m.Examples {
if !seeds[strings.ToLower(e)] {
t.Errorf("%s: example %q is not a seed row", m.ID, e)
}
}
}
}
// The fixture is the sole held-out measurement. An example drawn from it makes
// every number after that unfalsifiable.
func TestExamplesAreNotFixtureCases(t *testing.T) {
inv := load(t)
b, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "router", "eval", "ru_routing_v1.json"))
if err != nil {
t.Skipf("fixture not readable: %v", err)
}
var raw struct {
Cases []struct {
Utterance string `json:"utterance"`
} `json:"cases"`
}
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("fixture shape changed, and this invariant must not silently skip: %v", err)
}
held := map[string]bool{}
for _, c := range raw.Cases {
if c.Utterance != "" {
held[strings.ToLower(strings.TrimSpace(c.Utterance))] = true
}
}
if len(held) == 0 {
t.Fatal("read no utterances from the fixture")
}
for _, m := range inv.Modes {
for _, e := range m.Examples {
if held[strings.ToLower(e)] {
t.Errorf("%s: example %q is a fixture case", m.ID, e)
}
}
}
}
// Not a failure, a report. Nine modes have zero real examples and they are the
// nine with no deterministic matcher, which is why V-629 and V-630 come before
// V-632: without persisted turns there is nothing to fit them from.
func TestFittableReport(t *testing.T) {
inv := load(t)
var ready, short, empty []string
for _, m := range inv.Modes {
switch {
case len(m.Examples) == 0:
empty = append(empty, m.ID)
case m.Fittable():
ready = append(ready, m.ID)
default:
short = append(short, m.ID)
}
}
t.Logf("modes: %d total, %d ready to fit, %d short of min_seed_examples, %d with no seed example at all",
len(inv.Modes), len(ready), len(short), len(empty))
t.Logf(" no examples: %s", strings.Join(empty, ", "))
t.Logf(" short: %s", strings.Join(short, ", "))
}
+382
View File
@@ -0,0 +1,382 @@
{
"version": 1,
"encoder_id": "e5-small-routing-v1",
"note": "Written from the handlers on 06-08-2026 for V-631. Examples are drawn only from train_seeds.jsonl, which is src=seed. The 91-case fixture is not touched. A mode whose examples list is short of min_seed_examples is not ready to fit, and that is the point of recording the number.",
"modes": [
{
"id": "query.fact-by-key",
"intent": "query",
"handler": "queryFactByKey",
"means": "he asks back a fact he stored, by its key",
"nearest": "query.recall",
"separated_by": "a key exists in the fact store; recall has to search",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["сколько я спал сегодня", "какой сегодня вес", "сколько воды я выпил сегодня", "когда последний раз поливал цветы", "когда кормил кота в последний раз", "сколько времени прошло с последней тренировки", "how many hours did I sleep this week"]
},
{
"id": "query.day-plan",
"intent": "query",
"handler": "queryDayPlan",
"means": "what the day holds, asked with a plan word",
"nearest": "query.calendar",
"separated_by": "a plan word is present; the calendar listing is the general case",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["что у меня сегодня по плану", "какие планы на завтра", "планы на сегодня", "какие у меня планы на завтра"]
},
{
"id": "query.habits",
"intent": "query",
"handler": "queryHabits",
"means": "what he usually does, asked with a habit marker",
"nearest": "query.calendar",
"separated_by": "обычно, каждый, по средам; not a single dated occasion",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.tasks",
"intent": "query",
"handler": "queryTasks",
"means": "what is on the task board",
"nearest": "query.day-plan",
"separated_by": "a task noun or an explicit что … сделать, with no date",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.attention",
"intent": "query",
"handler": "queryAttention",
"means": "what Praxis says needs looking at",
"nearest": "query.tasks",
"separated_by": "an attention marker; the board is Maven's, attention is Praxis's",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.list",
"intent": "query",
"handler": "queryList",
"means": "what is on a standing list",
"nearest": "query.tasks",
"separated_by": "an explicit list marker",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.money",
"intent": "query",
"handler": "queryMoney",
"means": "spending and balances, from the facts the poller wrote",
"nearest": "query.fact-by-key",
"separated_by": "a money noun plus an actual ask",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["какой баланс на счету", "сколько стоит свет в этом месяце", "сколько электричества мы потратили"]
},
{
"id": "query.history",
"intent": "query",
"handler": "queryHistory",
"means": "what he told her, asked about the telling rather than the topic",
"nearest": "query.recall",
"separated_by": "both halves of a history phrase and no named topic",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.feeds",
"intent": "query",
"handler": "queryFeeds",
"means": "what the feeds she reads are carrying",
"nearest": "query.world",
"separated_by": "a feed noun plus an ask; the world source would invent news",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["что нового"]
},
{
"id": "query.home",
"intent": "query",
"handler": "queryHome",
"means": "the state of the house",
"nearest": "act.tool",
"separated_by": "it asks rather than switches; a device word plus an ask",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": ["какая температура в комнате"]
},
{
"id": "query.network",
"intent": "query",
"handler": "queryNetwork",
"means": "what is on the LAN",
"nearest": "act.tool",
"separated_by": "the subject is the network, not this box",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["что с интернетом", "какая скорость интернета", "сколько трафика сегодня"]
},
{
"id": "query.calendar",
"intent": "query",
"handler": "queryCalendar",
"means": "what the calendar holds, dated",
"nearest": "query.day-plan",
"separated_by": "date-aware, and the only source a continuation turn still asks",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["что у меня сегодня по календарю", "что сегодня в календаре", "покажи календарь на сегодня", "расписание на сегодня", "что у меня завтра", "есть ли что-то завтра", "сколько времени до встречи", "какие напоминания на сегодня"]
},
{
"id": "query.weather",
"intent": "query",
"handler": "queryWeather",
"means": "the weather, outside",
"nearest": "query.home",
"separated_by": "outside rather than in a room; the home source bails on weather wording",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": ["какая погода", "какая погода в москве", "сколько градусов", "температура на улице", "холодно сегодня", "будет дождь", "погода на сегодня", "weather in london", "какой завтра прогноз погоды", "какая температура воздуха"]
},
{
"id": "query.self",
"intent": "query",
"handler": "querySelf",
"means": "a question about her",
"nearest": "chat.open",
"separated_by": "it wants a fact about her, not a conversation",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["как тебя зовут", "сколько тебе лет", "у тебя есть чувства", "do you have feelings"]
},
{
"id": "query.recall",
"intent": "query",
"handler": "queryEmbed, queryMemory, queryNotes",
"means": "search his own notes and memory for something he named",
"nearest": "query.fact-by-key",
"separated_by": "no key exists, so the text has to be searched",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["покажи заметки про сервер", "найди заметку про сервер", "найди мою заметку о бэкапах", "поищи заметку про роутер", "найди заметку где я записал пароль", "что я записывал про полив", "покажи заметку про починку крана", "найди в заметках про home assistant", "find my note about the database backup", "search my notes for the wifi password", "what did I note about the garden", "покажи мои заметки за неделю"]
},
{
"id": "query.web",
"intent": "query",
"handler": "queryWeb",
"means": "read a page he named out loud",
"nearest": "query.world",
"separated_by": "he supplied the URL; it is an instruction, not a question",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "query.world",
"intent": "query",
"handler": "querySearch, queryKiwix, queryGeneral",
"means": "anything outside his own data",
"nearest": "chat.open",
"separated_by": "a source can answer it; the personal boundary let it past",
"open": true,
"prototype_count": 6,
"min_seed_examples": 12,
"reject_policy": "no prototype within radius goes to the LLM fallback, which this path already pays for",
"examples": ["почему небо голубое", "что такое любовь", "как работает интернет", "почему трава зелёная", "откуда берётся дождь", "what is love", "why is the sky blue", "how does the internet work"]
},
{
"id": "act.tool",
"intent": "act",
"handler": "tools.Exec against the enabled allowlist",
"means": "switch, start, stop or read something the tool allowlist names",
"nearest": "query.home",
"separated_by": "it names a tool the allowlist carries; destructive is the tool rows field, not a mode of its own",
"open": false,
"prototype_count": 6,
"min_seed_examples": 8,
"note": "act.tool.hoststats was a mode here until 06-08-2026 and is not one: it ran the same tools.Exec, and read against change is the tool rows destructive field, which the confirm gate already reads. Its nine examples went with it, because they are question-shaped query seeds that no configured alias matches, so no tool answers them today. replySystem's память/загрузк/аптайм arm answers “системная статистика пока не подключена.” and always did.",
"examples": ["включи свет на кухне", "выключи кондиционер", "открой шторы", "закрой окно", "перезагрузи роутер", "запусти пылесос", "заблокируй дверь", "maven, restart nginx", "перезапусти nginx", "останови контейнер", "maven, сделай бэкап", "запусти обновление системы"]
},
{
"id": "act.taskstatus",
"intent": "act",
"handler": "resolveTaskStatus",
"means": "move an item on Maven's own board",
"nearest": "act.praxis",
"separated_by": "the board is Maven's; Praxis owns attention, not this",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": []
},
{
"id": "act.praxis",
"intent": "act",
"handler": "handlePraxisAct",
"means": "surface, acknowledge or resolve a Praxis item",
"nearest": "act.taskstatus",
"separated_by": "the item lives in Praxis, and the three lifecycle words differ",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": []
},
{
"id": "act.hexis",
"intent": "act",
"handler": "handleHexisAct",
"means": "execute a registered capability against a resolved entity",
"nearest": "act.tool",
"separated_by": "it names an entity Nexus must resolve before anything runs",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": []
},
{
"id": "note.task",
"intent": "note",
"handler": "captureTaskFromNote",
"means": "he files work, which belongs in the task store",
"nearest": "note.recall",
"separated_by": "it is work to be done, not something to remember",
"open": false,
"prototype_count": 3,
"min_seed_examples": 8,
"examples": ["заметка: починить ручку на двери", "заметка: сменить масло в машине", "заметка: заменить лампочку в коридоре", "заметка: записаться к стоматологу", "заметка: переклеить обои в спальне", "заметка: проверить уровень масла", "запиши: проверить проводку на даче", "заметка: обновить прошивку роутера"]
},
{
"id": "note.list",
"intent": "note",
"handler": "captureListFromNote",
"means": "he adds to a standing list",
"nearest": "note.task",
"separated_by": "a list marker; the item is bought, not done",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["запиши что нужно купить в магазине", "купить новый фильтр для аквариума", "заметка: купить новый фильтр для воды", "запиши: купить семена для огорода", "запиши: купить подарок на день рождения"]
},
{
"id": "note.recall",
"intent": "note",
"handler": "WriteNote plus memStore.Insert",
"means": "free text he wants indexed for later recall",
"nearest": "fact.self",
"separated_by": "nothing keys it, and the subject need not be him",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["запиши рецепт: 3 яйца, мука, молоко", "запиши пароль от wifi в заметки", "запиши адрес: москва, тверская 7", "запиши время работы химчистки", "запиши цену на стройматериалы", "запиши размеры полки для шкафа", "note: check the DNS config after update", "note: staggered cooldown by time of day", "запиши книгу, которую посоветовали"]
},
{
"id": "fact.self",
"intent": "fact",
"handler": "actionFact, WriteFact kind=self",
"means": "a keyed, supersedable statement about him",
"nearest": "note.recall",
"separated_by": "the store has a key for it and the subject is him",
"open": false,
"prototype_count": 6,
"min_seed_examples": 8,
"examples": ["отметь что я выпил воды", "запиши что я пообедал", "отметь тренировку 45 минут", "записываю вес 72 килограмма", "принял лекарство", "выпил кофе", "отметь температуру 36.6", "записываю давление 120 на 80", "вес 73.5 килограмма", "сон 7 часов", "slept 6h", "walked 8000 steps"]
},
{
"id": "reminder.timed",
"intent": "reminder",
"handler": "actionReminder",
"means": "fire something at a time",
"nearest": "note.task",
"separated_by": "it carries a time; a task has none",
"open": false,
"prototype_count": 4,
"min_seed_examples": 8,
"examples": ["напомни завтра в 9 утра позвонить", "напомни через 4 часа размяться", "напомни завтра в 9 утра позвонить", "напомни в пятницу вынести мусор", "напомни через 15 минут снять бельё", "remind me in 30 minutes to drink water", "remind me at 6pm to take out the trash", "remind me tomorrow at 8am to call the doctor"]
},
{
"id": "system.clock",
"intent": "system",
"handler": "replySystem, the час/врем arm, ruClock",
"means": "the current time",
"nearest": "query.world",
"separated_by": "answered from the box's own clock, not from a source",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["который час", "сколько времени", "сколько сейчас времени", "который час у нас", "который час в Москве"]
},
{
"id": "system.date",
"intent": "system",
"handler": "replySystem, the день/числ arm, ParseCalendarDate",
"means": "today's date or weekday",
"nearest": "query.calendar",
"separated_by": "it asks what day it is, not what is on that day",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["какой сегодня день", "какое сегодня число", "какой сегодня день недели"]
},
{
"id": "system.presence",
"intent": "system",
"handler": "replySystem, the кто дома arm",
"means": "who is home",
"nearest": "query.home",
"separated_by": "the subject is people, not devices",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["кто сейчас дома", "сколько человек дома", "есть ли кто дома", "все ли дома", "кто дома сейчас"]
},
{
"id": "system.quiet",
"intent": "system",
"handler": "quiet_toggle.go, matched pre-route",
"means": "turn the quiet mode on or off",
"nearest": "act.tool",
"separated_by": "it flips a daemon-wide setting from any channel, so the match is exact",
"open": false,
"prototype_count": 2,
"min_seed_examples": 8,
"examples": ["тихий режим", "не шуми", "не беспокоить", "включи тихий режим", "выключи тихий режим", "громкий режим", "quiet mode on", "quiet off"]
},
{
"id": "chat.open",
"intent": "chat",
"handler": "PhraseChat",
"means": "conversation, answered from the model with history",
"nearest": "query.self",
"separated_by": "nothing else claimed it and no source can answer it",
"open": true,
"prototype_count": 6,
"min_seed_examples": 12,
"reject_policy": "stays a measured positive class even while acting as a fallback region, or it silently absorbs every genuine miss",
"examples": ["привет", "как дела", "о чём поговорим", "чем занимаешься", "расскажи историю", "пошути", "анекдот", "что ты думаешь о жизни", "i'm bored", "tell me a joke", "what's up", "how are you"]
}
]
}
+51 -3
View File
@@ -52,10 +52,13 @@ type PlanEntry struct {
// Plan — the ordered day. Date is the calendar day it describes. Rest marks a
// plan trimmed by After, which changes what an empty one means: a day with
// nothing on it and a day whose last item has passed are different answers.
// More counts what Next dropped off the end, so the sentence can say that more
// remains instead of implying the day ends after the third line.
type Plan struct {
Date time.Time
Items []PlanEntry
Rest bool
More int
}
// BuildPlan orders everything known about the day Now falls on: calendar
@@ -142,13 +145,24 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.
return out
}
// NextSpoken — how many entries "что дальше?" reads aloud. Three, for the same
// reason the feed reads three headlines: the answer is spoken once and cannot be
// scrolled back, and a list longer than a breath is not an answer, it is a
// recital. Asked at 04:45 on a day with 43 entries, the trim below removes
// nothing — everything is still ahead — so the cap is what makes "дальше" mean
// next rather than today (V-618).
const NextSpoken = 3
// After returns the part of the plan that has not happened yet — the answer to
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
// empty result still knows which day it is empty for.
//
// Strictly after: an entry at exactly now is the thing happening, not the thing
// next.
func (p Plan) After(now time.Time) Plan {
out := Plan{Date: p.Date, Rest: true}
for _, it := range p.Items {
if it.At.Before(now) {
if !it.At.After(now) {
continue
}
out.Items = append(out.Items, it)
@@ -156,10 +170,30 @@ func (p Plan) After(now time.Time) Plan {
return out
}
// Next is After with a spoken cap — what "что дальше?" actually answers with.
// The overflow is counted rather than dropped, because "дальше: 10:00 …" with
// forty entries hidden behind it is a false picture of the day.
func (p Plan) Next(now time.Time, n int) Plan {
out := p.After(now)
if n > 0 && len(out.Items) > n {
out.More = len(out.Items) - n
out.Items = out.Items[:n]
}
return out
}
// FormatRU renders the plan as maven says it. Feminine self-reference,
// informal address, no pet names — and no exhortation: she reads the day back,
// she does not tell him to get on with it.
//
// Every hour is read in the plan's own zone — Date's, which BuildPlan sets from
// the asking clock. Printed raw, an hour read whatever zone its instant arrived
// in: an event or a reminder comes off the store as UTC, while a checklist line
// is built local, so one spoken sentence named two zones. This is the voice
// path, so that is what the owner heard (V-614); the same defect on the two web
// pages was V-612.
func (p Plan) FormatRU() string {
zone := p.Date.Location()
if len(p.Items) == 0 {
// "что дальше?" after the last item of the day. The day was not empty,
// it is over, and saying it was empty is a false statement about a day
@@ -171,14 +205,28 @@ func (p Plan) FormatRU() string {
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
line := fmt.Sprintf("%s — %s", it.At.In(zone).Format("15:04"), it.Text)
if it.Uncertain {
line = say.S(say.PlanUncertain, map[string]string{"line": line})
}
parts[i] = line
}
items := strings.Join(parts, "; ")
// The rest of the day is a different sentence, not a shorter day plan. It
// carries no date — he asked what is next, and he knows which day he is in —
// and it says out loud when there is more behind the cap.
if p.Rest {
if p.More > 0 {
return say.S(say.PlanNextMore, map[string]string{
"items": items,
"n": fmt.Sprint(p.More),
"word": say.CountWord(p.More, "дело", "дела", "дел"),
})
}
return say.S(say.PlanNext, map[string]string{"items": items})
}
return say.S(say.PlanDay, map[string]string{
"date": p.Date.Format("02.01.2006"),
"items": strings.Join(parts, "; "),
"items": items,
})
}
+110
View File
@@ -1,6 +1,7 @@
package morning
import (
"fmt"
"strings"
"testing"
"time"
@@ -151,6 +152,37 @@ func TestPlanFormatRU(t *testing.T) {
}
}
// One spoken sentence names one clock. An event and a reminder come off the
// store as UTC and a checklist line is built in the asking clock's zone, so the
// raw Format printed the two halves of one sentence in two zones (V-614). The
// zones here are three hours off whatever this machine runs in, so the test
// tells "read in his clock" apart from "read in the zone the instant arrived
// in" under TZ=UTC as well.
func TestPlanFormatRUReadsEveryHourInThePlansZone(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)
p := Plan{
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, away),
Items: []PlanEntry{
{At: stored, Text: "Планёрка", Kind: PlanEvent},
{At: planAt(time.Date(2026, 8, 3, 0, 0, 0, 0, away), 10, 30),
Text: "утро — осталось: витамины", Kind: PlanChecklist},
},
}
got := p.FormatRU()
if want := stored.In(away).Format("15:04"); !strings.Contains(got, want) {
t.Errorf("the event is not read in the plan's zone (%s): %q", want, got)
}
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
t.Errorf("the event is read in the zone it was stored in (%s): %q", bad, got)
}
if !strings.Contains(got, "10:30") {
t.Errorf("the checklist line moved zone: %q", got)
}
}
func TestPlanAfter(t *testing.T) {
p, now := planFixture(t)
rest := p.After(planAt(now, 11, 0))
@@ -171,6 +203,84 @@ func TestPlanAfter(t *testing.T) {
}
}
// nextFixture — a day with more entries than the cap, built in a zone three
// hours off UTC so the test fails under TZ=UTC as well as under the machine's
// own zone if the plan ever renders in the wrong one.
func nextFixture(t *testing.T) (Plan, time.Time) {
t.Helper()
zone := time.FixedZone("MSK", 3*60*60)
now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
var events []PlanEntry
for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} {
events = append(events, PlanEntry{
At: planAt(now, hhmm[0], hhmm[1]),
Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]),
Kind: PlanEvent,
})
}
return BuildPlan(nil, nil, events, nil, now), now
}
// "что дальше?" asked at 04:45 on a day with everything still ahead. The trim
// removes nothing there, so before V-618 she read the whole day out loud.
func TestPlanNextCapsWhatIsSpoken(t *testing.T) {
p, now := nextFixture(t)
got := p.Next(now, NextSpoken).FormatRU()
want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " +
"14:00 — событие 14:00. и ещё 2 дела до конца дня."
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
// No date: he asked what is next, not what day it is.
if strings.Contains(got, "03.08.2026") {
t.Errorf("rest-of-day answer stamps a date: %q", got)
}
}
// Nothing hidden means nothing claimed hidden.
func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) {
p, now := nextFixture(t)
got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU()
want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
// The whole-day question is not narrowed: same plan, no trim, no cap.
func TestPlanWholeDayIsNotNarrowed(t *testing.T) {
p, _ := nextFixture(t)
got := p.FormatRU()
if n := strings.Count(got, "событие"); n != 5 {
t.Errorf("whole day read %d of 5 entries: %q", n, got)
}
if !strings.HasPrefix(got, "план на 03.08.2026: ") {
t.Errorf("whole day lost its date: %q", got)
}
}
// The empty case says the day is over rather than returning an empty sentence,
// and it does not say the day was empty.
func TestPlanNextEmptySaysSo(t *testing.T) {
p, now := nextFixture(t)
got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU()
if got != "на сегодня больше ничего не запланировано." {
t.Errorf("got %q", got)
}
}
// An entry at exactly the asking minute is what is happening, not what is next.
func TestPlanNextIsStrictlyAfterNow(t *testing.T) {
p, now := nextFixture(t)
rest := p.Next(planAt(now, 5, 45), NextSpoken)
if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 {
t.Errorf("got %+v", rest.Items)
}
if rest.More != 1 {
t.Errorf("More = %d, want 1", rest.More)
}
}
// The plan says what today still has not got done, and a closed window does not
// make a skipped routine untrue. Evaluate reports Active only inside the
// window, so keying the checklist line off it meant the one thing the plan can
+69 -11
View File
@@ -31,6 +31,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
@@ -154,31 +155,78 @@ func clientHandshake(c net.Conn, token string) error {
// Listener wraps a net.Listener so Accept performs the token check for a tcp
// seam. A connection that fails the check is closed and never surfaces, so
// the protocol above this layer only ever sees authorized peers.
//
// A unix seam takes none of that machinery: Accept delegates straight to the
// wrapped listener, which is what it did before the token existed.
type Listener struct {
net.Listener
addr Addr
start sync.Once
closeOnce sync.Once
conns chan net.Conn
errc chan error // buffered 1, re-armed so every Accept sees the error
done chan struct{}
}
// Accept returns the next authorized connection. Unauthorized peers are
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
// reason to stop serving.
//
// Each tcp handshake runs in its own goroutine rather than inline here. A peer
// that connects and then says nothing holds its greeting open for
// handshakeTimeout, and inline that peer stalls every other connection for
// five seconds — one silent stranger was enough to freeze the seam.
func (l *Listener) Accept() (net.Conn, error) {
if l.addr.IsUnix() {
return l.Listener.Accept()
}
l.start.Do(func() { go l.acceptLoop() })
select {
case c := <-l.conns:
return c, nil
case err := <-l.errc:
l.errc <- err
return nil, err
}
}
// acceptLoop takes connections off the wrapped listener and greets each one
// concurrently. It ends on the first listener error, which every later Accept
// then reports.
func (l *Listener) acceptLoop() {
for {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
select {
case l.errc <- err:
case <-l.done:
}
return
}
if l.addr.IsUnix() {
return c, nil
}
if err := serverHandshake(c, l.addr.Token); err != nil {
_ = c.Close()
continue
}
return c, nil
go l.greet(c)
}
}
func (l *Listener) greet(c net.Conn) {
if err := serverHandshake(c, l.addr.Token); err != nil {
_ = c.Close()
return
}
select {
case l.conns <- c:
case <-l.done:
_ = c.Close()
}
}
// Close stops the listener and releases any connection still waiting to be
// handed to Accept.
func (l *Listener) Close() error {
l.closeOnce.Do(func() { close(l.done) })
return l.Listener.Close()
}
// Addr reports the parsed seam address this listener was built from.
func (l *Listener) SeamAddr() Addr { return l.addr }
@@ -236,7 +284,7 @@ func Listen(a Addr) (*Listener, error) {
if err != nil {
return nil, err
}
return &Listener{Listener: ln, addr: a}, nil
return wrap(ln, a), nil
}
if a.Token == "" {
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
@@ -245,7 +293,17 @@ func Listen(a Addr) (*Listener, error) {
if err != nil {
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
}
return &Listener{Listener: ln, addr: a}, nil
return wrap(ln, a), nil
}
func wrap(ln net.Listener, a Addr) *Listener {
return &Listener{
Listener: ln,
addr: a,
conns: make(chan net.Conn),
errc: make(chan error, 1),
done: make(chan struct{}),
}
}
func listenUnix(path string) (net.Listener, error) {
+36
View File
@@ -5,6 +5,7 @@ import (
"net"
"path/filepath"
"testing"
"time"
)
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
@@ -140,6 +141,41 @@ func TestTCPUngreetedPeerDoesNotKillTheListener(t *testing.T) {
}
}
// A peer that connects and never speaks must not hold the seam. The greeting
// it owes is bounded by handshakeTimeout, so serving it on the accept path
// costs every later connection those five seconds.
func TestTCPSilentPeerDoesNotStallTheSeam(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
go echoOnce(ln)
mute, err := net.Dial("tcp", addr.Address)
if err != nil {
t.Fatalf("mute dial: %v", err)
}
defer mute.Close()
done := make(chan string, 1)
go func() {
c, err := Dial(addr)
if err != nil {
done <- "dial: " + err.Error()
return
}
defer c.Close()
done <- roundTrip(t, c, "still here")
}()
select {
case got := <-done:
if got != "still here" {
t.Fatalf("got %q", got)
}
case <-time.After(handshakeTimeout / 2):
t.Fatal("a silent peer stalled the listener")
}
}
// A tcp seam with no token is a misconfiguration, and it must fail at bind
// rather than serve the owner's turns to anyone who connects.
func TestTCPListenRequiresToken(t *testing.T) {
+2 -3
View File
@@ -33,6 +33,7 @@ import (
"net/netip"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -323,7 +324,7 @@ scan:
go func(addr string, port int) {
defer wg.Done()
defer func() { <-sem }()
if s.dial(ctx, net.JoinHostPort(addr, itoa(port)), s.cfg.Timeout) {
if s.dial(ctx, net.JoinHostPort(addr, strconv.Itoa(port)), s.cfg.Timeout) {
results <- result{addr: addr, ports: []int{port}}
}
}(addr, port)
@@ -371,8 +372,6 @@ scan:
return Result{Hosts: out, Truncated: truncated}, nil
}
func itoa(n int) string { return fmt.Sprintf("%d", n) }
func dialTCP(ctx context.Context, addr string, timeout time.Duration) bool {
d := net.Dialer{Timeout: timeout}
ctx, cancel := context.WithTimeout(ctx, timeout)
+7 -7
View File
@@ -17,6 +17,8 @@ import (
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/lexicon"
)
// Facts — the optional, deployment-specific half of the block. All fields may
@@ -34,12 +36,10 @@ type Facts struct {
Tools bool // at least one shell act is on the allowlist
}
var ruWeekdays = [...]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"}
var ruMonths = [...]string{
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
}
// The weekday and month names are closed classes and live in internal/lexicon,
// which indexes weekdays from Sunday the way time.Weekday does and months from
// one. This file used to carry its own copies, making four copies of the twelve
// months in the tree after cmd/mavend/ruwords.go gave up its own (Vikunja #525).
// Block renders the context block for one turn. Russian even in front of the
// English prompts: the rules it states are Russian grammar (ты/тебя, feminine
@@ -61,7 +61,7 @@ func (f Facts) Block(now time.Time) string {
}
b.WriteString(fmt.Sprintf("Сейчас: %s, %d %s %d, %02d:%02d (местное время).\n",
ruWeekdays[int(now.Weekday())], now.Day(), ruMonths[int(now.Month())-1], now.Year(),
lexicon.Weekday(int(now.Weekday())), now.Day(), lexicon.MonthGenitive(int(now.Month())), now.Year(),
now.Hour(), now.Minute()))
b.WriteString("Умеешь: " + strings.Join(f.can(), "; ") +
+11 -1
View File
@@ -45,6 +45,14 @@ const (
// is the only authority the voice path can offer, and this is the one act
// it is not enough for (Vikunja #449, #523).
ActNeedsAuthedSurface = "act_needs_authed_surface"
// ActUnknownTarget — the verb reached a tool and the target did not reach
// anything. Named rather than run, because the alias match swallowed the verb
// and handed on the next word of the sentence (V-634).
ActUnknownTarget = "act_unknown_target"
// RepairNoted — he said the turn was wrong and did not say what it should
// have been. She confirms the label landed and does not ask, because the
// answer would be one of her own intent names (V-636).
RepairNoted = "repair_noted"
EcoDenied = "eco_denied"
EcoDown = "eco_down"
@@ -76,7 +84,7 @@ const (
var actKeys = []string{
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
ActNeedsAuthedSurface,
ActNeedsAuthedSurface, ActUnknownTarget, RepairNoted,
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
AttentionNone, AttentionList, AttentionFail,
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
@@ -102,6 +110,8 @@ var actFloor = map[string]string{
ActServerDown: "инструмент есть, но сервер не подключён.",
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
RepairNoted: "поняла, отметила, что ответила не так.",
ActUnknownTarget: "«{name}» — не знаю такой цели. назови её как в системе.",
ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
EcoDenied: "{name} отклоняет доступ, проверь токен.",
+8
View File
@@ -60,6 +60,14 @@
"fixed": true,
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
},
"repair_noted": {
"fixed": true,
"variants": ["поняла, отметила, что ответила не так."]
},
"act_unknown_target": {
"fixed": true,
"variants": ["«{name}» — не знаю такой цели. назови её как в системе."]
},
"act_needs_authed_surface": {
"fixed": true,
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
+33
View File
@@ -78,3 +78,36 @@ func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
t.Errorf("error = %v; want it to name the empty response", err)
}
}
// The other two paths, which had no such guard. The evidence branch of
// PhraseQuery and PhraseChat both returned ("", nil) off a server that produced
// no tokens — an empty answer reported as a successful phrasing. The daemon's
// callers check for the empty string and paper over it; the eval does not, and
// scored a silent model as bad phrasing rather than as a failure.
func TestEmptyAnswerIsAnErrorOnEveryPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
t.Run("evidence", func(t *testing.T) {
got, err := p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbQuerySources, "два литра", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbQuerySources)
}
})
t.Run("chat", func(t *testing.T) {
got, err := p.PhraseChat(context.Background(), "как дела", nil)
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbChat, "", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbChat)
}
})
}
+20 -6
View File
@@ -299,6 +299,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
if text != "" {
return text, nil
}
if raw == "" {
// Same guard the knowledge branch above has had since it was written,
// and this branch did not: the server answered and the model wrote
// nothing, which returned ("", nil) — an empty answer reported as a
// successful phrasing. The daemon's callers happen to check for the
// empty string, so it read as a silent fallback there; the eval scored
// it as bad phrasing rather than as the failure it is, and nothing on
// either path logged that the model had produced no tokens.
return SourcesFallback(strings.Join(notes, "; ")), errEmptyResponse
}
return raw, nil
}
@@ -375,7 +385,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
if i := strings.IndexByte(resp, '\n'); i >= 0 {
resp = resp[:i]
}
return strings.TrimSpace(resp), nil
if resp = strings.TrimSpace(resp); resp == "" {
// The model was up and wrote nothing. Same rule as PhraseQuery: the
// fallback keeps the turn alive and the failure stays visible, rather
// than ("", nil) telling the caller the chat path succeeded.
return ChatFallback(), fmt.Errorf("phrase chat: %w", errEmptyResponse)
}
return resp, nil
}
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
@@ -414,11 +430,9 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
if mood == "" {
mood = "neutral"
}
summary := body
if len(summary) > 60 {
summary = summary[:57] + "..."
}
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary, Mood: mood}, nil
return delivery.PhrasedReminder{
Decision: d, Body: body, Summary: reminderSummary(body), Mood: mood,
}, nil
}
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
+316
View File
@@ -0,0 +1,316 @@
package phraser
// The persona guard for the Go floor strings.
//
// internal/phraser/eval/fallbacks_test.go already scores everything Variants()
// returns — that is the JSON decks. What it cannot see is the floor UNDER those
// decks: the hardFloor/ackFloor/queryFloor/actFloor/confirmFloor maps and the
// literals in nudge_llm.go, which are what she says when the JSON is unusable or
// when the model is unreachable. Those are exactly the moments the model is not
// doing the talking, so leaving them unscored left the persona unchecked when it
// was most load-bearing (Vikunja #621).
//
// Two tests here, and the second one is the point:
//
// - TestGoFloorPersona scores the floor corpus on the same checks.
// - TestGoFloorCoverage walks the package source with go/ast and fails on any
// Russian string literal that neither reached the corpus nor sits inside a
// declaration declared prompt-side. A hand-written list of strings would rot
// the first time somebody adds one; a hand-written list of PROMPT BUILDERS
// does not, because the default for a new literal is "must be scored".
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"regexp"
"strconv"
"strings"
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser/eval"
"github.com/kami/maven/internal/store"
)
// personaChecks — the checks that apply to a floor line.
//
// The same three the non-goals section names (feminine self-reference, how she
// addresses him, no pet names), plus lang: an English floor line is unusable out
// loud. No hisgender, for the reason eval/fallbacks_test.go gives — her own
// feminine verb near "тебе" is correct and that check reads it as addressing him
// as a woman. No length, because a floor line composed from his own data has no
// bounded length, and no ontopic/mood, which need a fixture case.
var personaChecks = map[string]bool{
eval.CheckLang: true,
eval.CheckFeminine: true,
eval.CheckAddress: true,
eval.CheckCringe: true,
}
var floorPlaceholderRE = regexp.MustCompile(`\{[a-z_]+\}`)
// formatVerbRE — the fmt verbs a floor line is composed with, so the coverage
// test compares the Russian either side of them and not the verb.
var formatVerbRE = regexp.MustCompile(`%[+\-# 0-9.]*[a-zA-Z]`)
// floorLine — one scored string and where it came from, so a failure names the
// map or the function to go and edit.
type floorLine struct {
origin string
text string
}
// floorCorpus — every line the Go floor can produce. Maps are read whole, so a
// new entry in one is scored without touching this file; the composing functions
// are CALLED rather than scraped, so their glue text is scored in place.
func floorCorpus() []floorLine {
var out []floorLine
add := func(origin, text string) {
if strings.TrimSpace(text) != "" {
out = append(out, floorLine{origin, text})
}
}
for name, m := range map[string]map[string]string{
"fallbacks.go hardFloor": hardFloor,
"acks.go ackFloor": ackFloor,
"query.go queryFloor": queryFloor,
"acts.go actFloor": actFloor,
"confirm.go confirmFloor": confirmFloor,
"nudge_llm.go fallbackNudges": fallbackNudges,
} {
for key, text := range m {
add(name+"["+key+"]", text)
}
}
// fallbackNudge composes three of its four arms in Go. Drive every rule name
// the maps know, one it does not, and the down-services arm.
rules := map[string]bool{"": true, "unknown_rule": true}
for name := range fallbackNudges {
rules[name] = true
}
for name := range ruleTopics {
rules[name] = true
}
for name := range ruleKeywords {
rules[name] = true
}
for name := range rules {
c := loop.Candidate{}
c.Rule.Name = name
add(fmt.Sprintf("nudge_llm.go fallbackNudge(%q)", name), fallbackNudge(c))
}
// The keyword arm again, through a rule name shaped "family:keyword", which
// is where ruleKeyword's second branch lives.
c := loop.Candidate{}
c.Rule.Name = "custom:зарядку"
add("nudge_llm.go fallbackNudge(custom)", fallbackNudge(c))
// The keywords themselves. A rule that has both a keyword and a fallback
// line never reaches the keyword arm, but the map is edited as one thing and
// the next rule may have only the keyword, so score every value.
for rule, kw := range ruleKeywords {
add("nudge_llm.go ruleKeywords["+rule+"]", "Напоминаю: "+kw+".")
}
// The down-services arm, which needs a service actually reading down.
down := loop.Candidate{}
down.Rule.Name = "service_down"
down.State.Facts = map[string]store.Fact{
loop.ServiceDownPrefix + "gitea": {
Key: loop.ServiceDownPrefix + "gitea",
Value: `"down"`,
Source: loop.ServiceDownSource,
Ts: time.Now(),
},
}
add("nudge_llm.go fallbackNudge(down services)", fallbackNudge(down))
// The spoken duration words. Both functions are pure and bounded, so scoring
// their whole range beats scraping the literals out of the switch.
for m := 0; m <= 60*30; m += 7 {
d := time.Duration(m) * time.Minute
add("nudge_llm.go ruDur", ruDur(d))
add("nudge_templates.go ruSinceWords", ruSinceWords(d))
}
for h := 0; h <= hoursSpoken; h++ {
add("nudge_templates.go hourPlural", hourPlural(h))
add("nudge_templates.go hourWord", hourWord(h))
}
return out
}
// TestGoFloorPersona scores every line the Go floor can say.
func TestGoFloorPersona(t *testing.T) {
corpus := floorCorpus()
if len(corpus) == 0 {
t.Fatal("no floor lines — the corpus builder found nothing to score")
}
for _, line := range corpus {
// A placeholder stands for his own words and carries no persona.
body := floorPlaceholderRE.ReplaceAllString(line.text, "вода")
for _, r := range eval.RunChecks(eval.Case{}, body, "neutral") {
if personaChecks[r.Name] && !r.Pass {
t.Errorf("%s: %q fails %s: %s", line.origin, line.text, r.Name, r.Detail)
}
}
}
}
// promptDecls — declarations whose Russian is written FOR the model, not for
// him. They are excluded by name, not by string, so adding a line inside one of
// them stays excluded and adding a line anywhere else fails the coverage test.
//
// Every name here is asserted to still exist, so a rename fails loudly instead
// of silently widening the exemption.
var promptDecls = map[string]string{
"ReplySystemPrompt": "the system prompt for the reply model",
"replyContext": "renders the decision FOR the model, never spoken",
"ruleTopics": "situation descriptions fed to the nudge prompt",
"ruleTopic": "same, plus the two prefixes it composes",
"buildNudgePrompt": "the nudge prompt itself",
"chatUserMessage": "the history block handed to the model",
"PhraseReminder": "the reminder prompt; its reply is scored, its prompt is not",
}
// promptFiles — files whose whole job is prompt text. Asserted to exist, same
// reason as promptDecls.
var promptFiles = map[string]string{
"prompts.go": "every literal in it is a prompt",
}
func hasCyrillic(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}
// TestGoFloorCoverage — the guard that survives the next person.
//
// It reads the package source and requires every Russian string literal to be
// one of two things: reachable in floorCorpus (so TestGoFloorPersona scored it),
// or inside a declaration named above as prompt-side. There is no third answer
// and no way to add a floor string that quietly gets neither.
func TestGoFloorCoverage(t *testing.T) {
var scored []string
for _, line := range floorCorpus() {
scored = append(scored, line.text)
}
// A literal is covered when every Russian piece of it shows up in something
// the corpus scored. Pieces, not the whole string, because a format string
// ("%d ч") and a concatenation fragment ("Не отвечает: ") only ever reach him
// with the surrounding value filled in.
covered := func(lit string) bool {
for _, part := range formatVerbRE.Split(lit, -1) {
part = strings.TrimSpace(part)
if part == "" || !hasCyrillic(part) {
continue
}
found := false
for _, s := range scored {
if strings.Contains(s, part) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0)
if err != nil {
t.Fatalf("parse package: %v", err)
}
pkg, ok := pkgs["phraser"]
if !ok {
t.Fatal("package phraser did not parse — the coverage guard cannot run")
}
seenDecl := map[string]bool{}
seenFile := map[string]bool{}
for path, file := range pkg.Files {
base := path[strings.LastIndexByte(path, '/')+1:]
if _, exempt := promptFiles[base]; exempt {
seenFile[base] = true
continue
}
for _, decl := range file.Decls {
names := declNames(decl)
skip := false
for _, n := range names {
if _, ok := promptDecls[n]; ok {
seenDecl[n] = true
skip = true
}
}
if skip {
continue
}
ast.Inspect(decl, func(n ast.Node) bool {
bl, ok := n.(*ast.BasicLit)
if !ok || bl.Kind != token.STRING {
return true
}
lit, err := strconv.Unquote(bl.Value)
if err != nil || !hasCyrillic(lit) {
return true
}
if !covered(lit) {
t.Errorf("%s: Russian literal %q is spoken by nothing the persona guard scores.\n"+
"Either reach it from floorCorpus in persona_floor_test.go, or — if it is written "+
"for the model rather than for him — name its declaration in promptDecls.",
fset.Position(bl.Pos()), lit)
}
return true
})
}
}
for name, why := range promptDecls {
if !seenDecl[name] {
t.Errorf("promptDecls names %q (%s) and no such declaration exists — "+
"a rename left the exemption open", name, why)
}
}
for base, why := range promptFiles {
if !seenFile[base] {
t.Errorf("promptFiles names %q (%s) and no such file exists", base, why)
}
}
}
// declNames — the names a top-level declaration binds, so a prompt-side var,
// const or func can be matched whatever kind it is.
func declNames(decl ast.Decl) []string {
switch d := decl.(type) {
case *ast.FuncDecl:
return []string{d.Name.Name}
case *ast.GenDecl:
var out []string
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.ValueSpec:
for _, n := range s.Names {
out = append(out, n.Name)
}
case *ast.TypeSpec:
out = append(out, s.Name.Name)
}
}
return out
}
return nil
}
+25 -4
View File
@@ -115,11 +115,32 @@ func (s *Stub) PhraseReminder(_ context.Context, d loop.ReminderDecision) (deliv
if text == "" {
text = "reminder"
}
summary := text
if len(summary) > 60 {
summary = summary[:57] + "..."
// Mood, for the same reason PhraseNudge sets it: the Stub is a production
// fallback, so it owes the output contract a value. This one was left at the
// zero value, which is not one of the five moods.
return delivery.PhrasedReminder{
Decision: d, Body: text, Summary: reminderSummary(text), Mood: "neutral",
}, nil
}
// summaryLimit — how much of a reminder goes to the away channels.
const summaryLimit = 60
// reminderSummary shortens a reminder body to the away-channel summary.
//
// Counted in runes. Both copies of this counted bytes — `len(s) > 60` and
// `s[:57]` — and on a Russian reminder that is wrong twice. A Cyrillic letter is
// two bytes, so the cut fell at about 28 letters rather than 60; and byte 57
// lands inside a letter about half the time, so the summary ended in half a
// rune. That is not cosmetic: Sendable.Summary is the text voicesink hands to
// piper and the text the telegram sink posts, so the broken byte was spoken and
// sent.
func reminderSummary(body string) string {
r := []rune(body)
if len(r) <= summaryLimit {
return body
}
return delivery.PhrasedReminder{Decision: d, Body: text, Summary: summary}, nil
return string(r[:summaryLimit-3]) + "..."
}
// phraseNudge — the per-rule templates. each reads the context the predicate
+29
View File
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
@@ -185,6 +186,34 @@ func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
}
}
// The same truncation, in the language she actually speaks. The test above is
// ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]`
// cut a Russian reminder at about 28 letters instead of 60, and landed inside a
// letter about half the time. Summary is what voicesink hands to piper and what
// the telegram sink posts, so half a rune was spoken and sent.
func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) {
long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера"
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
State: loop.State{Now: time.Now().UTC()},
}
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
if !utf8.ValidString(pr.Summary) {
t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary)
}
if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit {
t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary)
}
// The cut must be near the limit, not near half of it. A byte count would
// stop at 28 letters here.
if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 {
t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary)
}
if pr.Mood == "" {
t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood")
}
}
func TestPhraseReminderNonJSONPayload(t *testing.T) {
// a payload that isn't JSON → the phraser falls back to the raw string.
rd := loop.ReminderDecision{
+72
View File
@@ -0,0 +1,72 @@
package router
import "testing"
// Before V-633 the matcher only ever matched the English tool name, so no
// Russian utterance could reach a tool: 55 of the 69 lines in models/seeds/act.txt
// routed to IntentAct and then fell to proposeGap. These are those lines.
func TestActMatcherAliases(t *testing.T) {
m := DefaultActMatcher{
Fns: []string{"status", "ps", "uptime", "disk", "memory", "logs",
"restart", "stop", "start", "docker-restart", "docker-stop", "reboot"},
Aliases: map[string][]string{
"status": {"статус", "покажи статус"},
"ps": {"статус докера", "что запущено"},
"uptime": {"покажи uptime", "как работает сервер"},
"disk": {"сколько места на диске"},
"memory": {"свободная память"},
"logs": {"покажи логи", "логи"},
"restart": {"перезагрузи", "перезапусти"},
"docker-restart": {"перезагрузи контейнер"},
"reboot": {"перезагрузи сервер"},
},
}
cases := []struct {
utterance string
wantFn string
wantArgs []string
}{
{"покажи статус nginx", "status", []string{"nginx"}},
{"статус sshd", "status", []string{"sshd"}},
{"статус докера", "ps", nil},
{"что запущено", "ps", nil},
{"сколько места на диске", "disk", nil},
{"свободная память", "memory", nil},
{"покажи uptime", "uptime", nil},
{"логи nginx", "logs", []string{"nginx"}},
{"перезагрузи nginx", "restart", []string{"nginx"}},
// Longest phrase first, so the two-word alias wins over the one word
// inside it and the act reaches the right tool.
{"перезагрузи контейнер maven", "docker-restart", []string{"maven"}},
{"перезагрузи сервер", "reboot", nil},
// The English names still match, unchanged.
{"restart nginx", "restart", []string{"nginx"}},
{"uptime", "uptime", nil},
}
for _, c := range cases {
fn, args, ok := m.Match(c.utterance)
if !ok || fn != c.wantFn {
t.Errorf("%q: got fn=%q ok=%v, want %q", c.utterance, fn, ok, c.wantFn)
continue
}
if len(args) != len(c.wantArgs) {
t.Errorf("%q: got args=%v, want %v", c.utterance, args, c.wantArgs)
continue
}
for i := range args {
if args[i] != c.wantArgs[i] {
t.Errorf("%q: got args=%v, want %v", c.utterance, args, c.wantArgs)
break
}
}
}
// Past tense is a fact, not a command, and aliases match exact tokens so it
// stays one. This is the trap cmd/mavend/quiet_toggle.go documents.
if fn, _, ok := m.Match("перезагрузил роутер"); ok {
t.Errorf("past tense reached a tool: fn=%q", fn)
}
// A phrase nobody configured still refuses, so the router can clarify.
if fn, _, ok := m.Match("свари кофе"); ok {
t.Errorf("unconfigured phrase reached a tool: fn=%q", fn)
}
}
+8 -7
View File
@@ -35,16 +35,14 @@ var dayPlanWords = []string{
// answer today and stamp it with today's date, which is a wrong answer where
// falling through is only a terse one.
//
// The weekday names are here as a refusal, not as a feature. "какие планы на
// понедельник?" carries no other-day token in the сегодня family and does carry
// "планы", so the plan used to claim it and recite today.
// A weekday is a refusal too, and it is not in this list: IsDayPlanQuery asks
// WeekdayIndex, so every case of every name refuses rather than the nine forms
// that used to be written out here (V-581). "какие планы на понедельник?"
// carries no other-day token in the сегодня family and does carry "планы", so
// the plan used to claim it and recite today.
var otherDayWords = []string{
"завтра", "послезавтра", "вчера", "позавчера",
"tomorrow", "yesterday",
"понедельник", "вторник", "среду", "среда", "четверг", "пятницу", "пятница",
"субботу", "суббота", "воскресенье",
"понедельника", "вторника", "четверга", "пятницы", "субботы", "воскресенья",
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
"неделю", "неделя", "недели", "неделе",
"выходные", "выходных", "выходным",
"месяц", "месяца", "месяце",
@@ -69,6 +67,9 @@ func IsDayPlanQuery(text string) bool {
}
toks := planTokens(text)
for _, t := range toks {
if _, ok := WeekdayIndex(t); ok {
return false
}
for _, w := range otherDayWords {
if t == w {
return false
+15 -6
View File
@@ -45,10 +45,10 @@ try:
now = datetime.fromisoformat(sys.argv[2])
# Pre-process: replace Russian time qualifiers with AM/PM.
# Handles "9 утра", "10 часов утра", "3 часа дня" etc.
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
# A bare hour after a preposition is dropped on the floor by dateparser:
# "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7
# часов" is read as seven hours from now. Only a qualifier (already an
@@ -56,8 +56,17 @@ try:
# English "at 7" fails identically, so both prepositions are rewritten.
# "на 9" is the same hour said with the other preposition, and it was not
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
# "к двум часам" is a third preposition and the dative that goes with it,
# and it was read as no time at all until V-609.
# The preposition is normalised as well as the hour (V-610). dateparser
# joins a day word to a clock through "в" and through no other Russian
# preposition, so "завтра к 03:00 pm" loses the clock and resolves to
# tomorrow at the CURRENT minute. "на" was silently losing it the same way.
def _at(m):
prep = 'at' if m.group(1).lower() in ('at', 'by') else 'в'
return '%s %02d:00' % (prep, int(m.group(2)))
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
_at, text, flags=re.IGNORECASE)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
# parse() gets the time right (search_dates mishandles AM/PM).
+286
View File
@@ -0,0 +1,286 @@
package router
import (
"strconv"
"strings"
"testing"
)
// The fact-parser corpus (V-586). DefaultFactParser is the only thing standing
// between a spoken sentence and a written self-care fact, and until this file
// it was covered by a handful of examples chosen by whoever last edited it. The
// RU routing fixture does not cover it either: it holds three fact cases and
// all three miss on intent, so the parser is never reached and a parser change
// scores as "unchanged".
//
// So the corpus is here, and it scores BOTH implementations: the closed-class
// parser that ships, and legacyFactParse below, a faithful copy of the
// hand-written substring version 22edc3c replaced. The table is the measurement
// — see docs/evals/2026-08-06-fact-parser.md for the numbers as of that day.
//
// Three case classes, and the third is the point:
//
// true positive — a sentence he would say, with the key it must write.
// misfire — a sentence the substring parser wrote a fact for and
// should not have. want is "".
// false-negative — a sentence he would plausibly say whose word is in NO
// lexicon set. want is the key a human would assign, and
// the ship parser is EXPECTED to miss it. These measure the
// cost of the word-list design, not a bug in it.
//
// Nothing here asserts a score. A closed class is a decision about vocabulary
// and the bar belongs in a dated eval, not in an assertion that turns every
// vocabulary edit into a red test. What it does fail on is a regression in the
// two classes that are not judgement calls: a true positive that stops matching
// and a misfire that starts.
type factCase struct {
utterance string
want string // "" — no fact
class string // "tp", "misfire", "fn"
broken bool // ship parser is known to get this wrong; see the 06-08 eval
note string
}
var factCorpus = []factCase{
// ---- water, true positives -------------------------------------------
{"выпил стакан воды", "water", "tp", false, ""},
{"попил воды", "water", "tp", false, ""},
{"я попил водички", "water", "tp", false, ""},
{"пью воду", "water", "tp", false, ""},
{"воду пил уже", "water", "tp", false, ""},
{"допил воду", "water", "tp", true, "REGRESSION: the dictionary lemmatises допил to допилить, to finish sawing — the same saw collision drink_verbs already carries пил for, unfixed for the prefixed form"},
{"запил таблетку водой", "water", "tp", false, ""},
{"drank water", "water", "tp", false, ""},
{"i drank some water", "water", "tp", false, ""},
// ---- meal, true positives --------------------------------------------
{"поужинал", "meal", "tp", false, ""},
{"я пообедал", "meal", "tp", false, ""},
{"позавтракал кашей", "meal", "tp", false, ""},
{"перекусил бутербродом", "meal", "tp", false, ""},
{"покушал", "meal", "tp", false, ""},
{"поел супа", "meal", "tp", false, ""},
{"обед был в час", "meal", "tp", false, ""},
{"ужинать буду позже", "meal", "tp", false, ""},
{"i ate", "meal", "tp", false, ""},
{"had lunch", "meal", "tp", false, ""},
{"dinner done", "meal", "tp", false, ""},
// ---- shower, true positives ------------------------------------------
{"принял душ", "shower", "tp", false, ""},
{"сходил в душ", "shower", "tp", false, ""},
{"душ принят", "shower", "tp", false, ""},
{"ополоснулся душем", "shower", "tp", false, ""},
{"took a shower", "shower", "tp", false, ""},
{"i showered", "shower", "tp", false, ""},
// ---- break, true positives -------------------------------------------
{"сделал перерыв", "break", "tp", false, ""},
{"отдохнул полчаса", "break", "tp", false, ""},
{"передохнул немного", "break", "tp", false, ""},
{"отдыхаю", "break", "tp", false, ""},
{"был перерыв на обед", "meal", "tp", false, "meal wins the switch; both keys are true of the sentence"},
{"took a break", "break", "tp", false, ""},
// ---- sleep, true positives -------------------------------------------
{"спал восемь часов", "sleep", "tp", false, ""},
{"спала плохо", "sleep", "tp", false, "she may report her own; the parser is speaker-agnostic"},
{"поспал днём", "sleep", "tp", false, ""},
{"выспался наконец", "sleep", "tp", false, ""},
{"проспал будильник", "sleep", "tp", false, ""},
{"пойду спать", "sleep", "tp", false, ""},
{"slept 8 hours", "sleep", "tp", false, ""},
{"i slept badly", "sleep", "tp", false, ""},
// ---- misfires 22edc3c set out to reject -------------------------------
{"пилот сказал что вылет через час", "", "misfire", false, "substring пил"},
{"водитель уже подъехал", "", "misfire", false, "substring вод"},
{"надо заводить машину", "", "misfire", false, "substring вод"},
{"душа болит", "", "misfire", false, "substring душ"},
{"в комнате душно", "", "misfire", false, "substring душ"},
{"это была беда", "", "misfire", false, "substring еда"},
{"наша победа", "", "misfire", false, "substring еда"},
{"пила лежит в гараже", "", "misfire", false, "substring пил"},
{"водитель пилота ждёт", "", "misfire", false, "both stems in one sentence — the old water arm fires"},
{"обеденный перерыв отменили", "", "misfire", true, "neither parser gets this: the old one writes meal off the adjective, the new one writes break off перерыв. A cancelled break is not a break taken."},
// ---- hard negatives that decide the design ----------------------------
{"есть новости по бэкапу базы", "", "misfire", false, "есть is the existential, not a meal"},
{"напоминания на завтра есть", "", "misfire", false, "завтра carries завтрак as a substring"},
{"на душе легко", "", "misfire", false, "душе is the soul, and also the prepositional of душ"},
{"пилил доску весь вечер", "", "misfire", false, ""},
{"поставь будильник на завтра", "", "misfire", false, "завтра again, no meal"},
// ---- false negatives: words in no lexicon set -------------------------
// Water.
{"выпил чаю", "water", "fn", false, "hydration by any liquid; чай is in no set"},
{"глотнул воды", "water", "fn", false, "глотнуть is not a drink verb"},
{"хлебнул воды", "water", "fn", false, "хлебнуть is not a drink verb"},
{"воды хлебнул из бутылки", "water", "fn", false, ""},
{"выпил стакан", "water", "fn", false, "the noun is elided; he says this"},
{"i hydrated", "water", "fn", false, "hydrate is in no set"},
{"finished my bottle of water", "water", "fn", false, "no drink verb in the English set"},
// Meal.
{"ем суп", "meal", "fn", false, "есть is deliberately absent, and this is the cost"},
{"съел бутерброд", "meal", "fn", false, "съесть is in no set"},
{"наелся", "meal", "fn", false, "наесться is in no set"},
{"пожрал", "meal", "fn", false, "coarse but spoken"},
{"полдник был", "meal", "fn", false, "полдник is in no set"},
{"i had a snack", "meal", "fn", false, "snack is in no set"},
{"having supper", "meal", "fn", false, "supper is in no set"},
{"brunch was good", "meal", "fn", false, "brunch is in no set"},
{"i eat now", "meal", "fn", false, "eat is in no set — only ate is"},
// Shower.
{"был в душе", "shower", "fn", false, "prepositional; anyExact cannot take it without taking the soul"},
{"после душа полегчало", "shower", "fn", false, "genitive, same collision"},
{"помылся", "shower", "fn", false, "помыться is in no set"},
{"сходил в ванную", "shower", "fn", false, "ванная is in no set"},
{"искупался", "shower", "fn", false, "искупаться is in no set"},
{"i am showering", "shower", "fn", false, "showering is not a member and the set is exact-matched"},
// Break.
{"сделал передышку", "break", "fn", false, "передышка is in no set"},
{"перекур", "break", "fn", false, "перекур is in no set"},
{"полежал немного", "break", "fn", false, "полежать is in no set"},
{"сделал паузу", "break", "fn", false, "пауза is in no set"},
{"i took five", "break", "fn", false, "idiomatic, in no set"},
{"resting now", "break", "fn", false, "resting is not a member and rest is matched by lemma only"},
// Sleep.
{"вздремнул", "sleep", "fn", false, "вздремнуть is in no set"},
{"прикорнул на диване", "sleep", "fn", false, "прикорнуть is in no set"},
{"дрых до обеда", "sleep", "fn", false, "дрыхнуть is in no set — and обед makes this a MEAL for both parsers"},
{"недоспал", "sleep", "fn", false, "недоспать is in no set"},
{"лёг в двенадцать", "sleep", "fn", false, "лечь is in no set"},
{"сон был короткий", "sleep", "fn", false, "сон deliberately absent"},
{"i napped", "sleep", "fn", false, "nap is in no set"},
{"took a nap", "sleep", "fn", false, "break matches nothing here either"},
}
// legacyFactParse — DefaultFactParser exactly as it stood at 22edc3c's parent
// (0445693), copied here so the corpus scores the trade rather than describing
// it. Do not fix it. It is a frozen baseline, and the day it stops being worth
// comparing against, delete it and the two-column table with it.
func legacyFactParse(utterance string) (string, string, bool) {
s := strings.ToLower(strings.TrimSpace(utterance))
hasRoot := func(root string) bool { return strings.Contains(s, root) }
containsWord := func(w string) bool {
for _, tok := range strings.Fields(s) {
if tok == w {
return true
}
}
return false
}
switch {
case (containsWord("water") && containsWord("drank")) ||
(hasRoot("вод") && (hasRoot("пил") || hasRoot("пью") || hasRoot("пей"))):
return "water", `"drank"`, true
case containsWord("meal") || (containsWord("ate") && !containsWord("backup")) || containsWord("lunch") || containsWord("dinner") ||
hasRoot("поел") || hasRoot("поесть") || hasRoot("куша") || hasRoot("обед") || hasRoot("ужин") || hasRoot("завтрак") || hasRoot("еда"):
return "meal", `"ate"`, true
case containsWord("shower") || hasRoot("душ"):
return "shower", `"took"`, true
case containsWord("break") || hasRoot("перерыв") || hasRoot("отдох"):
return "break", `"took"`, true
case containsWord("slept") || containsWord("sleep") ||
hasRoot("спал") || hasRoot("выспал"):
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
return "sleep", strconv.Quote(v), true
}
return "sleep", `"slept"`, true
}
return "", "", false
}
// TestFactParserCorpus scores both parsers over the corpus and prints the
// side-by-side. Run it with -v; the table is the output.
func TestFactParserCorpus(t *testing.T) {
type tally struct{ tpHit, tpMiss, misfire, misfireOK, fnHit, fnMiss int }
var now, old tally
var rows []string
rows = append(rows, "| utterance | class | want | old (substring) | new (closed class) |")
rows = append(rows, "|---|---|---|---|---|")
score := func(key string, ok bool, c factCase, tl *tally) string {
got := ""
if ok {
got = key
}
switch c.class {
case "tp":
if got == c.want {
tl.tpHit++
} else {
tl.tpMiss++
}
case "misfire":
if got == c.want {
tl.misfireOK++
} else {
tl.misfire++
}
case "fn":
if got == c.want {
tl.fnHit++
} else {
tl.fnMiss++
}
}
if got == "" {
return "—"
}
return got
}
for _, c := range factCorpus {
nk, _, nok := DefaultFactParser{}.Parse(c.utterance)
ok, _, ook := legacyFactParse(c.utterance)
gotNew := score(nk, nok, c, &now)
gotOld := score(ok, ook, c, &old)
want := c.want
if want == "" {
want = "—"
}
mark := ""
if gotOld != gotNew {
mark = " **≠**"
}
rows = append(rows, "| `"+c.utterance+"` | "+c.class+" | "+want+" | "+gotOld+" | "+gotNew+mark+" |")
}
line := func(name string, tl tally) string {
return name +
": true positives " + strconv.Itoa(tl.tpHit) + "/" + strconv.Itoa(tl.tpHit+tl.tpMiss) +
", misfires rejected " + strconv.Itoa(tl.misfireOK) + "/" + strconv.Itoa(tl.misfireOK+tl.misfire) +
", false-negative cases recovered " + strconv.Itoa(tl.fnHit) + "/" + strconv.Itoa(tl.fnHit+tl.fnMiss)
}
t.Log("\n" + strings.Join(rows, "\n") + "\n\n" + line("old (substring)", old) + "\n" + line("new (closed class)", now))
// The two regressions that are not judgement calls.
for _, c := range factCorpus {
k, _, ok := DefaultFactParser{}.Parse(c.utterance)
got := ""
if ok {
got = k
}
if c.class == "fn" {
continue
}
if c.broken {
// Recorded as wrong on 06-08. If it starts passing, someone fixed
// it and the flag is now a lie — say so rather than staying green.
if got == c.want {
t.Errorf("%q now returns %q as wanted — drop its broken flag", c.utterance, got)
}
continue
}
if got != c.want {
t.Errorf("%s %q: got %q, want %q (%s)", c.class, c.utterance, got, c.want, c.note)
}
}
}
+5 -21
View File
@@ -27,26 +27,10 @@ var habitMarkers = []string{
"typically", "normally",
}
// weekdayWords — every form of a weekday name maven needs to recognise,
// including the "по …ам" plural the question is usually phrased in.
var weekdayWords = map[string]time.Weekday{
"понедельник": time.Monday, "понедельникам": time.Monday,
"вторник": time.Tuesday, "вторникам": time.Tuesday,
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
"четверг": time.Thursday, "четвергам": time.Thursday,
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
"воскресенья": time.Sunday, "воскресенью": time.Sunday,
"воскресеньем": time.Sunday, "воскресеньях": time.Sunday,
"monday": time.Monday, "mondays": time.Monday,
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
"thursday": time.Thursday, "thursdays": time.Thursday,
"friday": time.Friday, "fridays": time.Friday,
"saturday": time.Saturday, "saturdays": time.Saturday,
"sunday": time.Sunday, "sundays": time.Sunday,
}
// The weekday a habit question names comes from WeekdayIndex, not from a map
// here. This file used to keep its own declension table, which had "воскресеньях"
// and no "средах" — a list of forms is finished by whoever last thought of one,
// and a dictionary is not (V-581).
// weekendWords — the weekend as one unit. "что я обычно делаю по выходным?"
// has a habit marker and names days, but no weekday name is in it, so it used
@@ -77,7 +61,7 @@ func ParseHabitQuery(text string) (HabitQuery, bool) {
return HabitQuery{}, false
}
for _, t := range toks {
if wd, ok := weekdayWords[t]; ok {
if wd, ok := WeekdayIndex(t); ok {
return HabitQuery{Weekday: wd, HasWeekday: true}, true
}
if weekendWords[t] {
+74
View File
@@ -0,0 +1,74 @@
package router
import (
"context"
"testing"
"time"
)
// TestDativePluralHourIsAnHour — "напомни к двум часам позвонить маме" reached
// the daemon with no time at all and she asked the open "Когда?", while "к трём"
// one word over read fine (V-609). The word that lost it was "часам", the dative
// plural of "час", which four separate hour sets in this package left out.
func TestDativePluralHourIsAnHour(t *testing.T) {
const s = "напомни к двум часам позвонить маме"
if !MentionsTime(s) {
t.Errorf("MentionsTime(%q) = false; the sentence names two o'clock", s)
}
if !NamesAnHour(s) {
t.Errorf("NamesAnHour(%q) = false; the sentence names two o'clock", s)
}
if got, want := SpellOutDigits(s), "напомни к 2 часам позвонить маме"; got != want {
t.Errorf("SpellOutDigits(%q) = %q, want %q", s, got, want)
}
// The slot itself, which is what the daemon reads. It was empty, so
// whenGapOf named the hour missing and she asked "Когда?".
now := time.Date(2026, 8, 6, 3, 39, 0, 0, time.UTC)
ex := Extractor{Time: StubDateTimeParser{}}
got := ex.Extract(context.Background(), IntentReminder, s, now)
if !got.HasTime {
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", got)
}
if h := got.Time.Hour(); h != 2 && h != 14 {
t.Errorf("fire time = %s, want two o'clock in one half of the day or the other", got.Time.Format("15:04"))
}
}
// TestHourUnitReachesEverySite — the four sets that read the hour noun now read
// one lexicon key, so a form added there is a form all four know. "часам" is the
// form that was missing from every one of them.
func TestHourUnitReachesEverySite(t *testing.T) {
for _, w := range []string{"час", "часа", "часов", "часу", "часам"} {
if !numeralContext[w] {
t.Errorf("numeralContext is missing %q", w)
}
if !hourMarkers[w] {
t.Errorf("hourMarkers is missing %q", w)
}
if !timeMarkers[w] {
t.Errorf("timeMarkers is missing %q", w)
}
if _, ok := unitToDuration(2, w); !ok {
t.Errorf("unitToDuration does not know %q", w)
}
}
}
// TestMinuteUnitHasTheSameForms — the same defect one noun over: "минутам" was
// missing everywhere "минут" and "минуты" were present.
func TestMinuteUnitHasTheSameForms(t *testing.T) {
for _, w := range []string{"минут", "минуты", "минуту", "минутам"} {
if !numeralContext[w] {
t.Errorf("numeralContext is missing %q", w)
}
if !hourMarkers[w] {
t.Errorf("hourMarkers is missing %q", w)
}
if !timeMarkers[w] {
t.Errorf("timeMarkers is missing %q", w)
}
if _, ok := unitToDuration(20, w); !ok {
t.Errorf("unitToDuration does not know %q", w)
}
}
}
+172
View File
@@ -0,0 +1,172 @@
package router
import (
"context"
"os/exec"
"testing"
"time"
)
// probeNow is the clock the five sentences below were measured against on the
// box at 03:53 on 2026-08-06, right after #252 deployed. Three of them wrote a
// reminder for 03:53 itself, which is the current minute and not an hour anyone
// said (V-610).
var probeNow = time.Date(2026, 8, 6, 3, 53, 0, 0, time.Local)
// kProbe — the five sentences, with what each must resolve to. The two that
// already worked are here so that fixing "к" cannot cost "в".
var kProbe = []struct {
text string
// day is the offset from probeNow's date, hour is the fire hour.
day int
hour int
whyItIs string
}{
{
text: "напомни завтра в три часа дня позвонить врачу",
day: 1,
hour: 15,
whyItIs: "в plus a spoken hour and a qualifier resolves, and did before #252",
},
{
text: "напомни завтра в 15:00 позвонить врачу",
day: 1,
hour: 15,
whyItIs: "a written clock resolves, and did before #252",
},
{
text: "напомни завтра к трём часам дня позвонить врачу",
day: 1,
hour: 15,
whyItIs: "к names the same hour в does, and wrote 03:53 after #252",
},
{
text: "напомни сегодня к пяти часам вечера позвонить врачу",
day: 0,
hour: 17,
whyItIs: "the same defect on today and on the oblique пяти",
},
{
text: "напомни завтра к трём часам позвонить врачу",
day: 1,
hour: 3,
whyItIs: "no qualifier, so the hour reads as spoken and the daemon asks which half",
},
}
// TestKPrepositionHourStub — the probe against the floor parser, which answers
// on every box whether or not python is installed.
func TestKPrepositionHourStub(t *testing.T) {
ex := Extractor{Time: StubDateTimeParser{}}
for _, c := range kProbe {
t.Run(c.text, func(t *testing.T) {
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
if !got.HasTime {
t.Fatalf("time slot empty: %s", c.whyItIs)
}
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
})
}
}
// TestKPrepositionHourPython — the same probe against the production parser.
// Skips where python3 or dateparser is missing, as the rest of this package's
// python tests do.
func TestKPrepositionHourPython(t *testing.T) {
requirePython(t)
p := NewPythonDateParser()
ex := Extractor{Time: p}
for _, c := range kProbe {
t.Run(c.text, func(t *testing.T) {
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
if !got.HasTime {
t.Fatalf("time slot empty: %s", c.whyItIs)
}
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
})
}
}
func checkFire(t *testing.T, fire time.Time, dayOffset, hour int, why string) {
t.Helper()
if fire.Hour() != hour || fire.Minute() != 0 {
t.Errorf("fire = %s, want %02d:00 — %s", fire.Format("2006-01-02 15:04"), hour, why)
}
if fire.Minute() == probeNow.Minute() && fire.Hour() == probeNow.Hour() {
t.Errorf("fire = %s, which is the clock at the moment of the turn and not an hour he said", fire.Format("15:04"))
}
want := probeNow.AddDate(0, 0, dayOffset)
if fire.Year() != want.Year() || fire.Month() != want.Month() || fire.Day() != want.Day() {
t.Errorf("fire = %s, want the %s — %s", fire.Format("2006-01-02"), want.Format("2006-01-02"), why)
}
}
// TestUnresolvedHourLeavesTheSlotEmpty — the durable half. A parser that read
// the day and took the minute off the clock must not fill the time slot, no
// matter which preposition lost the hour. This is the whole class the "к" case
// was one member of.
func TestUnresolvedHourLeavesTheSlotEmpty(t *testing.T) {
ex := Extractor{Time: clockEchoParser{}}
for _, s := range []string{
"напомни завтра к трём часам дня позвонить врачу",
"напомни завтра в три часа дня позвонить врачу",
"напомни завтра на девять позвонить врачу",
"напомни сегодня к пяти часам вечера позвонить врачу",
} {
got := ex.Extract(context.Background(), IntentReminder, s, probeNow)
if got.HasTime {
t.Errorf("Extract(%q) filled the slot with %s, which is the current minute; she has to ask", s, got.Time.Format("15:04"))
}
}
}
// TestSpokenMinutesSurviveTheRefusal — the three shapes that name a minute of
// their own, and an hour that happens to be the hour it is spoken in. Refusing
// on the whole instant instead of on the minute would cost every one of these,
// and ru-rem-006 in the routing fixture is the case that says so.
func TestSpokenMinutesSurviveTheRefusal(t *testing.T) {
noon := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
ex := Extractor{Time: StubDateTimeParser{}}
for _, c := range []struct {
text string
hour int
min int
}{
{"напомни послезавтра в 12 забрать заказ", 12, 0},
{"разбуди меня в 6:30", 6, 30},
{"напомни в половине восьмого выпить таблетку", 7, 30},
{"напомни без четверти восемь выходить", 7, 45},
} {
got := ex.Extract(context.Background(), IntentReminder, c.text, noon)
if !got.HasTime {
t.Errorf("Extract(%q) left the slot empty; he said the time", c.text)
continue
}
if got.Time.Hour() != c.hour || got.Time.Minute() != c.min {
t.Errorf("Extract(%q) = %s, want %02d:%02d", c.text, got.Time.Format("15:04"), c.hour, c.min)
}
}
}
// TestIntervalKeepsTheCurrentMinute — an interval is measured from now and may
// land on now's own minute, so the refusal above must not reach it.
func TestIntervalKeepsTheCurrentMinute(t *testing.T) {
ex := Extractor{Time: StubDateTimeParser{}}
got := ex.Extract(context.Background(), IntentReminder, "напомни через час позвонить врачу", probeNow)
if !got.HasTime {
t.Fatal("через час names one instant and answers the hour and the day together")
}
if want := probeNow.Add(time.Hour); !got.Time.Equal(want) {
t.Errorf("fire = %s, want %s", got.Time.Format("15:04"), want.Format("15:04"))
}
}
func requirePython(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not on PATH — skipping dateparser tests")
}
if err := exec.Command("python3", "-c", "import dateparser").Run(); err != nil {
t.Skip("python dateparser not installed — skipping dateparser tests")
}
}

Some files were not shown because too many files have changed in this diff Show More