# Off-screen Memory — AW-backed Recall — Design **Date:** 2026-06-05 **Status:** Approved, ready for implementation planning **Scope:** Give Keel its first durable, cross-run memory by wiring **one** mode (off-screen) to a real ActivityWatch bucket. Off-screen records its own decisions (`proposal_made` / `action_taken` / `proposal_dismissed`) to `keel.events` and reads its recent history back into the next brief, so proposals stay fresh and follow up on what was not acted on. The memory layer is introduced as a thin reusable port (`memory.Store`) behind which AW, HTTP, and bucket names are sealed — the seam every later mode reuses. This realizes the "Next increment: memory" note in `docs/keel-architecture.md` §5 and the storage design in §7, at the smallest honest vertical. ## Problem Keel has no durable memory. Mode state dies in `~/.keel/.../state.json`; the off-screen mode cannot remember what it proposed last time, so it can repeat itself and can never follow up ("you said you'd walk before the call — did you?"). The architecture (§7) resolves storage to **ActivityWatch buckets** (no new DB), because AW is an append-only, timestamped, queryable event store — the substrate that later unlocks trend/spiral detection no single tool can do. Today, zero AW client code exists in the repo and no `keel.*` bucket exists. This design builds the first real slice of that memory, and only that slice. ## Decisions (locked in brainstorming 2026-06-05) 1. **Slice width = thin port + one consumer.** Add a generic `memory.Store` port to `harness.Services` (write an event, query recent), backed by AW, but wire exactly one real behavior: off-screen remembers. Focus and other event kinds come later. 2. **Behavior = fresh + follow-up.** The brief includes recent proposals *and* their outcome (confirmed / dismissed / unactioned). The brain is told to avoid blindly repeating and to follow up on a recent unactioned/dismissed one when it still fits. 3. **Brain-only (no UI this slice).** Memory shapes the proposal; nothing new is rendered. A "recently proposed" UI panel is a trivial later follow-up once the data is flowing. 4. **AW from the start, behind a port.** Not the existing JSON `store` package — §3/§7 lock storage to AW precisely for queryability. Modes never touch HTTP. ## Architecture Two new leaf packages, matching the existing ports-and-adapters style (`evidence.Source`, `knowledge.Source`, `tasks.Provider`, `ai.Backend`, `enforce.Guard`): ### `internal/aw` — thin AW REST client No Keel concepts; just buckets and events. Grounded in the live API (`/api/0`, AW server v0.13.2): events are `{id, timestamp, duration, data{}}`, and `GET .../events?limit=N` returns newest-first. ```go type Event struct { Timestamp time.Time Duration float64 Data map[string]any } type Client struct { /* baseURL, *http.Client with timeout */ } func New(baseURL string) *Client func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error // idempotent func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error // POST .../events func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) // GET .../events?limit=N ``` - `EnsureBucket` is idempotent: creating an existing bucket is treated as success (AW returns a benign "already exists" response). - `Insert` posts a single event (`duration: 0` for discrete derived events). - Any transport/HTTP-status error is returned; callers treat memory as best-effort. ### `internal/memory` — Keel-facing port + adapters ```go type Event struct { Kind string // "proposal_made", "action_taken", "proposal_dismissed" At time.Time Data map[string]any } type Store interface { Record(ctx context.Context, e Event) error Recent(ctx context.Context, kind string, n int) ([]Event, error) } ``` - `awStore` implements `Store` over `*aw.Client`, bucket id `keel.events`. It maps `memory.Event ↔ aw.Event`, carrying `Kind` inside `data._kind` and the rest of `Data` alongside. Because `keel.events` interleaves all kinds, `Recent(kind, n)` **over-fetches** a bounded window of recent bucket events (a fixed cap, e.g. 200), filters by `_kind` client-side, and returns up to `n` newest. Events older than that window are not seen — acceptable for "recent", and revisited if/when the event volume grows. - `nopStore` — `Record`/`Recent` no-ops; used when AW is unreachable/disabled. - `fake` — in-memory `Store` for tests (records appended in memory, `Recent` filters and returns newest-first). ### Wiring - Add `Memory memory.Store` to `harness.Services`. - `cmd/keeld.buildServices` constructs the AW-backed store: base URL from a new `KEEL_AW_URL` setting (default `http://localhost:5600`), then `EnsureBucket("keel.events", "keel.event", "keel")` **once at startup**. If that fails, log once and fall back to `nopStore` so a missing/down AW never breaks boot. - `offscreen.New` / `offscreen.Factory` receive `svc.Memory`. Modes depend only on the `memory.Store` interface; AW, HTTP, and bucket names stay sealed inside the adapter — same shape as every other port. ## Data model `keel.events` is an append-only log of Keel's **derived** events. This slice writes three kinds, correlated by a `proposal_id` (short random hex minted when a proposal is made; `crypto/rand`, in the daemon, not the sandbox): | Kind | When | `data` | |---|---|---| | `proposal_made` | brain returns a proposal | `{proposal_id, mode:"offscreen", next_action, rationale}` | | `action_taken` | confirm succeeds | `{proposal_id, mode:"offscreen", next_action}` | | `proposal_dismissed` | dismiss | `{proposal_id, mode:"offscreen"}` | **Reference, don't duplicate (§7):** `next_action` is Keel's *own* output, not another tool's source of truth — recording it logs Keel's decision, it does not duplicate Marvin. The Marvin task id is *not* captured because `tasks.Provider.Create` returns only `error`; surfacing the real id is a future enhancement gated on that signature and is out of scope here. ## Data flow ### Write path (all best-effort) A memory write failure is logged and swallowed; it never fails the command. - Proposal produced (async `onComplete`, status → `proposed`): mint a `proposal_id`, stash it on the mode, `Record(proposal_made)`. - `confirm()`: after `tasks.Create` succeeds, `Record(action_taken)`. - `dismiss()`: `Record(proposal_dismissed)`. ### Read path `offscreen.assembleBrief()` gains a `## Recently proposed` section: 1. `Recent("proposal_made", 5)` (newest-first); drop entries older than ~7 days. 2. For each, resolve outcome by matching `proposal_id` against recent `action_taken` / `proposal_dismissed` → label `confirmed` / `dismissed` / `unactioned`, with a relative age. 3. Render lines, e.g. `- "Walk before the call" (confirmed, 2h ago)`. 4. `buildProposePrompt` gains a rule: *don't repeat these; if a recent one was dismissed or unactioned and still fits, follow up on it instead of inventing new.* If `Memory` is nil or `Recent` errors, the section is omitted — the proposal still happens, ungrounded by history. ## Error handling / degradation Memory is never on the critical path; it degrades exactly like the `knowledge` and `tasks` ports already do in this mode. - AW down at **startup** → `EnsureBucket` fails → log once, use `nopStore`. Off-screen works, unremembering. - AW down **mid-run** → `Record`/`Recent` error → logged, swallowed. Propose / confirm / dismiss all still succeed. ## Testing - `internal/aw` — against an `httptest` server: asserts the POST event body and the `GET ?limit=N` round-trip; an error status surfaces as an error. - `internal/memory` — `awStore` against `httptest` (the `_kind` mapping; `Recent` filters by kind); `nopStore` trivial; the `fake` exercised directly. - `internal/mode/offscreen` — using the `fake` store: (a) `proposal_made` written on propose, (b) `action_taken` on confirm, (c) `proposal_dismissed` on dismiss, (d) brief contains recent proposals with outcome labels, (e) everything still works with a nil/erroring store. Existing offscreen tests get the `fake` (or nil) injected — no behavior regressions. ## Out of scope (named to prevent scope creep) - No `keel.state` bucket — only `keel.events`. - No focus-mode events; no `mode_started` / `feedback_given` / `spiral_flagged` / `coach_line`. - No UI rendering of history (brain-only). - No spiral/trend detection — the *reason* for AW, but a later slice that reads this data. - No cross-device AW sync, no Marvin-id capture, no assembler privacy-filter rework. ## What ships Off-screen mode that writes its decisions to a real AW bucket and reads its own history back into the next proposal — fresher proposals that follow up on what was not done. The first durable-memory vertical, end to end, and the seam every later mode reuses.