feat(ambient): evaluate loop, two-stage surfacing, snooze, config
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user