Compare commits

..

63 Commits

Author SHA1 Message Date
claude 12667fd3b8 Merge V-555: the self prompt asks for the present tense (#202) 2026-08-05 23:06:48 +04:00
claude e4fd6140a9 the self prompt asks for the present tense (V-555)
Measured on the box: "глаголы в прошедшем времени с окончанием -ла",
copied from the query prompt where it fixes her gender, was read by the
resident model as an instruction to use the past tense throughout. She
answered "я вела заметки" and "если ты разрешил, я управляла домом",
which makes a live capability sound finished.

The gender rule stays, without the example.
2026-08-05 23:06:48 +04:00
claude 2ba5d0a60e Merge V-555 tail: she does not look herself up (#201) 2026-08-05 23:05:09 +04:00
claude c6b11a6d1d she does not look herself up (V-555)
Two defects found probing the new source on the box.

"кто ты" was answered from one of his notes. The self source sat below
memory and notes, which match by proximity and have no idea the subject
is her. It belongs above all three: a question about her has no answer
in his data either.

And PhraseQuery opens every answer with "вот что я нашла: ", which is
deliberate — it marks the answer as a lookup. Her own description is the
one subject she did not look up, so this is PhraseSelf instead, same
read-only discipline and its own opener. The Stub reads the description
out as it stands, which needs no fallback: it is already her voice.
2026-08-05 23:05:09 +04:00
claude 45622eff3d Merge V-555: a question about herself has an answer (#200) 2026-08-05 23:00:03 +04:00
claude 5815f0b8f3 a question about herself has an answer (V-555)
"что ты умеешь" reached the personal boundary, which claimed it as his
and said "не знаю — не нашла у тебя такой записи" about her own
description. Letting it past would be no better: SearXNG answers about
somebody else's assistant.

A self query source above the boundary, reading one frozen description.
It is NOT a note — notes are his, and a note about her would come back
for "что я записал", would be fed to the digestion worker as something
he said, and would be recalled by proximity for questions that are not
about her.

The description names only what this box does. Everything that depends
on config — the house, the LAN, the feeds, the list, weather, telegram —
is named as depending on what he allowed, and a test pins that split:
inventing a capability here is the same defect as inventing a fact.

topicSelf is scored like every other topic, with a narrow keyword floor
for the no-embedder case. "что ты умеешь" moved off topicOther, where it
had been sitting so an attention question had something to lose to — a
phrasing on two sides never clears the margin. TestONNXTopics 38/38 ->
43/43 on held-out utterances.
2026-08-05 22:59:56 +04:00
claude f68d49d9e2 Merge V-554 tail: a device's history is not a scan request (#199) 2026-08-05 22:45:10 +04:00
claude 36bc603f52 a device's history is not a scan request (V-554)
Found verifying the three fixes on the box: "кто изобрёл телефон" ran a
LAN scan and answered "нашла 3 устройства". The network seed set opens
with "кто в сети сейчас" and names devices throughout, so a "кто ..."
question about any device noun landed there.

Three topicOther seeds, same shape as the V-553 fix. TestONNXTopics
34/34 -> 38/38 on held-out utterances, and a real scan is still a scan.
2026-08-05 22:45:10 +04:00
claude 59214b4fdd Merge V-554: three defects that made an ordinary conversation go wrong (#198) 2026-08-05 22:40:38 +04:00
claude e94c868160 a chat prompt says which turn to answer (V-554)
Prior turns were joined with newlines and nothing else, so the model got
four unlabelled lines and no way to tell which one was the question. It
answered an earlier one: asked "как дела" after a question about the
telephone, she carried on about the telephone. Four turns live for
fifteen minutes, so the line she answered was often minutes old.

One user message still, because the template constraint that forced the
flattening is real. The turns are labelled as his own earlier words and
the current utterance is named as the one to answer. With no history
the message is the utterance alone, unchanged.
2026-08-05 22:40:28 +04:00
claude de4c47459a the personal boundary lets a narrative world question through (V-554)
"расскажи про Байкал" was refused as his by 0.0052. Every world seed
opened with an interrogative, so a world question phrased as an order
landed nearer "я тебе рассказывал об этом?" — the same verb about his
own words. Four narrative seeds on the world side.

TestONNXPersonalBoundary 25/25 -> 29/29 on held-out utterances, and the
control "я рассказывал тебе про байкал?" is still his. TestONNXTopics
unchanged at 34/34.
2026-08-05 22:36:10 +04:00
claude 27bb9119fb clarify steps aside when the next turn is its own request (V-554)
A parked question consumed whatever came next. One act she could not
fulfil ate three turns: "выключи свет в спальне" asked "Что сделать?",
and "кто изобрёл телефон" was scored as an answer to it, then "как
дела" after that. Nothing tested whether the words could be an answer.

The test is two offline token checks that already existed for other
callers: a question shape, or a capture verb. It fires only where the
answer filled nothing, so an answer that closes the gap still lands
whatever shape it has, and the retry budget is untouched — the count
was never the problem.
2026-08-05 22:34:01 +04:00
claude 0ab5dc1482 Merge the personal boundary day-word seeds (#197) 2026-08-05 21:57:32 +04:00
claude 35ae1f41da the personal boundary reads the day-word frame too (V-553)
The topic seeds let "какой сегодня праздник" and "что интересного
произошло сегодня в мире" past the weather source, and the personal
boundary refused them one source further down: "не знаю — не нашла у
тебя такой записи" about a public holiday.

Same defect, same mechanism, one layer lower. "что у меня сегодня" is a
personal seed and worldSeeds had nothing in that frame. Two seeds fix it.

TestONNXPersonalBoundary 22/22 -> 25/25, nothing regressed.
2026-08-05 21:57:32 +04:00
claude a9db82b04c Merge the day-word topic seeds (#196) 2026-08-05 21:54:38 +04:00
claude 278eeeffdf the world question that names a day is not weather and not his (V-553)
Two recognisers claimed world questions naming a day, both by the same
mechanism and neither by its keyword floor.

topics: weather was the only topic whose seeds carry a day word, four of
eight. So every "какой сегодня X" landed nearest it. "какой сегодня
курс доллара" cleared the margin by 0.0220 and "какой сегодня
праздник" by 0.0398, against 0.0883 for a real weather question, and the
gate asked "для какого города?" about the dollar.

The margin was not the knob: 0.0398 is not a coin flip, and raising the
bar far enough would take real weather with it. topicOther was missing
the negative class. Six seeds, four naming a day and two carrying the
"какой сегодня X" frame itself — a frame both topics use has to sit on
both sides, or the side that owns it wins every noun it has never seen.

personal boundary: the same shape one layer down. "что у меня сегодня"
and "когда моя встреча" put "when does a thing happen" on the
personal side and no world seed answered it, so "во сколько закат
сегодня" was refused as his. Three world seeds, each carrying сегодня,
which is the half of the frame that does the pulling — without it they
caught nothing.

Measured, both opt-in against the ONNX embedder homesrv runs:
  TestONNXTopics           27/27 -> 34/34 (7 new cases, none regressed)
  TestONNXPersonalBoundary 19/19 -> 22/22 (3 new cases, none regressed)

The control matters as much as the fix: "во сколько у меня встреча" is
the same frame about something that IS his, and it holds at +0.0842,
unchanged from before the seeds moved.
2026-08-05 21:54:28 +04:00
claude b23596f54f Merge the calendar narrowing (#195) 2026-08-05 21:41:00 +04:00
claude 6e3bb3be97 each agenda grammar is tested against its own example (V-552)
Replaces a test whose name promised more than its body checked: it
looped the grammars asserting Pattern != nil, which regexp.MustCompile
already guarantees at init. Asserting the grammars pass IsAgendaQuestion
would be true by construction, since the first arm is that same loop.

A hand-written example per grammar name catches what neither does: a
grammar edited until it no longer matches the case its comment gives,
and a new grammar nobody wrote an example for.
2026-08-05 21:40:49 +04:00
claude aa7ef33bbf the calendar answers his day, not any day (V-552)
queryCalendar matched on a day word and stepped aside only on weather
wording. Every world question naming a day was claimed by it and answered
with an empty schedule: "какой сегодня курс доллара" replied "на
05.08.2026 ничего нет", which reads as an answer about a subject she
never looked at. All four probe utterances have an answer in search, and
search sits below the calendar.

V-474 fixed one instance of the class. Sunset, holidays, exchange rates
and world news are the same class and weather wording does not cover them.

router.IsAgendaQuestion is the narrowing. Its first arm reuses
AgendaQueryGrammars, so the rule that routes a question to the query
chain and the rule that lets the calendar answer it cannot drift. The
second reads a scheduled-thing noun, wider than the grammars because
"какие встречи завтра" carries no possessive. The third claims a
question that names no subject of its own.

A continuation is exempt: "а завтра?" cannot name an agenda, and this
is the only date-aware source there is.
2026-08-05 21:40:15 +04:00
claude f2851b3729 Merge the routing re-measurement (#194)
V-320 items 2 and 3. Cascade + resident model is 75.8% full / 80.2%
intent-only at p50 1.19s on the 91-case fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:31:31 +04:00
claude d49067f7dd eval: score and time the resident model as router (V-320)
Item 2 was blocked because the resident llama-server binds --port 0 inside the
container, so no host process can reach it. Cleared by taking the first of the
three ways out the task listed: a second llama-server on the same gguf, on a
fixed host port.

Cascade + resident model scores 75.8% full and 80.2% intent-only at p50 1.19s
and p95 1.65s, on the fixture as it now stands at 91 cases. That is a new
baseline rather than a movement: 14 cases were added since the 77-case number
in CLAUDE.md.

The model alone scores 37.4% full against 61.5% intent-only. The gap is slots,
not routing. Every reminder case leaves the time to the daemon, which is what
the contract asks of it, and the cascade fills them.

Item 3: the ~6s figure recorded in the task was one sample through the whole
of POST /api/chat, not the router, and is not comparable.

Item 4 is still not run. Killing the resident llama-server needs a permission
this session does not have, and it now has a second half anyway, since with the
workstation up only killing both proves the classifier answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:31:31 +04:00
claude b528a8f5c9 Merge the bare-hour fix (#193)
V-551. "завтра в семь" booked the reminder for the current clock. dateparser
needs the colon, so the script gives it one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:17:30 +04:00
claude 44320ee496 dates: a bare hour after a day word is an hour, not the current clock (V-551)
At 21:12 "напомни мне завтра в семь позвонить маме" confirmed a reminder for
21:12 tomorrow. The hour was dropped and the wall clock carried onto the named
day. She did not ask; she named a time nobody gave her, on a path that fires.
A bare "напомни в семь" declines correctly, so adding "завтра" turned a decline
into an invented answer.

dateparser only reads a bare hour when it carries a qualifier or a colon.
"завтра в 7" keeps the current clock and "завтра в 7 часов" is read as seven
hours from now, which moves the day as well. English "at 7" fails identically,
so this is not a Russian defect and both prepositions are rewritten.

The script now gives it the colon: "в 7", "в 7 часов" and "at 7" become
"в 07:00" beside the existing утра/вечера rewrites. A duration is untouched,
because "через 2 часа" has no preposition to match, and so are "в 7:30",
"в 30 минут" and "в 2026 году".

The stub parser has always read the token after the day word, so the floor was
right and the production parser was not. No test on the stub could have caught
this. The four new cases are in TestPythonDateParser, which runs where
dateparser is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:17:21 +04:00
claude 337a777d2e Merge the reach measurement with the resident model (#192)
V-517. The model alone reaches Praxis 0/12, so the V-516 stage-0 grammars are
the only path there. Cascade+llm is 28/30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:03:23 +04:00
claude 576dfd8b4c eval: the resident model never reaches Praxis either (V-517)
V-405 measured reach with the classifier only, and the LLM router is the
deployed default, so 16/30 was the floor rather than the shipped behaviour.
TestReachWithLLMRouter scores the same 30 cases with the model, gated on
MAVEN_LLM_URL like TestLLMRouterBaseline.

The open question was whether the model writes a literal Praxis capability
into the fn slot and reaches a service the classifier structurally cannot. It
does not. Praxis is 0/12 with the model alone, exactly what the classifier
alone scores, and all twelve fail the same way: local, empty fn. Nothing in the
router prompt names a Praxis capability, so there is no string for it to write.

So V-516's stage-0 grammars are the only path to Praxis, not a determinism
argument. Through the cascade the model scores 28/30 with praxis 11/12, one
point above the classifier baseline. Hexis is 10/10 either way.

Overreach is 1 in both configurations, under the 4 the harness asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 21:03:05 +04:00
claude ed9db8dc44 Merge the history side fix (#191)
V-456. A question about what she recorded is answered as her turn, not his.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:54:20 +04:00
claude a8710c859b history: answer the side of the question that was asked (V-456)
"что ты записала сегодня?" was recognised as a history question and then
answered with "ты говорил: …". The rows are right — a tapped fact is one act
seen from two sides — but the sentence hands the question back instead of
answering it.

historyAsks returns which side was asked and queryHistory phrases from it,
including the nothing-found reply. His side is tested first, because "отмечать"
is on both verb lists and "что я отметил" is not a question about her.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:54:11 +04:00
claude 1bacbb7952 Merge the wipe (#190)
V-494 part 1. Store.Wipe drops every table and rebuilds from the migrations;
mavend -wipe is a dry run and -confirm-wipe deletes. QA isolation and
onboarding are the remaining two thirds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:51:55 +04:00
claude 82d9a3324e wipe: one command empties the box and leaves it standing (V-494)
There was no documented way to repair a poisoned box. Two invented facts
written during QA disabled world answering for every later turn (V-470), and
revert voids the SQL row while leaving the vector behind (V-493). This is the
operation that undoes both.

Store.Wipe drops every table sqlite_master reports and rebuilds from schema.sql
plus the migrations, rather than deleting from a hand-written list. A list has
to be edited whenever a table is added, and the once it is not, the wipe leaves
personal data behind while reporting success. It vacuums afterwards, because
free pages still hold readable text.

mavend -wipe prints every table and its row count and exits. That alone is a
dry run and answers what a QA session actually asks: what is on this box. It
deletes only with -confirm-wipe. Two flags, because the destructive reading of
one flag is the reading a mistyped command gets.

Nothing outside the database moves. Config, models, passkeys.json and the
encryption key are files.

QA isolation and onboarding are the other two thirds of V-494 and are not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:51:46 +04:00
claude 03d48ab789 Merge the intake form on /tasks (#189)
V-511. Confirming a candidate asks for a definition of done, resolves a
blocked-on name against nexus, and books a reminder when a date is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:46:44 +04:00
claude 570b204571 board: /tasks confirms a candidate into an open task (V-511)
The candidate row is now an intake form, not a button. Confirming asks for a
definition of done and refuses without one, takes an optional blocked-on name,
and carries the date and the importance through.

The blocked-on is a name in the form and a canonical nexus id in the store.
promoteCandidate resolves it over the new ipc.ResolveEntity seam and stops the
confirmation on an ambiguous or unplaceable name rather than picking.

A date set here books a reminder for 09:00 that morning. That is the only
unprompted delivery the persona allows, because the owner set the date himself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:46:35 +04:00
claude cb350efb19 a surface can ask Nexus for the id behind a name (V-511)
blocked_on stores a canonical entity id, so the form that fills it needs a way
to turn "Kate" into one. ipc.ResolveEntity is that seam: the store adapter
refuses it, because identity is not the store's to answer, and the daemon
overrides it with the Nexus client the voice path already holds.

Three outcomes are kept apart, because a caller deciding whether to store an
id has to tell them apart. No nexus block is ErrNotImplemented. A miss is
ErrNoEntity. Several matches come back Ambiguous with the names, and the
caller asks — picking one is how a task ends up blocked on the wrong person
with nobody able to see it happened.

An outage stays the transport error. "There is no such person" and "Nexus is
down" must not read the same.
2026-08-05 20:42:28 +04:00
claude 43b0c32154 Merge the task edit path (#188) 2026-08-05 20:39:29 +04:00
claude b95a0278a4 /tasks edits a task in place (V-509)
The open list carries the text, the date and the importance as an inline form
with a save button. The status is not in it: that ladder is one-way and has
its own two buttons.

The step-up gate was re-argued rather than inherited, which is what the task
asked for, and edit stays ungated. It rewrites a line on a list he reads
himself, the same blast radius drop already has here, and the store refuses
the two edits that would cost something. A collision is named ("another open
task already says this"), not merged.

A weight outside the three rungs keeps its own option in the select, or
saving an unrelated edit would silently reset it to normal.
2026-08-05 20:39:22 +04:00
claude a6b17ada8b a live task can be edited, a resolved one cannot (V-509)
SetTaskStatus was the only mutation on a task row, so a typo in a dictated
task was permanent and a deadline could not move. EditTask rewrites the three
fields capture set — text, due date and weight — and nothing else. Status
stays the one-way ladder SetTaskStatus owns.

Two things the task asked to settle.

A text edit re-normalises the dedupe key and can collide with another live
row. That is ErrTaskDuplicate, a refusal rather than a merge: two live rows
carry two provenances, two capture times and possibly two external
identities, and merging picks a winner for all three with nobody asked. The
surface names the row that holds the text.

A resolved task is refused outright (ErrTaskResolved). Its text is the record
of what was finished, and rewriting it rewrites history.

due nil clears the date, because clearing has to be sayable — an absent date
and "remove the date" cannot be one argument.
2026-08-05 20:39:11 +04:00
claude 92949e886f Merge the Vikunja MCP preload note (#187) 2026-08-05 20:29:47 +04:00
claude 6b2667b7af CLAUDE.md: load the Vikunja MCP schemas in one call (V-445)
The four schemas are deferred, so a session that looks them up on first use
spends four round trips on tools it always needs. One ToolSearch line at the
start covers them.

Also records the update_task quirk: a call carrying a description resets done
to false, so closing a task with a write-up takes two calls.
2026-08-05 20:29:47 +04:00
claude 494a7721e0 Merge the CLAUDE.md pronoun fix (#186) 2026-08-05 20:26:22 +04:00
claude 46b58f0278 CLAUDE.md names the owner instead of saying "he" (V-550)
The third person here leaked into answers addressed to him, where it reads as
talking about the person reading the reply. Six lines now say "the owner".

"you" is not available in this file: CLAUDE.md addresses the agent, so "you"
there means the agent.

One "him" stays, in the persona block. That line states that Maven must never
say "он"/"его" about the owner, which is a fact about required Russian output
rather than a reference.
2026-08-05 20:26:22 +04:00
claude a88c984d16 Merge the definition of done and the blocker (#185) 2026-08-05 20:17:38 +04:00
claude 496559c9dd tasks carry a definition of done and a blocker (V-510)
Migration #22 adds done_when and blocked_on to tasks, both NOT NULL DEFAULT
''. "He has not written one" and "there is nothing to write" are the same
state here, so no caller has to tell NULL from empty.

blocked_on is a canonical Nexus entity id, never a name. It names a person
and identity lives in Nexus, so free text here would be a second answer to a
question Nexus already owns. The caller resolves before it writes.

Both columns round-trip through ipc.TaskAPI: on ipc.Task, settable at intake
through CaptureTaskReq, and writable afterwards through the new
SetTaskFields, which is deliberately not one-way — he may sharpen a
criterion, and a blocker clears when the person answers.

SetTaskStatus now refuses candidate → open when done_when is empty
(ErrTaskNoDoneWhen, mapped across the wire), the same refusal
ParseTaskCapture makes for a capture marker with nothing after it: confirming
work whose finish line nobody wrote is how a board fills with rows that can
never leave it. Dropping such a candidate stays legal, and the /tasks confirm
button now says what is missing instead of surfacing a not-found.

One caller skips the gate. CaptureTask promoting a candidate he stated out
loud would otherwise be denied intake rather than asked for a criterion, and
a direct open capture never carried one either. The gate belongs to the
deliberate promotion on /tasks, where V-511 puts a form.
2026-08-05 20:17:30 +04:00
claude d21b4a65da Merge the board status change and the stall counts (#184) 2026-08-05 19:54:21 +04:00
claude be62660be9 /tasks counts stall shapes, and assesses none of them (V-512)
Step 5 of the board build. internal/tasks/stall.go counts three shapes —
overdue, sitting longer than StallDays, waiting for confirmation — and states
nothing about what any of them means. That is the line
internal/memory/behavior.go already drew for habits, and the reason is the
same: a 1.7B asked to judge will agree fluently and launder a guess into a
decision. A test asserts the wording carries no assessment.

Sitting is measured from created_ts, the only clock a live row carries: the
store stamps resolved_ts and nothing else. So "no state change in eleven days"
is exactly "captured eleven days ago and still live", which is narrower than
the plan's wording and is the claim the data supports. A candidate is never
counted as overdue, because its due date is Maven's reading of a mail rather
than a deadline he set.

Not a nag. No tick rule reads the counts; they go on /tasks and into the list
reply when he asks, and tickLoop.dayPlan still does not read tasks at all. The
empty case renders as nothing: "ничего не залежалось" appended to every list
read is a nag with a friendly face.

Three say entries, so the page and the spoken list cannot word it differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 19:54:21 +04:00
claude cd6549fa51 the daemon moves the task he named, or says which part it cannot (V-512)
The other half of the stage-0 rule. actionAct intercepts task_status ahead of
both ecosystem clients, because the board is Maven's own store and reaching a
capability registry would answer a question about his task list with a gap.

Three answers besides the move, and none of them guesses. No match says so.
More than one match asks which, since closing the wrong task marks work he
never finished as done. No task named asks which too, because the router claims
the turn without the referent and the list lives here.

Matching is normalised containment either direction, over the same
store.NormalizeTaskText key capture dedupes on — he shortens what he said as
often as he pads it. Deliberately not fuzzy: a ranked best guess always returns
exactly one answer, and the one thing this has to be able to say is that it is
not sure.

A candidate he says is done takes both legal store moves. The store refuses
candidate → done, and saying it out loud IS the confirmation the candidate was
waiting for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 19:53:47 +04:00
claude c259ed6c73 a spoken status change reaches the board at stage 0 (V-512)
Step 4 of the board build (docs/plans/15-board-surface.md). Naming a task
instead of its position reached nothing: "закрой задачу купить молоко" routed
act, found no allowlisted fn, and the gate asked "Что сделать?". The position
path already worked through resolveCandidate, but only in the two turns after
she read the list out.

TaskStatusGrammar is the same shape TaskCaptureGrammar uses — matches broadly,
decides in Build, no eighth intent — and fills the fn slot with task_status,
which is neither a Hexis capability nor a Praxis one. Three conditions, all
required: the board noun, so no ordinary sentence claims a turn; exactly one
status class, since "готово, убери" names two and asking beats picking; and a
status word matched as an imperative exactly or a stative by lemma. So a bare
"готово" and a bare "закрой" are not this rule's, and the second belongs to
Praxis, which claims it already.

Two lexicon sets rather than one with a value. The store records which of the
two transitions happened and /tasks shows it: work he chose to stop is not work
he did.

Measured on the fixture, two new cases (ru-act-020, ru-act-021). Classifier +
ONNX 62/89 (69.7%) → 64/91 (70.3%); cascade+llm 67/89 (75.3%) → 69/91 (75.8%,
80.2% intent-only) at p50 1.225s. Both new cases claimed at stage 0, no case
regressed, clarify counts unchanged at 3 false / 1 missed.

The task's own warning stands: every such grammar runs its parser ahead of the
resident model on every turn, so this is the last one that is free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 19:53:20 +04:00
claude 8f2377d27d Merge the subjectless reminder gate (#183) 2026-08-05 19:36:29 +04:00
claude d850f1f5fd a bare "напомни" asks instead of failing to parse (V-548)
The subjectless-reminder gate has been dead since V-383. It tested
`d.Slots.Text == ""`, and that slot is never empty: fillSlots hands it the
utterance when the model names nothing narrower. Measured on the box on
05-08-2026 — "напомни" alone routed to IntentReminder with Text:напомни,
reached actionReminder, and answered "не получилось разобрать время
напоминания." A parse error for a request he never finished asking about.
"ну напомни же" did the same.

The test is now what the slot CONTAINS. reminderHasSubject discounts the
reminder verb by lemma and the filler particles, and asks whether anything
is left. A day or an hour counts as a subject, which is why this does not
reuse cmd/mavend/reminderbody.go — that one strips the time words too.

filler_particles is the lexicon's 16th set. Not a stopword list: every word
in it is one that cannot BE a reminder's subject.

Measured against the 87-case fixture with and without the change: 65/87
both ways, identical clarify counts, because no case exercised the shape.
So amb-007 "напомни" and amb-008 "ну напомни же" were added, both
want_clarify. At 89 cases the cascade scores 67/89 (75.3% full, 79.8%
intent-only), 3 false clarifies / 1 missed, p50 1.199s — the two new cases
clarify, and nothing else moved. The classifier path still guesses both
(62/89, 8 missed clarify); the gate is on the LLM arm only.

The box needs a rebuild for this to take effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 19:36:29 +04:00
claude 4ed951040f Merge the scriptable chat path (#182) 2026-08-05 19:13:39 +04:00
claude 8d2c1b6f99 the simulator can script a chat reply (V-542)
Item 4. actionChat calls h.phraser.PhraseChat, and LLMPhraser posts raw
HTTP to /v1/chat/completions rather than going through the llm client
scriptedLLM stands in for. The simulator wired phraser.NewStub() anyway,
so no scenario could assert what she says on a chat turn: every reply came
back as a pick from fallbacks_ru_v1.json, four variants deep, and the same
scenario returned "тут я пас." one run and "не знаю, честно." the next.

scriptedPhraser embeds the Stub and overrides PhraseChat only, reading the
same script entries the router reads. A reply is accepted in either shape
the phrasing contract allows, the {"response","mood"} object or plain text,
so a scenario writes one thing for both paths.

An unscripted chat turn returns an error rather than a fallback, matching
scriptedLLM: actionChat logs it and uses ChatFallback(), so scenarios that
never meant to assert a chat reply behave as before.

conversation_anaphora turn 4 now pins its text — the reply that asks which
device he means, which is the recorded defect in the box's own words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:13:39 +04:00
claude ee4c26f13c Merge the conversation fixture (#181) 2026-08-05 19:02:32 +04:00
claude 49cadcf1b7 a conversation about one object has a fixture (V-542)
Five Russian turns, one monitor, four questions that say "он" and never
name it again. Item 3 of the task: the shape had nowhere to fail, because
the routing fixture scores one utterance at a time and a conversation that
breaks on turn 2 cannot lose a point there.

Routes are scripted exactly as the box produced them on 05-08-2026. Turn 1
files a fact despite "давай поболтаем", the questions go to query, turn 4
goes to chat, and none of the five replies names the monitor. Four steps
assert the reply LACKS "монитор" and are marked WRONG in their notes with
what each must become.

The absence assertion is forced, not chosen. The simulator wires
phraser.NewStub(), and PhraseChat posts raw HTTP to /v1/chat/completions
rather than through the llm client the harness scripts, so a chat reply
cannot be scripted at all. The wrong replies come from
fallbacks_ru_v1.json, which picks between four variants per turn, so
asserting a string would pin the picker. Missing referent holds whichever
variant she reaches for.

Items 1 and 2 stay open: they are owner decisions about which store a
referent comes from and whether "давай поболтаем" claims a turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:02:32 +04:00
claude 988c2ae981 Merge the topic query vector fix (#180) 2026-08-05 18:51:50 +04:00
claude 982c25118a topic seeds get a query vector to score (V-547)
turnIsAbout scored t.vec, and t.vec was set in one place: queryEmbed, the
source at actions_query.go:125. Every topic source sits above it — attention,
list, feeds, home, network, weather. So best was handed an empty slice on every
deployed turn, returned ok=false, and all six recognisers ran on their keyword
floors. The seeds have decided nothing outside the tests since the mechanism
landed.

TestONNXTopics passes because it embeds each utterance itself and calls best
directly. That is the shape that hid this for a month: it measures the scorer
and never the wiring. Found on the box instead — "что мне нужно купить" was
answered from an old note about a monitor, and the seeds place it as the list by
0.0841.

turnVector computes the vector on first ask and caches it on the turn;
queryEmbed returns early when it is already set. Chosen over moving the embed
source up the list, because the cost is then paid only by turns that ask a
topic source, and the order of querySources keeps meaning what its comments
argue for.

One scenario assertion moved, and it is a behaviour change rather than a bent
test. morning_missed step 5 pinned "не знаю" for "что я пропустил?" with an
unresolved Praxis item on the board. isAttentionQuery does not match that
phrasing and the topicAttend seeds carry "что важное я пропустил" almost
verbatim, so she reads the item back now. Reading a surfaced item aloud is not
inventing a morning summary, so the floor that step exists for still holds;
what moved is which source answers.
2026-08-05 18:51:42 +04:00
claude de10d7664f Merge the list read-back seeds (#179) 2026-08-05 18:43:19 +04:00
claude a08403087f list read-back asks the seeds; add and clear keep their tables (V-522)
internal/router/list.go was the last file on the sweep, and the answer is a
split rather than one mechanism. What the four paths need is different, and the
V-529 comment in the file already had half of the argument.

Reading a list back needs one bit — is this about the list — so topicList joins
the subjects in cmd/mavend/topics.go and queryList calls turnIsAbout.
listQueryPrefixes stays as the offline floor. Which list he named is a noun in
the dictionary either way, through the new router.ListNamedIn, which scans the
whole utterance: the seeds claim a read-back without eating a prefix, so "что
мне нужно в аптеке" has nothing for takeListTag to read the front of.

The other three keep their phrase tables, and the header says why. Add and
remove have to know WHERE the item starts, and a cosine over a whole utterance
does not say which byte the milk begins at. Clear deletes the list, so a false
claim loses rows he cannot get back — that is not the trade a margin makes.

Measured on TestONNXTopics, four held-out cases added: 27/27, no case
regressed. Two existing margins moved by under a hundredth because the new
seeds became the runner-up, both still far clear of topicMargin.
2026-08-05 18:43:13 +04:00
claude 3e87ad7eb6 Merge the feed topic seeds (#178) 2026-08-05 18:37:46 +04:00
claude 47128bb1ca feed questions ask the seeds, not a stem list (V-522)
Whether a turn is about the feeds is a question about meaning, and
internal/router/feeds.go was deciding it with three word lists. Their own
comments admit the shape: vagueNouns exists because "что нового?" is the most
common opener in the language and it matched a feed noun, so a daemon with no
feeds block answered a greeting with a configuration status.

So topicFeed joins the four subjects in cmd/mavend/topics.go and queryFeeds
calls turnIsAbout. The word lists stay as the offline floor, reached through
feedFloor, and they are allowed to stay narrow now that they are not the only
answer. The category is not a recogniser — a topic is marked by a preposition —
so it comes out of the utterance either way, through the new
router.FeedCategoryOf.

The greeting is handled by the shape rather than by a bail-out list. "что
нового" is a topicOther seed, close enough to the feed seeds that a bare
"что нового?" cannot clear topicMargin, and a thin call goes to
ParseFeedQuery, which declines a vague noun with no topic beside it.

Measured on TestONNXTopics, four held-out cases added: 23/23, and no case that
passed before it regressed. One seed pair was added during the measurement,
because "какие сегодня заголовки" first read as weather — "какая сегодня
погода" was the nearest thing in the whole set carrying "сегодня".
2026-08-05 18:37:38 +04:00
claude 8e3b288858 Merge the ordinal lexicon change (#177) 2026-08-05 18:17:03 +04:00
claude 52a4772962 ordinal selection asks the lexicon, and declines a half hour (V-522)
Group 1 of the sweep listed cmd/mavend/ordinal.go, and it was still
picking a position by stem prefix: {"перв", 1}, {"втор", 2}. The lexicon
already carries every form with its position and "последний" as -1, up to
twelve rather than five, so parseOrdinal reads that instead. "вторым" and
"седьмую" were missed before and now land.

A wider set opens one hole the stems did not have. Russian names a half
hour with the genitive ordinal of the hour it is entering, so "в половине
восьмого" would read as the eighth thing she read out. The forms of
"половина" move into the lexicon as half_hour, where the clock rewrite in
internal/router/halfpast.go and this refusal read one copy, and
parseOrdinal skips an ordinal standing behind one.

Six new parseOrdinal cases. cmd/mavend, internal/router, internal/lexicon
and internal/calendar all pass.
2026-08-05 18:16:54 +04:00
claude 52d80394ec Merge the audio-in routing measurement (#176) 2026-08-05 17:06:05 +04:00
claude 42a7bd88b2 offload: speech-to-text stays two stages (V-486)
The one-call audio path is refused by measurement, so the inventory says
so where a future caller would read it.
2026-08-05 17:06:05 +04:00
claude b789676244 audio-in routing measured: transcribe then route (V-486)
Four paths on the same 72 RU cases with the daemon's own router prompt.
Text in scores 90.3% intent-only. Whisper then route scores 84.7% at p50
1372ms. The workstation transcribing then routing scores 83.3% at p50 997ms.
One call from audio straight to a route scores 54.2%.

The one-call number is not a transcription failure. Four clips it
transcribes word for word it then routes wrong or refuses, and the emitted
slot holds the tail of the sentence with the interrogative head gone. A
3.5k-character classification prompt and an audio part compete for
attention, so transcription needs its own call with a short instruction.

The two speech-to-text paths differ by one case, which is noise on 72, so
the choice is latency and transcript quality. The workstation wins both.
mavgpud.json on the workstation is restored to its text-only args.
2026-08-05 17:04:45 +04:00
claude b621c477a0 Merge the e5-small routing plan (#175) 2026-08-05 16:23:22 +04:00
74 changed files with 3410 additions and 311 deletions
+33 -8
View File
@@ -43,7 +43,7 @@ are the work.
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented
free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner hears instead of an invented
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
@@ -90,7 +90,7 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
daemon there listens to nobody. They belong on a client machine where he is standing.
daemon there listens to nobody. They belong on a client machine where the owner is standing.
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
needs building to allow it, but no such machine exists yet. **The consequence: the wake
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
@@ -165,6 +165,15 @@ that stood here until 2026-08-02 was contention, not the model.** See `docs/eval
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
work off the bakeoff table.
**Re-measured 2026-08-05 on the fixture as it now stands, 91 cases** (V-320 item 2,
`docs/evals/2026-08-05-routing-resident-model.md`): cascade + resident model scores
**75.8% full / 80.2% intent-only at p50 1.19s / p95 1.65s**. That is a new baseline and not
a movement, because 14 cases were added since the 77-case number above. The model alone
scores 37.4% full against 61.5% intent-only, and the gap is slots rather than routing: it
routes `reminder` and leaves the time to the daemon, which is what the contract asks. To
re-run it, start a **second** llama-server on a fixed host port — the resident one binds
`--port 0` inside the container and no host process can reach it.
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
@@ -227,7 +236,13 @@ llama-server in that run), so judge it again before quoting a cascade number.
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name.
**These grammars are the only path to Praxis, not a faster one.** Measured
2026-08-05 with the resident model as router (V-517,
`docs/evals/2026-08-05-reach-llm-router.md`): the model alone reaches Praxis
**0/12**, the same as the classifier alone, because nothing in the router
prompt names a Praxis capability and there is no string for it to write.
Through the cascade it is 11/12. Deleting these rules costs every point. Praxis reach
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
@@ -281,7 +296,7 @@ Seeds are scoring data. Editing one moves a recogniser and must be re-measured a
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
(she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
@@ -291,7 +306,7 @@ world questions, so she needs to read external sources. What replaces it:
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
about Maven is reported to anyone, and inference stays on the box.
- **His data first, then the world.** Every source that reads his facts, notes, calendar,
- **The owner's data first, then the world.** Every source that reads the owner's facts, notes, calendar,
tasks or house runs before anything outside, and the personal boundary sits between them.
Reading beats recalling for a small model.
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
@@ -299,7 +314,7 @@ world questions, so she needs to read external sources. What replaces it:
homesrv answer when the search is empty, unreachable, or the line is down.
**Verified with the line down on 2026-08-05** (V-508,
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds he waited
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
instance that did connect keeps the full 8. **A Russian question reads
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
@@ -318,8 +333,8 @@ world questions, so she needs to read external sources. What replaces it:
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
it off again.
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
his stored personal notes to an upstream engine are different acts. Only the utterance goes
- **The owner's notes and facts are never search input.** Looking up why the sky is blue and
sending the owner's stored personal notes to an upstream engine are different acts. Only the utterance goes
out, never the persona block, history, or matched notes.
## Web UI conventions
@@ -341,6 +356,16 @@ Vikunja is the durable task store. A task holds the goal, the constraints and th
assumption ledger. Work without a task id is work nobody can resume, so a session that
has no id asks for one before it starts.
The MCP tool schemas are deferred, so load the four you actually use in ONE call at the
start of a session rather than one lookup per first use:
```text
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
```
`update_task` carrying a `description` resets `done` to false, so closing a task with a
write-up takes two calls: the description, then `done: true`.
## Session workflow
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
-113
View File
@@ -1,113 +0,0 @@
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
//
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
// project, and it names the stage 0 grammars as the high-precision label
// functions to start from. This runs them — the real ones, in the real
// buildRouter order — rather than a reimplementation, so a rule change moves
// the training data with it.
//
// A grammar that declines leaves the line unlabeled. Those go to the model, and
// keeping them is the point: a set labeled only by the rules teaches only the
// rules.
//
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
//
// The wakeword-act grammar is absent, because its allowlist is the deployment's
// enabled tool names and this tool has no deployment. Every other rule is here.
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/kami/maven/internal/router"
)
// label is one output row. The grammar name rides along so a reviewer can see
// which rule made the claim, and so a rule that turns out to be wrong can have
// its rows pulled without re-running everything.
type label struct {
Utterance string `json:"utterance"`
Intent string `json:"intent,omitempty"`
Grammar string `json:"grammar,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
Fn string `json:"fn,omitempty"`
Text string `json:"text,omitempty"`
Labeled bool `json:"labeled"`
}
// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
// load-bearing there and so it is here: the agenda rules must sit after the
// clock rules, Praxis before the capture marker, the narrative rules last.
func grammars() []router.Grammar {
var g []router.Grammar
g = append(g, router.SystemTimeDateGrammars()...)
g = append(g, router.AgendaQueryGrammars()...)
g = append(g, router.FeedQueryGrammar())
g = append(g, router.TaskListGrammar())
g = append(g, router.ListGrammars()...)
g = append(g, router.ReminderGrammar())
g = append(g, router.PraxisGrammars()...)
g = append(g, router.TaskCaptureGrammar())
g = append(g, router.NarrativeQueryGrammars()...)
return g
}
func match(gs []router.Grammar, utterance string) label {
out := label{Utterance: utterance}
for _, g := range gs {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
continue
}
d, ok := g.Build(m)
if !ok {
continue // the rule saw its shape and declined it
}
out.Intent = string(d.Intent)
out.Grammar = g.Name
out.Key = d.Slots.Key
out.Value = d.Slots.Value
out.Fn = d.Slots.Fn
out.Text = d.Slots.Text
out.Labeled = true
return out
}
return out
}
func main() {
gs := grammars()
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
out := bufio.NewWriter(os.Stdout)
defer out.Flush()
enc := json.NewEncoder(out)
var seen, labeled int
for in.Scan() {
line := strings.TrimSpace(in.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
seen++
l := match(gs, line)
if l.Labeled {
labeled++
}
if err := enc.Encode(l); err != nil {
fmt.Fprintln(os.Stderr, "labelgen:", err)
os.Exit(1)
}
}
if err := in.Err(); err != nil {
fmt.Fprintln(os.Stderr, "labelgen:", err)
os.Exit(1)
}
// Coverage on stderr, so the count is visible without polluting the JSONL.
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
}
+8
View File
@@ -24,6 +24,14 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
}
}
// The board is Maven's own store, so a spoken status change is answered here
// and never offered to an ecosystem client (Vikunja #512). First, because
// task_status is on no allowlist and no capability registry: reaching either
// of them would answer a turn about his own task list with a gap.
if dec.Slots.Fn == router.TaskStatusFn {
return h.resolveTaskStatus(ctx, dec)
}
// Praxis ecosystem tools: intercept before the system command executor.
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
+13 -2
View File
@@ -95,6 +95,12 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
return "", false
}
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
func listFloor(u string) bool {
_, ok := router.ParseListQuery(u)
return ok
}
// queryList — "что в списке покупок?", "что мне купить?".
//
// A query source, so it sits in querySources and either claims the turn or
@@ -102,10 +108,15 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
// source is: the notes pass would otherwise answer a list question with
// whatever note is nearest.
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
list, ok := router.ParseListQuery(t.dec.Utterance)
if !ok || h.dataStore == nil {
if h.dataStore == nil {
return "", false
}
// The seeds decide the subject and listQueryPrefixes is the floor behind
// them (V-522). Which list he named is a noun lookup either way.
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
return "", false
}
list := router.ListNamedIn(t.dec.Utterance)
items, err := h.dataStore.ListItems(ctx, list, "")
if err != nil {
log.Printf("voice: list items: %v", err)
+38 -4
View File
@@ -122,6 +122,12 @@ var querySources = []querySource{
{name: "network", answer: (*reactiveHandler).queryNetwork},
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
{name: "weather", answer: (*reactiveHandler).queryWeather},
// A question about her, above the three sources that search his own data
// (Vikunja #555). It has no answer anywhere else: below the boundary
// SearXNG answers about somebody else's assistant, and above it his notes
// answer by proximity — "кто ты" came back from a note of his, measured on
// the box, because the recall index has no idea the subject is her.
{name: "self", answer: (*reactiveHandler).querySelf},
{name: "embed", answer: (*reactiveHandler).queryEmbed},
{name: "memory", answer: (*reactiveHandler).queryMemory},
{name: "notes", answer: (*reactiveHandler).queryNotes},
@@ -292,6 +298,14 @@ const (
feedReadOut = 3
)
// feedFloor — the keyword test behind topicFeed, in the one-string shape
// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the
// gate has no use for; the caller reads it separately.
func feedFloor(u string) bool {
_, ok := router.ParseFeedQuery(u)
return ok
}
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
// (Vikunja #258).
//
@@ -299,10 +313,13 @@ const (
// never speaks; asking is the trigger. If that ever changes, the thing that
// changed is "Maven is not a nag", not a detail of this file.
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
q, ok := router.ParseFeedQuery(t.dec.Utterance)
if !ok {
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
// them (V-522). The category still comes from the utterance either way,
// because a topic is marked by a preposition and needs no recogniser.
if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) {
return "", false
}
category := router.FeedCategoryOf(t.dec.Utterance)
if !h.feedsOn {
// Claim only when nothing below can read the world. The reason this
// source used to claim unconditionally was that general knowledge would
@@ -327,7 +344,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
}
var picked []string
for _, n := range notes {
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
if !router.CategoryMatches(rss.NoteCategory(n.Text), category) {
continue
}
// The note carries title, summary, category tag and link; she reads the
@@ -339,7 +356,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
}
}
if len(picked) == 0 {
if q.Category != "" {
if category != "" {
return phraser.Q(phraser.QueryFeedsTopic, nil), true
}
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
@@ -360,6 +377,19 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
if isWeatherQuery(t.dec.Utterance) {
return "", false
}
// Weather was one instance of a wider class (Vikunja #552). Naming a day
// does not make a question his agenda: "какой сегодня курс доллара" and
// "во сколько закат сегодня" both answered "ничего нет", which reads as an
// answer about a subject she never looked at. All of them have an answer
// in search, and search sits below this source. So the question must ask
// about his schedule, not merely name a day.
//
// A continuation is exempt. "а завтра?" names no agenda and cannot: the
// subject was in the turn before it, and this is the only date-aware
// source there is.
if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) {
return "", false
}
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
if !ok {
return "", false
@@ -457,6 +487,10 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
// sources below both need, run once, in the position it always ran in. It
// only claims the turn when the embedder fails.
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
// A topic source above already paid for this one; see turnVector.
if len(t.vec) > 0 {
return "", false
}
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
if err != nil {
log.Printf("voice: embed query: %v", err)
+90 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"log"
"strings"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
@@ -81,7 +82,95 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
}
h.offerCandidates(ctx, cands)
return tasks.FormatRU(ranked), true
reply := tasks.FormatRU(ranked)
// The counted shapes, after the list and only when there are any (V-512).
// They answer "what is going wrong with this list" without assessing any of
// it, and they are said here rather than announced: no tick rule reads them.
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
if !strings.HasSuffix(reply, ".") {
reply += "."
}
reply += " " + stalls
}
return reply, true
}
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
//
// The position path already worked: resolveCandidate answers "первую сделал"
// against the list she just read. This is the other half — naming the task
// instead of its position, which reached no code at all before the stage-0 rule
// in internal/router/taskstatus.go filled the fn slot.
//
// Three answers besides the move, and none of them guesses. No match says so. A
// match on more than one asks which, because closing the wrong task is work he
// never finished being marked done. No task named asks which too, since the
// router claims the turn without the referent and the list lives here.
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string {
live, err := h.api.ListTasks(ctx, "live")
if err != nil {
log.Printf("voice: task status: list: %v", err)
return "не получилось посмотреть задачи."
}
if dec.Slots.Text == "" {
return "какую задачу?"
}
match := matchTaskText(live, dec.Slots.Text)
switch len(match) {
case 0:
return "не нашла такой задачи."
case 1:
default:
return "у тебя несколько подходящих — какую именно?"
}
pick := match[0]
status := dec.Slots.Value
// A candidate is work Maven proposed and he never confirmed, and the store
// refuses candidate → done: the legal move is to open it first. Saying it is
// done IS the confirmation, so both writes happen rather than the turn
// naming a gap about a distinction he did not make.
if pick.Status == store.TaskCandidate && status == store.TaskDone {
if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil {
log.Printf("voice: task status: promote %d: %v", pick.ID, err)
return "не получилось изменить задачу."
}
}
if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil {
log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err)
return "не получилось изменить задачу."
}
log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status)
if status == store.TaskDropped {
return "убрала: " + pick.Text
}
return "закрыла: " + pick.Text
}
// matchTaskText finds the live tasks he could have meant.
//
// Normalised containment, either direction, over store.NormalizeTaskText — the
// same key capture dedupes on, so a task he can file twice is a task he can name
// twice. Either direction because he shortens what he said ("молоко" for
// "купить молоко") as often as he pads it.
//
// Deliberately not fuzzy. A ranked best guess would always return exactly one
// answer, and the one thing this must be able to say is that it is not sure.
func matchTaskText(live []ipc.Task, named string) []ipc.Task {
want := store.NormalizeTaskText(named)
if want == "" {
return nil
}
var out []ipc.Task
for _, t := range live {
have := store.NormalizeTaskText(t.Text)
if have == "" {
continue
}
if strings.Contains(have, want) || strings.Contains(want, have) {
out = append(out, t)
}
}
return out
}
// taskItems maps wire rows onto the ranker's input. Written here rather than in
+87
View File
@@ -26,6 +26,9 @@ type taskAPI struct {
tasks []ipc.Task
listArg string
listErr error
moved []setStatusCall
moveErr error
}
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
@@ -253,3 +256,87 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
}
}
}
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
// says is done can be shown to take both legal moves.
type setStatusCall struct {
id int64
status string
by string
}
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
return a.moveErr
}
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
api := &taskAPI{tasks: []ipc.Task{
{ID: 7, Text: "купить молоко", Status: "open"},
{ID: 8, Text: "оплатить интернет", Status: "open"},
}}
h := taskHandler(api)
reply := h.resolveTaskStatus(context.Background(), router.Decision{
Intent: router.IntentAct,
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
})
if api.listArg != "live" {
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
}
if len(api.moved) != 1 {
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
}
if api.moved[0].id != 7 || api.moved[0].status != "done" {
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
}
if !strings.Contains(reply, "купить молоко") {
t.Errorf("reply = %q, want the task named back", reply)
}
}
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
cases := []struct {
name string
tasks []ipc.Task
named string
want string
}{
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
{"two matches", []ipc.Task{
{ID: 7, Text: "купить молоко", Status: "open"},
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
}, "купить молоко", "несколько"},
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
api := &taskAPI{tasks: c.tasks}
h := taskHandler(api)
reply := h.resolveTaskStatus(context.Background(), router.Decision{
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
})
if len(api.moved) != 0 {
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
}
if !strings.Contains(reply, c.want) {
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
}
})
}
}
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
// The store refuses candidate → done. Saying it is done is the confirmation
// the candidate was waiting for, so the arm makes both legal moves.
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
h := taskHandler(api)
h.resolveTaskStatus(context.Background(), router.Decision{
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
})
if len(api.moved) != 2 {
t.Fatalf("moved %+v, want open then done", api.moved)
}
if api.moved[0].status != "open" || api.moved[1].status != "done" {
t.Errorf("moved %+v, want open then done", api.moved)
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"context"
"testing"
"github.com/kami/maven/internal/router"
)
// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was
// one instance of a wider class, and V-474 fixed only that instance. Every one
// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and
// every one of them has an answer in search, which sits below the calendar.
func TestCalendarStepsAsideForTheWorld(t *testing.T) {
h, api := contQueryHandler()
for _, u := range []string{
"во сколько закат сегодня",
"какой сегодня курс доллара",
"какой сегодня праздник",
"что интересного произошло сегодня в мире",
} {
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
}); ok {
t.Errorf("the calendar claimed %q with %q", u, reply)
}
}
if api.events != 0 {
t.Errorf("CalendarEvents called %d times for world questions, want 0", api.events)
}
}
// The other half of the same narrowing: a question about his own day still
// reaches the calendar, including the one that names no subject at all.
func TestCalendarStillAnswersHisDay(t *testing.T) {
for _, u := range []string{
"что у меня сегодня",
"во сколько у меня встреча сегодня",
"какие встречи завтра",
"что в календаре на завтра",
"что сегодня?",
} {
h, _ := contQueryHandler()
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
}); !ok {
t.Errorf("the calendar passed on %q", u)
}
}
}
// A continuation carries its subject in the turn before it, and the calendar
// is the only date-aware source, so the narrowing must not reach it.
func TestCalendarStillAnswersAContinuation(t *testing.T) {
h, _ := contQueryHandler()
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true},
}); !ok {
t.Error("the calendar passed on a continuation")
}
}
+31
View File
@@ -179,6 +179,20 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
// she parked — no second parser. If it still does not fill the gap she asks
// again, up to MaxAttempts; after that she says out loud that she did not
// understand. She never drops the request in silence.
// isOwnRequest reports whether an utterance asks for something in its own
// right, which is what a clarify answer never does. Two offline tests over
// tokens, both already written for other callers: a question shape, and a
// capture verb. Cheap on purpose — this runs on the answer to every parked
// question, and it must not cost a model call.
//
// It is not a general relevance test. A bare noun that answers nothing ("синий"
// after "Что сделать?") is still treated as an answer and still re-asked, and
// that is the intended shape: only an utterance that carries its own request
// wins over the question in front of it.
func isOwnRequest(text string) bool {
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text)
}
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
if h.clarifyStore == nil {
return "", false
@@ -191,6 +205,23 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
intent := router.Intent(q.Intent)
answer := h.extractor.Extract(ctx, intent, text, h.now())
merged := q.Answer(text, toDialogueSlots(answer))
// He moved on. A parked question used to swallow whatever came next, so one
// act she could not fulfil ate the following three turns: "выключи свет в
// спальне" asked "Что сделать?", and "кто изобрёл телефон" was scored as an
// answer to it, then "как дела" after that (Vikunja #554). Nothing checked
// whether the words could be an answer at all.
//
// Deliberately narrow. It only fires where the answer filled nothing, so a
// turn that closes the gap is still an answer whatever shape it has, and
// the retry budget is untouched — the count was never the problem. Dropping
// the question and routing the utterance as itself is what he meant either
// way: if he really was answering, he can say it again, and if he was not,
// he gets the thing he asked for instead of being asked a third time.
if len(dialogue.StillMissing(q.Missing, merged)) > 0 && isOwnRequest(text) {
h.clarifyStore.Delete(dialogueIDOf(ctx))
log.Printf("voice: clarify — %q is its own request, not an answer to %v; dropping the question", text, q.Missing)
return "", false
}
// Fold a newly answered subject into the raw utterance. Downstream actions
// phrase from Utterance, not from the text slot — actionReminder stores it
// as the reminder payload — so a reminder clarified out of a bare "напомни"
+56
View File
@@ -564,3 +564,59 @@ func TestARestartExpiresTheParkedQuestion(t *testing.T) {
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
}
}
// TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not
// fulfil parked "Что сделать?", and the three turns after it were scored as
// answers to that question: a world question, then "как дела", then the give-up
// line. None of them was ever an answer.
func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
ctx := context.Background()
h, _, _ := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет в спальне"}, "выключи свет в спальне")); !asked {
t.Fatal("an act with no fn should be asked about")
}
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
t.Fatalf("a world question must route as itself, got %q", reply)
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
t.Error("the parked question must be dropped, not left to eat the turn after this one")
}
}
// TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the
// reason the test above is narrow. A bare noun answers nothing either, but it
// carries no request of its own, so she asks again as before.
func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) {
ctx := context.Background()
h, _, _ := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
t.Fatal("expected the time question")
}
reply, handled := h.resolveClarifyAnswer(ctx, "ага")
if !handled || reply == "" {
t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply)
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
t.Error("the question must survive a missed answer")
}
}
// TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only
// where nothing was filled. "во сколько?" is question-shaped and is also how a
// time gets said back, so an answer that closes the gap wins whatever its shape.
func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
t.Fatal("expected the time question")
}
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp {
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err)
}
}
+40 -7
View File
@@ -54,30 +54,56 @@ var historyMarkersEn = [][2]string{
// answers a topic far better than a list of the last five facts does.
var historyRecall = []string{" про ", " об ", " о ", " about "}
// historySide — whose turn the question asks about. The rows read are the same
// either way, because a tapped fact is one act seen from two sides, but the
// sentence is not: answering "что ты записала сегодня?" with "ты говорил…"
// hands the question back instead of answering it (Vikunja #456).
type historySide int
const (
historyAskedHim historySide = iota // "что я тебе говорил"
historyAskedHer // "что ты записала сегодня"
)
// isHistoryQuery reports whether he is asking what he told her.
func isHistoryQuery(u string) bool {
_, ok := historyAsks(u)
return ok
}
// historyAsks reports whether this is a history question, and whose turn it is
// about.
func historyAsks(u string) (historySide, bool) {
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
if s == " " {
return false
return historyAskedHim, false
}
for _, r := range historyRecall {
if strings.Contains(s, r) {
return false
return historyAskedHim, false
}
}
for _, pair := range historyMarkersEn {
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
return true
if strings.Contains(pair[0], "you") {
return historyAskedHer, true
}
return historyAskedHim, true
}
}
toks := historyTokens(s)
if !hasAny(toks, "что", "чего") {
return false
return historyAskedHim, false
}
// His side is tested first: "отмечать" is on both verb lists, so "что я
// отметил" must not read as a question about her.
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
return true
return historyAskedHim, true
}
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) {
return historyAskedHer, true
}
return historyAskedHim, false
}
// historyTokens splits an utterance into bare words. The punctuation goes
@@ -142,7 +168,8 @@ const historyWindow = 24 * time.Hour
// pass: the notes pass would otherwise answer this from whatever note happens
// to be nearest, which reads as an answer and is not one.
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
if !isHistoryQuery(t.dec.Utterance) {
side, ok := historyAsks(t.dec.Utterance)
if !ok {
return "", false
}
facts, err := h.api.RecentFacts(ctx, historyScan)
@@ -164,8 +191,14 @@ func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (strin
if len(said) == 0 {
// Claim the turn rather than fall through. "ничего не говорил" is the
// true answer, and recall would answer it with an old note instead.
if side == historyAskedHer {
return "за последние сутки я ничего с твоих слов не записывала.", true
}
return "за последние сутки ты мне ничего такого не говорил.", true
}
if side == historyAskedHer {
return "я записала: " + strings.Join(said, "; "), true
}
return "ты говорил: " + strings.Join(said, "; "), true
}
+27
View File
@@ -89,6 +89,33 @@ func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
}
}
// The rows are the same either way, because a tapped fact is one act seen from
// two sides. The sentence is not: "что ты записала" answered with "ты говорил"
// hands the question back (Vikunja #456).
func TestHistoryAnswersTheSideItWasAsked(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)})
his, ok := askHistory(h, "что я тебе говорил?")
if !ok || !strings.HasPrefix(his, "ты говорил") {
t.Errorf("reply = %q, ok = %v, want his side", his, ok)
}
hers, ok := askHistory(h, "что ты записала сегодня?")
if !ok || !strings.HasPrefix(hers, "я записала") {
t.Errorf("reply = %q, ok = %v, want her side", hers, ok)
}
// "отмечать" is on both verb lists, so his subject has to win.
if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim {
t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok)
}
empty, _ := historyHandler(now)
none, ok := askHistory(empty, "что ты записала сегодня?")
if !ok || !strings.Contains(none, "не записывала") {
t.Errorf("empty reply = %q, ok = %v, want her side", none, ok)
}
}
// Nothing said is an answer of its own. Falling through would hand the question
// to recall, which answers it with an old note.
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
+11
View File
@@ -128,6 +128,8 @@ func run(args []string) error {
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
flag.CommandLine.Parse(args)
reembedOnStart = *reembed
allowSeedOnStart = *allowSeed
@@ -210,6 +212,14 @@ func run(args []string) error {
}()
}
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
if *wipe {
if locked {
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
}
return runWipe(ctx, st, os.Stdout, *confirmWipe)
}
// ----- daemon components (only wired when unlocked) -----
// Pre-declare so the unlock path can wire them later.
var (
@@ -341,6 +351,7 @@ func run(args []string) error {
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
seedStore: seedStoreIfAllowed(st),
nexus: nexusOf(voiceW),
}
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
+23 -25
View File
@@ -8,6 +8,7 @@ import (
"unicode"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/store"
)
@@ -26,44 +27,41 @@ import (
// does not say what to do with it. Acting on the bare word would guess, and a
// wrong guess here closes work he never finished.
// candidateOrdinals — the words that pick a position, by index. Prefix match,
// because Russian declines them: "первый", "первую", "первое".
var candidateOrdinals = []struct {
word string
nth int
}{
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
{"first", 1}, {"second", 2}, {"third", 3},
}
// The position words come from the lexicon, which lists every form with its
// position and "последний" as -1 (V-522). They used to be stem prefixes here —
// {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem
// decides meaning by guessing where a word ends, and "трет" also opens
// "third-party". The lexicon runs to twelve rather than five, so he can pick
// past the fifth of a longer list; resolveCandidate already answers a position
// she did not read.
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
// "15" starts with "1" and is a time, not a position.
// "15" starts with "1" and is a time, not a position. Digits are not a Russian
// word list, so they stay here rather than in the lexicon.
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
// candidateLast — "последний" picks the end of the list whatever its length.
var candidateLast = []string{"последн", "last"}
// parseOrdinal reads which position he named. 0 and false when he named none.
// A negative result means the last one.
func parseOrdinal(text string) (int, bool) {
// Token by token, not substring: " 1" would otherwise match inside
// "напомни в 15:00" and turn a reminder into a selection.
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
for _, w := range candidateLast {
if strings.HasPrefix(tok, w) {
return -1, true
}
}
})
for i, tok := range toks {
if n, ok := candidateDigits[tok]; ok {
return n, true
}
for _, o := range candidateOrdinals {
// Prefix, because Russian declines them: "первый", "первую".
if strings.HasPrefix(tok, o.word) {
return o.nth, true
}
// A spoken half hour names the hour it is entering with the same
// genitive ordinal: "в половине восьмого" is 07:30, not the eighth
// thing she read out. She reads a list and he answers with a time
// often enough that this has to be declined here, or the reminder
// becomes a selection.
if i > 0 && lexicon.IsHalfHour(toks[i-1]) {
continue
}
if n, ok := lexicon.Ordinal(tok); ok {
return n, true
}
}
return 0, false
+10
View File
@@ -27,6 +27,16 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
{"", 0, false},
// A digit inside a time is not a position.
{"напомни в 15:00", 0, false},
// Forms the stem list used to miss, and positions past its fifth.
{"вторым", 2, true},
{"седьмую", 7, true},
{"одиннадцатый", 11, true},
// A spoken half hour names its hour with the same genitive ordinal, so
// this is 07:30 and not the eighth thing she read out (V-522).
{"напомни в половине восьмого", 0, false},
{"полвосьмого", 0, false},
// The ordinal still wins when the half word is not in front of it.
{"восьмую сделал", 8, true},
}
for _, c := range cases {
got, ok := parseOrdinal(c.text)
+26
View File
@@ -77,6 +77,32 @@ var worldSeeds = []string{
"что мне почитать про историю",
"что я должен знать про питон",
"what can i watch tonight",
// A third shape that looks personal and is not: asking when something
// happens (Vikunja #553). "во сколько закат сегодня" scored personal,
// because "что у меня сегодня" and "когда моя встреча" put that frame on
// the personal side and nothing here answered it. The sunset is the one
// thing on his list that is the same for everybody standing outside.
// "сегодня" is carried on purpose. Without it these caught nothing: the
// day word is most of what pulls the frame personal, because "что у меня
// сегодня" is a personal seed and the day word is the half it shares.
"во сколько сегодня открывается магазин",
"когда сегодня начинается матч",
"во сколько сегодня восход солнца",
// The other frame a day word carries, and the same story: "что у меня
// сегодня" is a personal seed, so "какой сегодня праздник" and "что
// интересного произошло сегодня в мире" were refused as his after the
// topic seeds had already let them past the weather source.
"какой сегодня курс валют",
"что сегодня происходит в мире",
// The narrative shape (Vikunja #554). "расскажи про Байкал" was refused as
// his by 0.0052, and nothing here was phrased as an order rather than a
// question: every world seed above opens with an interrogative. So a world
// question that names its subject and asks for prose landed nearer "я тебе
// рассказывал об этом?", which is the same verb about his own words.
"расскажи про байкал",
"расскажи про древний рим",
"объясни как работает двигатель",
"tell me about the roman empire",
}
// personalBoundary holds the embedded seeds. Zero value is usable and means
+21
View File
@@ -68,6 +68,27 @@ func TestONNXPersonalBoundary(t *testing.T) {
{"я хочу узнать про рим", false},
{"кто такой гагарин", false},
{"how do i boil an egg", false},
// Asking when a public thing happens (Vikunja #553). "во сколько закат
// сегодня" was answered "не знаю — не нашла у тебя такой записи",
// because the frame lived only on the personal side. The pair above it
// is the control: "во сколько у меня встреча" is the same frame about
// something that IS his, and it has to stay personal.
{"во сколько закат сегодня", false},
{"когда сегодня заканчивается концерт", false},
{"во сколько завтра открывается аптека", false},
// The "какой сегодня X" frame. These clear the weather topic after the
// V-553 seeds and were then refused here, which is the same defect one
// source further down the chain.
{"какой сегодня праздник", false},
{"что интересного произошло сегодня в мире", false},
{"кто выиграл вчера матч", false},
// The narrative shape, held out from the seeds above (Vikunja #554).
// The control is the row after them: the same verb about his own words
// is still his.
{"расскажи про эверест", false},
{"расскажи про войну 1812 года", false},
{"объясни что такое инфляция", false},
{"я рассказывал тебе про байкал?", true},
}
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"context"
"log"
"regexp"
"github.com/kami/maven/internal/phraser"
)
// A question about her — "что ты умеешь", "кто ты" — used to have no answer at
// all (Vikunja #555). It reached the personal boundary, which claimed it as his
// and said "не знаю — не нашла у тебя такой записи", because the boundary knows
// two sides and this is neither: her own description is not his data and it is
// not the world's either. Letting it past the boundary is no better, because
// then SearXNG answers about somebody else's assistant.
//
// The description does NOT live in the note store. Notes are his. A note about
// her sitting in his index would come back for "что я записал", would be fed to
// the digestion worker as something he said, and would be recalled by vector
// proximity for questions that are not about her at all. It is her own text, so
// it lives here, in one place, and it is the only copy.
//
// This source sits ABOVE the boundary, because a question about her never had
// an answer below it.
// selfDescription — what she is and what this box actually does. Frozen text,
// and the one rule for editing it: name only what is really wired. Anything
// that depends on config — the house, the LAN, the feeds, telegram, search — is
// named as depending on what he allowed, never claimed outright. Inventing a
// capability here is the same defect as inventing a fact, and it is worse than
// silence because he would plan around it.
//
// Written in her own voice, feminine, addressing him informally, because it is
// handed to the phraser as the evidence for the answer and the phraser will
// keep the words it is given.
const selfDescription = `Я Мэйвен, твоя помощница. Я живу на твоём сервере, ` +
`и наружу уходит только поисковый запрос — больше ничего.
Что я делаю сама: запоминаю, что ты мне говоришь, и потом отвечаю на вопросы ` +
`об этом; веду заметки; ставлю напоминания; читаю твой календарь и задачи; ` +
`отвечаю на вопросы о мире — сначала поиском, а если сети нет, то по ` +
`офлайновой энциклопедии.
Что зависит от того, что ты мне разрешил: дом, локальная сеть, ленты, ` +
`список покупок, погода, телеграм. Если что-то из этого не настроено, я ` +
`скажу об этом прямо, а не буду выдумывать ответ.
Говорю по-русски и по-английски.`
// selfSeeds — the questions this source claims. Scoring data like every other
// topic set: editing one moves the recogniser and has to be re-measured against
// TestONNXTopics.
//
// All of them are about HER — what she is, what she can do, who made her. The
// neighbouring set is topicAttend, "что требует внимания", which asks about the
// state of his things; the two share almost nothing but the second person.
var selfSeeds = []string{
"что ты умеешь",
"что ты можешь делать",
"кто ты такая",
"расскажи о себе",
"какие у тебя возможности",
// Added after measuring: it won self by 0.0002, under the margin, and the
// floor does not carry it — "способна" names no verb the floor matches.
"на что ты способна",
"чем ты можешь помочь",
"what can you do",
"who are you",
}
// selfFloor — the offline floor, for a handler with no embedder or a turn whose
// vector never got computed. Narrow on purpose, like every other floor here: it
// answers only when the seeds cannot, and a broad guess made blind is worse
// than a narrow one.
//
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
// Russian patterns spell the boundary out.
var selfPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])ты\s+(умеешь|можешь)([^\p{L}\p{N}]|$)`),
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])кто\s+ты([^\p{L}\p{N}]|$)`),
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])(расскажи|поведай)\s+о\s+себе([^\p{L}\p{N}]|$)`),
regexp.MustCompile(`(?i)\bwhat\s+can\s+you\s+do\b`),
regexp.MustCompile(`(?i)\bwho\s+are\s+you\b`),
}
func selfFloor(utterance string) bool {
for _, re := range selfPatterns {
if re.MatchString(utterance) {
return true
}
}
return false
}
// querySelf answers a question about her from selfDescription. The description
// goes through the phraser as evidence so the answer is shaped to what he
// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls
// back to the text itself, which is already readable, if the model is down.
func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) {
if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) {
return "", false
}
var reply string
if h.phraser != nil {
var err error
reply, err = h.phraser.PhraseSelf(ctx, t.dec.Utterance, selfDescription)
if err != nil {
log.Printf("voice: phrase self: %v", err)
}
}
if reply == "" {
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": selfDescription})
}
return reply, true
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// TestSelfFloorClaimsAQuestionAboutHerAndNothingElse — the offline floor, which
// is what answers with no embedder. Narrow on purpose, so the rows that must
// NOT match are the point.
func TestSelfFloorClaimsAQuestionAboutHerAndNothingElse(t *testing.T) {
claimed := []string{
"что ты умеешь",
"что ты можешь",
"а что ты умеешь?",
"кто ты",
"кто ты такая?",
"расскажи о себе",
"what can you do",
"who are you",
}
for _, u := range claimed {
if !selfFloor(u) {
t.Errorf("%q is a question about her and the floor missed it", u)
}
}
declined := []string{
"что у меня сегодня",
"расскажи про байкал",
"кто изобрёл телефон",
"что требует внимания",
"запиши что я пил воду",
// The floor spells its own word boundaries out, because Go's \b never
// fires next to a Cyrillic letter. Without that these would match.
"кто тыкал в розетку",
"расскажи о себестоимости",
}
for _, u := range declined {
if selfFloor(u) {
t.Errorf("%q is not about her and the floor claimed it", u)
}
}
}
// TestSelfSourceAnswersFromTheDescription — with no embedder the source falls
// to the floor, and the answer has to be the description rather than silence.
func TestSelfSourceAnswersFromTheDescription(t *testing.T) {
h := personalHandler()
reply, claimed := h.querySelf(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "что ты умеешь"},
})
if !claimed {
t.Fatal("a question about her must be claimed above the boundary")
}
if !strings.Contains(reply, "напоминания") {
t.Errorf("the answer must come from the description: %q", reply)
}
if _, claimed := h.querySelf(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "почему небо синее"},
}); claimed {
t.Error("a world question must pass this source")
}
}
// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out,
// so the same rules the phrasing eval enforces apply to it. Feminine
// self-reference, informal address, no pet names.
func TestSelfDescriptionHoldsThePersona(t *testing.T) {
lower := strings.ToLower(selfDescription)
for _, bad := range []string{"я рад ", "я готов ", "вы ", "ваш", "милый", "дорогой"} {
if strings.Contains(lower, bad) {
t.Errorf("the description breaks the persona on %q", bad)
}
}
for _, want := range []string{"тво", "ты"} {
if !strings.Contains(lower, want) {
t.Errorf("the description must address him directly, missing %q", want)
}
}
}
// TestSelfDescriptionClaimsNothingUnconditionally — the constraint that makes
// this text safe to read out. Every capability that depends on config has to be
// named as depending on it, and inventing one here is the same defect as
// inventing a fact.
func TestSelfDescriptionClaimsNothingUnconditionally(t *testing.T) {
conditional := selfDescription[strings.Index(selfDescription, "Что зависит"):]
for _, cap := range []string{"дом", "локальная сеть", "ленты", "список покупок", "погода", "телеграм"} {
if !strings.Contains(conditional, cap) {
t.Errorf("%q is configured, not wired — it must sit under the conditional half", cap)
}
}
}
+50 -1
View File
@@ -347,6 +347,55 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
}
// scriptedPhraser answers the chat path from the same script the router reads.
//
// It exists because actionChat calls h.phraser.PhraseChat, and the production
// implementation posts raw HTTP to /v1/chat/completions rather than going
// through the llm client scriptedLLM stands in for. So until this, no scenario
// could script what she SAYS on a chat turn: the simulator wired phraser.NewStub()
// and every chat reply came back as a pick from fallbacks_ru_v1.json, four
// variants deep, which varied between two runs of one scenario (V-542 item 4).
//
// Everything except PhraseChat is the Stub's, by embedding. A nudge and a
// reminder are phrased by the tick loop, which has its own phraser and its own
// assertions; this seam is only about the conversation.
type scriptedPhraser struct {
*phraser.Stub
entries []scriptEntry
}
// PhraseChat returns the scripted reply for the utterance, or an error when the
// scenario scripted none. The error rather than a fallback is deliberate and
// matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a
// scenario that never meant to assert on a chat reply behaves exactly as it did
// before, and one that DID means to is told its script has a hole.
func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []dialogue.Turn) (string, error) {
for _, e := range p.entries {
if e.Reply == "" {
continue
}
if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) {
continue
}
return chatReplyText(e.Reply), nil
}
return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60))
}
// chatReplyText reads a scripted reply in either shape the phrasing contract
// allows: the {"response","mood"} object the model emits, or plain text.
// LLMPhraser does this parse itself, so a scenario writes one thing and both
// paths understand it.
func chatReplyText(reply string) string {
var out struct {
Response string `json:"response"`
}
if err := json.Unmarshal([]byte(reply), &out); err == nil && out.Response != "" {
return out.Response
}
return reply
}
// ---------------------------------------------------------------------------
// Building the world
// ---------------------------------------------------------------------------
@@ -440,7 +489,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
api: api,
matcher: matcher,
tools: tool.NewExecutor(api, 5*time.Second),
phraser: phraser.NewStub(),
phraser: &scriptedPhraser{Stub: phraser.NewStub(), entries: sc.Script},
replier: newLLMReplier(scripted, nil),
now: clock.Now,
dataStore: st,
@@ -0,0 +1,79 @@
{
"schema_version": 1,
"name": "conversation_anaphora",
"description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor, then asks four questions that all say \"он\" and never name it again.\n\nThis scenario exists because the shape had nowhere to fail. The routing fixture scores one utterance at a time, so a conversation that breaks on its second turn cannot lose a point there, and V-44 step 2 could only be verified by hand. That is item 3 of V-542.\n\nFour of the five replies below are WRONG, and the assertions pin them anyway. Read them as the recorded defect rather than the contract: she has the last four turns in front of her and never once names the thing he is asking about. Every wrong assertion is marked in its step note with what it must become. When V-542 lands, those flip and the ones marked correct do not move.\n\nWhat the four assert is that the reply LACKS \"монитор\". Absence is the defect itself: she is answering a question about a thing she wrote down two minutes ago and cannot name it. It also survives the fallback picker, which matters on the three query turns — they refuse from internal/phraser/fallbacks_ru_v1.json, four variants deep, and the same scenario returned \"тут я пас.\" one run and \"не знаю, честно.\" the next, so a string assertion there would pin the picker rather than the daemon.\n\nTurn 4 asserts its text as well, because that turn goes through the chat path and the chat path is now scriptable. scriptedPhraser in simulator_test.go answers PhraseChat from the same script entries the router reads (V-542 item 4); before it, the simulator wired phraser.NewStub() and no scenario could say what she SAYS on a chat turn at all.\n\nThe routes are scripted exactly as the box produced them, because the failure is not the model's. Turn 1 went to fact despite \"давай поболтаем\", every question after it went to query, and turn 4 went to chat. A scripted route is what lets this scenario pin the daemon's half without a llama-server in the loop.",
"start": "2026-08-05T14:00:00+03:00",
"script": [
{
"match": "купил новый монитор",
"route": "[{\"intent\":\"fact\",\"key\":\"purchase\",\"value\":\"новый монитор\"}]",
"reply": "{\"response\":\"записала: новый монитор.\",\"mood\":\"neutral\"}"
},
{
"match": "он большой",
"route": "[{\"intent\":\"query\",\"text\":\"а он большой?\"}]"
},
{
"match": "сколько он примерно стоит",
"route": "[{\"intent\":\"query\",\"text\":\"сколько он примерно стоит по-твоему?\"}]"
},
{
"match": "переплатил",
"route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]",
"reply": "{\"response\":\"я не знаю, о каком именно устройстве ты говоришь.\",\"mood\":\"neutral\"}"
},
{
"match": "стоит его вернуть",
"route": "[{\"intent\":\"query\",\"text\":\"стоит его вернуть?\"}]"
},
{
"match": "",
"route": "[{\"intent\":\"chat\",\"text\":\"\"}]",
"reply": "{\"response\":\"я рада тебя слышать.\",\"mood\":\"happy\"}"
}
],
"steps": [
{
"at": "14:00",
"note": "CORRECT, and it is the first half of the defect. \"давай поболтаем\" is an explicit request to converse and the turn is filed as a fact anyway. Storing what he said is not wrong on its own — he did buy a monitor — but the object then lives in the fact store and never enters the transcript PhraseChat reads. That is V-542 decision 2: either the marker claims the turn at stage 0, or it means nothing and comes out of the fixture.",
"say": "давай поболтаем: я вчера купил новый монитор",
"expect_events": ["purchase"],
"expect_no_send": true
},
{
"at": "14:01",
"note": "WRONG. \"он\" is the monitor from one turn ago, and she says she has no record of it. followUpMerge inherits prev.Slots.Key, and a query turn asking about a pronoun has no key to merge, so the question reaches the query sources naked and the notes source answers the only way it can. Must become: an answer about the monitor, or a route to chat where the transcript is.",
"say": "а он большой?",
"expect_reply_lacks": ["монитор"],
"expect_no_send": true
},
{
"at": "14:02",
"note": "WRONG, and it rules out one explanation. This is not the previous turn failing to stick — it is the same wall a second time, two turns from where the monitor was named. Nothing accumulates across query turns.",
"say": "сколько он примерно стоит по-твоему?",
"expect_reply_lacks": ["монитор"],
"expect_no_send": true
},
{
"at": "14:03",
"note": "WRONG, and it is the same wall from the other side. This turn routed chat, so it HAD the history that Session.History holds, and it asks which device he means anyway — because turn 1's object went to the fact store rather than the transcript. So a source reading the conversation is not sufficient on its own; decision 1 has to say which store the referent comes from. This is the one step whose text is pinned: the reply is scripted and reaches PhraseChat, so it is the box's own words rather than a fallback pick. Must become: a reply that names the monitor.",
"say": "мне кажется я переплатил",
"expect_reply_contains": ["о каком именно устройстве"],
"expect_reply_lacks": ["монитор"],
"expect_no_send": true
},
{
"at": "14:04",
"note": "WRONG. The fifth turn is the one that shows the cost. A returns question about a purchase two minutes old is answered with \"не нашла у тебя такой записи\", which is wrong in kind rather than merely unhelpful: the record exists, she wrote it herself at 14:00 under the key purchase.",
"say": "стоит его вернуть?",
"expect_reply_lacks": ["монитор"],
"expect_no_send": true
},
{
"at": "14:05",
"note": "CORRECT, and it is the control. Nothing in five conversational turns was sent at him unprompted, and a tick with him mid-conversation stays silent. Whatever V-542 changes must not change this.",
"tick": true,
"expect_no_send": true
}
]
}
+2 -2
View File
@@ -74,9 +74,9 @@
},
{
"at": "08:50",
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
"note": "he asks what he missed, and Praxis holds one unresolved item — the morning medicine — so she reads that back. This step pinned \"не знаю\" until 05-08-2026, and that was the keyword floor's blind spot rather than a rule: isAttentionQuery does not match \"что я пропустил\", while the topicAttend seeds carry \"что важное я пропустил\" almost verbatim. The seeds only started deciding when turnVector fixed the empty query vector every topic source was reading (V-547). Reading a surfaced item aloud is not inventing a morning summary, so the no-hallucination floor still holds; what moved is which source answers. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
"say": "что я пропустил?",
"expect_reply_contains": ["не знаю"],
"expect_reply_contains": ["требует внимания", "morning_medicine"],
"expect_reply_lacks": ["рад ", "милый", "ваш"]
},
{
+47
View File
@@ -23,6 +23,11 @@ type daemonAPI struct {
chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
// nexus — the identity client, nil when no nexus block is configured. It
// is what makes ResolveEntity answerable at all; without it the store
// adapter's refusal stands, and a surface that wanted an entity id says so
// instead of storing a name.
nexus *nexusClient
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
// the whole off-switch for the backdated write path (Vikunja #518), and it
// is a store rather than a bool so that leaving the flag off means the
@@ -41,6 +46,48 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
return d.getEvents(n), nil
}
// nexusOf — the identity client the voice wiring built, or nil. Same shape as
// embedderOf: a wiring that is absent and a wiring with no nexus block are one
// answer here.
func nexusOf(w *voiceWiring) *nexusClient {
if w == nil || w.handler == nil || w.handler.ecosystem == nil {
return nil
}
return w.handler.ecosystem.nexus
}
// ResolveEntity asks Nexus for the canonical id behind a name (Vikunja #511).
//
// Three outcomes, kept apart on purpose. No nexus block is ErrNotImplemented,
// so a surface can say "identity is not configured here" rather than invent an
// id. A miss is ipc.ErrNoEntity. A match against several entities comes back
// Ambiguous with the names, because picking one is how a task ends up blocked
// on the wrong person and nobody can see it happened.
func (d *daemonAPI) ResolveEntity(ctx context.Context, query string, types []string) (ipc.EntityRef, error) {
if d.nexus == nil {
return ipc.EntityRef{}, ipc.ErrNotImplemented
}
res, err := d.nexus.Resolve(ctx, query, types)
if err != nil {
return ipc.EntityRef{}, err
}
if len(res.Candidates) > 1 {
names := make([]string, 0, len(res.Candidates))
for _, c := range res.Candidates {
names = append(names, c.DisplayName)
}
return ipc.EntityRef{Ambiguous: true, Candidates: names}, nil
}
if res.Entity == nil || res.Entity.ID == "" {
return ipc.EntityRef{}, ipc.ErrNoEntity
}
return ipc.EntityRef{
ID: res.Entity.ID,
Type: res.Entity.Type,
DisplayName: res.Entity.DisplayName,
}, nil
}
// Chat runs one text turn and reports which query source claimed it. The sink
// rides the context so handleText keeps the one string signature the mic,
// telegram and the web all call it through (V-539).
+121 -4
View File
@@ -9,7 +9,7 @@ import (
)
// Which subject is this question about — the weather, the house, the LAN, what
// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian
// needs looking at, his feeds, or none of them. Third of the three mechanisms replacing hand-written Russian
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
// sets that can be finished and internal/morph answers the grammar questions;
// this is for the sets that can never be finished, because "is this about the
@@ -37,6 +37,11 @@ import (
// внимания", which is Praxis's operational state and reached the web search
// before the source existed (Vikunja #475).
//
// A fifth joined on 05-08-2026: the feeds, "что нового в лентах". Its word lists
// were the last pair of hand-written Russian stem lists in the router (V-522),
// and they carried the same admission in their own comments — vagueNouns exists
// because "что нового?" is a greeting that matched a feed noun.
//
// The regexes stay as the offline floor, unchanged, for a handler with no
// embedder or a turn whose vector never got computed. They are allowed to remain
// narrow now precisely because they are no longer the only answer.
@@ -52,6 +57,9 @@ const (
topicHome topicLabel = "home"
topicNetwork topicLabel = "network"
topicAttend topicLabel = "attention"
topicFeed topicLabel = "feeds"
topicList topicLabel = "list"
topicSelf topicLabel = "self"
topicOther topicLabel = "other"
)
@@ -117,7 +125,56 @@ var topicSeedSets = map[topicLabel][]string{
"what needs attention",
"what needs looking at right now",
},
topicFeed: {
"что нового в лентах",
"какие новости",
"что нового по технологиям",
"почитай заголовки",
// Two seeds carrying a day word beside the headlines. Without them
// "какие сегодня заголовки" read as weather, because "какая сегодня
// погода" is the nearest thing in the whole set with "сегодня" in it.
"заголовки за сегодня",
"какие главные новости за день",
"покажи новости за сегодня",
"что пишут в новостях",
"что нового про политику",
"расскажи что нового в ленте",
"what is new in the feeds",
"any news headlines today",
},
// Reading a standing list back, and only that. Adding to one and clearing
// one stay on the phrase tables in internal/router/list.go — see its header
// for why a span and a delete are not seed-shaped work.
topicList: {
"что в списке покупок",
"что мне нужно купить",
"прочитай список покупок",
"покажи что в списке",
"что осталось купить в магазине",
"что мне нужно в аптеке",
"какой у меня список покупок",
"what is on my shopping list",
"read me the grocery list",
},
// Questions about her (Vikunja #555). The set lives in self.go beside the
// description it unlocks, so the two are edited together — a seed claiming
// a question the description does not answer is the failure mode.
//
// "что ты умеешь" was a topicOther seed until this existed, put there so an
// attention question had something to lose to. It is a self seed now, and
// it cannot be both: a phrasing on two sides never clears the margin.
topicSelf: selfSeeds,
topicOther: {
// A task question is not a list read-back. They collide on "что у меня",
// and the list has its own table to lose to as well.
"какие у меня задачи",
"что у меня в делах",
// The bare newness opener, which is a greeting and not a request for
// headlines. It sits here on purpose: it is close enough to the feed
// seeds that it will not clear topicMargin, and a thin call goes to
// ParseFeedQuery, which declines a vague noun with no topic beside it.
"что нового",
"как дела",
// Complaints, which are not requests to scan or to read the house.
// isNetworkQuery's comment names this one: a scan she runs unasked is
// the noisy behaviour the bounds exist to prevent.
@@ -137,9 +194,40 @@ var topicSeedSets = map[topicLabel][]string{
"что я говорил про бэкапы",
"что у меня сегодня по календарю",
"напомни мне позвонить маме",
// An attention question is about the state of his things; this is not.
"что ты умеешь",
"what did i say about backups",
// World questions that name a day (Vikunja #553). Weather was the only
// topic whose seeds carry a day word — four of its eight do — so every
// "какой сегодня X" landed nearest it and cleared the margin: the
// dollar rate by 0.0220 and a public holiday by 0.0398, against 0.0883
// for a real weather question. The gate then asked "для какого города?"
// about the dollar.
//
// The margin was not the knob. 0.0398 is not a coin flip, and raising
// the bar far enough to catch it would take real weather questions with
// it. What was missing is the negative class: a day word means the
// question is about a day, and says nothing about whether it is about
// the sky.
"сколько стоит биткоин сегодня",
"какой завтра праздник в стране",
"во сколько сегодня восход солнца",
"кто вчера победил в чемпионате",
// The frame itself, twice. "какая сегодня погода" is a weather seed,
// and the four above did not move "какой сегодня курс доллара" or
// "что интересного произошло сегодня в мире" off weather, because what
// pulls them is the frame and not the noun. A frame that both topics
// use has to sit on both sides, or the side that owns it wins every
// noun it has never seen.
"какой сегодня курс валют",
"что сегодня происходит в мире",
// The same story one topic over, found while verifying V-554 on the
// box: "кто изобрёл телефон" ran a LAN scan and answered "нашла 3
// устройства". The network set opens with "кто в сети сейчас" and
// names devices throughout, so a "кто ..." question about any device
// noun landed there. A device has a history, and asking about it is
// not asking what is plugged in.
"кто изобрёл телефон",
"когда появился первый компьютер",
"как работает роутер",
},
}
@@ -201,6 +289,35 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
return label, first - second, true
}
// turnVector returns the turn's query vector, computing it on first ask and
// caching it on the turn.
//
// It exists because every topic source sits ABOVE the "embed" source in
// querySources, and that source was the only thing that ever set t.vec. So
// turnIsAbout was reading an empty vector on every deployed turn, best returned
// ok=false, and all six recognisers ran on their keyword floors — the seeds
// decided nothing outside the tests, which embed the utterance themselves and
// call best directly. Found on the box on 05-08-2026: "что мне нужно купить" was
// answered from an old note, and the seeds place it as the list by 0.0841.
//
// Computing here rather than moving the embed source up: the cost is paid by the
// turns that ask, the cache means queryEmbed below reuses this one, and the
// order of querySources stays what its comments argue for.
func (h *reactiveHandler) turnVector(ctx context.Context, t *queryTurn) []float32 {
if len(t.vec) > 0 || h.recall.embedder == nil {
return t.vec
}
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
if err != nil {
// The floor answers. A topic source is not the place to fail a turn:
// the recall sources below hit the same embedder and report it there.
log.Printf("voice: topic vector for %q: %v", t.dec.Utterance, err)
return nil
}
t.vec = vec
return vec
}
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
// the embedder is there, which is every deployed box; floor is the source's own
// keyword test, which answers when they are not.
@@ -213,7 +330,7 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
h.recall.topics.load(ctx, h.recall.embedder)
label, margin, ok := h.recall.topics.best(t.vec)
label, margin, ok := h.recall.topics.best(h.turnVector(ctx, t))
if !ok {
return floor(t.dec.Utterance)
}
+46
View File
@@ -25,6 +25,8 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
{"что включено в доме?", topicHome, isHomeQuery, true},
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
{"что требует внимания?", topicAttend, isAttentionQuery, true},
{"что нового в лентах?", topicFeed, feedFloor, true},
{"что в списке покупок?", topicList, listFloor, true},
{"почему небо синее", topicWeather, isWeatherQuery, false},
{"я дома", topicHome, isHomeQuery, false},
{"интернет не работает", topicNetwork, isNetworkQuery, false},
@@ -86,6 +88,50 @@ func TestONNXTopics(t *testing.T) {
{"что требует моего внимания сейчас", topicAttend, isAttentionQuery},
{"что не так с базой данных", topicAttend, isAttentionQuery},
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
{"что нового в ленте за сегодня", topicFeed, feedFloor},
{"какие сегодня заголовки", topicFeed, feedFloor},
{"что нового про искусственный интеллект", topicFeed, feedFloor},
// The greeting. It has to lose to topicOther, or fall thin enough that
// ParseFeedQuery — which declines a vague noun with no topic — answers.
{"что нового?", topicOther, feedFloor},
{"что мне надо купить в магазине", topicList, listFloor},
{"прочитай мне список", topicList, listFloor},
{"что там в аптеке нужно взять", topicList, listFloor},
// A task read-back is not a list read-back, and the two collide on
// "что у меня".
{"какие у меня сейчас задачи", topicOther, listFloor},
// World questions that name a day (Vikunja #553). Weather was the only
// topic carrying day words, so all of these read as weather and two of
// them cleared the margin: the gate asked "для какого города?" about
// the dollar. The last two are far from any seed on purpose — the
// first three are close enough to the new topicOther seeds that they
// would pass on similarity alone.
{"какой сегодня курс доллара", topicOther, isWeatherQuery},
{"какой сегодня праздник", topicOther, isWeatherQuery},
{"что интересного произошло сегодня в мире", topicOther, isWeatherQuery},
{"во сколько завтра открывается музей", topicOther, isWeatherQuery},
{"кто сегодня играет в лиге чемпионов", topicOther, isWeatherQuery},
// The control the seeds above must not cost: real weather still reads
// as weather, including the two that lean on the keyword floor.
{"будет ли завтра дождь в москве", topicWeather, isWeatherQuery},
{"какая температура завтра утром", topicWeather, isWeatherQuery},
// The same shape one topic over, seen on the box (Vikunja #554): a
// device has a history, and asking about it is not asking what is
// plugged in. "кто изобрёл телефон" answered "нашла 3 устройства".
// Held out from the seeds, which name the telephone and the computer.
{"кто придумал радио", topicOther, isNetworkQuery},
{"когда изобрели телевизор", topicOther, isNetworkQuery},
{"как устроен телефон внутри", topicOther, isNetworkQuery},
// The control: a real scan is still a scan.
{"какие устройства подключены к вайфаю", topicNetwork, isNetworkQuery},
// Questions about her (Vikunja #555), held out from selfSeeds.
{"а что ты вообще умеешь делать", topicSelf, selfFloor},
{"какие у тебя навыки", topicSelf, selfFloor},
{"расскажи мне о себе", topicSelf, selfFloor},
{"what are you able to do", topicSelf, selfFloor},
// The control: an attention question is about the state of his things,
// and it is the neighbour these seeds could have taken.
{"что требует внимания у меня в сервисах", topicAttend, isAttentionQuery},
}
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
+4
View File
@@ -400,6 +400,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
// marker never collides with a clock or agenda question.
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
// board noun), and before the capture marker, which would otherwise read
// "убери из задач купить молоко" as a new task (Vikunja #512).
grammars = append(grammars, router.TaskStatusGrammar())
grammars = append(grammars, router.TaskCaptureGrammar())
// After the capture marker, so "запиши" still wins over "расскажи", and
// last overall because it matches on the first word alone: "расскажи про
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"fmt"
"io"
"github.com/kami/maven/internal/store"
)
// runWipe implements the -wipe flag: it prints what the database holds, and
// removes it only when the operator also passed -confirm-wipe (Vikunja #494).
//
// Two flags rather than one, because the destructive reading of a single flag
// is the reading a mistyped command gets. Without the confirmation this is a
// dry run that costs nothing and answers the question a QA session actually
// has — what is on this box right now.
//
// It runs before any daemon component is wired, so nothing is writing while
// the tables go. The daemon exits afterwards rather than serving a store it
// just emptied, because every component that read the old rows at boot would
// still be holding them.
func runWipe(ctx context.Context, st *store.Store, out io.Writer, confirmed bool) error {
counts, err := st.WipeCounts(ctx)
if err != nil {
return fmt.Errorf("wipe: read counts: %w", err)
}
total := 0
for _, c := range counts {
total += c.Rows
fmt.Fprintf(out, " %-24s %d\n", c.Table, c.Rows)
}
fmt.Fprintf(out, " %-24s %d rows in %d tables\n", "TOTAL", total, len(counts))
if !confirmed {
fmt.Fprintln(out, "\nnothing was deleted. pass -confirm-wipe to delete all of it.")
fmt.Fprintln(out, "config, models, passkeys and the encryption key are files and are never touched.")
return nil
}
if err := st.Wipe(ctx); err != nil {
return err
}
fmt.Fprintf(out, "\nwiped. %d rows gone, the schema is intact, mavend knows nobody.\n", total)
return nil
}
+186 -22
View File
@@ -883,14 +883,19 @@ type taskRow struct {
Created string
Resolved string
ResolvedBy string
// DueValue and Weight are the raw values the edit form posts back
// (Vikunja #509). Due above is for reading and says "—" for no date; a
// date input needs "2026-08-07" or the empty string.
DueValue string
Weight int
// Why — the ranker's reason for this row's position (Vikunja #129), in
// Russian, empty when nothing distinguished the task. Blank is the honest
// rendering: he never said this one mattered more.
Why string
}
// handleTasks serves the task review surface (GET) and the four writes it
// offers (POST): add, confirm, done, drop.
// handleTasks serves the task review surface (GET) and the five writes it
// offers (POST): add, edit, confirm, done, drop.
//
// Not step-up gated, unlike /tools and /routines, and the difference is the
// point: enabling a tool defines argv Maven will execute, and accepting a
@@ -900,6 +905,13 @@ type taskRow struct {
// still sits behind whatever transport auth fronts mavweb, like every other
// page.
//
// "edit" was re-argued on the same terms rather than inheriting the exemption
// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads
// himself, the same blast radius "drop" already has on this page, and the store
// refuses the two edits that would cost something: a resolved task keeps the
// text it was finished under, and a text collision with another live row is
// named instead of merged.
//
// "confirm" is the only interesting move: it promotes a candidate Maven derived
// from something she read into work he owns. That review step is why derived
// tasks are captured as candidates in the first place.
@@ -965,6 +977,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight,
Why: r.Reason,
}
if t.Status == "candidate" {
@@ -979,11 +992,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tasksTmpl.Execute(w, struct {
Msg, Err string
Stalls []tasks.Stall
Candidates []taskRow
Open []taskRow
Resolved []taskRow
ResolvedMore bool
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
log.Printf("tasks render: %v", err)
}
}
@@ -999,27 +1013,16 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
return "", errors.New("empty task text")
}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
// Importance is his, stated on the form. Out-of-range values are
// clamped rather than rejected — a bad select is not worth a 400.
if v := r.FormValue("weight"); v != "" {
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
// form value is not a place to accept trailing garbage.
wgt, err := strconv.Atoi(v)
if err != nil || wgt < 0 {
return "", fmt.Errorf("bad weight %q", v)
}
if wgt > tasks.MaxWeight {
wgt = tasks.MaxWeight
}
req.Weight = wgt
wgt, err := formWeight(r)
if err != nil {
return "", err
}
if d := r.FormValue("due"); d != "" {
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
if err != nil {
return "", fmt.Errorf("bad due date %q", d)
}
req.Due = &due
req.Weight = wgt
due, err := formDue(r, now())
if err != nil {
return "", err
}
req.Due = due
resp, err := core.CaptureTask(ctx, req)
if err != nil {
return "", err
@@ -1037,6 +1040,44 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if err != nil {
return "", errors.New("invalid id")
}
if action == "promote" {
msg, err := promoteCandidate(ctx, core, r, id)
if err != nil {
return "", err
}
return msg, nil
}
if action == "edit" {
// The three fields capture set, and only those (Vikunja #509). Status
// is not editable here: that ladder is one-way and has its own buttons.
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
}
wgt, err := formWeight(r)
if err != nil {
return "", err
}
due, err := formDue(r, now())
if err != nil {
return "", err
}
switch err := core.EditTask(ctx, id, text, due, wgt); {
case err == nil:
return "saved task", nil
case errors.Is(err, ipc.ErrTaskDuplicate):
// Naming the collision instead of merging: two live rows carry two
// provenances, and picking one is not the page's call.
return "", errors.New("another open task already says this — drop one of the two")
case errors.Is(err, ipc.ErrTaskResolved):
return "", errors.New("a resolved task keeps the text it was finished under")
default:
return "", err
}
}
var status, msg string
switch action {
case "confirm":
@@ -1049,11 +1090,134 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
return "", fmt.Errorf("unknown action %q", action)
}
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
// The refusal has to name what is missing, or the button looks
// broken. The field it asks for arrives with the intake form
// (Vikunja #511).
return "", errors.New("write a definition of done before confirming this candidate")
}
return "", err
}
return msg, nil
}
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
// promoteCandidate turns a candidate into open work with the three things the
// board needs (Vikunja #511): a definition of done, an optional blocker, and an
// optional date.
//
// The definition of done is required, and the refusal is the store's — this
// only reaches it in a readable order. The blocker is a NAME here and an entity
// id in the row: identity lives in Nexus, so the name is resolved first and a
// name Nexus cannot resolve stops the promotion instead of being stored.
//
// A date set here writes a reminder, which is the one unprompted delivery the
// persona allows: he asked to be told, on a day he named.
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
if doneWhen == "" {
return "", errors.New("write a definition of done — what has to be true for this to be finished")
}
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
return "", errors.New("empty task text")
}
due, err := formDue(r, now())
if err != nil {
return "", err
}
blockedOn := ""
if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" {
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
switch {
case errors.Is(err, ipc.ErrNotImplemented):
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
case errors.Is(err, ipc.ErrNoEntity):
return "", fmt.Errorf("nexus does not know %q", name)
case err != nil:
return "", fmt.Errorf("resolving %q: %w", name, err)
case ref.Ambiguous:
// Asking, not picking: a task blocked on the wrong person is a
// mistake nobody can see afterwards.
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
}
blockedOn = ref.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
}
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
return "", err
}
}
if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil {
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
return "", errors.New("write a definition of done before confirming this candidate")
}
return "", err
}
if due == nil {
return "confirmed", nil
}
// A date-only field has no hour. Nine in the morning, because the reminder
// is about a day's work and being told at midnight is being told the night
// before.
fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location())
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
// The task IS promoted; only the reminder failed. Saying "confirmed"
// and nothing else would leave him expecting a nudge that will not come.
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
}
return "confirmed, and maven will remind you that morning", nil
}
// formWeight reads the importance select. Out-of-range clamps rather than
// rejects — a bad select is not worth a 400 — but trailing garbage is refused,
// because strconv is not Sscanf and "3junk" is not a 3.
func formWeight(r *http.Request) (int, error) {
v := r.FormValue("weight")
if v == "" {
return 0, nil
}
wgt, err := strconv.Atoi(v)
if err != nil || wgt < 0 {
return 0, fmt.Errorf("bad weight %q", v)
}
if wgt > tasks.MaxWeight {
wgt = tasks.MaxWeight
}
return wgt, nil
}
// formDue reads the date input. An empty field is nil, which on an edit means
// "clear the date" — the form has no other way to say it.
func formDue(r *http.Request, now time.Time) (*time.Time, error) {
d := r.FormValue("due")
if d == "" {
return nil, nil
}
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
if err != nil {
return nil, fmt.Errorf("bad due date %q", d)
}
return &due, nil
}
func fmtTaskDateValue(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Local().Format("2006-01-02")
}
func fmtTaskTime(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
+44 -6
View File
@@ -21,20 +21,43 @@
</form>
</section>
{{if .Stalls}}
<section class=card>
<h2 class=card-title>shapes</h2>
<!-- Counts, and nothing about what they mean (V-512). Whether a task should be
dropped is his call and Maven does not have an opinion to show here. -->
<div class=scroll><table>
<tr><th>count</th><th>shape</th></tr>
{{range .Stalls}}<tr><td>{{.N}}</td><td>{{.Line}}</td></tr>{{end}}
</table></div>
</section>
{{end}}
{{if .Candidates}}
<section class=card>
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
<div class=hint>confirming asks for a definition of done: what has to be true for this to be finished. a task without one can never leave the board. a date here also books a reminder that morning.</div>
<div class=scroll><table>
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
<tr><th>task</th><th>where from</th><th>captured</th><th>confirm</th><th></th></tr>
{{range .Candidates}}<tr>
<td class=text-max>{{.Text}}</td>
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
<td>{{.Due}}</td>
<td class=muted>{{.Created}}</td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=confirm>
<input type=hidden name=action value=promote>
<input type=hidden name=text value="{{.Text}}">
<input type=text name=done_when placeholder="готово, когда…" size=26 required>
<!-- A name, not an id. It is resolved against nexus before anything is
stored, and a name nexus cannot place stops the confirmation. -->
<input type=text name=blocked_on placeholder="ждёт кого-то" size=14>
<input type=date name=due value="{{.DueValue}}" title="due date">
<select name=weight title=importance>
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
</select>
<button class=btn>confirm</button></form></td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
@@ -48,12 +71,27 @@
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
{{if .Open}}<div class=scroll><table>
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
<tr><th>task</th><th>why</th><th>from</th><th>captured</th><th></th><th></th></tr>
{{range .Open}}<tr>
<td class=text-max>{{.Text}}</td>
<!-- The text, the date and the importance are editable in place (V-509): a
dictated task can carry a typo, and a deadline moves. The status is not
here — that ladder is one-way and has its own two buttons. -->
<td class=text-max><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=edit>
<input type=text name=text value="{{.Text}}" size=30 required>
<input type=date name=due value="{{.DueValue}}" title="due date">
<select name=weight title=importance>
<!-- Any weight that is not one of the three rungs keeps its own option, or
saving an unrelated edit would silently reset it to normal. -->
{{if and (ne .Weight 0) (ne .Weight 2) (ne .Weight 3)}}<option value={{.Weight}} selected>{{.Weight}}</option>{{end}}
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
</select>
<button class="btn btn-muted">save</button></form></td>
<td class=hint>{{.Why}}</td>
<td class=hint>{{.Source}}</td>
<td>{{.Due}}</td>
<td class=muted>{{.Created}}</td>
<td><form method=post action=/tasks class=inline-form>
<input type=hidden name=id value="{{.ID}}">
+6 -2
View File
@@ -77,10 +77,14 @@ func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
t.Errorf("body missing %q", want)
}
}
// The candidate must offer confirm, and the open task must not.
if !strings.Contains(body, "value=confirm") {
// The candidate must offer the intake form, and it asks for a definition of
// done before it will confirm anything (V-511).
if !strings.Contains(body, "value=promote") {
t.Error("candidate row has no confirm action")
}
if !strings.Contains(body, "name=done_when") {
t.Error("the confirm form does not ask for a definition of done")
}
}
func TestHandleTasksAddCaptures(t *testing.T) {
+93
View File
@@ -0,0 +1,93 @@
# Routing from audio: four paths, one fixture
**05-08-2026. Vikunja #486.** Workstation `gemma-4-12B-it-qat-UD-Q4_K_XL` with
`mmproj-F16.gguf`, homesrv whisper `ggml-small`, piper `ru_RU-irina-medium`.
**Verdict: transcribe, then route.** One call from audio straight to a route loses 36
points, so it is not a candidate. Moving speech-to-text to the workstation buys 375ms and
better transcripts at no measurable accuracy cost. So #486 proceeds on the two-call shape.
## The numbers
72 Russian cases from `internal/router/eval/ru_routing_v1.json`, rendered by piper at
16kHz mono, 153.9s of audio, mean 2.14s per clip. Every path used the daemon's own
`routeSystem` prompt and `routeGrammar`, read out of `internal/router/llmrouter.go` at run
time, at `temperature 0` and `enable_thinking:false`.
| Path | Intent-only | Verbatim transcripts | p50 | p95 |
|---|---|---|---|---|
| text in, the ceiling | **90.3%** (65/72) | — | 361ms | 495ms |
| whisper on homesrv, then route | **84.7%** (61/72) | 29/72 | 1372ms | 1546ms |
| workstation transcribes, then routes | **83.3%** (60/72) | 48/72 | 997ms | 1177ms |
| workstation, one call from audio | **54.2%** (39/72) | — | 425ms | 756ms |
The 90.3% ceiling is the same model on the same 72 cases with the utterance as text. It is
not the 93.5% in `docs/evals/2026-08-02-workstation-gemma4-12b.md`, which scored all 87
cases including the English ones.
The two speech-to-text paths differ by one case, which is noise on 72. So the choice
between them is latency and transcript quality, and the workstation wins both.
## One call from audio is not a transcription failure
The obvious reading of 54.2% is that the audio encoder cannot hear Russian. It can. Eight
of the failing clips were sent back with a transcribe instruction instead of the router
prompt:
| Clip | Said | Heard, transcribing | Routing from audio |
|---|---|---|---|
| ru-sys-002 | какое число завтра | Какое число завтра? | `unknown` |
| ru-sys-003 | переходи в тихий режим | Переходи в тихий режим. | `unknown` |
| ru-query-001 | сколько воды я выпил с утра | Сколько воды я выпил с утра? | `fact`, value "выпил с утра" |
| ru-act-002 | выключи свет в спальне | Выключи свет в спальне. | `fact`, value "включен" |
Four clips it transcribes word for word, and routes wrong or refuses. The `ru-query-001`
row shows the mechanism: the emitted slot holds the tail of the sentence and the
interrogative head is gone. The model is not deaf, it stops attending to the audio once it
is also holding a 3.5k-character classification prompt.
That pattern decides the whole task. A long system prompt and an audio part compete, so the
transcription has to be its own call with a short instruction. It also means the number
would not be rescued by a better prompt, a longer clip, or a bigger `mmproj`.
The failures cluster where the head of the sentence carries the intent: `ru-act` 1/6,
`ru-sys` 2/5, `ru-query` 12/25. Reminders scored 10/10, because "напомни" is the first word
and nothing after it changes the answer.
## Transcript quality and routing accuracy come apart
The workstation transcribes 48 of 72 verbatim against whisper's 29, and routes one case
worse. Both directions of that appear in the same run:
- `ru-chat-002`: whisper heard "Кто думаешь про переезд", the workstation heard "Что ты
думаешь про переезд". The correct transcript routed to `chat`, the broken one to `query`.
- `ru-query-020`: whisper heard "Кто дальше?", the workstation heard the correct "Что
дальше?". The **broken** transcript routed correctly and the correct one missed.
A word error rate is not a proxy for routing accuracy here. Judge a speech-to-text change
on the routing fixture, not on transcripts.
Three cases only the text path gets right. No speech-to-text path recovers them, so they
are lost in the rendering rather than in the model.
## Latency
Whisper `ggml-small` on homesrv CPU costs p50 998ms for a 2.14s clip, which is nearly
all of that path's 1372ms. The workstation does the same job inside its 997ms end-to-end
total for two calls. So the transfer is worth about 375ms per turn at p50, and more at p95.
Both are above the one-call 425ms, and that is the trade the table settles: 29 points of
accuracy for 572ms.
## Notes for the next run
- `--mmproj /mnt/D/AI/gemma4/mmproj-F16.gguf` has to be in `llama_args` in
`~/.config/mavgpud.json`, or `/props` reports `modalities.audio: false` and every audio
part is dropped silently. It was added for this measurement and removed afterwards, so
the box is back to the text-only config.
- `enable_thinking:false` is mandatory. It was set for all 224 calls here.
- The degenerate `<|channel>thought` output recorded against #486 did not reproduce, in 80
transcribe calls or in 144 routing calls.
- Piper renders at 22050Hz mono. Every clip was resampled with
`ffmpeg -ar 16000 -ac 1 -c:a pcm_s16le`, because 16kHz is what `audio.PCM16kMono`
declares and what the earlier measurement used.
+70
View File
@@ -0,0 +1,70 @@
# Ecosystem reach with the resident model as router
Date: 2026-08-05. Vikunja #517, split out of #405.
Fixture: `internal/router/eval/ru_ecosystem_v1.json`, 30 held-out Russian cases.
Model: Qwen3-1.7B-UD-Q4_K_XL, llama-server on the host at 127.0.0.1:8899.
Harness: `TestReachWithLLMRouter` in `internal/router/eval/llmrouter_test.go`.
## The numbers
| configuration | reached the right place | praxis | hexis | none |
|---|---|---|---|---|
| classifier + hash (V-405 floor) | 16/30 | 0/12 | — | — |
| classifier + ONNX, after the V-516 grammars | 27/30 | 11/12 | — | — |
| **llm-only** (resident model alone) | **17/30 (56.7%)** | **0/12** | 10/10 | 7/8 |
| **cascade + llm** + hash fallback | **28/30 (93.3%)** | **11/12** | 10/10 | 7/8 |
Latency: llm-only p50 1.29s, p95 1.65s. Cascade p50 1.11s, p95 1.64s.
No case errored in either configuration.
## The open question is answered: the model never reaches Praxis
The route grammar lets the model write any string into the `fn` slot. So it
could in principle emit a literal Praxis capability name, and reach a service
the classifier structurally cannot. It does not. **Praxis is 0/12 with the
model alone.** That is exactly what the classifier alone scores. Every one of
the twelve fails the same way: the utterance stays local with an empty `fn`.
So the stage-0 Praxis grammars from V-516 are not a determinism argument. They
are the only path to Praxis that exists. Deleting them takes reach from 11/12
back to 0/12 whichever engine is answering.
The failure is not that the model routes these badly in its own terms. It
spreads them across `query`, `fact`, `system` and `chat`. Those are reasonable
readings of "что требует внимания" and "готово, закрывай" for a model that has
never been told Praxis exists. Nothing in the prompt names a Praxis capability,
so there is no string for it to write.
## What the model does buy
Hexis is 10/10 with the model alone, and the mutating tag is 10/15 llm-only
against 15/15 through the cascade. The model reaches everything Hexis owns
without help, which is the half the act allowlist already names in the prompt.
Cascade + llm scores one point above the classifier baseline: 28/30 against
27/30, the difference being one attention case. That is the same shape as the
routing fixture, where the router buys about 4 points rather than a doubling.
## The two that still miss
- `eco-ru-021 "что там с нексусом"`. Routes `query`, stays local, wants Praxis.
Asking after a named service reads as a question about a thing, and no
grammar claims a service name.
- `eco-ru-029 "сделай это"`. Routes `act` and reaches Hexis. The fixture wants
nothing reached, because "это" names no target. This is the overreach case
and it is the one direction worth failing on. The confirmation binding
downstream still resolves a canonical entity id before anything executes.
The fixture is right that the turn should have asked.
Overreach is 1 in both configurations, under the 4 the harness asserts.
## How to re-run
```sh
MAVEN_LLM_URL=http://127.0.0.1:8899 \
deps/go/go/bin/go test -v -count=1 -timeout 40m \
-run TestReachWithLLMRouter ./internal/router/eval/
```
The host `http_proxy` answers 503 for 127.0.0.1. `noProxyLoopback` in the test
excludes it. A run that scores every case as a route error measured the proxy.
@@ -0,0 +1,69 @@
# Routing with the resident model, re-measured
Date: 2026-08-05. Vikunja #320 items 2 and 3.
Fixture: `internal/router/eval/ru_routing_v1.json`, now **91 cases** (76 ru, 15 en).
Model: Qwen3-1.7B-UD-Q4_K_XL, llama-server on the host at 127.0.0.1:8899.
Harness: `TestLLMRouterBaseline`, `make eval-models`.
## How the block was cleared
Item 2 was blocked because the resident llama-server binds `--host 127.0.0.1
--port 0` inside `maven-mavend-1`. The port is kernel-assigned, scraped from
stderr and never published, so no `go test` on the host can reach it. The task
listed three ways out. This run took the first: a **second** llama-server on
the same gguf, on a fixed host port. The Vega takes the second copy of a 1.7B
without complaint.
## The numbers
| configuration | full | intent-only | p50 | p95 |
|---|---|---|---|---|
| llm-only | 34/91 (37.4%) | 61.5% | 1.24s | 1.65s |
| cascade + llm + hash fallback | 69/91 (75.8%) | 80.2% | 1.19s | 1.65s |
By language, through the cascade: ru 57/76, en 12/15.
Clarify: 3 false, 1 missed. No errors. Six slots deferred to the daemon.
For comparison, the figures that stood in CLAUDE.md were 72.7% full and 77.9%
intent-only, measured on 77 cases. The fixture has grown by 14 cases since, so
this is a new baseline rather than a movement.
## llm-only is low for a reason that is not routing
37.4% full against 61.5% intent-only is the gap, and it is almost entirely
slots. Every reminder case fails with "no time slot, want one". The model
routes `reminder` correctly and leaves the time to the daemon, which is what
the contract asks of it. The cascade fills those slots. That is why the same
model scores 38 points higher inside it.
Three cases errored in the llm-only arm and none in the cascade, which is the
fallback working as designed.
## Item 3: latency
Router p50 1.19s, p95 1.65s, max 1.79s through the cascade. The one earlier
data point in the task, roughly 6s wall clock for `привет` through
`POST /api/chat`, was the whole path and not the router. It is not comparable
and should not be quoted as a routing number.
These numbers are the homesrv floor. With the workstation up, routing completes
against gemma-4-12b at p50 329ms, measured separately in
`docs/evals/2026-08-02-workstation-gemma4-12b.md`.
## What still misses
The confusion is concentrated in one direction: `query→fact ×4`,
`query→note ×3`, `query→system ×3`. A question about his own rows that carries
no interrogative reads as a statement to the model. Ten of the twenty-two
failures are that shape, including "я сегодня вообще пил воду" and "чем я
занимался в среду". This is the case V-546's three-head classifier is aimed at.
The two `разбуди меня` cases clarify at 0.300 instead of routing `reminder`.
## Item 4 is still not run
Killing the resident llama-server to confirm the classifier floor needs a
permission this session does not have. The test is otherwise ready. It now has
a second half. With the workstation up, killing the resident server should
still complete a turn through `modelSeam`. Only killing both proves the
classifier answers.
@@ -1,82 +0,0 @@
# Gemma as a label function, and what it found in the seeds
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
labels the 344 hand-written classifier seeds. Agreement with the label a person already
chose is a precision number rather than a guess.
## What ran
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
seed lines and leaves the rest.
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
retyped. Temperature 0.
## Cost
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
20k examples is under two hours of card, which matches its estimate.
## The stage 0 rules as label functions
Agreement between the grammar's label and the seed file the line came from:
| seed intent | agree |
|---|---|
| reminder | 37/37 |
| query | 9/10 |
| system | 7/8 |
| act | 2/2 |
| chat | 0/4 |
| note | 0/1 |
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
under two intents.
## Gemma against the seeds
**197/277, 71.1%.** By intent:
| seed intent | agree |
|---|---|
| note | 33/33 |
| act | 57/64 |
| fact | 37/40 |
| query | 51/54 |
| chat | 15/35 |
| system | 4/43 |
| reminder | 0/8 |
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
and the prompt holding different definitions of the same intent. Three boundaries carry 42
of them, and V-626 is the fix:
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
of 31-07-2026, which the seeds never received.
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
chat was the only honest destination for a question nothing could answer, and external
search now answers them.
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
bare verb phrase, and that is what a seed file is for.
Four intents have not been redefined since the seeds were written: note, fact, query and
act. They agree at 178 of 191.
## What this says about the plan
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
The plan already budgets a day of the owner reading the set. This says where to spend it.
It also says the two engines in the cascade are being taught different rules on 80 lines.
A routing measurement that swaps between the classifier and the router is measuring some of
that disagreement rather than the models.
+8 -1
View File
@@ -1,6 +1,6 @@
# Offloading model work to the workstation
*Last verified: 2026-08-03 @ 12530c8. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-05 @ b789676. Living doc: correct it in place, do not append.*
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
work, and this file holds the shape and the rules all four must obey.
@@ -149,6 +149,13 @@ flips. It is wired anyway: `PhraseReminder` is on the same transport and is on.
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
Speech-to-text stays two stages when it moves. One call carrying both a clip and the router
prompt was measured on 05-08-2026. It scores 54.2% intent-only against 84.7% for whisper on
homesrv, on the same 72 cases. The model transcribes clips it then routes wrong, so a long
classification prompt and an audio part compete for attention. Transcribing on the
workstation and routing the text scores 83.3% at p50 997ms. So the transfer buys 375ms and
cleaner transcripts, not accuracy. See `docs/evals/2026-08-05-audio-in-routing.md`.
## Order
1. **Transport** (#484). Nothing else is possible until a seam can cross a host.
+72
View File
@@ -142,6 +142,11 @@ type Task struct {
Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
// DoneWhen — the acceptance criterion. Empty until he writes one, and a
// candidate with no criterion cannot be promoted to open (Vikunja #510).
DoneWhen string `json:"done_when,omitempty"`
// BlockedOn — a canonical Nexus entity id, never a name.
BlockedOn string `json:"blocked_on,omitempty"`
}
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
@@ -170,6 +175,11 @@ type CaptureTaskReq struct {
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
// DoneWhen and BlockedOn are optional at intake. A derived source leaves
// both empty: mail says what to do, not what finishing means, and guessing
// a criterion would put Maven's reading in the field he is meant to write.
DoneWhen string `json:"done_when,omitempty"`
BlockedOn string `json:"blocked_on,omitempty"`
}
// CaptureTaskResp — Created is false when the same live task already existed,
@@ -539,6 +549,51 @@ type setTaskStatusReq struct {
By string `json:"by,omitempty"`
}
// EntityRef — one canonical entity from Nexus. Maven never mints these: an id
// exists because Nexus resolved a name to it.
//
// Ambiguous is the answer when the name matched more than one entity. It is a
// separate state from "not found" because the surface handles them
// differently: an unknown name may be a typo, and an ambiguous one has to be
// asked about, never picked (ECOSYSTEM-SPEC, and the same rule the mutating
// Hexis path follows).
type EntityRef struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Ambiguous bool `json:"ambiguous,omitempty"`
Candidates []string `json:"candidates,omitempty"`
}
type resolveEntityReq struct {
Query string `json:"query"`
Types []string `json:"types,omitempty"`
}
type resolveEntityResp struct {
Ref EntityRef `json:"ref"`
}
// editTaskReq — the rewrite of the three fields capture set (Vikunja #509).
// Due nil clears the date, so "no date given" and "remove the date" cannot be
// the same request.
type editTaskReq struct {
ID int64 `json:"id"`
Text string `json:"text"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
}
// setTaskFieldsReq — the write for the two board columns. Both are sent every
// time and both may be empty: clearing a blocker is as ordinary as setting one,
// so an omitted field cannot mean "leave it alone" without a second way to say
// "make it empty".
type setTaskFieldsReq struct {
ID int64 `json:"id"`
DoneWhen string `json:"done_when,omitempty"`
BlockedOn string `json:"blocked_on,omitempty"`
}
// idReq — methods keyed by a single id.
type idReq struct {
ID int64 `json:"id"`
@@ -847,6 +902,23 @@ type unlockReq struct {
// wire round-tripping via errors.Is).
var ErrToolNotFound = errors.New("ipc: tool not found")
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open with no
// definition of done (Vikunja #510). Carried across the wire so the /tasks
// form can say which refusal it hit rather than "не найдено".
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
// ErrTaskDuplicate — an edit would collide with another live task's normalised
// text (Vikunja #509). The surface says which row holds it rather than merging.
var ErrTaskDuplicate = errors.New("ipc: another live task already has this text")
// ErrNoEntity — Nexus resolved the name to nothing. Distinct from an outage,
// which surfaces as the transport error: "there is no such person" and "Nexus
// is down" must not read the same to a caller deciding whether to store an id.
var ErrNoEntity = errors.New("ipc: no such entity")
// ErrTaskResolved — a resolved task is not editable.
var ErrTaskResolved = errors.New("ipc: task is resolved")
// callerKey — context key for the authenticated caller. Server sets it from
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
// a missing Caller as "trusted same-process", the equivalent of the socket's
+16
View File
@@ -515,6 +515,22 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
}
func (c *Client) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
var r resolveEntityResp
if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil {
return EntityRef{}, err
}
return r.Ref, nil
}
func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil)
}
func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
}
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
// means core has no email block configured — the caller should stop asking, not
// retry.
+17
View File
@@ -142,6 +142,23 @@ type TaskAPI interface {
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
// ResolveEntity asks Nexus for the canonical id behind a name. It is how a
// surface turns "Kate" into an entity id before storing one, because
// identity lives in Nexus and a local name is a second answer to a
// question Nexus already owns. ErrNotImplemented when no Nexus is
// configured, ErrNoEntity when the name matched nothing, and an Ambiguous
// ref when it matched several — the caller asks, it does not pick.
ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error)
// EditTask rewrites the three fields capture set: text, due date and
// weight. Status is not among them — that ladder is one-way and belongs to
// SetTaskStatus. A resolved task is refused, and a text edit that would
// duplicate another live task is refused rather than merged.
EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error
// SetTaskFields writes the definition of done and the blocker. Not a
// status move, so it is not one-way: he may sharpen a criterion, and a
// blocker clears when the person answers. blockedOn is a canonical Nexus
// entity id or empty, never a name the caller had lying around.
SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error
}
// SystemAPI — what the daemon knows about itself, plus the one method that
+3
View File
@@ -31,6 +31,9 @@ var mapErrPairs = []struct {
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
}
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
+13
View File
@@ -540,6 +540,19 @@ var methodTable = map[Method]handlerFunc{
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
}),
MethodResolveEntity: withParams(func(ctx context.Context, api CoreAPI, p resolveEntityReq) (resolveEntityResp, error) {
ref, err := api.ResolveEntity(ctx, p.Query, p.Types)
if err != nil {
return resolveEntityResp{}, err
}
return resolveEntityResp{Ref: ref}, nil
}),
MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error {
return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight)
}),
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
}),
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
out, err := api.ListProposedRoutines(ctx)
if err != nil {
+25
View File
@@ -317,6 +317,8 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
DoneWhen: req.DoneWhen,
BlockedOn: req.BlockedOn,
})
if err != nil {
return CaptureTaskResp{}, mapErr(err)
@@ -343,6 +345,8 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
DoneWhen: t.DoneWhen,
BlockedOn: t.BlockedOn,
}
}
return out, nil
@@ -352,6 +356,21 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
}
// ResolveEntity is not the store's to answer: identity lives in Nexus and this
// adapter has no client. The daemon overrides it (cmd/mavend/tick_api.go), and
// a deployment with no nexus block keeps this refusal.
func (a *storeAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
return EntityRef{}, ErrNotImplemented
}
func (a *storeAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
return mapErr(a.s.EditTask(ctx, id, text, due, weight))
}
func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rs, err := a.s.ListProposedRoutines(ctx)
if err != nil {
@@ -458,6 +477,12 @@ func mapErr(err error) error {
return ErrReminderState
case errors.Is(err, store.ErrToolNotFound):
return ErrToolNotFound
case errors.Is(err, store.ErrTaskNoDoneWhen):
return ErrTaskNoDoneWhen
case errors.Is(err, store.ErrTaskDuplicate):
return ErrTaskDuplicate
case errors.Is(err, store.ErrTaskResolved):
return ErrTaskResolved
}
return err
}
+9
View File
@@ -114,6 +114,15 @@ func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Tas
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
return EntityRef{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
return ErrNotImplemented
}
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
return nil, ErrNotImplemented
}
+3
View File
@@ -55,6 +55,9 @@ const (
MethodCaptureTask Method = "capture_task"
MethodListTasks Method = "list_tasks"
MethodSetTaskStatus Method = "set_task_status"
MethodSetTaskFields Method = "set_task_fields"
MethodEditTask Method = "edit_task"
MethodResolveEntity Method = "resolve_entity"
MethodIngestMail Method = "ingest_mail"
MethodSwapModel Method = "swap_model"
MethodModelStatus Method = "model_status"
+43 -1
View File
@@ -63,7 +63,8 @@ func mustLoad() lexiconFile {
for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"not_place_after_v", "parts_of_day", "reminder_verbs",
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
"filler_particles", "task_done_words", "task_drop_words",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
@@ -112,6 +113,47 @@ func PartsOfDay() []string { return words("parts_of_day") }
// ReminderVerbs returns the imperatives that open a reminder.
func ReminderVerbs() []string { return words("reminder_verbs") }
// TaskDoneWords returns the words that finish a task, and TaskDropWords the
// words that abandon one. Two sets rather than one with a value, because the
// store records which of the two happened and the caller has to say so.
//
// Both mix moods on purpose, and the caller must match them the way the sets'
// notes say: an imperative exactly, a stative by lemma.
func TaskDoneWords() []string { return words("task_done_words") }
// TaskDropWords — see TaskDoneWords.
func TaskDropWords() []string { return words("task_drop_words") }
// IsFillerParticle reports whether a word can never be the subject of a
// request: a particle, a politeness word, or the first-person object. See the
// set's own note for why this is not a stopword list.
func IsFillerParticle(word string) bool {
w := norm(word)
for _, p := range ru.Sets["filler_particles"].Words {
if w == p {
return true
}
}
return false
}
// HalfHourWords returns those forms, for a caller folding every time word into
// one set rather than asking about one word.
func HalfHourWords() []string { return words("half_hour") }
// IsHalfHour reports whether a word introduces a spoken half hour, so the
// ordinal after it is an hour rather than a position. One caller reads that
// ordinal as the hour and another has to decline it; both ask here.
func IsHalfHour(word string) bool {
w := norm(word)
for _, h := range ru.Sets["half_hour"].Words {
if w == h {
return true
}
}
return false
}
// Cardinal reports the value of a spoken number word. The word is compared
// lowercased and trimmed, because it arrives from a tokenizer that may not have
// done either.
+31
View File
@@ -166,6 +166,37 @@
"напомни", "напомните", "напомнить", "напоминай",
"remind"
]
},
"half_hour": {
"note": "The forms of \"половина\" that introduce a spoken half hour: \"в половине восьмого\", \"к половине\", the bare \"пол\" of \"полвосьмого\". The set matters to two callers and for opposite reasons (V-522). The clock rewrite reads the ordinal after one of these as the hour being entered, and the ordinal-selection turn has to REFUSE that ordinal, because \"в половине восьмого\" names a time and not the eighth thing she read out.",
"words": [
"половина", "половине", "половину", "половины", "пол",
"half"
]
},
"filler_particles": {
"note": "Words that carry no subject of their own: particles, the politeness words, and the first-person object he addresses her with. A caller asking \"did he say WHAT to remind him about\" has to discount these, or \"ну напомни же\" and \"напомни мне пожалуйста\" both read as a reminder whose subject is the particle. Closed in the sense that matters: these are function words, and the language is not adding any. Not a stopword list — a stopword list is a scoring convenience and may be as long as it likes, while every word here has to be one that cannot BE a reminder's subject.",
"words": [
"ну", "же", "уж", "там", "вот", "пожалуйста", "плиз", "ка",
"давай", "давай-ка", "а", "и", "бы", "мне", "меня", "мной",
"please", "just", "hey", "me"
]
},
"task_done_words": {
"note": "The ways he says a task is finished, split by mood the way the Praxis lifecycle words are (Vikunja #512). The imperatives are addressed to her and are matched exactly, because morph.SameWord makes \"закрой\" and \"закрыл\" one word and only one of them is an instruction. The statives report his own day and are matched by lemma, since \"сделано\", \"сделана\" and \"сделанную\" are one state. Closed because these are her vocabulary for one transition, not a discovery about Russian.",
"words": [
"закрой", "закройте", "закрыть", "заверши", "завершить", "close", "finish",
"сделано", "сделал", "сделала", "выполнено", "выполнил", "выполнила",
"готово", "готова", "закрыл", "закрыла", "done", "finished"
]
},
"task_drop_words": {
"note": "The ways he abandons a task rather than finishing it (Vikunja #512). Same two moods as task_done_words and the same matching rule. Separate from the done words because the store records which of the two happened and /tasks shows it: dropped work he chose to stop is not work he did.",
"words": [
"убери", "уберите", "убрать", "удали", "удалить", "отмени", "отменить",
"drop", "remove", "cancel",
"передумал", "передумала", "неактуально"
]
}
}
}
+64 -8
View File
@@ -512,19 +512,40 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
// message array from dialogue history + the current user utterance. On any LLM
// error it returns both ChatFallback and the error, on the same rule as
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
// chatUserMessage folds the prior turns and the current one into a single user
// message, because some chat templates (Ministral and others) reject two user
// turns in a row. That constraint is real; what was wrong is how it was met.
//
// The turns used to be joined with newlines and nothing else, so the model was
// handed four unlabelled lines and no way to tell which one it was answering
// (Vikunja #554). It answered an earlier one, or answered all of them at once:
// asked "как дела" after a question about the telephone, she carried on about
// the telephone. Four turns live for fifteen minutes, so the wrong line was
// often several minutes old.
//
// The history holds only his own utterances, never her replies, so the label
// says so and stays in the second person the persona requires. With no history
// the message is the utterance alone, which is the common case and unchanged.
func chatUserMessage(utterance string, history []dialogue.Turn) string {
prior := make([]string, 0, len(history))
for _, t := range history {
if s := strings.TrimSpace(t.Text); s != "" {
prior = append(prior, "- "+s)
}
}
if len(prior) == 0 {
return strings.TrimSpace(utterance)
}
return "Раньше ты говорил:\n" + strings.Join(prior, "\n") +
"\n\nОтветь только на то, что ты говоришь сейчас: " + strings.TrimSpace(utterance)
}
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
sys := chatSystemPrompt(p.cfg.ContextBlock)
msgs := []chatMsg{
{Role: "system", Content: sys},
}
// Combine history and current utterance into one user message.
// Some model chat templates (Ministral, etc.) reject consecutive user turns.
var combined string
for _, t := range history {
combined += t.Text + "\n"
}
combined += utterance
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
msgs = append(msgs, chatMsg{Role: "user", Content: chatUserMessage(utterance, history)})
resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil {
@@ -920,6 +941,41 @@ func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) {
fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
}
// PhraseSelf answers a question about her from her own description. Same
// discipline as the evidence branch — say only what the text says — and a
// different opener, because "вот что я нашла: я — твоя помощница" says she
// looked herself up (Vikunja #555). She did not; this is the one subject she
// does not have to read about.
//
// On any error it reads the description out rather than ship a fragment. That
// is already a readable answer, which is why this needs no separate fallback.
func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) {
sys := persona.Prepend(p.cfg.ContextBlock,
"Он спрашивает о тебе. Отвечай ТОЛЬКО по описанию, которое тебе дали: всё, что ты говоришь о себе, должно быть в нём. "+
"Не добавляй умений, которых там нет, и не догадывайся. Не начинай с \"вот что я нашла\" — ты говоришь о себе, а не о находке. "+
// The gender rule is stated WITHOUT the "-ла" example the other
// prompts carry. Measured on the box: a 1.7B reads that as an
// instruction to use the past tense and answers "я вела заметки,
// управляла домом" — she describes what she does, in the present,
// and the past tense makes a live capability sound finished.
"Отвечай по-русски, коротко и своими словами, в настоящем времени — ты описываешь, что делаешь сейчас. О себе говори в женском роде. "+
"Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}.")
prompt := fmt.Sprintf("Он спрашивает: %q\n\nТвоё описание:\n%s\n\nОтветь ему на то, что он спросил.", utterance, description)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
text, _, perr := parseResponseMood(resp)
if err != nil || perr != nil {
cause := err
if cause == nil {
cause = perr
}
return description, fmt.Errorf("phrase self: %w", cause)
}
if text != "" {
return text, nil
}
return description, nil
}
// evidencePrompt — the sources branch: read these, add nothing. Shared with
// PhraseWorld for the same reason as knowledgePrompt.
func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) {
@@ -0,0 +1,42 @@
package phraser
import (
"strings"
"testing"
"github.com/kami/maven/internal/dialogue"
)
// TestChatUserMessageMarksWhichTurnToAnswer — Vikunja #554. Four prior turns
// were joined with newlines and nothing else, so nothing in the message said
// which line was the question.
func TestChatUserMessageMarksWhichTurnToAnswer(t *testing.T) {
got := chatUserMessage("как дела", []dialogue.Turn{
{Text: "кто изобрёл телефон"},
{Text: "а когда это было"},
})
if !strings.Contains(got, "кто изобрёл телефон") {
t.Error("the prior turns must survive — they are what anaphora reads")
}
now := strings.LastIndex(got, "как дела")
if now < strings.Index(got, "кто изобрёл телефон") {
t.Error("the current utterance must come last, after the turns it follows")
}
if !strings.Contains(got, "Раньше ты говорил") {
t.Errorf("the prior turns must be labelled as prior: %q", got)
}
if strings.Contains(got, " вы ") || strings.Contains(got, "Вы ") {
t.Errorf("the persona addresses him informally: %q", got)
}
}
// TestChatUserMessageWithNoHistoryIsJustTheUtterance — the common case must not
// grow a preamble that the model then has to see past.
func TestChatUserMessageWithNoHistoryIsJustTheUtterance(t *testing.T) {
if got := chatUserMessage(" привет ", nil); got != "привет" {
t.Errorf("chatUserMessage = %q, want %q", got, "привет")
}
if got := chatUserMessage("привет", []dialogue.Turn{{Text: " "}}); got != "привет" {
t.Errorf("a blank prior turn must not label anything: %q", got)
}
}
+10
View File
@@ -49,6 +49,9 @@ type Phraser interface {
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
// PhraseSelf answers a question about her from her own description, which
// is not a source she read and must not be phrased as one (Vikunja #555).
PhraseSelf(ctx context.Context, utterance, description string) (string, error)
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
Close() error
}
@@ -81,6 +84,13 @@ func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string,
return SourcesFallback(strings.Join(notes, "; ")), nil
}
// PhraseSelf reads the description out as it stands. There is nothing to fall
// back to and nothing to shorten: the text is already written in her voice, and
// that is the whole reason it is a constant rather than a prompt.
func (s *Stub) PhraseSelf(_ context.Context, _, description string) (string, error) {
return description, nil
}
// Close implements Phraser.Close (no-op for the stub).
func (s *Stub) Close() error { return nil }
+57
View File
@@ -0,0 +1,57 @@
package router
import "regexp"
// IsAgendaQuestion answers whether an utterance asks about the owner's own
// schedule, as opposed to merely naming a day.
//
// It exists because the calendar query source used to match on a day word and
// nothing else (Vikunja #552). Every world question that happened to name a
// day was claimed by the calendar and answered with an empty schedule: "какой
// сегодня курс доллара" replied "на 05.08.2026 ничего нет", which reads as an
// answer about a subject she never looked at. V-474 had already fixed one
// instance of the class by teaching the calendar to step aside on weather
// wording. Sunset, holidays, exchange rates and world news are the same class
// and weather wording does not cover them.
//
// Three arms, and the order is only readability — any one of them is enough:
//
// - an agenda grammar already claims the phrasing. Reusing
// AgendaQueryGrammars means the rule that ROUTES a question to the query
// chain and the rule that lets the CALENDAR answer it cannot drift apart.
// - the utterance names a scheduled thing. Wider than the grammars on
// purpose: "какие встречи завтра" carries no possessive and no plan noun,
// so no grammar claims it, and it is plainly a calendar question.
// - the question names no subject of its own. "что сегодня?" is his agenda
// by default, because there is nothing else for it to be about. This is
// the same test the bare-imperative Praxis arm applies.
//
// Not a routing decision and not a fact, so a pattern is the right mechanism
// here: it selects which source answers, and every source below still runs
// when it returns false.
func IsAgendaQuestion(u string) bool {
for _, g := range AgendaQueryGrammars() {
if g.Pattern.MatchString(u) {
return true
}
}
return scheduledThing.MatchString(u) || subjectlessDayQuestion.MatchString(u)
}
// scheduledThing — the nouns that name something on a calendar. Closed in the
// sense that matters: these are the words for an appointment itself, not the
// words for what an appointment is about. The stems are the union of the ones
// AgendaQueryGrammars already carries, read here as a noun test rather than as
// part of a phrasing.
//
// Stems and not whole words, because Russian declines them and "какие встречи"
// and "на встречу" are one question.
var scheduledThing = regexp.MustCompile(`(?i)(календар|расписани|повестк|планёрк|планерк|встреч|созвон|митинг|совещани|приём|прием|собеседовани|тренировк|занятие|занятия)`)
// subjectlessDayQuestion — "что сегодня?", "что там на завтра", "что в среду".
// An interrogative, an optional preposition, a day word, and nothing else. The
// anchors at both ends are the whole point: the moment the sentence names what
// it is asking about, it stops being his agenda and this must not match.
var subjectlessDayQuestion = regexp.MustCompile(
`(?i)^\s*(что|чего|какие|сколько|what)\s+(там\s+|ещё\s+|еще\s+)?(на\s+|в\s+|во\s+)?` +
dayWordPattern + `\s*[?!.]*$`)
+66
View File
@@ -0,0 +1,66 @@
package router
import "testing"
// The four utterances in Vikunja #552 plus the ones that must keep reaching
// the calendar. The list is the whole point of the predicate: every "want
// false" row was answered "на 05.08.2026 ничего нет" on the deployed daemon.
func TestIsAgendaQuestionSeparatesHisDayFromTheWorld(t *testing.T) {
tests := []struct {
utterance string
want bool
}{
// His day.
{"что у меня сегодня", true},
{"во сколько у меня встреча сегодня", true},
{"что в календаре на завтра", true},
{"какие планы на завтра", true},
{"какие встречи завтра", true},
{"когда планёрка", true},
{"покажи расписание на среду", true},
{"что дальше?", true},
// No subject of its own, so his day by default.
{"что сегодня?", true},
{"что на завтра", true},
{"что там в среду?", true},
// The world, naming a day. Every one of these is #552.
{"во сколько закат сегодня", false},
{"какой сегодня курс доллара", false},
{"какой сегодня праздник", false},
{"что интересного произошло сегодня в мире", false},
{"кто выиграл вчера матч", false},
// A day word plus a subject is never subjectless, however short.
{"что за праздник сегодня", false},
}
for _, tt := range tests {
if got := IsAgendaQuestion(tt.utterance); got != tt.want {
t.Errorf("IsAgendaQuestion(%q) = %v, want %v", tt.utterance, got, tt.want)
}
}
}
// One hand-written utterance per agenda grammar, keyed by name. Asserting that
// the grammars pass IsAgendaQuestion would be true by construction, since the
// first arm is the loop over them. This asserts something else: that each
// grammar still matches the case its own comment gives, and that the set of
// grammars has not grown a member nobody wrote an example for.
func TestEachAgendaGrammarStillMatchesItsOwnExample(t *testing.T) {
examples := map[string]string{
"calendar-query": "что в календаре на завтра",
"agenda-query": "что у меня сегодня",
"plan-day-query": "какие планы на завтра",
"rest-of-day-query": "что дальше?",
"event-time-query": "когда планёрка",
}
for _, g := range AgendaQueryGrammars() {
u, ok := examples[g.Name]
if !ok {
t.Errorf("agenda grammar %q has no example here — add one", g.Name)
continue
}
if !g.Pattern.MatchString(u) {
t.Errorf("grammar %q no longer matches %q", g.Name, u)
}
}
}
+7
View File
@@ -49,6 +49,13 @@ try:
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
# A bare hour after a preposition is dropped on the floor by dateparser:
# "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7
# часов" is read as seven hours from now. Only a qualifier (already an
# am/pm above) or a colon makes it read the hour, so give it the colon.
# English "at 7" fails identically, so both prepositions are rewritten.
text = re.sub(r'(?<![\w:])(в|во|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
# Two-step: search_dates finds the date substring in text,
# parse() gets the time right (search_dates mishandles AM/PM).
+47
View File
@@ -103,6 +103,53 @@ func TestPythonDateParser(t *testing.T) {
}
},
},
// A bare hour after a day word used to be dropped, and the current
// clock carried onto that day: at 21:12 "завтра в семь" confirmed a
// reminder for 21:12 tomorrow (Vikunja #551). She invented a time
// instead of asking, on a path that then fires.
{
name: "ru bare hour — завтра в семь",
text: "напомни мне завтра в семь позвонить маме",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 7 || got.Minute() != 0 {
t.Errorf("завтра в семь: %02d:%02d, want 07:00", got.Hour(), got.Minute())
}
},
},
{
// "7 часов" was read as seven hours from now, which also moved the day.
name: "ru bare hour — завтра в 7 часов",
text: "напомни завтра в 7 часов позвонить",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 7 || got.Minute() != 0 {
t.Errorf("завтра в 7 часов: %02d:%02d, want 07:00", got.Hour(), got.Minute())
}
},
},
{
name: "en bare hour — tomorrow at 7",
text: "remind me tomorrow at 7 to call mum",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
if got.Hour() != 7 || got.Minute() != 0 {
t.Errorf("tomorrow at 7: %02d:%02d, want 07:00", got.Hour(), got.Minute())
}
},
},
{
// The rewrite must not touch a duration: "через 2 часа" is not "в 2".
name: "ru duration is untouched — через 2 часа",
text: "напомни через 2 часа выпить воды",
wantOK: true,
checkT: func(t *testing.T, got, now time.Time) {
d := got.Sub(now)
if d < 110*time.Minute || d > 130*time.Minute {
t.Errorf("через 2 часа: got %v from now, want ~2h", d)
}
},
},
{
name: "no date — напомни мне",
text: "напомни мне",
+1
View File
@@ -243,6 +243,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
grammars = append(grammars, router.PraxisGrammars()...)
grammars = append(grammars, router.TaskStatusGrammar())
grammars = append(grammars, router.TaskCaptureGrammar())
// "расскажи про X" is a world question the model called a fact, and the
// rule goes last because it matches on the first word alone (Vikunja #498).
+78
View File
@@ -112,6 +112,84 @@ func TestLLMRouterBaseline(t *testing.T) {
}
}
// TestReachWithLLMRouter — the reach fixture scored with the resident model as
// router (Vikunja #517). V-405 measured the classifier only, and the LLM router
// is the deployed default, so 16/30 with praxis 0/12 is the floor rather than
// the shipped behaviour.
//
// The question is specific. The route grammar lets the model write any string
// into the fn slot, so it *could* emit a literal Praxis capability name and
// reach a service the classifier structurally cannot. If it does, V-516's
// stage-0 Praxis grammars are a determinism argument. If it does not, they are
// the only path.
//
// Same gate and same two configurations as TestLLMRouterBaseline, for the same
// reason: "the LLM router" alone and the cascade that actually ships answer
// different questions.
func TestReachWithLLMRouter(t *testing.T) {
base := os.Getenv("MAVEN_LLM_URL")
if base == "" {
t.Skip("MAVEN_LLM_URL unset — start llama-server and point it here (see doc comment)")
}
noProxyLoopback(t)
f, err := LoadReach()
if err != nil {
t.Fatalf("LoadReach: %v", err)
}
client := llm.New(base, 60*time.Second)
if err := ping(context.Background(), client); err != nil {
t.Skipf("llama-server at %s unreachable: %v", base, err)
}
ctx := context.Background()
model, err := llm.ModelID(ctx, base)
if err != nil {
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel)
model = llm.UnknownModel
}
t.Logf("scoring reach with model %s at %s", model, base)
lr := router.NewLLMRouter(client)
m := router.DefaultActMatcher{Fns: actFns}
llmOnly := RouterFunc(func(ctx context.Context, u string, now time.Time) (router.Decision, error) {
d, ok, err := lr.Route(ctx, u, now)
if err != nil {
return d, err
}
if !ok {
return d, fmt.Errorf("llm router declined without an error")
}
return d, nil
})
repLLM, err := ScoreReach(ctx, "reach: llm-only ("+model+")", llmOnly, m, f)
if err != nil {
t.Fatalf("ScoreReach llm-only: %v", err)
}
t.Log("\n" + repLLM.String() + repLLM.Failures())
// Hash embedder for the classifier floor, so any lift is the model's and
// not the embedder's — the same control TestLLMRouterBaseline uses.
repCascade, err := ScoreReach(ctx, "reach: cascade+llm ("+model+") + hash fallback",
newBaselineRouter(t, router.NewHashEmbedder(1024), lr), m, f)
if err != nil {
t.Fatalf("ScoreReach cascade: %v", err)
}
t.Log("\n" + repCascade.String() + repCascade.Failures())
for _, rep := range []ReachReport{repLLM, repCascade} {
if rep.Errors == rep.Total {
t.Errorf("%s: all %d cases errored — harness fault, not a measurement", rep.Name, rep.Total)
}
}
// Overreach is the one direction worth failing on, for the reason
// TestReachBaselineHash gives: he never gets asked about it.
if repCascade.Overreach > 4 {
t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s",
repCascade.Overreach, repCascade.Failures())
}
}
func ping(ctx context.Context, c *llm.Client) error {
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
+5 -1
View File
@@ -70,6 +70,8 @@
{ "id": "ru-act-004", "utterance": "включи вытяжку", "lang": "ru", "intent": "act", "want_fn": true },
{ "id": "ru-act-005", "utterance": "запусти бэкап сейчас", "lang": "ru", "intent": "act", "want_fn": true },
{ "id": "ru-act-006", "utterance": "закрой жалюзи", "lang": "ru", "intent": "act", "want_fn": true },
{ "id": "ru-act-020", "utterance": "закрой задачу купить молоко", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "a spoken status change over the board. Routed act with no allowlisted fn until V-512, so the gate asked \"Что сделать?\"; TaskStatusGrammar fills the fn slot with task_status and actionAct answers it from Maven's own store" },
{ "id": "ru-act-021", "utterance": "убери из задач оплатить интернет", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "the drop half of the same rule. Dropped work he chose to stop is not work he did, so the two status sets are separate lexicons" },
{ "id": "en-act-001", "utterance": "maven, restart the media server", "lang": "en", "intent": "act", "want_fn": true, "tags": ["wake-token"] },
{ "id": "en-act-002", "utterance": "turn off the kitchen light", "lang": "en", "intent": "act", "want_fn": true },
@@ -102,6 +104,8 @@
{ "id": "amb-003", "utterance": "ну это", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] },
{ "id": "amb-004", "utterance": "сделай это", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "anaphora"], "note": "unresolved anaphora with an imperative — must not guess an fn" },
{ "id": "amb-005", "utterance": "потом", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] },
{ "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] }
{ "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] },
{ "id": "amb-007", "utterance": "напомни", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder"], "note": "the reminder verb and nothing else — she knows the shape of the request and not one thing about it. Answered 'не получилось разобрать время напоминания' on the box until V-548: the subjectless-reminder gate tested Slots.Text == \"\", and fillSlots had put the verb in that slot" },
{ "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" }
]
}
+17 -4
View File
@@ -5,10 +5,16 @@ import "strings"
// Feed queries — "что нового в лентах?", "что нового по технологиям?"
// (Vikunja #258).
//
// Deterministic matching, like the calendar, plan and habit matchers above it:
// the LLM router says this is a query, and this decides whether it is a question
// about the feeds. A model deciding that would occasionally answer "что нового?"
// out of world knowledge, which is the one thing a feed reader exists to avoid.
// This file is the OFFLINE FLOOR as of 05-08-2026 (V-522). Whether a turn is
// about the feeds is a question about meaning, so the frozen seeds decide it —
// topicFeed in cmd/mavend/topics.go, through turnIsAbout. The word lists below
// stay because they always answer: a box with no embedder, a turn whose vector
// never got computed, and any call that does not clear topicMargin. They are
// allowed to stay narrow now that they are not the only answer.
//
// What has not changed is that no generative model decides this. One would
// occasionally answer "что нового?" out of world knowledge, which is the one
// thing a feed reader exists to avoid.
// FeedQuery — a parsed "what's new" question. Category is the topic he named
// ("технологии"), empty when he asked about the feeds in general.
@@ -95,6 +101,13 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
// cannot be mistaken for anything else.
var categoryPreps = map[string]bool{"по": true, "об": true, "про": true, "about": true, "on": true}
// FeedCategoryOf reads the topic out of an utterance without deciding whether the
// turn is a feed question at all. The seeds answer that now (topicFeed in
// cmd/mavend/topics.go, V-522), and they answer it for phrasings the word lists
// here never held — but a claimed turn still needs its category, and a category
// is marked by a preposition rather than recognised.
func FeedCategoryOf(text string) string { return feedCategory(planTokens(text)) }
func feedCategory(toks []string) string {
for i, t := range toks {
if categoryPreps[t] && i+1 < len(toks) {
+4 -8
View File
@@ -24,13 +24,9 @@ import (
// had one and added when it did not, because the stub scans for "в" before a
// clock and the contracted "полвосьмого" carries no preposition at all.
// halfWords — the forms of "половина" a spoken time uses. "в половине",
// "половина", "к половине", "полвосьмого". Closed and tiny; the ordinal beside
// them is what carries the hour, and that comes from the lexicon.
var halfWords = map[string]bool{
"половина": true, "половине": true, "половину": true, "половины": true,
"пол": true, "half": true,
}
// The forms of "половина" a spoken time uses are a closed class and live in the
// lexicon as half_hour, because the ordinal-selection turn has to decline the
// same ordinal this file reads (V-522). Ask lexicon.IsHalfHour.
// minutesTo — the words that name the minutes in a "без X" hour. "четверти" is
// the only one that is not a number; the rest are cardinals and are read as
@@ -74,7 +70,7 @@ func halfPastAt(toks []string, i int) (hour, width int, ok bool) {
return h, 1, true
}
}
if !halfWords[head] || i+1 >= len(toks) {
if !lexicon.IsHalfHour(head) || i+1 >= len(toks) {
return 0, 0, false
}
h, ok := enteredHour(cleanWord(toks[i+1]))
+34
View File
@@ -17,6 +17,15 @@ import (
// The markers are deliberately explicit. "молоко закончилось" is an
// observation about the world and belongs in a note; only an instruction to
// put something on a list puts it there.
//
// The four paths split on 05-08-2026, and the split is by what the caller needs
// rather than by language (V-522). Reading a list back needs one bit — is this
// about the list — so the seeds decide it, topicList through turnIsAbout, and
// listQueryPrefixes below is the offline floor. The other three keep the tables
// as the answer. Add and remove need to know WHERE the item starts, and a
// cosine over a whole utterance does not say which byte the milk begins at.
// Clear DELETES the list, so it stays on exact phrases: a false claim there
// loses rows he cannot get back, which is not the trade a margin makes.
// listTags — the lists he can name, as one dictionary form each. Russian
// declines the tag ("список покупок", "в покупки", "в покупках"), and the
@@ -186,6 +195,31 @@ func ParseListQuery(text string) (string, bool) {
return list, true
}
// ListNamedIn reports which standing list an utterance names, anywhere in it,
// defaulting to покупки when it names none.
//
// takeListTag is not enough for a read-back, because it reads the FRONT of a
// remainder a prefix table has already eaten. The seeds claim a read-back
// without eating anything (topicList, cmd/mavend/topics.go, V-522), so "что мне
// нужно в аптеке" has to be scanned rather than trimmed. A list name is a noun
// in the dictionary, so this is a lookup and decides nothing about meaning.
func ListNamedIn(text string) string {
for _, f := range strings.Fields(strings.ToLower(text)) {
head := strings.Trim(f, listTrimCut)
for _, s := range listTags {
if morph.SameWord(head, s.word) {
return s.list
}
}
for _, s := range listTagsEN {
if head == s.word {
return s.list
}
}
}
return "покупки"
}
// ParseListClear reports whether an utterance crosses off a whole list.
func ParseListClear(text string) (string, bool) {
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
+18
View File
@@ -26,6 +26,24 @@ var (
captureVerbs = lexicon.CaptureVerbs()
)
// CarriesCaptureVerb reports whether text tells Maven to write something down.
// Sibling of IsQuestionShaped and matched over the same tokens, and the two do
// not overlap: IsQuestionShaped returns false for anything this returns true
// for, because "запиши что я пил воду" is a capture and not a question.
//
// Both exist together so a caller can ask "is this its own request?" — a
// clarify answer that asks a question or orders a capture is not an answer
// (Vikunja #554).
func CarriesCaptureVerb(text string) bool {
toks := planTokens(strings.TrimSpace(text))
for _, v := range captureVerbs {
if hasTok(toks, v) {
return true
}
}
return false
}
// IsQuestionShaped reports whether text asks for something rather than
// records it. It is a deterministic offline test over tokens, so it costs
// nothing and never depends on the model that produced the routing decision.
+62
View File
@@ -0,0 +1,62 @@
package router
import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// A reminder needs something to say at the hour, and the gate that checks for
// one was reading a slot that is never empty.
//
// gateLLMDecision has asked about a subjectless reminder since V-383, on the
// test `d.Slots.Text == ""`. Measured on the box on 05-08-2026: "напомни" alone
// routes to IntentReminder with `Text:напомни`, because fillSlots hands the text
// slot the utterance when the model names nothing narrower. So the slot was
// never empty, the gate never fired, and the turn reached actionReminder and
// answered "не получилось разобрать время напоминания." — a parse error for a
// request she never finished asking about. "ну напомни же" did the same.
//
// The fix is to ask what the text slot CONTAINS rather than whether it is set.
// Two closed classes answer that and no third mechanism is needed: the reminder
// verbs are her own vocabulary (lexicon.ReminderVerbs), and the particles and
// politeness words cannot be the subject of anything (lexicon.IsFillerParticle).
// A verb is matched by lemma through morph.SameWord, so "напоминай" and
// "напомнить" need no entry of their own.
//
// Deliberately NOT reusing cmd/mavend/reminderbody.go, which strips the same
// marker: that function also strips the time words, so "напомни завтра" would
// read as subjectless there. Asking is right when he named no subject, and wrong
// when he named a day — the reminder for tomorrow is the one whose subject she
// should ask about, not one she should treat as noise.
// reminderHasSubject reports whether a reminder's text names anything to say at
// the hour. False for "напомни", "напомни мне", "ну напомни же"; true for
// "напомни позвонить маме" and for "напомни завтра", where the day is a subject
// she can ask nothing better about.
func reminderHasSubject(text string) bool {
for _, f := range strings.Fields(strings.ToLower(text)) {
w := strings.Trim(f, " ,.;:!?—-«»\"'()")
if w == "" || lexicon.IsFillerParticle(w) {
continue
}
if isReminderVerb(w) {
continue
}
return true
}
return false
}
// isReminderVerb matches one of her reminder imperatives by lemma. Lemma and not
// prefix: "напоминание" is a noun he can perfectly well ask to be reminded
// about, and a stem test would eat it.
func isReminderVerb(word string) bool {
for _, v := range lexicon.ReminderVerbs() {
if word == v || morph.SameWord(word, v) {
return true
}
}
return false
}
+40
View File
@@ -0,0 +1,40 @@
package router
import "testing"
func TestReminderHasSubject(t *testing.T) {
cases := []struct {
text string
want bool
}{
// The three the box produced, and the reason this file exists.
{"напомни", false},
{"ну напомни же", false},
{"напомни мне", false},
{"напомни мне пожалуйста", false},
// Lemma, not literal: none of these forms is the one in the utterance
// the lexicon lists first.
{"напоминай", false},
{"напомнить", false},
{"remind me", false},
{"remind me please", false},
// A real subject, however short.
{"напомни позвонить маме", true},
{"напомни про таблетки", true},
{"напомни выпить воды", true},
{"remind me to call mom", true},
// A day is a subject she can ask nothing better about, so she does not
// ask. This is where reminderBody's stripping would disagree, on purpose.
{"напомни завтра", true},
{"напомни в семь", true},
// A noun that starts like the verb. A stem test would eat it.
{"напомни про напоминание", true},
// Empty is subjectless without asking the lexicon anything.
{"", false},
}
for _, c := range cases {
if got := reminderHasSubject(c.text); got != c.want {
t.Errorf("reminderHasSubject(%q) = %v, want %v", c.text, got, c.want)
}
}
}
+6 -1
View File
@@ -189,7 +189,12 @@ func (r *Router) gateLLMDecision(d *Decision) {
// A reminder with no subject: she knows when but not what to say then.
// Setting it anyway fires an empty reminder at the hour, which reads as a
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence {
//
// The test is what the text slot CONTAINS, not whether it is set. It was the
// latter until 05-08-2026, and the slot is never empty: fillSlots hands it
// the utterance, so "напомни" arrived here with Text:напомни and the gate
// never fired (V-457). See remindersubject.go.
if d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text) && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
if d.Confidence < r.threshold {
+178
View File
@@ -0,0 +1,178 @@
package router
import (
"regexp"
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/store"
)
// A spoken status change over the task list (Vikunja #512, step 4 of
// docs/plans/15-board-surface.md).
//
// Capture and the list read were built; moving a task was only possible in the
// two turns after she read one out, through resolveCandidate — "первую сделал"
// against the bound list. Naming the task instead of its position reached
// nothing: "закрой задачу купить молоко" routed act, found no allowlisted fn,
// and the gate asked "Что сделать?".
//
// Same shape TaskCaptureGrammar uses, for the same reason: no eighth intent, so
// the grammar matches broadly and a deterministic parser inside Build decides.
// The task's own warning applies — every such grammar runs its parser ahead of
// the resident model on every turn, so this is the last one that is free.
//
// TaskStatusFn is the fn slot the daemon dispatches on. Not a Hexis capability
// and not a Praxis one: the board is Maven's own store, so actionAct intercepts
// this name before either ecosystem client sees it.
const TaskStatusFn = "task_status"
// TaskStatus — a parsed status change. Status is a store task status, and Text
// is the task he named, empty when he named none ("закрой задачу"), which is a
// turn the daemon claims and answers by asking which.
type TaskStatus struct {
Status string
Text string
}
// taskStatusNouns — the noun that makes this a board turn rather than ordinary
// speech. Required, and it is the whole reason this rule is safe to run on every
// utterance: "готово" alone is him reporting his day, "убери" alone is a request
// about the room, and neither names the list.
//
// "дело" is deliberately absent. "в чём дело" and "дело в том" are ordinary
// speech, and "список дел" is already a list query.
var taskStatusNouns = []string{"task", "tasks", "todo", "todos"}
// taskStatusFillers — the words to ignore when what is left over is the task he
// named. Prepositions and the possessive, because "убери из моих задач купить
// молоко" names the same task as "убери задачу купить молоко".
var taskStatusFillers = []string{"из", "в", "во", "с", "со", "мои", "моих", "моё", "мой", "моя", "мою", "my", "the", "from", "off", "as", "как"}
// ParseTaskStatus reads a status change over the board: which transition, and
// which task.
//
// Three conditions, all required. A task noun, so no ordinary sentence claims
// the turn. Exactly one status class, because "готово, убери" names two and
// asking beats picking. And a status word that is either an imperative in the
// exact form he said it or a stative by lemma — the trap quiet_toggle.go
// documents, where "закрой" and "закрыл" are one lemma and only one is a
// command.
func ParseTaskStatus(text string) (TaskStatus, bool) {
toks := praxisTokens(strings.ToLower(strings.TrimSpace(text)))
if len(toks) == 0 || !taskStatusNamesBoard(toks) {
return TaskStatus{}, false
}
status := ""
for _, c := range []struct {
status string
words []string
}{
{store.TaskDone, lexicon.TaskDoneWords()},
{store.TaskDropped, lexicon.TaskDropWords()},
} {
if !taskStatusHasWord(toks, c.words) {
continue
}
if status != "" {
// Two transitions in one sentence. They are different rows on the
// page, so this declines and the cascade answers.
return TaskStatus{}, false
}
status = c.status
}
if status == "" {
return TaskStatus{}, false
}
return TaskStatus{Status: status, Text: taskStatusReferent(toks)}, true
}
// taskStatusNamesBoard reports whether the sentence names the task list. The
// Russian noun is matched by lemma, because a noun means the same thing in every
// case and he says "из задач", "задачу", "задача" for one list.
func taskStatusNamesBoard(toks []string) bool {
for _, t := range toks {
if morph.SameWord(t, "задача") {
return true
}
for _, n := range taskStatusNouns {
if t == n {
return true
}
}
}
return false
}
// taskStatusHasWord matches a status word the way its set's note requires: an
// imperative exactly, a stative by lemma. It cannot tell the two columns apart
// from the data, so it tries the exact form first and then the lemma — which
// costs the imperative trap back, except that both columns of one set mean the
// SAME transition. "закрой" and "закрыл" are one lemma and, here, one status.
func taskStatusHasWord(toks, words []string) bool {
for _, t := range toks {
for _, w := range words {
if t == w || morph.SameWord(t, w) {
return true
}
}
}
return false
}
// taskStatusReferent is what is left after the status words, the board noun and
// the fillers: the task he named, or "" when he named none.
//
// Word order is kept, because the leftover is matched against stored task text
// and he says the task the way he first said it.
func taskStatusReferent(toks []string) string {
done, drop := lexicon.TaskDoneWords(), lexicon.TaskDropWords()
var out []string
for _, t := range toks {
switch {
case taskStatusHasWord([]string{t}, done), taskStatusHasWord([]string{t}, drop):
case morph.SameWord(t, "задача"), taskStatusIn(t, taskStatusNouns):
case taskStatusIn(t, taskStatusFillers), lexicon.IsFillerParticle(t):
default:
out = append(out, t)
}
}
return strings.Join(out, " ")
}
func taskStatusIn(tok string, words []string) bool {
for _, w := range words {
if tok == w {
return true
}
}
return false
}
// TaskStatusGrammar — stage 0 for a spoken status change. Wired after the Praxis
// rules and before the capture marker: Praxis claims a bare "закрой" and this
// rule requires the board noun, so the two cannot collide, and the capture
// marker must not read "убери из задач купить молоко" as a new task.
func TaskStatusGrammar() Grammar {
return Grammar{
Name: "task-status",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
c, ok := ParseTaskStatus(m[1])
if !ok {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentAct,
Confidence: 1.0,
// Value carries the transition and Text the task he named,
// which is the pairing handlePraxisAct uses for an item and its
// reference. Empty Text is a claim, not a refusal: the daemon
// asks which task, having the list she does not.
Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text},
}, true
},
}
}
+68
View File
@@ -0,0 +1,68 @@
package router
import "testing"
func TestParseTaskStatus(t *testing.T) {
cases := []struct {
utterance string
ok bool
status string
text string
}{
// The shapes that reached nothing before this rule.
{"закрой задачу купить молоко", true, "done", "купить молоко"},
{"задачу купить молоко сделал", true, "done", "купить молоко"},
{"убери из задач купить молоко", true, "dropped", "купить молоко"},
{"убери из моих задач купить молоко", true, "dropped", "купить молоко"},
{"отмени задачу оплатить интернет", true, "dropped", "оплатить интернет"},
{"task buy milk done", true, "done", "buy milk"},
// The referent may be missing. The turn is still his, and the daemon has
// the list to ask about.
{"закрой задачу", true, "done", ""},
{"убери задачу", true, "dropped", ""},
// No board noun: ordinary speech, and every one of these means something
// else. "закрой" alone belongs to Praxis.
{"готово", false, "", ""},
{"закрой", false, "", ""},
{"убери со стола", false, "", ""},
{"я всё сделал", false, "", ""},
{"закрой шторы в комнате", false, "", ""},
// The board noun with no status word is a list query, not a move.
{"какие у меня задачи", false, "", ""},
{"добавь в задачи купить молоко", false, "", ""},
// Two transitions in one sentence. Asking beats picking.
{"задачу купить молоко готово убери", false, "", ""},
{"", false, "", ""},
}
for _, c := range cases {
got, ok := ParseTaskStatus(c.utterance)
if ok != c.ok {
t.Errorf("ParseTaskStatus(%q) ok = %v, want %v", c.utterance, ok, c.ok)
continue
}
if !ok {
continue
}
if got.Status != c.status || got.Text != c.text {
t.Errorf("ParseTaskStatus(%q) = %+v, want status %q text %q", c.utterance, got, c.status, c.text)
}
}
}
func TestTaskStatusGrammarFillsTheFnSlot(t *testing.T) {
g := TaskStatusGrammar()
m := g.Pattern.FindStringSubmatch("закрой задачу купить молоко")
if m == nil {
t.Fatal("pattern did not match")
}
dec, ok := g.Build(m)
if !ok {
t.Fatal("Build declined")
}
if dec.Intent != IntentAct || !dec.Slots.HasFn || dec.Slots.Fn != TaskStatusFn {
t.Fatalf("decision = %+v, want act with fn %q", dec, TaskStatusFn)
}
if dec.Slots.Value != "done" || dec.Slots.Text != "купить молоко" {
t.Fatalf("slots = %+v, want value done text \"купить молоко\"", dec.Slots)
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ func buildTimeMarkers() map[string]bool {
m[w] = true
}
}
for w := range halfWords {
for _, w := range lexicon.HalfHourWords() {
m[w] = true
}
for w := range minutesTo {
+15
View File
@@ -36,6 +36,13 @@ const (
TasksFirst = "tasks_first"
TasksCandidates = "tasks_candidates"
// The counted stall shapes on /tasks and in the spoken list (V-512). Each
// one states a count and nothing about what it means: "лежит дольше десяти
// дней" is arithmetic, "стоит бросить" would be a judgement she may not make.
StallOverdue = "stall_overdue"
StallSitting = "stall_sitting"
StallUnconfirmed = "stall_unconfirmed"
ReasonOverdue = "reason_overdue"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
@@ -64,6 +71,7 @@ const (
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
StallOverdue, StallSitting, StallUnconfirmed,
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
@@ -86,6 +94,10 @@ var summaryFloor = map[string]string{
TasksFirst: "сначала: {items}",
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
StallOverdue: "{n} {word} просрочено",
StallSitting: "{n} {word} лежит дольше {days} {dayword}",
StallUnconfirmed: "{n} {word} ждёт подтверждения",
ReasonOverdue: "просрочено",
ReasonOverdueDays: "просрочено на {n} {word}",
ReasonToday: "сегодня",
@@ -128,6 +140,9 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
{StallSitting, "{n}"}, {StallSitting, "{days}"},
{StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"},
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
+13
View File
@@ -44,6 +44,19 @@
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
},
"stall_overdue": {
"fixed": true,
"variants": ["{n} {word} просрочено"]
},
"stall_sitting": {
"fixed": true,
"variants": ["{n} {word} лежит дольше {days} {dayword}"]
},
"stall_unconfirmed": {
"fixed": true,
"variants": ["{n} {word} ждёт подтверждения"]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
+10
View File
@@ -290,6 +290,16 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
ALTER TABLE nudges_new RENAME TO nudges;
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
// #22 — the two columns that make tasks a work board rather than a to-do
// list (Vikunja #510). done_when is the acceptance criterion, and blocked_on
// is a canonical Nexus entity id: it names a person, identity lives in
// Nexus, and a local free-text name would be a second answer to a question
// Nexus already owns. Both default to empty rather than NULL, because "he
// has not written one" and "there is nothing to write" are the same state
// here and no caller has to tell them apart.
`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
}
// migrate applies every migration with a number greater than the DB's current
+122 -6
View File
@@ -66,6 +66,16 @@ type Task struct {
Weight int
ResolvedTs *time.Time
ResolvedBy string
// DoneWhen — the acceptance criterion, in his words. It must be able to
// close on either outcome: "it already works" counts as complete, and a
// criterion only one result satisfies is a wish rather than a definition
// (Vikunja #510). Empty until he writes one.
DoneWhen string
// BlockedOn — a canonical Nexus entity id, never a name. Identity lives in
// Nexus, so storing "Саша" here would be a second answer to a question
// Nexus already owns. Empty when nothing blocks the task.
BlockedOn string
}
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
@@ -82,6 +92,22 @@ var (
ErrTaskNotFound = errors.New("store: task not found")
ErrTaskEmpty = errors.New("store: task text is empty")
ErrTaskStatus = errors.New("store: invalid task status")
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open without a
// definition of done (Vikunja #510). Same refusal ParseTaskCapture makes
// for a capture marker with nothing after it: confirming work whose
// finish line nobody wrote is how a board fills with rows that can never
// leave it. Dropping such a candidate stays legal.
ErrTaskNoDoneWhen = errors.New("store: task has no definition of done")
// ErrTaskDuplicate — an edit would give this task the normalised text of
// another live row (Vikunja #509). A refusal, not a merge: two live rows
// carry two provenances, two capture times and possibly two external
// identities, and merging picks a winner for all three silently. The
// surface tells the owner which row already holds the text and lets him
// drop one.
ErrTaskDuplicate = errors.New("store: another live task already has this text")
// ErrTaskResolved — a resolved task is not editable. Its text is the
// record of what was finished, and rewriting it rewrites history.
ErrTaskResolved = errors.New("store: task is resolved")
)
// liveTaskStatuses — the two statuses that count as outstanding work.
@@ -160,10 +186,10 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error)
// Untargeted DO NOTHING: either unique index may be the one that fires, and
// the lookup below sorts out which.
res, err := s.db.ExecContext(ctx,
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
VALUES (?,?,?,?,?,?,?,?,?)
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight, done_when, blocked_on)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT DO NOTHING`,
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight, t.DoneWhen, t.BlockedOn)
if err != nil {
return CaptureResult{}, fmt.Errorf("capture task: %w", err)
}
@@ -194,7 +220,7 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error)
}
}
if status == TaskOpen && existing.Status == TaskCandidate {
if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil {
if err := s.setTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source, false); err != nil {
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
}
return CaptureResult{ID: existing.ID, Promoted: true}, nil
@@ -231,7 +257,7 @@ func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, er
return t, nil
}
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by FROM tasks`
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by, done_when, blocked_on FROM tasks`
// LookupTask returns one task by id.
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
@@ -304,6 +330,16 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
// says what resolved it.
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return s.setTaskStatus(ctx, id, status, ts, by, true)
}
// setTaskStatus — the move, with the promotion gate optional.
//
// It is optional for exactly one caller: CaptureTask promoting a candidate he
// stated out loud (Vikunja #510). Refusing there would deny intake rather than
// ask for a criterion, and a direct open capture never had one either — the gate
// belongs to the deliberate promotion on /tasks, where there is a form to fill.
func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string, gateDoneWhen bool) error {
var from []string
switch status {
case TaskOpen:
@@ -329,6 +365,13 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts t
for _, f := range from {
args = append(args, f)
}
if status == TaskOpen && gateDoneWhen {
// Promotion needs an acceptance criterion. Checked in the same
// statement rather than read-then-write, so two callers confirming one
// candidate cannot race past it; the row is read afterwards only to say
// WHICH refusal this was.
q += ` AND done_when <> ''`
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("set task status: %w", err)
@@ -338,11 +381,84 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts t
return fmt.Errorf("set task status: rows affected: %w", err)
}
if n == 0 {
if status == TaskOpen && gateDoneWhen {
if t, lookErr := s.LookupTask(ctx, id); lookErr == nil && t.Status == TaskCandidate && t.DoneWhen == "" {
return fmt.Errorf("%w: id=%d", ErrTaskNoDoneWhen, id)
}
}
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
}
return nil
}
// EditTask rewrites the three fields capture set and nothing else: text, due
// date and weight (Vikunja #509). Status stays the one-way ladder SetTaskStatus
// owns, and a resolved task is refused outright — its text is the record of
// what was finished.
//
// Editing text re-normalises the dedupe key, which can collide with another
// live row. That is ErrTaskDuplicate and it is a refusal: merging would pick
// one row's provenance, capture time and external identity over the other's
// with nobody asked.
//
// due nil clears the date. Clearing has to be sayable, so an absent date and
// "remove the date" cannot be the same argument.
func (s *Store) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
text = strings.TrimSpace(text)
if text == "" {
return ErrTaskEmpty
}
cur, err := s.LookupTask(ctx, id)
if err != nil {
return err
}
if cur.Status != TaskCandidate && cur.Status != TaskOpen {
return fmt.Errorf("%w: id=%d is %s", ErrTaskResolved, id, cur.Status)
}
norm := NormalizeTaskText(text)
if norm != NormalizeTaskText(cur.Text) {
if other, err := s.lookupLiveTaskByNorm(ctx, norm); err == nil && other.ID != id {
return fmt.Errorf("%w: id=%d holds it", ErrTaskDuplicate, other.ID)
} else if err != nil && !errors.Is(err, ErrTaskNotFound) {
return err
}
}
var dueVal sql.NullInt64
if due != nil {
dueVal = sql.NullInt64{Int64: due.UnixMilli(), Valid: true}
}
if _, err := s.db.ExecContext(ctx,
`UPDATE tasks SET text = ?, norm = ?, due_ts = ?, weight = ? WHERE id = ?`,
text, norm, dueVal, weight, id); err != nil {
return fmt.Errorf("edit task: %w", err)
}
return nil
}
// SetTaskFields writes the two board columns. Separate from SetTaskStatus
// because a status move is one-way and these are not: he may sharpen a
// definition of done, and a blocker clears when the person answers.
//
// blockedOn is a canonical Nexus entity id or empty. Free text does not belong
// here — identity lives in Nexus, and a local name would be a second answer to
// a question Nexus already owns. The caller resolves before it writes.
func (s *Store) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE tasks SET done_when = ?, blocked_on = ? WHERE id = ?`,
strings.TrimSpace(doneWhen), strings.TrimSpace(blockedOn), id)
if err != nil {
return fmt.Errorf("set task fields: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set task fields: rows affected: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
}
return nil
}
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
// whitespace collapsed. Exported because the intake seam (and its tests) needs
// to reason about what will and will not be treated as the same task.
@@ -372,7 +488,7 @@ func scanTask(sc scanner) (Task, error) {
var t Task
var created int64
var due, resolved sql.NullInt64
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil {
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy, &t.DoneWhen, &t.BlockedOn); err != nil {
return Task{}, err
}
t.CreatedTs = time.UnixMilli(created).UTC()
+169 -1
View File
@@ -126,6 +126,11 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
}
// Promotion needs a definition of done; see
// TestPromotingACandidateNeedsADefinitionOfDone for that refusal.
if err := st.SetTaskFields(ctx, cand, "запись есть", ""); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
t.Fatal(err)
}
@@ -245,7 +250,10 @@ func TestCaptureTaskExternalIDSurvivesResolution(t *testing.T) {
if !first.Created {
t.Fatal("first capture must create a row")
}
// He confirms it and does it.
// He confirms it and does it. Confirming needs a criterion (Vikunja #510).
if err := st.SetTaskFields(ctx, first.ID, "страховка продлена", ""); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
@@ -374,3 +382,163 @@ func TestListTasksIsBounded(t *testing.T) {
t.Errorf("first = %q, want the newest", all[0].Text)
}
}
func TestTaskBoardColumnsRoundTrip(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{
Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now,
DoneWhen: "квитанция оплачена", BlockedOn: "ent_kate",
})
if err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.DoneWhen != "квитанция оплачена" || got.BlockedOn != "ent_kate" {
t.Fatalf("task = %+v, want both board columns back", got)
}
// Not one-way, unlike a status move: he sharpens the criterion, and the
// blocker clears when the person answers.
if err := st.SetTaskFields(ctx, res.ID, " пришло подтверждение ", ""); err != nil {
t.Fatal(err)
}
got, err = st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.DoneWhen != "пришло подтверждение" {
t.Errorf("done_when = %q, want the trimmed rewrite", got.DoneWhen)
}
if got.BlockedOn != "" {
t.Errorf("blocked_on = %q, want it cleared", got.BlockedOn)
}
if err := st.SetTaskFields(ctx, 9999, "x", ""); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("SetTaskFields on a missing row = %v, want ErrTaskNotFound", err)
}
}
func TestPromotingACandidateNeedsADefinitionOfDone(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{
Text: "продлить домен", Source: "email:main", Status: TaskCandidate,
ExternalID: "msg-1:0", Evidence: "Domain expiring", CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
// A board row whose finish line nobody wrote can never leave the board.
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); !errors.Is(err, ErrTaskNoDoneWhen) {
t.Fatalf("promotion with no criterion = %v, want ErrTaskNoDoneWhen", err)
}
// Dropping it stays legal — declining work does not need one.
dropped, err := st.CaptureTask(ctx, Task{
Text: "перезвонить в банк", Source: "email:main", Status: TaskCandidate,
ExternalID: "msg-2:0", CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, dropped.ID, TaskDropped, now, "tap:web"); err != nil {
t.Fatalf("dropping a candidate with no criterion: %v", err)
}
if err := st.SetTaskFields(ctx, res.ID, "домен продлён до 2027", ""); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); err != nil {
t.Fatalf("promotion after writing a criterion: %v", err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskOpen {
t.Errorf("status = %q, want open", got.Status)
}
}
func TestEditTaskRewritesTheCaptureFields(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
due := now.Add(48 * time.Hour)
res, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now, Weight: 1})
if err != nil {
t.Fatal(err)
}
if err := st.EditTask(ctx, res.ID, " купить молоко ", &due, 3); err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.Text != "купить молоко" || got.Weight != 3 || got.Due == nil || !got.Due.Equal(due) {
t.Fatalf("task = %+v, want the dictation typo fixed with the date and weight", got)
}
// Clearing the date has to be sayable, or an absent date and "remove the
// date" would be one argument.
if err := st.EditTask(ctx, res.ID, "купить молоко", nil, 3); err != nil {
t.Fatal(err)
}
if got, err = st.LookupTask(ctx, res.ID); err != nil {
t.Fatal(err)
} else if got.Due != nil {
t.Errorf("due = %v, want it cleared", got.Due)
}
// The dedupe key moved with the text: capturing the old wording is new work.
again, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if !again.Created {
t.Error("the old normalised text must be free after the edit")
}
if err := st.EditTask(ctx, res.ID, "", nil, 0); !errors.Is(err, ErrTaskEmpty) {
t.Errorf("empty text = %v, want ErrTaskEmpty", err)
}
}
func TestEditTaskRefusesACollisionAndAResolvedRow(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
second, err := st.CaptureTask(ctx, Task{Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
// Two live rows carry two provenances and two capture times, so a merge
// would pick a winner for both with nobody asked.
if err := st.EditTask(ctx, second.ID, "Купить молоко!", nil, 0); !errors.Is(err, ErrTaskDuplicate) {
t.Errorf("collision = %v, want ErrTaskDuplicate", err)
}
// Editing a row to the text it already has is not a collision with itself.
if err := st.EditTask(ctx, first.ID, "купить молоко", nil, 2); err != nil {
t.Errorf("re-saving the same text: %v", err)
}
if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
// A resolved task's text is the record of what was finished.
if err := st.EditTask(ctx, first.ID, "купить кефир", nil, 0); !errors.Is(err, ErrTaskResolved) {
t.Errorf("editing a resolved task = %v, want ErrTaskResolved", err)
}
if err := st.EditTask(ctx, 9999, "что-то", nil, 0); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("editing a missing row = %v, want ErrTaskNotFound", err)
}
}
+121
View File
@@ -0,0 +1,121 @@
package store
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
)
// WipeCount is one table and the number of rows a wipe would remove from it.
type WipeCount struct {
Table string
Rows int
}
// WipeCounts reports every table in the database and its row count, so a caller
// can show what a wipe is about to destroy before it destroys it. Tables with
// no rows are included: a QA reader needs to see that the table was considered,
// not guess whether it was missed.
func (s *Store) WipeCounts(ctx context.Context) ([]WipeCount, error) {
names, err := userTables(ctx, s.db)
if err != nil {
return nil, err
}
out := make([]WipeCount, 0, len(names))
for _, name := range names {
var n int
// name comes from sqlite_master, not from a caller, and is quoted.
q := fmt.Sprintf(`SELECT count(*) FROM %s`, quoteIdent(name))
if err := s.db.QueryRowContext(ctx, q).Scan(&n); err != nil {
return nil, fmt.Errorf("count %s: %w", name, err)
}
out = append(out, WipeCount{Table: name, Rows: n})
}
return out, nil
}
// Wipe removes every piece of personal data and leaves the install standing.
//
// It drops every object in the database and rebuilds the schema from
// schema.sql plus the migration list, rather than deleting from a hand-written
// list of tables. That is the whole point: a list has to be edited every time a
// table is added, and the one time it is not, a wipe leaves personal data
// behind while reporting success. Dropping what the database says exists cannot
// miss a table (Vikunja #494).
//
// Nothing outside the database is touched. Config, models, the WebAuthn
// credentials in passkeys.json and the encryption key all live in files, so
// this leaves a working install with no memory of anyone.
//
// The caller is responsible for the daemon being the only thing holding the
// store, and for asking whoever ran it whether they meant it.
func (s *Store) Wipe(ctx context.Context) error {
names, err := userTables(ctx, s.db)
if err != nil {
return err
}
// Foreign keys are on per connection. Dropping tables in an arbitrary order
// trips them, and the drop order does not matter once everything goes.
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("wipe: disable foreign keys: %w", err)
}
defer func() {
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
// The handle is about to be closed by the caller either way.
_ = err
}
}()
var drops strings.Builder
for _, name := range names {
fmt.Fprintf(&drops, "DROP TABLE IF EXISTS %s;\n", quoteIdent(name))
}
// user_version back to zero, or migrate() applies nothing to the fresh
// schema and every ALTER that a later migration assumes is missing.
drops.WriteString("PRAGMA user_version = 0;\n")
if _, err := s.db.ExecContext(ctx, drops.String()); err != nil {
return fmt.Errorf("wipe: drop tables: %w", err)
}
if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil {
return fmt.Errorf("wipe: reapply schema: %w", err)
}
if err := migrate(ctx, s.db); err != nil {
return fmt.Errorf("wipe: re-migrate: %w", err)
}
// The file keeps the pages the dropped rows used until it is vacuumed, and
// a wipe that leaves readable text in free pages is not a wipe.
if _, err := s.db.ExecContext(ctx, `VACUUM`); err != nil {
return fmt.Errorf("wipe: vacuum: %w", err)
}
return nil
}
// userTables lists every table the schema owns, sorted, excluding sqlite's own
// internal tables (sqlite_sequence and friends, which cannot be dropped).
func userTables(ctx context.Context, db *sql.DB) ([]string, error) {
rows, err := db.QueryContext(ctx,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
return nil, fmt.Errorf("list tables: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("list tables: %w", err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list tables: %w", err)
}
sort.Strings(names)
return names, nil
}
func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
+99
View File
@@ -0,0 +1,99 @@
package store
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
)
// A wipe has to leave a working, empty install: every table still there, every
// migration still applied, and not one row of his anywhere (Vikunja #494).
func TestWipeEmptiesEveryTableAndLeavesTheSchemaUsable(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "wipe.db")
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
now := time.Now()
if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "новый", "tap:test", 1, sql.NullInt64{}); err != nil {
t.Fatal(err)
}
if _, err := s.WriteNote(ctx, now, "купил монитор", []float32{0.1, 0.2}, "tap:test"); err != nil {
t.Fatal(err)
}
if _, err := s.CaptureTask(ctx, Task{Text: "вернуть монитор", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil {
t.Fatal(err)
}
before, err := s.WipeCounts(ctx)
if err != nil {
t.Fatal(err)
}
populated := 0
for _, c := range before {
if c.Rows > 0 {
populated++
}
}
if populated == 0 {
t.Fatal("nothing was written, so the wipe proves nothing")
}
if err := s.Wipe(ctx); err != nil {
t.Fatal(err)
}
after, err := s.WipeCounts(ctx)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before) {
t.Errorf("table count changed across the wipe: %d before, %d after", len(before), len(after))
}
for _, c := range after {
if c.Rows != 0 {
t.Errorf("table %s still holds %d rows after the wipe", c.Table, c.Rows)
}
}
// The migrations are what make the schema usable, so the check that matters
// is a write through the newest columns, not a version number.
if _, err := s.CaptureTask(ctx, Task{Text: "новая задача", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil {
t.Errorf("the store is not usable after a wipe: %v", err)
}
if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "другой", "tap:test", 1, sql.NullInt64{}); err != nil {
t.Errorf("the store is not usable after a wipe: %v", err)
}
}
// A wipe that reports success while leaving a table behind is the failure the
// drop-everything approach exists to prevent, so the count has to see the
// tables migrations added, not only the ones schema.sql declares.
func TestWipeCountsSeeMigratedTables(t *testing.T) {
ctx := context.Background()
s, err := Open(ctx, filepath.Join(t.TempDir(), "counts.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
counts, err := s.WipeCounts(ctx)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, c := range counts {
seen[c.Table] = true
}
// One from schema.sql, three from migrations, one from a table rebuild.
for _, want := range []string{"facts", "memory_vectors", "tasks", "ecosystem_traces", "nudges"} {
if !seen[want] {
t.Errorf("wipe does not see table %s", want)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package tasks
import (
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Counted stall shapes (Vikunja #512, step 5 of docs/plans/15-board-surface.md).
//
// She may count. She may not assess. Every shape here is arithmetic over rows he
// can see — how many, how long, how many undated — and none of it says whether a
// task matters, whether a blocker is real, or whether something should be
// dropped. That is the line internal/memory/behavior.go already drew for habits
// and the reason is the same: a 1.7B asked to judge will agree fluently and
// launder a guess into a decision.
//
// Not a nag either. Nothing here is read by the tick loop; the counts go on
// /tasks and into the answer when he asks for the list. tickLoop.dayPlan
// deliberately does not read tasks — keep it that way.
// StallDays — how long a live task has to have sat before it is counted as
// sitting. Ten days rather than a week, because a task captured on a Friday and
// still open the next Friday is an ordinary week, not a stall.
//
// Measured from Created, which is the only clock a live row carries: the store
// stamps resolved_ts and nothing else, so "no state change in eleven days" is
// exactly "captured eleven days ago and still live". That is a narrower claim
// than the plan's wording and it is the one the data supports.
const StallDays = 10
// Stall — one counted shape: how many tasks, and the sentence that says what
// they have in common. N is always ≥ 1; a shape with no tasks in it is not
// returned, because "нет просроченных" is a reassurance nobody asked for.
type Stall struct {
N int
Line string
}
// Stalls counts the shapes present in a set of live tasks, in a fixed order:
// overdue first, then sitting, then unconfirmed. Fixed because the order is what
// he reads first, and sorting by count would move the sections around every time
// one number changed.
//
// Resolved tasks are not passed in and would not be counted if they were: this
// is a statement about outstanding work.
func Stalls(items []Item, now time.Time) []Stall {
var overdue, sitting, unconfirmed int
for _, it := range items {
if it.Status == StatusCandidate {
unconfirmed++
// A candidate is Maven's reading of something she read. Counting it as
// overdue would put her own guess about a deadline in a number he is
// meant to act on.
continue
}
if it.Due != nil && it.Due.Before(now) {
overdue++
}
if now.Sub(it.Created) >= StallDays*24*time.Hour {
sitting++
}
}
var out []Stall
add := func(n int, key string, args map[string]string) {
if n == 0 {
return
}
out = append(out, Stall{N: n, Line: say.S(key, args)})
}
add(overdue, say.StallOverdue, map[string]string{
"n": fmt.Sprint(overdue), "word": say.CountWord(overdue, "задача", "задачи", "задач"),
})
add(sitting, say.StallSitting, map[string]string{
"n": fmt.Sprint(sitting), "word": say.CountWord(sitting, "задача", "задачи", "задач"),
"days": fmt.Sprint(StallDays), "dayword": say.Days(StallDays),
})
add(unconfirmed, say.StallUnconfirmed, map[string]string{
"n": fmt.Sprint(unconfirmed), "word": say.CountWord(unconfirmed, "задача", "задачи", "задач"),
})
return out
}
// StallsRU joins the shapes into one sentence for the spoken list, or "" when
// there are none. Empty on purpose: the list read is the answer to his question,
// and appending "ничего не залежалось" to it every time is a nag with a friendly
// face.
func StallsRU(stalls []Stall) string {
if len(stalls) == 0 {
return ""
}
parts := make([]string, 0, len(stalls))
for _, s := range stalls {
parts = append(parts, s.Line)
}
return strings.Join(parts, "; ") + "."
}
+68
View File
@@ -0,0 +1,68 @@
package tasks
import (
"strings"
"testing"
"time"
)
func stallNow() time.Time { return time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) }
func TestStallsCountsTheThreeShapes(t *testing.T) {
now := stallNow()
day := 24 * time.Hour
yesterday := now.Add(-day)
items := []Item{
// Overdue and sitting at once: it counts in both, because they are two
// different things wrong with one task.
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-20 * day), Due: &yesterday},
{ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-12 * day)},
{ID: 3, Text: "позвонить маме", Status: StatusOpen, Created: now.Add(-time.Hour)},
{ID: 4, Text: "продлить домен", Status: StatusCandidate, Created: now.Add(-30 * day), Due: &yesterday},
}
got := Stalls(items, now)
if len(got) != 3 {
t.Fatalf("shapes = %+v, want overdue, sitting, unconfirmed", got)
}
if got[0].N != 1 {
t.Errorf("overdue = %d, want 1 — a candidate's due date is Maven's reading of a mail", got[0].N)
}
if got[1].N != 2 {
t.Errorf("sitting = %d, want 2", got[1].N)
}
if got[2].N != 1 {
t.Errorf("unconfirmed = %d, want 1", got[2].N)
}
line := StallsRU(got)
for _, want := range []string{"просрочено", "лежит", "подтверждения"} {
if !strings.Contains(line, want) {
t.Errorf("StallsRU = %q, want it to mention %q", line, want)
}
}
}
func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) {
now := stallNow()
items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-time.Hour)}}
if got := Stalls(items, now); len(got) != 0 {
t.Fatalf("shapes = %+v, want none", got)
}
// "ничего не залежалось" appended to every list read is a nag with a
// friendly face, so the empty case renders as nothing at all.
if got := StallsRU(nil); got != "" {
t.Errorf("StallsRU(nil) = %q, want empty", got)
}
}
func TestStallsCountsNoJudgement(t *testing.T) {
// The line this shape may not cross. Every sentence states a count; none of
// them says whether the work matters or should be dropped.
now := stallNow()
items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-40 * 24 * time.Hour)}}
line := StallsRU(Stalls(items, now))
for _, banned := range []string{"стоит", "лучше", "надо", "брось", "важно"} {
if strings.Contains(line, banned) {
t.Errorf("StallsRU = %q — %q is an assessment, and counting is the whole licence here", line, banned)
}
}
}