Files
antidrift/docs/superpowers/plans/2026-06-05-offscreen-memory.md
T
felixm 3386405c7c docs: implementation plan for off-screen AW-backed memory
Nine TDD tasks: internal/aw REST client (EnsureBucket/Insert/Recent),
internal/memory port (Store + awStore over-fetch filter + nop + fake),
KEEL_AW_URL setting, harness.Services.Memory wiring with nop fallback,
off-screen write path (proposal_made/action_taken/proposal_dismissed) and
read path (## Recently proposed + follow-up prompt rule), plus full and
live verification. Derived from the 2026-06-05 design spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:16:47 -04:00

1255 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Off-screen AW-backed Memory — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give Keel its first durable cross-run memory by adding an AW REST client and a `memory.Store` port, then wiring the off-screen mode to record its decisions to the `keel.events` bucket and read its recent history back into the next proposal.
**Architecture:** Two new leaf packages — `internal/aw` (thin ActivityWatch REST client: buckets + events) and `internal/memory` (the `Store` port with an AW-backed adapter, a nop, and a test fake). `harness.Services` gains a `Memory` field; `cmd/keeld` constructs it (bucket ensured at startup, nop fallback if AW is down). Off-screen writes `proposal_made` / `action_taken` / `proposal_dismissed` and reads them back, all best-effort.
**Tech Stack:** Go 1.26, `net/http` + `encoding/json` (AW REST at `http://localhost:5600/api/0`), `net/http/httptest` for client tests, existing ports-and-adapters style.
**Spec:** `docs/superpowers/specs/2026-06-05-offscreen-memory-design.md`
---
## File structure
- `internal/aw/aw.go` (create) — `Event`, `Client`, `New`, `EnsureBucket`, `Insert`, `Recent`. No Keel concepts.
- `internal/aw/aw_test.go` (create) — httptest-backed client tests.
- `internal/memory/memory.go` (create) — `Event`, `Store`, `AWClient`, `awStore`, `nopStore`, `Fake`, constructors.
- `internal/memory/memory_test.go` (create) — `Fake`, `nopStore`, `awStore` mapping/over-fetch tests.
- `internal/settings/settings.go` (modify) — add `AWURL` field + seed.
- `internal/settings/settings_test.go` (modify) — seed test.
- `internal/harness/services.go` (modify) — add `Memory memory.Store`.
- `cmd/keeld/main.go` (modify) — construct the store inside `buildServices`.
- `internal/mode/offscreen/offscreen.go` (modify) — mem/now/proposalID fields, write path, event constants, helpers.
- `internal/mode/offscreen/collect.go` (modify) — `briefHistory`, `relativeAge`, brief section.
- `internal/mode/offscreen/offscreen_test.go` (modify) — write-path + read-path tests.
- `internal/ai/proposer.go` (modify) — add the follow-up rule to the propose prompt.
- `internal/ai/proposer_test.go` (modify) — assert the rule is present.
Event-kind names (`proposal_made`, …) live in the **offscreen** package — `memory` stays a generic event store.
---
## Task 1: `internal/aw` — Client + EnsureBucket
**Files:**
- Create: `internal/aw/aw.go`
- Test: `internal/aw/aw_test.go`
- [ ] **Step 1: Write the failing test**
```go
package aw
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestEnsureBucketPostsCreate(t *testing.T) {
var gotPath string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("EnsureBucket: %v", err)
}
if gotPath != "/api/0/buckets/keel.events" {
t.Fatalf("path = %q", gotPath)
}
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
t.Fatalf("body = %+v", gotBody)
}
}
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("existing bucket should be OK, got %v", err)
}
}
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
t.Fatal("expected error on 500")
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/aw/`
Expected: FAIL — `undefined: New` / package has no Go files compiled.
- [ ] **Step 3: Write minimal implementation**
```go
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
// and writes events. It holds no Keel concepts, so it stays a leaf package.
package aw
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
// derived events it is 0.
type Event struct {
Timestamp time.Time
Duration float64
Data map[string]any
}
// Client talks to an aw-server over its /api/0 REST surface.
type Client struct {
baseURL string
http *http.Client
}
// New builds a client for the given base URL (e.g. http://localhost:5600).
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 5 * time.Second},
}
}
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
// response (304) is success.
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
body, _ := json.Marshal(map[string]string{
"client": client,
"type": eventType,
"hostname": hostname(),
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
return nil
default:
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
}
}
func hostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "keel"
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/aw/`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add internal/aw/aw.go internal/aw/aw_test.go
git commit -m "feat(aw): AW REST client with idempotent EnsureBucket"
```
---
## Task 2: `internal/aw` — Insert + Recent
**Files:**
- Modify: `internal/aw/aw.go`
- Test: `internal/aw/aw_test.go`
- [ ] **Step 1: Write the failing test** (append to `aw_test.go`)
```go
func TestInsertPostsEventArray(t *testing.T) {
var gotPath string
var gotEvents []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotEvents)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
t.Fatalf("Insert: %v", err)
}
if gotPath != "/api/0/buckets/keel.events/events" {
t.Fatalf("path = %q", gotPath)
}
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
t.Fatalf("events = %+v", gotEvents)
}
}
func TestRecentDecodesNewestFirst(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("limit = %q, want 5", got)
}
_, _ = io.WriteString(w, `[
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
]`)
}))
defer srv.Close()
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
t.Fatalf("got = %+v", got)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/aw/ -run 'Insert|Recent'`
Expected: FAIL — `c.Insert undefined` / `c.Recent undefined`.
- [ ] **Step 3: Write minimal implementation** (append to `aw.go`)
```go
type eventJSON struct {
Timestamp time.Time `json:"timestamp"`
Duration float64 `json:"duration"`
Data map[string]any `json:"data"`
}
// Insert posts a single event to the bucket. The AW events endpoint takes a
// JSON array, so the event is wrapped in a one-element list.
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
}
return nil
}
// Recent returns up to limit events from the bucket, newest first (the AW
// default ordering for the events endpoint).
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
url := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
}
var raw []eventJSON
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
out := make([]Event, len(raw))
for i, e := range raw {
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
}
return out, nil
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/aw/`
Expected: PASS (all four tests).
- [ ] **Step 5: Commit**
```bash
git add internal/aw/aw.go internal/aw/aw_test.go
git commit -m "feat(aw): Insert and Recent over the events endpoint"
```
---
## Task 3: `internal/memory` — Store, nopStore, Fake
**Files:**
- Create: `internal/memory/memory.go`
- Test: `internal/memory/memory_test.go`
- [ ] **Step 1: Write the failing test**
```go
package memory
import (
"context"
"testing"
"time"
)
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
f := NewFake()
ctx := context.Background()
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
got, err := f.Recent(ctx, "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["n"] != "b" {
t.Fatalf("want newest-first [b,a], got %+v", got)
}
}
func TestFakeRecentRespectsLimit(t *testing.T) {
f := NewFake()
ctx := context.Background()
for i := 0; i < 4; i++ {
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
}
got, _ := f.Recent(ctx, "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("limit not honored: %d", len(got))
}
}
func TestNopStoreIsInert(t *testing.T) {
s := NewNop()
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
t.Fatalf("nop Record: %v", err)
}
got, err := s.Recent(context.Background(), "x", 5)
if err != nil || len(got) != 0 {
t.Fatalf("nop Recent = %+v, %v", got, err)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/memory/`
Expected: FAIL — `undefined: NewFake` / `NewNop`.
- [ ] **Step 3: Write minimal implementation**
```go
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
// events and read them back with Recent; the AW-backed adapter persists them to
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
// them — this package stays a generic event store.
package memory
import (
"context"
"sync"
"time"
)
// Event is one derived event Keel chooses to remember. Kind is the event type
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
// other tools' source of truth.
type Event struct {
Kind string
At time.Time
Data map[string]any
}
// Store records derived events and reads recent ones back by kind.
type Store interface {
Record(ctx context.Context, e Event) error
Recent(ctx context.Context, kind string, n int) ([]Event, error)
}
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
type nopStore struct{}
// NewNop returns a Store that drops writes and returns no history.
func NewNop() Store { return nopStore{} }
func (nopStore) Record(context.Context, Event) error { return nil }
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
// Fake is an in-memory Store for tests.
type Fake struct {
mu sync.Mutex
events []Event
}
// NewFake returns an empty in-memory Store.
func NewFake() *Fake { return &Fake{} }
func (f *Fake) Record(_ context.Context, e Event) error {
f.mu.Lock()
f.events = append(f.events, e)
f.mu.Unlock()
return nil
}
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Event
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
if f.events[i].Kind == kind {
out = append(out, f.events[i])
}
}
return out, nil
}
// Events returns a snapshot of everything recorded, for test assertions.
func (f *Fake) Events() []Event {
f.mu.Lock()
defer f.mu.Unlock()
return append([]Event(nil), f.events...)
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/memory/`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add internal/memory/memory.go internal/memory/memory_test.go
git commit -m "feat(memory): Store port with nop and fake adapters"
```
---
## Task 4: `internal/memory` — AW-backed Store
**Files:**
- Modify: `internal/memory/memory.go`
- Test: `internal/memory/memory_test.go`
- [ ] **Step 1: Write the failing test** (append to `memory_test.go`)
```go
import "keel/internal/aw" // add to the existing import block
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
// recent list so we can test mapping and over-fetch filtering without HTTP.
type fakeAWClient struct {
inserted []aw.Event
recent []aw.Event // newest-first, mixed kinds
lastLimit int
}
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
f.inserted = append(f.inserted, e)
return nil
}
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
f.lastLimit = limit
return f.recent, nil
}
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
fc := &fakeAWClient{}
s := NewAWStore(fc, "keel.events")
at := time.Unix(1000, 0).UTC()
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
t.Fatalf("Record: %v", err)
}
if len(fc.inserted) != 1 {
t.Fatalf("inserted %d", len(fc.inserted))
}
got := fc.inserted[0]
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
t.Fatalf("mapped event = %+v", got)
}
}
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
fc := &fakeAWClient{recent: []aw.Event{
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
}}
s := NewAWStore(fc, "keel.events")
got, err := s.Recent(context.Background(), "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if fc.lastLimit != overfetch {
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
}
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
t.Fatalf("filtered = %+v", got)
}
}
func TestAWStoreRecentCapsAtN(t *testing.T) {
var recent []aw.Event
for i := 0; i < 5; i++ {
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
}
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
got, _ := s.Recent(context.Background(), "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("cap not applied: %d", len(got))
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/memory/ -run AWStore`
Expected: FAIL — `undefined: NewAWStore` / `undefined: overfetch`.
- [ ] **Step 3: Write minimal implementation** (append to `memory.go`, add `"keel/internal/aw"` to imports)
```go
// overfetch bounds how many recent bucket events awStore pulls before filtering
// by kind, because keel.events interleaves all kinds. Events older than this
// window are not seen — acceptable for "recent".
const overfetch = 200
// kindKey is the data field carrying the Event.Kind inside an AW event.
const kindKey = "_kind"
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
type AWClient interface {
Insert(ctx context.Context, bucketID string, e aw.Event) error
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
}
type awStore struct {
c AWClient
bucketID string
}
// NewAWStore returns a Store that persists events to the given AW bucket.
func NewAWStore(c AWClient, bucketID string) Store {
return &awStore{c: c, bucketID: bucketID}
}
func (s *awStore) Record(ctx context.Context, e Event) error {
data := make(map[string]any, len(e.Data)+1)
for k, v := range e.Data {
data[k] = v
}
data[kindKey] = e.Kind
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
}
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
if err != nil {
return nil, err
}
var out []Event
for _, ev := range raw {
if len(out) >= n {
break
}
if k, _ := ev.Data[kindKey].(string); k == kind {
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: ev.Data})
}
}
return out, nil
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/memory/`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add internal/memory/memory.go internal/memory/memory_test.go
git commit -m "feat(memory): AW-backed Store with over-fetch kind filtering"
```
---
## Task 5: `settings` — KEEL_AW_URL
**Files:**
- Modify: `internal/settings/settings.go:21-25` (struct) and `:70-80` (SeedFromEnv)
- Test: `internal/settings/settings_test.go`
- [ ] **Step 1: Write the failing test** (append to `settings_test.go`)
```go
func TestSeedFromEnvAWURL(t *testing.T) {
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
t.Fatalf("AWURL = %q", got)
}
}
func TestSeedFromEnvAWURLDefault(t *testing.T) {
t.Setenv("KEEL_AW_URL", "")
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
t.Fatalf("default AWURL = %q", got)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/settings/ -run AWURL`
Expected: FAIL — `s.AWURL undefined`.
- [ ] **Step 3: Write minimal implementation**
In the `Settings` struct (after `KnowledgePath`), add:
```go
AWURL string `json:"aw_url"`
```
Update the struct doc comment to mention the new var:
```go
// Settings is the user-editable configuration. Field names mirror the env vars
// they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL.
```
In `SeedFromEnv`, before the `return`, add the default and field:
```go
awURL := os.Getenv("KEEL_AW_URL")
if awURL == "" {
awURL = "http://localhost:5600"
}
return Settings{
AIBackend: backend,
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
AWURL: awURL,
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/settings/`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add internal/settings/settings.go internal/settings/settings_test.go
git commit -m "feat(settings): KEEL_AW_URL with localhost default"
```
---
## Task 6: Wire `Memory` into Services and `cmd/keeld`
**Files:**
- Modify: `internal/harness/services.go:16-24`
- Modify: `cmd/keeld/main.go:16-28` (imports) and `:60-73` (buildServices)
This task is wiring; it is verified by `go build` plus a manual smoke against the running AW server (Task 9 re-checks end to end).
- [ ] **Step 1: Add the Services field**
In `internal/harness/services.go`, add the import `"keel/internal/memory"` and a field to `Services`:
```go
Memory memory.Store
```
Update the struct doc to note: `Memory is Keel's durable cross-run event store (AW-backed, or a nop when AW is down).`
- [ ] **Step 2: Construct the store in buildServices**
In `cmd/keeld/main.go`, add imports `"keel/internal/aw"` and `"keel/internal/memory"`. Replace the body of `buildServices` so it builds memory after the AI backend validates:
```go
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
awURL := s.AWURL
if awURL == "" {
awURL = "http://localhost:5600"
}
var mem memory.Store = memory.NewNop()
awc := aw.New(awURL)
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
} else {
mem = memory.NewAWStore(awc, "keel.events")
log.Printf("memory: keel.events ready at %s", awURL)
}
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Memory: mem,
Clock: time.Now,
}, nil
}
```
- [ ] **Step 3: Build and vet**
Run: `go build ./... && go vet ./...`
Expected: no output, exit 0.
- [ ] **Step 4: Smoke against the running AW server**
Run: `go run ./cmd/keeld` (Ctrl-C after it logs). Expected log line: `memory: keel.events ready at http://localhost:5600`.
Verify the bucket now exists:
Run: `curl -s http://localhost:5600/api/0/buckets/ | python3 -c "import sys,json; print('keel.events' in json.load(sys.stdin))"`
Expected: `True`
- [ ] **Step 5: Commit**
```bash
git add internal/harness/services.go cmd/keeld/main.go
git commit -m "feat(keeld): construct AW memory store, nop fallback when AW is down"
```
---
## Task 7: Off-screen write path
**Files:**
- Modify: `internal/mode/offscreen/offscreen.go`
- Test: `internal/mode/offscreen/offscreen_test.go`
- [ ] **Step 1: Write the failing test** (append to `offscreen_test.go`; add `"keel/internal/memory"` to imports)
```go
// newTestModeMem is newTestMode with an injected in-memory Store.
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Memory: mem,
Clock: func() time.Time { return time.Unix(1000, 0) },
Notify: func() {},
}
return New(svc)
}
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, e := range f.Events() {
if e.Kind == kind {
return e
}
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
return memory.Event{}
}
func TestProposeRecordsProposalMade(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
ev := waitEvent(t, mem, "proposal_made")
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
t.Fatalf("proposal_made data = %+v", ev.Data)
}
}
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
made := waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
taken := waitEvent(t, mem, "action_taken")
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
}
}
func TestDismissRecordsProposalDismissed(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
_ = waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
_ = waitEvent(t, mem, "proposal_dismissed")
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/mode/offscreen/ -run 'Records|Dismiss'`
Expected: FAIL — `New` ignores `Memory`, so no events are ever recorded (waitEvent times out).
- [ ] **Step 3: Write minimal implementation** in `offscreen.go`
Add imports: `"crypto/rand"`, `"encoding/hex"`, `"log"`, `"keel/internal/memory"`.
Add event-kind constants near the existing status constants:
```go
const (
kindProposalMade = "proposal_made"
kindActionTaken = "action_taken"
kindProposalDismissed = "proposal_dismissed"
)
```
Add fields to the `Mode` struct (after `know`):
```go
mem memory.Store // may be nil; record helpers guard
now func() time.Time // event timestamps; never nil after New
```
and after `done`:
```go
proposalID string
```
In `New`, resolve the clock and wire memory:
```go
func New(svc harness.Services) *Mode {
now := svc.Clock
if now == nil {
now = time.Now
}
m := &Mode{
ai: svc.AI,
tasks: svc.Tasks,
know: svc.Knowledge,
mem: svc.Memory,
now: now,
status: statusPending,
}
m.async = harness.NewAsync(&m.mu, svc.Notify)
return m
}
```
In `start`, the apply closure's success branch records the proposal (off-lock, fire-and-forget):
```go
func() {
if err != nil {
m.status = statusError
m.errMsg = err.Error()
return
}
m.status = statusProposed
m.proposal = &p
m.proposalID = newProposalID()
m.recordAsync(memory.Event{
Kind: kindProposalMade,
At: m.now(),
Data: map[string]any{
"proposal_id": m.proposalID,
"mode": "offscreen",
"next_action": p.NextAction,
"rationale": p.Rationale,
},
})
})
```
In `confirm`, after `tasks.Create` succeeds, capture the id under the lock and record synchronously:
```go
func (m *Mode) confirm(ctx context.Context) error {
m.mu.Lock()
p := m.proposal
m.mu.Unlock()
if p == nil {
return errors.New("offscreen: nothing to confirm")
}
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
return err
}
m.mu.Lock()
id := m.proposalID
m.status = statusDone
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindActionTaken,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
})
return nil
}
```
Replace the `dismiss` case in `Command` to record:
```go
case "dismiss":
m.mu.Lock()
id := m.proposalID
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindProposalDismissed,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
})
return nil
```
Add the helpers at the end of the file:
```go
// recordAsync persists an event off the mode's lock, best-effort. Used on the
// propose path, which sets the event under the async apply lock.
func (m *Mode) recordAsync(ev memory.Event) {
if m.mem == nil {
return
}
go func() {
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}()
}
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
// which already do synchronous work.
func (m *Mode) recordSync(ev memory.Event) {
if m.mem == nil {
return
}
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}
// newProposalID returns a short random hex id correlating a proposal with its
// outcome event.
func newProposalID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "proposal"
}
return hex.EncodeToString(b[:])
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `go test ./internal/mode/offscreen/`
Expected: PASS — new tests plus the five existing tests (which inject no `Memory`, so the nil-guard keeps them green).
- [ ] **Step 5: Commit**
```bash
git add internal/mode/offscreen/offscreen.go internal/mode/offscreen/offscreen_test.go
git commit -m "feat(offscreen): record proposal_made/action_taken/proposal_dismissed"
```
---
## Task 8: Off-screen read path (brief history + prompt rule)
**Files:**
- Modify: `internal/ai/proposer.go` (prompt rule)
- Test: `internal/ai/proposer_test.go`
- Modify: `internal/mode/offscreen/collect.go` (briefHistory + relativeAge + section)
- Test: `internal/mode/offscreen/offscreen_test.go`
- [ ] **Step 1: Write the failing prompt test** (append to `proposer_test.go`)
```go
func TestProposePromptHasFollowUpRule(t *testing.T) {
p := buildProposePrompt("brief")
if !strings.Contains(p, "recently proposed") {
t.Fatalf("prompt missing follow-up rule:\n%s", p)
}
}
```
(If `proposer_test.go` does not already import `strings`, add it.)
- [ ] **Step 2: Run it to verify it fails**
Run: `go test ./internal/ai/ -run FollowUp`
Expected: FAIL — rule text absent.
- [ ] **Step 3: Add the rule** in `internal/ai/proposer.go`
In `buildProposePrompt`, add a bullet to the `Rules:` block (after the `rationale:` rule):
```go
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
```
- [ ] **Step 4: Run it to verify it passes**
Run: `go test ./internal/ai/`
Expected: PASS
- [ ] **Step 5: Write the failing brief test** (append to `offscreen_test.go`)
```go
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
mem := memory.NewFake()
ctx := context.Background()
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s"}})
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
brief := m.assembleBrief()
if !strings.Contains(brief, "Recently proposed") {
t.Fatalf("brief missing history section:\n%s", brief)
}
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
t.Fatalf("brief missing confirmed walk:\n%s", brief)
}
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
}
}
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
if strings.Contains(m.assembleBrief(), "Recently proposed") {
t.Fatal("history section should be absent without memory")
}
}
```
(Add `"strings"` to the offscreen test imports if not present.)
- [ ] **Step 6: Run it to verify it fails**
Run: `go test ./internal/mode/offscreen/ -run Brief`
Expected: FAIL — `assembleBrief` produces no history section.
- [ ] **Step 7: Implement briefHistory** in `internal/mode/offscreen/collect.go`
Add `"context"` is already imported; add `"fmt"` and `"time"` to imports. Add constants near the top:
```go
const (
recentProposals = 5 // how many recent proposals to show
outcomeScan = 50 // how many outcome events to scan for correlation
recentWindow = 7 * 24 * time.Hour
)
```
In `assembleBrief`, insert the history section after the tasks block (before goals):
```go
if hist := m.briefHistory(); hist != "" {
b.WriteString("## Recently proposed\n")
b.WriteString(hist)
b.WriteString("\n")
}
```
Add the methods:
```go
// briefHistory renders recent proposals with their outcome, newest first and
// within recentWindow. Returns "" when there is no memory or no recent history.
func (m *Mode) briefHistory() string {
if m.mem == nil {
return ""
}
ctx := context.Background()
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
if err != nil || len(proposals) == 0 {
return ""
}
taken := m.idSet(ctx, kindActionTaken)
dismissed := m.idSet(ctx, kindProposalDismissed)
now := m.now()
var b strings.Builder
for _, ev := range proposals {
if now.Sub(ev.At) > recentWindow {
continue
}
id, _ := ev.Data["proposal_id"].(string)
action, _ := ev.Data["next_action"].(string)
outcome := "unactioned"
if taken[id] {
outcome = "confirmed"
} else if dismissed[id] {
outcome = "dismissed"
}
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
}
return b.String()
}
// idSet reads recent events of a kind and returns their proposal_ids as a set.
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
out := map[string]bool{}
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
if err != nil {
return out
}
for _, e := range evs {
if id, _ := e.Data["proposal_id"].(string); id != "" {
out[id] = true
}
}
return out
}
// relativeAge renders a coarse human age for a duration.
func relativeAge(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
```
- [ ] **Step 8: Run it to verify it passes**
Run: `go test ./internal/mode/offscreen/`
Expected: PASS (history tests plus all earlier offscreen tests).
- [ ] **Step 9: Commit**
```bash
git add internal/ai/proposer.go internal/ai/proposer_test.go internal/mode/offscreen/collect.go internal/mode/offscreen/offscreen_test.go
git commit -m "feat(offscreen): read recent proposals into the brief; prompt follows up"
```
---
## Task 9: Full verification + live smoke
**Files:** none (verification only)
- [ ] **Step 1: Build, vet, and full test suite**
Run: `go build ./... && go vet ./... && go test ./...`
Expected: all packages `ok`, exit 0.
- [ ] **Step 2: Live end-to-end smoke (AW running)**
Start the daemon: `go run ./cmd/keeld`
In the web UI (`http://localhost:7777`), start the off-screen mode, wait for a proposal, and **confirm** it.
- [ ] **Step 3: Verify the event landed in AW**
Run: `curl -s 'http://localhost:5600/api/0/buckets/keel.events/events?limit=10' | python3 -m json.tool`
Expected: at least a `proposal_made` and an `action_taken` event, each with `data._kind`, `data.proposal_id`, and `data.mode == "offscreen"`, sharing the same `proposal_id`.
- [ ] **Step 4: Verify recall on the next run**
Stop and restart `go run ./cmd/keeld`, start off-screen again, and confirm the next proposal is informed by history (it should avoid repeating the just-confirmed action, or follow up on a dismissed one). This is a qualitative check that the brief now carries `## Recently proposed`.
- [ ] **Step 5: Final commit (if any verification fixups were needed)**
```bash
git add -A
git commit -m "test: verify off-screen AW memory end to end"
```
(If Steps 14 pass with no changes, skip this commit.)
---
## Done when
- `go build ./... && go vet ./... && go test ./...` is green.
- The `keel.events` bucket exists and receives `proposal_made` / `action_taken` / `proposal_dismissed` with correlated `proposal_id`s.
- Off-screen's brief carries a `## Recently proposed` section when history exists, and proposals visibly avoid repetition / follow up.
- AW being down degrades to `nopStore` with a log line, and every off-screen command still succeeds (covered by the nil-memory tests).