initial commit
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user