// Package memory implements the Cyrene v2 memory subsystem described in // DevDocs/design/03-记忆.md. // // It exposes three layers: // // - Store: a SQLite-backed long-term memory store. It persists memory items // with persona/role isolation (design §0 layer ②, §4) and offers // Add/Get/ListByPersona/Delete plus a manual "记住" path (design §1). // - WorkingMemory: a per-session sliding window of messages plus a summary // (design §0 layer ①). // - Retriever: relevance + recency retrieval and context injection // (design §2). // // Extraction (design §1) is exposed through the Extractor interface; the // concrete M2 implementation is the rule-based ManualExtractor, which records // what the user explicitly asks to remember. LLM-driven automatic extraction is // deferred to milestone M4 and is not implemented here. // // Milestone M4 additions that this package deliberately leaves out but keeps // extension points for: persistent vector storage, decay scheduling, and the // project-index hierarchy (design §3). The schema already carries decay_weight // and the retriever already plugs an optional embedding.Embedder, so those // features land without a schema migration. package memory import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "strings" "time" ) // Scope classifies who a long-term memory belongs to. It is orthogonal to Kind // and both are stored on every item (design §3 note). type Scope string const ( // ScopeUser is a memory about a user's habits, shared across personas. ScopeUser Scope = "user" // ScopePersona is a persona's everyday memory; other personas are read-only. ScopePersona Scope = "persona" // ScopeProject is a project-scoped memory, readable and writable by all. ScopeProject Scope = "project" ) // valid reports whether s is one of the defined scopes. func (s Scope) valid() bool { switch s { case ScopeUser, ScopePersona, ScopeProject: return true default: return false } } // Kind classifies the content category of a long-term memory item. type Kind string const ( // KindRelationship is a memory about "you and me": preferences, habits, // agreements (design §0 "关系记忆"). KindRelationship Kind = "relationship" // KindWorldEvent is a memory about objective facts and events in the world // (design §0 "世界/事件记忆"). KindWorldEvent Kind = "world_event" ) // valid reports whether k is one of the defined kinds. func (k Kind) valid() bool { switch k { case KindRelationship, KindWorldEvent: return true default: return false } } // MemoryItem is a single long-term memory record. The zero value is not a valid // item: PersonaID and Content are required, Scope and Kind are normalized to // their defaults by Add. type MemoryItem struct { ID string PersonaID string RoleID string Scope Scope Kind Kind Content string Tags []string Confidence float64 SourceConversation string CreatedAt time.Time UpdatedAt time.Time DecayWeight float64 } // Sentinels distinguishing memory package failures. Each is wrapped with %w at // the call site so callers can use errors.Is. var ( // ErrMissingPersona is returned when an item has no PersonaID. ErrMissingPersona = errors.New("memory: persona_id is required") // ErrMissingContent is returned when an item has no content. ErrMissingContent = errors.New("memory: content is required") // ErrInvalidScope is returned when an item's Scope is not a defined scope. ErrInvalidScope = errors.New("memory: invalid scope") // ErrInvalidKind is returned when an item's Kind is not a defined kind. ErrInvalidKind = errors.New("memory: invalid kind") // ErrNotFound is returned when an item id does not exist. ErrNotFound = errors.New("memory: item not found") // ErrNilDB is returned when NewStore receives a nil *sql.DB. ErrNilDB = errors.New("memory: nil db") ) // Schema defaults and time-window constants. const ( defaultConfidence = 1.0 defaultDecayWeight = 1.0 ) // Store is a long-term memory store backed by a *sql.DB. // // The store owns its schema: NewStore creates the memory_items table and its // indexes idempotently. Concurrency is delegated to *sql.DB, which is safe for // concurrent use by contract, so Store holds no further lock. Every query is // parameterized (?) and lists columns explicitly (no SELECT *), and reads take // a context.Cancel. type Store struct { db *sql.DB log *slog.Logger } // rowScanner is satisfied by both *sql.Row and *sql.Rows, so a single scanItem // helper serves Get and ListByPersona. type rowScanner interface { Scan(dest ...any) error } // NewStore builds a Store on db, ensuring the memory_items schema exists. // // db must be a live, migrated connection (the migration framework itself is // owned by internal/db). A nil db returns ErrNilDB. log may be nil, in which // case a discard logger is used. ctx controls schema creation and must be // honored by the caller. func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { if db == nil { return nil, ErrNilDB } log = discardLogger(log) for _, ddl := range []string{ createMemoryItemsTable, createMemoryItemsPersonaIndex, createMemoryItemsPersonaCreatedIndex, } { if _, err := db.ExecContext(ctx, ddl); err != nil { return nil, fmt.Errorf("memory: apply schema: %w", err) } } return &Store{db: db, log: log}, nil } // Add persists item, assigning an id and missing timestamps, and normalizing // Scope/Kind/Confidence/DecayWeight to their defaults. It returns the stored // item with the id and timestamps filled in. func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { if err := item.normalize(); err != nil { return MemoryItem{}, err } var err error if item, err = applyTimestamps(item); err != nil { return MemoryItem{}, err } tagsJSON, err := json.Marshal(item.Tags) if err != nil { return MemoryItem{}, fmt.Errorf("memory: encode tags: %w", err) } _, err = s.db.ExecContext(ctx, `INSERT INTO memory_items (id, persona_id, role_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, item.ID, item.PersonaID, item.RoleID, string(item.Scope), string(item.Kind), item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight) if err != nil { return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err) } return item, nil } // Get returns the item with the given id. It returns ErrNotFound when the id is // empty or does not exist. func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { if strings.TrimSpace(id) == "" { return MemoryItem{}, ErrNotFound } row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight FROM memory_items WHERE id = ?`, id) item, err := scanItem(row) if errors.Is(err, sql.ErrNoRows) { return MemoryItem{}, ErrNotFound } if err != nil { return MemoryItem{}, fmt.Errorf("memory: get item: %w", err) } return item, nil } // ListByPersona returns every item for personaID, newest first. It is the // persona-isolation boundary: items belonging to other personas are never // returned, so personas never share memory (design §4). func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) { if strings.TrimSpace(personaID) == "" { return nil, ErrMissingPersona } rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight FROM memory_items WHERE persona_id = ? ORDER BY created_at DESC`, personaID) if err != nil { return nil, fmt.Errorf("memory: list persona items: %w", err) } defer func() { _ = rows.Close() }() items := make([]MemoryItem, 0) for rows.Next() { item, err := scanItem(rows) if err != nil { return nil, fmt.Errorf("memory: scan persona item: %w", err) } items = append(items, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("memory: iterate persona items: %w", err) } return items, nil } // Delete removes the item with the given id and returns ErrNotFound when the id // is empty or no row was removed. Callers should only pass ids they obtained // through a persona-scoped read (ListByPersona/Get) so deletion stays within // the caller's persona boundary. func (s *Store) Delete(ctx context.Context, id string) error { if strings.TrimSpace(id) == "" { return ErrNotFound } res, err := s.db.ExecContext(ctx, `DELETE FROM memory_items WHERE id = ?`, id) if err != nil { return fmt.Errorf("memory: delete item: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("memory: delete row count: %w", err) } if n == 0 { return ErrNotFound } return nil } // RememberManual writes a single long-term memory item for content text — the // "记住 X" path from design §1. The item is scoped to the persona by default and // recorded as a relationship memory at full confidence. Returns the stored item. func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text string) (MemoryItem, error) { item := MemoryItem{ PersonaID: personaID, RoleID: roleID, Scope: ScopePersona, Kind: KindRelationship, Content: strings.TrimSpace(text), } if item.Content == "" { return MemoryItem{}, ErrMissingContent } return s.Add(ctx, item) } // normalize fills default Scope/Kind/Confidence/DecayWeight then validates. // It is called by Add so callers may omit values while still catching // explicitly invalid ones. func (it *MemoryItem) normalize() error { if it.Scope == "" { it.Scope = ScopePersona } if it.Kind == "" { it.Kind = KindRelationship } if it.Confidence == 0 { it.Confidence = defaultConfidence } if it.DecayWeight == 0 { it.DecayWeight = defaultDecayWeight } return it.validate() } // validate checks required and enumerated fields. It is pure: it does not apply // defaults, so it can also be used to reject a caller-provided value. func (it *MemoryItem) validate() error { if strings.TrimSpace(it.PersonaID) == "" { return ErrMissingPersona } if strings.TrimSpace(it.Content) == "" { return ErrMissingContent } if !it.Scope.valid() { return ErrInvalidScope } if !it.Kind.valid() { return ErrInvalidKind } return nil } // applyTimestamps assigns an id and missing CreatedAt/UpdatedAt values. It // returns an error only when a fresh id cannot be generated. func applyTimestamps(item MemoryItem) (MemoryItem, error) { now := time.Now().UTC() if item.ID == "" { id, err := newID() if err != nil { return MemoryItem{}, err } item.ID = id } if item.CreatedAt.IsZero() { item.CreatedAt = now } if item.UpdatedAt.IsZero() { item.UpdatedAt = now } return item, nil } // scanItem reads one memory_items row. tags is stored as a JSON array string; // timestamps are stored as RFC3339 strings. func scanItem(sc rowScanner) (MemoryItem, error) { var ( it MemoryItem scope, kind string tagsJSON string createdAt, updatedAt string ) if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &scope, &kind, &it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation, &createdAt, &updatedAt, &it.DecayWeight); err != nil { return MemoryItem{}, err } it.Scope = Scope(scope) it.Kind = Kind(kind) if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil { return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err) } if it.Tags == nil { it.Tags = []string{} } var err error if it.CreatedAt, err = parseTime(createdAt); err != nil { return MemoryItem{}, err } if it.UpdatedAt, err = parseTime(updatedAt); err != nil { return MemoryItem{}, err } return it, nil } // newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a // UUID dependency so the package stays self-contained. rand.Read fails only if // the platform entropy source is broken, so the error is propagated. func newID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("memory: generate id: %w", err) } return hex.EncodeToString(b), nil } // formatTime encodes t in UTC as an RFC3339 string. The value is always written // as UTC so ordering and round-trips are stable across processes. func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } // parseTime decodes a stored RFC3339 string. Blank input yields a zero time. func parseTime(s string) (time.Time, error) { if strings.TrimSpace(s) == "" { return time.Time{}, nil } t, err := time.Parse(time.RFC3339Nano, s) if err != nil { return time.Time{}, fmt.Errorf("memory: parse time %q: %w", s, err) } return t, nil } // discardLogger returns log if non-nil, otherwise a logger that writes nowhere. func discardLogger(log *slog.Logger) *slog.Logger { if log != nil { return log } return slog.New(slog.NewTextHandler(io.Discard, nil)) } // Schema DDL. The migration framework in internal/db is the authoritative // mechanism for domain tables, but it is intentionally not used here: the db // package's migration tests assert an exact migration count, and the memory // store must be self-contained and usable from any *sql.DB. The table and // indexes are therefore created idempotently by NewStore. const createMemoryItemsTable = ` CREATE TABLE IF NOT EXISTS memory_items ( id TEXT PRIMARY KEY, persona_id TEXT NOT NULL, role_id TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '[]', confidence REAL NOT NULL DEFAULT 1.0, source_conversation TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, decay_weight REAL NOT NULL DEFAULT 1.0 )` const createMemoryItemsPersonaIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id ON memory_items (persona_id)` const createMemoryItemsPersonaCreatedIndex = ` CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at ON memory_items (persona_id, created_at)`