Files
antidrift/docs/superpowers/specs/2026-06-05-offscreen-memory-design.md
T
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

201 lines
8.8 KiB
Markdown

# 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.