The first Control loop / scheduler component (architecture §3): an always-on sentinel beside the one-mode harness that judges activity against the ~/owc frame when no session is declared, and surfaces drift to the status bar, a notify-send toast, and a web-UI banner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
Ambient Drift Coach — Design
Date: 2026-06-05
Status: Approved, ready for implementation planning
Scope: Give Keel an always-on presence. Today Keel only coaches inside a
declared focus session; the moment you are just working, the harness goes idle,
drops every window snapshot, and the status bar reads idle. This slice adds an
ambient drift coach: a sentinel that watches the window stream continuously
(even when no mode is active), judges your recent activity against the broad
~/owc frame (goals, values, life-domains) plus today's Marvin tasks, and —
when you have clearly slid off what matters — surfaces a single coaching line to
the status bar, a notify-send toast, and a web-UI banner you can act on.
This builds the Control loop / scheduler component from
docs/keel-architecture.md §3, which today has zero implementation, and is the
first thing that makes Keel show up unprompted (SOUL: "when my partner drifts,
I name it").
Problem
The focus mode is already a capable proactive coach — local on-task matching, an
LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the
status bar and web UI, all sensibly debounced. But every bit of it hangs off a
declared Commitment. You must open the UI and start a session first. With
no session:
harness.RecordWindowfinds no active mode and silently drops the snapshot.statusfile.Renderof an empty envelope returnsidle.- No brain call ever happens; Keel is blind and mute.
So the common case — Felix working without ceremonially declaring a task — gets nothing. The fix is an observer that runs regardless of the active mode and is grounded in the standing frame instead of a per-session commitment.
Approach (and the one real fork)
The ambient coach is not a mode. It is an always-on sentinel that runs beside the one-mode harness. Two harness invariants make "ambient as a mode" the wrong shape:
- The harness hosts exactly one mode and drops evidence when idle — an ambient watcher must run continuously, including when idle.
harness.StartreturnsErrBusyif a different mode is active — an ambient mode would either block you from starting focus, or need a special auto-yield carve-out that erodes the single-mode contract.
A sentinel sidesteps both and matches §3, where the control loop is a distinct
component beside the harness. Coexistence rule: when any harness mode is
active (h.State().ActiveMode != ""), the sentinel stays silent — focus mode
already owns coaching during a declared session, so there is never double
coaching.
Components
Each unit has one responsibility, a defined interface, and is testable alone.
1. internal/notify — notification effector (new)
The OS-level "interrupt me" primitive, mirroring enforce.Guard.
- Interface:
Notifier interface { Notify(ctx context.Context, title, body string) error } - Linux adapter: shells
notify-send <title> <body>. Best-effort: returns an error for diagnostics; callers never block on it. - Constructor:
NewNotifier() Notifierreturns the real adapter when thenotify-sendbinary is found onPATH(viaexec.LookPath), else a nop. On non-Linux it is a nop. This means a missing binary degrades to "no toast," never a failure. - Nop:
Notifyreturns nil, does nothing. - Depends on:
os/execonly. Leaf package.
2. internal/frame — shared frame assembler (new; DRY extraction)
The "what matters to Felix" brief, today duplicated inside off-screen's
collect.go. Extracting it removes the duplication and gives the sentinel the
same grounding off-screen uses.
- Interface:
Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) stringreturns a bounded markdown brief with three sections: Today's tasks (fromtasks.Today), Goals (~/owc/goals-2026.mdviaknowledge.Load), and Life domains (every~/owc/resources/bug-*.mdviaknowledge.Load). - Best-effort: never panics, never errors; tolerates nil ports and missing
files, degrading to a short, mostly-empty brief. Same truncation cap
(
maxBriefBytes) and rune-safe clipping off-screen uses today. - Refactor: off-screen's
briefTasks/briefGoals/briefLifeDomainsare replaced by a call toframe.Assemble; off-screen keeps its own proposal-history section (briefHistory) prepended around it. Off-screen's existing tests must stay green. - Depends on:
knowledge,tasks. Imports nothing fromai/mode.
3. internal/ai — AmbientDrift method (new; mirrors Nudge)
The brain call grounded in the frame, not a commitment.
- Method:
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)returns""when the activity plausibly serves the frame (on-track), and a one-sentence coaching line when it is a slide. - Prompt: an "ambient coach" prompt stating the user has not declared a
task; it is handed the frame and the recent window-title sequence and asked
whether the activity serves any of what matters, forgivingly —
legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is
ONLYJSON:{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}. - Parse (
parseAmbientDrift): reusesextractJSONand the sharedErrEmptyResponse/ErrNoJSONsentinels.drifting:false→"".drifting:truewith a message → the message.drifting:truewith no message →""(silence) — the ambient signal is advisory, so the safe degenerate is to say nothing, exactly likeparseNudge. - Depends on: the existing
Backend.aistays a leaf (primitives only).
4. internal/ambient — the Sentinel (new; the meat)
Always-on observer: consume evidence → tick on cadence → judge → surface.
Construction: New(deps) where deps carry *ai.Service, knowledge.Source,
tasks.Provider, notify.Notifier, memory.Store, clock func() time.Time,
and activeMode func() string (reads h.State().ActiveMode for the coexistence
rule). Config: cadence time.Duration and mode (off | status | notify).
State (under one mutex): recentTitles []string (consecutive-dedup ring,
capped like focus's recentTitlesMax), lastWindow evidence.WindowSnapshot,
line string (current drift line; "" when on-track), lastEvalTitles
(snapshot of the ring at the last brain call), wasDrifting bool,
episodeNotified bool, snoozeUntil time.Time, gen int, and onChange []func().
Methods:
OnWindow(snap evidence.WindowSnapshot)— called from the evidence fan-out. UpdateslastWindow, appends the title to the ring (skip consecutive dup, cap). Cheap and lock-bounded; fires no brain call.Run(ctx context.Context)— cadence loop: a ticker everycadencecallsevaluate(); returns on ctx cancel.evaluate()— the decision (see Trigger discipline below).Line() string— current ambient line for the surfaces (""when on-track, quiet, or snoozed).Snooze(d time.Duration)— setssnoozeUntil = clock()+d; clears the line and episode flags; firesonChange. While snoozed,evaluate()returns early.SetConfig(cadence time.Duration, mode string)— live settings update.AddOnChange(f func())— register a surface refresh (status wake, web broadcast). Fired whenever the line changes.
Depends on: ai, frame, notify, memory, evidence, and a
activeMode closure. It does not import harness (closure injection keeps
it decoupled and testable).
5. Wiring — cmd/keeld/main.go
- Build the notifier (
notify.NewNotifier()) and the sentinel from settings (cadence, mode) and the shared ports (the sameai.Service,knowledge,tasks,memory, clock used to buildharness.Services), withactiveMode: func() string { return h.State().ActiveMode }. - Fan the evidence stream to both the harness and the sentinel:
go src.Watch(ctx, func(w evidence.WindowSnapshot) { h.RecordWindow(w) sentinel.OnWindow(w) }) - Run the sentinel:
go sentinel.Run(ctx). - Surface refresh: register both the status writer's
Wakeand the web broadcast withsentinel.AddOnChange(...), so a new ambient line re-renders the bar and pushes over SSE promptly. - Live settings:
applyFn(the existing POST/settingsre-wire) also callssentinel.SetConfig(...)from the new settings, so the dial takes effect without a restart.
Trigger discipline (aggressive, but not trigger-happy)
A notify-send toast on a false positive is far more irritating than a stale
status line, so evaluate() is conservative and brain-call-thrifty:
- If
mode == off→ return. - If snoozed (
clock() < snoozeUntil) → return. - If
activeMode() != ""→ a mode is coaching; clear the line (if set), fireonChange, return. (Coexistence rule.) - Cheap gate: if the ring is unchanged since
lastEvalTitlesand we are currently on-track (line == "") → skip the brain call. This naturally pauses calls during steady deep work and while AFK (no new titles arrive in either case), while still re-checking an unresolved drift. - Assemble the frame (
frame.Assemble) and callai.AmbientDrift(ctx, frame, recentTitles)under a timeout. - Apply the result:
- On-track (
""): if a line was set, clear it and resetwasDrifting=false,episodeNotified=false; fireonChange. Otherwise no-op. - Drifting (
msg):- New episode (
!wasDrifting): setline=msg,wasDrifting=true,episodeNotified=false; fireonChange. The status bar / banner show immediately, but no toast yet. - Sustained (already
wasDriftingon this consecutive eval): record anambient_nudgeevent tomemory({ "message": msg, "title": lastWindow.Title, "class": lastWindow.Class }), and — ifmode == notifyand!episodeNotified— fire exactly onenotify-sendtoast and setepisodeNotified=true. Further sustained ticks do not re-toast.
- New episode (
- On a brain error, leave the prior line intact and log — never fabricate
drift (same discipline as
Nudge).
- On-track (
- Update
lastEvalTitlesto the current ring.
Net effect with the default ~5-minute cadence: a 40-second glance at Twitter never pops a toast (it resolves before the second tick); a sustained ~10-minute slide shows on the bar at the first tick and pops one toast at the second. Memory records only sustained drifts, independent of the surface mode.
Surfaces
- Status bar (
internal/statusfile):Rendergains the current ambient line (passed in by theWriter, which readssentinel.Line). When the envelope is idle (ActiveMode == ""): a non-empty line renders⚠ <line>; an empty line rendersidleas today. Non-idle envelopes are unchanged. TheWriteradds anambientLine func() stringsource andsentinel.AddOnChange(writer.Wake)drives prompt re-render; the existing minute ticker still applies. - Web UI (
internal/web+static/app.js): the served state gains an"ambient": "<line>"field (read fromsentinel.Line), pushed over the existing SSE channel viasentinel.AddOnChange(broadcast).app.jsrenders a banner when the field is non-empty: the coaching line plus two controls — Snooze 1h (POST /ambient/snooze) and Start focus (reuses the existing start-session affordance so a nudge converts into a declared session). The banner clears automatically when the line goes empty. - New endpoint:
POST /ambient/snooze→sentinel.Snooze(1 * time.Hour). Returns 204. No body needed in v1.
Config (the agency-vs-cage dial, §8)
settings.Settings gains two fields (env-seedable, mirroring the existing ones):
ambient_mode(KEEL_AMBIENT_MODE):off|status|notify. Defaultnotify. Validated in the settings applier (likeai_backend); an unknown value is rejected with a wrapped sentinel so POST/settingscan map it to 400.ambient_cadence_secs(KEEL_AMBIENT_CADENCE_SECS): integer seconds between evaluations. Default300. A non-positive value falls back to the default.
The web settings form gains a select (ambient_mode) and a number input
(ambient_cadence_secs); applyFn pushes both into sentinel.SetConfig.
Error handling & graceful degradation
- No
notify-sendbinary / non-Linux: nop notifier; status line and banner still work. - Brain error / timeout:
AmbientDriftreturns an error; the sentinel logs and leaves the prior line intact. Never fabricates drift. - AW down:
memoryis already the nop store;ambient_nudgewrites are dropped silently. knowledge/taskserrors:frame.Assembledegrades to a partial brief (best-effort), never errors.- Headless / no X11: the evidence source is a no-op, so
OnWindowis never called, the ring stays empty, the cheap gate skips every brain call, and the sentinel is silent. No new X11 requirement. - Nothing in the sentinel can crash the daemon — every external call is best-effort and lock-bounded.
Testing
internal/ambient(fake clock, fakeaiwith scriptableAmbientDriftreturns and a call counter, fakenotify.Notifierrecording calls, faketasks/knowledge,activeModestub,memory.Fake):- on-track → no line, no toast, no memory.
- single drift tick → line set, no toast, no memory yet.
- two consecutive drift ticks → line set, exactly one toast, one
ambient_nudgeevent. - third consecutive drift tick → still one toast (once per episode).
- back on-track → line cleared, flags reset.
activeMode() != ""→ no eval, line cleared.mode == status→ drift sets the line but never toasts (memory still records the sustained drift).mode == off→ nothing happens, no brain call.- snooze → no eval / no line during the window.
- cheap gate → unchanged ring + on-track → assert the fake
aicall count does not increase.
internal/notify:NewNotifierwithnotify-sendabsent → nop;Notifyreturns nil, no panic. (The real shell path is not unit-tested.)internal/ai:parseAmbientDrift— on-track→"", drift+msg→msg, drift with no msg→"", empty→ErrEmptyResponse, no-JSON→ErrNoJSON.internal/frame:Assemblewith fake ports — sections present; nil ports and missing files degrade without error; truncation honored. Off-screen's existing tests stay green after the refactor.internal/statusfile: idle + ambient line →⚠ …; idle + empty line →idle; non-idle envelopes unaffected.internal/web: served state includesambient;POST /ambient/snoozeroutes toSnoozeand returns 204.internal/settings:ambient_modevalidation (good values accepted, unknown rejected with the sentinel); cadence default on non-positive.
Out of scope (deliberate, for a later slice)
- AFK precision via the AW afk bucket. v1 uses the cheap-gate heuristic (no new titles ⇒ no call), which covers both AFK and deep-focus well enough. Reading AW's input-derived afk bucket to prove presence is a future collector.
- Ambient enforcement (window-minimize). The sentinel is advisory only; minimizing windows with no declared session would be far too cagey. Enforcement stays a focus-session decision.
- Trend / spiral detection over the accumulating
ambient_nudgehistory (§7). This slice produces that history; reading it for cross-time patterns is the next increment. - Richer banner actions beyond snooze + start-focus (e.g. "I'm on it", per-domain mute).
Open questions
- Default cadence. 300 s is a starting guess; may want tuning after living with it. Configurable, so cheap to change.
- Snooze duration. Fixed 1 h in v1; a picker is a later refinement.