d52f60c54e
- Add CalendarEvents method to recordingAPI in auth_test.go - Add CalendarEvents method to fakeCore in handlers_test.go Co-Authored-By: opencode <opencode@anthropic.com>
110 lines
4.2 KiB
Go
110 lines
4.2 KiB
Go
// Package loop is maven's proactive trigger engine.
|
||
//
|
||
// Loop contract (from spec):
|
||
//
|
||
// - ticks ~60s. no llm. 99% of ticks evaluate a few predicates and die for free.
|
||
// - a predicate is `(State) -> Bool`, PURE, no i/o → unit-testable with a fake State.
|
||
// - since(key)==null → don't fire. silence on no-data = "shuts up when uncertain".
|
||
// - the GATE is 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.
|
||
// - ONE nudge per tick (max severity), never dogpile.
|
||
// - rules as code, not a DSL. revisit at ~30 rules.
|
||
//
|
||
// Reminders are a SEPARATE class — reuses the loop, NOT a second scheduler:
|
||
// - predicate: `fire_ts <= now AND pending`
|
||
// - BYPASSES the restraint gate — "wake me 7" fires in quiet hours; that's the point
|
||
// - snooze still applies; fires once (pending → fired)
|
||
//
|
||
// Architecture: the Gatherer is the only impure bit — it builds a State snapshot
|
||
// under the store lock. Everything from there on is PURE functions over State.
|
||
// Phrasing (the llm lane) and delivery are out of scope here — the loop emits
|
||
// Decisions; the daemon wires them to phrasing + delivery.
|
||
package loop
|
||
|
||
import (
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/store"
|
||
)
|
||
|
||
// Severity — higher = more insistent (harder to suppress).
|
||
//
|
||
// Per the spec's delivery table:
|
||
//
|
||
// sev1–2: care nudges (water, meal, break). voice when present; DROP on away.
|
||
// sev3: ops soft (backup failed, cert soon). voice + once over ntfy when away.
|
||
// sev4: ops hard (disk critical, service down). voice + ntfy present;
|
||
// telegram, repeat til ack, when away.
|
||
//
|
||
// "Max severity" in one-nudge-per-tick therefore means: the loudest/most insistent
|
||
// candidate wins — a disk-full nudge (sev4) preempts a water nudge (sev1).
|
||
type Severity int
|
||
|
||
const (
|
||
Sev1 Severity = 1 // care, lowest insistence — drops on away
|
||
Sev2 Severity = 2
|
||
Sev3 Severity = 3 // ops soft
|
||
Sev4 Severity = 4 // ops hard, highest insistence
|
||
)
|
||
|
||
func (s Severity) IsCare() bool { return s <= Sev2 } // suppressed by quiet hours + away
|
||
|
||
// State — the loop's view of the world, gathered under the store lock at the
|
||
// start of each tick. From here on everything is pure — predicates and the gate
|
||
// read ONLY this struct and never touch the store.
|
||
//
|
||
// Keys in Facts/LastNudge/SnoozeUntil/CooldownUntil are rule-specific lookups
|
||
// the rules + gate have pre-arranged. Presence/PresenceScore/Now are global.
|
||
// The Gatherer decides what to populate; rules see what's in the snapshot.
|
||
type State struct {
|
||
Now time.Time
|
||
Presence store.Bucket
|
||
PresenceScore float64
|
||
|
||
// Latest non-voided fact per key the loop requested at gather time.
|
||
// Missing key (or zero Value Fact with Ts.IsZero) ⇒ since(key)==null ⇒ don't fire.
|
||
Facts map[string]store.Fact
|
||
|
||
// Last nudge per rule, for cooldown enforcement (newest send).
|
||
LastNudge map[string]store.Nudge
|
||
|
||
// Per-rule snooze-until — explicit "don't bother me about this rule until X".
|
||
// Overlays on top of cooldown; both must be clear to fire.
|
||
SnoozeUntil map[string]time.Time
|
||
|
||
// Per-rule cooldown-until — derived from LastNudge + cooldown duration
|
||
// (the auto-tuner adjusts the duration via feedback outcomes).
|
||
CooldownUntil map[string]time.Time
|
||
|
||
// Cross-cutting env flags derived from facts at gather time:
|
||
// QuietHours — the care gate suppresses sev1–2 when true. ops still surface.
|
||
QuietHours bool
|
||
// CalendarBusy — "don't nag mid-meeting". acts as an env predicate in the gate.
|
||
CalendarBusy bool
|
||
}
|
||
|
||
// Fact returns the latest non-voided fact for key, or
|
||
// (store.Fact{}, false) — the predicate's "no data" case.
|
||
// since(key)==null → don't fire is implemented by checking the bool.
|
||
func (s State) Fact(key string) (store.Fact, bool) {
|
||
f, ok := s.Facts[key]
|
||
if !ok || f.Ts.IsZero() {
|
||
return store.Fact{}, false
|
||
}
|
||
return f, true
|
||
}
|
||
|
||
// Since returns the duration since the latest fact for key, or (0,false).
|
||
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
||
func (s State) Since(key string) (time.Duration, bool) {
|
||
f, ok := s.Fact(key)
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
if s.Now.Before(f.Ts) {
|
||
return 0, true
|
||
}
|
||
return s.Now.Sub(f.Ts), true
|
||
}
|