314 lines
7.6 KiB
Go
314 lines
7.6 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()
|
|
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()
|
|
}
|