package memory import ( "context" "database/sql" "errors" "fmt" "strings" ) // 作用域访问控制哨兵错误。当角色试图执行 design §3 规则 // 不允许的写入时返回。 var ( // ErrReadOnly 在角色试图写入或删除 // 不属于它的角色作用域记忆条目时返回。按 design §3,其他角色 // 对角色日常记忆只读。 ErrReadOnly = errors.New("memory: read-only: persona does not own this item") // ErrMissingProjectID 在项目作用域查询的 id 为空时返回。 ErrMissingProjectID = errors.New("memory: project_id is required") ) // canWrite 报告 actorID 按 design §3 作用域规则是否可以写入 item: // // - ScopeUser:任何角色都可写(用户习惯全局共享)。 // - ScopePersona:只有归属角色可写(其他角色只读)。 // - ScopeProject:任何角色都可写(项目记忆人人可写)。 // // 调用方必须已校验 item.Scope;非法作用域对任何人 // 都不可写。 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 以 actorID 的权限持久化 item,按 persona_id + scope 施加 // design §3 写规则。actorID 为空返回 ErrMissingPersona。 // item.PersonaID 为空时用 actorID 填充它,因此角色总是 // 记录自身的来源;归属其他角色的角色作用域条目 // 返回 ErrReadOnly。用户与项目作用域的条目任何角色都可写。 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 返回 personaID 可见的该作用域全部条目,最新的在前, // 施加 design §3 读规则(rowid 打破 created_at 并列,使 // 同一时钟刻内写入的条目保持稳定的最新优先顺序): // // - ScopeUser:全部用户作用域条目(跨角色全局共享)。 // - ScopePersona:全部角色作用域条目。其他角色只读但 // 仍可读,因此角色在这里可以读到另一个角色的角色日常记忆。 // - ScopeProject:全部项目作用域条目(人人可读)。 // // personaID 指明读取方角色,且必填,以便调用方即使在共享 // 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5) // 接收这些内容仍由更高层策略决定;本方法只施加 // 基础读规则。 // // 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸** // (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona / // ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用 // 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。 // // `tiers` 的档位过滤见同族的 ListScopeTiers(签名不变的理由见 // store_list.go 的 ListByPersona 注释:网关接口钉着这批精确签名)。 func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) { return s.listScope(ctx, scope, personaID, nil) } // ListScopeTiers 是 ListScope 的**可选档位过滤**版本(空 tiers = 不过滤; // `RetentionTierUnset` = 只看尚未分型的行)。 func (s *Store) ListScopeTiers(ctx context.Context, scope Scope, personaID string, tiers ...RetentionTier) ([]MemoryItem, error) { return s.listScope(ctx, scope, personaID, tiers) } // listScope 是 ListScope / ListScopeTiers 的共同实现(tiers 为空不过滤)。 func (s *Store) listScope(ctx context.Context, scope Scope, personaID string, tiers []RetentionTier) ([]MemoryItem, error) { if strings.TrimSpace(personaID) == "" { return nil, ErrMissingPersona } if !scope.valid() { return nil, ErrInvalidScope } now := timeGateNow() query := `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight, retention_tier, sensitive FROM memory_items WHERE scope = ? AND status = ?` args := []any{string(scope), string(tidyStatusActive)} query, args, err := retentionTierList(query, args, tiers) if err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, query+timeGateClause+` ORDER BY created_at DESC, rowid DESC`, append(args, now, now)...) if err != nil { return nil, fmt.Errorf("memory: list scope items: %w", err) } return scanItems(rows, "scope item") } // ListProject 返回链接到 projectID 的全部记忆条目,最新的在前, // rowid 打破 created_at 并列,使同一刻的写入保持稳定顺序。项目 // 作用域记忆携带一个 ProjectID,链到 projectindex // 层级(design §3);这是深检索入口 —— 调用方把它与 // projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目 // 记忆所有角色可读,因此不施加角色过滤。projectID 为空 // 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过 // **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。 // // `tiers` 的档位过滤见同族的 ListProjectTiers —— 本方法虽然是深检索入口,但 // 「留多久」这根轴对项目记忆同样成立,因此一并支持。 func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) { return s.listProject(ctx, projectID, nil) } // ListProjectTiers 是 ListProject 的**可选档位过滤**版本(空 tiers = 不过滤)。 func (s *Store) ListProjectTiers(ctx context.Context, projectID string, tiers ...RetentionTier) ([]MemoryItem, error) { return s.listProject(ctx, projectID, tiers) } // listProject 是 ListProject / ListProjectTiers 的共同实现(tiers 为空不过滤)。 func (s *Store) listProject(ctx context.Context, projectID string, tiers []RetentionTier) ([]MemoryItem, error) { if strings.TrimSpace(projectID) == "" { return nil, ErrMissingProjectID } now := timeGateNow() query := `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight, retention_tier, sensitive FROM memory_items WHERE project_id = ? AND status = ?` args := []any{projectID, string(tidyStatusActive)} query, args, err := retentionTierList(query, args, tiers) if err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, query+timeGateClause+` ORDER BY created_at DESC, rowid DESC`, append(args, now, now)...) if err != nil { return nil, fmt.Errorf("memory: list project items: %w", err) } return scanItems(rows, "project item") } // DeleteScoped 删除给定 id 的条目,但仅当按 design §3 规则 actorID // 可以写它。这在删除上施加角色只读边界: // 角色可以删自己的角色作用域条目,以及任何用户或 // 项目作用域条目,但不能删另一个角色的角色日常记忆。id 为空返回 // ErrNotFound;改动他人的角色作用域条目返回 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 在 memory_items 表创建于 M4 项目索引 // (design §3)存在之前时把它前滚:缺失时补加 project_id 列, // 已存在时忽略重复列错误。它是 // SQLite 特有的(记忆存储基于 SQLite)且幂等,因此 NewStore // 可在全新 schema 或既有 schema 上调用。 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 } // 当该列已存在(例如由 NewStore 中的 CREATE TABLE 创建, // 或由上一次运行创建)时,SQLite 报 "duplicate column name: project_id"。 if strings.Contains(err.Error(), "duplicate column name") { return nil } return fmt.Errorf("memory: roll forward project_id column: %w", err) }