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 (rowid breaks created_at ties so items // written within the same clock tick keep a stable newest-first order): // // - 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, rowid 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, with // rowid breaking created_at ties so same-tick writes keep a stable order. 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, rowid 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) }