149 lines
6.0 KiB
Go
149 lines
6.0 KiB
Go
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)
|
|
}
|