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"
|
"time"
|
||||||
|
|
||||||
"keel/internal/ai"
|
"keel/internal/ai"
|
||||||
|
"keel/internal/ambient"
|
||||||
"keel/internal/aw"
|
"keel/internal/aw"
|
||||||
"keel/internal/enforce"
|
"keel/internal/enforce"
|
||||||
"keel/internal/evidence"
|
"keel/internal/evidence"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"keel/internal/mode"
|
"keel/internal/mode"
|
||||||
"keel/internal/mode/focus"
|
"keel/internal/mode/focus"
|
||||||
"keel/internal/mode/offscreen"
|
"keel/internal/mode/offscreen"
|
||||||
|
"keel/internal/notify"
|
||||||
"keel/internal/settings"
|
"keel/internal/settings"
|
||||||
"keel/internal/statusfile"
|
"keel/internal/statusfile"
|
||||||
"keel/internal/tasks"
|
"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 := web.NewServer(h)
|
||||||
srv.Init() // re-arm or expire a restored deadline-bearing session
|
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
|
// 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
|
// fresh harness.Services and installs it via SetServices. Per the harness
|
||||||
// contract this affects the NEXT mode start; an already-active mode keeps the
|
// 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
|
// services it was built with. It validates the backend before mutating, so
|
||||||
// POST /settings can reject an invalid backend atomically.
|
// POST /settings can reject an invalid backend atomically.
|
||||||
applyFn := func(s settings.Settings) error {
|
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)
|
next, err := buildServices(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.SetServices(next)
|
h.SetServices(next)
|
||||||
|
mode := s.AmbientMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = settings.AmbientNotify
|
||||||
|
}
|
||||||
|
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
srv.SetSettings(settingsPath, cfg, applyFn)
|
srv.SetSettings(settingsPath, cfg, applyFn)
|
||||||
@@ -162,19 +195,22 @@ func main() {
|
|||||||
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
log.Printf("status file disabled: %v", err)
|
log.Printf("status file disabled: %v", err)
|
||||||
} else {
|
} else {
|
||||||
writer := statusfile.NewWriter(statusPath, h.State)
|
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
|
||||||
// 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.
|
|
||||||
h.AddOnChange(writer.Wake)
|
h.AddOnChange(writer.Wake)
|
||||||
|
sentinel.AddOnChange(writer.Wake)
|
||||||
go writer.Run(context.Background())
|
go writer.Run(context.Background())
|
||||||
log.Printf("status: writing %s", statusPath)
|
log.Printf("status: writing %s", statusPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed the window sensor stream into the harness, which forwards it to the
|
// Feed the window sensor stream into BOTH the harness (for the active mode)
|
||||||
// active mode iff that mode consumes evidence. On a headless box the source is
|
// and the ambient sentinel (always-on). On a headless box the source is a
|
||||||
// a no-op, so this degrades gracefully without new X11 requirements.
|
// no-op, so both degrade gracefully without new X11 requirements.
|
||||||
src := evidence.NewSource()
|
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)
|
go openBrowser("http://" + addr)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user