diff --git a/internal/ambient/sentinel.go b/internal/ambient/sentinel.go new file mode 100644 index 0000000..bdfb467 --- /dev/null +++ b/internal/ambient/sentinel.go @@ -0,0 +1,155 @@ +// 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 diff --git a/internal/ambient/sentinel_test.go b/internal/ambient/sentinel_test.go new file mode 100644 index 0000000..e6c849e --- /dev/null +++ b/internal/ambient/sentinel_test.go @@ -0,0 +1,46 @@ +// internal/ambient/sentinel_test.go +package ambient + +import ( + "context" + "testing" + + "keel/internal/evidence" +) + +func snap(title, class string) evidence.WindowSnapshot { + return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}} +} + +func TestNewLineEmpty(t *testing.T) { + s := New(Deps{}, 0, "notify") + if s.Line() != "" { + t.Fatalf("fresh sentinel line = %q, want empty", s.Line()) + } +} + +func TestOnWindowRingDedupesAndCaps(t *testing.T) { + s := New(Deps{}, 0, "notify") + s.OnWindow(snap("a", "x")) + s.OnWindow(snap("a", "x")) // consecutive dup ignored + s.OnWindow(snap("b", "x")) + if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("ring = %v, want [a b]", got) + } + for i := 0; i < recentTitlesMax+5; i++ { + s.OnWindow(snap(string(rune('A'+i)), "x")) + } + if got := s.recentForTest(); len(got) != recentTitlesMax { + t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax) + } +} + +func TestOnWindowSkipsEmptyTitle(t *testing.T) { + s := New(Deps{}, 0, "notify") + s.OnWindow(snap("", "x")) + if got := s.recentForTest(); len(got) != 0 { + t.Fatalf("empty title must not enter ring, got %v", got) + } +} + +var _ = context.Background