docs: design the ambient drift coach (always-on sentinel)
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>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
# 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.RecordWindow` finds no active mode and silently drops the snapshot.
|
||||
- `statusfile.Render` of an empty envelope returns `idle`.
|
||||
- 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:
|
||||
|
||||
1. The harness hosts exactly one mode and drops evidence when idle — an ambient
|
||||
watcher must run continuously, including when idle.
|
||||
2. `harness.Start` returns `ErrBusy` if 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() Notifier` returns the real adapter when the
|
||||
`notify-send` binary is found on `PATH` (via `exec.LookPath`), else a nop. On
|
||||
non-Linux it is a nop. This means a missing binary degrades to "no toast,"
|
||||
never a failure.
|
||||
- **Nop:** `Notify` returns nil, does nothing.
|
||||
- **Depends on:** `os/exec` only. 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) string`
|
||||
returns a bounded markdown brief with three sections: **Today's tasks**
|
||||
(from `tasks.Today`), **Goals** (`~/owc/goals-2026.md` via `knowledge.Load`),
|
||||
and **Life domains** (every `~/owc/resources/bug-*.md` via `knowledge.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` / `briefLifeDomains`
|
||||
are replaced by a call to `frame.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 from `ai`/`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
|
||||
`ONLY` JSON: `{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}`.
|
||||
- **Parse (`parseAmbientDrift`):** reuses `extractJSON` and the shared
|
||||
`ErrEmptyResponse` / `ErrNoJSON` sentinels. `drifting:false` → `""`.
|
||||
`drifting:true` with a message → the message. `drifting:true` with no message →
|
||||
`""` (silence) — the ambient signal is advisory, so the safe degenerate is to
|
||||
say nothing, exactly like `parseNudge`.
|
||||
- **Depends on:** the existing `Backend`. `ai` stays 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.
|
||||
Updates `lastWindow`, 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 every `cadence` calls
|
||||
`evaluate()`; 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)` — sets `snoozeUntil = clock()+d`; clears the line and
|
||||
episode flags; fires `onChange`. 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 same `ai.Service`, `knowledge`,
|
||||
`tasks`, `memory`, clock used to build `harness.Services`), with
|
||||
`activeMode: func() string { return h.State().ActiveMode }`.
|
||||
- **Fan the evidence stream to both** the harness and the sentinel:
|
||||
```go
|
||||
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 `Wake` and the web
|
||||
broadcast with `sentinel.AddOnChange(...)`, so a new ambient line re-renders
|
||||
the bar and pushes over SSE promptly.
|
||||
- **Live settings:** `applyFn` (the existing POST `/settings` re-wire) also calls
|
||||
`sentinel.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:
|
||||
|
||||
1. If `mode == off` → return.
|
||||
2. If snoozed (`clock() < snoozeUntil`) → return.
|
||||
3. If `activeMode() != ""` → a mode is coaching; clear the line (if set), fire
|
||||
`onChange`, return. (Coexistence rule.)
|
||||
4. **Cheap gate:** if the ring is unchanged since `lastEvalTitles` **and** 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.
|
||||
5. Assemble the frame (`frame.Assemble`) and call
|
||||
`ai.AmbientDrift(ctx, frame, recentTitles)` under a timeout.
|
||||
6. Apply the result:
|
||||
- **On-track (`""`):** if a line was set, clear it and reset
|
||||
`wasDrifting=false`, `episodeNotified=false`; fire `onChange`. Otherwise no-op.
|
||||
- **Drifting (`msg`):**
|
||||
- **New episode** (`!wasDrifting`): set `line=msg`, `wasDrifting=true`,
|
||||
`episodeNotified=false`; fire `onChange`. The **status bar / banner show
|
||||
immediately**, but **no toast yet**.
|
||||
- **Sustained** (already `wasDrifting` on this consecutive eval): record an
|
||||
`ambient_nudge` event to `memory` (`{ "message": msg, "title":
|
||||
lastWindow.Title, "class": lastWindow.Class }`), and — if `mode == notify`
|
||||
and `!episodeNotified` — fire exactly one `notify-send` toast and set
|
||||
`episodeNotified=true`. Further sustained ticks do not re-toast.
|
||||
- On a brain **error**, leave the prior line intact and log — never fabricate
|
||||
drift (same discipline as `Nudge`).
|
||||
7. Update `lastEvalTitles` to 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`):** `Render` gains the current ambient line
|
||||
(passed in by the `Writer`, which reads `sentinel.Line`). When the envelope is
|
||||
idle (`ActiveMode == ""`): a non-empty line renders `⚠ <line>`; an empty line
|
||||
renders `idle` as today. Non-idle envelopes are unchanged. The `Writer` adds an
|
||||
`ambientLine func() string` source and `sentinel.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 from `sentinel.Line`), pushed over the
|
||||
existing SSE channel via `sentinel.AddOnChange(broadcast)`. `app.js` renders 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`. Default
|
||||
`notify`. Validated in the settings applier (like `ai_backend`); an unknown
|
||||
value is rejected with a wrapped sentinel so POST `/settings` can map it to 400.
|
||||
- `ambient_cadence_secs` (`KEEL_AMBIENT_CADENCE_SECS`): integer seconds between
|
||||
evaluations. Default `300`. 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-send` binary / non-Linux:** nop notifier; status line and banner
|
||||
still work.
|
||||
- **Brain error / timeout:** `AmbientDrift` returns an error; the sentinel logs
|
||||
and leaves the prior line intact. Never fabricates drift.
|
||||
- **AW down:** `memory` is already the nop store; `ambient_nudge` writes are
|
||||
dropped silently.
|
||||
- **`knowledge` / `tasks` errors:** `frame.Assemble` degrades to a partial brief
|
||||
(best-effort), never errors.
|
||||
- **Headless / no X11:** the evidence source is a no-op, so `OnWindow` is 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, fake `ai` with scriptable `AmbientDrift`
|
||||
returns and a call counter, fake `notify.Notifier` recording calls, fake
|
||||
`tasks`/`knowledge`, `activeMode` stub, `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_nudge` event.
|
||||
- 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 `ai` call count does
|
||||
**not** increase.
|
||||
- **`internal/notify`:** `NewNotifier` with `notify-send` absent → nop; `Notify`
|
||||
returns 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`:** `Assemble` with 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 includes `ambient`; `POST /ambient/snooze`
|
||||
routes to `Snooze` and returns 204.
|
||||
- **`internal/settings`:** `ambient_mode` validation (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_nudge` history
|
||||
(§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.
|
||||
Reference in New Issue
Block a user