主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增 retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤 一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。 结构(迁移 0026 + 自举前滚): - memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型, 历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过 (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。 - NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。 判定(retention.go,全仓唯一入口): - RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。 - resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源 不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。 - tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔 INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。 合并(defer ⇔ temp): - judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此 全仓唯一(源码级断言钉住)。 - 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。 - 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。 - IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。 读侧: - 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。 - 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位 过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。 原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。 - store.go 的读路径拆到 store_list.go,守 500 行上限。 回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
198 lines
8.4 KiB
Go
198 lines
8.4 KiB
Go
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 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 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)
|
||
}
|