# 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 1–4 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).