// 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...) } // silence: keep log imported until evaluate (Task 6) uses it. var _ = log.Printf var _ = frame.MaxBriefBytes