Compare commits

...

12 Commits

Author SHA1 Message Date
claude e082e06868 Merge pull request 'Bug: morning.Item has no required/optional flag, so behaviour 1 of task 280 cannot hold' (#125) from task/473-bug-morning-item-has-no-required-flag into master 2026-08-04 18:22:45 +02:00
claude 88dc4e1383 Merge pull request 'Bug: make simulate routes with an empty seed set, so a green run proves less than it looks' (#124) from task/465-bug-make-simulate-routes-with-an-empty into master 2026-08-04 18:22:37 +02:00
claude 60759a991e Merge pull request 'Bug: spoken task capture is dead — the router calls the marker an act, and capture only rides the note intent' (#123) from task/467-bug-spoken-task-capture-is-dead into master 2026-08-04 18:22:28 +02:00
claude 9537346441 Merge pull request 'Bug: a pending clarify is global, so one unanswerable question swallows the next three utterances from anybody' (#122) from task/466-bug-a-pending-clarify-is-global-so-one-u into master 2026-08-04 18:21:49 +02:00
claude c6be818f13 Merge pull request 'Bug: pattern.Detect has no minimum-interval floor, so four fast taps mint a permanent false routine' (#121) from task/468-bug-pattern-detect-has-no-minimum-interv into master 2026-08-04 18:21:38 +02:00
claude b5575a9402 Merge pull request 'Bug: CheckFeminine flags second-person masculine verbs as self-reference' (#120) from task/462-bug-checkfeminine-flags-second-person-ma into master 2026-08-04 18:21:27 +02:00
claude fcda5e3d2c Merge pull request 'safeKey drops Cyrillic, so Russian calendar events on one day collide' (#119) from task/443-safekey-drops-cyrillic-so-russian-calend into master 2026-08-04 18:21:15 +02:00
claude bdafc82e35 Merge pull request 'llama-server core-dumps on every SIGTERM, so each mavgpud yield writes a core file' (#116) from task/491-llama-server-core-dumps-on-every-sigterm into master 2026-08-04 14:09:25 +02:00
claude c69023c310 Merge PR #117 into task/491 (V-383) 2026-08-04 14:08:59 +02:00
claude 2815adee03 morning: an item can be optional, so a skipped stretch is not a skipped pill (V-473)
Item carried only Key, FactKey and Label, so every checklist entry was
implicitly required and behaviour 1 of #280 could not hold at all. It was not
thin config — there was no field to set.

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

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

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

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

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

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

ru-note-006 is the fixture case. The classifier alone cannot pass it, and the
hash baseline drops by that one case; the daemon answers it at stage 0.
2026-08-04 03:12:47 +04:00
12 changed files with 323 additions and 14 deletions
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"strings"
"testing"
"github.com/kami/maven/internal/morning"
)
// TestMorningNudgeBodySeparatesOptional — the one message a routine is allowed
// per day says what was not done, then what he could still do (Vikunja #473).
func TestMorningNudgeBodySeparatesOptional(t *testing.T) {
cand := morning.Candidate{
Routine: morning.Routine{Name: "утро"},
Missing: []morning.Item{
{Key: "meds", Label: "таблетки"},
{Key: "stretch", Label: "растяжка", Optional: true},
},
}
body := morningNudgeBody(cand)
if !strings.Contains(body, "не сделано — таблетки") {
t.Fatalf("the required item must be named as not done: %q", body)
}
if !strings.Contains(body, "если будет время — растяжка") {
t.Fatalf("the optional item must read softer: %q", body)
}
if strings.Contains(body, "не сделано — таблетки, растяжка") {
t.Fatalf("optional must not be folded into the required list: %q", body)
}
// Nothing optional missing: the sentence is what it always was.
only := morning.Candidate{
Routine: morning.Routine{Name: "утро"},
Missing: []morning.Item{{Key: "meds", Label: "таблетки"}},
}
if got, want := morningNudgeBody(only), "утро: не сделано — таблетки"; got != want {
t.Fatalf("morningNudgeBody = %q, want %q", got, want)
}
}
+37
View File
@@ -2,12 +2,14 @@ package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
@@ -85,3 +87,38 @@ func TestReactiveNotesReminders(t *testing.T) {
}
})
}
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
// task table. It went dead when the router started claiming the marker as an
// act: capture rides the note intent, so nothing below actionNote was ever
// reached and every capture answered "Что сделать?" (Vikunja #467).
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(api)
h := &reactiveHandler{
api: api,
embedder: emb,
router: buildRouter(emb, matcher, 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
if !strings.Contains(reply, "купить молоко") {
t.Fatalf("capture did not claim the turn: %q", reply)
}
open, err := st.ListTasks(ctx, store.TaskOpen)
if err != nil || len(open) != 1 {
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
}
// The words he said, not the model's rewrite of them.
if open[0].Text != "купить молоко" {
t.Fatalf("task text was rewritten: %q", open[0].Text)
}
}
+23
View File
@@ -1043,3 +1043,26 @@ func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
}
}
// TestSimulatorRoutesWithTheDeployedSeeds — the scenarios must replay against
// the classifier the deploy runs, not an empty one.
//
// They did not. The seed path was relative to the working directory, which is
// cmd/mavend under `go test`, so every file failed to open and the whole
// simulator scored three green scenarios with zero examples loaded (Vikunja
// #465). The count is asserted rather than logged, because a silent zero is
// exactly the failure that hid here for as long as it did.
func TestSimulatorRoutesWithTheDeployedSeeds(t *testing.T) {
cls := router.NewClassifier(router.NewHashEmbedder(1024))
seedClassifier(cls)
total := 0
for _, intent := range cls.Intents() {
total += len(cls.Examples(intent))
}
if total == 0 {
t.Fatalf("no seed examples loaded from %s — the simulator would route on nothing", seedPath())
}
if len(cls.Intents()) != 7 {
t.Fatalf("seeded %d intents, want all 7", len(cls.Intents()))
}
}
+21 -5
View File
@@ -761,11 +761,7 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
facts := t.gatherMorningFacts(ctx)
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
labels := make([]string, len(cand.Missing))
for i, it := range cand.Missing {
labels[i] = it.Label
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
body := morningNudgeBody(cand)
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
@@ -781,6 +777,26 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
}
}
// morningNudgeBody words the one message a routine gets per day. Required
// items are what she says was not done; optional ones follow, worded as
// something he could still do rather than something he owes (Vikunja #473).
// Operator text, not phrased by the model, for the same reason it always was:
// a checklist item must not be invented.
func morningNudgeBody(cand morning.Candidate) string {
labels := func(items []morning.Item) string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Label
}
return strings.Join(out, ", ")
}
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
body += fmt.Sprintf(". если будет время — %s", labels(opt))
}
return body
}
// gatherMorningFacts reads the latest fact for every item's fact_key across
// all configured morning routines. Shared by fireMorningRoutines (nudge
// decision) and morningStatus (read-only query) so the two paths can never
+33 -5
View File
@@ -380,6 +380,11 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
// 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.
grammars = append(grammars, router.TaskCaptureGrammar())
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
@@ -393,11 +398,34 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
})
}
// seedDir is the directory containing intent seed files. Each file is named
// <intent>.txt and contains one training example per line (blank lines and
// lines starting with # are ignored). Relative to the working directory.
// seedDir is the directory containing intent seed files, relative to the repo
// root. Each file is named <intent>.txt and holds one training example per
// line (blank lines and lines starting with # are ignored).
const seedDir = "models/seeds"
// seedPath resolves seedDir against the working directory, walking up until it
// finds it. The daemon runs from the repo root and the first candidate hits.
//
// A test does not: `go test ./cmd/mavend/` runs with the working directory at
// cmd/mavend, so every open failed and the simulator scenarios replayed a whole
// scripted day against a classifier holding zero examples (Vikunja #465). They
// passed, which is the part that matters — a green simulator was not exercising
// the routing the deploy runs, and a regression in the seed set could not have
// shown up there.
//
// Bounded at five levels, so a daemon started somewhere without the seeds logs
// the same failure it always did rather than walking to the filesystem root.
func seedPath() string {
dir := seedDir
for i := 0; i < 5; i++ {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir
}
dir = filepath.Join("..", dir)
}
return seedDir
}
// seedClassifier floors the embedded examples so the cold-boot path
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
@@ -422,11 +450,11 @@ func seedClassifier(c *router.Classifier) {
}
total += n
}
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
log.Printf("voice: loaded %d seed examples from %s", total, seedPath())
}
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
path := filepath.Join(seedDir, string(intent)+".txt")
path := filepath.Join(seedPath(), string(intent)+".txt")
f, err := os.Open(path)
if err != nil {
return 0, fmt.Errorf("open %s: %w", path, err)
+4 -1
View File
@@ -601,6 +601,9 @@ type MorningRoutineItemConfig struct {
Key string `json:"key"`
FactKey string `json:"fact_key"`
Label string `json:"label"`
// Optional — this one being skipped does not earn a nudge. Default false,
// so a routine written before 04-08-2026 keeps behaving as it did.
Optional bool `json:"optional,omitempty"`
}
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
@@ -1734,7 +1737,7 @@ func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
for i, r := range mc {
items := make([]morning.Item, len(r.Items))
for j, it := range r.Items {
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
}
weekdays := make([]time.Weekday, len(r.Weekdays))
for j, w := range r.Weekdays {
+43 -2
View File
@@ -30,6 +30,18 @@ type Item struct {
Key string
FactKey string
Label string // RU text surfaced when this item is still missing.
// Optional — a missing one is not worth a nudge on its own.
//
// Every item was implicitly required until 04-08-2026, because there was
// no field, so a skipped stretch read exactly like skipped medication and
// #280's first behaviour could not hold (Vikunja #473). A checklist where
// everything is mandatory is a checklist he learns to ignore.
//
// It changes two things and nothing else: an all-optional routine never
// nudges, and a nudge that does fire names the optional stragglers after
// the required ones, in softer words. Evidence, the window and the day
// plan treat both kinds alike — a missing optional item is still missing.
Optional bool
}
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
@@ -60,12 +72,37 @@ type Status struct {
}
// Candidate — a routine that's due for its one-per-day nag: the window has
// reached NudgeAt and at least one item is still unevidenced.
// reached NudgeAt and at least one REQUIRED item is still unevidenced. Missing
// carries the optional stragglers too, so the one message she is allowed per
// day per routine can mention them; they never cause it.
type Candidate struct {
Routine Routine
Missing []Item
}
// Required reports the missing items that are not optional. The nudge fires on
// these; the rest ride along.
func Required(missing []Item) []Item {
var out []Item
for _, it := range missing {
if !it.Optional {
out = append(out, it)
}
}
return out
}
// OptionalOnly is the other half of Required.
func OptionalOnly(missing []Item) []Item {
var out []Item
for _, it := range missing {
if it.Optional {
out = append(out, it)
}
}
return out
}
// Validate reports the first structural problem with a routine set: missing
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
// within a routine, or an out-of-range weekday. Called at config load so a
@@ -191,7 +228,11 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
missing = append(missing, it)
}
}
if len(missing) == 0 {
// A day where only the optional items were skipped is a fine day, and
// nagging about it is what teaches him to stop listening (Vikunja
// #473). The optional ones still travel in Missing so the message can
// mention them when it is being sent anyway.
if len(Required(missing)) == 0 {
continue
}
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
+37
View File
@@ -182,3 +182,40 @@ func TestDueRespectsExplicitNudgeAt(t *testing.T) {
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
}
}
// TestOptionalItemsDoNotEarnANudge — behaviour 1 of #280, which could not hold
// while every item was implicitly required (Vikunja #473).
func TestOptionalItemsDoNotEarnANudge(t *testing.T) {
r := Routine{
Name: "утро",
WindowStart: "07:00",
WindowEnd: "10:00",
Items: []Item{
{Key: "meds", FactKey: "meds", Label: "таблетки"},
{Key: "stretch", FactKey: "stretch", Label: "растяжка", Optional: true},
},
}
now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
took := map[string]store.Fact{"meds": {Key: "meds", Ts: now.Add(-2 * time.Hour)}}
// Only the stretch was skipped: nothing to say.
if due := Due([]Routine{r}, took, map[string]time.Time{}, now); len(due) != 0 {
t.Fatalf("an optional item alone must not nudge, got %+v", due)
}
// The medication was skipped: she says so, and mentions the stretch too.
due := Due([]Routine{r}, map[string]store.Fact{}, map[string]time.Time{}, now)
if len(due) != 1 {
t.Fatalf("a missing required item must nudge, got %+v", due)
}
if got := Required(due[0].Missing); len(got) != 1 || got[0].Key != "meds" {
t.Fatalf("Required = %+v, want the meds item alone", got)
}
if got := OptionalOnly(due[0].Missing); len(got) != 1 || got[0].Key != "stretch" {
t.Fatalf("OptionalOnly = %+v, want the stretch item alone", got)
}
// The window still reports it as missing — optional is not invisible.
st := Evaluate(r, map[string]store.Fact{}, now.Add(-time.Hour))
if len(st.Missing) != 2 {
t.Fatalf("Evaluate must still list both, got %+v", st.Missing)
}
}
+1
View File
@@ -71,6 +71,7 @@
{ "id": "ru-note-003", "utterance": "заметка про настройку vlan на свитче", "lang": "ru", "intent": "note", "tags": ["homelab"] },
{ "id": "ru-note-004", "utterance": "запиши идею: гидропоника на балконе", "lang": "ru", "intent": "note" },
{ "id": "ru-note-005", "utterance": "запиши что сосед просил номер электрика", "lang": "ru", "intent": "note" },
{ "id": "ru-note-006", "utterance": "добавь в задачи купить молоко", "lang": "ru", "intent": "note", "tags": ["capture"], "note": "an explicit capture marker — the model called it an act and rewrote the payload (Vikunja #467), stage 0 claims it" },
{ "id": "en-note-001", "utterance": "note: rotate the kuma api key", "lang": "en", "intent": "note", "tags": ["homelab"] },
{ "id": "ru-sys-001", "utterance": "сколько сейчас времени в киеве", "lang": "ru", "intent": "system", "tags": ["time"] },
+48 -1
View File
@@ -1,6 +1,9 @@
package router
import "strings"
import (
"regexp"
"strings"
)
// Task capture and task listing, matched deterministically (Vikunja #130).
//
@@ -201,3 +204,47 @@ func IsTaskListQuery(text string) bool {
}
return false
}
// TaskCaptureGrammar — stage 0 for an explicit capture marker, so the resident
// model never sees it (Vikunja #467).
//
// Capture was built to ride the note intent, deliberately: #130 said no eighth
// intent, and while the classifier was routing, a note-shaped utterance with a
// marker in it reached actionNote and captureTaskFromNote claimed it there. The
// router pre-empted that. Measured 2026-08-02: "добавь в задачи купить молоко"
// routed act, so captureTaskFromNote was never consulted, the act arm found no
// allowlisted fn, and the gate asked "Что сделать?". Every capture utterance
// tried filed nothing.
//
// The model also rewrote the payload on the way — "купить молоко" came back as
// "сделать покупку молока". A task must read as the words he said, which is a
// second reason to answer this before the model rather than to prompt around
// it.
//
// The marker list is data (task_phrases.json) and the parse strips urgency, so
// the pattern here matches any utterance and the decision is ParseTaskCapture's
// to make — same shape as the wake-word act grammar, which also matches broadly
// and refuses in Build. Intent stays note: the daemon's note path is where
// capture lives, and nothing about the contract with the model changes.
func TaskCaptureGrammar() Grammar {
return Grammar{
Name: "task-capture",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
c, ok := ParseTaskCapture(m[1])
if !ok {
return Decision{}, false // not a capture — fall through
}
return Decision{
Stage: 0,
Intent: IntentNote,
Confidence: 1.0,
// The capture text, not the raw utterance: it is what the
// clarify gate reads as the payload. captureTaskFromNote
// re-parses the utterance itself, so the task text comes from
// the same place either way.
Slots: Slots{Text: c.Text},
}, true
},
}
}
+3
View File
@@ -27,6 +27,9 @@
"добавь в список",
"добавь задачу",
"запиши в задачи",
"запиши в список дел",
"запиши в список задач",
"запиши в список",
"запиши задачу",
"новая задача",
"поставь задачу",
+34
View File
@@ -85,3 +85,37 @@ func TestIsTaskListQuery(t *testing.T) {
}
}
}
// TestTaskCaptureGrammarClaimsTheMarker — the capture marker is answered at
// stage 0, so the model never gets to call it an act (Vikunja #467).
func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
g := TaskCaptureGrammar()
captures := map[string]string{
"добавь в задачи купить молоко": "купить молоко",
"запиши в список дел купить хлеб": "купить хлеб",
"поставь задачу вынести мусор": "вынести мусор",
"добавь в задачи срочно оплатить дом": "оплатить дом",
}
for in, want := range captures {
m := g.Pattern.FindStringSubmatch(in)
if m == nil {
t.Fatalf("%q did not match the grammar pattern", in)
}
d, ok := g.Build(m)
if !ok {
t.Fatalf("%q must be claimed as a capture", in)
}
if d.Intent != IntentNote || d.Slots.Text != want {
t.Errorf("%q → intent=%s text=%q, want note/%q", in, d.Intent, d.Slots.Text, want)
}
}
// Everything without a marker falls through, including a marker with no
// task after it and a question about the list.
for _, in := range []string{"надо бы поспать", "добавь в задачи", "какие у меня задачи?", "перезапусти nginx"} {
if m := g.Pattern.FindStringSubmatch(in); m != nil {
if _, ok := g.Build(m); ok {
t.Errorf("%q must fall through to the cascade", in)
}
}
}
}