feat(memory): M4 记忆完善(三类作用域访问规则+项目索引分层+LLM异步提取+衰减溯源+向量接入,兼容既有57用例)
This commit is contained in:
1 parent
5b37f5a551
commit
d947ec41a9
10 files changed
+1349
-53
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user