feat(memory): 长记忆 store(SQLite+persona 隔离)/工作记忆滑动窗口/检索注入/手工提取钩子
This commit is contained in:
1 parent
285e1e9911
commit
aa697c385f
10 files changed
+1705
No files matched your search
@@ -0,0 +1,443 @@
|
||||
// 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)`
|
||||
Reference in new issue
Block a user