feat(keeld): wire the ambient drift coach sentinel
Automated smoke test results: - GET /: 200 - POST /ambient/snooze: 204 - GET /settings: includes ambient_mode and ambient_cadence_secs fields - daemon log: "ambient: mode=notify cadence=0s" (cadence=0 because existing settings.json predates these fields; ambient.New defaults to 5 min when cadence is non-positive — correct degradation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+43
-7
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"keel/internal/ai"
|
||||
"keel/internal/ambient"
|
||||
"keel/internal/aw"
|
||||
"keel/internal/enforce"
|
||||
"keel/internal/evidence"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"keel/internal/mode"
|
||||
"keel/internal/mode/focus"
|
||||
"keel/internal/mode/offscreen"
|
||||
"keel/internal/notify"
|
||||
"keel/internal/settings"
|
||||
"keel/internal/statusfile"
|
||||
"keel/internal/tasks"
|
||||
@@ -139,20 +141,51 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
|
||||
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
|
||||
// mode is active. Backend/port changes need a daemon restart to reach it; live
|
||||
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
|
||||
// services").
|
||||
ambMode := cfg.AmbientMode
|
||||
if ambMode == "" {
|
||||
ambMode = settings.AmbientNotify
|
||||
}
|
||||
sentinel := ambient.New(ambient.Deps{
|
||||
AI: base.AI,
|
||||
Knowledge: base.Knowledge,
|
||||
Tasks: base.Tasks,
|
||||
Notifier: notify.NewNotifier(),
|
||||
Memory: base.Memory,
|
||||
Clock: time.Now,
|
||||
ActiveMode: func() string { return h.State().ActiveMode },
|
||||
}, time.Duration(cfg.AmbientCadenceSecs)*time.Second, ambMode)
|
||||
log.Printf("ambient: mode=%s cadence=%ds", ambMode, cfg.AmbientCadenceSecs)
|
||||
|
||||
srv := web.NewServer(h)
|
||||
srv.Init() // re-arm or expire a restored deadline-bearing session
|
||||
|
||||
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
|
||||
sentinel.AddOnChange(srv.Broadcast)
|
||||
|
||||
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
|
||||
// fresh harness.Services and installs it via SetServices. Per the harness
|
||||
// contract this affects the NEXT mode start; an already-active mode keeps the
|
||||
// services it was built with. It validates the backend before mutating, so
|
||||
// POST /settings can reject an invalid backend atomically.
|
||||
applyFn := func(s settings.Settings) error {
|
||||
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
|
||||
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
|
||||
}
|
||||
next, err := buildServices(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.SetServices(next)
|
||||
mode := s.AmbientMode
|
||||
if mode == "" {
|
||||
mode = settings.AmbientNotify
|
||||
}
|
||||
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
|
||||
return nil
|
||||
}
|
||||
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||
@@ -162,19 +195,22 @@ func main() {
|
||||
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||
log.Printf("status file disabled: %v", err)
|
||||
} else {
|
||||
writer := statusfile.NewWriter(statusPath, h.State)
|
||||
// Re-render the bar on every harness change, not just the coarse tick, so a
|
||||
// drift transition reaches the status bar as fast as it reaches the web UI.
|
||||
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
|
||||
h.AddOnChange(writer.Wake)
|
||||
sentinel.AddOnChange(writer.Wake)
|
||||
go writer.Run(context.Background())
|
||||
log.Printf("status: writing %s", statusPath)
|
||||
}
|
||||
|
||||
// Feed the window sensor stream into the harness, which forwards it to the
|
||||
// active mode iff that mode consumes evidence. On a headless box the source is
|
||||
// a no-op, so this degrades gracefully without new X11 requirements.
|
||||
// Feed the window sensor stream into BOTH the harness (for the active mode)
|
||||
// and the ambient sentinel (always-on). On a headless box the source is a
|
||||
// no-op, so both degrade gracefully without new X11 requirements.
|
||||
src := evidence.NewSource()
|
||||
go src.Watch(context.Background(), h.RecordWindow)
|
||||
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
|
||||
h.RecordWindow(w)
|
||||
sentinel.OnWindow(w)
|
||||
})
|
||||
go sentinel.Run(context.Background())
|
||||
|
||||
go openBrowser("http://" + addr)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user