feat(memory): M4 记忆完善(三类作用域访问规则+项目索引分层+LLM异步提取+衰减溯源+向量接入,兼容既有57用例)

This commit is contained in:
Aska committed 2026-09-06 06:15:18 +08:00
1 parent 5b37f5a551
commit d947ec41a9
10 files changed
+1349 -53

No files matched your search

+43 -23
View File
@@ -4,23 +4,27 @@
// 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).
// with persona/role isolation (design §0 layer ②, §4), applies the
// three-scope access rules (design §3) via AddScoped/ListScope/ListProject/
// DeleteScoped, and offers Add/Get/ListByPersona/Delete plus a manual
// "记住" path (design §1). Each item may carry a project_id linking the
// project-index hierarchy.
// - WorkingMemory: a per-session sliding window of messages plus a summary
// (design §0 layer ①).
// - Retriever: relevance + recency retrieval and context injection
// (design §2).
// - Retriever: relevance (keyword + optional semantic) × decay retrieval with
// source provenance and context injection (design §2). It accepts an optional
// vector.Index for the persistent semantic path and a projectindex.Store so
// project-scoped hits surface their linked project entry.
//
// 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.
// Extraction (design §1) is exposed through the Extractor interface with two
// implementations: ManualExtractor (rule-based, records an explicit "记住 X"
// directive) and LLMExtractor (drives a model.Adapter to denoise and deduplicate
// a turn, running synchronously via Extract or asynchronously via ExtractAsync).
//
// 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.
// Milestone M4 is implemented here: the schema carries decay_weight and
// project_id, the retriever plugs an optional embedding.Embedder / vector.Index
// and a project-index store, and the store enforces the user/persona/project
// scope rules. No external dependency is needed beyond the existing packages.
package memory
import (
@@ -35,6 +39,8 @@ import (
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Scope classifies who a long-term memory belongs to. It is orthogonal to Kind
@@ -89,6 +95,7 @@ type MemoryItem struct {
ID string
PersonaID string
RoleID string
ProjectID string
Scope Scope
Kind Kind
Content string
@@ -156,7 +163,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if db == nil {
return nil, ErrNilDB
}
log = discardLogger(log)
log = logger.WithSource(discardLogger(log), "memory")
for _, ddl := range []string{
createMemoryItemsTable,
createMemoryItemsPersonaIndex,
@@ -166,6 +173,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
return nil, fmt.Errorf("memory: apply schema: %w", err)
}
}
if err := ensureProjectIDColumn(ctx, db); err != nil {
return nil, err
}
return &Store{db: db, log: log}, nil
}
@@ -186,10 +196,10 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
}
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
(id, persona_id, role_id, scope, kind, content, tags, confidence,
(id, persona_id, role_id, project_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),
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, 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 {
@@ -204,7 +214,7 @@ 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,
row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE id = ?`, id)
item, err := scanItem(row)
@@ -219,30 +229,39 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
// 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).
// returned, so personas never share memory (design §4). Use ListScope when a
// caller needs to see shared user/project scopes or another persona's read-only
// items.
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,
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_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)
}
return scanItems(rows, "persona item")
}
// scanItems reads every row into a MemoryItem slice, closing rows and wrapping
// scan/iterate failures with the operation name. A non-nil, possibly empty slice
// is always returned on success.
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
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)
return nil, fmt.Errorf("memory: scan %s: %w", op, err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate persona items: %w", err)
return nil, fmt.Errorf("memory: iterate %s: %w", op, err)
}
return items, nil
}
@@ -352,7 +371,7 @@ func scanItem(sc rowScanner) (MemoryItem, error) {
tagsJSON string
createdAt, updatedAt string
)
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &scope, &kind,
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
&createdAt, &updatedAt, &it.DecayWeight); err != nil {
return MemoryItem{}, err
@@ -423,6 +442,7 @@ CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
persona_id TEXT NOT NULL,
role_id TEXT NOT NULL DEFAULT '',
project_id TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL,
kind TEXT NOT NULL,
content TEXT NOT NULL,