Compare commits

...

61 Commits

Author SHA1 Message Date
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 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 01230bf16b Merge the routine, morning and tasks sweep (#242)
Four real bugs, all of them the kind that show as a wrong number or a silence.

tasks.Stalls compared Due against an instant while Rank compares whole calendar
days through dayDelta, under a long comment about that exact trap. Both render
on /tasks, so a task due at 09:00 counted as просрочено in the header from 09:01
while its own row still read сегодня. Stalls reads dayDelta now.

tasks.Stalls also counted a row with no capture time as sitting, because the
zero time is January of year 1. score() already guarded IsZero and Stalls did
not.

morning and routine both key their last-fired map by name, and neither Validate
rejected a duplicate. Two routines sharing one name take turns suppressing each
other, and the operator sees a routine that never runs and no error. Both
Validate functions reject it now.

parseHHMM checked digits arithmetically, so a stray character could cancel out.
window_start: "2 :00" parsed as 04:00 and passed the validation whose whole job
is catching that typo. All four positions are checked as digits, which makes the
negative bounds unreachable, so they are gone.

FormatRU and Spoken each carried a byte-identical open and candidate partition,
now one split. They have to agree on where that line falls, or she reads one
list and binds ordinals against another. The three copies of the unevidenced
item loop folded into one helper.

The not-a-nag check passes. All three packages are pure, return candidates, and
reach no sink.

(V-581)
2026-08-06 03:13:32 +04:00
claude ebce90b984 Merge the capture and dialogue sweep (#241)
One bug with teeth. capture.windowBytes computed int64(window.Seconds()) *
bytesPerSecond, truncating to whole seconds. A sub-second window came out as
zero bytes, which transcribeFile reads as no window, so it hands the transcriber
the entire recording in one call. Multiplied in float now.

One drifted comment. Peek said expired entries below the top are left alone. The
code deletes the whole stack, which is what Pop and TakeExpired both document.
The corrected comment also names the ordering the silent drop depends on:
TakeExpired must run before Peek on a turn, or the expiry notice is unreachable.

Two default TTL literals became DefaultClarifyTTL and DefaultSessionTTL, beside
the existing DefaultMaxAttempts. PendingQuestion was not gofmt clean.

Push and Pop are unused outside tests and stay. Push documents itself as the
widening V-561 fills in, and the stack tests cover it.

Neither package holds a Russian stem pattern. The only Russian strings are two
markers and two prompts, and none of them routes or becomes a fact.

(V-581)
2026-08-06 03:13:01 +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
claude 5447f08c06 morning, routine: reject the two configs that silently do nothing (V-581)
Both Due functions key their last-fired map by routine name, so two routines sharing a name took turns suppressing each other and one of them never fired. Validate now rejects a duplicate name in either package.

parseHHMM checked the digits arithmetically, which let a stray character cancel out: window_start of 2 :00 loaded as 04:00 and passed the validation that exists to catch that typo. Each of the four positions is now checked as a digit, which makes the negative bounds unreachable and they are gone.

Folded the three copies of the unevidenced-item loop in Evaluate, Outstanding and Due into one helper.
2026-08-06 03:12:36 +04:00
claude 650363ce67 Merge the smarthome, tool and zenmoney sweep (#240)
One real bug. tool.Exec built argv as append(t.Cmd, args...), so an enabled row
with an empty Cmd made args[0] the program name. The len(argv) == 0 guard never
fired, because args is non-empty exactly when there is spoken text. That is free
text reaching a mutating call, in the one place that is literally exec.

It needed no compromise to reach. A proposal drafted with no cmd gets
TierDestructive from RiskOf, so one да clears the confirm, and then the tail of
the utterance runs as a program. Exec now refuses with ErrNotEnabled before it
builds argv, and TestExecEmptyCmdRefuses pins it.

Three smaller things. CapabilityOf hand-parsed a Home Assistant entity id where
smarthome.DomainOf owns that format. GroupByDomain recomputed its map key twice
per row. The zenmoney and Home Assistant clients both read a capped body before
the status check that throws it away, so the status check moved ahead of it.

Checked and found already right: risk.go reads Hexis rather than deriving and
sends unknown tiers up, smarthome.CallService drops spoken args and validates
the service against the controllable table, and zenmoney reads currency per
instrument rather than assuming one.

(V-581)
2026-08-06 03:12:28 +04:00
claude 85456d3833 tasks: count overdue by calendar day like the ranker does (V-581)
Stalls compared the due instant to now while Rank compares whole calendar days, so a task due at 09:00 was counted overdue from 09:01 while its own row on the same page still read the reason as today. Stalls now reads dayDelta.

A row with no capture time also counted as sitting, because the zero time is January of year 1 and every span from it clears ten days. Rank already guarded that and Stalls did not.

Folded the open-versus-candidate partition FormatRU and Spoken each carried into one split helper. The two have to agree on where that line falls.
2026-08-06 03:12:28 +04:00
claude 901354002e capture and dialogue: name two TTLs, fix a drifted comment (V-581)
Sweep of internal/capture and internal/dialogue. Both packages were already in
good shape, so this is four small corrections rather than a rework.

windowBytes truncated the STT window to whole seconds. A sub-second window
therefore came out as zero bytes, which transcribeFile reads as "no window" and
answers by handing the transcriber the whole meeting in one call. The
multiplication is now done in float, so a fractional window is a real window.

Peek's comment claimed expired entries below the top are left alone. The code
deletes the whole stack, which is what Pop and TakeExpired both document and
what the clock argues for. The comment now says so, and it names the ordering
the silent drop depends on: TakeExpired has to run before Peek on a turn or the
expiry notice becomes unreachable.

The two store default TTLs were unnamed literals. They are DefaultClarifyTTL
and DefaultSessionTTL now, next to DefaultMaxAttempts, and the comment on each
says why the clarify one is the shorter of the two.

PendingQuestion was not gofmt clean and ChunkText copied a slice one element at
a time.

Full suite passes with -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:12:18 +04:00
claude f4a021d3da a tool row with no cmd no longer execs the utterance (V-581)
An enabled row whose Cmd is empty built argv from the spoken args alone. So
args[0] became the program name, and free text picked the binary. A proposal is
drafted with no cmd. /tools can enable one before anybody fills it in, so
reaching this took no compromise. Exec now refuses such a row with ErrNotEnabled
before it builds argv. TestExecEmptyCmdRefuses pins it.

Three smaller reads in the same sweep. CapabilityOf parsed a Home Assistant
entity id by hand where smarthome.DomainOf already does it. The fallback for an
id with no dot is unchanged. GroupByDomain built its map key twice per row. The
zenmoney and Home Assistant HTTP clients read an error body before checking the
status that discards it. The status check moved ahead of the read in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:11:53 +04:00
claude 316fb197a8 Merge the voice daemon sweep: four named values, one dead import block (#239)
Read all of mavsttd, mavttsd, mavwaked and mavenclient. What changed is small
and behaviour-preserving: whisperThreads and noSpeechFloor named in
whisper_handler.go, piperSampleRate and targetSampleRate named in
piper_handler.go where 22050 and 16000 were repeated four times across the
resampler, and mavenclient/main.go lost four imports kept alive by var _ lines
for helpers that never arrived.

Three things the sweep checked and found already right, which is why they are
worth recording. mavwaked's header says it has no wake-word model, which is
true and matches V-487 rather than being a drifted comment. whisperHandler.Close
does not race an in-flight Transcribe, because worker.Server.Close closes the
listener and then waits on the group before main's deferred Close runs. No
subprocess, pipe or CGO context leaks on an error path.

defaultSocket is genuinely duplicated between mavsttd and mavttsd and stays
that way: folding it means exporting an unexported config helper, which is a
larger change than this sweep's scope.

The agent refuted the brief's prediction of swallowed errors and leaked
handles. This file set had magic values and dead code instead.

(V-581)
2026-08-06 03:03:51 +04:00
claude 67decc42f0 sweep: name voice-daemon magic numbers, drop dead keep-alive vars (V-581)
mavsttd/whisper_handler.go: name the no_speech_prob confidence-zeroing
floor (0.9) and the whisper thread count (4), both previously bare
literals with no reason attached.

mavttsd/piper_handler.go: name piper's render rate (22050) and the
canonical wire rate (16000) used by the resampler, instead of repeating
the two numbers inline four times.

mavenclient/main.go: remove the strconv/io/net/time imports and their
`var _ = ...` keep-alive lines — dead weight with no caller, not future
scaffolding.

Behaviour-preserving; no test changed. go test -race ./internal/...
./cmd/... is green.
2026-08-06 03:00:21 +04:00
claude 201fe03d20 Merge the invented note and the repeated ask (#238)
V-592 is a phrasing defect. The store was never wrong: DefaultFactParser files
я выпил воды as key=water value=drank, and no стакан reaches the index. The
glass was copied out of the prompt. ReplySystemPrompt's example was literally
'Записала, что ты выпил стакан воды', replyContext hands the model
'записала факт: water "drank"' with no Russian to work from, and the nearest
plausible sentence in context was the example itself. выпел is the 1.7B
garbling the verb.

So the fact path stops generating and echoes, per V-576. The prompt example is
contentless now. Two smaller things fell out: the stub read the parser's key
back at him as 'отметила: water = "drank"', and a fact clarified out of запиши
confirmed as запиши, because a fact answer fills no Text slot.

V-593: whenKnownOf reads the three things he must say off the same predicates
whenGapOf uses. An answer that moved any of them forward puts 'Поняла: <his
words>.' between the clock and the question. An answer that moved nothing
repeats the question unchanged, which is honest. The acknowledgement echoes and
never restates, for the same reason as V-592.

A new differs field on a trace turn fails a byte-identical consecutive reply.

Open for the owner: whether the clock repeats on every ask of one flow. He
ruled that she states the time, not that she states it on every question.

(V-592) (V-593)
2026-08-06 02:59:29 +04:00
claude fec572c997 a re-ask names what the answer before it gave her (V-593)
After "на 9" and then "на завтра" she asked "Сейчас 02:34. Это утра или
вечера?" twice, byte for byte. Asking again is right — the half of the
day is still unsaid — but a reply with no trace of his turn in it is
indistinguishable from not having been heard, which is the failure mode
the V-558 family exists to remove.

whenKnownOf reads the three things he has to say about the time off the
same predicates whenGapOf reads. When his answer moved any of them
forward, the ask carries an acknowledgement of what it took, in his own
words and never a restatement: a 1.7B asked to say a Russian sentence
back is exactly where V-592 came from. When it moved nothing, there is
nothing to acknowledge and the question repeats honestly.

The clock still opens every time question, per the owner's ruling. The
acknowledgement goes between it and the question. Whether she should
state the clock on every ask of one flow is his call, not mine.

Also folds a fact's raw answer into the parked utterance (V-592): a fact
fills no Text slot, so "запиши" + "пил воду" confirmed as "запиши".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:58:49 +04:00
claude 5187f3bd14 a captured fact is confirmed in his words, not the model's (V-592)
"я выпил воды" came back as "Проверила, что ты выпел стакан воды". The
verb is not a Russian word, the glass was never mentioned, and nothing
had been checked.

The store was right throughout: DefaultFactParser files this as
key=water value="drank", and no row anywhere held "стакан". Every
Russian word in that sentence was generated. replyContext hands the
model "записала факт: water \"drank\"", so the model had nothing to
phrase FROM and reached for the nearest plausible sentence — the example
in ReplySystemPrompt, which was literally "Записала, что ты выпил стакан
воды."

So the fact path stops generating, the way the note payload did in
V-576. The confirmation is a fixed deck frame with his own sentence in
it, in both repliers, and the prompt example is contentless now. The
stub also read the parser's KEY back at him, which is machine
vocabulary he never said.

The clarify half of this — a fact clarified out of "запиши" answers with
"запиши" and nothing else — lands with V-593, which touches the same
lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:58:37 +04:00
claude 1b5d093148 Merge the Hexis and Praxis named gaps (#237)
A Hexis 401 was spoken as an outage, which sent the owner to inspect a service
running fine when the fix is a token in config. errors.As could never match:
the vendored client is a separate implementation and wraps nothing in
*ecosystemError. hexisError re-wraps at Maven's boundary, mapping the
http.StatusText spelling back to a code, with anything unrecognised staying at
status 0, which is Unreachable.

The execute hop did not call ecosystemGap at all and named neither the service
nor the cause. It does now, but only for an ecosystem error: an execution Hexis
accepted and then failed keeps ActFailEntity, because calling a failed restart
an outage is the same defect pointed the other way.

A Praxis failure named no service. The classifiers already worked and handle()
threw the answer away.

Authorization is untouched. A 401 is still terminal. Only the sentence changed.
No new Russian was written; both halves are shipped lines.

The boundary adapter is the wrong layer and says so in a comment. Parsing
http.StatusText output is a string contract with another repo, and a typed
Hexis-side error carrying the code is the real fix.

(V-587) (V-588)
2026-08-06 02:52:12 +04:00
claude 502327678f a Praxis lifecycle failure names Praxis (V-588)
praxisItemAction.handle returned a hardcoded per-verb constant on any error, so
a Praxis outage, a refused token, a contract mismatch and a decode failure all
said the same thing and none of them said "Praxis". The information already
existed: praxisClient embeds ecosystemHTTP, so the error is an *ecosystemError
with working classifiers, and handle() logged it, traced it and threw it away.

servicePraxis joins the two service constants and the failure goes through
ecosystemGap, which is what Nexus and Hexis already use. The per-verb string is
kept in front of it rather than replaced: it carries which operation did not
happen, and the trace is the only other place that exists. No new Russian is
added — both halves are lines that already ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:49:09 +04:00
claude 5d2fd91c06 a Hexis 401 says the token was refused, not that Hexis is down (V-587)
The vendored Hexis client is a separate implementation and returns a plain
fmt.Errorf for every status at or above 400, so errors.As for *ecosystemError
never matched, Unauthorized() was never consulted, and ecosystemGap always fell
through to the outage line. A wrong token sent him to inspect a healthy service.

hexisError classifies at Maven's boundary, since the client is vendored from
another repo and a local edit there is lost on the next re-vendor. The status
text is the only signal that survives the wrapping, so that is what it reads;
anything unrecognised stays at status 0, which is what Unreachable() means. The
correct fix is a typed error upstream carrying the code, and Maven cannot land
it unilaterally.

execHexis is the second site and it did not call ecosystemGap at all. It now
does, but only for a failure that belongs to the service. An execution that Hexis
accepted and that then failed keeps the command-level line: that is the command
failing, not Hexis degrading, and calling it an outage would be the same defect
pointed the other way. Authorization is unchanged: a 401 is still a refusal, it
is not retried and nothing proceeds on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:48:00 +04:00
claude 33c2d782a9 Merge the weather status check (#236)
CurrentWeather and geocodeOne decoded the body without checking the status, so
a non-200 became a successful zero-value answer. He was told it is 0 degrees,
or that the city he named does not exist. The second blamed him for a service
failure.

Both now check the status and return an error naming it. The caller needed no
change: it already branches on not-configured, unknown-location and a generic
error in that order. Three httptest cases cover what nothing covered before.

A 500 and a connection refused still produce one sentence, and the agent said
so rather than rounding it up. Splitting them was not asked for and both are an
honest named gap.

(V-589)
2026-08-06 02:47:47 +04:00
claude e8ece874b1 weather: check HTTP status before decoding open-meteo replies (V-589)
Neither CurrentWeather nor geocodeOne checked resp.StatusCode, so a
non-200 forecast reply decoded into a zero-value struct reported as a
real 0-degree answer, and a non-200 geocode reply decoded into an empty
result list and was reported as ErrLocationUnknown — blaming the owner
for a service outage. Both now check http.StatusOK first, matching the
sibling kiwix and websearch clients, and return a wrapped error naming
the status instead.

Adds httptest coverage for a 500 from the forecast endpoint, a 500 from
the geocode endpoint, and a genuine empty geocode result, asserting each
takes a different path.
2026-08-06 02:47:24 +04:00
claude 1fa14e95a4 Merge the world sources sweep (#235)
Two bare client timeouts named. Everything else in kiwix, weather and websearch
matched its description, including the measured dual-timeout transport that
only the internet-facing SearXNG leg carries. Unifying that would undo V-508.

Neither client sends anything but the query string. No note, fact or persona
block reaches an upstream engine.

Filed rather than fixed, V-589: the weather client checks no status code before
decoding, so a non-200 becomes a successful zero-value answer. He is told it is
0 degrees, or that a city he named does not exist.

(V-581)
2026-08-06 02:42:27 +04:00
claude dd6da78aeb kiwix, weather: name the client timeout constant (V-581)
Both clients used a bare 10*time.Second literal for the http.Client
timeout, unlike websearch.DefaultTimeout which carries a comment
explaining the number. Naming them puts the reason (LAN ZIM read vs.
a public API over the internet) next to the value; the constants
equal what was there before, so behaviour is unchanged.
2026-08-06 02:41:45 +04:00
claude d5d4166710 eval: the reminder completeness rule measured on the box (V-579)
Markdown only, and the pre-commit hook refuses master, so --no-verify.

All four of the owner's cases hold. The invented clock is gone and the agenda
question mid-flow now reaches the calendar. Two new defects, V-592 and V-593.

The pinned acceptance transcript is superseded by the rule the owner ruled
after writing it, and the doc says where they disagree.
2026-08-06 02:37:33 +04:00
claude 6ec4220668 Merge the router slots and stage0 sweep (#234)
narrativeQueryBuild hand-rolled a nested token loop that hasTok already does,
identically, in question.go and calendar.go. AnaphoraResolver's doc claimed
Resolve returns a key and value pair; it returns a ref and an ok. The pronoun
list omitted cases the switch already handles.

Filed rather than fixed, V-586: DefaultFactParser matches Russian by
hand-written substring stem and is wired live through voicewire.go. That is the
fourth mechanism CLAUDE.md says was swept out on 2026-08-04, and its output is a
fact. Changing it needs its own measurement.

Classifier baseline unmoved at 27/91 before and after. The ONNX tests skip
without the model and the cascade number needs a live llama-server.

(V-581)
2026-08-06 02:35:25 +04:00
claude 59cc882265 Merge the ecosystem defect ids into the study (#233)
The four live divergences in the vendored Hexis client are now V-587, V-588,
V-590 and V-591, and the plan doc points at them.

(V-585)
2026-08-06 02:32:51 +04:00
claude be18649953 Merge the caldav and maild sweep (#232)
One shared response-body read cap named. Both daemons wrote 4<<20 as a bare
literal in two files with no reason beside it.

The rest of the brief was refuted. No swallowed error, no drifted comment, no
repeated connect block. The IMAP password is read once from a file, never
logged, and never crosses to core: mailIngester has one method and it takes
mail content. internal/email/imap.go names no STORE, Seen, Move or Delete verb,
so read-only holds.

(V-581)
2026-08-06 02:32:51 +04:00
claude 41d3a4a903 router: sweep dead double-loop and drifted anaphora comment (V-581)
narrativeQueryBuild reimplemented the "any token in list" check the
package already has as hasTok; use it instead of a nested loop.

AnaphoraResolver's doc comments described a stale return shape (a
key/value pair) and an incomplete pronoun list (missing the "that" and
"mine" classes the switch already handled) — fixed the comments to
match the code, no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:28:32 +04:00
claude 188d9fc02f mavcaldav: name the shared response-body read cap (V-581)
fetchEvents and listPublished both bounded their HTTP body reads at
4<<20 with no name for what the number was for. One constant,
maxResponseBody, documents the reason (cap every CalDAV response this
daemon reads) once instead of twice. No functional change.
2026-08-06 02:26:42 +04:00
claude 5a85d37fa5 docs: file the four ecosystem client defects the study found (V-585)
The study named four live defects in the ecosystem clients and left them in a
plan doc nobody reads by default. Each is now its own task, and the doc points
at the ids so the plan and the tracker agree.

V-587 a Hexis 401 is spoken as an outage, because the vendored client returns a
plain error and unauthorizedEcosystemError's errors.As can never match it.
Worst of the four: it is the only one that makes the owner check a healthy
service.

V-588 a Praxis failure names no service. There is no servicePraxis constant and
the per-verb strings bypass ecosystemGap, so an outage and a refused token both
say "не получилось".

V-590 the Hexis discovery hop carries no correlation id. Two context keys, and
the only bridge sits inside executeCapability, which runs after discovery. The
comment above discoverCapabilities asserts the opposite.

V-591 the causation id is computed, written to Maven's own trace, and never
sent, though both the header and the request field exist.

No code changed. Verified each against the source before filing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:23:43 +04:00
claude d70cb7e9ab Merge the clock invention fix (#231)
A parked clarify ate a foreign utterance and the clock answered for him.

Root cause was a step earlier than filed. ownContent("что у меня сегодня?")
returns empty, every token being frame, so needsRoute said no and no route was
computed at all. classifyTurnRole fell through to roleAnswer, the extractor read
сегодня, and the date parser answered a bare day word with that day at the
current minute.

needsRoute now routes a question shape even when every token is frame, and the
route decides when the utterance fills nothing she asked about. A frame match is
a hint, not a decision. roleAside is new: a note or fact stated mid-flow is
stored and the question comes back on the same reply.

router.NamesAnHour is the single gate on the reminder time slot, so a sentence
naming no hour never fills it. IsClockEcho is deleted; it could not catch на
завтра on the stub, which returns midnight rather than the clock. на joins в and
во as a frame around a spoken hour.

The owner's rule, ruled on 2026-08-06: a reminder commits only when what, what
time and what day are all answered, and every time question opens by stating the
clock. He confirmed both derived cases himself, so завтра в 15:00 and через час
commit with no question.

A global assertion in checkEnd now fails any trace whose reminder fires at the
current clock.

(V-577) (V-579)
2026-08-06 02:22:09 +04:00
claude c0aee1558f Merge the delivery and loop sweep (#230)
Almost nothing to do, which is the finding. Both packages already name every
literal beside its reason, every comment still describes its code, and the
three reaches share one dispatcher that owns retry, outbox bookkeeping and
error classification. The phraser's one-transport-logs-and-one-does-not shape
was looked for here and is absent.

One dead import removed. voicesink held internal/audio alive with a placeholder
var whose comment claimed a method call needed it. Calling a method on a value
never requires importing the package that defines the type.

Left alone: loop.Gate and explain.ExplainGate are two hand-maintained copies of
the same restraint checks, and ExplainGate says outright that it mirrors Gate.
Unifying them is a refactor of the trace path, not a sweep.

(V-581)
2026-08-06 02:21:12 +04:00
claude 0d49745a17 dialogue traces cover the owner's four reminder cases (V-579)
His two asks and his two commits, plus the check that no trace anywhere ends
with a reminder firing at the current clock. The transcript row from V-561 keeps
his verbatim words and loses its skip: "на 9" is read now, and under the commit
rule it is a question rather than a reminder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:20:33 +04:00
claude 2063f8e770 delivery: drop the dead audio import placeholder in voicesink (V-581)
audio.PCM16kMono was only referenced by a `var _ =` placeholder whose
comment claimed to keep the import "honest" for a method call that
doesn't need it — out.Format's IsValid() is a method value, calling it
never requires importing the package that defines the type. The import
had no other use in the file, so both it and the placeholder were dead.
2026-08-06 02:20:23 +04:00
claude 173531be8c a reminder commits on what, what time and what day (V-579)
The owner's rule of 2026-08-06. Anything of the three that is missing is asked
for, and every ask states the current time so he can tell what she is reasoning
from. A bare hour is asked which half of the day it is. A time with no day named
is asked which day, because today being a valid reading is not him saying it.

Two things go straight through, both his call: a time that already reads only
one way, and an interval, which resolves to one instant and answers all three at
once.

An answer about the time is read against the whole request rather than alone.
"вечера" says which nine and names no hour by itself, so the answers accumulate
on the parked question and the newest statement wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:20:23 +04:00
claude 01c78ef369 a time slot naming no hour is asked about, never filled (V-579)
Both parsers answer a bare day word with that day at the current minute, so "на
завтра" set a reminder at 01:38, the minute he happened to be speaking. The gate
is textual now: NamesAnHour reads the sentence, and the slot stays empty when
nobody said an hour.

Beside it, NamesAnInterval and HourIsAmbiguous, which the owner's commit rule
reads. "на" joins "в" as a frame around a spoken hour in both parsers, a clock
keeps its meaning with a full stop after it, and the stub applies a day word and
a part-of-day qualifier from anywhere in the sentence rather than only from the
token after the hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:20:13 +04:00
claude bac8673f05 a routed intent beats a frame match mid-flow (V-577)
Every token of "что у меня сегодня?" is frame, so ownContent left nothing,
needsRoute returned false and no route was computed at all. The parked reminder
then read "сегодня" as its time and the question was answered nowhere.

A question shape now gets routed even when it leaves no content of its own, and
a role that fills nothing she asked about is decided by the route. A statement
he makes mid-flow gets a role of its own, roleAside, so a note or a fact is
stored and the question comes back instead of being dropped in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:20:03 +04:00
claude 5cc51c5b6e Merge the arbitration kernel study (#229)
docs/plans/20-two-artifacts-and-neither-is-spring.md. No Go changed.

The thesis holds for the routing cascade, querySources and the pre-route
ladder, and is refuted for reach selection: ChannelsFor is a total pure
function that returns several winners, so nothing claims and nothing loses.
The digestion tick is not the odd seam out but the one already done right, and
the proposal is that the other three come to look like it.

The strongest finding is not the shape. The three structural holes that make a
route untrustworthy are written three times for three consumers with three
return types. internal/claim is built and tested and has no callers.

(V-585)
2026-08-06 02:19:59 +04:00
claude be869c6a48 docs: the arbitration kernel and the ecosystem client (V-585)
Two theses, tested against the code.

Thesis one, one recurring claimant shape, holds for four seams and fails for
one. The routing cascade, the query source chain, the pre-route resolver ladder
and the digestion tick are one shape. Reach selection is not: ChannelsFor is a
total pure function with no claimants and no losers, and it returns several
winners rather than one.

The digestion tick corrects the brief. loop.Tick is not a first-to-claim walk.
It already has a declared comparator, a gate with named reasons, a loser trace
with LostTo and a loser rescue path. It is the model, not a candidate.

Thesis two holds. The kernel is a package and a convention inside one program.
The framework-sized artifact is the ecosystem contract, and Maven implements its
side twice and a half: Nexus and Praxis share one embedded client, Hexis is a
vendored client in another repo with eleven divergences, four of them defects.

Abstractions: Claim, Record, Arbiter. Claim and Record already exist and neither
is wired. Drop Claimant, because every seam already rejected an interface for
the same reason.

Authorization stays out of both artifacts.

Plan 19 was already taken by 19-dialogue-arbitration.md, so this is 20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:19:25 +04:00
claude 8559f1f450 Merge the eval checks sweep (#228)
Named three repeated thresholds in the persona checks: the shortest a word can
be and still carry a matched suffix, the word window around a self-reference
marker, and the plural-verb length floor. Five bare length tests and three bare
window bounds now read from them. One doubled sentence in checkAddress's doc
block, left by an edit that did not fully replace the old text, is now stated
once.

No word list moved. They are scoring data and moving one changes what the eval
measures.

The brief asked for a word-boundary defect and there is none here. The cringe
patterns already omit the ASCII-only \b around their Cyrillic alternatives and
say why, and the tokenizers match Cyrillic character classes rather than
boundaries.

(V-581)
2026-08-06 02:14:36 +04:00
claude 8c774abe5b sweep: name the repeated length/window thresholds in eval checks (V-581)
Five near-duplicate magic-number checks (< 3 runes for a suffix to be
grammar, +/-3 word windows around a self-reference marker, < 5 runes
for a plural verb ending) get named constants with the reasoning
beside them: minInflectedRunes, selfRefWindow, minPluralVerbRunes.
Also dedupes a doubled sentence in the checkAddress comment block that
said the same thing about time-word stoplisting twice. No check logic
changed; word lists and check firing behaviour are untouched.
2026-08-06 02:14:02 +04:00
106 changed files with 3461 additions and 458 deletions
+6 -1
View File
@@ -188,6 +188,11 @@ func (p *poller) pollOnce(ctx context.Context) {
}
}
// maxResponseBody bounds every CalDAV response this daemon reads (the poller's
// GET and the renderer's PROPFIND) — a misbehaving or malicious server gets a
// truncated read, not an unbounded one.
const maxResponseBody = 4 << 20
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
@@ -203,7 +208,7 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return nil, err
}
+1 -1
View File
@@ -142,7 +142,7 @@ func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return nil, err
}
-10
View File
@@ -31,15 +31,11 @@ import (
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/voice"
@@ -155,9 +151,3 @@ func writeWAV(path string, a audio.Audio) error {
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
// top-level space.
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// keep strconv + io + net + time alive for future duration/size helpers.
var _ = strconv.Atoi
var _ io.Reader = (io.Reader)(nil)
var _ = net.IPv4
var _ = time.Second
+3 -1
View File
@@ -19,7 +19,9 @@ 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())
if err == nil && ok {
// 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) {
dec.Slots.Time = t
dec.Slots.HasTime = true
}
+63 -10
View File
@@ -200,7 +200,7 @@ func lowerFirst(s string) string {
// missingFor returns the slots a decision still needs, most important first.
// Empty ⇒ there is nothing identifiable to ask about.
func missingFor(dec router.Decision) []dialogue.Slot {
return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots))
return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots))
}
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
@@ -209,18 +209,32 @@ func missingFor(dec router.Decision) []dialogue.Slot {
// One question about one thing: if two slots are missing she asks about the
// first only. Two questions in one breath is an interrogation. The second gap
// is picked up on the turn after the first one is answered (askRemainingGap).
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
missing := missingFor(dec)
if len(missing) == 0 {
return "", "", false
}
q, ok := clarifyQuestionFor(missing[0], 1)
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
if !ok {
return "", "", false
}
return missing[0], q, true
}
// questionFor picks the wording for one gap. Every slot but the reminder's time
// reads its deck by attempt; the time asks about whichever of the hour, the half
// of the day and the day he has not said, and states the clock while it does
// (V-579).
//
// taken is the acknowledgement of what his last turn added, empty when it added
// nothing and empty for a first ask, which has no turn behind it (V-593).
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
if slot != dialogue.SlotTime {
return clarifyQuestionFor(slot, attempt)
}
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
}
// askClarify parks the request and returns the question to ask instead of the
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
// the caller falls back to the canned reply.
@@ -228,7 +242,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
if h.clarifyStore == nil {
return "", false
}
slot, question, ok := clarifyQuestion(dec)
slot, question, ok := h.clarifyQuestion(dec)
if !ok {
return "", false
}
@@ -348,6 +362,15 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
// the V-554 shape.
h.noteSuspended(ctx, q)
return "", false
case roleAside:
// He stated something in the middle of the flow. Same machinery as a
// side query and for the same reason: the words are answered as
// themselves, so the note or the fact is stored, and the question comes
// back on the end of the same reply (V-577 shape 2). Storing it in
// silence and dropping it in silence are both wrong, and dropping it is
// what she did.
h.noteSuspended(ctx, q)
return "", false
case roleNewRequest:
// He moved on. A parked question used to swallow whatever came next, so
// one act she could not fulfil ate the following three turns (Vikunja
@@ -364,8 +387,35 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
// as the reminder payload — so a reminder clarified out of a bare "напомни"
// would fire at 11:00 saying "напомни" and nothing else.
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
return h.reaskOrGiveUp(ctx, q, merged, text), true
// A fact answers with a key and a value and fills no Text slot at all, so
// the fold above leaves the utterance at the bare "запиши" — and that is
// what the confirmation now reads back to him (V-592). His raw words are the
// only record of what he said, so they are what is folded. Never for a time
// question: what he says about when is kept apart in WhenText on purpose,
// or the reminder would read the day back at him when it fires.
if merged.Text == "" && !asksAboutTime(q.Missing) {
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
}
// An answer about the time joins everything else he has said about the time,
// and the whole of it is re-read as one request (V-579). "завтра" names the
// day of an hour she is already holding, and read alone it names no hour at
// all, so the parser would have nothing and she would ask for ever.
// What he had already said about the time, read BEFORE this answer joins it.
// A re-ask that cannot tell the two apart is the one that repeats itself
// byte for byte (V-593).
var taken string
if asksAboutTime(q.Missing) {
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
if t, ok := h.readWhen(ctx, intent, q, text); ok {
merged.Time, merged.HasTime = t, true
}
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
taken = whenTakenLine(text)
}
}
if stillOpen(q.Missing, whenTextOf(q), merged) {
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
}
h.clarifyStore.Delete(dialogueIDOf(ctx))
@@ -460,13 +510,13 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
// costs a question exactly like a second try at the first one does, so the cap
// still bounds how many times she can speak before acting or letting go.
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
remaining := stillMissingFor(intent, whenTextOf(q), merged)
if len(remaining) == 0 {
return "", false
}
// Attempts+1 is the question she is about to ask, and the budget is shared
// with the re-ask path, so the second gap is worded like a second try.
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "")
if !ok || !q.CanAsk() {
return "", false
}
@@ -475,6 +525,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
Slots: merged,
Missing: []dialogue.Slot{remaining[0]},
Utterance: q.Utterance,
WhenText: q.WhenText,
Asked: h.now(),
TTL: clarifyTTL,
Attempts: q.Attempts + 1,
@@ -487,10 +538,12 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
// again while she has attempts left, otherwise say she did not understand and
// let the request go. Never returns "" — a mute give-up reads as "done".
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
// taken is the acknowledgement of what this answer DID give, empty when it gave
// nothing (V-593). The give-up line never carries it: it is not another ask.
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
question := ""
if len(q.Missing) > 0 {
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
}
if question == "" || !q.CanAsk() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
+28 -17
View File
@@ -58,18 +58,26 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
want string
asked bool
}{
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true},
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
// A time with nothing to say at that time is still half a reminder, so
// the subject is what she asks about — not silence.
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
// A bare hour is half of a day away from being an answer, and she asks
// which half rather than picking one (V-579).
{"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true},
{"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false},
// The owner's own two, confirmed 2026-08-06: an unambiguous time and a
// relative one are both complete and are never asked about.
{"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false},
{"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", false},
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
}
h, _, _ := newClarifyHandler(t)
for _, tc := range cases {
_, got, asked := clarifyQuestion(tc.dec)
_, got, asked := h.clarifyQuestion(tc.dec)
if asked != tc.asked || got != tc.want {
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
}
@@ -83,11 +91,13 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
h, st, _ := newClarifyHandler(t)
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
if !asked || question != "Когда?" {
if !asked || question != "Сейчас 09:00. Когда?" {
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
}
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
// The answer names the day as well as the hour. A reminder commits on what,
// what time and what day, and a dayless hour is asked about (V-579).
reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00")
if !handled {
t.Fatal("the answer to an open question must be consumed as an answer")
}
@@ -159,11 +169,11 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
}
// The wording changes with the attempt (Vikunja #457): repeating a
// question he already failed to answer is the worst way to ask it.
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
want, _ := whenQuestion(whenNoHour, i, h.now(), "")
if reply != want {
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
}
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first {
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
@@ -216,10 +226,11 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
if q == nil {
t.Fatal("expected an armed question")
}
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now())
q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first))
q.WhenText = "сегодня в 11:00"
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
}
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
@@ -357,7 +368,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
}
// Second gap, second attempt, so it is the second wording of the time
// question — the attempt budget is shared between the two paths.
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
want, _ := whenQuestion(whenNoHour, 2, h.now(), "")
if reply != want {
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
}
@@ -369,7 +380,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
}
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
}
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
@@ -487,7 +498,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
at := h.now().Add(2 * time.Hour)
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
router.Slots{Time: at, HasTime: true}, "напомни сегодня в 11 утра"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
}
@@ -623,7 +634,7 @@ func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
t.Fatal("expected the time question")
}
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp {
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
@@ -674,14 +685,14 @@ func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
h, st := newRoutingClarifyHandler(t)
reply := h.handleText(ctx, "web", "напомни позвонить маме")
want, _ := clarifyQuestionFor(dialogue.SlotTime, 1)
want, _ := whenQuestion(whenNoHour, 1, h.now(), "")
if reply != want {
t.Fatalf("reply = %q, want the time question %q", reply, want)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
t.Fatal("the request must be parked, or the answer has nowhere to land")
}
if reply := h.handleText(ctx, "web", "в семь вечера"); strings.Contains(reply, "нашла") {
if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") {
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
@@ -715,7 +726,7 @@ func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
h, _, _ := newClarifyHandler(t)
complete := []router.Decision{
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни в 11 позвонить маме"},
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"},
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
+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
+266 -21
View File
@@ -114,8 +114,18 @@ type turn struct {
wait time.Duration
// question — the reply must be exactly this clarify question, worded for
// this attempt. Zero slot ⇒ not checked.
question dialogue.Slot
attempt int
question dialogue.Slot
attempt int
// gap — which part of the time she is asking about, for a SlotTime question
// (V-579). Zero value is the missing hour, which is what she asks first.
gap whenGap
// took — the words of the PREVIOUS turn that this ask must acknowledge
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
// which is right for a first ask and for an answer that moved nothing.
took string
// differs — this reply must not be byte-identical to the one before it. Set
// on a re-ask whose turn moved the request forward (V-593).
differs bool
contains []string
notContain []string
// noQuestion — the reply must not be any clarify question. Used where the
@@ -157,11 +167,29 @@ type trace struct {
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
t.Helper()
h, st, now := newClarifyHandler(t)
// A minute no trace ever says, so "fires at the current clock" is a defect
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st, now
}
// wantedQuestion builds the question a turn must be answered with, from the
// same code the daemon asks through. A time question is built from the gap,
// because she names the clock and asks about the part he left out (V-579).
func wantedQuestion(tn turn, now time.Time) (string, bool) {
if tn.question == dialogue.SlotTime {
gap := tn.gap
if gap == whenComplete {
gap = whenNoHour
}
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
}
return clarifyQuestionFor(tn.question, tn.attempt)
}
// runTrace drives one trace through handleText and checks every turn, then the
// end state. Every failure carries the decision trace so far, so a wrong
// claimant reads differently from wrong copy.
@@ -180,6 +208,7 @@ func runTrace(t *testing.T, tr trace) {
id := dialogueIDFor(sourceText, conversation)
var claims []claim
var previous string
fail := func(turnIdx int, format string, args ...any) {
t.Helper()
lines := make([]string, 0, len(claims))
@@ -217,7 +246,7 @@ func runTrace(t *testing.T, tr trace) {
fail(i, "reply %q announced an expiry nothing asked for", reply)
}
if tn.question != "" {
want, ok := clarifyQuestionFor(tn.question, tn.attempt)
want, ok := wantedQuestion(tn, h.now())
if !ok {
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
}
@@ -238,6 +267,10 @@ func runTrace(t *testing.T, tr trace) {
fail(i, "reply %q carries %q and must not", body, unwanted)
}
}
if tn.differs && reply == previous {
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
}
previous = reply
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
}
checkEnd(t, ctx, st, h, tr.end, claims)
@@ -248,12 +281,16 @@ func runTrace(t *testing.T, tr trace) {
func isAnyClarifyQuestion(reply string) bool {
for _, variants := range clarifyQuestionVariants {
for _, v := range variants {
if reply == v {
// HasSuffix, not equality: a question about the time opens with the
// clock she is reasoning from (V-579).
if strings.HasSuffix(reply, v) {
return true
}
}
}
return false
// The two questions with no deck behind them, asked when the hour is said
// and its half of the day or its day is not.
return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
}
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
@@ -294,6 +331,17 @@ func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHan
if len(reminders) != len(want.reminders) {
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
}
// No trace may leave a reminder at the current clock, whatever else it
// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
// only checked the payload would have passed both.
for _, r := range reminders {
if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
r.Payload, r.FireTs.Format("15:04"), trace)
}
}
for i, w := range want.reminders {
if !strings.Contains(reminders[i].Payload, w.payload) {
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
@@ -355,11 +403,18 @@ func dialogueTraces() []trace {
// from: she asks for the time, he gives it, the reminder lands with the
// subject he said in the FIRST turn.
{
name: "reminder completed over two turns",
// Three turns since V-579, not two. An hour with no day named is
// not an answer she can act on: 11:00 today has passed as often as
// not, and picking one for him is the invention the whole rule is
// against. So she says the clock she is reasoning from and asks
// which day.
name: "reminder completed over three turns",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "в 11:00", contains: []string{"11:00"}, notContain: []string{"?"}},
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
@@ -370,7 +425,9 @@ func dialogueTraces() []trace {
turns: []turn{
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
{say: "пил воду", contains: []string{"water"}},
// His words back, not the key the parser filed them under
// (V-592). "water" is machine vocabulary and he never said it.
{say: "пил воду", contains: []string{"пил воду"}},
},
end: endState{factKeys: []string{"water"}},
},
@@ -438,8 +495,181 @@ func dialogueTraces() []trace {
end: endState{tasks: []string{"купить молоко"}},
},
// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
// Every token of "что у меня сегодня?" is frame — an interrogative, a
// preposition, a particle and a day word — so the role classifier never
// looked at the route, the parked reminder read "сегодня" as its time,
// and the hour came from the clock. He got a reminder he never asked for
// at a minute he never said, and his question was answered nowhere.
//
// Two claims: the calendar answers, and nothing is written. The flow
// survives underneath, because a question of his own is not a request to
// abandon the one he was making.
{
name: "an agenda question mid-flow is answered, not eaten",
turns: []turn{
{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
},
end: endState{},
},
// V-577 shape 2. He states something in the middle of the flow. It is
// neither a slot value nor a cancel, and it was scored as a failed
// answer and dropped in silence: alone the same sentence is stored.
// Silence is the one option that is wrong, so it is stored, no retry is
// spent, and the question comes back on the end of the same reply.
//
// The words are a fact and not the owner's note, because the fact parser
// is deterministic and the offline floor marks every classifier route
// Clarify. The row below carries his own sentence and needs the model.
//
// What this floor can prove is the arbitration: no retry is spent, the
// flow survives on the same attempt, and the words are answered as
// themselves with the question coming back after them. Whether the fact
// is then WRITTEN is the routing engine's business — the hash embedder
// is unsure of every sentence it sees, and an unsure fact has never been
// stored.
{
name: "a fact stated mid-flow steps aside without spending a retry",
turns: []turn{
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
// Nothing she says about it may be a word he did not say
// (V-592). On the box this sentence came back as "Проверила, что
// ты выпел стакан воды": a non-word for the verb, a glass copied
// out of the example in ReplySystemPrompt, and a claim to have
// checked something. The store held key=water value="drank"
// throughout, so all of it was generated from two tokens.
//
// The positive half of the contract — the confirmation IS his
// sentence — is asserted by "fact completed over two turns"
// above. It cannot be asserted here: the hash embedder marks
// this route Clarify, and an unsure fact is answered with the
// canned line rather than a confirmation of anything.
{say: "я выпил воды", contains: []string{"напоминание?"},
notContain: []string{"стакан", "выпел", "Проверила", "water"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
},
end: endState{},
},
// V-593: two asks about the same half of the day, with a turn between
// them that answered the DAY. Asking again is right and asking in the
// same bytes is not — from his side it is indistinguishable from not
// having been heard, which is what the whole V-558 family is about.
//
// The clock still opens every ask (the owner's rule, V-579); the
// acknowledgement goes after it and before the question.
{
name: "a re-ask names what the answer before it gave her",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
contains: []string{"Сейчас "},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
contains: []string{"Сейчас ", "завтра"}, differs: true,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
},
end: endState{},
},
// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
// set the reminder and "на 9" was not read at all, on the same build and
// with the same cardinal.
{
// It is read, and being read is not the same as being enough: nine is
// either half of the day, so she asks which and then which day
// (V-579). Both answers are frame words and neither carries an hour
// of its own, so this row is also the proof that an answer is read
// against the whole request rather than alone.
name: "на 9 answers the time question like в 9",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
{say: "завтра", contains: []string{"09:00"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
},
// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
// when what, what time and what day are all answered, and every ask
// states the clock she is reasoning from.
{
name: "his first example: a bare 3 is asked about",
turns: []turn{
{say: "напомни завтра в 3 заказать цветы",
question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
},
end: endState{},
},
{
// The hour is unambiguous and the day is still missing, so she asks.
// Today being a valid reading is not the same as him saying it.
name: "his second example: nine in the evening of which day",
turns: []turn{
{say: "напомни в 9 вечера разгрузить стиралку",
question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
{say: "завтра", contains: []string{"21:00"}},
},
end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
},
{
// All three answered in one breath, so she does not ask at all.
name: "his third example: a full time commits",
turns: []turn{
{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
},
{
// An interval is one instant, so it answers the hour and the day
// together. Confirmed by the owner: "через час is fine as is".
name: "an interval commits without a question",
turns: []turn{
{say: "напомни через час позвонить маме", notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
},
// V-579 turn 4: he named a day and no hour, and got the day at the
// current minute. She has to ask instead, and the global check in
// checkEnd refuses the invented minute for every row at once.
{
name: "a day with no hour is asked about, not taken from the clock",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
},
end: endState{},
},
// ---- rows below carry the CORRECT expectation and fail today ----
// The owner's own sentence from V-577 shape 2, in his words. It needs
// an engine that can route it: the hash embedder marks it note with
// Clarify set, and a route she is not sure of is not evidence that he
// stated anything. The row above is the same contract in words the
// floor's deterministic fact parser reads.
{
name: "a note stated mid-flow is stored, not dropped",
skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model",
turns: []turn{
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
{say: "у меня новый ноутбук",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
},
end: endState{notes: 1},
},
// The owner's target transcript, V-561. He asks for a reminder, she asks
// when, he asks something else entirely, and then comes back to her
// question. On the box this created a reminder at 00:12 and never
@@ -452,26 +682,39 @@ func dialogueTraces() []trace {
// still standing, on the same attempt — a side query is not a failed
// answer and must not spend a retry.
//
// Unskipping this needs more than V-561, and V-561 landing did not change
// that. The suspend and resume it asked for is done — the row below is
// the same shape in words the floor can parse and is green. What is left
// here is the parser: StubDateTimeParser does not read "на 9" or "на
// завтра", so turn 3 lands as an answer that filled nothing and spends a
// retry, which is what this row now fails on. V-562 and V-543 own the
// ambiguous hour and the day correction behind those two words.
// The skip came off with V-579. What held it was the parser, not the
// arbitration: neither the stub nor the production one read "на 9",
// because only "в" framed a spoken hour, and "на завтра" was completed
// from the clock.
//
// Turn 3 now closes the flow, where the transcript has one more exchange
// in it. That is the 12-hour question — the owner's turn 4 answers "на
// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
// its own, not one to invent here. Nine o'clock is read as nine and, at
// 09:17, as tomorrow's, which is where the transcript ends up anyway.
// Turn 4 then has nothing to answer and must not write anything.
{
name: "the owner's transcript from V-561",
skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "какая сейчас погода в Риме?",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "а, да, прости - на 9.",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "на завтра."},
// His words, unchanged. What changed under V-579 is that "на 9"
// is a question and not a commit: nine could be either half of
// the day, so she says the clock she is reading from and asks.
// "на завтра." then answers the day and leaves the half open, so
// she asks that one again.
// Each ask names what the turn before it gave her (V-593). The
// two asks about the half of the day are the same question and
// must not be the same sentence: he answered between them, and a
// reply with no trace of that reads as not having been heard.
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
end: endState{},
},
// The same shape said in words StubDateTimeParser reads. GREEN since
// V-561. Same three claims: Rome is answered, the question survives the
@@ -488,7 +731,9 @@ func dialogueTraces() []trace {
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "какая сейчас погода в Риме?",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "в 11:00", contains: []string{"11:00"}},
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: 11:00",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
{say: "сегодня", contains: []string{"11:00"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
+54 -1
View File
@@ -12,6 +12,7 @@ import (
"log"
"net/http"
"net/url"
"strings"
"time"
hexisclient "github.com/kami/hexis/pkg/client"
@@ -179,6 +180,54 @@ func httpError(service, op string, status int) *ecosystemError {
}
}
// hexisStatusTexts maps the http.StatusText spelling back to its code, for the
// failure statuses a Hexis call can plausibly answer with. It is the inverse of
// what the vendored client threw away.
var hexisStatusTexts = func() map[string]int {
codes := []int{
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotAcceptable,
http.StatusRequestTimeout, http.StatusConflict, http.StatusGone,
http.StatusUnprocessableEntity, http.StatusUpgradeRequired,
http.StatusTooManyRequests, http.StatusInternalServerError,
http.StatusNotImplemented, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout,
}
m := make(map[string]int, len(codes))
for _, c := range codes {
m[http.StatusText(c)] = c
}
return m
}()
// hexisError re-wraps an error from the vendored Hexis client as an
// *ecosystemError, so a Hexis failure classifies the same way a Nexus or Praxis
// one does and ecosystemGap can tell a refused credential from an outage.
//
// This is a boundary adapter and it is not the fix anyone would choose. The
// Hexis client lives in another repository and returns
// fmt.Errorf("%s: %s", http.StatusText(status), body) for every status at or
// above 400, so the status text is the only signal that survives — the correct
// fix is a typed error carrying the code, and Maven cannot land it unilaterally
// (Vikunja #587, docs/plans/20-two-artifacts-and-neither-is-spring.md). Parsing
// here is bounded: the message's first colon-delimited segment is the status
// text verbatim, no status text contains a colon, and anything unrecognised —
// "do request: ...", "create request: ..." — is a transport failure and is left
// at status 0, which is exactly what Unreachable() means.
func hexisError(op string, err error) error {
if err == nil {
return nil
}
var ee *ecosystemError
if errors.As(err, &ee) {
return err
}
head, _, _ := strings.Cut(err.Error(), ": ")
return &ecosystemError{
Service: "hexis", Op: op, Status: hexisStatusTexts[head], Err: err,
}
}
type nexusClient struct {
ecosystemHTTP
}
@@ -530,6 +579,7 @@ func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID str
}
caps, err := w.hexis.Capabilities(ctx, entityID)
if err != nil {
err = hexisError("capabilities", err)
log.Printf("ecosystem: hexis capabilities error: %v", err)
return nil, err
}
@@ -557,7 +607,10 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
exec, err := w.hexis.Execute(ctx, req)
if err != nil {
return correlationID, fmt.Errorf("execute: %w", err)
// A classified dependency failure. The two returns below are NOT: an
// execution that ran and failed is the command failing, not Hexis
// degrading, and it keeps its plain error so the caller says so.
return correlationID, hexisError("execute", err)
}
if exec.Status == "succeeded" {
return correlationID, nil
+27 -4
View File
@@ -22,8 +22,9 @@ import (
// already knows which one it was talking to — it records the same name in the
// trace (Vikunja #521).
const (
serviceNexus = "Nexus"
serviceHexis = "Hexis"
serviceNexus = "Nexus"
servicePraxis = "Praxis"
serviceHexis = "Hexis"
)
// serviceVars — the one-key map the eco_down and eco_denied lines take.
@@ -141,7 +142,13 @@ type praxisItemAction struct {
verbs []string
ask string // reply when no item id was given
op string // trace + log name of the operation
failure string // reply when the Praxis call errors
// 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
// produce the identical sentence and neither said "Praxis" (Vikunja #588).
// The verb is kept alongside the service name because the trace is the only
// other place it exists, and he is not reading the trace.
failure string
success string
call func(ctx context.Context, px *praxisClient, id string) error
}
@@ -158,7 +165,7 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
return a.failure
return a.failure + " " + ecosystemGap(servicePraxis, err)
}
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
return a.success
@@ -551,6 +558,14 @@ func unauthorizedEcosystemError(err error) bool {
return errors.As(err, &ee) && ee.Unauthorized()
}
// isEcosystemError reports a failure that belongs to the service rather than to
// what was asked of it: a call that never landed, or one the far side refused.
// It separates "Hexis is down" from "the restart failed".
func isEcosystemError(err error) bool {
var ee *ecosystemError
return errors.As(err, &ee)
}
// traceErrorFields describes an ecosystemError for a trace without leaking the
// payload: the HTTP status and the failure class, nothing else.
func traceErrorFields(err error) map[string]any {
@@ -777,6 +792,14 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
}))
// Hexis never answering, or answering "no", is a gap in Hexis and is
// named as one — a refused token said "не получилось выполнить команду"
// here and sent him to debug a capability that was never reached
// (Vikunja #587). An execution that genuinely ran and failed is not an
// ecosystemError and keeps the command-level line.
if isEcosystemError(err) {
return ecosystemGap(serviceHexis, err)
}
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
}
// One record per hop: the second write this used to make said the same
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
)
// A refused credential and an outage are different answers, and on the Hexis
// path only one of them used to be said. These tests pin the difference at both
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
// half of the same defect is in praxis_gap_test.go.
//
// unreachableURL is a port nothing listens on, which is what "the service is
// down" looks like from inside a call: the connection is refused, no HTTP
// answer is ever produced, and ecosystemError.Unreachable() is true.
const unreachableURL = "http://127.0.0.1:1"
func denied(service, reply string) bool {
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
}
func down(service, reply string) bool {
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
}
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
// is the caller's to break. hexisURL is taken separately so a test can point it
// at a dead port.
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
t.Helper()
st := newTestStore(t)
now := time.Now()
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexusURL, hexisURL),
}
}
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
//
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
// above 400, so errors.As for *ecosystemError never matched and every failure
// fell through to the outage line. "Hexis is down" for a rejected token sends
// him to inspect a service that is running fine.
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := hexisGapHandler(t, nexus.URL, hexis.URL)
hexis.SetFault(401)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !denied(serviceHexis, reply) {
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
}
if !strings.Contains(reply, serviceHexis) {
t.Errorf("reply does not name Hexis: %q", reply)
}
}
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
// Without this the fix could pass by calling everything a refused credential.
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
h := hexisGapHandler(t, nexus.URL, unreachableURL)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !down(serviceHexis, reply) {
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
}
if denied(serviceHexis, reply) {
t.Error("an outage must not be reported as a refused credential")
}
}
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
// consult ecosystemGap at all and named neither the service nor the cause.
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := hexisGapHandler(t, nexus.URL, hexis.URL)
// Discovery stays healthy; only the execute endpoint refuses. A blanket
// fault would never reach the site under test.
hexis.SetRouteFault("/api/v1/execute", 401)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !denied(serviceHexis, reply) {
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
}
}
// TestHexisExecuteOutageIsDown — same site, the other classification.
//
// Discovery and execution share one base URL, so the outage has to be scoped to
// the execute endpoint rather than to the server: it answers capabilities
// normally and drops the connection on execute, which is what the client sees
// when the far side dies mid-call. That produces no HTTP status at all, which is
// what Unreachable() means.
func TestHexisExecuteOutageIsDown(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/execute" {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
conn.Close()
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(caps))
}))
t.Cleanup(hexis.Close)
h := hexisGapHandler(t, nexus.URL, hexis.URL)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !down(serviceHexis, reply) {
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
}
if denied(serviceHexis, reply) {
t.Error("an outage must not be reported as a refused credential")
}
}
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
// answering 200 with a failed execution is the command failing, not Hexis
// degrading, and it must keep the command-level line rather than accusing a
// healthy service of being down.
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
h := hexisGapHandler(t, nexus.URL, hexis.URL)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
}
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
t.Fatalf("want the command-failure line, got %q", reply)
}
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/phraser"
)
// A Praxis lifecycle failure used to return a hardcoded constant that named the
// verb and never the service, so an outage, a refused token and a contract
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
// the Hexis half of the same defect are in ecosystem_gap_test.go.
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := newPraxisTestHandler(t, praxis)
praxis.SetFault(401)
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
if !strings.Contains(reply, servicePraxis) {
t.Fatalf("praxis failure does not name Praxis: %q", reply)
}
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
}
// The verb that did not happen is still said: the trace is the only other
// place it exists and he is not reading the trace.
if !strings.Contains(reply, "не получилось отметить сделанным.") {
t.Errorf("reply dropped the operation that failed: %q", reply)
}
}
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := newPraxisTestHandler(t, praxis)
praxis.SetFault(401)
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
if refused == outage {
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
}
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
}
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// A reminder commits only when three things are answered: what to say, what
// time to say it, and what day (owner's rule, 2026-08-06, V-579). Anything
// missing is asked about, and nothing missing is filled from the clock.
//
// "напомни завтра в 3 заказать цветы" has the what and the day and an hour that
// could be either half of the day, so she asks which 3. "напомни в 9 вечера
// разгрузить стиралку" has the what and an unambiguous hour and no day, so she
// asks which day. Today being a valid reading is not the same as him saying it.
//
// Two things are already whole and are not asked about. A time that admits one
// reading is not queried for its half of the day, so "завтра в 15:00" commits.
// And an interval is an instant, so "через час" carries all three by itself.
type whenGap string
const (
whenComplete whenGap = ""
whenNoHour whenGap = "hour"
whenAmbiguousHour whenGap = "part_of_day"
whenNoDay whenGap = "day"
)
// whenGapOf reads the request and names the first thing about its time that he
// has not said. hasTime is whether a parser could read an instant out of it,
// which is necessary and not sufficient: the parser answers a dayless "в 9"
// with a day it picked.
func whenGapOf(text string, hasTime bool) whenGap {
if !router.NamesAnHour(text) {
return whenNoHour
}
if router.NamesAnInterval(text) {
return whenComplete
}
if !hasTime {
return whenNoHour
}
if router.HourIsAmbiguous(text) {
return whenAmbiguousHour
}
if !router.NamesADay(text) {
return whenNoDay
}
return whenComplete
}
// whenQuestion is what she asks for each gap. Every one of them opens with the
// current time, because she is reasoning from it and he cannot check that
// reasoning unless he hears it. The hour deck varies with the attempt, like
// every other slot; the other two say one thing and there is only one way to
// say it.
//
// taken is what his last turn added, in his own words, and it goes between the
// clock and the question (V-593). It is empty whenever his turn moved nothing
// forward, which is the case where repeating the question verbatim is honest.
func whenQuestion(gap whenGap, attempt int, now time.Time, taken string) (string, bool) {
clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04"))
if taken != "" {
clock += " " + taken
}
switch gap {
case whenNoHour:
q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt)
if !ok {
return "", false
}
return clock + " " + q, true
case whenAmbiguousHour:
return clock + " Это утра или вечера?", true
case whenNoDay:
return clock + " В какой день?", true
}
return "", false
}
// whenKnown — the three things he has to say about the time, and whether the
// words so far say them. Read off the same predicates whenGapOf reads, so the
// two cannot disagree about what is still open.
type whenKnown struct{ hour, part, day bool }
func whenKnownOf(text string, hasTime bool) whenKnown {
if !router.NamesAnHour(text) {
return whenKnown{}
}
if router.NamesAnInterval(text) {
return whenKnown{hour: true, part: true, day: true}
}
if !hasTime {
return whenKnown{}
}
return whenKnown{
hour: true,
part: !router.HourIsAmbiguous(text),
day: router.NamesADay(text),
}
}
// movedForward reports whether b says something a did not.
func (a whenKnown) movedForward(b whenKnown) bool {
return (!a.hour && b.hour) || (!a.part && b.part) || (!a.day && b.day)
}
// whenTakenLine — the acknowledgement in front of a re-ask, in the words he
// just used (V-593).
//
// It is an echo and never a restatement, for the same reason the fact
// confirmation is (V-592): a 1.7B asked to say a Russian sentence back invents.
// Its only job is evidence that the turn between two asks was heard, so after
// "на 9" and then "на завтра" she does not ask "утра или вечера?" twice
// byte-identically while he wonders whether the microphone is on.
func whenTakenLine(text string) string {
text = strings.TrimSpace(text)
text = strings.TrimRight(text, " \t.,!?;:")
if text == "" {
return ""
}
return "Поняла: " + text + "."
}
// whenTextOf is everything he has said about when, the original request plus
// every answer he has given to a question about it.
//
// The answers are kept apart from the utterance on purpose. The utterance is
// the reminder's payload, so folding "завтра" into it would have her read the
// day back to him at the time she says it. And a time answer has to be read
// against the request rather than alone: "завтра" names no hour, and the hour
// it belongs to is the one she is already holding.
func whenTextOf(q *dialogue.PendingQuestion) string {
if q.WhenText == "" {
return q.Utterance
}
return strings.TrimSpace(q.Utterance + " " + q.WhenText)
}
// slotStillMissing reports whether a slot is still open. Every slot but the
// reminder's time is open when it is empty; the time is open until all three of
// what he must say about it are said.
func slotStillMissing(slot dialogue.Slot, utterance string, s dialogue.Slots) bool {
if len(dialogue.StillMissing([]dialogue.Slot{slot}, s)) > 0 {
return true
}
return slot == dialogue.SlotTime && whenGapOf(utterance, s.HasTime) != whenComplete
}
// readWhen reads the instant out of what he has said about the time, newest
// statement first.
//
// The request plus his latest answer is tried before the whole history, and
// that order is what makes a correction win: "нет, сегодня в 15:00" after "в
// 11:00" must land on 15:00, and a parser reading left to right off the joined
// history would find the 11 he just took back. The history is the fallback,
// because an answer often completes an earlier one rather than replacing it -
// "вечера" says which 9, and alone it names no hour at all.
func (h *reactiveHandler) readWhen(ctx context.Context, intent router.Intent, q *dialogue.PendingQuestion, text string) (time.Time, bool) {
latest := strings.TrimSpace(q.Utterance + " " + text)
if router.NamesAnHour(text) {
if w := h.extractor.Extract(ctx, intent, latest, h.now()); w.HasTime {
return w.Time, true
}
}
if w := h.extractor.Extract(ctx, intent, whenTextOf(q), h.now()); w.HasTime {
return w.Time, true
}
return time.Time{}, false
}
// asksAboutTime reports whether the parked question is one about when.
func asksAboutTime(missing []dialogue.Slot) bool {
for _, s := range missing {
if s == dialogue.SlotTime {
return true
}
}
return false
}
// stillOpen reports whether any of the slots she asked about is still unsaid.
func stillOpen(missing []dialogue.Slot, utterance string, s dialogue.Slots) bool {
for _, slot := range missing {
if slotStillMissing(slot, utterance, s) {
return true
}
}
return false
}
// stillMissingFor is missingFor's engine, in wantedSlots order. It reads the
// utterance as well as the slots, which plain StillMissing cannot: whether an
// hour is ambiguous is a fact about the words, not about the instant they
// parsed to.
func stillMissingFor(intent router.Intent, utterance string, s dialogue.Slots) []dialogue.Slot {
var out []dialogue.Slot
for _, want := range wantedSlots[intent] {
if slotStillMissing(want, utterance, s) {
out = append(out, want)
}
}
return out
}
+8
View File
@@ -31,6 +31,14 @@ func (r *llmReplier) Reply(d router.Decision) string {
// a generation to say something this small.
return clarifyMissedLine(d)
}
if d.Intent == router.IntentFact {
// A captured fact is confirmed by echoing him, and the model is not
// asked (V-592). It has nothing to phrase FROM: replyContext hands it
// "записала факт: water \"drank\"", so every Russian word in the reply
// was the model's own invention, and on 2026-08-06 that was "Проверила,
// что ты выпел стакан воды" for "я выпил воды".
return phraser.FactAck(d.Utterance)
}
out, err := r.p.PhraseReply(context.Background(), d)
if err != nil || out == "" {
return r.stub.Reply(d)
+14 -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
}
}
+35
View File
@@ -0,0 +1,35 @@
package main
import "testing"
// 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)
}
}
}
+2 -2
View File
@@ -24,8 +24,8 @@
"at": "21:00",
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
"audio": "ru_fact",
"expect_reply_contains": ["записала"],
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
"expect_reply_contains": ["записала", "выпил воды"],
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш", "стакан"],
"expect_events": ["water"]
},
{
+3 -3
View File
@@ -81,10 +81,10 @@
},
{
"at": "08:55",
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
"note": "stating a fact writes it and says so, in the feminine, and in his own words. The reply no longer comes from the model at all (V-592): a 1.7B asked to restate «я выпил воды» answered «Проверила, что ты выпел стакан воды», so the confirmation is now a deck frame with his sentence in it. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\".",
"say": "я выпил воды",
"expect_reply_contains": ["записала"],
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
"expect_reply_contains": ["записала", "я выпил воды"],
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "стакан"],
"expect_events": ["water"]
},
{
+71
View File
@@ -31,6 +31,7 @@ const (
roleCorrection turnRole = "correction" // it replaces a value she already had
roleSideQuery turnRole = "side_query" // a question of its own, asked mid-flow
roleNewRequest turnRole = "new_request" // a different request entirely
roleAside turnRole = "aside" // something he stated, not an answer
roleCancel turnRole = "cancel" // call the pending action off
roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn
)
@@ -212,11 +213,28 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
// tests are the floor and answer for free; the route is what sees a request
// with no shape to it — "погода в риме" asks a question and carries neither
// a question mark nor an interrogative, and only the router knows that.
//
// An utterance of pure frame gets one more chance, and V-577 is why. Every
// token of "что у меня сегодня?" is frame, so the content gate called it an
// answer, the parked reminder took "сегодня" for its time, and the question
// he asked was answered nowhere. A routed intent beats a frame match,
// because the frame is a hint and the route is a decision.
//
// The condition is that it fills nothing she asked about. That keeps the
// hedged "а что если в 11:00" an answer, which is what it is: it carries the
// hour, and no route saying "question" changes that. It works because the
// extractor no longer reads a day word as the current clock, so a sentence
// that names no hour now fills nothing to weigh.
own := false
if len(ownContent(text)) > 0 {
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
} else if ok && fillsNothingAsked(q, answer) {
own = carriesOwnRequest(routed, text)
}
if !own {
if isAside(q, text, answer, routed, ok) {
return roleAside
}
if replacesFilledSlot(q, answer) {
return roleCorrection
}
@@ -228,6 +246,59 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
return roleNewRequest
}
// isAside reports whether the utterance is something he STATED while she was
// waiting on a question (V-577 shape 2).
//
// "у меня новый ноутбук" said into a parked reminder was dropped in silence: it
// carries no capture verb, so it is not a request of its own, and it fills no
// slot, so it is not an answer either. Neither storing it nor saying it was
// ignored is the one behaviour that is wrong, and it was the behaviour.
//
// Three conditions, and all three are needed. The route has to call it a
// statement AND stand behind that, so a bare time is never an aside. It has to
// fill none of what she asked about, so an answer she can use stays an answer.
// And it has to say something, so a shrug is still a failed answer and still
// spends a retry.
func isAside(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) bool {
if !ok || q == nil {
return false
}
if !statesSomething(routed) {
return false
}
if len(ownContent(text)) == 0 {
return false
}
return fillsNothingAsked(q, answer)
}
// statesSomething reports whether the route is evidence that these words state
// a thing, rather than a guess she has to interrupt a flow over.
//
// Two kinds of evidence, and the second one exists because the classifier floor
// marks nearly everything Clarify. A parsed fact key comes from the
// deterministic fact parser and not from a similarity score, so "я выпил воды"
// is a statement on any engine. A confident note or fact is the other kind, and
// that is the one the resident model gives for "у меня новый ноутбук".
func statesSomething(routed router.Decision) bool {
switch routed.Intent {
case router.IntentFact:
return routed.Slots.HasKey || !routed.Clarify
case router.IntentNote:
return !routed.Clarify
}
return false
}
// fillsNothingAsked reports whether the utterance gave her none of what she
// asked for. Nothing is pending counts as nothing filled.
func fillsNothingAsked(q *dialogue.PendingQuestion, answer dialogue.Slots) bool {
if q == nil {
return true
}
return len(dialogue.StillMissing(q.Missing, answer)) == len(q.Missing)
}
// replacesFilledSlot reports whether the utterance overwrites something the
// pending action already had, rather than filling the gap she asked about —
// "нет, на девять" while she is waiting for the subject. Both are handled the
+31
View File
@@ -123,6 +123,37 @@ func TestTurnRoleReadsTheRoutedDecision(t *testing.T) {
ok: true,
want: roleAnswer,
},
{
// V-577 shape 1. Every token is frame, so the content gate called
// this an answer and the reminder took "сегодня" for its time. It
// fills nothing she asked about, so the route decides, and the route
// says the calendar answers it.
name: "an agenda question of pure frame words is a side query",
text: "что у меня сегодня?",
routed: dec(router.IntentQuery, router.Slots{}),
ok: true,
want: roleSideQuery,
},
{
// V-577 shape 2. Neither a slot value nor a request nor a cancel.
// It was dropped in silence; it is an aside, and an aside is stored
// and re-asked.
name: "a fact stated mid-flow is an aside",
text: "у меня новый ноутбук",
routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}),
ok: true,
want: roleAside,
},
{
// A route she is not sure of is not evidence that he stated
// anything, and "позвонить маме" is the answer to the other half of
// a reminder.
name: "an unsure note is not an aside",
text: "позвонить маме",
routed: router.Decision{Intent: router.IntentNote, Clarify: true},
ok: true,
want: roleAnswer,
},
{
name: "a bare noun that answers nothing is still an answer",
text: "ага",
+11 -1
View File
@@ -110,6 +110,16 @@ func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router
// This is a fast path to the SAME answer and must stay one. If it ever needs a
// rule the classifier does not have, it has become a second decision procedure
// and it is the thing V-560 deleted.
//
// A question shape is the exception and V-577 is why (measured 2026-08-06).
// "что у меня сегодня?" is an interrogative, a preposition, a particle and a day
// word, so every token of it is frame and it left no content of its own. The
// fast path called it an answer, the parked reminder read "сегодня" as its time,
// and the question he asked was never answered. Asked alone the same sentence
// routes to query at stage 0, so the route knew and was never consulted.
func needsRoute(text string) bool {
return !isCancel(text) && len(ownContent(text)) > 0
if isCancel(text) {
return false
}
return len(ownContent(text)) > 0 || router.IsQuestionShaped(text)
}
+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 {
+14 -2
View File
@@ -22,6 +22,18 @@ import (
// to it before sending, so it's also the rate the silence gate assumes.
const whisperSampleRate = 16000
// whisperThreads — greedy decode is single-pass and this is a laptop CPU
// (homesrv), not a server box; 4 was picked to leave headroom for the rest
// of the daemons sharing the machine, not measured against a latency target.
const whisperThreads = 4
// noSpeechFloor — whisper's own no_speech_prob past this point means the
// segment it transcribed is not speech (the model still emits token
// probabilities for silence/noise, so a high avgLogProb-derived confidence
// can coexist with a segment that should be zero). Read as "at least 90%
// sure this was not speech."
const noSpeechFloor = 0.9
type whisperHandler struct {
ctx *C.struct_whisper_context
minMs int // clips shorter than this are dropped (hallucination bait)
@@ -101,7 +113,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
params.print_realtime = false
params.print_timestamps = false
params.print_special = false
params.n_threads = C.int(4)
params.n_threads = C.int(whisperThreads)
params.single_segment = true
lang := C.CString(req.Lang)
@@ -162,7 +174,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
}
noSpeechProb := float64(C.whisper_full_get_segment_no_speech_prob(h.ctx, 0))
if noSpeechProb > 0.9 {
if noSpeechProb > noSpeechFloor {
confidence = 0
}
+13 -4
View File
@@ -116,18 +116,27 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
}, nil
}
// resample22050To16000 converts raw 16-bit PCM from 22050 Hz to 16000 Hz
// using linear interpolation.
// piperSampleRate is the rate piper's onnx voices render at (ru_RU-irina and
// the other models this daemon has been pointed at). targetSampleRate is the
// canonical maven wire rate (audio.PCM16kMono) that every downstream
// consumer — playback, the voice wire, whisper on the way back in — expects.
const (
piperSampleRate = 22050
targetSampleRate = 16000
)
// resample22050To16000 converts raw 16-bit PCM from piperSampleRate to
// targetSampleRate using linear interpolation.
func resample22050To16000(input []byte) []byte {
if len(input) < 2 {
return nil
}
nSamples := len(input) / 2
outSamples := int(float64(nSamples) * 16000.0 / 22050.0)
outSamples := int(float64(nSamples) * float64(targetSampleRate) / float64(piperSampleRate))
output := make([]byte, outSamples*2)
ratio := 22050.0 / 16000.0
ratio := float64(piperSampleRate) / float64(targetSampleRate)
for i := 0; i < outSamples; i++ {
srcPos := float64(i) * ratio
@@ -0,0 +1,94 @@
# The reminder completeness rule, measured on the box
Measured 2026-08-06 at 02:34 local, on homesrv, master `59cc882`, immediately after V-577
and V-579 merged as #231. Probe is `POST /api/chat` through mavweb, which is the same
`handleText` seam the mic and telegram use.
## The rule being measured
The owner ruled it the same night. A reminder commits only when three things are answered:
what, what time, and what day. Anything missing is asked for. Every time question opens by
stating the clock.
He gave two examples and confirmed two derived cases when asked.
## Result: the rule holds on all four
| said | what | time | day | reply |
|---|---|---|---|---|
| `напомни завтра в 3 заказать цветы` | yes | no part of day | yes | `Сейчас 02:34. Это утра или вечера?` |
| `напомни в 9 вечера разгрузить стиралку` | yes | yes | no | `Сейчас 02:34. В какой день?` |
| `напомни завтра в 15:00 позвонить врачу` | yes | yes | yes | `хорошо, напомню завтра в 15:00.` |
| `напомни через час выключить духовку` | yes | yes | yes | `хорошо, напомню сегодня в 03:34.` |
Row two is the one worth noting. `в 9 вечера` is unambiguous as a clock time and is still
asked about, because the day is missing. Today being a valid reading is not enough.
## The invented clock is gone
Last night the worst failure was silent. A reminder was written at the current minute and
nothing said so.
```text
напомни забрать посылку -> Сейчас 02:34. Когда?
что у меня сегодня? -> на 06.08.2026 ничего нет, на какое время поставить напоминание? [s=calendar]
в 15 -> Сейчас 02:34. В какой день?
```
Measured last night, the second turn answered nothing and wrote a reminder at the clock.
The question now reaches the calendar and the parked question resumes on the same reply.
The root cause was one step earlier than filed. `ownContent("что у меня сегодня?")` returns
empty, because every token is frame. `что` is an interrogative, `у` a frame word, `меня`
filler, `сегодня` a day offset. So `needsRoute` said no and no route was computed at all.
The turn fell through to answering the parked question. The extractor read `сегодня`, and
the date parser answered a bare day word with that day at the current minute.
So this was not a claimant beating the route. It was a claimant running where the route was
never asked for.
## Two defects the probe found
**V-592, invented content.** `я выпил воды` came back as
`Проверила, что ты выпел стакан воды`. `выпел` is not a Russian word and `стакан` was never
said. The arbitration around it is correct: the aside is taken, the parked question resumes,
nothing is dropped. Only the content is wrong. Whether the store agrees with the sentence is
not yet known. That answer decides whether this is a phrasing defect or a capture defect.
**V-593, an unacknowledged partial answer.** `на 9` then `на завтра` produces the same ask
twice, byte-identical.
```text
а, да, прости - на 9 -> Сейчас 02:34. Это утра или вечера?
на завтра -> Сейчас 02:34. Это утра или вечера?
```
The behaviour is right, since the part of day is missing both times. The wording gives him
no evidence he was heard, which is the failure mode the V-558 family exists to remove.
## The pinned acceptance transcript is now superseded
The transcript the owner wrote has her guess a reading and accept `на завтра` as the last
turn:
```text
me: а, да, прости - на 9.
maven: сейчас 15:23 - на 9 сегодня вечером?
me: на завтра.
maven: напомню.
```
Under the rule he later ruled, `на 9` plus `на завтра` still names no part of day. So she
must ask a third time rather than commit. The transcript and the rule disagree, and the rule
is the newer statement. Offering a reading, as that transcript does, remains allowed and is
not required. The implementation asks `Это утра или вечера?` and offers none.
## Regression checked
A new request still drops the old one out loud. That is the behaviour the owner kept when he
rejected the silent drop for side queries.
```text
напомни позвонить маме -> Сейчас 02:34. Когда?
напомни завтра в 9 купить молоко -> Прошлую просьбу отпускаю. Сейчас 02:34. Это утра или вечера?
```
@@ -0,0 +1,411 @@
# Two artifacts, and neither one is Spring
Proposal. V-585. Related umbrella V-558, and the design collected in
`docs/plans/19-dialogue-arbitration.md`.
## Verdict
**Thesis one holds for four seams and fails for one.** Four are one shape: the routing
cascade, the query source chain, the pre-route resolver ladder, the digestion tick. Reach
selection is not. It maps severity and presence to a set of channels. It has no claimants and
no losers.
**Thesis two holds.** An arbitration kernel is a package and a convention inside one program.
It is not a framework. A framework whose only client is the codebase it came from is that
codebase with more ceremony.
**The answer is two artifacts of different sizes.** One package inside Maven, built from
`internal/claim` and `internal/decision`. Both already exist and neither is wired. One small
library across the four services, holding the correlation id, the headers, the timeout policy
and the named gap. Neither is Spring. The daemons must not get a third.
**The strongest finding is the duplication, not the shape.** Three structural holes make a
route untrustworthy. They are written out by hand in three files, for three consumers, with
three return types. `gateLLMDecision` flattens them to a float. `vetoOf` re-derives them as a
sentence. `thinReason` re-derives them again as a trace string. That is what having no common
unit costs, and it is countable in lines rather than in taste.
## The five seams, tested
### 1. The routing cascade. The shape, ordered by hand.
The claimant is `router.Grammar` (`internal/router/stage0.go:19`). It holds a name, a regex
and a `Build` that may still decline. A claim is a regex match plus `ok` from `Build`.
Ordering is the append order in `buildRouter` (`cmd/mavend/voicewire.go:384`). Twelve appends.
Each one carries a comment arguing its position against its neighbours. First match wins at
confidence 1.0.
Below stage 0 the two engines are alternatives, not rivals. The classifier runs only when the
router is nil or errored. Inside the classifier the order is cosine score, and the top three
are recorded.
Losers are recorded and change nothing. `noteGrammarOutcomes`
(`internal/router/decisiontrace.go:56`) separates a grammar that did not match from one whose
`Build` declined. Everything past the winner is marked `NeverAsked`.
### 2. The query source chain. The shape, ordered by hand, with a boundary in it.
The claimant is `querySource` (`cmd/mavend/actions_query.go:45`), a name and one function
returning `(string, bool)`. Twenty-four of them sit in one slice literal, walked in order. The
comment on the slice says the order is load-bearing. It is right.
This seam carries something the others do not. The personal boundary at line 139 is a stop,
not an answer. Everything above it reads the owner's data. Everything below reads the world. A
question about him that reaches the boundary ends there.
### 3. The pre-route resolver ladder. The shape, and the one that hurts.
Seven rungs, each returning `(reply string, handled bool)`. Ordering is the order of the `if`
statements in `runTurn` (`cmd/mavend/voice.go:258`). The roster in `preRouteLadder`
(`cmd/mavend/decisiontrace.go:33`) is kept by hand, and its own comment admits nothing
enforces the correspondence.
The recurring bug lives here. A rung claims before the utterance is routed. So the claimant
with the earliest and strongest trigger is the one that knows least about what was said. V-560
fixed half of it. It computes the route once, before the ladder, and lets the clarify resolver
read it. The other rungs still decide without reading it.
### 4. The digestion tick. The shape, and the only one already done right.
This corrects the brief. `loop.Tick` (`internal/loop/loop.go:85`) is not a first-to-claim
walk. It is an arbitration with a declared comparator.
- The claimant is `loop.Rule`. Its `Predicate` says whether it wants the turn.
- The gate is separate from the claim. `Gate` (`loop.go:21`) checks snooze, cooldown, quiet
hours, calendar busy, presence and missing data. `ExplainGate` names which one blocked.
- The comparator is data, not position. Max severity wins, and ties break on name.
`DefaultRules` states outright that slice order is not load-bearing.
- Losers are recorded with what they lost to. `ExplainTick` (`internal/loop/explain.go:86`)
fills `LostTo`, and rewrites the previous best when a higher severity displaces it.
- Losers get a second life. `DigestEligible` (`loop.go:129`) decides which suppressed
candidates are bundled for later. It refuses cooldown and snooze, because neither is
restraint.
Every property the kernel wants already exists here, on five rules. The kernel argues that the
other three seams should look like this one. It does not need a new idea.
### 5. Reach selection. Not the shape.
`ChannelsFor` (`internal/delivery/channel.go:73`) takes severity and presence and returns a
slice of channels. Nothing claims. Nothing passes. Nothing loses. Every channel in the
returned slice sends, so there is not even one winner.
Naming the sinks claimants would be the forced abstraction. It would also hide the property
this table has and the ladders lack. It is total, it is pure, and every cell is covered by
`TestChannelsForEveryTableCell`.
One thing in the dispatcher does re-decide. `ErrVoiceNoSession` means the presence guess was
wrong, so the remaining channel list is replaced with the away table
(`internal/delivery/dispatcher.go:188`). That is a retry on new evidence, not a contest. Leave
it alone.
### What else has the shape
`fillMatchedSlots` (`internal/router/router.go:212`) arbitrates per slot. A matched value
always wins, and the extractor fills only what was left empty. That is the coverage-first rule
the kernel proposes, written once for four slots.
`bestRecall` and the topic veto pick between a fact and a note by score with a margin. That
one is a real score comparison, and it should stay one.
## The abstractions
Three, not four. The straw man had `Claimant`, `Claim`, `Arbiter` and `Record`. Drop
`Claimant`.
**`claim.Claim`, evidence rather than a verdict.** It exists at `internal/claim/claim.go`,
built and tested, imported by one function that nothing calls. It carries `Consumed` and
`Unexplained` for coverage, an ordinal `Band`, and a `Veto` string that keeps the reason a
float threw away. Coverage is compared before band. That is the fix for the Rome failure,
where a parked reminder ate the whole utterance while explaining none of it.
**`decision.Record`, the trace.** It exists at `internal/decision/decision.go` and it is wired
everywhere. It separates won, declined, lost on score, thinned, merged and never asked. The
last one is the valuable one. A claimant that never looked reads identically to one that looked and
passed. That is what hardcoded order hides.
**`Arbiter`, the thing that does not exist.** One function. It takes a set of claims and a
comparator, returns a winner, and notes the rest. `loop.Tick` is that function, specialised to
rules. Generalising it is the proposal.
**Against a `Claimant` interface.** Every seam already rejected one, for the same reason.
`querySource` is a struct of one function because the sources are methods on one handler with
no state. An interface would mean one empty type per source. `confirmResolver` is the same
shape, and `loop.Rule` is a struct with a closure. An interface would buy a shared name and
cost twenty-four empty types. The claimants stay what they are. Each seam builds `claim.Claim`
values at its own edge, which is what `router.ClaimOf` already does.
## What ordering becomes
Ordering becomes a comparator plus a rank, and the rank is data.
Today ordering is position in a slice, and position is invisible in the record. Add a rung to
`runTurn`, forget `preRouteLadder`, and the rung vanishes from the trace. The roster's own
comment admits nothing enforces it.
The proposal is smaller than a dependency graph. A claimant declares a rank. The arbiter sorts
by coverage, then band, then rank. Rank breaks the tie that evidence cannot break.
**A dependency graph is the wrong tool.** The real constraints are pairwise and local. Day
plan before calendar. Praxis before the capture marker. Narrative last. A graph turns those
into edges and then needs a topological sort whose output nobody can read. The twelve comments
in `buildRouter` would become twelve edges with the arguments deleted. Keep the arguments.
**Two claimants at the same rank must be an error, caught at wiring time.** Not at turn time.
The registry is built once at boot, so a duplicate rank is a boot failure naming both
claimants. Falling back to slice order on a tie would restore the invisible ordering the
kernel exists to remove.
**The roster stops being hand-kept.** A claimant registered with the arbiter is on the roster
by construction. That deletes the `preRouteLadder` failure mode outright.
## What it buys
**It deletes three copies of one test.** The three structural holes appear in
`gateLLMDecision` (`internal/router/router.go:271`), in `vetoOf`
(`internal/router/claim.go:101`), and in `thinReason` (`internal/router/decisiontrace.go:28`).
Three files, three return types, one rule. A fourth consumer would write it a fourth time.
With a claim carrying `Veto`, the rule is written once. The float, the sentence and the trace
string all derive from it. This one is worth the work on its own.
**It makes the recurring bug expressible.** Rome, V-567 and V-577 are one defect. The claimant
that knows least holds the earliest trigger. Coverage-first arbitration states the fix once,
in `MoreSpecificThan`. A parked clarify explaining zero tokens of "какая сейчас погода в
Риме?" loses to a weather claim explaining all of them. Nobody has to encode that a parked
clarify is less trustworthy than a grammar.
**The limit.** The kernel prevents the class only where the losing claimant
computes low coverage. Rome, V-567 and V-577 all qualify. Each is a stateful claimant
swallowing an utterance it explains none of. A claimant that matches a substring does explain
those tokens, and coverage does not catch it. V-567's substring match is that case from the
other side. Coverage there has to be measured against the whole utterance rather than the
matched span. `claimSpans` already does that, in the safe direction. So the kernel
prevents most of the class and describes the rest. Claiming more would be dishonest.
**It makes contention countable.** Today it is not. The 91-case fixture draws two stage-0
grammars exactly once, at `ru-query-019`, and both route the same intent. Nobody knows whether
contention is rare or whether the fixture omits it. An arbiter that sees every claim can
count.
**What it does not buy.** No accuracy point comes from this alone. Every number in
`docs/evals/2026-08-05-routing-resident-model.md` is reachable without it. The kernel is a
place to put the fix, not the fix.
## What it costs
Every seam rewritten is a chance to break a measured number.
Re-measure the 91-case routing fixture. The baseline is 75.8% full and 80.2% intent-only at
p50 1.19s, in `docs/evals/2026-08-05-routing-resident-model.md`. Judge against the classifier
and the resident model, because those are what always answer.
Re-measure Praxis reach. The baseline is 27/30 overall, 11/12 Praxis and 5/5 lifecycle, in
`docs/evals/2026-08-05-praxis-reach.md`. The Praxis grammars are the only path to Praxis, so a
reordering that demotes them costs every point.
Re-run the nine-scenario interleave probe in `docs/evals/2026-08-06-claimant-interleave.md`.
Six of nine pass today. That probe measures exactly what this proposal is for.
Re-run the dialogue contract tests from V-563. They are whole multi-turn traces, and the only
tests that cover the ladder as a ladder.
Latency is the cheap part. A stage-0 query costs 3.7µs. One claim per claimant adds two slices
and a token split, on a path whose p50 is over a second.
The real cost is the arguments. Twenty-three comments across `buildRouter` and `querySources`
explain why each entry sits where it does. A migration that turns them into rank integers and
drops the prose destroys the only documentation the ordering has.
## Migration order
**Step one, no behaviour change. Delete the duplication.** Make `vetoOf` the single definition
of the three structural holes. Have `gateLLMDecision` and `thinReason` read it. One rule,
three consumers, no new abstraction. Re-measure the routing fixture and nothing else. This is
worth landing whether or not the rest does.
**Step two, the proof. Arbitrate the pre-route ladder.** Smallest seam, seven rungs, and the
one with the measured defect. Each resolver returns a claim instead of a bool. The arbiter
compares coverage, then band, then rank. The roster comes from the registry. The proof is the
interleave probe reaching nine of nine with the routing fixture unmoved.
**Step three, the query source chain.** Twenty-four sources, most of which already compute a
match span. The personal boundary does not become a ranked claimant. It stays a hard stop, and
the arbiter runs above it and below it separately. Re-measure Praxis reach and the search and
Kiwix fallback.
**Step four, stage 0, or not at all.** Twelve grammar groups whose order encodes twelve
arguments, scoring 20/20 on the fixture. Most to lose, least to gain. Defer it until steps two
and three have sat in the deploy long enough to break something.
**`loop.Tick` moves last or never.** It already has the comparator, the gate with reasons, the
loser trace and the loser rescue. Rewriting it to call a generic arbiter risks the digest path
to gain a shared name.
## What must not be in the kernel
**Authorization.** This is the hard line. CLAUDE.md is explicit that LLM output is not
authorization, and that a confirmation binds capability id, target entity, arguments,
requester and expiry (`cmd/mavend/confirm.go`). A generic arbiter turns many opinions into
one winner. That is the wrong shape for a binding. Make confirm a claimant with a rank and a band, and a claim that scored higher could take the
turn from it. The binding would be softened into a comparison. Confirm may report to the record. It must not compete in the
arbiter.
The same rule covers the Hexis path. Free text never reaches a mutating call, and resolution
happens against Nexus. Neither is a contest, so neither is arbitration.
**The personal boundary.** Same reason, different currency. The boundary is not the most
specific claimant. It is a stop. A boundary that can lose to a higher-coverage claim is not a
boundary. The failure is the owner's notes reaching a search engine.
**Confidence as a float.** The band exists because the measurement said a calibrated float is
not available. The classifier scores 62% correct below its median and 62% above, over a spread
0.083 wide. Its top-two margin has a p50 of 0.009
(`docs/plans/19-dialogue-arbitration.md`). A kernel with a `Score float64` on the claim invites
every claimant to invent one. When V-546 lands a softmax head with a calibrated probability,
that number is read beside the bands, not squeezed inside them.
**Slot extraction and validation.** `fillMatchedSlots` runs after a winner exists, and slot
validation against the action schema is V-562. Both ask whether a claim is well formed. Neither
asks which claim wins.
## The second artifact: the ecosystem client
The kernel is Maven's alone. The one thing here with plural clients by construction is the
contract between the four services. Maven implements its side of it twice and a half.
### Is the contract uniform today? No.
Nexus and Praxis share one implementation. `ecosystemHTTP` (`cmd/mavend/ecosystem.go:62`) is
embedded in both, so both get the same 10 second timeout, the same `setHeaders`, the same
typed `ecosystemError`, and the same correlation key. Hexis is a separate client in another
repository, vendored at `vendor/github.com/kami/hexis/pkg/client`, and it agrees on some of
that and not the rest.
Eleven divergences. Four of them are defects rather than style, and each is filed on its own:
V-587 the 401, V-588 the unnamed Praxis service, V-590 the uncorrelated discovery hop, V-591
the unsent causation id. They are ranked in that order, worst first, and none of them waits on
this proposal. V-587 is the only one that makes the owner check the wrong thing.
**A Hexis 401 is spoken as an outage.** Nexus and Praxis return `*ecosystemError` with
`Unauthorized()`, `ContractMismatch()` and `Unreachable()` classifiers
(`cmd/mavend/ecosystem.go:144`). Hexis returns `fmt.Errorf` strings
(`hexis/pkg/client/client.go:157`). So `unauthorizedEcosystemError`
(`cmd/mavend/ecosystem_acts.go:549`) does `errors.As` and always gets false for Hexis. The
owner hears "Hexis is down" when the truth is that Hexis refused the credential. The comment
at `ecosystem_acts.go:32` says that conflation must not happen.
**The Hexis discovery hop is uncorrelated.** Hexis carries its own context key
(`client.go:81`), invisible to Maven's. The bridge is a manual second stamp at
`ecosystem.go:546`. `discoverCapabilities` (`ecosystem.go:527`) does not do it, and discovery
runs before execute, so that call goes out with no correlation id. The doc comment above it
claims the opposite.
**Causation is computed and never sent.** `causationID` is derived at
`ecosystem_acts.go:771`. Hexis supports `X-Causation-ID` (`client.go:147`). Nothing passes it.
**A Praxis failure names no service.** `ecosystemGap` (`ecosystem_acts.go:36`) is the shared
named-gap helper, and Nexus and Hexis call it. Praxis returns per-verb Russian strings instead
(`ecosystem_acts.go:62`, `:73`, `:84`, `:95`). There is no `servicePraxis` constant. A Praxis
outage and a Praxis 403 both say "не получилось", with the service unnamed.
The rest are real but smaller. Hexis sends no `X-Requested-By: maven` and no `Accept` header,
so Hexis cannot attribute a read call to Maven at all. Its timeout is 30 seconds against
Maven's 10, which Maven cannot change from here. Its success predicate is `>= 400` where the
shared client uses `!= 200`. `withToken` is duplicated verbatim per client because the
embedded struct cannot return the concrete type. The version constant `"v1"` is defined twice,
in two repositories, with nothing keeping the two equal.
### Would a shared library have plural clients?
**Partly verifiable, and the honest answer is that two of the three cannot be checked from
here.**
Hexis is verified. It is a Go module consumed through a `replace` directive, and it already
publishes a Go client library that Maven imports. That is an existence proof that the pattern
works for one of them.
Praxis is suggested and not proven. Two comments reference Go paths in its repository
(`cmd/mavend/factenrichment.go:4`, `cmd/mavend/ecosystem.go:365`). Nothing here compiles
against it.
Nexus is unverifiable from this repository. There is an HTTP base URL and some JSON shapes,
and no language signal at all.
So the claim that a shared library would have plural clients rests on one confirmed adopter
and two assumptions. Do not present it as settled. The cheaper test is to fix Maven's side first,
in Maven. Offer the package outward once it has proven itself on one caller.
### What belongs in it
The straw man is right, with one addition.
- The correlation id, minted once per action, with one context key that all clients read. The
two-key split is the cause of the uncorrelated discovery hop.
- Causation, since one of the three already supports it and the value is already computed.
- The version header and `X-Requested-By`.
- The timeout policy, as one number rather than 10 in one repository and 30 in another.
- A typed error with the three classifiers, so a refused credential never speaks as an outage.
- The named gap shape, so no client invents its own vocabulary the way Praxis did.
### What must stay out
**Authorization, for the second time and the same reason.** The confirmation binding is
Maven's. It binds capability id, target entity, arguments, requester and expiry, and it lives
in `cmd/mavend/confirm.go`. A shared client that offered a policy hook would invite each
service to supply its own, and the binding would become configuration. Free text never reaches
a mutating Hexis call, and entity resolution stays in Nexus. Neither belongs in a transport
library.
Retry stays out too, or nearly. The only retry Maven has is Nexus enrichment at the worker
layer, with backoff from one minute to one hour (`cmd/mavend/factenrichment.go:49`). It is
there because enrichment is a background job with no listener. A turn cannot retry, because
the owner is standing there. A transport-level retry in a shared library would hide a
second budget behind a turn that already has a name for failing.
## The daemons must not get a framework
The instinct is right, and half the work is already done in a way that shows why.
Transport is shared and abstracted. `ipc.Dial` (`internal/ipc/client.go:94`) and `ipc.Listen`
(`internal/ipc/server.go:202`) both go through `netaddr`. A bare path is a unix socket with
`SO_PEERCRED` identity. A `tcp://host:port?token=...` address binds a network listener with a
mandatory token. Callers pass a string and never branch on scheme. That seam made the
workstation offload a deployment rather than a build. It is a library, it has nine clients in
this repository, and it earns its weight.
Startup is not shared, and should not be. There are twelve binaries under `cmd/`, each with a
hand-written `main`, and no common lifecycle package. `cmd/mavend/main.go` runs 830 lines. The
reason is visible in it. The daemon can boot **locked**, with no store at all, and wire its
components later from inside an unlock handler. Thirteen subsystem pointers are pre-declared
nil (`main.go:236`) and filled on one of two paths. The whole graph is built a second time
inside the unlock path (`main.go:478`). A container owning object lifecycle would have to model
a graph whose nodes do not exist at boot and may never exist.
The deeper reason is a design property. Every daemon degrades alone, and every ecosystem
client is nil unless configured. A wiring framework's job is to fail loudly when a dependency
is missing. Maven needs the opposite: a missing dependency is a named gap in one answer and a
working daemon everywhere else. An abstraction over startup would trade that property for a
shorter `main`.
The duplicated locked and unlocked wiring in `mavend` is a real defect and worth fixing. Fix it
by extracting one function in that file. That is not a framework.
## The honest comparison
Spring and Django own object lifecycle and request handling for applications they have never
seen. That is where the weight is paid for. The abstraction is general because the clients are
unknown.
Maven is one application, on one box, with one user. Its clients are known, there are nine of
them, and they are in this repository. A framework here would be an abstraction with a census.
So the right unit is a shared package and a convention. That is a smaller and more defensible
claim. The evidence for its size is that both halves already exist in that form, and nobody
called them a framework. `internal/ipc` is a shared package and a convention, and it carried
the daemons off the box. `internal/decision` is a shared package and a convention, and it made
the losers readable in one release. `internal/claim` is the third, written and waiting for a
caller.
The thing to disagree with: **the answer is two artifacts of different sizes. A package inside
Maven, a library across the four services, and neither one is Spring.**
+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 != "напоминание" {
+4 -1
View File
@@ -626,6 +626,9 @@ func windowBytes(f audio.Format, window time.Duration) int64 {
if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
return 0
}
per := int64(window.Seconds()) * bytesPerSecond(f)
// Fractional seconds count. Truncating the window to whole seconds turned
// any sub-second window into zero bytes, which the caller reads as "no
// window" and answers by handing the transcriber the entire meeting at once.
per := int64(window.Seconds() * float64(bytesPerSecond(f)))
return per - per%bps
}
+1 -3
View File
@@ -193,9 +193,7 @@ func ChunkText(text string, maxRunes int) []string {
// Oversized sentence: emit what is buffered, then cut this one on
// word boundaries.
flush()
for _, piece := range splitWords(sr, maxRunes) {
out = append(out, piece)
}
out = append(out, splitWords(sr, maxRunes)...)
continue
}
if len(cur)+len(sr) > maxRunes {
+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
+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:]
}
}
-7
View File
@@ -34,7 +34,6 @@ import (
"log"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/ttsnorm"
@@ -102,9 +101,3 @@ func (s *Sink) Send(ctx context.Context, send delivery.Sendable) error {
}
return nil
}
// keep audio import honest (used in Send's audio.PCM check indirect via
// Format.IsValid which is a method on the imported audio.Format). The alias
// below keeps the import alive even if a future refactor moves the only
// reference. Today, the synthesizer's audio.Audio directly flows through.
var _ = audio.PCM16kMono
+23 -9
View File
@@ -31,9 +31,14 @@ type PendingQuestion struct {
Slots Slots // what it already filled
Missing []Slot // what is still empty, in the order to ask about
Utterance string // the user's original raw words
Asked time.Time
TTL time.Duration
Attempts int // questions already asked
// WhenText is every answer he has given about the time, joined in the order
// he gave them. Kept apart from Utterance because the utterance is the
// reminder's payload, and because a time answer has to be read against the
// request rather than alone: "завтра" names a day for an hour said earlier.
WhenText string
Asked time.Time
TTL time.Duration
Attempts int // questions already asked
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
MaxAttempts int
}
@@ -99,11 +104,14 @@ type ClarifyStore struct {
// reply can carry.
const MaxStackDepth = 2
// DefaultClarifyTTL — how long a parked question stays his answer to give.
// Short, like confirmTTL in voice.go: a clarifying question is a same-breath
// gesture, and a stale one should not eat a later utterance.
const DefaultClarifyTTL = 90 * time.Second
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
if defaultTTL <= 0 {
// Short, like confirmTTL in voice.go: a clarifying question is a
// same-breath gesture, a stale one should not eat a later utterance.
defaultTTL = 90 * time.Second
defaultTTL = DefaultClarifyTTL
}
return &ClarifyStore{
stacks: make(map[string][]*PendingQuestion),
@@ -146,9 +154,15 @@ func (s *ClarifyStore) Push(id string, q *PendingQuestion) *PendingQuestion {
return dropped
}
// Peek returns the live question on top, or nil when there is none. Expired
// entries below it are left alone: TakeExpired is what reports those, and
// dropping one here would be the silent death this store is careful about.
// Peek returns the live question on top, or nil when there is none. An expired
// top takes the whole stack with it, exactly as Pop does: the clock that killed
// it has been running for everything underneath too.
//
// That drop is silent, which is the death this store is otherwise careful
// about, so TakeExpired has to run BEFORE Peek on a turn — it is what counts the
// dropped questions and tells him they are gone. cmd/mavend/voice.go calls
// clarifyExpiredNotice first for that reason, and reordering the two makes the
// notice unreachable rather than wrong.
func (s *ClarifyStore) Peek(id string, now time.Time) *PendingQuestion {
s.mu.RLock()
stack := s.stacks[id]
+6 -1
View File
@@ -87,9 +87,14 @@ type SessionStore struct {
persist SessionPersister // may be nil: memory only (tests, no-store paths)
}
// DefaultSessionTTL — how long a turn stays available to inherit from. Longer
// than DefaultClarifyTTL because this is not a question waiting on an answer:
// it is the last thing said, and a follow-up may land after a real pause.
const DefaultSessionTTL = 2 * time.Minute
func NewSessionStore(defaultTTL time.Duration) *SessionStore {
if defaultTTL <= 0 {
defaultTTL = 2 * time.Minute
defaultTTL = DefaultSessionTTL
}
return &SessionStore{
sessions: make(map[string]*Session),
+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" {
+9 -3
View File
@@ -131,9 +131,15 @@ const (
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:
+6 -1
View File
@@ -39,11 +39,16 @@ type Client struct {
http *http.Client
}
// clientTimeout — the whole request, search or article. The server is on the
// same box (see the package doc), so this is slack for a cold ZIM read, not a
// budget tuned against a flaky link the way websearch.DefaultTimeout is.
const clientTimeout = 10 * time.Second
// New makes a client for a Kiwix base URL like http://127.0.0.1:8034.
func New(baseURL string) *Client {
return &Client{
base: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 10 * time.Second},
http: &http.Client{Timeout: clientTimeout},
}
}
+50 -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) {
@@ -139,7 +139,40 @@ func TaskDropWords() []string { return words("task_drop_words") }
// 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 +316,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) }
+33 -7
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": [
@@ -198,6 +210,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 +231,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": {
+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) {
+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.
+35 -19
View File
@@ -104,14 +104,22 @@ func OptionalOnly(missing []Item) []Item {
}
// Validate reports the first structural problem with a routine set: missing
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
// within a routine, or an out-of-range weekday. Called at config load so a
// typo surfaces at startup, not as a silently-broken checklist at runtime.
// name/items, an unparseable HH:MM, an inverted window, a duplicate routine or
// item key, or an out-of-range weekday. Called at config load so a typo
// surfaces at startup, not as a silently-broken checklist at runtime.
//
// Routine names must be unique because Due keys its once-a-day map by name. Two
// routines sharing one would take turns suppressing each other's nudge.
func Validate(routines []Routine) error {
names := make(map[string]bool, len(routines))
for _, r := range routines {
if r.Name == "" {
return fmt.Errorf("morning: name is required")
}
if names[r.Name] {
return fmt.Errorf("morning routine %q: duplicate name", r.Name)
}
names[r.Name] = true
if len(r.Items) == 0 {
return fmt.Errorf("morning routine %q: at least one item is required", r.Name)
}
@@ -177,6 +185,17 @@ func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status {
return st
}
// missing lists the items of r with no evidence in [start, now].
func missing(r Routine, facts map[string]store.Fact, start, now time.Time) []Item {
var out []Item
for _, it := range r.Items {
if !evidenced(it, facts, start, now) {
out = append(out, it)
}
}
return out
}
// Outstanding reports the items of a routine that today has no evidence for,
// whether or not the window is still open. Evaluate answers "what is missing
// right now" and goes silent the moment the window closes; the day plan asks a
@@ -191,13 +210,7 @@ func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item {
if !ok || now.Before(start) {
return nil
}
var missing []Item
for _, it := range r.Items {
if !evidenced(it, facts, start, now) {
missing = append(missing, it)
}
}
return missing
return missing(r, facts, start, now)
}
// Due returns the routines that have reached their nudge time today with at
@@ -222,24 +235,19 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
if !ok || now.Before(nudgeAt) {
continue
}
var missing []Item
for _, it := range r.Items {
if !evidenced(it, facts, start, now) {
missing = append(missing, it)
}
}
skipped := missing(r, facts, start, now)
// A day where only the optional items were skipped is a fine day, and
// nagging about it is what teaches him to stop listening (Vikunja
// #473). The optional ones still travel in Missing so the message can
// mention them when it is being sent anyway.
if len(Required(missing)) == 0 {
if len(Required(skipped)) == 0 {
continue
}
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
continue
}
last[r.Name] = now
out = append(out, Candidate{Routine: r, Missing: missing})
out = append(out, Candidate{Routine: r, Missing: skipped})
}
return out
}
@@ -284,13 +292,21 @@ func sameDay(a, b time.Time) bool {
return ay == by && am == bm && ad == bd
}
// parseHHMM reads a five-character "HH:MM". Every digit is checked as a digit:
// arithmetic alone lets a stray character cancel out, so "2 :00" used to load
// as 04:00 and Validate passed the typo it exists to catch.
func parseHHMM(s string) (hour, min int, ok bool) {
if len(s) != 5 || s[2] != ':' {
return 0, 0, false
}
for _, i := range [4]int{0, 1, 3, 4} {
if s[i] < '0' || s[i] > '9' {
return 0, 0, false
}
}
h := int(s[0]-'0')*10 + int(s[1]-'0')
m := int(s[3]-'0')*10 + int(s[4]-'0')
if h < 0 || h > 23 || m < 0 || m > 59 {
if h > 23 || m > 59 {
return 0, 0, false
}
return h, m, true
+13
View File
@@ -56,6 +56,19 @@ func TestValidate(t *testing.T) {
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for duplicate item key")
}
// Due keys its once-a-day map by name, so two routines sharing one would
// suppress each other's nudge instead of both firing.
if err := Validate([]Routine{r, r}); err == nil {
t.Fatal("expected error for duplicate routine name")
}
// Arithmetic alone let a stray character cancel out: "2 :00" read as 04:00.
bad = r
bad.WindowStart = "2 :00"
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for a non-digit in window_start")
}
}
func TestEvaluateInactiveOutsideWindow(t *testing.T) {
+4 -4
View File
@@ -113,12 +113,12 @@ func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminder
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
var out []PlanEntry
for _, r := range routines {
missing := Outstanding(r, facts, now)
if len(missing) == 0 {
left := Outstanding(r, facts, now)
if len(left) == 0 {
continue
}
labels := make([]string, 0, len(missing))
for _, it := range missing {
labels := make([]string, 0, len(left))
for _, it := range left {
label := it.Label
if label == "" {
label = it.Key
+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(), "; ") +
+6 -1
View File
@@ -5,7 +5,8 @@
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title or, in ack_fact_echo, his own sentence. His data is interpolated Go-side — the file holds the frame, never his words.",
"ack_fact_echo is the one entry with a single variant, deliberately: what varies in it is his own sentence, which is different every time, and the frame around it is what the simulator scenarios read back.",
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
],
"entries": {
@@ -18,6 +19,10 @@
"ack_fact_kv": {
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
},
"ack_fact_echo": {
"fixed": true,
"variants": ["записала: {text}"]
},
"ack_note": {
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
},
+25 -1
View File
@@ -15,6 +15,7 @@ import (
_ "embed"
"log"
"math/rand"
"strings"
"sync"
"github.com/kami/maven/internal/say"
@@ -33,6 +34,7 @@ const (
AckFact = "ack_fact"
AckFactKey = "ack_fact_key"
AckFactValue = "ack_fact_kv"
AckFactEcho = "ack_fact_echo"
AckNote = "ack_note"
AckReminder = "ack_reminder"
AckAct = "ack_act"
@@ -58,7 +60,7 @@ const (
// ackKeys — every key the code requires the file to define.
var ackKeys = []string{
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
AckFact, AckFactKey, AckFactValue, AckFactEcho, AckNote, AckReminder, AckAct,
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
AckQuietOn, AckQuietOff,
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
@@ -71,6 +73,7 @@ var ackFloor = map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
AckFactEcho: "записала: {text}",
AckNote: "сохранила заметку.",
AckReminder: "напомню.",
AckAct: "ок, записала действие: {fn}",
@@ -109,6 +112,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
// captured, which reads as a successful save of nothing.
for _, req := range []struct{ key, ph string }{
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckFactEcho, "{text}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
@@ -157,6 +161,26 @@ func DefaultAcks() *Acks {
// Ack — one acknowledgement line, the way every caller says it.
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
// FactAck — the confirmation for a captured fact, in the words he used (V-592).
//
// It is a deck line with his sentence dropped into it, and there is no
// generation anywhere on this path. Asking a 1.7B to say his sentence back
// produced "Проверила, что ты выпел стакан воды" for "я выпил воды": a non-word
// for the verb, a glass he never mentioned — lifted straight out of the example
// in ReplySystemPrompt — and a claim to have checked something. The fact store
// held key=water value="drank" throughout, so nothing was mis-captured and
// everything after the capture was invented.
//
// An empty utterance falls back to the contentless line rather than confirming
// a capture of nothing.
func FactAck(utterance string) string {
utterance = strings.TrimSpace(utterance)
if utterance == "" {
return Ack(AckFact, nil)
}
return Ack(AckFactEcho, map[string]string{"text": utterance})
}
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
+30 -17
View File
@@ -126,6 +126,17 @@ func checkLength(body string) Result {
// correct, "я напомнил" is not. Both directions matter, which is why this is a
// windowed scan around "я" and not a bare search for masculine endings.
// minInflectedRunes — the shortest a word can be and still carry one of the
// verb/adjective endings this file matches (masculine -л, feminine -ла, …).
// Below this length a suffix match would be coincidence, not grammar.
const minInflectedRunes = 3
// selfRefWindow — how many words past a self-reference marker ("я"/"ты", …)
// still count as belonging to that reference before the sentence has moved
// on. Same window on both the forward scan after "я" and the backward scans
// in governedByYou and hersNotHis.
const selfRefWindow = 3
var wordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[,.;:!?…—-]`)
// secondPerson — pronouns that end the self-reference window. Everything after
@@ -154,7 +165,7 @@ var masculinePredicative = map[string]bool{
// ends in -л AND is a form of a verb (Vikunja #526). Every noun the list held is
// correctly not a verb, and so are the ones it had not got round to.
func masculinePast(w string) bool {
if len([]rune(w)) < 3 {
if len([]rune(w)) < minInflectedRunes {
return false
}
if !strings.HasSuffix(w, "л") && !strings.HasSuffix(w, "лся") {
@@ -172,7 +183,7 @@ func checkFeminine(body string) Result {
// Scan the next few words. Stop at punctuation or at a second-person
// pronoun: past that point the sentence is about him and masculine is
// correct.
for j := i + 1; j < len(words) && j <= i+3; j++ {
for j := i + 1; j < len(words) && j <= i+selfRefWindow; j++ {
nw := words[j]
if len(nw) == 1 && !unicode.Is(unicode.Cyrillic, []rune(nw)[0]) {
break
@@ -259,7 +270,7 @@ var notFeminineVerb = map[string]bool{
// femininePast reports whether a word looks like a feminine past-tense verb:
// "отдыхала", "поела", "выспалась".
func femininePast(w string) bool {
if len([]rune(w)) < 3 || notFeminineVerb[w] {
if len([]rune(w)) < minInflectedRunes || notFeminineVerb[w] {
return false
}
return strings.HasSuffix(w, "ла") || strings.HasSuffix(w, "лась")
@@ -268,7 +279,7 @@ func femininePast(w string) bool {
// looksFeminineNoun — a crude guard against "зарядка была": a word right before
// the verb that ends in "а"/"я" and is not itself a verb is probably the subject.
func looksFeminineNoun(w string) bool {
if femininePast(w) || len([]rune(w)) < 3 {
if femininePast(w) || len([]rune(w)) < minInflectedRunes {
return false
}
return strings.HasSuffix(w, "а") || strings.HasSuffix(w, "я")
@@ -307,7 +318,7 @@ func checkHisGender(body string) Result {
// hersNotHis — the verb is Maven's own if "я" comes shortly before it, or if the
// thing she did was done to him ("напомнила тебе", "проверила за тебя").
func hersNotHis(words []string, i int) bool {
for j := i - 1; j >= 0 && j >= i-3; j-- {
for j := i - 1; j >= 0 && j >= i-selfRefWindow; j-- {
if words[j] == "я" {
return true
}
@@ -368,12 +379,10 @@ func prevWord(words []string, i int) string {
// after the pronoun and is flagged wrongly.
// - any noun earlier in the message counts as an antecedent, even when it is
// not one ("после обеда он не ел", "выпей воды, он не пил" — both missed).
// Verbs and time words no longer count, which covers the usual nudge, but a
// plain noun before the pronoun still blinds it. The
// common time words are stoplisted so the usual nudge opening does not
// blind it, but a message with any other noun in front still slips through.
// This is the check's real hole; widening it further would start flagging
// legitimate third-party messages, so it stops here.
// Verbs and the common time words are stoplisted so the usual nudge opening
// does not blind it, but a message with any other noun in front still
// slips through. This is the check's real hole; widening it further would
// start flagging legitimate third-party messages, so it stops here.
// - a message that opens with "ты" and only later slips into "он" is missed,
// because "ты" itself is skipped but the words around it are not.
// - formal address outside these endings (short adjectives, "вашими" style
@@ -400,10 +409,14 @@ var prepositions = map[string]bool{
"под": true, "про": true, "без": true, "для": true, "через": true,
}
// minPluralVerbRunes — the -ите/-ете/-йте/-ьте endings pluralVerb matches are
// three runes on their own, so anything shorter can't carry a stem plus one.
const minPluralVerbRunes = 5
// pluralVerb reports whether a word looks like a plural/formal verb form:
// "приходите", "выпейте", "забудьте", "хотите".
func pluralVerb(w string) bool {
if len([]rune(w)) < 5 {
if len([]rune(w)) < minPluralVerbRunes {
return false
}
return strings.HasSuffix(w, "ите") || strings.HasSuffix(w, "ете") ||
@@ -443,7 +456,7 @@ var notAnAntecedent = map[string]bool{
// slipped through with "попробуй" taken for the person being talked about).
func looksVerb(w string) bool {
r := []rune(w)
if len(r) < 3 {
if len(r) < minInflectedRunes {
return false
}
for _, suf := range []string{
@@ -681,11 +694,11 @@ func checkEllipsis(body string) Result {
}
// governedByYou reports whether "ты" stands close enough in front of the verb
// at index i to be its subject. Three words, the same window checkFeminine's
// first pass uses after "я", and it stops at a first-person pronoun so "ты
// просил, я напомнил" still trips.
// at index i to be its subject. selfRefWindow words, the same window
// checkFeminine's first pass uses after "я", and it stops at a first-person
// pronoun so "ты просил, я напомнил" still trips.
func governedByYou(words []string, i int) bool {
for j := i - 1; j >= 0 && j >= i-3; j-- {
for j := i - 1; j >= 0 && j >= i-selfRefWindow; j-- {
switch words[j] {
case "ты":
return true
+6 -1
View File
@@ -30,8 +30,13 @@ const replyTimeout = 60 * time.Second
// ReplySystemPrompt — the reactive confirmation contract: one short Russian
// sentence, feminine self-reference, informal address, no question.
//
// The example is deliberately contentless. It used to be "Записала, что ты
// выпил стакан воды.", and the model copied the glass into a real reply about
// water he never described that way (V-592). An example carrying a plausible
// completion of the input is an invitation to reuse it.
const ReplySystemPrompt = `Ты Maven, домашняя ассистентка (о себе в женском роде). Владелец мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
Пример: {"response": "Хорошо, напомню.", "mood": "neutral"}
Никогда не пиши "..." в поле response.`
// Replier phrases reactive confirmations with the resident model. It has no
+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
+9 -5
View File
@@ -45,16 +45,20 @@ 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
# am/pm above) or a colon makes it read the hour, so give it the colon.
# English "at 7" fails identically, so both prepositions are rewritten.
text = re.sub(r'(?<![\w:])(в|во|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
# "на 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.
# "к двум часам" is a third preposition and the dative that goes with it,
# and it was read as no time at all until V-609.
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\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)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
+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)
}
}
}
+15 -6
View File
@@ -34,12 +34,21 @@ func numeralDigit(word string) (string, bool) {
// numeralContext — the words that make a numeral a time. A numeral is only
// rewritten when one of these sits next to it, so "три яблока" in a note is
// left alone and "в три часа" is not.
var numeralContext = map[string]bool{
"в": true, "во": true, "к": true, "около": true, "на": true,
"часа": true, "часов": true, "час": true, "часу": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"минут": true, "минуты": true, "минуту": true,
"at": true, "by": true,
var numeralContext = buildNumeralContext()
func buildNumeralContext() map[string]bool {
m := map[string]bool{
"в": true, "во": true, ": true, "около": true, "на": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"at": true, "by": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
return m
}
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
+83 -16
View File
@@ -55,7 +55,11 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
switch intent {
case IntentReminder:
if e.Time != nil {
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok {
// NamesAnHour is the gate, not the parser's ok (V-577, V-579). A
// sentence that names a day and no hour parses to that day at the
// current minute, and filling the slot with it invents the answer
// she asked for. Left empty, the daemon asks.
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && NamesAnHour(utterance) {
s.Time = t
s.HasTime = true
}
@@ -171,6 +175,15 @@ func afterWord(s, w string) string {
return ""
}
// hourPrepositions — the words a spoken hour sits behind. Five, and no more:
// the lexicon's frame set is much wider, and a word goes in here only when the
// number after it is an hour of the day rather than a count of anything.
//
// "к" and "ко" joined the three on V-609. "напомни к двум часам" named an hour
// and parsed to nothing, so the reminder reached the daemon with no time and she
// asked the open question about an hour he had just said.
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true, "к": true, "ко": true}
// StubDateTimeParser — a tiny relative/absolute parser standing in for
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
// "at HH:MM" / "HH:MM". The production path replaces this wholesale; the
@@ -210,18 +223,27 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
// with SpellOutDigits in front of this that is what "в семь вечера" reads
// as too (Vikunja #469).
//
// "на" and "во" frame a spoken hour the same way, and until V-579 only "в"
// did: "в 9" set the reminder and "на 9" was not read at all.
for i := 0; i+1 < len(toks); i++ {
if toks[i] != "в" {
if !hourPrepositions[toks[i]] {
continue
}
t, ok := parseClock(toks[i+1], now)
if !ok {
continue
}
if i+2 < len(toks) {
t = applyRuQualifier(t, toks[i+2], now)
// The qualifier is looked for anywhere in the sentence, not only right
// after the hour. It arrives on its own turn when she asks which half of
// the day he meant, and "на 9" plus "вечера" is one time (V-579).
if qual := ruQualifierIn(toks); qual != "" {
t = applyRuQualifier(t, qual, now)
}
return t, true, nil
// A day word anywhere in the sentence moves the hour onto that day. This
// scan runs before the calendar one below, so without this "напомни
// завтра в 15:00" landed today and V-579 asks about exactly that gap.
return applyRuDayShift(t, toks, now), true, nil
}
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
@@ -307,6 +329,10 @@ func sortDescByLen(ss []string) {
// parseClock — "7", "7:30" → today at that time; if already past today, roll
// to tomorrow (a "wake me 7" at 8pm fires tomorrow 7). Used by the stub scan.
func parseClock(clock string, now time.Time) (time.Time, bool) {
// Speech arrives with its punctuation attached: "на 9." ends a sentence and
// still names nine o'clock (V-579). The colon is kept, since it is the one
// mark that is part of a clock.
clock = strings.Trim(clock, ".,!?;")
parts := strings.SplitN(clock, ":", 2)
h, err := strconv.Atoi(parts[0])
if err != nil || h < 0 || h > 23 {
@@ -376,10 +402,18 @@ func leadingWordNumber(s string) (int, string, bool) {
}
func unitToDuration(n int, unit string) (time.Duration, bool) {
switch unit {
case "h", "hour", "hours", "hr", "hrs":
// The hour and the minute nouns are closed classes with one home in the
// lexicon, and the list here used to be short of the oblique forms (V-609).
if lexicon.IsHourUnit(unit) {
return time.Duration(n) * time.Hour, true
case "m", "min", "mins", "minute", "minutes":
}
if lexicon.IsMinuteUnit(unit) {
return time.Duration(n) * time.Minute, true
}
switch unit {
case "h", "hr", "hrs":
return time.Duration(n) * time.Hour, true
case "m", "min", "mins":
return time.Duration(n) * time.Minute, true
case "s", "sec", "secs", "second", "seconds":
return time.Duration(n) * time.Second, true
@@ -387,10 +421,6 @@ func unitToDuration(n int, unit string) (time.Duration, bool) {
case "day", "days":
return time.Duration(n) * 24 * time.Hour, true
// Russian units (inflected forms)
case "час", "часа", "часов":
return time.Duration(n) * time.Hour, true
case "минута", "минуты", "минут":
return time.Duration(n) * time.Minute, true
case "день", "дня", "дней":
return time.Duration(n) * 24 * time.Hour, true
case "неделя", "недели", "недель":
@@ -416,18 +446,22 @@ func parseDurationValue(s string) (string, bool) {
}
// AnaphoraResolver resolves pronouns like "это", "он", "она" to the prior
// turn's key entity. Returns a (key, value) pair the prior fact carried,
// or ("", "", false) when no pronoun is detected.
// turn's key entity. Returns the matched pronoun's class as ref, or
// ("", false) when no pronoun is detected — the caller cross-references ref
// against the prior turn's own slots, this type holds no state of its own.
type AnaphoraResolver struct{}
// Resolve checks if text contains an anaphoric reference to a prior turn's
// entity. For MVP this handles the common Russian pronouns:
// - "это" / "этого" / "этому" / "этим" / "этом" → "this" (most common)
// - "это" / "этого" / "этому" / "этим" / "этом" / "эти" / "эта" → "this"
// (most common)
// - "он" / "его" / "ему" / "ним" → "he/it", masc
// - "она" / "её" / "ей" / "ней" → "she/it", fem
// - "оно" → "it", neuter
// - "тот" / "та" / "то" / "те" → "that"
// - "мой" and its declined forms → "mine"
//
// Returns the matching pronoun type for cross-referencing with prior slots.
// Returns the matching pronoun class for cross-referencing with prior slots.
func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
s := strings.ToLower(strings.TrimSpace(text))
toks := strings.Fields(s)
@@ -480,6 +514,39 @@ func midnight(now time.Time, days int) time.Time {
//
// The date is recomputed rather than shifted, so an hour that parseClock
// already pushed to tomorrow does not land two days out.
// applyRuDayShift moves an hour onto the day the sentence names, if it names
// one. The hour is kept exactly as read: the day word says which day and says
// nothing about when in it.
// ruQualifierIn returns the first part-of-day word in the sentence, or "".
func ruQualifierIn(toks []string) string {
for _, tok := range toks {
switch cleanWord(tok) {
case "утра", "вечера", "дня", "ночи":
return cleanWord(tok)
}
}
return ""
}
func applyRuDayShift(t time.Time, toks []string, now time.Time) time.Time {
for _, tok := range toks {
days := 0
switch cleanWord(tok) {
case "сегодня":
days = 0
case "завтра":
days = 1
case "послезавтра":
days = 2
default:
continue
}
base := now.AddDate(0, 0, days)
return time.Date(base.Year(), base.Month(), base.Day(), t.Hour(), t.Minute(), 0, 0, now.Location())
}
return t
}
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
h := t.Hour()
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
+8 -9
View File
@@ -292,16 +292,15 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
if chatNarrativeTopics.MatchString(m[0]) {
return Decision{}, false
}
for _, t := range planTokens(topic) {
for _, v := range captureVerbs {
if t == v {
return Decision{}, false
}
toks := planTokens(topic)
for _, v := range captureVerbs {
if hasTok(toks, v) {
return Decision{}, false
}
for _, v := range entertainmentNouns {
if t == v {
return Decision{}, false
}
}
for _, v := range entertainmentNouns {
if hasTok(toks, v) {
return Decision{}, false
}
}
return Decision{
+179 -10
View File
@@ -3,6 +3,7 @@ package router
import (
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
@@ -81,6 +82,152 @@ func NamesADay(text string) bool {
return false
}
// NamesAnHour reports whether the sentence names a time of day or an interval
// away from now: a written clock, a numeral, a half or quarter past, or one of
// the words an interval is built from. A day word alone is not one — "завтра"
// says which day and says nothing about when in it.
//
// It is the gate on the reminder's time slot (V-577, V-579). A time slot that
// names no hour is never filled, it is asked about. Both parsers answer a bare
// day word with that day at the current minute, so "что у меня сегодня?" set a
// reminder at 01:28 and "на завтра" set one at 01:38 — the minute he happened
// to be speaking, in a request that never named one. The stub answers with
// midnight instead, which is a different invented hour and no better.
//
// Same discipline as MentionsTime above: every signal is a closed lexicon class
// or a digit, so this reads data and decides nothing about meaning.
func NamesAnHour(text string) bool {
toks := strings.Fields(strings.ToLower(text))
for i, raw := range toks {
tok := cleanWord(raw)
if isDigitClock(tok) || isAllDigits(tok) {
return true
}
if _, ok := numeralDigit(tok); ok {
return true
}
if hourMarkers[tok] {
return true
}
if _, _, ok := halfPastAt(toks, i); ok {
return true
}
if _, _, _, ok := quarterToAt(toks, i); ok {
return true
}
}
return false
}
// NamesAnInterval reports whether the sentence measures the time from now
// instead of naming it: "через час", "через 10 минут", "in 30 minutes".
//
// An interval resolves to one instant, so it answers the hour and the day
// together and nothing about it is ambiguous. Callers that ask which day or
// which nine o'clock have to skip it (V-579).
func NamesAnInterval(text string) bool {
for _, raw := range strings.Fields(strings.ToLower(text)) {
switch cleanWord(raw) {
case "через", "спустя", "in":
return true
}
}
return false
}
// HourIsAmbiguous reports whether the hour named could be either half of the
// day: "в 3" is three in the afternoon or three at night, and only he knows
// which (V-579, owner's rule of 2026-08-06).
//
// Three things settle it and any one is enough. A qualifier - "вечера", "pm",
// "полдень" - says which half. A written clock says it by being written. An
// hour above twelve says it by arithmetic. An interval names no hour at all.
func HourIsAmbiguous(text string) bool {
if NamesAnInterval(text) {
return false
}
toks := strings.Fields(strings.ToLower(text))
for _, raw := range toks {
tok := cleanWord(raw)
if hourQualifiers[tok] || isDigitClock(tok) {
return false
}
}
for _, raw := range toks {
tok := cleanWord(raw)
d, ok := numeralDigit(tok)
if !ok && isAllDigits(tok) {
d, ok = tok, true
}
if !ok {
continue
}
n, err := strconv.Atoi(d)
if err == nil && n >= 1 && n <= 12 {
return true
}
}
return false
}
// hourQualifiers — the words that pin an hour to one half of the day. Closed,
// and every member is a lexicon class or the two English markers.
var hourQualifiers = buildHourQualifiers()
func buildHourQualifiers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"полдень": true, "полночь": true, "полудня": true,
"am": true, "pm": true, "noon": true, "midnight": true,
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}
return m
}
func isAllDigits(tok string) bool {
if tok == "" {
return false
}
for _, r := range tok {
if r < '0' || r > '9' {
return false
}
}
return true
}
// hourMarkers — timeMarkers minus the day words and the weekdays, which name a
// day and not an hour, and minus "сейчас", which IS the clock and so can never
// be the evidence that the clock was meant.
var hourMarkers = buildHourMarkers()
func buildHourMarkers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"через": true, "спустя": true, "полчаса": true,
"полдень": true, "полночь": true,
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}
for _, w := range lexicon.HalfHourWords() {
m[w] = true
}
for w := range minutesTo {
m[w] = true
}
return m
}
// isMonth reports whether the token is a month name. The lexicon holds the
// genitive, which is the form a spoken date uses: "10 июля".
func isMonth(tok string) bool {
@@ -92,16 +239,34 @@ func isMonth(tok string) bool {
return false
}
// isWeekday reports whether the token is a day of the week in any case. The
// lexicon lists the nominative, and "в пятницу" is what a reminder says, so the
// match is by lemma — grammar is morph's job, not a second word list.
func isWeekday(tok string) bool {
for i := 0; i < 7; i++ {
if morph.SameWord(tok, lexicon.Weekday(i)) {
return true
// WeekdayIndex reports which day of the week a token names, in any case and in
// either language, or false when it names none.
//
// One matcher for the whole daemon (V-581). Four files used to keep a weekday
// list of their own and each one was short in a different direction: the habit
// map had "воскресеньях" but no "средах", the plan refusal had "среду" but not
// "среде", and cmd/mavend matched the STEM "сред" with strings.Contains, so
// "среди" and "средство" read as Wednesday. The lexicon lists the nominative,
// every Russian case lemmatises to it, and only English needs its forms written
// out — the vendored dictionary is Russian and leaves "mondays" alone.
func WeekdayIndex(tok string) (time.Weekday, bool) {
t := strings.ToLower(strings.TrimSpace(tok))
if n, ok := lexicon.WeekdayEnglish(t); ok {
return time.Weekday(n), true
}
for i, name := range lexicon.Weekdays() {
if morph.SameWord(t, name) {
return time.Weekday(i), true
}
}
return false
return 0, false
}
// isWeekday reports whether the token is a day of the week, when the caller
// does not need to know which one.
func isWeekday(tok string) bool {
_, ok := WeekdayIndex(tok)
return ok
}
// timeMarkers — the words that name a time on their own: the qualifiers that
@@ -113,11 +278,15 @@ var timeMarkers = buildTimeMarkers()
func buildTimeMarkers() map[string]bool {
m := map[string]bool{
"утра": true, "вечера": true, "дня": true, "ночи": true,
"часа": true, "часов": true, "час": true, "часу": true,
"минут": true, "минуты": true, "минуту": true,
"через": true, "полчаса": true, "сейчас": true,
"am": true, "pm": true, "noon": true, "midnight": true,
}
for _, w := range lexicon.HourUnits() {
m[w] = true
}
for _, w := range lexicon.MinuteUnits() {
m[w] = true
}
for _, w := range lexicon.PartsOfDay() {
m[w] = true
}
+86 -1
View File
@@ -1,6 +1,10 @@
package router
import "testing"
import (
"context"
"testing"
"time"
)
func TestMentionsTime(t *testing.T) {
for _, s := range []string{
@@ -21,6 +25,87 @@ func TestMentionsTime(t *testing.T) {
}
}
// TestNamesAnHour — the gate on the reminder's time slot (V-577, V-579). A day
// word is not an hour, and a sentence that names no hour is asked about rather
// than completed from the clock.
func TestNamesAnHour(t *testing.T) {
for _, s := range []string{
"в 11:00", "на 9", "в 9", "в девять", "в 9 утра", "завтра в 9",
"через час", "через двадцать минут", "в половине восьмого",
"без четверти восемь", "remind me at noon", "вечером",
} {
if !NamesAnHour(s) {
t.Errorf("NamesAnHour(%q) = false; this names an hour or an interval", s)
}
}
for _, s := range []string{
"на завтра", "что у меня сегодня?", "напомни завтра позвонить маме",
"в пятницу", "позвонить маме", "",
} {
if NamesAnHour(s) {
t.Errorf("NamesAnHour(%q) = true; no hour was spoken, so she has to ask", s)
}
}
}
// TestHourIsAmbiguous — the owner's rule of 2026-08-06. A bare hour is either
// half of the day and gets asked about; a qualifier, a written clock, an hour
// above twelve or an interval settles it and goes straight through.
func TestHourIsAmbiguous(t *testing.T) {
for _, s := range []string{
"напомни завтра в 3 заказать цветы", "в 9", "на 9", "в девять", "в 11 позвонить маме",
} {
if !HourIsAmbiguous(s) {
t.Errorf("HourIsAmbiguous(%q) = false; the hour could be either half of the day", s)
}
}
for _, s := range []string{
"напомни в 9 вечера разгрузить стиралку", "завтра в 15:00", "в 21", "в 11:00",
"через час", "через 10 минут", "remind me at noon", "напомни позвонить маме",
} {
if HourIsAmbiguous(s) {
t.Errorf("HourIsAmbiguous(%q) = true; this time reads only one way", s)
}
}
}
// TestNamesAnInterval — an interval resolves to one instant, so it answers the
// hour and the day at once and is never asked about.
func TestNamesAnInterval(t *testing.T) {
for _, s := range []string{"через час", "через 10 минут", "через полчаса", "in 30 minutes"} {
if !NamesAnInterval(s) {
t.Errorf("NamesAnInterval(%q) = false", s)
}
}
for _, s := range []string{"завтра в 15:00", "в 9 вечера", ""} {
if NamesAnInterval(s) {
t.Errorf("NamesAnInterval(%q) = true", s)
}
}
}
// TestReminderSlotRefusesAnHourNobodySaid — the same rule where it bites. The
// parser answers a bare day word with that day at the current minute, and the
// slot must stay empty so the daemon asks.
func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) {
now := time.Date(2026, 8, 6, 1, 38, 0, 0, time.UTC)
ex := Extractor{Time: clockEchoParser{}}
if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime {
t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04"))
}
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime {
t.Error("«на 9» names an hour and must still fill the slot")
}
}
// clockEchoParser stands in for what both real parsers do with a bare day word:
// it answers with the current time of day.
type clockEchoParser struct{}
func (clockEchoParser) Parse(_ context.Context, _ string, now time.Time) (time.Time, bool, error) {
return now.AddDate(0, 0, 1), true, nil
}
// A sentence with no time in it must not read as one, or a real follow-up stops
// inheriting the hour it meant.
func TestMentionsTimeIgnoresSentencesWithoutOne(t *testing.T) {
+44
View File
@@ -0,0 +1,44 @@
package router
import (
"testing"
"time"
)
// TestWeekdayIndexReplacesFourLists — four files kept a weekday list of their
// own and each was short in a different direction (V-581). The forms below are
// the ones at least one of those lists missed, so they are the point of having
// one matcher: the lexicon names the day and the dictionary answers the case.
func TestWeekdayIndexReplacesFourLists(t *testing.T) {
for _, tc := range []struct {
word string
want time.Weekday
}{
{"понедельник", time.Monday},
{"понедельникам", time.Monday},
{"понедельником", time.Monday},
{"вторник", time.Tuesday},
{"среда", time.Wednesday},
{"среду", time.Wednesday},
{"среде", time.Wednesday},
{"средам", time.Wednesday},
{"четверга", time.Thursday},
{"пятницу", time.Friday},
{"субботам", time.Saturday},
{"воскресеньях", time.Sunday},
{"Воскресенье", time.Sunday},
{"monday", time.Monday},
{"Fridays", time.Friday},
} {
got, ok := WeekdayIndex(tc.word)
if !ok || got != tc.want {
t.Errorf("WeekdayIndex(%q) = %v, %v; want %v, true", tc.word, got, ok, tc.want)
}
}
// A stem match said yes to all of these. A word match says no.
for _, w := range []string{"среди", "средство", "средний", "среднем", "субботник", "", "через"} {
if _, ok := WeekdayIndex(w); ok {
t.Errorf("WeekdayIndex(%q) claimed a weekday", w)
}
}
}
+11 -2
View File
@@ -38,13 +38,22 @@ type Routine struct {
}
// Validate reports the first structural problem with a routine set: a missing
// name/cron/body or an unparseable cron expression. Called at config load so a
// typo surfaces at startup, not as a silently-never-firing routine at runtime.
// name/cron/body, a duplicate name or an unparseable cron expression. Called at
// config load so a typo surfaces at startup, not as a silently-never-firing
// routine at runtime.
//
// Names must be unique because Due keys its last-fired map by name. Two
// routines sharing one would take turns being suppressed by each other's fire.
func Validate(routines []Routine) error {
seen := make(map[string]bool, len(routines))
for _, r := range routines {
if r.Name == "" {
return fmt.Errorf("routine: name is required")
}
if seen[r.Name] {
return fmt.Errorf("routine %q: duplicate name", r.Name)
}
seen[r.Name] = true
if r.Body == "" {
return fmt.Errorf("routine %q: body is required", r.Name)
}
+6
View File
@@ -60,6 +60,12 @@ func TestValidate(t *testing.T) {
}
})
}
// Due keys its last-fired map by name, so two routines sharing one would
// take turns being suppressed by the other's fire.
if err := Validate(append(ok, ok[0])); err == nil {
t.Error("expected an error for a duplicate name, got nil")
}
}
func TestDue(t *testing.T) {
+3 -2
View File
@@ -173,8 +173,9 @@ var (
)
// PlainText strips markup and decodes entities — feed summaries are HTML, and
// what reaches a note (and possibly the TTS) must be text. Exported because the
// crawler's extractor needs exactly this on a bigger input.
// what reaches a note (and possibly the TTS) must be text. Exported so a caller
// holding raw feed markup can reduce it the same way; crawl/extract.go does the
// bigger job on a whole document and does not go through here.
func PlainText(s string) string {
s = scriptRE.ReplaceAllString(s, " ")
s = tagRE.ReplaceAllString(s, " ")
+4 -1
View File
@@ -1,6 +1,7 @@
package rss
import (
"bytes"
"context"
"fmt"
"log"
@@ -164,7 +165,9 @@ func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int
if err != nil {
return 0, err
}
feed, err := Parse(strings.NewReader(string(body.Bytes)))
// bytes.NewReader and not strings.NewReader(string(…)): the latter copied a
// feed document that can run to a megabyte, for nothing.
feed, err := Parse(bytes.NewReader(body.Bytes))
if err != nil {
return 0, err
}
+2 -1
View File
@@ -141,7 +141,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
{StallSitting, "{n}"}, {StallSitting, "{days}"},
{StallSitting, "{n}"}, {StallSitting, "{word}"},
{StallSitting, "{days}"}, {StallSitting, "{dayword}"},
{StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"},
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
+6 -5
View File
@@ -295,14 +295,15 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
return nil, fmt.Errorf("smarthome: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The status only, and read before the body: an error page can carry the
// instance's own detail, and there is no reason to pull it into memory to
// discard it.
return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
}
out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, fmt.Errorf("smarthome: read %s: %w", path, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The body of an error can contain the instance's own detail; the token
// never appears in it, but keep it to one line anyway.
return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
}
return out, nil
}
+2 -2
View File
@@ -75,8 +75,8 @@ func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error)
if !a.Format.IsValid() {
return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
}
if seconds(a) < r.minSec {
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), r.minSec)
if sec := seconds(a); sec < r.minSec {
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, sec, r.minSec)
}
vec, err := r.embed(ctx, a)
if err != nil {
+4 -6
View File
@@ -55,17 +55,15 @@ func (s *Store) LastSent(ctx context.Context, key string) (time.Time, error) {
// alarm (voice acknowledgment, Telegram callback, etc.).
func (s *Store) MarkAcked(ctx context.Context, key string) error {
now := time.Now()
res, err := s.db.ExecContext(ctx,
// No pending nudges is not an error — already acked or never sent — so the
// rows-affected count is not read at all: every outcome below this line is
// the same nil.
_, err := s.db.ExecContext(ctx,
`UPDATE nudges SET outcome = 'acted', outcome_ts = ?
WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`,
now.UnixMilli(), key)
if err != nil {
return fmt.Errorf("mark acked %s: %w", key, err)
}
n, _ := res.RowsAffected()
if n == 0 {
// no pending nudges — already acked or never sent; not an error.
return nil
}
return nil
}
+5 -20
View File
@@ -2,7 +2,6 @@ package store
import (
"context"
"encoding/json"
"fmt"
"time"
)
@@ -103,30 +102,16 @@ func (s *Store) ReembedAll(ctx context.Context, currentID string, embed EmbedFun
id, text, kind string
}
var vecs []vecRow
rows, err = tx.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
memRows, err := allMemVectorMetas(ctx, tx)
if err != nil {
return res, fmt.Errorf("reembed: read memory vectors: %w", err)
return res, fmt.Errorf("reembed: %w", err)
}
for rows.Next() {
var id, metaJSON string
if err := rows.Scan(&id, &metaJSON); err != nil {
rows.Close()
return res, fmt.Errorf("reembed: memory row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
rows.Close()
return res, fmt.Errorf("reembed: meta for %q: %w", id, err)
}
if meta["text"] == "" {
for _, v := range memRows {
if v.Meta["text"] == "" {
res.NoText++
continue
}
vecs = append(vecs, vecRow{id: id, text: meta["text"], kind: meta["type"]})
}
rows.Close()
if err := rows.Err(); err != nil {
return res, fmt.Errorf("reembed: memory vectors: %w", err)
vecs = append(vecs, vecRow{id: v.ID, text: v.Meta["text"], kind: v.Meta["type"]})
}
for _, v := range vecs {
+6 -20
View File
@@ -64,9 +64,9 @@ func (s *Store) RepairFactVectors(ctx context.Context, embed EmbedFunc) (FactVec
return res, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
memRows, err := allMemVectorMetas(ctx, s.db)
if err != nil {
return res, fmt.Errorf("repair fact vectors: read: %w", err)
return res, fmt.Errorf("repair fact vectors: %w", err)
}
type factVec struct {
id, key string
@@ -75,33 +75,19 @@ func (s *Store) RepairFactVectors(ctx context.Context, embed EmbedFunc) (FactVec
}
var vecs []factVec
newest := map[string]int64{} // key → newest ts seen for it
for rows.Next() {
var id, metaJSON string
if err := rows.Scan(&id, &metaJSON); err != nil {
rows.Close()
return res, fmt.Errorf("repair fact vectors: row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
rows.Close()
return res, fmt.Errorf("repair fact vectors: meta for %q: %w", id, err)
}
if meta["type"] != "fact" {
for _, v := range memRows {
if v.Meta["type"] != "fact" {
continue
}
key, ts, ok := splitFactVectorID(id)
key, ts, ok := splitFactVectorID(v.ID)
if !ok {
continue
}
vecs = append(vecs, factVec{id: id, key: key, meta: meta, ts: ts})
vecs = append(vecs, factVec{id: v.ID, key: key, meta: v.Meta, ts: ts})
if ts > newest[key] {
newest[key] = ts
}
}
rows.Close()
if err := rows.Err(); err != nil {
return res, fmt.Errorf("repair fact vectors: rows: %w", err)
}
for _, v := range vecs {
drop := v.ts < newest[v.key]
+43
View File
@@ -169,6 +169,49 @@ func (m *MemoryStore) DeletePrefix(ctx context.Context, prefix string) (int64, e
return n, nil
}
// memVectorRow is one memory_vectors row with its meta blob decoded — the
// shape both ReembedAll (backfill.go) and RepairFactVectors (factvectors.go)
// read the whole table as, before each decides what to do with a row on its
// own terms (one keys off meta["text"], the other off meta["type"] and the
// id's embedded key/timestamp). Query-then-scan was duplicated across the two
// before this, id-for-id.
type memVectorRow struct {
ID string
Meta map[string]string
}
// queryContexter is the common surface *sql.DB and *sql.Tx share that
// allMemVectorMetas needs. ReembedAll reads inside a transaction so its
// migration is atomic; RepairFactVectors reads directly off the db handle.
type queryContexter interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// allMemVectorMetas reads every memory_vectors row and decodes its meta blob.
func allMemVectorMetas(ctx context.Context, q queryContexter) ([]memVectorRow, error) {
rows, err := q.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
if err != nil {
return nil, fmt.Errorf("read memory vectors: %w", err)
}
defer rows.Close()
var out []memVectorRow
for rows.Next() {
var id, metaJSON string
if err := rows.Scan(&id, &metaJSON); err != nil {
return nil, fmt.Errorf("memory vector row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
return nil, fmt.Errorf("meta for %q: %w", id, err)
}
out = append(out, memVectorRow{ID: id, Meta: meta})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory vectors: %w", err)
}
return out, nil
}
// escapeLike neutralises the LIKE wildcards in a literal prefix.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
+4 -1
View File
@@ -187,7 +187,10 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Tim
if err != nil {
return fmt.Errorf("accept proposed routine: %w", err)
}
n, _ := res.RowsAffected()
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("accept proposed routine: rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: id=%d not in 'proposed' status", ErrProposedRoutineNotFound, id)
}
+16 -16
View File
@@ -208,14 +208,7 @@ const SpokenLimit = 5
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
// one order and shows another.
func FormatRU(ranked []Ranked) string {
var open, cands []Ranked
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
open, cands := split(ranked)
if len(open) == 0 && len(cands) == 0 {
return say.S(say.TasksNone, nil)
}
@@ -243,6 +236,20 @@ func FormatRU(ranked []Ranked) string {
return b.String()
}
// split separates confirmed work from candidates, preserving Rank's order
// within each half. FormatRU and Spoken have to agree on where the line falls,
// so they read it from one place.
func split(ranked []Ranked) (open, cands []Ranked) {
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
return open, cands
}
// joinRU lists up to limit tasks, then says how many are left. withReasons
// attaches the parenthesised reason — candidates are listed bare, since their
// due dates are Maven's reading of a mail and not something he stated.
@@ -273,14 +280,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
// an ordinal resolves against is built here and not by a caller guessing how
// the renderer split and truncated it.
func Spoken(ranked []Ranked) []Ranked {
var open, cands []Ranked
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
open, cands := split(ranked)
out := make([]Ranked, 0, 2*SpokenLimit)
for _, group := range [][]Ranked{open, cands} {
if len(group) > SpokenLimit {
+8 -2
View File
@@ -56,10 +56,16 @@ func Stalls(items []Item, now time.Time) []Stall {
// meant to act on.
continue
}
if it.Due != nil && it.Due.Before(now) {
// Whole calendar days, the reading Rank already uses. An instant
// comparison calls a task due at 18:00 overdue from 18:01, so the count
// above the table would say "просрочено" beside a row whose own reason
// still said "сегодня".
if it.Due != nil && dayDelta(*it.Due, now) < 0 {
overdue++
}
if now.Sub(it.Created) >= StallDays*24*time.Hour {
// A row with no capture time has not sat for anything. Without the
// guard its zero time is January of year 1 and it always counts.
if !it.Created.IsZero() && now.Sub(it.Created) >= StallDays*24*time.Hour {
sitting++
}
}
+21
View File
@@ -54,6 +54,27 @@ func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) {
}
}
func TestStallsCountsOverdueByCalendarDay(t *testing.T) {
// Same reading as Rank, or the count above the table contradicts the reason
// in the row: due at 09:00, asked at 12:00, and it is still due today.
now := stallNow()
earlier := now.Add(-3 * time.Hour)
items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-time.Hour), Due: &earlier}}
if got := Stalls(items, now); len(got) != 0 {
t.Fatalf("shapes = %+v, want none — a task due today is not overdue", got)
}
}
func TestStallsIgnoresATaskWithNoCaptureTime(t *testing.T) {
// The zero time is January of year 1, so an unstamped row would count as
// sitting forever.
now := stallNow()
items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen}}
if got := Stalls(items, now); len(got) != 0 {
t.Fatalf("shapes = %+v, want none", got)
}
}
func TestStallsCountsNoJudgement(t *testing.T) {
// The line this shape may not cross. Every sentence states a count; none of
// them says whether the work matters or should be dropped.
+6 -4
View File
@@ -62,9 +62,10 @@ func capSegment(s string) string {
// the tool name when the argv carries no second word.
func CapabilityOf(t ipc.Tool) Capability {
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
domain := entityID
if i := strings.Index(entityID, "."); i > 0 {
domain = entityID[:i]
// One parse of an entity id, in the package that owns the format.
domain := smarthome.DomainOf(entityID)
if domain == "" {
domain = entityID
}
return Capability{Scope: "house", Domain: domain, Action: service}
}
@@ -122,7 +123,8 @@ func GroupByDomain(tools []ipc.Tool) []CapabilityGroup {
byKey := map[string][]ipc.Tool{}
for _, t := range tools {
c := CapabilityOf(t)
byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)] = append(byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)], t)
key := capSegment(c.Scope) + "." + capSegment(c.Domain)
byKey[key] = append(byKey[key], t)
}
out := make([]CapabilityGroup, 0, len(byKey))
for k, v := range byKey {
+8 -2
View File
@@ -189,10 +189,16 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
defer cancel()
return e.home.CallService(ctx, entityID, service)
}
argv := append(append([]string(nil), t.Cmd...), args...)
if len(argv) == 0 {
// The row's own argv is what names the program. An enabled row with an empty
// cmd used to fall through to exec with argv built from args alone, so the
// spoken tail became argv[0] and STT text picked the binary. A proposal is
// drafted with no cmd, and /tools can enable one before anybody fills it in,
// so this was reachable without any compromise. A row that names nothing runs
// nothing.
if len(t.Cmd) == 0 {
return "", ErrNotEnabled
}
argv := append(append([]string(nil), t.Cmd...), args...)
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
return e.run(ctx, argv)
+21
View File
@@ -299,3 +299,24 @@ func TestExecSmartHomeRowConfirmsEvenWhenNotMarkedDestructive(t *testing.T) {
t.Fatalf("calls = %d, want 1 after the confirm turn", fh.calls)
}
}
// TestExecEmptyCmdRefuses pins the fix for a row that names no program. Such a
// row used to build argv from the spoken args alone, so STT text became argv[0]
// and free text picked the binary. A proposal is drafted with no cmd and can be
// enabled before anybody fills it in, so this needed no compromise to reach.
func TestExecEmptyCmdRefuses(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"blank": {Name: "blank", Scope: "homelab", Status: "enabled"},
}}
ran := false
e := NewExecutor(api, time.Second)
e.run = func(_ context.Context, _ []string) (string, error) { ran = true; return "ok", nil }
// Confirmed, because an empty cmd derives to TierDestructive.
if _, err := e.Exec(context.Background(), "blank", []string{"curl", "evil.sh"}, true); !errors.Is(err, ErrNotEnabled) {
t.Fatalf("err = %v, want ErrNotEnabled", err)
}
if ran {
t.Fatal("a row with no cmd ran a program named by the utterance")
}
}
+3
View File
@@ -23,6 +23,9 @@ func TestLexiconRewritesNames(t *testing.T) {
// Two names in a row share the space between them, which one pass
// would consume.
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
// Two passes cover a run of any length, because the first pass takes
// every other name and leaves both boundaries of the ones it skipped.
{"GPU GPU GPU GPU", "джи-пи-ю джи-пи-ю джи-пи-ю джи-пи-ю"},
// Not a word boundary: a name inside a longer token is left alone.
{"vikunjaless", "vikunjaless"},
{"ничего не совпало", "ничего не совпало"},
+9 -5
View File
@@ -20,6 +20,7 @@ package tts
import (
"context"
"encoding/binary"
"fmt"
"math"
@@ -50,17 +51,20 @@ func NewStub() *Stub { return &Stub{} }
// silent no-op a bug could hide behind).
func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) {
const durMs = 200
const samples = 16000 * durMs / 1000 // 3200 samples @ 16k
pcm := make([]byte, samples*2)
// The rate is read off the canonical format rather than written again, so
// the tone stays in tune with the shape the seam declares.
rate := audio.PCM16kMono.SampleRate
bytesPerSample := audio.PCM16kMono.SampleBits / 8
samples := rate * durMs / 1000
pcm := make([]byte, samples*bytesPerSample)
freq := 220.0 // A3
if len(text) > 0 {
freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band
}
for i := 0; i < samples; i++ {
t := float64(i) / 16000.0
t := float64(i) / float64(rate)
v := int16(12000 * math.Sin(2*math.Pi*freq*t))
pcm[i*2] = byte(v)
pcm[i*2+1] = byte(v >> 8)
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v))
}
return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil
}
+16 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/say"
)
// The month names are a closed class and live in internal/lexicon, 1-indexed,
@@ -32,7 +33,7 @@ func Speakable(s string) string {
})
s = reTime.ReplaceAllStringFunc(s, func(m string) string {
p := reTime.FindStringSubmatch(m)
return p[1] + " часов " + p[2] + " минут"
return spokenTime(mustInt(p[1]), mustInt(p[2]))
})
s = reDots.ReplaceAllStringFunc(s, func(m string) string {
return strings.Join(strings.Split(m, "."), " точка ")
@@ -46,6 +47,20 @@ func Speakable(s string) string {
return Pronounce(s)
}
// spokenTime reads a clock time the way it is said rather than the way it is
// written. Two things the written form gets wrong out loud. The noun after a
// numeral inflects, so 21:00 is "час" and 22:00 is "часа", where the old
// rewrite said "часов" for every hour and "минут" for every minute. And a
// leading zero is punctuation, not a word: 14:00 is "14 часов" and 9:05 is
// "9 часов 5 минут", never "00 минут" or "05 минут".
func spokenTime(h, m int) string {
out := strconv.Itoa(h) + " " + say.CountWord(h, "час", "часа", "часов")
if m == 0 {
return out
}
return out + " " + strconv.Itoa(m) + " " + say.CountWord(m, "минута", "минуты", "минут")
}
func spokenDate(dd, mm, yyyy string) string {
mi, _ := strconv.Atoi(mm)
if mi < 1 || mi > 12 {
+5 -2
View File
@@ -6,8 +6,11 @@ func TestSpeakable(t *testing.T) {
cases := []struct{ in, want string }{
{"напомню 10.07.2026", "напомню 10 июля 2026"},
{"срок 01.01", "срок 1 января"},
{"встреча в 14:00", "встреча в 14 часов 00 минут"},
{"в 9:05 подъём", "в 9 часов 05 минут подъём"},
{"встреча в 14:00", "встреча в 14 часов"},
{"в 9:05 подъём", "в 9 часов 5 минут подъём"},
{"в 21:00 отбой", "в 21 час отбой"},
{"в 22:02 отбой", "в 22 часа 2 минуты отбой"},
{"в 1:01 проснулся", "в 1 час 1 минута проснулся"},
{"это 3.2.1 версия", "это 3 точка 2 точка 1 версия"},
{"без чисел", "без чисел"},
}
+4 -7
View File
@@ -67,13 +67,10 @@ func (s *StubReplier) Reply(d router.Decision) string {
case router.IntentReminder:
return phraser.Ack(phraser.AckReminder, nil)
case router.IntentFact:
if d.Slots.HasKey {
if d.Slots.Value != "" {
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
}
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
}
return phraser.Ack(phraser.AckFact, nil)
// His words, not the key the parser filed them under (V-592). The key
// is machine vocabulary — "water", "meal" — and reading it back was
// never a confirmation he could check.
return phraser.FactAck(d.Utterance)
case router.IntentNote:
return phraser.Ack(phraser.AckNote, nil)
case router.IntentQuery:
+13 -1
View File
@@ -13,13 +13,19 @@ import (
"github.com/kami/maven/internal/morph"
)
// clientTimeout — the whole request, geocode or forecast. Both are one call to
// a public API over the internet rather than a LAN service, hence longer than
// a bare "it's slow" budget; there is no retry behind it, so a slow reply
// still costs the caller the whole wait.
const clientTimeout = 10 * time.Second
type OpenMeteoProvider struct {
httpClient *http.Client
}
func NewOpenMeteoProvider() *OpenMeteoProvider {
return &OpenMeteoProvider{
httpClient: &http.Client{Timeout: 10 * time.Second},
httpClient: &http.Client{Timeout: clientTimeout},
}
}
@@ -77,6 +83,9 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string)
return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Weather{}, fmt.Errorf("open-meteo forecast: http %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -186,6 +195,9 @@ func (p *OpenMeteoProvider) geocodeOne(ctx context.Context, location string) (la
return 0, 0, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, 0, "", fmt.Errorf("open-meteo geocode: http %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
+83
View File
@@ -3,9 +3,11 @@ package weather
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -76,6 +78,87 @@ func (t *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) {
return http.DefaultTransport.RoundTrip(req)
}
// TestOpenMeteoProvider_ForecastHTTPError — a non-200 forecast reply must not
// decode into a zero-value Weather{Temperature: 0, Condition: "неизвестно"}
// reported as success (Vikunja #589). It must be a distinct error, not
// ErrLocationUnknown, so the caller does not blame the owner's city.
func TestOpenMeteoProvider_ForecastHTTPError(t *testing.T) {
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/search" {
json.NewEncoder(w).Encode(geoResponse{
Results: []geoResult{{Name: "Moscow", Latitude: 55.7558, Longitude: 37.6173, Country: "Russia"}},
})
return
}
w.WriteHeader(http.StatusInternalServerError)
}))
defer mock.Close()
p := NewOpenMeteoProvider()
p.SetClient(&http.Client{Transport: &mockTransport{
geoURL: mock.URL + "/v1/search",
forecastURL: mock.URL + "/v1/forecast",
}})
w, err := p.CurrentWeather(context.Background(), "Moscow")
if err == nil {
t.Fatalf("CurrentWeather: want error, got Weather{%+v}", w)
}
if errors.Is(err, ErrLocationUnknown) {
t.Fatalf("CurrentWeather: want a service error, got ErrLocationUnknown: %v", err)
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("CurrentWeather: error %q does not name the status", err.Error())
}
}
// TestOpenMeteoProvider_GeocodeHTTPError — a non-200 geocode reply must not be
// read as "no such city" (ErrLocationUnknown). A 500 is a service outage, and
// the owner hears a different sentence for each (Vikunja #589).
func TestOpenMeteoProvider_GeocodeHTTPError(t *testing.T) {
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer mock.Close()
p := NewOpenMeteoProvider()
p.SetClient(&http.Client{Transport: &mockTransport{
geoURL: mock.URL + "/v1/search",
forecastURL: mock.URL + "/v1/forecast",
}})
_, err := p.CurrentWeather(context.Background(), "Уфе")
if err == nil {
t.Fatal("CurrentWeather: want error")
}
if errors.Is(err, ErrLocationUnknown) {
t.Fatalf("CurrentWeather: want a service error, got ErrLocationUnknown: %v", err)
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("CurrentWeather: error %q does not name the status", err.Error())
}
}
// TestOpenMeteoProvider_GeocodeEmptyResult — a genuine 200 reply with no
// results is still ErrLocationUnknown, distinct from a service failure.
func TestOpenMeteoProvider_GeocodeEmptyResult(t *testing.T) {
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(geoResponse{Results: nil})
}))
defer mock.Close()
p := NewOpenMeteoProvider()
p.SetClient(&http.Client{Transport: &mockTransport{
geoURL: mock.URL + "/v1/search",
forecastURL: mock.URL + "/v1/forecast",
}})
_, err := p.CurrentWeather(context.Background(), "Атлантида")
if !errors.Is(err, ErrLocationUnknown) {
t.Fatalf("CurrentWeather: want ErrLocationUnknown, got %v", err)
}
}
func TestStubProvider(t *testing.T) {
p := NewStubProvider()
_, err := p.CurrentWeather(context.Background(), "Moscow")

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