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
@@ -0,0 +1,148 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Scope access control sentinels. These surface when a persona attempts a write
|
||||
// it is not permitted to make under the design §3 rules.
|
||||
var (
|
||||
// ErrReadOnly is returned when a persona tries to write or delete a
|
||||
// persona-scope memory item it does not own. Under design §3 other personas
|
||||
// are read-only for 角色日常记忆.
|
||||
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
|
||||
|
||||
// ErrMissingProjectID is returned when a project-scoped query has a blank id.
|
||||
ErrMissingProjectID = errors.New("memory: project_id is required")
|
||||
)
|
||||
|
||||
// canWrite reports whether actorID may write item under the design §3 scope rules:
|
||||
//
|
||||
// - ScopeUser: any persona may write (user habits are globally shared).
|
||||
// - ScopePersona: only the owning persona may write (other personas read-only).
|
||||
// - ScopeProject: any persona may write (project memory is 人人可写).
|
||||
//
|
||||
// The caller must have already validated item.Scope; an invalid scope is not
|
||||
// writable by anyone.
|
||||
func canWrite(actorID string, it MemoryItem) bool {
|
||||
switch it.Scope {
|
||||
case ScopeUser, ScopeProject:
|
||||
return true
|
||||
case ScopePersona:
|
||||
return it.PersonaID == actorID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AddScoped persists item under actorID's authority, enforcing the design §3
|
||||
// write rules by persona_id + scope. A blank actorID returns ErrMissingPersona.
|
||||
// When item.PersonaID is blank it is filled with actorID, so a persona always
|
||||
// records its own provenance; a persona-scope item owned by a different persona
|
||||
// returns ErrReadOnly. User- and project-scope items are writable by any persona.
|
||||
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
|
||||
if strings.TrimSpace(actorID) == "" {
|
||||
return MemoryItem{}, ErrMissingPersona
|
||||
}
|
||||
if item.PersonaID == "" {
|
||||
item.PersonaID = actorID
|
||||
}
|
||||
if !item.Scope.valid() {
|
||||
return MemoryItem{}, ErrInvalidScope
|
||||
}
|
||||
if !canWrite(actorID, item) {
|
||||
return MemoryItem{}, ErrReadOnly
|
||||
}
|
||||
return s.Add(ctx, item)
|
||||
}
|
||||
|
||||
// ListScope returns every item of scope visible to personaID, newest first,
|
||||
// applying the design §3 read rules:
|
||||
//
|
||||
// - ScopeUser: every user-scope item (globally shared across personas).
|
||||
// - ScopePersona: every persona-scope item. Other personas are read-only but
|
||||
// still readable, so a persona can read another persona's 角色日常记忆 here.
|
||||
// - ScopeProject: every project-scope item (人人可读).
|
||||
//
|
||||
// personaID names the reading persona and is required so callers always declare
|
||||
// the identity boundary even for a shared scope. The higher policy layer still
|
||||
// decides whether a restricted context (group chat / stranger, design §5) may
|
||||
// receive these; this method only enforces the base read rule.
|
||||
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
}
|
||||
if !scope.valid() {
|
||||
return nil, ErrInvalidScope
|
||||
}
|
||||
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 scope = ?
|
||||
ORDER BY created_at DESC`, string(scope))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list scope items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "scope item")
|
||||
}
|
||||
|
||||
// ListProject returns every memory item linked to projectID, newest first. A
|
||||
// project-scoped memory carries a ProjectID that links to the projectindex
|
||||
// hierarchy (design §3); this is the deep-search entry point — callers combine
|
||||
// it with the projectindex store to walk 项目索引 → 项目描述 → 章节内容. Project
|
||||
// memory is readable by all personas, so no persona filter is applied. A blank
|
||||
// projectID returns ErrMissingProjectID.
|
||||
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, ErrMissingProjectID
|
||||
}
|
||||
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 project_id = ?
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list project items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "project item")
|
||||
}
|
||||
|
||||
// DeleteScoped removes the item with the given id, but only if actorID may write
|
||||
// it under the design §3 rules. This enforces the persona read-only boundary on
|
||||
// deletion: a persona may delete its own persona-scope items and any user- or
|
||||
// project-scope item, but not another persona's 角色日常记忆. A blank id returns
|
||||
// ErrNotFound; writing a foreign persona-scope item returns ErrReadOnly.
|
||||
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
|
||||
if strings.TrimSpace(actorID) == "" {
|
||||
return ErrMissingPersona
|
||||
}
|
||||
item, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory: delete scoped load: %w", err)
|
||||
}
|
||||
if !canWrite(actorID, item) {
|
||||
return ErrReadOnly
|
||||
}
|
||||
return s.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ensureProjectIDColumn rolls the memory_items table forward when it was created
|
||||
// before the M4 project index (design §3) existed: it adds the project_id column
|
||||
// if it is missing, and ignores the duplicate-column result when present. It is
|
||||
// SQLite-specific (the memory store is SQLite-backed) and idempotent, so NewStore
|
||||
// may be called on a fresh schema or a pre-existing one.
|
||||
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
|
||||
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// SQLite reports "duplicate column name: project_id" when the column already
|
||||
// exists (e.g. created by the CREATE TABLE in NewStore, or by a previous run).
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("memory: roll forward project_id column: %w", err)
|
||||
}
|
||||
Reference in new issue
Block a user