b36964a427
A settings.json predating the ambient fields deserializes cadence to 0; the sentinel clamps that to its 5m default, so log the clamped value instead of a misleading cadence=0s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
8.5 KiB
Go
251 lines
8.5 KiB
Go
// Command keeld is the Keel daemon: it serves a local web UI and drives the
|
|
// harness — the general collect→brain→act loop that hosts one mode at a time
|
|
// (focus or offscreen).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"keel/internal/ai"
|
|
"keel/internal/ambient"
|
|
"keel/internal/aw"
|
|
"keel/internal/enforce"
|
|
"keel/internal/evidence"
|
|
"keel/internal/harness"
|
|
"keel/internal/knowledge"
|
|
"keel/internal/memory"
|
|
"keel/internal/mode"
|
|
"keel/internal/mode/focus"
|
|
"keel/internal/mode/offscreen"
|
|
"keel/internal/notify"
|
|
"keel/internal/settings"
|
|
"keel/internal/statusfile"
|
|
"keel/internal/tasks"
|
|
"keel/internal/web"
|
|
)
|
|
|
|
const addr = "localhost:7777"
|
|
|
|
func main() {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
log.Fatalf("resolve home dir: %v", err)
|
|
}
|
|
root := filepath.Join(home, ".keel")
|
|
|
|
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
|
|
// first run. After first run the file is the sole source of truth; env is
|
|
// ignored. A missing file is the first-run signal.
|
|
settingsPath, err := settings.DefaultPath()
|
|
if err != nil {
|
|
log.Fatalf("resolve settings path: %v", err)
|
|
}
|
|
cfg, err := settings.Load(settingsPath)
|
|
if err != nil {
|
|
cfg = settings.SeedFromEnv()
|
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
|
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
|
|
} else {
|
|
log.Printf("settings: seeded %s from environment", settingsPath)
|
|
}
|
|
}
|
|
|
|
// buildServices turns a Settings value into a fresh harness.Services. It
|
|
// validates the AI backend FIRST and returns an error before constructing any
|
|
// ports, so an invalid backend never half-rewires the harness. The same code
|
|
// path serves startup and live POST /settings edits.
|
|
buildServices := func(s settings.Settings) (harness.Services, error) {
|
|
backend, err := ai.NewBackend(s.AIBackend)
|
|
if err != nil {
|
|
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
|
|
}
|
|
svc := ai.NewService(backend)
|
|
|
|
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
|
|
// first run, but Load returns whatever the JSON file holds on later starts.
|
|
awURL := s.AWURL
|
|
if awURL == "" {
|
|
awURL = "http://localhost:5600"
|
|
}
|
|
var mem memory.Store = memory.NewNop()
|
|
awc := aw.New(awURL)
|
|
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
|
|
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
|
|
} else {
|
|
mem = memory.NewAWStore(awc, "keel.events")
|
|
log.Printf("memory: keel.events ready at %s", awURL)
|
|
}
|
|
|
|
return harness.Services{
|
|
AI: svc,
|
|
Tasks: tasks.NewMarvin(s.MarvinCmd),
|
|
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
|
|
Enforce: enforce.NewGuard(),
|
|
Memory: mem,
|
|
Clock: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// Build the base services at startup. A bad persisted backend should not be
|
|
// fatal: fall back to claude (always valid), persist the correction, and retry.
|
|
base, err := buildServices(cfg)
|
|
if err != nil {
|
|
log.Printf("settings: %v; falling back to claude backend", err)
|
|
cfg.AIBackend = "claude"
|
|
if err := settings.Save(settingsPath, cfg); err != nil {
|
|
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
|
|
}
|
|
base, err = buildServices(cfg)
|
|
if err != nil {
|
|
log.Fatalf("settings: build failed even with claude: %v", err)
|
|
}
|
|
}
|
|
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
|
|
log.Printf("enforce: window-minimize guard")
|
|
|
|
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
|
|
// mode builds its own file paths under Services.Dir.
|
|
withDir := func(dir string, f harness.Factory) harness.Factory {
|
|
return func(s harness.Services) mode.Mode {
|
|
s.Dir = dir
|
|
return f(s)
|
|
}
|
|
}
|
|
|
|
focusDir := filepath.Join(root, "modes", "focus")
|
|
offscreenDir := filepath.Join(root, "modes", "offscreen")
|
|
h := harness.New(base, map[string]harness.Factory{
|
|
"focus": withDir(focusDir, focus.Factory),
|
|
"offscreen": withDir(offscreenDir, offscreen.Factory),
|
|
})
|
|
|
|
// Re-adopt a live focus session restored from disk, if one was in flight.
|
|
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
|
|
// restored mode's async completions reach the harness's onChange listeners.
|
|
focusSvc := h.Services()
|
|
focusSvc.Dir = focusDir
|
|
if m, live, err := focus.Restore(focusSvc); err != nil {
|
|
log.Printf("focus: restore failed (continuing idle): %v", err)
|
|
} else if live {
|
|
if err := h.Adopt(m); err != nil {
|
|
log.Printf("focus: adopt restored session failed: %v", err)
|
|
} else {
|
|
log.Printf("focus: restored a live session")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Compute the effective cadence so the log reflects what the sentinel will
|
|
// actually use: New clamps a non-positive value (e.g. a settings.json that
|
|
// predates these fields) to a default, mirrored here for the log line.
|
|
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
|
|
if ambCadence <= 0 {
|
|
ambCadence = 5 * time.Minute
|
|
}
|
|
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 },
|
|
}, ambCadence, ambMode)
|
|
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
|
|
|
|
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)
|
|
|
|
// Mirror runtime status to ~/.keel_status for a window-manager bar.
|
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
|
log.Printf("status file disabled: %v", err)
|
|
} else {
|
|
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 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(), func(w evidence.WindowSnapshot) {
|
|
h.RecordWindow(w)
|
|
sentinel.OnWindow(w)
|
|
})
|
|
go sentinel.Run(context.Background())
|
|
|
|
go openBrowser("http://" + addr)
|
|
|
|
log.Printf("keeld listening on http://%s", addr)
|
|
if err := srv.Router().Run(addr); err != nil {
|
|
log.Fatalf("server: %v", err)
|
|
}
|
|
}
|
|
|
|
// openBrowser best-effort launches the default browser after a short delay so
|
|
// the server is listening first. Failures are logged, not fatal.
|
|
func openBrowser(url string) {
|
|
time.Sleep(300 * time.Millisecond)
|
|
var cmd string
|
|
var args []string
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
cmd, args = "xdg-open", []string{url}
|
|
case "darwin":
|
|
cmd, args = "open", []string{url}
|
|
case "windows":
|
|
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
|
default:
|
|
log.Printf("open browser manually: %s", url)
|
|
return
|
|
}
|
|
if err := exec.Command(cmd, args...).Start(); err != nil {
|
|
log.Printf("could not open browser (%v); visit %s", err, url)
|
|
}
|
|
}
|