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>
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)
- Slice width = thin port + one consumer. Add a generic
memory.Storeport toharness.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. - 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.
- 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.
- AW from the start, behind a port. Not the existing JSON
storepackage — §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
EnsureBucketis idempotent: creating an existing bucket is treated as success (AW returns a benign "already exists" response).Insertposts a single event (duration: 0for 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)
}
awStoreimplementsStoreover*aw.Client, bucket idkeel.events. It mapsmemory.Event ↔ aw.Event, carryingKindinsidedata._kindand the rest ofDataalongside. Becausekeel.eventsinterleaves all kinds,Recent(kind, n)over-fetches a bounded window of recent bucket events (a fixed cap, e.g. 200), filters by_kindclient-side, and returns up tonnewest. Events older than that window are not seen — acceptable for "recent", and revisited if/when the event volume grows.nopStore—Record/Recentno-ops; used when AW is unreachable/disabled.fake— in-memoryStorefor tests (records appended in memory,Recentfilters and returns newest-first).
Wiring
- Add
Memory memory.Storetoharness.Services. cmd/keeld.buildServicesconstructs the AW-backed store: base URL from a newKEEL_AW_URLsetting (defaulthttp://localhost:5600), thenEnsureBucket("keel.events", "keel.event", "keel")once at startup. If that fails, log once and fall back tonopStoreso a missing/down AW never breaks boot.offscreen.New/offscreen.Factoryreceivesvc.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 aproposal_id, stash it on the mode,Record(proposal_made). confirm(): aftertasks.Createsucceeds,Record(action_taken).dismiss():Record(proposal_dismissed).
Read path
offscreen.assembleBrief() gains a ## Recently proposed section:
Recent("proposal_made", 5)(newest-first); drop entries older than ~7 days.- For each, resolve outcome by matching
proposal_idagainst recentaction_taken/proposal_dismissed→ labelconfirmed/dismissed/unactioned, with a relative age. - Render lines, e.g.
- "Walk before the call" (confirmed, 2h ago). buildProposePromptgains 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 →
EnsureBucketfails → log once, usenopStore. Off-screen works, unremembering. - AW down mid-run →
Record/Recenterror → logged, swallowed. Propose / confirm / dismiss all still succeed.
Testing
internal/aw— against anhttptestserver: asserts the POST event body and theGET ?limit=Nround-trip; an error status surfaces as an error.internal/memory—awStoreagainsthttptest(the_kindmapping;Recentfilters by kind);nopStoretrivial; thefakeexercised directly.internal/mode/offscreen— using thefakestore: (a)proposal_madewritten on propose, (b)action_takenon confirm, (c)proposal_dismissedon dismiss, (d) brief contains recent proposals with outcome labels, (e) everything still works with a nil/erroring store. Existing offscreen tests get thefake(or nil) injected — no behavior regressions.
Out of scope (named to prevent scope creep)
- No
keel.statebucket — onlykeel.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.