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 (sev1–2) 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 — sev1–2 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 (Sev1–2), 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) }