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
+109
View File
@@ -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:
//
// sev12: 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 sev12 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
}