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>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user