Files
antidrift/docs/superpowers/specs/2026-06-05-offscreen-memory-design.md
felixm 730ffe33ce docs: design off-screen AW-backed memory (first durable-memory slice)
Brainstormed design for Keel's first cross-run memory: a thin memory.Store
port backed by an internal/aw REST client writing to the keel.events bucket,
with off-screen as the single wired consumer (records proposal_made /
action_taken / proposal_dismissed and reads its recent history back into the
brief for fresher, follow-up-aware proposals). Brain-only, best-effort,
degrades to nop when AW is down. Realizes keel-architecture.md §5/§7 at the
smallest honest vertical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:08:41 -04:00

8.8 KiB

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.

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

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.
  • nopStoreRecord/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 startupEnsureBucket fails → log once, use nopStore. Off-screen works, unremembering.
  • AW down mid-runRecord/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/memoryawStore 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.