// 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" "keel/internal/aw" ) // 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...) } // 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) { if n <= 0 { return nil, nil } 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 { data := make(map[string]any, len(ev.Data)) for dk, dv := range ev.Data { if dk != kindKey { data[dk] = dv } } out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data}) } } return out, nil }