feat(ambient): sentinel core — evidence ring, Line, deps
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user