Compare commits

...

28 Commits

Author SHA1 Message Date
claude 4f6dec0cf2 gofmt mcp_test.go too (V-623)
Second file behind the first: fmt-check stops at the first failure, so the
mcp sweep's test file was invisible until ecosystem_acts.go was clean.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:47:27 +04:00
claude 0445693a16 Merge remote-tracking branch 'origin/master' 2026-08-06 11:43:42 +04:00
kami c7d22858ba Merge pull request 'media store: a failed write leaks its budget reservation' (#173) from task/584-media-store-a-failed-write-leaks-its-bud into master
Reviewed-on: #173
2026-08-06 09:41:31 +02:00
claude 12ecc30c57 Merge the ecosystem sweep: the wrong item, and an unjoined authorisation (#264)
Seven of the eight non-negotiable rules hold and were checked one by
one. The eighth, one correlation id per action, was violated across the
confirm boundary.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No floor line violates the persona today.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

wg's stderr was dropped by Output(), leaving 'exit status 1' in the log
where the real cause is a missing CAP_NET_ADMIN or a bad interface.
2026-08-06 04:03:48 +04:00
41 changed files with 1785 additions and 59 deletions
+17 -4
View File
@@ -235,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
//
// Read-only by construction — the plan is assembled and rendered core-side and
// nothing here schedules or announces. "что дальше?" asks for the rest of the
// day, so that phrasing trims what has already passed.
// day, so that phrasing trims what has already passed and reads only the next
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
// nothing, and she read 43 entries aloud in one sentence (V-618).
//
// "что у меня сегодня?" is a different question and is not narrowed here — it
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
// answers the whole day.
//
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
// surface: Maven holds the work board, runs the intake form, never argues").
@@ -254,16 +260,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
}
// Rebuild the pure plan so the rest-of-day rendering is the same code that
// rendered the whole day — one formatter, one persona.
p := morning.Plan{Date: plan.Date}
//
// The instants are put back in the asking clock's zone on the way in. They
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
// hours in the plan's own frame, so setting that frame here is what makes
// the recital name his clock rather than the store's (V-614).
zone := h.now().Location()
p := morning.Plan{Date: plan.Date.In(zone)}
for _, it := range plan.Items {
p.Items = append(p.Items, morning.PlanEntry{
At: it.At,
At: it.At.In(zone),
Text: it.Text,
Kind: morning.PlanKind(it.Kind),
Uncertain: it.Uncertain,
})
}
return p.After(h.now()).FormatRU(), true
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
}
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
+4 -3
View File
@@ -19,9 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
// time wasn't parsed. Run the parser as a fallback.
if dec.Stage == 0 && h.timeParser != nil {
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
// Same gate as the extractor (V-577, V-579): a request that named
// no hour gets asked about, never completed from the clock.
if err == nil && ok && router.NamesAnHour(dec.Utterance) {
// Same gate as the extractor (V-577, V-579, V-610): a request whose
// hour was not spoken, or was spoken and not read, gets asked about
// and is never completed from the clock.
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
dec.Slots.Time = t
dec.Slots.HasTime = true
}
+13 -1
View File
@@ -24,6 +24,14 @@ type pendingHexisExec struct {
entityID string
displayName string
expiry time.Time
// correlationID — the id the proposing turn minted for this action. A
// confirm arrives on a later turn with a context of its own, so without
// carrying it here the execution recorded a fresh id and no causation at
// all, and the resolve, the discovery and the thing they authorised sat in
// the trace as unrelated calls. The contract mints one id per action, and
// the action began when she asked.
correlationID string
}
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
@@ -144,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
return hx != nil && !h.now().After(hx.expiry)
},
yes: func() string {
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
execCtx := ctx
if hx.correlationID != "" {
execCtx = withCorrelationID(execCtx, hx.correlationID)
}
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
},
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
},
+84
View File
@@ -4,12 +4,14 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
}
}
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
// instants on it carry the zone the core read them in — a calendar fact's Ts
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
// the store's clock instead of his (V-614). The asking clock is three hours off
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
h := &reactiveHandler{
api: &planAPI{plan: ipc.DayPlan{
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
}},
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
}
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
}
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
}
}
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
// nothing, because the whole day is still ahead. She read 43 entries aloud as
// one sentence. The zone is three hours off UTC so the test also fails under
// TZ=UTC if the rendering ever slips zones.
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
zone := time.FixedZone("MSK", 3*60*60)
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
for i := 0; i < 43; i++ {
plan.Items = append(plan.Items, ipc.DayPlanItem{
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
Text: fmt.Sprintf("пункт %d", i),
Kind: "event",
})
}
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
}}
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
})
if !ok {
t.Fatal("expected the plan source to claim it")
}
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
}
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
t.Errorf("the next thing is not first: %q", reply)
}
// The rest is counted, not silently dropped.
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
t.Errorf("the sentence hides that the day goes on: %q", reply)
}
}
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
// plan word, so the plan source declines it and the calendar listing answers —
// asserted here beside the cap so the two questions cannot drift together.
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
if router.IsDayPlanQuery("что у меня сегодня?") {
t.Error("the plan source claims the whole-day question")
}
if !router.IsDayPlanQuery("что дальше?") {
t.Error("the plan source stopped claiming the rest-of-day question")
}
if router.IsRestOfDayQuery("какие планы на сегодня?") {
t.Error("the whole-day plan question got narrowed to the rest of the day")
}
}
// A question that is not about the plan must fall through, or the plan buries
// the calendar listing and the weather behind it.
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
+17 -4
View File
@@ -139,9 +139,9 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
// an item id from the value slot, call one Praxis endpoint, trace the result.
type praxisItemAction struct {
verbs []string
ask string // reply when no item id was given
op string // trace + log name of the operation
verbs []string
ask string // reply when no item id was given
op string // trace + log name of the operation
// failure is the first half of the reply when the Praxis call errors: which
// operation did not happen. ecosystemGap supplies the second half, which
// names Praxis and splits a refused token from an outage — those two used to
@@ -346,14 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
})
var parts []string
var spoken []string
for _, item := range items {
title, _ := item["title"].(string)
if title == "" {
continue
}
parts = append(parts, title)
surfaceSpoken(ctx, px, item)
if id := surfaceSpoken(ctx, px, item); id != "" {
spoken = append(spoken, id)
}
}
// The scoped digest is a list she read out, so it replaces the positional
// memory exactly as the unscoped one does. It used to surface these items
// and remember none of them, which left the previous digest live: "отметь
// второй как сделанное" then indexed into a list he had not just heard and
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
// transitions the wrong item).
h.rememberSurfaced(spoken)
if known := h.localFactsForEntity(ctx, entityID); known != "" {
parts = append(parts, known)
}
@@ -768,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
entityID: entityID,
displayName: displayName,
expiry: h.now().Add(confirmTTL),
// This action's id, so the execution the confirm authorises is
// joined to the resolve and the discovery that proposed it.
correlationID: correlationIDFromCtx(ctx),
}
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
+80
View File
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
t.Fatalf("expected success once nexus recovers, got %q", reply)
}
}
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
// she read out, so a positional follow-up must land on one of ITS items. It
// surfaced them and remembered none, which left the previous digest live and
// sent "отметь второй" at somebody else's item.
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
scoped := fixturePraxisAttentionScoped("ent_muzick",
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
praxis := newFakePraxis(t, scoped)
h := ecoHandler(t, nexus, praxis, nil)
// A digest from an earlier turn, still the positional memory.
h.rememberSurfaced([]string{"item_stale"})
reply := h.handlePraxisAct(ctx, router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
})
if !strings.Contains(reply, "indexer wedged") {
t.Fatalf("expected the scoped item to be read out, got %q", reply)
}
h.mu.Lock()
surfaced := append([]string(nil), h.surfacedItems...)
h.mu.Unlock()
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
}
// The follow-up resolves against what he just heard, not the stale list.
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
t.Fatal("positional follow-up should have been claimed by praxis")
}
var body string
for _, r := range praxis.Requests() {
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
body = string(r.Body)
}
}
if !strings.Contains(body, "item_scoped_1") {
t.Fatalf("resolve must transition the item she read out, posted %q", body)
}
if strings.Contains(body, "item_stale") {
t.Fatal("resolve transitioned an item from a previous digest")
}
}
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
// later turn with a context of its own. The contract mints one id per action,
// so the execution it authorises must still be joinable to the resolve and the
// discovery that proposed it — it recorded a fresh id and no causation at all.
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
}
resolve := findTrace(t, h, "nexus", "resolve")
if resolve == nil || resolve.CorrelationID == "" {
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
}
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
t.Fatal("confirm should have been claimed")
}
exec := findTrace(t, h, "hexis", "execute")
if exec == nil {
t.Fatal("expected a hexis execute trace")
}
if exec.CausationID != resolve.CorrelationID {
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
exec.CausationID, resolve.CorrelationID)
}
}
+5
View File
@@ -154,6 +154,11 @@ func hasDurationWords(u string) bool {
// Used by the query handler when answering "когда я это сделал?"-style questions.
func formatTime(t time.Time) string {
now := time.Now()
// The argument is a fact's Ts, which the store hands back as UTC. Only the
// last branch names a wall clock, and it named the store's until V-614: an
// answer to "когда я это сделал?" read hours off, in the same sentence
// shape the plan reads a day in.
t = t.Local()
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
return "только что"
}
+23 -1
View File
@@ -1,6 +1,28 @@
package main
import "testing"
import (
"strings"
"testing"
"time"
)
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
// UTC, and the branch that names a wall clock printed it in whatever zone it
// arrived in (V-614). The instant here is built three hours off this machine's
// zone, so the assertion holds under TZ=UTC as well.
func TestFormatTimeReadsHisClock(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Now().Add(-72 * time.Hour).In(away)
got := formatTime(stored)
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
}
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
}
}
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
// weekday half of this guard was a list of stems matched with strings.Contains,
+34 -1
View File
@@ -77,6 +77,21 @@ func run(args []string) error {
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
}
// A bad duration or an empty -wg-cmd used to get past start and kill the
// poller on the first tick — time.NewTicker panics on a non-positive
// interval, and pollWg indexed field 0 of an empty command. A zero -timeout
// is worse than a crash: http.Client reads it as "no deadline", so one
// wedged source stalls every other source behind it forever. Refuse all
// three here, where the operator sees the message.
if *interval <= 0 {
return fmt.Errorf("-interval must be positive, got %s", *interval)
}
if *timeout <= 0 {
return fmt.Errorf("-timeout must be positive, got %s", *timeout)
}
if *wgIface != "" && strings.TrimSpace(*wgCmd) == "" {
return fmt.Errorf("-wg-cmd is empty but -wg is set")
}
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
if err != nil {
@@ -283,9 +298,19 @@ const (
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
func (p *poller) pollWg(ctx context.Context) error {
fields := strings.Fields(p.wgCmd)
if len(fields) == 0 {
return fmt.Errorf("wg command is empty")
}
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
if err != nil {
// wg says why it refused on stderr — usually a missing CAP_NET_ADMIN or
// an interface that does not exist. Output() drops that, leaving a log
// line that reads "exit status 1" and diagnoses nothing.
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return fmt.Errorf("run %s: %w: %s", p.wgCmd, err, strings.TrimSpace(string(ee.Stderr)))
}
return fmt.Errorf("run %s: %w", p.wgCmd, err)
}
maxTs := parseMaxHandshake(string(out))
@@ -552,6 +577,11 @@ func isNoFact(err error) bool {
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
//
// Hitting the cap is an error, not a shorter body. A truncated kuma page parses
// cleanly right up to the cut, and every monitor past it reads as deleted — the
// poller would write "unknown" over live services and the down-rule would go
// quiet. Reading one byte past the cap is how we tell full from truncated.
const maxBodyBytes = 4 << 20
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
@@ -567,12 +597,15 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
if len(body) > maxBodyBytes {
return nil, fmt.Errorf("GET %s: body over %d bytes", url, maxBodyBytes)
}
return body, nil
}
+57
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -212,3 +213,59 @@ func TestRunRequiresSomethingToPoll(t *testing.T) {
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
}
}
// A flag value that would kill the poller later is refused at start, before it
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
// zero timeout means http.Client waits forever, and an empty wg command used to
// index field 0 of an empty slice.
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
err := run(args)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("err = %v, want %q", err, c.want)
}
})
}
}
// pollWg refuses an empty command rather than panicking on fields[0].
func TestPollWgEmptyCommand(t *testing.T) {
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
if err := p.pollWg(context.Background()); err == nil {
t.Error("want an error, got a poll that ran something")
}
}
// A body at the cap is a truncated body, and a truncated kuma page reads as
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
func TestGetRefusesTruncatedBody(t *testing.T) {
big := strings.Repeat("x", maxBodyBytes+64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, big)
}))
defer srv.Close()
p := &poller{http: srv.Client()}
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
t.Error("want an over-size refusal, got a silently truncated body")
}
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "ok")
}))
defer small.Close()
body, err := p.get(context.Background(), small.URL, "")
if err != nil || string(body) != "ok" {
t.Errorf("get = %q, %v; want the whole small body", body, err)
}
}
+7 -2
View File
@@ -14,8 +14,13 @@ memory only, so a restart empties this.</div>
<div class=scroll><table class=mono>
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
{{range .Events}}<tr>
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
is the bus's local instant, OccurredAt is whatever zone the source used —
the store hands back UTC and internal/rss parses a pubDate to UTC — so
rendering them raw put two zones side by side in the same row and made a
feed item look hours older than it was. -->
<td>{{.NoticedAt.Local.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.OccurredAt.Local.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.Source}}</td>
<td class=gray>{{.Kind}}</td>
<td class=gray>{{.Priority}}</td>
+34 -1
View File
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
if !strings.Contains(body, want) {
t.Errorf("page does not mention %q", want)
}
@@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) {
}
}
// awayFromLocal returns a zone three hours off whatever this machine runs in,
// so a test can tell "rendered in his clock" apart from "rendered in whatever
// zone the value arrived in" without depending on TZ.
func awayFromLocal() *time.Location {
_, off := time.Now().Zone()
return time.FixedZone("away", off+3*60*60)
}
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
// OccurredAt carries the source's zone — the store hands back UTC and
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
// instant. Rendered raw, the two columns of one row were in two zones and a
// feed item read hours older than it was.
away := awayFromLocal()
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
noticed := occurred.Add(2 * time.Minute)
core := &eventsCore{events: []ipc.IntakeEvent{{
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
OccurredAt: occurred, NoticedAt: noticed,
}}}
body := getEvents(t, core).Body.String()
const layout = "02.01 15:04:05"
for _, ts := range []time.Time{occurred, noticed} {
if !strings.Contains(body, ts.Local().Format(layout)) {
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
}
if strings.Contains(body, ts.In(away).Format(layout)) {
t.Errorf("page rendered %s in the source's zone", ts)
}
}
}
func TestEventsPageEscapesIntakeText(t *testing.T) {
// Titles come from outside — a feed headline, a notification. They are shown
// on a page and must never be able to inject markup into it.
+4 -1
View File
@@ -8,7 +8,10 @@
<div class=scroll><table class=mono>
<tr><th>at<th>kind<th>what</tr>
{{range .Items}}<tr>
<td>{{.At.Format "15:04"}}</td>
<!-- In his clock. A plan item's At is a calendar fact's Ts or a reminder's
FireTs, and the store hands both back as UTC, so the raw hour printed a
reminder here at an hour /reminders did not agree with (V-469). -->
<td>{{.At.Local.Format "15:04"}}</td>
<td class=gray>{{.Kind}}</td>
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
</tr>{{end}}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// morningCore serves a canned checklist and day plan.
type morningCore struct {
ipc.UnimplementedCoreAPI
status []ipc.MorningRoutineStatus
plan ipc.DayPlan
}
func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) {
return c.status, nil
}
func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil }
func TestMorningRendersPlanTimesInLocalZone(t *testing.T) {
// A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the
// store hands both back as UTC. Printed raw, /morning named an hour for a
// reminder that /reminders — which does call Local — disagreed with.
away := awayFromLocal()
at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away)
core := &morningCore{plan: ipc.DayPlan{
Date: at,
Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}},
}}
w := httptest.NewRecorder()
handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, at.Local().Format("15:04")) {
t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body)
}
if strings.Contains(body, at.In(away).Format("15:04")) {
t.Errorf("plan item rendered in the stored zone: %s", body)
}
}
+9 -5
View File
@@ -300,11 +300,15 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
return "", err
}
if due != nil {
wgt, err := formWeight(r)
if err != nil {
return "", err
}
wgt, err := formWeight(r)
if err != nil {
return "", err
}
// The importance select is posted whether or not a date is. This ran under
// `if due != nil`, so confirming a candidate as "срочно" with no deadline
// dropped the word on the floor — the row came back normal and nothing said
// why. A promote with neither field set still writes nothing.
if due != nil || wgt != 0 {
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
return "", err
}
+68
View File
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
statusErr error
promoted bool
// The promote path's two extra writes.
fields []any
edits []editCall
editErr error
fieldErr error
}
// editCall records one EditTask, so a test can say what the form actually sent
// down rather than only that the promotion succeeded.
type editCall struct {
ID int64
Text string
Due *time.Time
Weight int
}
func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error {
f.edits = append(f.edits, editCall{id, text, due, weight})
return f.editErr
}
func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error {
f.fields = append(f.fields, []any{id, doneWhen, blockedOn})
return f.fieldErr
}
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) {
}
}
// Confirming a candidate posts the importance select whether or not a date is
// set. The weight write hung off `if due != nil`, so "срочно" with no deadline
// was read off the form and thrown away, and the row came back normal.
func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
"done_when": {"полис на руках"}, "weight": {"3"},
}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if len(core.edits) != 1 {
t.Fatalf("edits = %+v, want the weight written once", core.edits)
}
if core.edits[0].Weight != 3 || core.edits[0].ID != 4 {
t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0])
}
if core.edits[0].Due != nil {
t.Errorf("edit invented a due date: %v", core.edits[0].Due)
}
if core.statusVal != "open" {
t.Errorf("status = %q, want the candidate promoted", core.statusVal)
}
}
// A promote with neither field set still writes nothing: the row is unchanged
// apart from its status, and an EditTask here would be a no-op that can fail.
func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
"done_when": {"полис на руках"}, "weight": {"0"},
}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if len(core.edits) != 0 {
t.Errorf("edits = %+v, want none", core.edits)
}
}
// Out of range clamps rather than 400s; a non-number is a real client error.
func TestApplyTaskPostClampsWeight(t *testing.T) {
core := &fakeTaskCore{created: true}
+31 -3
View File
@@ -172,21 +172,49 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespBytes))
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
// error_code + description on failure. parse the body either way so a 200
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
var tr telegramResp
if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
jsonErr := json.Unmarshal(rb, &tr)
if jsonErr == nil && !tr.Ok {
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
}
// A 2xx whose body is not the bot API's envelope did not come from the bot
// API. The normal path here is the relay: this box reaches telegram through
// an HTTP/SOCKS5 proxy, and a proxy that is up but cannot reach
// api.telegram.org answers 200 with an HTML page of its own. Reading that as
// a delivered message is the worst outcome the sink has — the dispatcher
// writes a 'sent' outbox row, MarkSent restarts the repeat clock, and the
// sev4 alarm that never arrived goes quiet for a whole interval. Only
// ok=true is a send.
if jsonErr != nil {
return fmt.Errorf("telegramsink: telegram returned %d with a body that is not the bot API envelope (not a confirmed send): %s", resp.StatusCode, snippet(rb))
}
return nil
}
// maxRespBytes caps the response read — the body is wire-controlled and the
// relay in front of it is not telegram. It is far above any sendMessage
// envelope (a few hundred bytes; the result echoes one short away message),
// because a truncated body no longer parses and now reads as a failed send.
const maxRespBytes = 64 << 10
// snippet trims a response body down to something an error line can carry. A
// relay's HTML page is measured in kilobytes and none of it belongs in the log.
func snippet(rb []byte) string {
s := strings.TrimSpace(string(rb))
if len(s) > 200 {
return s[:200] + "…"
}
return s
}
// sendMessageURL — the bot API path. the token is in the URL path
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
// it anywhere else. the URL is built per-send from the resolved base and never
@@ -291,6 +291,66 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
}
}
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
// of its own. That is not a delivered message, and calling it one silences a
// sev4 alarm for a full repeat interval.
func TestSendRefusesA200ThatIsNotTheBotAPIEnvelope(t *testing.T) {
rs := newRecordingServer(t, http.StatusOK, `<html><body>proxy: upstream unreachable</body></html>`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
if err == nil {
t.Fatal("want error on a 200 that is not the bot API envelope")
}
if !strings.Contains(err.Error(), "not a confirmed send") {
t.Fatalf("error should say the send is unconfirmed, got: %v", err)
}
}
// The success path must stay a success: ok=true on 200 is a send.
func TestSendAcceptsOkTrue(t *testing.T) {
rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7}}`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
t.Fatalf("want success on ok=true, got: %v", err)
}
}
// A body long enough to have been truncated by the old 4096-byte cap still
// parses, so a real send is not reported as a failure.
func TestSendAcceptsAnOversizedButValidEnvelope(t *testing.T) {
rs := newRecordingServer(t, http.StatusOK,
`{"ok":true,"result":{"message_id":7,"text":"`+strings.Repeat("x", 8000)+`"}}`)
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
t.Fatalf("want success on a large ok=true envelope, got: %v", err)
}
}
// The error line carries a snippet, not the relay's whole page.
func TestSendErrorDoesNotCarryTheWholeBody(t *testing.T) {
rs := newRecordingServer(t, http.StatusBadGateway, strings.Repeat("z", 5000))
srv := httptest.NewServer(rs.handler())
defer srv.Close()
sink, _ := New(sinkCfg(srv.URL))
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
if err == nil {
t.Fatal("want error on 502")
}
if len(err.Error()) > 500 {
t.Fatalf("error line should be trimmed, got %d bytes", len(err.Error()))
}
}
func TestSendReturnsErrorOnNon2xx(t *testing.T) {
rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway")
srv := httptest.NewServer(rs.handler())
+10 -1
View File
@@ -257,7 +257,16 @@ func (c *Client) call(ctx context.Context, method string, params any, out any) e
if resp.Error != nil {
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
}
if out == nil || len(resp.Result) == 0 {
// A frame carrying our id and neither result nor error is not an answer.
// The HTTP transport already refuses one; the stdio transport does not, and
// without this check the refusal depended on which door the server was
// behind. Letting it through is the one failure that lies: tools/call
// returns an empty string and a nil error, so the act is recorded as done
// and the tool never ran.
if len(resp.Result) == 0 {
return fmt.Errorf("mcp: %s: %s: response carries neither result nor error", c.name, method)
}
if out == nil {
return nil
}
if err := json.Unmarshal(resp.Result, out); err != nil {
+8 -2
View File
@@ -525,10 +525,16 @@ func (m *Manager) Resources(ctx context.Context) []Resource {
// ReadResource reads one resource from one server.
func (m *Manager) ReadResource(ctx context.Context, server, uri string) (string, error) {
cl, cfg, _, _, _ := m.lookup(server, "")
if cl == nil {
cl, cfg, _, configured, _ := m.lookup(server, "")
if !configured {
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
}
// A configured server that is merely down is not an unknown server. Call
// already keeps the two apart; reporting ErrNoServer here tells a caller
// the resource can never exist, when the truth is "not right now".
if cl == nil {
return "", fmt.Errorf("%w: %s", ErrNotConnected, server)
}
cctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
return cl.ReadResource(cctx, uri)
+56
View File
@@ -641,3 +641,59 @@ func TestReconnectBackoffGrows(t *testing.T) {
t.Fatalf("first retry = %v, want %v", c.backoff(), DefaultReconnectEvery)
}
}
// emptyFrameTransport answers the handshake normally and then replies to every
// later call with a well-formed frame carrying our id and nothing else — no
// result, no error. That is the answer a partially-implemented server gives,
// and it is the one that lies: without a check it reads as success.
type emptyFrameTransport struct{ handshaken bool }
func (t *emptyFrameTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
id := req.ID
if !t.handshaken {
t.handshaken = true
raw, _ := json.Marshal(map[string]any{
"protocolVersion": ProtocolVersion,
"serverInfo": map[string]any{"name": "empty", "version": "0"},
})
return &rpcResponse{JSONRPC: "2.0", ID: &id, Result: raw}, nil
}
return &rpcResponse{JSONRPC: "2.0", ID: &id}, nil
}
func (t *emptyFrameTransport) Notify(context.Context, string, any) error { return nil }
func (t *emptyFrameTransport) Close() error { return nil }
func TestResultlessResponseIsNotSuccess(t *testing.T) {
c := newClient("empty", &emptyFrameTransport{})
if err := c.Initialize(context.Background()); err != nil {
t.Fatalf("initialize: %v", err)
}
out, err := c.CallTool(context.Background(), "break_thing", map[string]any{"q": "x"})
if err == nil {
t.Fatalf("a frame with neither result nor error must not read as success (got %q)", out)
}
if out != "" {
t.Fatalf("out = %q", out)
}
if _, err := c.ListTools(context.Background()); err == nil {
t.Fatal("tools/list with no result must be an error, not an empty catalogue")
}
}
func TestReadResourceOnDownServerIsNotConnected(t *testing.T) {
// A url server with no poster factory: configured, validated, never dialed.
m, err := NewManager(nil, []ServerConfig{{
Name: "down", URL: "http://example.test/mcp", Enabled: true,
}})
if err != nil {
t.Fatal(err)
}
m.Connect(context.Background())
if _, err := m.ReadResource(context.Background(), "down", "note://one"); !errors.Is(err, ErrNotConnected) {
t.Fatalf("err = %v, want ErrNotConnected", err)
}
if _, err := m.ReadResource(context.Background(), "nosuch", "note://one"); !errors.Is(err, ErrNoServer) {
t.Fatalf("err = %v, want ErrNoServer", err)
}
}
+51 -3
View File
@@ -52,10 +52,13 @@ type PlanEntry struct {
// Plan — the ordered day. Date is the calendar day it describes. Rest marks a
// plan trimmed by After, which changes what an empty one means: a day with
// nothing on it and a day whose last item has passed are different answers.
// More counts what Next dropped off the end, so the sentence can say that more
// remains instead of implying the day ends after the third line.
type Plan struct {
Date time.Time
Items []PlanEntry
Rest bool
More int
}
// BuildPlan orders everything known about the day Now falls on: calendar
@@ -142,13 +145,24 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.
return out
}
// NextSpoken — how many entries "что дальше?" reads aloud. Three, for the same
// reason the feed reads three headlines: the answer is spoken once and cannot be
// scrolled back, and a list longer than a breath is not an answer, it is a
// recital. Asked at 04:45 on a day with 43 entries, the trim below removes
// nothing — everything is still ahead — so the cap is what makes "дальше" mean
// next rather than today (V-618).
const NextSpoken = 3
// After returns the part of the plan that has not happened yet — the answer to
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
// empty result still knows which day it is empty for.
//
// Strictly after: an entry at exactly now is the thing happening, not the thing
// next.
func (p Plan) After(now time.Time) Plan {
out := Plan{Date: p.Date, Rest: true}
for _, it := range p.Items {
if it.At.Before(now) {
if !it.At.After(now) {
continue
}
out.Items = append(out.Items, it)
@@ -156,10 +170,30 @@ func (p Plan) After(now time.Time) Plan {
return out
}
// Next is After with a spoken cap — what "что дальше?" actually answers with.
// The overflow is counted rather than dropped, because "дальше: 10:00 …" with
// forty entries hidden behind it is a false picture of the day.
func (p Plan) Next(now time.Time, n int) Plan {
out := p.After(now)
if n > 0 && len(out.Items) > n {
out.More = len(out.Items) - n
out.Items = out.Items[:n]
}
return out
}
// FormatRU renders the plan as maven says it. Feminine self-reference,
// informal address, no pet names — and no exhortation: she reads the day back,
// she does not tell him to get on with it.
//
// Every hour is read in the plan's own zone — Date's, which BuildPlan sets from
// the asking clock. Printed raw, an hour read whatever zone its instant arrived
// in: an event or a reminder comes off the store as UTC, while a checklist line
// is built local, so one spoken sentence named two zones. This is the voice
// path, so that is what the owner heard (V-614); the same defect on the two web
// pages was V-612.
func (p Plan) FormatRU() string {
zone := p.Date.Location()
if len(p.Items) == 0 {
// "что дальше?" after the last item of the day. The day was not empty,
// it is over, and saying it was empty is a false statement about a day
@@ -171,14 +205,28 @@ func (p Plan) FormatRU() string {
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
line := fmt.Sprintf("%s — %s", it.At.In(zone).Format("15:04"), it.Text)
if it.Uncertain {
line = say.S(say.PlanUncertain, map[string]string{"line": line})
}
parts[i] = line
}
items := strings.Join(parts, "; ")
// The rest of the day is a different sentence, not a shorter day plan. It
// carries no date — he asked what is next, and he knows which day he is in —
// and it says out loud when there is more behind the cap.
if p.Rest {
if p.More > 0 {
return say.S(say.PlanNextMore, map[string]string{
"items": items,
"n": fmt.Sprint(p.More),
"word": say.CountWord(p.More, "дело", "дела", "дел"),
})
}
return say.S(say.PlanNext, map[string]string{"items": items})
}
return say.S(say.PlanDay, map[string]string{
"date": p.Date.Format("02.01.2006"),
"items": strings.Join(parts, "; "),
"items": items,
})
}
+110
View File
@@ -1,6 +1,7 @@
package morning
import (
"fmt"
"strings"
"testing"
"time"
@@ -151,6 +152,37 @@ func TestPlanFormatRU(t *testing.T) {
}
}
// One spoken sentence names one clock. An event and a reminder come off the
// store as UTC and a checklist line is built in the asking clock's zone, so the
// raw Format printed the two halves of one sentence in two zones (V-614). The
// zones here are three hours off whatever this machine runs in, so the test
// tells "read in his clock" apart from "read in the zone the instant arrived
// in" under TZ=UTC as well.
func TestPlanFormatRUReadsEveryHourInThePlansZone(t *testing.T) {
_, off := time.Now().Zone()
away := time.FixedZone("away", off+3*60*60)
stored := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)
p := Plan{
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, away),
Items: []PlanEntry{
{At: stored, Text: "Планёрка", Kind: PlanEvent},
{At: planAt(time.Date(2026, 8, 3, 0, 0, 0, 0, away), 10, 30),
Text: "утро — осталось: витамины", Kind: PlanChecklist},
},
}
got := p.FormatRU()
if want := stored.In(away).Format("15:04"); !strings.Contains(got, want) {
t.Errorf("the event is not read in the plan's zone (%s): %q", want, got)
}
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
t.Errorf("the event is read in the zone it was stored in (%s): %q", bad, got)
}
if !strings.Contains(got, "10:30") {
t.Errorf("the checklist line moved zone: %q", got)
}
}
func TestPlanAfter(t *testing.T) {
p, now := planFixture(t)
rest := p.After(planAt(now, 11, 0))
@@ -171,6 +203,84 @@ func TestPlanAfter(t *testing.T) {
}
}
// nextFixture — a day with more entries than the cap, built in a zone three
// hours off UTC so the test fails under TZ=UTC as well as under the machine's
// own zone if the plan ever renders in the wrong one.
func nextFixture(t *testing.T) (Plan, time.Time) {
t.Helper()
zone := time.FixedZone("MSK", 3*60*60)
now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
var events []PlanEntry
for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} {
events = append(events, PlanEntry{
At: planAt(now, hhmm[0], hhmm[1]),
Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]),
Kind: PlanEvent,
})
}
return BuildPlan(nil, nil, events, nil, now), now
}
// "что дальше?" asked at 04:45 on a day with everything still ahead. The trim
// removes nothing there, so before V-618 she read the whole day out loud.
func TestPlanNextCapsWhatIsSpoken(t *testing.T) {
p, now := nextFixture(t)
got := p.Next(now, NextSpoken).FormatRU()
want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " +
"14:00 — событие 14:00. и ещё 2 дела до конца дня."
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
// No date: he asked what is next, not what day it is.
if strings.Contains(got, "03.08.2026") {
t.Errorf("rest-of-day answer stamps a date: %q", got)
}
}
// Nothing hidden means nothing claimed hidden.
func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) {
p, now := nextFixture(t)
got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU()
want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
// The whole-day question is not narrowed: same plan, no trim, no cap.
func TestPlanWholeDayIsNotNarrowed(t *testing.T) {
p, _ := nextFixture(t)
got := p.FormatRU()
if n := strings.Count(got, "событие"); n != 5 {
t.Errorf("whole day read %d of 5 entries: %q", n, got)
}
if !strings.HasPrefix(got, "план на 03.08.2026: ") {
t.Errorf("whole day lost its date: %q", got)
}
}
// The empty case says the day is over rather than returning an empty sentence,
// and it does not say the day was empty.
func TestPlanNextEmptySaysSo(t *testing.T) {
p, now := nextFixture(t)
got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU()
if got != "на сегодня больше ничего не запланировано." {
t.Errorf("got %q", got)
}
}
// An entry at exactly the asking minute is what is happening, not what is next.
func TestPlanNextIsStrictlyAfterNow(t *testing.T) {
p, now := nextFixture(t)
rest := p.Next(planAt(now, 5, 45), NextSpoken)
if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 {
t.Errorf("got %+v", rest.Items)
}
if rest.More != 1 {
t.Errorf("More = %d, want 1", rest.More)
}
}
// The plan says what today still has not got done, and a closed window does not
// make a skipped routine untrue. Evaluate reports Active only inside the
// window, so keying the checklist line off it meant the one thing the plan can
+33
View File
@@ -78,3 +78,36 @@ func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
t.Errorf("error = %v; want it to name the empty response", err)
}
}
// The other two paths, which had no such guard. The evidence branch of
// PhraseQuery and PhraseChat both returned ("", nil) off a server that produced
// no tokens — an empty answer reported as a successful phrasing. The daemon's
// callers check for the empty string and paper over it; the eval does not, and
// scored a silent model as bad phrasing rather than as a failure.
func TestEmptyAnswerIsAnErrorOnEveryPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
t.Run("evidence", func(t *testing.T) {
got, err := p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbQuerySources, "два литра", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbQuerySources)
}
})
t.Run("chat", func(t *testing.T) {
got, err := p.PhraseChat(context.Background(), "как дела", nil)
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbChat, "", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbChat)
}
})
}
+20 -6
View File
@@ -299,6 +299,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
if text != "" {
return text, nil
}
if raw == "" {
// Same guard the knowledge branch above has had since it was written,
// and this branch did not: the server answered and the model wrote
// nothing, which returned ("", nil) — an empty answer reported as a
// successful phrasing. The daemon's callers happen to check for the
// empty string, so it read as a silent fallback there; the eval scored
// it as bad phrasing rather than as the failure it is, and nothing on
// either path logged that the model had produced no tokens.
return SourcesFallback(strings.Join(notes, "; ")), errEmptyResponse
}
return raw, nil
}
@@ -375,7 +385,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
if i := strings.IndexByte(resp, '\n'); i >= 0 {
resp = resp[:i]
}
return strings.TrimSpace(resp), nil
if resp = strings.TrimSpace(resp); resp == "" {
// The model was up and wrote nothing. Same rule as PhraseQuery: the
// fallback keeps the turn alive and the failure stays visible, rather
// than ("", nil) telling the caller the chat path succeeded.
return ChatFallback(), fmt.Errorf("phrase chat: %w", errEmptyResponse)
}
return resp, nil
}
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
@@ -414,11 +430,9 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
if mood == "" {
mood = "neutral"
}
summary := body
if len(summary) > 60 {
summary = summary[:57] + "..."
}
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary, Mood: mood}, nil
return delivery.PhrasedReminder{
Decision: d, Body: body, Summary: reminderSummary(body), Mood: mood,
}, nil
}
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
+316
View File
@@ -0,0 +1,316 @@
package phraser
// The persona guard for the Go floor strings.
//
// internal/phraser/eval/fallbacks_test.go already scores everything Variants()
// returns — that is the JSON decks. What it cannot see is the floor UNDER those
// decks: the hardFloor/ackFloor/queryFloor/actFloor/confirmFloor maps and the
// literals in nudge_llm.go, which are what she says when the JSON is unusable or
// when the model is unreachable. Those are exactly the moments the model is not
// doing the talking, so leaving them unscored left the persona unchecked when it
// was most load-bearing (Vikunja #621).
//
// Two tests here, and the second one is the point:
//
// - TestGoFloorPersona scores the floor corpus on the same checks.
// - TestGoFloorCoverage walks the package source with go/ast and fails on any
// Russian string literal that neither reached the corpus nor sits inside a
// declaration declared prompt-side. A hand-written list of strings would rot
// the first time somebody adds one; a hand-written list of PROMPT BUILDERS
// does not, because the default for a new literal is "must be scored".
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"regexp"
"strconv"
"strings"
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser/eval"
"github.com/kami/maven/internal/store"
)
// personaChecks — the checks that apply to a floor line.
//
// The same three the non-goals section names (feminine self-reference, how she
// addresses him, no pet names), plus lang: an English floor line is unusable out
// loud. No hisgender, for the reason eval/fallbacks_test.go gives — her own
// feminine verb near "тебе" is correct and that check reads it as addressing him
// as a woman. No length, because a floor line composed from his own data has no
// bounded length, and no ontopic/mood, which need a fixture case.
var personaChecks = map[string]bool{
eval.CheckLang: true,
eval.CheckFeminine: true,
eval.CheckAddress: true,
eval.CheckCringe: true,
}
var floorPlaceholderRE = regexp.MustCompile(`\{[a-z_]+\}`)
// formatVerbRE — the fmt verbs a floor line is composed with, so the coverage
// test compares the Russian either side of them and not the verb.
var formatVerbRE = regexp.MustCompile(`%[+\-# 0-9.]*[a-zA-Z]`)
// floorLine — one scored string and where it came from, so a failure names the
// map or the function to go and edit.
type floorLine struct {
origin string
text string
}
// floorCorpus — every line the Go floor can produce. Maps are read whole, so a
// new entry in one is scored without touching this file; the composing functions
// are CALLED rather than scraped, so their glue text is scored in place.
func floorCorpus() []floorLine {
var out []floorLine
add := func(origin, text string) {
if strings.TrimSpace(text) != "" {
out = append(out, floorLine{origin, text})
}
}
for name, m := range map[string]map[string]string{
"fallbacks.go hardFloor": hardFloor,
"acks.go ackFloor": ackFloor,
"query.go queryFloor": queryFloor,
"acts.go actFloor": actFloor,
"confirm.go confirmFloor": confirmFloor,
"nudge_llm.go fallbackNudges": fallbackNudges,
} {
for key, text := range m {
add(name+"["+key+"]", text)
}
}
// fallbackNudge composes three of its four arms in Go. Drive every rule name
// the maps know, one it does not, and the down-services arm.
rules := map[string]bool{"": true, "unknown_rule": true}
for name := range fallbackNudges {
rules[name] = true
}
for name := range ruleTopics {
rules[name] = true
}
for name := range ruleKeywords {
rules[name] = true
}
for name := range rules {
c := loop.Candidate{}
c.Rule.Name = name
add(fmt.Sprintf("nudge_llm.go fallbackNudge(%q)", name), fallbackNudge(c))
}
// The keyword arm again, through a rule name shaped "family:keyword", which
// is where ruleKeyword's second branch lives.
c := loop.Candidate{}
c.Rule.Name = "custom:зарядку"
add("nudge_llm.go fallbackNudge(custom)", fallbackNudge(c))
// The keywords themselves. A rule that has both a keyword and a fallback
// line never reaches the keyword arm, but the map is edited as one thing and
// the next rule may have only the keyword, so score every value.
for rule, kw := range ruleKeywords {
add("nudge_llm.go ruleKeywords["+rule+"]", "Напоминаю: "+kw+".")
}
// The down-services arm, which needs a service actually reading down.
down := loop.Candidate{}
down.Rule.Name = "service_down"
down.State.Facts = map[string]store.Fact{
loop.ServiceDownPrefix + "gitea": {
Key: loop.ServiceDownPrefix + "gitea",
Value: `"down"`,
Source: loop.ServiceDownSource,
Ts: time.Now(),
},
}
add("nudge_llm.go fallbackNudge(down services)", fallbackNudge(down))
// The spoken duration words. Both functions are pure and bounded, so scoring
// their whole range beats scraping the literals out of the switch.
for m := 0; m <= 60*30; m += 7 {
d := time.Duration(m) * time.Minute
add("nudge_llm.go ruDur", ruDur(d))
add("nudge_templates.go ruSinceWords", ruSinceWords(d))
}
for h := 0; h <= hoursSpoken; h++ {
add("nudge_templates.go hourPlural", hourPlural(h))
add("nudge_templates.go hourWord", hourWord(h))
}
return out
}
// TestGoFloorPersona scores every line the Go floor can say.
func TestGoFloorPersona(t *testing.T) {
corpus := floorCorpus()
if len(corpus) == 0 {
t.Fatal("no floor lines — the corpus builder found nothing to score")
}
for _, line := range corpus {
// A placeholder stands for his own words and carries no persona.
body := floorPlaceholderRE.ReplaceAllString(line.text, "вода")
for _, r := range eval.RunChecks(eval.Case{}, body, "neutral") {
if personaChecks[r.Name] && !r.Pass {
t.Errorf("%s: %q fails %s: %s", line.origin, line.text, r.Name, r.Detail)
}
}
}
}
// promptDecls — declarations whose Russian is written FOR the model, not for
// him. They are excluded by name, not by string, so adding a line inside one of
// them stays excluded and adding a line anywhere else fails the coverage test.
//
// Every name here is asserted to still exist, so a rename fails loudly instead
// of silently widening the exemption.
var promptDecls = map[string]string{
"ReplySystemPrompt": "the system prompt for the reply model",
"replyContext": "renders the decision FOR the model, never spoken",
"ruleTopics": "situation descriptions fed to the nudge prompt",
"ruleTopic": "same, plus the two prefixes it composes",
"buildNudgePrompt": "the nudge prompt itself",
"chatUserMessage": "the history block handed to the model",
"PhraseReminder": "the reminder prompt; its reply is scored, its prompt is not",
}
// promptFiles — files whose whole job is prompt text. Asserted to exist, same
// reason as promptDecls.
var promptFiles = map[string]string{
"prompts.go": "every literal in it is a prompt",
}
func hasCyrillic(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}
// TestGoFloorCoverage — the guard that survives the next person.
//
// It reads the package source and requires every Russian string literal to be
// one of two things: reachable in floorCorpus (so TestGoFloorPersona scored it),
// or inside a declaration named above as prompt-side. There is no third answer
// and no way to add a floor string that quietly gets neither.
func TestGoFloorCoverage(t *testing.T) {
var scored []string
for _, line := range floorCorpus() {
scored = append(scored, line.text)
}
// A literal is covered when every Russian piece of it shows up in something
// the corpus scored. Pieces, not the whole string, because a format string
// ("%d ч") and a concatenation fragment ("Не отвечает: ") only ever reach him
// with the surrounding value filled in.
covered := func(lit string) bool {
for _, part := range formatVerbRE.Split(lit, -1) {
part = strings.TrimSpace(part)
if part == "" || !hasCyrillic(part) {
continue
}
found := false
for _, s := range scored {
if strings.Contains(s, part) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0)
if err != nil {
t.Fatalf("parse package: %v", err)
}
pkg, ok := pkgs["phraser"]
if !ok {
t.Fatal("package phraser did not parse — the coverage guard cannot run")
}
seenDecl := map[string]bool{}
seenFile := map[string]bool{}
for path, file := range pkg.Files {
base := path[strings.LastIndexByte(path, '/')+1:]
if _, exempt := promptFiles[base]; exempt {
seenFile[base] = true
continue
}
for _, decl := range file.Decls {
names := declNames(decl)
skip := false
for _, n := range names {
if _, ok := promptDecls[n]; ok {
seenDecl[n] = true
skip = true
}
}
if skip {
continue
}
ast.Inspect(decl, func(n ast.Node) bool {
bl, ok := n.(*ast.BasicLit)
if !ok || bl.Kind != token.STRING {
return true
}
lit, err := strconv.Unquote(bl.Value)
if err != nil || !hasCyrillic(lit) {
return true
}
if !covered(lit) {
t.Errorf("%s: Russian literal %q is spoken by nothing the persona guard scores.\n"+
"Either reach it from floorCorpus in persona_floor_test.go, or — if it is written "+
"for the model rather than for him — name its declaration in promptDecls.",
fset.Position(bl.Pos()), lit)
}
return true
})
}
}
for name, why := range promptDecls {
if !seenDecl[name] {
t.Errorf("promptDecls names %q (%s) and no such declaration exists — "+
"a rename left the exemption open", name, why)
}
}
for base, why := range promptFiles {
if !seenFile[base] {
t.Errorf("promptFiles names %q (%s) and no such file exists", base, why)
}
}
}
// declNames — the names a top-level declaration binds, so a prompt-side var,
// const or func can be matched whatever kind it is.
func declNames(decl ast.Decl) []string {
switch d := decl.(type) {
case *ast.FuncDecl:
return []string{d.Name.Name}
case *ast.GenDecl:
var out []string
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.ValueSpec:
for _, n := range s.Names {
out = append(out, n.Name)
}
case *ast.TypeSpec:
out = append(out, s.Name.Name)
}
}
return out
}
return nil
}
+25 -4
View File
@@ -115,11 +115,32 @@ func (s *Stub) PhraseReminder(_ context.Context, d loop.ReminderDecision) (deliv
if text == "" {
text = "reminder"
}
summary := text
if len(summary) > 60 {
summary = summary[:57] + "..."
// Mood, for the same reason PhraseNudge sets it: the Stub is a production
// fallback, so it owes the output contract a value. This one was left at the
// zero value, which is not one of the five moods.
return delivery.PhrasedReminder{
Decision: d, Body: text, Summary: reminderSummary(text), Mood: "neutral",
}, nil
}
// summaryLimit — how much of a reminder goes to the away channels.
const summaryLimit = 60
// reminderSummary shortens a reminder body to the away-channel summary.
//
// Counted in runes. Both copies of this counted bytes — `len(s) > 60` and
// `s[:57]` — and on a Russian reminder that is wrong twice. A Cyrillic letter is
// two bytes, so the cut fell at about 28 letters rather than 60; and byte 57
// lands inside a letter about half the time, so the summary ended in half a
// rune. That is not cosmetic: Sendable.Summary is the text voicesink hands to
// piper and the text the telegram sink posts, so the broken byte was spoken and
// sent.
func reminderSummary(body string) string {
r := []rune(body)
if len(r) <= summaryLimit {
return body
}
return delivery.PhrasedReminder{Decision: d, Body: text, Summary: summary}, nil
return string(r[:summaryLimit-3]) + "..."
}
// phraseNudge — the per-rule templates. each reads the context the predicate
+29
View File
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
@@ -185,6 +186,34 @@ func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
}
}
// The same truncation, in the language she actually speaks. The test above is
// ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]`
// cut a Russian reminder at about 28 letters instead of 60, and landed inside a
// letter about half the time. Summary is what voicesink hands to piper and what
// the telegram sink posts, so half a rune was spoken and sent.
func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) {
long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера"
rd := loop.ReminderDecision{
Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
State: loop.State{Now: time.Now().UTC()},
}
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
if !utf8.ValidString(pr.Summary) {
t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary)
}
if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit {
t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary)
}
// The cut must be near the limit, not near half of it. A byte count would
// stop at 28 letters here.
if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 {
t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary)
}
if pr.Mood == "" {
t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood")
}
}
func TestPhraseReminderNonJSONPayload(t *testing.T) {
// a payload that isn't JSON → the phraser falls back to the raw string.
rd := loop.ReminderDecision{
+8 -1
View File
@@ -58,8 +58,15 @@ try:
# 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.
# The preposition is normalised as well as the hour (V-610). dateparser
# joins a day word to a clock through "в" and through no other Russian
# preposition, so "завтра к 03:00 pm" loses the clock and resolves to
# tomorrow at the CURRENT minute. "на" was silently losing it the same way.
def _at(m):
prep = 'at' if m.group(1).lower() in ('at', 'by') else 'в'
return '%s %02d:00' % (prep, int(m.group(2)))
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
_at, text, flags=re.IGNORECASE)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
# parse() gets the time right (search_dates mishandles AM/PM).
+172
View File
@@ -0,0 +1,172 @@
package router
import (
"context"
"os/exec"
"testing"
"time"
)
// probeNow is the clock the five sentences below were measured against on the
// box at 03:53 on 2026-08-06, right after #252 deployed. Three of them wrote a
// reminder for 03:53 itself, which is the current minute and not an hour anyone
// said (V-610).
var probeNow = time.Date(2026, 8, 6, 3, 53, 0, 0, time.Local)
// kProbe — the five sentences, with what each must resolve to. The two that
// already worked are here so that fixing "к" cannot cost "в".
var kProbe = []struct {
text string
// day is the offset from probeNow's date, hour is the fire hour.
day int
hour int
whyItIs string
}{
{
text: "напомни завтра в три часа дня позвонить врачу",
day: 1,
hour: 15,
whyItIs: "в plus a spoken hour and a qualifier resolves, and did before #252",
},
{
text: "напомни завтра в 15:00 позвонить врачу",
day: 1,
hour: 15,
whyItIs: "a written clock resolves, and did before #252",
},
{
text: "напомни завтра к трём часам дня позвонить врачу",
day: 1,
hour: 15,
whyItIs: "к names the same hour в does, and wrote 03:53 after #252",
},
{
text: "напомни сегодня к пяти часам вечера позвонить врачу",
day: 0,
hour: 17,
whyItIs: "the same defect on today and on the oblique пяти",
},
{
text: "напомни завтра к трём часам позвонить врачу",
day: 1,
hour: 3,
whyItIs: "no qualifier, so the hour reads as spoken and the daemon asks which half",
},
}
// TestKPrepositionHourStub — the probe against the floor parser, which answers
// on every box whether or not python is installed.
func TestKPrepositionHourStub(t *testing.T) {
ex := Extractor{Time: StubDateTimeParser{}}
for _, c := range kProbe {
t.Run(c.text, func(t *testing.T) {
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
if !got.HasTime {
t.Fatalf("time slot empty: %s", c.whyItIs)
}
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
})
}
}
// TestKPrepositionHourPython — the same probe against the production parser.
// Skips where python3 or dateparser is missing, as the rest of this package's
// python tests do.
func TestKPrepositionHourPython(t *testing.T) {
requirePython(t)
p := NewPythonDateParser()
ex := Extractor{Time: p}
for _, c := range kProbe {
t.Run(c.text, func(t *testing.T) {
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
if !got.HasTime {
t.Fatalf("time slot empty: %s", c.whyItIs)
}
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
})
}
}
func checkFire(t *testing.T, fire time.Time, dayOffset, hour int, why string) {
t.Helper()
if fire.Hour() != hour || fire.Minute() != 0 {
t.Errorf("fire = %s, want %02d:00 — %s", fire.Format("2006-01-02 15:04"), hour, why)
}
if fire.Minute() == probeNow.Minute() && fire.Hour() == probeNow.Hour() {
t.Errorf("fire = %s, which is the clock at the moment of the turn and not an hour he said", fire.Format("15:04"))
}
want := probeNow.AddDate(0, 0, dayOffset)
if fire.Year() != want.Year() || fire.Month() != want.Month() || fire.Day() != want.Day() {
t.Errorf("fire = %s, want the %s — %s", fire.Format("2006-01-02"), want.Format("2006-01-02"), why)
}
}
// TestUnresolvedHourLeavesTheSlotEmpty — the durable half. A parser that read
// the day and took the minute off the clock must not fill the time slot, no
// matter which preposition lost the hour. This is the whole class the "к" case
// was one member of.
func TestUnresolvedHourLeavesTheSlotEmpty(t *testing.T) {
ex := Extractor{Time: clockEchoParser{}}
for _, s := range []string{
"напомни завтра к трём часам дня позвонить врачу",
"напомни завтра в три часа дня позвонить врачу",
"напомни завтра на девять позвонить врачу",
"напомни сегодня к пяти часам вечера позвонить врачу",
} {
got := ex.Extract(context.Background(), IntentReminder, s, probeNow)
if got.HasTime {
t.Errorf("Extract(%q) filled the slot with %s, which is the current minute; she has to ask", s, got.Time.Format("15:04"))
}
}
}
// TestSpokenMinutesSurviveTheRefusal — the three shapes that name a minute of
// their own, and an hour that happens to be the hour it is spoken in. Refusing
// on the whole instant instead of on the minute would cost every one of these,
// and ru-rem-006 in the routing fixture is the case that says so.
func TestSpokenMinutesSurviveTheRefusal(t *testing.T) {
noon := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
ex := Extractor{Time: StubDateTimeParser{}}
for _, c := range []struct {
text string
hour int
min int
}{
{"напомни послезавтра в 12 забрать заказ", 12, 0},
{"разбуди меня в 6:30", 6, 30},
{"напомни в половине восьмого выпить таблетку", 7, 30},
{"напомни без четверти восемь выходить", 7, 45},
} {
got := ex.Extract(context.Background(), IntentReminder, c.text, noon)
if !got.HasTime {
t.Errorf("Extract(%q) left the slot empty; he said the time", c.text)
continue
}
if got.Time.Hour() != c.hour || got.Time.Minute() != c.min {
t.Errorf("Extract(%q) = %s, want %02d:%02d", c.text, got.Time.Format("15:04"), c.hour, c.min)
}
}
}
// TestIntervalKeepsTheCurrentMinute — an interval is measured from now and may
// land on now's own minute, so the refusal above must not reach it.
func TestIntervalKeepsTheCurrentMinute(t *testing.T) {
ex := Extractor{Time: StubDateTimeParser{}}
got := ex.Extract(context.Background(), IntentReminder, "напомни через час позвонить врачу", probeNow)
if !got.HasTime {
t.Fatal("через час names one instant and answers the hour and the day together")
}
if want := probeNow.Add(time.Hour); !got.Time.Equal(want) {
t.Errorf("fire = %s, want %s", got.Time.Format("15:04"), want.Format("15:04"))
}
}
func requirePython(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not on PATH — skipping dateparser tests")
}
if err := exec.Command("python3", "-c", "import dateparser").Run(); err != nil {
t.Skip("python dateparser not installed — skipping dateparser tests")
}
}
+6 -5
View File
@@ -55,11 +55,12 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
switch intent {
case IntentReminder:
if e.Time != nil {
// 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) {
// ResolvedTheHour is the gate, not the parser's ok (V-577, V-579,
// V-610). A sentence that names a day and no hour parses to that day
// at the current minute, and so does one whose hour the parser could
// not read. Filling the slot with either invents the answer she asked
// for. Left empty, the daemon asks.
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && ResolvedTheHour(utterance, t) {
s.Time = t
s.HasTime = true
}
+49
View File
@@ -119,6 +119,55 @@ func NamesAnHour(text string) bool {
return false
}
// ResolvedTheHour reports whether a parse read the hour the sentence names,
// rather than inheriting the clock it was handed as its relative base.
//
// It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks
// whether an hour was spoken and cannot ask whether it was read, so a
// preposition the parser half knew wrote a reminder at 03:53 for "напомни
// завтра к трём часам дня" and confirmed it as if it were the hour he said. A
// wrong instant she states as fact is worse than a question, because he stops
// thinking about it.
//
// The tell is the minute. A spoken hour lands on the hour, and the only three
// shapes that name a minute of their own are a written clock, a half hour and a
// quarter to. A parse that came back with any other minute took it from the
// clock it was handed, whatever hour it put in front of it. An interval is
// exempt, because it is measured from now and lands wherever the arithmetic
// says.
//
// Comparing the whole instant to now would be the obvious test and it is the
// wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's
// reference clock is 12:00, so an hour he did say would read as an hour nobody
// did.
func ResolvedTheHour(text string, t time.Time) bool {
if !NamesAnHour(text) {
return false
}
if NamesAnInterval(text) || t.Minute() == 0 {
return true
}
return namesTheMinute(text)
}
// namesTheMinute reports whether the sentence says which minute of the hour it
// means, in any of the three ways it can.
func namesTheMinute(text string) bool {
toks := strings.Fields(strings.ToLower(text))
for i, raw := range toks {
if isDigitClock(cleanWord(raw)) {
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".
//
+11 -2
View File
@@ -93,8 +93,17 @@ func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) {
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")
// "на 9" names an hour, and a parser that answered with the clock did not
// read it (V-610). Naming one is necessary and reading it is what fills the
// slot, so this echo is refused too and the daemon asks.
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); got.HasTime {
t.Errorf("«на 9» took %s from the clock; the parser never read the nine", got.Time.Format("15:04"))
}
// A parser that does read it fills the slot, which is the other half of the
// same rule.
real := Extractor{Time: StubDateTimeParser{}}
if got := real.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime || got.Time.Hour() != 9 {
t.Errorf("«на 9» must fill the slot with nine o'clock, got %+v", got)
}
}
+11
View File
@@ -32,6 +32,12 @@ const (
PlanDay = "plan_day"
PlanUncertain = "plan_uncertain"
// "что дальше?" — the next few entries, not the day. PlanNextMore is the
// same sentence when the cap hid something, so the count it states is the
// only signal that the day is not over after the last line read.
PlanNext = "plan_next"
PlanNextMore = "plan_next_more"
TasksNone = "tasks_none"
TasksFirst = "tasks_first"
TasksCandidates = "tasks_candidates"
@@ -70,6 +76,7 @@ const (
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
PlanNext, PlanNextMore,
TasksNone, TasksFirst, TasksCandidates,
StallOverdue, StallSitting, StallUnconfirmed,
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
@@ -89,6 +96,8 @@ var summaryFloor = map[string]string{
PlanDayEmpty: "на {date} ничего не запланировано.",
PlanDay: "план на {date}: {items}",
PlanUncertain: "похоже, {line}",
PlanNext: "дальше: {items}",
PlanNextMore: "дальше: {items}. и ещё {n} {word} до конца дня.",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}",
@@ -139,6 +148,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
for _, req := range []struct{ key, ph string }{
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{PlanNext, "{items}"},
{PlanNextMore, "{items}"}, {PlanNextMore, "{n}"}, {PlanNextMore, "{word}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
{StallSitting, "{n}"}, {StallSitting, "{word}"},
+8
View File
@@ -30,6 +30,14 @@
"fixed": true,
"variants": ["похоже, {line}"]
},
"plan_next": {
"fixed": true,
"variants": ["дальше: {items}"]
},
"plan_next_more": {
"fixed": true,
"variants": ["дальше: {items}. и ещё {n} {word} до конца дня."]
},
"tasks_none": {
"fixed": true,
+9 -3
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"database/sql"
"fmt"
"time"
)
@@ -38,16 +39,21 @@ func (s *Store) MarkSent(ctx context.Context, key string, ts time.Time) error {
// send goes through RecordNudge, not MarkSent, so the first MarkSent comes on
// the repeat path — LastSent may legitimately be zero until then).
func (s *Store) LastSent(ctx context.Context, key string) (time.Time, error) {
var millis int64
// MAX over an empty set is one row holding NULL, not zero rows, so this
// scans into a NullInt64 — the same trap OldestPendingTelegram spells out.
// A bare int64 turned the ordinary "nothing sent yet" case into a scan
// error, and RepeatUnacked aborts its whole sweep on one, so the first
// repeat could never go out for any rule.
var millis sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT MAX(sent_at) FROM ack_sends WHERE rule = ?`, key).Scan(&millis)
if err != nil {
return time.Time{}, fmt.Errorf("last sent %s: %w", key, err)
}
if millis == 0 {
if !millis.Valid {
return time.Time{}, nil
}
return time.UnixMilli(millis).UTC(), nil
return time.UnixMilli(millis.Int64).UTC(), nil
}
// MarkAcked marks ALL pending telegram nudges for the rule as "acted" —
+60
View File
@@ -0,0 +1,60 @@
package store
import (
"context"
"testing"
"time"
)
// TestLastSentOnEmptyTableIsZero pins the aggregate-over-nothing trap that
// nudges.go's OldestPendingTelegram already documents: MAX over an empty set is
// one row holding NULL, not zero rows. Scanning that into a bare int64 is an
// error, and the doc on LastSent promises a zero time instead.
//
// It is not a cosmetic promise. ack_sends is written only by MarkSent, and
// MarkSent is called only after a repeat has already gone out, so the first
// repeat for every rule reads an empty table. delivery.Dispatcher.RepeatUnacked
// aborts the whole sweep on that error, which means the repeat-til-ack loop can
// never take its first step for any rule.
func TestLastSentOnEmptyTableIsZero(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
last, err := s.LastSent(ctx, "service_down")
if err != nil {
t.Fatalf("last sent on an empty table must not error: %v", err)
}
if !last.IsZero() {
t.Fatalf("want the zero time before anything was sent, got %v", last)
}
}
// TestLastSentIsScopedToItsRule — a send for another rule must not answer for
// this one, or the repeat interval is clocked off somebody else's alarm.
func TestLastSentIsScopedToItsRule(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
at := time.UnixMilli(1_700_000_000_000).UTC()
if err := s.MarkSent(ctx, "other_rule", at); err != nil {
t.Fatalf("mark sent: %v", err)
}
last, err := s.LastSent(ctx, "service_down")
if err != nil {
t.Fatalf("last sent: %v", err)
}
if !last.IsZero() {
t.Fatalf("want zero for a rule with no sends, got %v", last)
}
if err := s.MarkSent(ctx, "service_down", at); err != nil {
t.Fatalf("mark sent: %v", err)
}
last, err = s.LastSent(ctx, "service_down")
if err != nil {
t.Fatalf("last sent: %v", err)
}
if !last.Equal(at) {
t.Fatalf("want %v, got %v", at, last)
}
}
+19 -3
View File
@@ -3,7 +3,9 @@ package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
@@ -44,19 +46,33 @@ func DigestBodyHash(rule, body string) string {
}
// EnqueueDigestEntry durably records a suppressed care candidate worth
// resurfacing later. If a pending entry with the same rule+body already
// resurfacing later. If a LIVE pending entry with the same rule+body already
// exists, this is a no-op that returns the existing id and deduped=true —
// the same suppressed nudge repeating across ticks must not pile up into
// several copies of itself in the eventual bundle.
//
// "Live" carries the same expiry test PendingDigestEntries reads with, and for
// the same reason: a row past its expires_ts is still status='pending' until
// the sweep gets to it, and the tick enqueues before it sweeps. Deduping
// against one meant reporting deduped=true against an entry that will never be
// spoken — the caller drops the phrasing it just paid the LLM for and nothing
// reaches the bundle. Not yet swept must not mean still deliverable on the
// write side either.
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
hash := DigestBodyHash(rule, body)
var existing int64
err = s.db.QueryRowContext(ctx,
`SELECT id FROM digest_entries WHERE status = ? AND rule = ? AND body_hash = ? LIMIT 1`,
DigestPending, rule, hash).Scan(&existing)
`SELECT id FROM digest_entries
WHERE status = ? AND rule = ? AND body_hash = ? AND expires_ts > ? LIMIT 1`,
DigestPending, rule, hash, now.UnixMilli()).Scan(&existing)
if err == nil {
return existing, true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
// A real read failure is not "nothing there". Inserting anyway would
// duplicate an entry whose existence we never established.
return 0, false, fmt.Errorf("enqueue digest entry: dedupe lookup: %w", err)
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO digest_entries (rule, severity, body, body_hash, status, created_ts, expires_ts)
+43
View File
@@ -200,3 +200,46 @@ func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
}
}
}
// TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry — an entry past its
// expires_ts is still status='pending' until the sweep runs, and the tick
// enqueues before it sweeps. Deduping against one reports deduped=true for a
// row PendingDigestEntries will never hand back, so the suppressed nudge is
// dropped instead of held: the entry says the thing was recorded when nothing
// was.
func TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
created := time.Now()
expiresAt := created.Add(time.Hour)
first, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", created, expiresAt)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if deduped {
t.Fatal("first enqueue must not report deduped")
}
// A tick after the expiry, with the sweep not yet run: the same suppressed
// nudge comes round again and must be recorded afresh.
after := expiresAt.Add(time.Minute)
second, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", after, after.Add(time.Hour))
if err != nil {
t.Fatalf("re-enqueue: %v", err)
}
if deduped {
t.Fatal("an expired entry must not swallow a fresh one")
}
if second == first {
t.Fatalf("want a new row, got the expired one back: id=%d", second)
}
entries, err := s.PendingDigestEntries(ctx, after)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(entries) != 1 || entries[0].ID != second {
t.Fatalf("want the fresh entry %d pending, got %+v", second, entries)
}
}
+31 -3
View File
@@ -205,8 +205,30 @@ func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
// RescheduleReminder computes the next fire time for a recurring reminder and
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
// recurring or not pending. If no more valid fire times exist, marks it fired.
// recurring or not pending. If the schedule yields no further fire time at all,
// marks it fired.
//
// Two things the first version got wrong, both fixed 06-08-2026 (V-616).
//
// The cron expression is a WALL CLOCK statement — "0 9 * * *" is nine in the
// morning where the owner stands — but scanReminder hands back instants in UTC,
// and robfig's Next walks the calendar in the location of the time it is given.
// Computing from a UTC instant therefore produced the next 09:00 UTC, so the
// second occurrence of a daily reminder landed one UTC offset late and stayed
// there: 12:00 for a Moscow owner. Everything is converted to loc first, which
// also makes the walk DST-correct — the schedule keeps its wall-clock hour
// across a changeover instead of drifting an hour with the offset.
//
// And a missed occurrence used to KILL the reminder: any next fire earlier than
// now marked it fired, so a daemon down overnight ended a daily standup forever.
// Occurrences in the past are skipped instead, so the reminder rolls forward to
// the first one strictly after now. Skipping and not replaying is deliberate:
// the same no-backlog rule routine.DueAccepted follows.
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return s.rescheduleReminderIn(ctx, id, now, time.Local)
}
func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Time, loc *time.Location) error {
row := s.db.QueryRowContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders WHERE id = ?`, id)
@@ -225,8 +247,14 @@ func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time)
if err != nil {
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
}
next := sched.Next(r.NextFireTs.Add(time.Minute))
if next.IsZero() || next.Before(now) {
// Next is strictly after the time it is given, so the last fire cannot be
// returned again and no fudge minute is needed. The bound stops a schedule
// that somehow yields a non-advancing time from spinning here.
next := sched.Next(r.NextFireTs.In(loc))
for i := 0; i < 4096 && !next.IsZero() && !next.After(now); i++ {
next = sched.Next(next)
}
if next.IsZero() || !next.After(now) {
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
return err
}
+104
View File
@@ -0,0 +1,104 @@
package store
import (
"context"
"testing"
"time"
)
// A recurring reminder keeps its wall-clock hour in the owner's zone. The cron
// walk used to run on the UTC instant scanReminder returns, so "0 9 * * *"
// created for 09:00 Moscow rescheduled to 09:00 UTC — noon, and noon every day
// after that (V-616).
func TestRescheduleKeepsWallClockHour(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
msk := time.FixedZone("MSK", 3*60*60)
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk)
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
if err != nil {
t.Fatal(err)
}
if err := s.rescheduleReminderIn(ctx, id, fire, msk); err != nil {
t.Fatalf("reschedule: %v", err)
}
want := time.Date(2026, 7, 2, 9, 0, 0, 0, msk)
got := nextFire(t, s, id)
if !got.Equal(want) {
t.Fatalf("next fire: want %s, got %s", want, got.In(msk))
}
}
// A daily reminder survives a daemon that was down for days. The past
// occurrences are skipped, not replayed, and the reminder stays pending.
func TestRescheduleRollsForwardAfterDowntime(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
msk := time.FixedZone("MSK", 3*60*60)
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk)
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
if err != nil {
t.Fatal(err)
}
// The box comes back three days later, mid-afternoon.
now := time.Date(2026, 7, 4, 15, 0, 0, 0, msk)
if err := s.rescheduleReminderIn(ctx, id, now, msk); err != nil {
t.Fatalf("reschedule: %v", err)
}
rs, err := s.ListReminders(ctx, 10)
if err != nil || len(rs) != 1 {
t.Fatalf("list: %d reminders, %v", len(rs), err)
}
if rs[0].Status != ReminderPending {
t.Fatalf("status: want %s, got %s — downtime killed the recurrence", ReminderPending, rs[0].Status)
}
want := time.Date(2026, 7, 5, 9, 0, 0, 0, msk)
if got := nextFire(t, s, id); !got.Equal(want) {
t.Fatalf("next fire: want %s, got %s", want, got.In(msk))
}
// And exactly one fire is owed, not a backlog of four.
due, err := s.DueReminders(ctx, want.Add(time.Second))
if err != nil || len(due) != 1 {
t.Fatalf("due after roll-forward: want 1, got %d (%v)", len(due), err)
}
}
// A daily 03:00 reminder does not drift across the spring-forward changeover:
// the hour is wall clock, so the interval is 23 hours that day, not 24.
func TestRescheduleAcrossDSTKeepsHour(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
fire := time.Date(2027, 3, 27, 3, 0, 0, 0, berlin) // CET, day before the change
id, err := s.CreateReminder(ctx, fire, `{"text":"бэкап"}`, "0 3 * * *")
if err != nil {
t.Fatal(err)
}
if err := s.rescheduleReminderIn(ctx, id, fire, berlin); err != nil {
t.Fatalf("reschedule: %v", err)
}
got := nextFire(t, s, id).In(berlin)
if got.Hour() != 3 || got.Day() != 28 {
t.Fatalf("next fire: want 2027-03-28 03:00 local, got %s", got)
}
if d := got.Sub(fire); d != 23*time.Hour {
t.Fatalf("gap across spring forward: want 23h, got %s", d)
}
}
func nextFire(t *testing.T, s *Store, id int64) time.Time {
t.Helper()
var ms int64
if err := s.db.QueryRow("SELECT next_fire_ts FROM reminders WHERE id = ?", id).Scan(&ms); err != nil {
t.Fatalf("read next_fire_ts: %v", err)
}
return time.UnixMilli(ms).UTC()
}