From 8e7aa0d45116ed7b1517b18354d58afbe27fdea2 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:42:44 +0400 Subject: [PATCH 1/6] nudges: a resolved outcome and a way to close a pending alarm (V-535) The sev4 repeat path reads the nudges table, so ending an alarm means writing an ending there. 'resolved' is the daemon closing it because the condition cleared, which is neither 'acted' nor 'ignored'. ResolvePendingTelegram is rule-scoped and accepts only the two endings the daemon may write. OldestPendingTelegram backs the age cap and scans into a NullInt64, because MIN over an empty set is one NULL row, not zero rows. Co-Authored-By: Claude Opus 5 --- internal/store/migrations.go | 21 ++++++++++ internal/store/nudges.go | 74 +++++++++++++++++++++++++++++++++++- internal/store/schema.sql | 4 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 12081d9..642075d 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -269,6 +269,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 UPDATE proposed_routines SET accepted_ts = created_ts, reminder_id = NULL WHERE status = 'accepted' AND accepted_ts IS NULL;`, + + // #21 — allow 'resolved' as a nudge outcome (Vikunja #535). The sev4 repeat + // path needs an ending that means "the condition cleared, so I stopped + // talking", which is neither 'acted' (he answered) nor 'ignored' (nobody + // ever did). A CHECK cannot be altered in place, so the table is rebuilt. + // Rows carry over unchanged; only the constraint widens. + `CREATE TABLE nudges_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + rule TEXT NOT NULL, + channel TEXT NOT NULL, + message TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')), + outcome_ts INTEGER + ); + INSERT INTO nudges_new (id, ts, rule, channel, message, outcome, outcome_ts) + SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges; + DROP TABLE nudges; + 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);`, } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/nudges.go b/internal/store/nudges.go index a295050..12604c1 100644 --- a/internal/store/nudges.go +++ b/internal/store/nudges.go @@ -26,6 +26,18 @@ const ( NudgeActed = "acted" NudgeSnoozed = "snoozed" NudgeIgnored = "ignored" + + // NudgeResolved — the thing it was about stopped being true, and nobody + // answered. Distinct from acted, which means he did something, and from + // ignored, which means he chose not to (Vikunja #535). + // + // It exists because a repeating alarm needs an ending that is not a lie. + // Marking a cleared service_down "acted" would credit him with a response + // he never made and would teach the cooldown tuner to nudge harder; leaving + // it pending is what made the alarm ring for two hours after the service + // came back. This outcome is written by the daemon, never by a person, and + // it is deliberately invisible to the feedback loop — see RecentOutcomes. + NudgeResolved = "resolved" ) // SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet. @@ -103,9 +115,14 @@ func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts t // for a given rule — the feedback loop's only input. used to compute // ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N. func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { + // 'resolved' is excluded alongside 'pending' (Vikunja #535). The tuner reads + // this as "how often does he answer me", and a resolution the daemon wrote + // is not him answering. Counting it would dilute both rates toward zero and + // make a service that fixes itself look like a rule he neither acts on nor + // ignores, which is a fact about the world and not feedback about her. rows, err := s.db.QueryContext(ctx, `SELECT outcome FROM nudges - WHERE rule = ? AND outcome != 'pending' + WHERE rule = ? AND outcome NOT IN ('pending', 'resolved') ORDER BY ts DESC, id DESC LIMIT ?`, rule, n) if err != nil { return nil, fmt.Errorf("recent outcomes: %w", err) @@ -152,6 +169,61 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { return out, rows.Err() } +// ResolvePendingTelegram closes every still-pending telegram nudge for a rule +// and reports how many it closed. This is what stops a repeating alarm +// (Vikunja #535). +// +// Rule-scoped and not id-scoped, deliberately: the repeat key IS the rule name, +// so a rule with several pending rows repeats once per tick for all of them and +// must go quiet for all of them at once. Closing one id would leave the alarm +// ringing on the others. +// +// The outcome is a parameter rather than hardcoded, because "the condition +// cleared" and "nobody could ever answer this" are different endings and +// /notifications should not show them as the same one. +func (s *Store) ResolvePendingTelegram(ctx context.Context, rule, outcome string, ts time.Time) (int64, error) { + switch outcome { + case NudgeResolved, NudgeIgnored: + default: + return 0, fmt.Errorf("store: %q is not an ending the daemon may write", outcome) + } + res, err := s.db.ExecContext(ctx, + `UPDATE nudges SET outcome = ?, outcome_ts = ? + WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`, + outcome, ts.UnixMilli(), rule) + if err != nil { + return 0, fmt.Errorf("resolve pending telegram %s: %w", rule, err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("resolve pending telegram %s: rows affected: %w", rule, err) + } + return n, nil +} + +// OldestPendingTelegram returns when the oldest still-pending telegram nudge +// for a rule was sent. Used for the repeat age cap: an alarm nobody has +// answered in hours is not one more repeat away from being answered. +// +// Oldest and not newest, because the age that matters is how long the alarm has +// been ringing, not when it last rang — the repeat itself does not create new +// rows, but a re-fire of the rule does, and the alarm is the whole run. +func (s *Store) OldestPendingTelegram(ctx context.Context, rule string) (time.Time, error) { + // MIN over an empty set is one row holding NULL, not zero rows, so this + // scans into a NullInt64 and never sees sql.ErrNoRows. + var tsMilli sql.NullInt64 + err := s.db.QueryRowContext(ctx, + `SELECT MIN(ts) FROM nudges + WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`, rule).Scan(&tsMilli) + if err != nil { + return time.Time{}, fmt.Errorf("oldest pending telegram %s: %w", rule, err) + } + if !tsMilli.Valid { + return time.Time{}, ErrNudgeNotFound + } + return time.UnixMilli(tsMilli.Int64).UTC(), nil +} + // SnoozedUntil — per rule, when its most recent snooze runs out. This is the // read behind the gate's snooze check: the `snoozed` outcome already in the // nudges table IS the restraint memory, so there is no snooze table. diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 388ca65..cc5a5e9 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -43,7 +43,9 @@ CREATE TABLE IF NOT EXISTS nudges ( rule TEXT NOT NULL, channel TEXT NOT NULL, message TEXT NOT NULL, - outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored')), + -- 'resolved' is the daemon closing an alarm because the condition cleared, + -- as opposed to 'acted' (he answered) or 'ignored' (nobody ever did). + outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')), outcome_ts INTEGER ); CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC); From 8a13d189bb35ca5e969fd8c722b0fd5cf10349d8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:42:44 +0400 Subject: [PATCH 2/6] a sev4 alarm stops when the service is back, or after two hours (V-535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It repeated every five minutes for over two hours. WasAcked was the only stop condition and nothing reachable from telegram can mark a nudge acked — the only ack is a voice 'готово' on a box that runs no voice loop. Two endings now. The condition cleared, which the rule answers through the new Rule.StillTrue — deliberately not Predicate, which is edge-triggered and reads false one tick after the alarm is raised, so building the stop on it would cancel every alarm immediately. Or the alarm got old, which is the bound that needs no cooperation from the rule. A rule with no StillTrue is never read as resolved and stops only on age. Covered by tests including the flap case, since none of it can be reproduced by hand without waiting hours. Co-Authored-By: Claude Opus 5 --- cmd/mavend/alarm_stop_test.go | 186 ++++++++++++++++++++++++++++++++++ cmd/mavend/tick.go | 79 +++++++++++++++ internal/loop/rules.go | 20 ++++ 3 files changed, 285 insertions(+) create mode 100644 cmd/mavend/alarm_stop_test.go diff --git a/cmd/mavend/alarm_stop_test.go b/cmd/mavend/alarm_stop_test.go new file mode 100644 index 0000000..81d9242 --- /dev/null +++ b/cmd/mavend/alarm_stop_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/store" +) + +// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every +// pending telegram nudge every repeat_interval, and nothing in the tree could +// ever mark one acked. None of what follows can be reproduced by hand without +// sitting in front of the box for hours, so it is covered here or nowhere. + +// seedDown writes one kuma monitor fact at ts. value is "down" or "up". +func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) { + t.Helper() + if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil { + t.Fatalf("seed service_down:db=%s: %v", value, err) + } +} + +// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which +// the shared helper leaves nil. Without it RepeatUnacked returns early and the +// repeat these tests are about never happens. The daemon wires it (main.go). +func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop { + t.Helper() + rules := loop.DefaultRules() + g := loop.NewGatherer(st, rules) + d := delivery.NewDispatcher(delivery.Config{ + Voice: sink, Ntfy: sink, Telegram: sink, + Ack: st, Nudges: st, Reminders: st, + }) + return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil) +} + +// telegramSends counts sends that went out on the telegram reach. +func telegramSends(sink *fakeSink, rule string) int { + n := 0 + for _, s := range sink.sends { + if s.RuleName == rule { + n++ + } + } + return n +} + +// outcomes returns the outcome of every nudge row for a rule, newest first. +func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string { + t.Helper() + rows, err := st.RecentNudges(ctx, 50) + if err != nil { + t.Fatalf("recent nudges: %v", err) + } + var out []string + for _, n := range rows { + if n.Rule == rule { + out = append(out, n.Outcome) + } + } + return out +} + +func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) { + // The condition clearing is the ending that should happen. StillTrue reads + // the same DownServices helper the phraser reads, so the repeat stops on + // exactly the monitor he was told about. + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedDown(t, st, ctx, "down", now) + + sink := &fakeSink{} + tl := newAlarmTickLoop(t, st, sink) + tl.tick(ctx, now) + if telegramSends(sink, "service_down") == 0 { + t.Fatal("the alarm never went out; the rest of this test proves nothing") + } + + seedDown(t, st, ctx, "up", now.Add(time.Minute)) + sink.sends = nil + tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval + + if n := telegramSends(sink, "service_down"); n != 0 { + t.Fatalf("repeated %d time(s) after the service came back up; want 0", n) + } + for _, o := range outcomes(t, st, ctx, "service_down") { + if o != store.NudgeResolved { + t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved) + } + } +} + +func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) { + // Still down, still un-acked, and nobody has answered in two hours. That is + // not one more repeat away from being answered. + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedDown(t, st, ctx, "down", now) + + sink := &fakeSink{} + tl := newAlarmTickLoop(t, st, sink) + tl.tick(ctx, now) + + sink.sends = nil + tl.tick(ctx, now.Add(6*time.Minute)) + if n := telegramSends(sink, "service_down"); n == 0 { + t.Fatal("no repeat inside the cap; the cap is not what stopped it later") + } + + sink.sends = nil + tl.tick(ctx, now.Add(maxAlarmAge+time.Minute)) + if n := telegramSends(sink, "service_down"); n != 0 { + t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge) + } + // Ignored, not resolved: nothing says the service got better. + for _, o := range outcomes(t, st, ctx, "service_down") { + if o != store.NudgeIgnored { + t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored) + } + } +} + +func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) { + // Down, up, down again. Closing the first run must not make the second run + // unreportable, and must not silently reopen the closed rows either. + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedDown(t, st, ctx, "down", now) + + sink := &fakeSink{} + tl := newAlarmTickLoop(t, st, sink) + tl.tick(ctx, now) + first := len(outcomes(t, st, ctx, "service_down")) + + seedDown(t, st, ctx, "up", now.Add(time.Minute)) + tl.tick(ctx, now.Add(2*time.Minute)) + if got := outcomes(t, st, ctx, "service_down"); len(got) != first { + t.Fatalf("closing the run changed the row count: %d → %d", first, len(got)) + } + + seedDown(t, st, ctx, "down", now.Add(30*time.Minute)) + sink.sends = nil + tl.tick(ctx, now.Add(31*time.Minute)) + + if n := telegramSends(sink, "service_down"); n == 0 { + t.Fatal("the second outage said nothing; the first alarm's ending swallowed it") + } + got := outcomes(t, st, ctx, "service_down") + if len(got) <= first { + t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first) + } +} + +func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) { + // StillTrue == nil means "I cannot tell you", never "it cleared". A rule + // that says nothing must keep its alarm until the age cap, or a rule author + // silences their own alarm by omission. + st := newTestStore(t) + ctx := context.Background() + now := refNow() + + sink := &fakeSink{} + tl := newAlarmTickLoop(t, st, sink) + tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue + + if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil { + t.Fatalf("record nudge: %v", err) + } + + live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute)) + if len(live) != 1 { + t.Fatalf("a nil StillTrue was read as resolved: live = %v", live) + } + + live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute)) + if len(live) != 0 { + t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live) + } +} diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 3c7b0a0..2c3563f 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -10,6 +10,7 @@ package main import ( "context" + "errors" "fmt" "log" "os" @@ -249,6 +250,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { return } keys = t.repeatableRules(keys) + keys = t.stopFinishedAlarms(ctx, keys, state, now) if len(keys) == 0 { return } @@ -260,6 +262,83 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { } } +// maxAlarmAge — how long one un-acked telegram alarm may keep repeating. +// +// This is the floor brake and it applies to every rule, including one that +// says nothing about its own condition (Vikunja #535). Nothing in the tree can +// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the +// only ack that exists is a voice "готово" on a box that runs no voice loop. So +// "repeat until acked" meant "repeat forever", and it did — every five minutes +// for over two hours. +// +// Two hours at the five-minute default is about 24 messages, which is already +// past the point of being read. An alarm nobody answered in two hours is not +// one more repeat away from being answered, and the right move is to stop +// talking, not to talk louder. +const maxAlarmAge = 2 * time.Hour + +// stopFinishedAlarms returns the keys that may still repeat, and closes the +// rest. +// +// Two ways an alarm ends without him. The condition cleared, which the rule +// answers through StillTrue — deliberately NOT Predicate, which is +// edge-triggered and reads false one tick after the alarm is raised, so using +// it would cancel every alarm immediately. Or the alarm simply got old, which +// is the bound that does not need the rule's cooperation. +// +// A rule with no StillTrue is not treated as resolved. Silence about the +// condition is not evidence the condition cleared, so those keys only ever stop +// on age. +func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string { + if len(keys) == 0 { + return nil + } + byName := make(map[string]loop.Rule, len(t.rules)) + for _, r := range t.rules { + byName[r.Name] = r + } + live := keys[:0:0] + for _, key := range keys { + outcome := "" + switch r := byName[key]; { + case r.StillTrue != nil && !r.StillTrue(state): + outcome = store.NudgeResolved + case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now): + // Not "resolved": nothing says the thing got better. This is her + // giving up on being answered, and /notifications should say so. + outcome = store.NudgeIgnored + } + if outcome == "" { + live = append(live, key) + continue + } + n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now) + if err != nil { + // Could not close it, so do not drop it either: repeating is the + // lesser fault against losing the alarm entirely. + log.Printf("tick: stop alarm %s: %v", key, err) + live = append(live, key) + continue + } + log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n) + } + return live +} + +// alarmIsOlderThan reports whether the oldest un-acked send for this rule is +// past the cap. A read failure answers false: an alarm that repeats one more +// time is better than one silenced by a transient store error. +func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool { + oldest, err := t.store.OldestPendingTelegram(ctx, rule) + if err != nil { + if !errors.Is(err, store.ErrNudgeNotFound) { + log.Printf("tick: oldest pending %s: %v", rule, err) + } + return false + } + return now.Sub(oldest) >= age +} + // repeatableRules drops keys whose rule is not wired any more. // // The repeat path reads the nudges table, not the rule set: any sev4 telegram diff --git a/internal/loop/rules.go b/internal/loop/rules.go index 1fc5ff6..e062df6 100644 --- a/internal/loop/rules.go +++ b/internal/loop/rules.go @@ -35,6 +35,22 @@ type Rule struct { // the prefix here instead. Prefixes never make a rule inert: an empty // family is the predicate's own "no data" case. WantPrefixes []string + + // StillTrue — is the CONDITION still true, ignoring whether it is worth + // saying again? Distinct from Predicate on purpose, and the distinction is + // the whole reason this field exists (Vikunja #535). + // + // Predicate answers "should this fire now", which folds in edge-triggering: + // ServiceDownRule ends in !s.NudgedSince(...), so it reads false the instant + // a nudge goes out even though the service is still down. A repeat loop that + // consulted Predicate would cancel every alarm one tick after raising it, + // which is exactly backwards. + // + // Only a rule whose alarm repeats needs this. nil means "I cannot tell you", + // and the caller must then fall back to a bound it can enforce without the + // rule's help. nil must never be read as "the condition cleared": a rule + // that says nothing about its condition is not a rule that resolved. + StillTrue func(State) bool } // Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't @@ -158,6 +174,10 @@ func ServiceDownRule() Rule { } return !s.NudgedSince("service_down", newest) }, + // The condition without the edge trigger. DownServices is the same + // helper the predicate and the phraser read, so the repeat stops on + // exactly the monitors he was told about. + StillTrue: func(s State) bool { return len(DownServices(s)) > 0 }, } } From 69ecea19d5f18bd4f2d9c9f13eaf1b58e5b1d3ba Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:48:18 +0400 Subject: [PATCH 3/6] reminders: confirm from the row, not from the sentence (V-507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation was phrased by the replier off Slots.Text, so it named whatever hour the utterance contained — including one the parser rejected or read differently. He heard 'напомню в семь' with no row at seven, and stopped thinking about it. actionReminder now phrases it itself from the stored fire time, so the sentence and the row cannot disagree. Deterministic: the one sentence that must match a database row is not one to hand to a 1.7B. Also fixes formatTime, which had t.Format("2 января") — Go reads that as a literal, so every fact older than a day read as January. The month comes from internal/lexicon now, which is where months live. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_reminder.go | 24 +++++++++++++- cmd/mavend/reactive_notes_test.go | 7 ++-- cmd/mavend/reminder_confirm_test.go | 51 +++++++++++++++++++++++++++++ cmd/mavend/ruwords.go | 4 ++- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 cmd/mavend/reminder_confirm_test.go diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index a2c11a2..7ff97ed 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -2,8 +2,11 @@ package main import ( "context" + "fmt" "log" + "time" + "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio log.Printf("voice: create reminder: %v", err) return phraser.Ack(phraser.FailReminder, nil) } - return "" + // Phrased from the row, never from the utterance (Vikunja #507). The + // replier only ever saw Slots.Text, so it named whatever hour the sentence + // contained — including one the parser had rejected or read differently. + // A confirmation naming an hour no row holds is worse than a clarify, + // because he stops thinking about it. + return reminderConfirm(dec.Slots.Time, h.now()) +} + +// reminderConfirm — the confirmation for a reminder that exists, naming the +// stored fire time. Deterministic on purpose: the one sentence that must match +// a database row is not one to hand to a 1.7B. +func reminderConfirm(fire, now time.Time) string { + when := dayPrefix(now, fire) + if when == "это" { + // Further out than the day words reach — say the date instead of a + // word that would be wrong. + date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month()))) + return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "." + } + return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "." } diff --git a/cmd/mavend/reactive_notes_test.go b/cmd/mavend/reactive_notes_test.go index 22bedc3..811e706 100644 --- a/cmd/mavend/reactive_notes_test.go +++ b/cmd/mavend/reactive_notes_test.go @@ -44,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) { HasTime: true, }, } + // The confirmation is phrased from the row now (Vikunja #507), so it + // names the stored hour rather than leaving the replier to read one + // out of the sentence. reply := h.applyAction(ctx, dec) - if reply != "" { - t.Errorf("expected empty reply from applyAction, got %q", reply) + if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want { + t.Errorf("reply = %q, want %q", reply, want) } reminders, err := st.ListReminders(ctx, 10) if err != nil { diff --git a/cmd/mavend/reminder_confirm_test.go b/cmd/mavend/reminder_confirm_test.go new file mode 100644 index 0000000..ade67c3 --- /dev/null +++ b/cmd/mavend/reminder_confirm_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// A reminder confirmation is the one sentence that must match a database row. +// It used to be phrased by the replier from Slots.Text, which meant it named +// whatever hour the sentence contained — including an hour the parser had +// rejected or read differently (Vikunja #507). + +func TestReminderConfirmNamesTheStoredHour(t *testing.T) { + now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) + got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today + if !strings.Contains(got, "19:00") { + t.Fatalf("confirmation = %q, want the stored 19:00 in it", got) + } + if !strings.Contains(got, "сегодня") { + t.Fatalf("confirmation = %q, want it to say сегодня", got) + } +} + +func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) { + // dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is + // not a sentence. A date is. + now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) + got := reminderConfirm(now.Add(10*24*time.Hour), now) + if strings.Contains(got, "это") { + t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got) + } + if !strings.Contains(got, "15 августа") { + t.Fatalf("confirmation = %q, want the date in it", got) + } +} + +func TestReminderConfirmIsFeminineAndInformal(t *testing.T) { + // The persona checks the phrasing eval enforces apply here too, and this + // sentence never passes through a phraser. + now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) + got := reminderConfirm(now.Add(time.Hour), now) + for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} { + if strings.Contains(strings.ToLower(got), bad) { + t.Fatalf("confirmation = %q contains %q", got, bad) + } + } + if !strings.HasPrefix(got, "хорошо, напомню") { + t.Fatalf("confirmation = %q, want it to open with the promise", got) + } +} diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go index c254be8..0fe4a84 100644 --- a/cmd/mavend/ruwords.go +++ b/cmd/mavend/ruwords.go @@ -165,6 +165,8 @@ func formatTime(t time.Time) string { n := int(diff.Hours()) return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов")) default: - return t.Format("2 января 15:04") + // Not t.Format("2 января …"): Go reads that as a literal, so every + // fact older than a day used to read as January (Vikunja #507). + return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04")) } } From c82dbd1e656604c2405c5b879633c069ae9c6775 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:51:43 +0400 Subject: [PATCH 4/6] phrasing temperature is a config field, and a sweep to measure it (V-402) Both chatReq sites sent a hardcoded 0.7 and the remote path had its own const, so the one dial that governs how much a 1.7B invents could not be turned from outside the package. Config.Temperature now feeds both, 0 still means 0.7, and world.go reads the same accessor so resident and remote cannot drift. TestTalkTemperatureSweep scores the talk fixture at 0.7, 0.4, 0.2 and near greedy, three runs each so the noise band is visible. Opt-in twice (MAVEN_LLM_URL and MAVEN_TEMP_SWEEP) because it costs upwards of twenty minutes on the CPU floor. It reports and asserts nothing: the composite is not the number to read. Co-Authored-By: Claude Opus 5 --- internal/phraser/eval/temperature_test.go | 87 +++++++++++++++++++++++ internal/phraser/llmphraser.go | 12 +++- internal/phraser/world.go | 22 ++++-- internal/phraser/world_test.go | 4 +- 4 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 internal/phraser/eval/temperature_test.go diff --git a/internal/phraser/eval/temperature_test.go b/internal/phraser/eval/temperature_test.go new file mode 100644 index 0000000..4415fdf --- /dev/null +++ b/internal/phraser/eval/temperature_test.go @@ -0,0 +1,87 @@ +package eval + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/persona" + "github.com/kami/maven/internal/phraser" +) + +// sweepTemperatures — the dial positions worth comparing (Vikunja #402). +// 0.7 is what the transport has always sent; 0.05 stands in for near-greedy, +// since 0 means "use the default" to the phraser. +var sweepTemperatures = []float64{0.7, 0.4, 0.2, 0.05} + +// sweepRuns — how many runs per position. Three, because one run of a sampled +// model tells you nothing about whether a two-point difference is real. +const sweepRuns = 3 + +// TestTalkTemperatureSweep scores the talk fixture at each temperature. +// +// Opt-in twice over: it needs a llama-server AND it costs roughly +// len(sweepTemperatures) * sweepRuns * the baseline run time, which is upwards +// of twenty minutes on the CPU floor. +// +// MAVEN_LLM_URL=http://127.0.0.1:18099 MAVEN_TEMP_SWEEP=1 \ +// go test -v -timeout 90m -run TestTalkTemperatureSweep ./internal/phraser/eval/ +// +// Reports, asserts nothing. The composite is not the number to read — the task +// says to watch ontopic and invented content against how flat the replies get, +// and the replies are logged for exactly that reason. +// +// Note that only the chat/query/world paths move: the reply path is a Replier +// over llm.Client, which samples greedily and does not read this dial. +func TestTalkTemperatureSweep(t *testing.T) { + base := os.Getenv("MAVEN_LLM_URL") + if base == "" { + t.Skip("MAVEN_LLM_URL unset — point it at a running llama-server") + } + if os.Getenv("MAVEN_TEMP_SWEEP") == "" { + t.Skip("MAVEN_TEMP_SWEEP unset — this sweep costs many minutes, see the doc comment") + } + noProxyLoopback(t) + + ctx := context.Background() + f, err := LoadTalk() + if err != nil { + t.Fatalf("LoadTalk: %v", err) + } + model, err := llm.ModelID(ctx, base) + if err != nil { + t.Fatalf("no model at %s: %v", base, err) + } + + block := func() string { return persona.Facts{}.Block(time.Now()) } + summary := fmt.Sprintf("temperature sweep, %s, %d runs each\n", model, sweepRuns) + + for _, temp := range sweepTemperatures { + for run := 1; run <= sweepRuns; run++ { + cfg := phraser.DefaultConfig("") + cfg.Timeout = 5 * time.Minute + cfg.ContextBlock = block + cfg.Temperature = temp + p := phraser.NewLLMPhraserAt(base, cfg) + + name := fmt.Sprintf("temp %.2f run %d", temp, run) + target := Pair{Talker: p, Confirmer: phraser.NewReplier(llm.New(base, cfg.Timeout), block)} + rep, err := ScoreTalk(ctx, name, target, f) + p.Close() + if err != nil { + t.Fatalf("ScoreTalk at %.2f: %v", temp, err) + } + if rep.Errors == rep.Total { + t.Fatalf("every case errored at %.2f — nothing was measured", temp) + } + t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures()) + summary += fmt.Sprintf(" %-18s %2d/%2d (%.1f%%) ontopic %d/%d errors %d\n", + name, rep.Passed, rep.Total, 100*rep.Accuracy(), + rep.ByCheck[CheckOnTopic], rep.Total, rep.Errors) + } + } + t.Log("\n" + summary) +} diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index ddbaa64..37e0020 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -131,6 +131,14 @@ type Config struct { // query and reminder phrasing are untouched and still go through the model. LLMNudges bool + // Temperature — what every phrasing call samples at. 0 ⇒ 0.7, which is + // what this transport has always sent. + // + // A field rather than a constant so the talk fixture can sweep it + // (Vikunja #402). Sampling is a dial, and a dial nobody can turn from + // outside the package cannot be measured, only argued about. + Temperature float64 + // NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON). // The escape hatch exists because the target resident model — the // locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template @@ -594,7 +602,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo defer release() req := chatReq{ Messages: msgs, - Temperature: 0.7, + Temperature: p.temperature(), MaxTokens: maxTokens, Grammar: p.grammar(), } @@ -762,7 +770,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma {Role: "system", Content: system}, {Role: "user", Content: user}, }, - Temperature: 0.7, + Temperature: p.temperature(), MaxTokens: maxTokens, Grammar: p.grammar(), } diff --git a/internal/phraser/world.go b/internal/phraser/world.go index a021a4b..08d18d3 100644 --- a/internal/phraser/world.go +++ b/internal/phraser/world.go @@ -37,11 +37,19 @@ type Remote interface { // (docs/evals/2026-08-02-workstation-gemma4-12b.md). var ErrNoWorldModel = errors.New("phraser: no world model available") -// chatTemperature — what the phraser's own transport has always sampled at. -// Named so the remote path cannot drift from it silently. Whether 0.7 is right -// at all is Vikunja #402, and answering that here would hide a phrasing change -// inside a routing change. -const chatTemperature = 0.7 +// defaultChatTemperature — what the phraser's own transport has always sampled +// at, and what Config.Temperature falls back to. Named so the remote path +// cannot drift from the resident one silently. +const defaultChatTemperature = 0.7 + +// temperature — the sampling temperature for every phrasing call, resident or +// remote. Both paths read this, so a sweep moves them together. +func (p *LLMPhraser) temperature() float64 { + if p.cfg.Temperature > 0 { + return p.cfg.Temperature + } + return defaultChatTemperature +} // UseRemote points the phraser at the workstation model. Wiring time only, once, // before anything phrases: the field is read without a lock on every call @@ -85,7 +93,7 @@ func (p *LLMPhraser) PhraseWorld(ctx context.Context, utterance string, sources User: user, Grammar: p.grammar(), MaxTokens: 768, - Temperature: chatTemperature, + Temperature: p.temperature(), }) if err != nil { // The cached probe was one interval stale, or the card went away @@ -124,7 +132,7 @@ func (p *LLMPhraser) remoteChat(ctx context.Context, system, user string, maxTok User: user, Grammar: p.grammar(), MaxTokens: maxTokens, - Temperature: chatTemperature, + Temperature: p.temperature(), }) if err != nil { log.Printf("phraser: workstation model declined, phrasing here instead: %v", err) diff --git a/internal/phraser/world_test.go b/internal/phraser/world_test.go index 1cdb6ba..285c9d6 100644 --- a/internal/phraser/world_test.go +++ b/internal/phraser/world_test.go @@ -141,9 +141,9 @@ func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) { if len(remote.got) != 1 { t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) } - if remote.got[0].Temperature != chatTemperature { + if remote.got[0].Temperature != defaultChatTemperature { t.Errorf("temperature = %v, want %v (what the resident transport samples at)", - remote.got[0].Temperature, chatTemperature) + remote.got[0].Temperature, defaultChatTemperature) } if len(spy.user) != 0 { t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user)) From b954e0cea65b2f8a12dda92603e17e3782874844 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:56:11 +0400 Subject: [PATCH 5/6] a pronunciation dictionary, so piper stops reading hostnames as noise (V-458) The RU voice reads a latin word letter by letter or guesses, so 'netdata' came out as noise and 'homesrv' as nothing. pronounce_ru_v1.json spells the sound in Cyrillic for the service names, hostnames and acronyms she actually says, and Speakable applies it last, after the numbers around it are words. Data, not code: nothing knows any of these names, and adding one is an edit to the JSON. A word the table does not hold is left exactly as it was, so a miss is the current behaviour rather than a guess. A malformed file logs and loads empty, because speech must not stop over a dictionary. Co-Authored-By: Claude Opus 5 --- internal/ttsnorm/pronounce.go | 68 +++++++++++++++++++++++++++ internal/ttsnorm/pronounce_ru_v1.json | 47 ++++++++++++++++++ internal/ttsnorm/pronounce_test.go | 61 ++++++++++++++++++++++++ internal/ttsnorm/ttsnorm.go | 4 +- 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 internal/ttsnorm/pronounce.go create mode 100644 internal/ttsnorm/pronounce_ru_v1.json create mode 100644 internal/ttsnorm/pronounce_test.go diff --git a/internal/ttsnorm/pronounce.go b/internal/ttsnorm/pronounce.go new file mode 100644 index 0000000..dcd19a6 --- /dev/null +++ b/internal/ttsnorm/pronounce.go @@ -0,0 +1,68 @@ +// ttsnorm/pronounce.go — how piper says the latin words that turn up inside a +// Russian sentence: service names, hostnames, acronyms (Vikunja #458). +// +// The RU voice reads latin letters one at a time or guesses, so "netdata" comes +// out as noise and "homesrv" comes out as nothing. The fix is spelling the +// sound in Cyrillic, and the mapping is data — nothing in the code knows any of +// these names, and adding one is an edit to pronounce_ru_v1.json. +// +// This is a rewrite over a latin token, not over Russian morphology: the +// pattern matches [a-z0-9] runs and asks the table. A word the table does not +// hold is left exactly as it was, so a miss is the current behaviour rather +// than a guess. +package ttsnorm + +import ( + _ "embed" + "encoding/json" + "log" + "regexp" + "strings" +) + +//go:embed pronounce_ru_v1.json +var pronounceJSON []byte + +// pronounceSchemaVersion — the version this loader understands. +const pronounceSchemaVersion = 1 + +// latinToken — one run of latin letters and digits. Cyrillic is untouched, so a +// Russian word next to an English one is never rewritten by accident. +var latinToken = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`) + +// pronounce — lowercase word to its Russian spelling. Empty when the file +// failed to load, which leaves speech exactly as it was before this existed. +var pronounce = loadPronounce() + +func loadPronounce() map[string]string { + var f struct { + SchemaVersion int `json:"schema_version"` + Entries map[string]string `json:"entries"` + } + if err := json.Unmarshal(pronounceJSON, &f); err != nil { + // Speech must not stop because a dictionary is malformed. + log.Printf("ttsnorm: pronunciation dictionary: %v", err) + return map[string]string{} + } + if f.SchemaVersion != pronounceSchemaVersion { + log.Printf("ttsnorm: pronunciation dictionary schema_version %d, want %d — not loading it", + f.SchemaVersion, pronounceSchemaVersion) + return map[string]string{} + } + return f.Entries +} + +// Pronounce rewrites every latin word the dictionary knows. Case-insensitive on +// the way in ("Netdata", "NETDATA" and "netdata" are one word) and lowercase on +// the way out, because the value is a sound and not a name. +func Pronounce(s string) string { + if len(pronounce) == 0 { + return s + } + return latinToken.ReplaceAllStringFunc(s, func(w string) string { + if say, ok := pronounce[strings.ToLower(w)]; ok { + return say + } + return w + }) +} diff --git a/internal/ttsnorm/pronounce_ru_v1.json b/internal/ttsnorm/pronounce_ru_v1.json new file mode 100644 index 0000000..72523a8 --- /dev/null +++ b/internal/ttsnorm/pronounce_ru_v1.json @@ -0,0 +1,47 @@ +{ + "schema_version": 1, + "_comment": "How piper should say latin words inside a Russian sentence. Keys are lowercase and matched whole, so 'nexus' is rewritten and 'nexuses' is not. Values are Russian spelling of the sound, which is the only thing piper's RU voice can read. Adding a word here is a data change: nothing in the code knows any of these names.", + "entries": { + "maven": "мэйвен", + "nexus": "нексус", + "praxis": "праксис", + "hexis": "хексис", + "vikunja": "викунья", + "homesrv": "хоумсерв", + "workpc": "воркписи", + "kuma": "кума", + "netdata": "нетдата", + "paperless": "пейперлес", + "gitea": "гитея", + "docker": "докер", + "kiwix": "кивикс", + "searxng": "серч эн джи", + "piper": "пайпер", + "whisper": "виспер", + "caldav": "калдав", + "telegram": "телеграм", + "ntfy": "нотифай", + "imap": "аймап", + "smtp": "эс эм ти пи", + "http": "эйч ти ти пи", + "https": "эйч ти ти пи эс", + "api": "эй пи ай", + "url": "юарэль", + "cpu": "цэпэу", + "gpu": "джипиу", + "ram": "рам", + "ssd": "эсэсди", + "hdd": "эйчдиди", + "usb": "юэсби", + "vpn": "вэпээн", + "nas": "нас", + "dns": "дээнэс", + "wifi": "вайфай", + "pdf": "пэдээф", + "json": "джейсон", + "llm": "элэлэм", + "tts": "титиэс", + "stt": "эстиэти", + "ok": "окей" + } +} diff --git a/internal/ttsnorm/pronounce_test.go b/internal/ttsnorm/pronounce_test.go new file mode 100644 index 0000000..463989f --- /dev/null +++ b/internal/ttsnorm/pronounce_test.go @@ -0,0 +1,61 @@ +package ttsnorm + +import ( + "strings" + "testing" +) + +func TestDictionaryLoads(t *testing.T) { + // An empty map is the failure mode, and it is silent at runtime by design. + if len(pronounce) == 0 { + t.Fatal("pronunciation dictionary is empty — it failed to load or failed its version check") + } +} + +func TestPronounceRewritesAKnownService(t *testing.T) { + got := Pronounce("netdata говорит что диск заполнен") + if strings.Contains(got, "netdata") { + t.Fatalf("got %q, want the latin name spelled in Cyrillic", got) + } +} + +func TestPronounceIsCaseInsensitive(t *testing.T) { + for _, in := range []string{"Netdata", "NETDATA", "netdata"} { + if got := Pronounce(in); got != pronounce["netdata"] { + t.Errorf("Pronounce(%q) = %q, want %q", in, got, pronounce["netdata"]) + } + } +} + +func TestPronounceLeavesUnknownWordsAlone(t *testing.T) { + // A miss must be the old behaviour, never a guess. + const in = "zzqx упал" + if got := Pronounce(in); got != in { + t.Fatalf("Pronounce(%q) = %q, want it untouched", in, got) + } +} + +func TestPronounceDoesNotTouchRussian(t *testing.T) { + const in = "напомню завтра в 19:00" + if got := Pronounce(in); got != in { + t.Fatalf("Pronounce(%q) = %q, want Cyrillic untouched", in, got) + } +} + +func TestPronounceMatchesWholeWordsOnly(t *testing.T) { + // "nexuses" is not "nexus", and half-rewriting a word is worse than not + // rewriting it. + if got := Pronounce("nexuses"); got != "nexuses" { + t.Fatalf("Pronounce(\"nexuses\") = %q, want it untouched", got) + } +} + +func TestSpeakableAppliesTheDictionary(t *testing.T) { + got := Speakable("homesrv: 10.07.2026") + if strings.Contains(got, "homesrv") { + t.Fatalf("Speakable did not apply the dictionary: %q", got) + } + if !strings.Contains(got, "июля") { + t.Fatalf("Speakable stopped rewriting dates: %q", got) + } +} diff --git a/internal/ttsnorm/ttsnorm.go b/internal/ttsnorm/ttsnorm.go index bb912c5..5fb37a1 100644 --- a/internal/ttsnorm/ttsnorm.go +++ b/internal/ttsnorm/ttsnorm.go @@ -41,7 +41,9 @@ func Speakable(s string) string { p := reDate.FindStringSubmatch(m) return spokenDate(p[1], p[2], "") }) - return s + // Last, so a hostname is spelled out after the numbers around it are + // already words and no rewrite above can see Cyrillic it did not expect. + return Pronounce(s) } func spokenDate(dd, mm, yyyy string) string { From cc72f69769f1be2681e74a0c63f9424d1a8434e8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 02:01:01 +0400 Subject: [PATCH 6/6] an ambient meeting suppresses a nudge for its own span (V-513) The ambient endpoint writes calendar_event_* and never calendar_busy, so a notification-derived meeting was good enough to recite out loud and not good enough to stop a nudge during it. Backwards: being wrong here costs one nudge. The loop gatherer now derives busy from the event facts themselves, so the expiry IS the meeting's span. No new level, no interval to choose, and no way for the suppression to outlive the meeting. calendar.FactSpan reads back what FactValue wrote; anything that does not parse says nothing about now. --- cmd/mavweb/ambient.go | 11 ++- internal/calendar/calendar.go | 64 +++++++++++++++ internal/loop/ambient_busy_test.go | 124 +++++++++++++++++++++++++++++ internal/loop/gather.go | 37 +++++++++ 4 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 internal/loop/ambient_busy_test.go diff --git a/cmd/mavweb/ambient.go b/cmd/mavweb/ambient.go index 470ea53..1d98b48 100644 --- a/cmd/mavweb/ambient.go +++ b/cmd/mavweb/ambient.go @@ -31,12 +31,11 @@ import ( // not a guesser-of-truth, and a mailbox of noise rendered as invented meetings // is worse than a gap. // -// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient -// meeting is good enough to recite and not good enough to stop a nudge — -// calendar_busy is still written only by the CalDAV poller. That is backwards, -// since suppressing a nudge is the lower-risk use of a low-confidence signal. -// calendar_busy is a level rather than an event, so an ambient writer needs an -// expiry, which is its own task and not a change here. +// This writes calendar_event_* and nothing else, and since Vikunja #513 that is +// enough to stop a nudge as well as to recite: the loop gatherer reads the event +// family and asks whether any span covers the instant. So there is no ambient +// calendar_busy and no expiry to pick — a level needs one and an event carries +// its own. calendar_busy stays the CalDAV poller's key. // ambientMaxBody bounds the request. A notification is two short lines. const ambientMaxBody = 8 << 10 diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index cb62305..40bf11b 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -16,6 +16,7 @@ package calendar import ( "fmt" "sort" + "strconv" "strings" "time" "unicode" @@ -125,6 +126,69 @@ func FactSummary(value string) string { return value[:i] } +// EventKeyPrefix — every calendar event fact starts with this. The loop scans +// the family to work out whether a meeting covers right now (Vikunja #513). +const EventKeyPrefix = "calendar_event_" + +// FactSpan reads an event fact back into the instants it covers, against loc. +// The day comes from the key and the two clock readings from the value's +// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote. +// +// ok is false for anything that does not parse. A fact whose span cannot be +// read tells you nothing about now, and guessing a span is how a signal that +// was meant to suppress one nudge starts suppressing all of them. +// +// An end at or before the start is read as crossing midnight, so a 23:30-00:15 +// meeting covers the quarter hour it actually covers. +func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) { + if !strings.HasPrefix(key, EventKeyPrefix) { + return time.Time{}, time.Time{}, false + } + rest := key[len(EventKeyPrefix):] + if len(rest) < 8 { + return time.Time{}, time.Time{}, false + } + day, err := time.ParseInLocation("20060102", rest[:8], loc) + if err != nil { + return time.Time{}, time.Time{}, false + } + // SetValue stores a string fact JSON-encoded, so the value comes back + // quoted. Reading the tail off the quote is how this returned false for + // every real event the first time it ran. + if unq, err := strconv.Unquote(value); err == nil { + value = unq + } + i := strings.LastIndex(value, " @ ") + if i < 0 { + return time.Time{}, time.Time{}, false + } + tail := value[i+len(" @ "):] + from, to, found := strings.Cut(tail, "-") + if !found { + return time.Time{}, time.Time{}, false + } + sh, sm, ok1 := parseHM(strings.TrimSpace(from)) + eh, em, ok2 := parseHM(strings.TrimSpace(to)) + if !ok1 || !ok2 { + return time.Time{}, time.Time{}, false + } + start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute) + end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute) + if !end.After(start) { + end = end.Add(24 * time.Hour) + } + return start, end, true +} + +// parseHM reads "15:04" and nothing else. +func parseHM(s string) (h, m int, ok bool) { + t, err := time.Parse("15:04", s) + if err != nil { + return 0, 0, false + } + return t.Hour(), t.Minute(), true +} + // KeyPrefixForDay is the fact-key prefix covering one calendar day. The store // range-scans between two of these. func KeyPrefixForDay(day time.Time) string { diff --git a/internal/loop/ambient_busy_test.go b/internal/loop/ambient_busy_test.go new file mode 100644 index 0000000..279f7a5 --- /dev/null +++ b/internal/loop/ambient_busy_test.go @@ -0,0 +1,124 @@ +package loop + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/kami/maven/internal/calendar" + "github.com/kami/maven/internal/store" +) + +// An ambient meeting suppresses a nudge for its own span and no longer +// (Vikunja #513). The span is read back off the event fact, so there is no +// expiry to configure and no way for it to outlive the meeting. +func TestAmbientEventSuppressesNudgesForItsOwnSpan(t *testing.T) { + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local) + ev := calendar.Event{ + Summary: "Встреча с Аней", + Start: day.Add(14 * time.Hour), + End: day.Add(15 * time.Hour), + } + if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev), + calendar.SourceAmbient, calendar.FactValue(ev), day); err != nil { + t.Fatalf("SetValue: %v", err) + } + + g := NewGatherer(s, nil) + for _, tc := range []struct { + name string + now time.Time + busy bool + }{ + {"before it starts", day.Add(13*time.Hour + 59*time.Minute), false}, + {"at the first minute", day.Add(14 * time.Hour), true}, + {"in the middle", day.Add(14*time.Hour + 30*time.Minute), true}, + {"at the end instant", day.Add(15 * time.Hour), false}, + {"an hour after", day.Add(16 * time.Hour), false}, + } { + t.Run(tc.name, func(t *testing.T) { + st, _, err := g.GatherState(ctx, tc.now) + if err != nil { + t.Fatalf("GatherState: %v", err) + } + if st.CalendarBusy != tc.busy { + t.Fatalf("CalendarBusy = %v at %s, want %v", st.CalendarBusy, tc.now.Format("15:04"), tc.busy) + } + }) + } +} + +// A meeting on another day must not make today busy at the same clock reading. +// The day comes from the key, which is what makes this hold. +func TestAnEventOnAnotherDayDoesNotSuppress(t *testing.T) { + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy_day.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + yesterday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.Local) + ev := calendar.Event{Summary: "Standup", Start: yesterday.Add(14 * time.Hour), End: yesterday.Add(15 * time.Hour)} + if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev), + calendar.SourceAmbient, calendar.FactValue(ev), yesterday); err != nil { + t.Fatalf("SetValue: %v", err) + } + + today := time.Date(2026, 8, 5, 14, 30, 0, 0, time.Local) + st, _, err := NewGatherer(s, nil).GatherState(ctx, today) + if err != nil { + t.Fatalf("GatherState: %v", err) + } + if st.CalendarBusy { + t.Fatal("yesterday's meeting suppressed a nudge today") + } +} + +func TestFactSpanReadsBackWhatFactValueWrote(t *testing.T) { + day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local) + ev := calendar.Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(13*time.Hour + 45*time.Minute)} + start, end, ok := calendar.FactSpan(calendar.FactKey(ev), calendar.FactValue(ev), time.Local) + if !ok { + t.Fatal("FactSpan could not read its own encoding") + } + if !start.Equal(ev.Start) || !end.Equal(ev.End) { + t.Fatalf("span = %s-%s, want %s-%s", start, end, ev.Start, ev.End) + } +} + +// An end at or before the start is a meeting crossing midnight, not a zero-length +// one. Reading it as zero-length would silently drop the suppression. +func TestFactSpanCrossesMidnight(t *testing.T) { + start, end, ok := calendar.FactSpan("calendar_event_20260805_Night", "Night @ 23:30-00:15", time.Local) + if !ok { + t.Fatal("FactSpan rejected a midnight-crossing event") + } + if got := end.Sub(start); got != 45*time.Minute { + t.Fatalf("span length = %s, want 45m", got) + } +} + +// A fact that does not parse says nothing about now. Guessing a span here is +// how one suppressed nudge becomes all of them. +func TestFactSpanRejectsWhatItCannotRead(t *testing.T) { + for _, tc := range []struct{ key, value string }{ + {"other_key_20260805_x", "x @ 10:00-11:00"}, + {"calendar_event_20260805_x", "x"}, + {"calendar_event_notadate_x", "x @ 10:00-11:00"}, + {"calendar_event_20260805_x", "x @ 25:00-11:00"}, + {"calendar_event_20260805_x", "x @ 10:00"}, + } { + if _, _, ok := calendar.FactSpan(tc.key, tc.value, time.Local); ok { + t.Errorf("FactSpan(%q, %q) parsed, want rejected", tc.key, tc.value) + } + } +} diff --git a/internal/loop/gather.go b/internal/loop/gather.go index 73e164f..de5db82 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -14,6 +14,7 @@ import ( "fmt" "time" + "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/store" ) @@ -158,6 +159,19 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto if f, ok := readFact(ctx, g.store, "calendar_busy"); ok { calBusy = f.Value == "true" || f.Value == `"true"` } + // An ambient meeting suppresses a nudge too (Vikunja #513). It writes + // calendar_event_* and never calendar_busy, which is the CalDAV poller's + // level, so before this a low-confidence meeting was good enough to recite + // out loud and not good enough to stop a nudge during it. That is + // backwards: being wrong here costs one nudge he did not get. + // + // The expiry is the event's own span, which is why there is no new level + // and no interval to choose. A poller re-asserts a level every cycle and a + // notification arrives once; an event that already ended covers nothing, + // and one that has not started yet covers nothing either. + if !calBusy { + calBusy = g.eventCoversNow(ctx, now) + } // due reminders — gate-bypassing class. read here, the daemon emits them. due, err := g.store.DueReminders(ctx, now) @@ -212,6 +226,29 @@ func parseHHMM(s string) (hour, min int, ok bool) { return h, m, true } +// eventCoversNow reports whether any stored calendar event covers this instant. +// Read from the event facts themselves, so it holds for exactly as long as the +// meeting does — see the note at the call site. +// +// A read failure answers false: a meeting nobody can read about is not a reason +// to go quiet. +func (g *Gatherer) eventCoversNow(ctx context.Context, now time.Time) bool { + fam, err := g.store.LatestFactsByPrefix(ctx, calendar.EventKeyPrefix) + if err != nil { + return false + } + for _, f := range fam { + start, end, ok := calendar.FactSpan(f.Key, f.Value, now.Location()) + if !ok { + continue + } + if !now.Before(start) && now.Before(end) { + return true + } + } + return false +} + func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) { f, err := s.LatestFact(ctx, key) if err != nil {