Files
Maven/internal/loop/loop.go
kami b0f5a16ec9 Add digest as a real outcome: suppressed care nudges get resurfaced, not lost
Vikunja #281. The interruption policy promised four outcomes — deliver_now,
queue, digest, drop — but only three existed: a care candidate the restraint
gate suppressed for quiet hours / away / calendar-busy simply vanished in
loop.Tick's `continue`, with only the trace remembering why.

internal/morning turned out not to be the natural drain: it's a fixed
Item/FactKey checklist engine, not a generic message bundler, so gate-
suppressed nudge text has nowhere to plug into its evidence model. Built a
parallel (but small, reusing the outbox's shape) durable digest instead:

- internal/store: digest_entries table + EnqueueDigestEntry (dedupes by
  rule+body, mirroring the delivery outbox's bodyHash), PendingDigestEntries,
  ExpireStaleDigestEntries, DrainDigestEntries (mark, never delete — an
  audit trail of what she actually said).
- internal/loop: DigestEligible(severity, blockedBy) is the pure boundary —
  only genuine restraint blocks (quiet_hours/calendar_busy/presence) even
  qualify (cooldown/snooze are not "suppression"); within care, Sev2 (break)
  digests, Sev1 (water/meal — stale by the time anyone could resurface them)
  drops. High severity never digests; alarms bypass the gate and deliver
  unchanged, on purpose.
- cmd/mavend/tick.go: each tick scans ExplainTick's trace for eligible
  blocked candidates, enqueues them, sweeps stale entries (24h expiry — the
  care rules are daily-cadence, so anything older is describing a day
  that's over), and drains the bundle only once the suppression reason has
  actually cleared, capped at 3 spoken items plus a trailing count so a
  digest can't turn into the exact nagging it was built to avoid.

Tests: store-level round-trip/restart-survival/dedupe/expiry/drain, loop-
level severity-boundary unit tests, and tick-level integration tests for
the drain-only-when-clear and never-digest-high-severity behavior.
2026-07-31 23:09:22 +04:00

198 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
import (
"fmt"
"time"
"github.com/kami/maven/internal/store"
)
// Gate — universal, applied by the loop, never per-rule.
//
// quiet-hours, presence, cooldown, snooze, calendar-busy all live in ONE
// fires(). Cross-cutting restraint in one place or it drifts.
//
// The gate does NOT itself decide "should this rule run" — the Rule.Predicate
// does. The gate answers "is it ALLOWED to fire right NOW" given the snapshot.
// Suppression-context ("don't nag mid-meeting") moves INTO the gate as an env
// predicate, not the LLM's job — same boundary as "rules decide, llm phrases."
//
// Gate is pure. no I/O. reads State + Rule only.
func Gate(s State, r Rule) bool {
now := s.Now
// snooze — per-rule "leave me alone until X". overrides everything below.
if until, ok := s.SnoozeUntil[r.Name]; ok && now.Before(until) {
return false
}
// cooldown — most recent same-rule nudge + base/feedback-tuned duration.
// Persisted as `facts (source=feedback)`; Gatherer rolls it into CooldownUntil.
if until, ok := s.CooldownUntil[r.Name]; ok && now.Before(until) {
return false
}
// quiet hours — care nudges (sev12) shut up. ops (sev ≥3) still surface
// (a failed backup at 2am genuinely matters and maven routes to telegram).
if s.QuietHours && r.Severity.IsCare() {
return false
}
// calendar busy — "don't nag mid-meeting" lifted INTO the gate as an env
// predicate; not the LLM's call.
if s.CalendarBusy && r.Severity.IsCare() {
return false
}
// presence — sev12 DROP on away (missed water nudge is noise).
// sev ≥3 HOLDS — see the delivery channel-routing table in the spec.
// (the loop doesn't pick the channel; it just decides whether to emit.)
if s.Presence == store.Away && r.Severity.IsCare() {
return false
}
// no-data inertness — since(key)==null → don't fire. shut up when uncertain.
// The predicate MAY have encoded this itself; the gate enforces it for any
// rule that declared InertWhenNoData keys.
for _, k := range r.InertWhenNoData {
if _, ok := s.Fact(k); !ok {
return false
}
}
return true
}
// Candidate — a rule that Wants (predicate true) AND Is Allowed (gate true).
// The loop picks one per tick (max severity).
type Candidate struct {
Rule Rule
Severity Severity
State State // snapshot at evaluation time — for the phraser's context
}
// Tick — PURE. Evaluates the configured rules against the snapshot, returns
// AT MOST one proactive candidate (max severity, with a deterministic tie-break).
// Returns nil when nothing fires ("shuts up" is the default outcome of a tick).
//
// Phrasing + sending happen OUT of the loop — the daemon hands Candidate to
// the phraser (the resident model, Qwen3-1.7B) and delivery module. The loop
// just decides.
//
// Reminders are NOT handled here — they're a separate, gate-bypassing class.
// See DueReminders (gathered separately) and RemindDecisions (the loop output
// flag for the daemon).
func Tick(s State, rules []Rule) *Candidate {
var fire *Candidate
for _, r := range rules {
if !r.Predicate(s) {
continue // rule doesn't want to fire — skip gate entirely (cheap path)
}
if !Gate(s, r) {
continue // wanted but suppressed this tick
}
c := Candidate{Rule: r, Severity: r.Severity, State: s}
if fire == nil {
fire = &c
continue
}
// max severity wins; tie-break: severity desc, then name asc for determinism.
if c.Severity > fire.Severity ||
(c.Severity == fire.Severity && c.Rule.Name < fire.Rule.Name) {
fire = &c
}
}
return fire
}
// DigestEligible decides digest-vs-drop for a care candidate the gate
// suppressed this tick (see ExplainGate's blockedBy). Pure — no I/O, no
// state, just the two facts that matter: why it was suppressed, and how
// insistent it was.
//
// Only genuine RESTRAINT blocks are eligible at all — quiet_hours,
// calendar_busy, presence(away). cooldown and snooze are not suppression in
// this sense: cooldown means "you already heard this recently" (resurfacing
// it later would be an actual repeat, not a rescue) and snooze is the user
// explicitly saying "not this" (digesting it anyway would defeat the ask).
// Ops severities (Sev3/4) never reach here — the gate never blocks them for
// these reasons in the first place (see Gate), and even if a future rule
// dropped Sev3+ into "care", digest still refuses them: alarms bypass the
// gate on purpose and must never be silently delayed into a bundle.
//
// Within care (Sev12), the boundary is severity itself: Sev1 (water, meal —
// biological timers with no "still relevant later" property; a water nudge
// from 3 hours into quiet hours is just wrong by morning) drops. Sev2
// (break — "you worked through a long stretch without a break while I
// couldn't reach you") is information that stays true and useful after the
// fact, so it digests.
func DigestEligible(sev Severity, blockedBy string) bool {
switch blockedBy {
case "quiet_hours", "calendar_busy", "presence":
default:
return false
}
return sev == Sev2
}
// ReminderDecision — a due reminder the daemon should deliver now.
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
// that's the point). Snooze is the one part of restraint that still applies.
type ReminderDecision struct {
Reminder store.Reminder
State State
}
// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder.
// Reminders have no rule name, so they share one key. A snooze aimed at a
// single reminder uses ReminderSnoozeKeyFor instead.
const ReminderSnoozeKey = "reminder"
// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id.
func ReminderSnoozeKeyFor(id int64) string {
return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id)
}
// RemindDecisions — returns the due reminders the daemon should deliver.
// Pure: accepts an already-filtered (due) list. The Gatherer produces that
// list from `fire_ts <= now AND pending`.
//
// Quiet hours, presence and cooldown are deliberately NOT consulted — a
// reminder must wake you at 7 even in the middle of quiet hours. Only snooze
// holds one back. A held reminder stays pending, so it comes back once the
// snooze runs out.
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
out := make([]ReminderDecision, 0, len(due))
for _, r := range due {
if reminderSnoozed(s, r) {
continue
}
out = append(out, ReminderDecision{Reminder: r, State: s})
}
return out
}
// reminderSnoozed — true when a snooze on this reminder, or on reminders as a
// class, is still running.
func reminderSnoozed(s State, r store.Reminder) bool {
keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)}
for _, k := range keys {
if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) {
return true
}
}
return false
}
// CooldownFor — helper for the Gatherer: given the active cooldown base
// (the rule's static Base, OR the feedback tuner's persisted tuning) and the
// last send ts, compute the wall-clock "cooldown-until" the gate will check.
// Pure. The auto-tuner writes the base the gatherer reads as a feedback
// fact; this function just adds it to the last send.
func CooldownFor(base time.Duration, lastSend time.Time) time.Time {
if lastSend.IsZero() {
return time.Time{} // never sent → no cooldown active
}
return lastSend.Add(base)
}