Files

324 lines
7.8 KiB
Go

// 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()
}