From 730ffe33cea1ea28161ed3bbfd449c63e5f611ca Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Fri, 5 Jun 2026 19:08:41 -0400 Subject: [PATCH] docs: design off-screen AW-backed memory (first durable-memory slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-06-05-offscreen-memory-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-offscreen-memory-design.md diff --git a/docs/superpowers/specs/2026-06-05-offscreen-memory-design.md b/docs/superpowers/specs/2026-06-05-offscreen-memory-design.md new file mode 100644 index 0000000..1da3a59 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-offscreen-memory-design.md @@ -0,0 +1,200 @@ +# 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.