diff --git a/internal/ambient/sentinel.go b/internal/ambient/sentinel.go index bdfb467..af4eaf8 100644 --- a/internal/ambient/sentinel.go +++ b/internal/ambient/sentinel.go @@ -150,6 +150,164 @@ func (s *Sentinel) recentForTest() []string { return append([]string(nil), s.recent...) } -// silence: keep log imported until evaluate (Task 6) uses it. -var _ = log.Printf -var _ = frame.MaxBriefBytes +// 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() + 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() +} diff --git a/internal/ambient/sentinel_test.go b/internal/ambient/sentinel_test.go index e6c849e..c623819 100644 --- a/internal/ambient/sentinel_test.go +++ b/internal/ambient/sentinel_test.go @@ -4,8 +4,10 @@ package ambient import ( "context" "testing" + "time" "keel/internal/evidence" + "keel/internal/memory" ) func snap(title, class string) evidence.WindowSnapshot { @@ -44,3 +46,201 @@ func TestOnWindowSkipsEmptyTitle(t *testing.T) { } var _ = context.Background + +// --- helpers --- + +type fakeJudge struct { + out []string // queued returns, consumed front-to-back + err error + calls int +} + +func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) { + f.calls++ + if f.err != nil { + return "", f.err + } + if len(f.out) == 0 { + return "", nil + } + r := f.out[0] + f.out = f.out[1:] + return r, nil +} + +type fakeNotifier struct { + calls int + body string +} + +func (f *fakeNotifier) Notify(_ context.Context, _, body string) error { + f.calls++ + f.body = body + return nil +} + +func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel { + return New(Deps{ + AI: j, + Notifier: n, + Memory: mem, + Clock: func() time.Time { return time.Unix(1_700_000_000, 0) }, + ActiveMode: func() string { return *active }, + }, time.Minute, mode) +} + +// memoryFake is a minimal memory.Store recorder. +type memoryFake struct { + events []memory.Event +} + +func (m *memoryFake) Record(_ context.Context, e memory.Event) error { + m.events = append(m.events, e) + return nil +} +func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil } + +// --- behavioural matrix --- + +func TestEvaluateOnTrackIsSilent(t *testing.T) { + j := &fakeJudge{out: []string{""}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeNotify, &active) + s.OnWindow(snap("main.go - code", "code")) + s.evaluate(context.Background()) + if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 { + t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events)) + } +} + +func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) { + j := &fakeJudge{out: []string{"deep in YouTube"}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if s.Line() == "" { + t.Fatal("first drift must set the status line") + } + if n.calls != 0 { + t.Fatalf("first drift must NOT toast, got %d", n.calls) + } + if len(mem.events) != 0 { + t.Fatalf("first drift must not record memory yet, got %d", len(mem.events)) + } +} + +func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) { + j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + + s.evaluate(context.Background()) // first: line, no toast + s.evaluate(context.Background()) // second: sustained -> one toast + one memory + s.evaluate(context.Background()) // third: still drift -> NO second toast + + if n.calls != 1 { + t.Fatalf("sustained drift must toast exactly once, got %d", n.calls) + } + if len(mem.events) != 1 { + t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events)) + } + if mem.events[0].Kind != kindAmbientNudge { + t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge) + } +} + +func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) { + j := &fakeJudge{out: []string{"drift", "drift", ""}} + n := &fakeNotifier{} + active := "" + s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + s.evaluate(context.Background()) + s.evaluate(context.Background()) // on-track now + if s.Line() != "" { + t.Fatalf("return to on-track must clear the line, got %q", s.Line()) + } +} + +func TestEvaluateSilentWhenModeActive(t *testing.T) { + j := &fakeJudge{out: []string{"drift"}} + active := "focus" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if j.calls != 0 { + t.Fatalf("an active mode must suppress the brain call, got %d", j.calls) + } + if s.Line() != "" { + t.Fatalf("an active mode must keep the line empty, got %q", s.Line()) + } +} + +func TestEvaluateStatusModeNeverToasts(t *testing.T) { + j := &fakeJudge{out: []string{"drift", "drift"}} + n := &fakeNotifier{} + mem := &memoryFake{} + active := "" + s := newTestSentinel(j, n, mem, modeStatus, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + s.evaluate(context.Background()) + if n.calls != 0 { + t.Fatalf("status mode must never toast, got %d", n.calls) + } + if s.Line() == "" { + t.Fatal("status mode must still show the line") + } + if len(mem.events) != 1 { + t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events)) + } +} + +func TestEvaluateOffModeDoesNothing(t *testing.T) { + j := &fakeJudge{out: []string{"drift"}} + active := "" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if j.calls != 0 { + t.Fatalf("off mode must not call the brain, got %d", j.calls) + } +} + +func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) { + j := &fakeJudge{out: []string{""}} // one on-track answer available + active := "" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("main.go - code", "code")) + s.evaluate(context.Background()) // calls=1, on-track + s.evaluate(context.Background()) // unchanged ring + on-track -> skip + if j.calls != 1 { + t.Fatalf("cheap gate must skip the second call, got %d", j.calls) + } +} + +func TestSnoozeMutesAndClears(t *testing.T) { + j := &fakeJudge{out: []string{"drift", "drift"}} + active := "" + s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) // line set + s.Snooze(time.Hour) + if s.Line() != "" { + t.Fatalf("snooze must clear the line, got %q", s.Line()) + } + callsBefore := j.calls + s.evaluate(context.Background()) // snoozed -> no call + if j.calls != callsBefore { + t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls) + } +}