// internal/ambient/sentinel.go // Package ambient is Keel's always-on drift coach: a Sentinel that watches the // window stream beside the one-mode harness and, when no mode is active, judges // recent activity against the ~/owc frame and surfaces drift. It is not a mode — // it runs continuously, including when the harness is idle. package ambient import ( "context" "log" "strings" "sync" "time" "keel/internal/evidence" "keel/internal/frame" "keel/internal/knowledge" "keel/internal/memory" "keel/internal/notify" "keel/internal/tasks" ) // recentTitlesMax bounds the title ring (matches the focus mode's history cap). const recentTitlesMax = 10 // evalTimeout bounds a single ambient brain call. const evalTimeout = 30 * time.Second // defaultCadence is used when the configured cadence is non-positive. const defaultCadence = 5 * time.Minute // kindAmbientNudge is the memory event recorded for a sustained ambient drift. const kindAmbientNudge = "ambient_nudge" // Ambient mode dial values (mirror settings.Ambient*). const ( modeOff = "off" modeStatus = "status" modeNotify = "notify" ) // AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies // it; declaring it here keeps ambient decoupled from the ai package. type AmbientDriftJudge interface { AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) } // Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil // (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards). // ActiveMode reports the harness's active mode kind ("" = idle); nil counts as // always-idle. Clock defaults to time.Now. type Deps struct { AI AmbientDriftJudge Knowledge knowledge.Source Tasks tasks.Provider Notifier notify.Notifier Memory memory.Store Clock func() time.Time ActiveMode func() string } // Sentinel watches activity and surfaces ambient drift. type Sentinel struct { mu sync.Mutex deps Deps cadence time.Duration mode string recent []string lastWindow evidence.WindowSnapshot line string lastEvalTitles string drifting bool committed bool snoozeUntil time.Time onChange []func() } // New builds a sentinel. A non-positive cadence falls back to defaultCadence; an // empty mode falls back to notify. func New(d Deps, cadence time.Duration, mode string) *Sentinel { if d.Clock == nil { d.Clock = time.Now } if cadence <= 0 { cadence = defaultCadence } return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)} } func normalizeMode(m string) string { if m == "" { return modeNotify } return m } // OnWindow ingests one sensor observation: it records the latest window and // appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires // no brain call. func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) { s.mu.Lock() defer s.mu.Unlock() s.lastWindow = w t := strings.TrimSpace(w.Title) if t == "" { return } if n := len(s.recent); n > 0 && s.recent[n-1] == t { return } s.recent = append(s.recent, t) if len(s.recent) > recentTitlesMax { s.recent = s.recent[len(s.recent)-recentTitlesMax:] } } // Line returns the current ambient coaching line ("" when on-track, quiet, or // snoozed) for the surfaces. func (s *Sentinel) Line() string { s.mu.Lock() defer s.mu.Unlock() return s.line } // AddOnChange registers a surface refresh fired when the line changes. func (s *Sentinel) AddOnChange(f func()) { s.mu.Lock() s.onChange = append(s.onChange, f) s.mu.Unlock() } // fireOnChange notifies registered surfaces off the lock. func (s *Sentinel) fireOnChange() { s.mu.Lock() fs := append([]func(){}, s.onChange...) s.mu.Unlock() for _, f := range fs { if f != nil { f() } } } // recentForTest returns a copy of the ring (test-only). func (s *Sentinel) recentForTest() []string { s.mu.Lock() defer s.mu.Unlock() return append([]string(nil), s.recent...) } // Run drives the cadence loop: every cadence it evaluates, until ctx is // cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the // next tick. func (s *Sentinel) Run(ctx context.Context) { for { select { case <-ctx.Done(): return case <-time.After(s.currentCadence()): s.evaluate(ctx) } } } func (s *Sentinel) currentCadence() time.Duration { s.mu.Lock() defer s.mu.Unlock() return s.cadence } // SetConfig updates the cadence and mode live (next tick). func (s *Sentinel) SetConfig(cadence time.Duration, mode string) { s.mu.Lock() if cadence > 0 { s.cadence = cadence } s.mode = normalizeMode(mode) s.mu.Unlock() } // Snooze mutes the sentinel for d: it clears the current line and episode and // suppresses evaluation until the window elapses. func (s *Sentinel) Snooze(d time.Duration) { s.mu.Lock() s.snoozeUntil = s.deps.Clock().Add(d) changed := s.line != "" s.line = "" s.drifting = false s.committed = false s.mu.Unlock() if changed { s.fireOnChange() } } // evaluate runs one decision cycle. See the spec's "Trigger discipline." func (s *Sentinel) evaluate(ctx context.Context) { s.mu.Lock() if s.mode == modeOff { s.mu.Unlock() return } if s.deps.Clock().Before(s.snoozeUntil) { s.mu.Unlock() return } if s.activeModeLocked() != "" { // A mode is coaching; stay silent and clear any stale line. changed := s.clearLocked() s.mu.Unlock() if changed { s.fireOnChange() } return } titles := append([]string(nil), s.recent...) joined := strings.Join(titles, "\n") // Cheap gate: nothing new to judge and we are on-track -> skip the brain call. if joined == s.lastEvalTitles && s.line == "" { s.mu.Unlock() return } win := s.lastWindow mode := s.mode judge := s.deps.AI s.mu.Unlock() if judge == nil { return } cctx, cancel := context.WithTimeout(ctx, evalTimeout) fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks) msg, err := judge.AmbientDrift(cctx, fr, titles) cancel() s.mu.Lock() // A mode may have activated during the (off-lock) brain call; re-check so a // result computed while idle is not surfaced over an now-active mode. if s.activeModeLocked() != "" { changed := s.clearLocked() s.mu.Unlock() if changed { s.fireOnChange() } return } s.lastEvalTitles = joined if err != nil { s.mu.Unlock() log.Printf("ambient: drift judge failed: %v", err) return // never fabricate drift; leave the prior line intact } if msg == "" { // on-track changed := s.clearLocked() s.mu.Unlock() if changed { s.fireOnChange() } return } if !s.drifting { // new episode: show the line, no toast yet s.line = msg s.drifting = true s.committed = false s.mu.Unlock() s.fireOnChange() return } // Sustained drift. Commit once: record memory, and toast in notify mode. changed := s.line != msg s.line = msg if s.committed { s.mu.Unlock() if changed { s.fireOnChange() } return } s.committed = true now := s.deps.Clock() mem := s.deps.Memory notifier := s.deps.Notifier s.mu.Unlock() if mem != nil { ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{ "message": msg, "title": win.Title, "class": win.Class, }} if e := mem.Record(ctx, ev); e != nil { log.Printf("ambient: memory record: %v", e) } } if mode == modeNotify && notifier != nil { if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil { log.Printf("ambient: notify: %v", e) } } s.fireOnChange() } // clearLocked resets line + episode state and reports whether the line changed. // Caller holds mu. func (s *Sentinel) clearLocked() bool { changed := s.line != "" s.line = "" s.drifting = false s.committed = false return changed } // activeModeLocked reads the active-mode kind, treating a nil hook as idle. // Caller holds mu (the hook does no locking on the sentinel). func (s *Sentinel) activeModeLocked() string { if s.deps.ActiveMode == nil { return "" } return s.deps.ActiveMode() }