initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
package loop
import (
"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 (LFM) 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
}
// 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 still applies — represented by a separate
// snooze-until the gatherer consults; for the scaffold, fired-reminders move
// straight to MarkReminder(fired).
type ReminderDecision struct {
Reminder store.Reminder
State State
}
// RemindDecisions — returns all due reminders (without gating their delivery
// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer
// produces that list from `fire_ts <= now AND pending`.
func RemindDecisions(s State, due []store.Reminder) []ReminderDecision {
out := make([]ReminderDecision, 0, len(due))
for _, r := range due {
out = append(out, ReminderDecision{Reminder: r, State: s})
}
return out
}
// 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)
}