730ffe33ce
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>
201 lines
8.8 KiB
Markdown
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.
|