M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里 面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车 道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏 即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与 「这条能不能外说」(sensitive)拆成两根正交的轴。 - 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语 白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构; memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。 - 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为 restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具 自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。 - 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true, 口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用 `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。 - 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池 前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。 - 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go 守住 500 行上限。 验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红 (sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单); 生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
488 lines
22 KiB
Go
488 lines
22 KiB
Go
// memory 包实现 Cyrene v2 记忆子系统,规格见
|
||
// DevDocs/design/03-记忆.md。
|
||
//
|
||
// 它对外暴露三层:
|
||
//
|
||
// - Store:基于 SQLite 的长期记忆存储。它按角色隔离持久化记忆条目
|
||
// (design §0 layer ②、§4),经 AddScoped/ListScope/ListProject/
|
||
// DeleteScoped 施加三作用域访问规则(design §3),并提供
|
||
// Add/Get/ListByPersona/Delete,外加一条手动的
|
||
// 「记住」路径(design §1)。每条条目可携带 project_id,链到
|
||
// 项目索引层级。
|
||
// - WorkingMemory:每会话的消息滑动窗口加一份摘要
|
||
// (design §0 layer ①)。
|
||
// - Retriever:相关性(关键词 + 可选语义)× 衰减检索,带
|
||
// 溯源与上下文注入(design §2)。它可选接收
|
||
// vector.Index 作为持久化语义路径,以及 projectindex.Store,使
|
||
// 项目作用域的命中带出其链接的项目条目。
|
||
//
|
||
// 提取(design §1)经 Extractor 接口暴露,有两个
|
||
// 实现:ManualExtractor(规则式,记录一条显式的「记住 X」
|
||
// 指令)与 LLMExtractor(驱动 model.Adapter 对
|
||
// 一轮对话去噪去重,经 Extract 同步运行或经 ExtractAsync 异步运行)。
|
||
//
|
||
// M4 里程碑在此实现:schema 携带 decay_weight 与
|
||
// project_id,检索器接入可选的 embedding.Embedder / vector.Index
|
||
// 与项目索引存储,存储层施加 user/persona/project
|
||
// 作用域规则。除既有包外不需要任何外部依赖。
|
||
package memory
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||
)
|
||
|
||
// Scope 分类一条长期记忆归属于谁。它与 Kind 正交,
|
||
// 两者都存于每条条目上(design §3 注)。
|
||
type Scope string
|
||
|
||
const (
|
||
// ScopeUser 是关于用户习惯的记忆,跨角色共享。
|
||
ScopeUser Scope = "user"
|
||
// ScopePersona 是角色的日常记忆;其他角色只读。
|
||
ScopePersona Scope = "persona"
|
||
// ScopeProject 是项目作用域记忆,人人可读可写。
|
||
ScopeProject Scope = "project"
|
||
)
|
||
|
||
// valid 报告 s 是否为已定义的作用域之一。
|
||
func (s Scope) valid() bool {
|
||
switch s {
|
||
case ScopeUser, ScopePersona, ScopeProject:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Kind 分类一条长期记忆条目的内容类别。
|
||
type Kind string
|
||
|
||
const (
|
||
// KindRelationship 是关于「你和我」的记忆:偏好、习惯、
|
||
// 约定(design §0「关系记忆」)。
|
||
KindRelationship Kind = "relationship"
|
||
// KindWorldEvent 是关于客观事实与世界事件的记忆
|
||
// (design §0「世界/事件记忆」)。
|
||
KindWorldEvent Kind = "world_event"
|
||
)
|
||
|
||
// valid 报告 k 是否为已定义的 kind 之一。
|
||
func (k Kind) valid() bool {
|
||
switch k {
|
||
case KindRelationship, KindWorldEvent:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MemoryItem 是一条长期记忆记录。零值不是合法
|
||
// 条目:PersonaID 与 Content 必填,Scope 与 Kind 由 Add 规范化到
|
||
// 其默认值。
|
||
type MemoryItem struct {
|
||
ID string
|
||
PersonaID string
|
||
RoleID string
|
||
ProjectID string
|
||
Scope Scope
|
||
Kind Kind
|
||
Content string
|
||
Tags []string
|
||
Confidence float64
|
||
SourceConversation string
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
DecayWeight float64
|
||
|
||
// ExpiresAt 是**临时记忆**的有效期末(design/03 §9.6、design/23 §18.1)。
|
||
// 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC
|
||
// 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。
|
||
//
|
||
// 它与 RetentionTier 是**同一根轴的两种表达**(会到期 ⇔ temp),因此两者由
|
||
// `Store.Add` 里的同一个判定与同一处换算落库,绝不会分叉:显式给非零值而
|
||
// 档位不是 temp 会被拒(ErrRetentionConflict),给 temp 而不给则按临时 TTL 算。
|
||
//
|
||
// ⚠️ **读回语义是「半开」的**(别把这一条读成「完全只写不读」):
|
||
//
|
||
// - `Add` 会把它写进 `expires_at` 列,**并把最终落库的时刻回填到返回的条目上**
|
||
// (调用方因此能立刻看到「这条被判成临时、有效期到哪」);
|
||
// - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go),
|
||
// 故**从库里读回来的**条目 ExpiresAt 恒为零值。
|
||
//
|
||
// 要读回时效需同步 15 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
|
||
// SQL —— 那是改扫描口径,不在本切片授权范围。
|
||
ExpiresAt time.Time
|
||
|
||
// RetentionTier 是「留多久 / 干什么用」这根轴(迁移 0026、retention.go)。
|
||
// 它与 Kind(关于什么)、Scope(谁的)**正交,互不推导**。
|
||
//
|
||
// 写入侧语义:空串 = 由 `Store.Add` 的单一判定填(见 resolveRetention);
|
||
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
|
||
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
|
||
RetentionTier RetentionTier
|
||
|
||
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
|
||
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与没做时间性
|
||
// 判断的写入路径,口径见 temporal.go 头注)。
|
||
//
|
||
// 🔴 **它目前只观测、不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,
|
||
// 判定的结论集与「没有这一位」逐字相同。保留它是为了积累「模型到底怎么标」的
|
||
// 证据 —— 接线前必须先看那份分布(temporal.go 头注里给了查询)。
|
||
//
|
||
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
|
||
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
|
||
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,现为 15 列,
|
||
// 加它就是 16 列),那是另一件事,不在本片授权范围。要按时间性位巡检/回填时
|
||
// 直读该列即可;写入侧的校验/编码见 store_temporal.go。
|
||
Temporal TemporalKind
|
||
|
||
// Sensitive 是**敏感标记**(迁移 0028、sensitive.go):true = 这条记忆是主人的
|
||
// 私事(住址 / 私称 / 作息 ……),因此**受限车道**(群聊 / 陌生人)的召回过滤器
|
||
// 会把整条排除在候选池外。
|
||
//
|
||
// 🔴 它是**数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部记忆
|
||
// (含 sensitive=1),没有任何读路径因它拒绝主人;它只影响
|
||
// `MemoryItem.SharedVisible` 这一条判据。谁是主人仍只由 actor 元数据 + 白名单
|
||
// + 策略引擎决定(AGENTS §9)。判据与理由见 sensitive.go 头注。
|
||
Sensitive bool
|
||
}
|
||
|
||
// 区分 memory 包各类失败的哨兵错误。每个都在
|
||
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
|
||
var (
|
||
// ErrMissingPersona 在条目没有 PersonaID 时返回。
|
||
ErrMissingPersona = errors.New("memory: persona_id is required")
|
||
|
||
// ErrMissingContent 在条目没有内容时返回。
|
||
ErrMissingContent = errors.New("memory: content is required")
|
||
|
||
// ErrInvalidScope 在条目的 Scope 不是已定义作用域时返回。
|
||
ErrInvalidScope = errors.New("memory: invalid scope")
|
||
|
||
// ErrInvalidKind 在条目的 Kind 不是已定义 kind 时返回。
|
||
ErrInvalidKind = errors.New("memory: invalid kind")
|
||
|
||
// ErrInvalidRetentionTier 在条目的 RetentionTier(或列表读路径的档位过滤)
|
||
// 不是闭集内的值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
|
||
ErrInvalidRetentionTier = errors.New("memory: invalid retention tier")
|
||
|
||
// ErrRetentionConflict 在条目同时给出「非 temp 的档位」与「非零的 expires_at」
|
||
// 时返回 —— 那是自相矛盾的输入(一行标着长期却会到期消失),既不猜也不静默
|
||
// 丢弃其中一半。
|
||
ErrRetentionConflict = errors.New("memory: retention tier conflicts with expires_at")
|
||
|
||
// ErrNotFound 在条目 id 不存在时返回。
|
||
ErrNotFound = errors.New("memory: item not found")
|
||
|
||
// ErrNilDB 在 NewStore 收到 nil *sql.DB 时返回。
|
||
ErrNilDB = errors.New("memory: nil db")
|
||
)
|
||
|
||
// schema 默认值与时间窗口常量。
|
||
const (
|
||
defaultConfidence = 1.0
|
||
defaultDecayWeight = 1.0
|
||
)
|
||
|
||
// Store 是基于 *sql.DB 的长期记忆存储。
|
||
//
|
||
// 存储自己拥有 schema:NewStore 幂等地创建 memory_items 表及其
|
||
// 索引。并发交由 *sql.DB,按约定它可安全
|
||
// 并发使用,因此除语义索引的接入外,Store 不再持锁。每个查询都
|
||
// 参数化(?)并显式列出列(无 SELECT *),读取带
|
||
// context.Cancel。
|
||
type Store struct {
|
||
db *sql.DB
|
||
log *slog.Logger
|
||
|
||
// vecMu 保护 vec,使持久语义索引能在装配后并发接入,而写入
|
||
// 路径每写一条记忆就 embed + add 到它(见 indexItem)。vec 为
|
||
// nil 表示没有配置持久索引,检索据此回退到内存余弦。
|
||
vecMu sync.RWMutex
|
||
vec vector.Index
|
||
|
||
// projMu 保护 proj,使项目索引存储能在装配后并发接入,而写入路径
|
||
// 每写一条项目作用域记忆就确保它链接的项目索引条目存在(见
|
||
// ensureProjectIndex)。proj 为 nil 表示未接入项目索引,此时写入
|
||
// 不触碰项目索引(design §3 项目记忆链接项目索引)。
|
||
projMu sync.RWMutex
|
||
proj *projectindex.Store
|
||
|
||
// reviewMu 保护 review,使入库前审查(design/23 §15.1 D3)能在装配后
|
||
// 并发接入:接入后每条经 Add 落库的条目都会**先过同步入库闸**(确定性判据
|
||
// 命中即落 merged,见 preinsert_gate.go),闸没拦下的再投入异步审查队列,
|
||
// 由独立的记忆管理会话判定 merge/modify/create/skip。review 为 nil 表示
|
||
// 未启用或未接线,此时写入路径行为逐字与接入前一致。
|
||
reviewMu sync.RWMutex
|
||
review *PreInsertReviewer
|
||
}
|
||
|
||
// NewStore 在 db 上构建 Store,确保 memory_items schema 存在。
|
||
//
|
||
// db 必须是存活且已迁移的连接(迁移框架本身由
|
||
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
|
||
// 使用丢弃型 logger。ctx 控制 schema 创建,调用方必须
|
||
// 遵守。任何旧的变宽时间戳都就地
|
||
// 规范化(见 normalizeMemoryTimestamps),使文本顺序与时间顺序一致。
|
||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||
if db == nil {
|
||
return nil, ErrNilDB
|
||
}
|
||
log = logger.WithSource(discardLogger(log), "memory")
|
||
for _, ddl := range []string{
|
||
createMemoryItemsTable,
|
||
createMemoryItemsPersonaIndex,
|
||
createMemoryItemsPersonaCreatedIndex,
|
||
} {
|
||
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||
return nil, fmt.Errorf("memory: apply schema: %w", err)
|
||
}
|
||
}
|
||
if err := ensureProjectIDColumn(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
// 记忆整理(design/23 §10)的列与两张新表:同样在这一处幂等前滚,
|
||
// 使「在任意 *sql.DB 上都能整理」与 memory_items 自身口径一致。
|
||
if err := ensureTidySchema(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
// 入库前审查(design/23 §15.1 D3)的留痕表:同一口径 —— 表在这里幂等
|
||
// 创建,因此审查能力在任何 *sql.DB 上都可用;未接线审查时它只是一张空表。
|
||
if err := ensureIngestReviewSchema(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
// 「留多久 / 干什么用」轴(迁移 0026):既有库补列 + 建读侧索引。它排在
|
||
// 建表与整理列之后,因为索引依赖的列必须先存在(见 retention_schema.go)。
|
||
if err := ensureRetentionSchema(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
// 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。
|
||
if err := ensureTemporalSchema(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
// 敏感标记(迁移 0028):同上,只补列、不回填 —— 历史数据的打标是独立的一次性
|
||
// 动作(BackfillSensitive),使结构前滚保持零副作用(见 sensitive_schema.go)。
|
||
if err := ensureSensitiveSchema(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
return &Store{db: db, log: log}, nil
|
||
}
|
||
|
||
// Add 持久化 item,分配 id 与缺失的时间戳,并把
|
||
// Scope/Kind/Confidence/DecayWeight 规范化到默认值。它返回已落库的
|
||
// 条目,id 与时间戳均已填充。
|
||
//
|
||
// 🔴 **同步入库闸**(主人 2026-10-07 裁定):本函数是全仓**唯一**写路径,因此
|
||
// 闸就摆在这里 —— 落库**之前**先在同分区内跑一次确定性判定(复用
|
||
// judgeIngest → tidy_rules.go / tidy_containment.go),判出「真重复」时这一行
|
||
// 直接落 status='merged' + merged_into=<存活行>(**照常插入、一个字不丢、可
|
||
// revert、从不进 active 集合**),于是 active 集合当场就是干净的。闸的总墙钟
|
||
// 预算显式且有界(见 defaultReviewGateBudget),超预算/候选源不可用一律
|
||
// fail-open(照常插入 + 留痕,**绝不静默丢记忆**)。闸未接线或被开关关掉时,
|
||
// 这段逻辑是零成本的 passthrough,写入路径逐字回到改动之前。
|
||
//
|
||
// 接入持久语义索引(见 attachVectorIndex)时,落库后同一条目也会
|
||
// 被向量化并写入索引,使索引与 memory_items 同步 —— 语义检索路径因此
|
||
// 真的有向量可查,而不是对一个恒空索引搜索(design/03 §3「真正启用
|
||
// 向量检索」、design/04 §7)。索引写入失败只记日志:条目已经在
|
||
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
|
||
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
|
||
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。
|
||
//
|
||
// `item.ExpiresAt`(临时记忆的时效,design/03 §9.6)同在这一处落库为
|
||
// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1),
|
||
// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的
|
||
// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。
|
||
//
|
||
// 🔴 **档位判定与有效期换算都在这一处**(迁移 0026、retention.go):
|
||
// `resolveRetention` 是全仓唯一的档位判据(调用方显式档位 > 显式有效期 >
|
||
// 受限来源不分型 > 留存规则 → temp > 默认 long),`plan.tempDeadline` 是唯一的
|
||
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
|
||
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
|
||
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
|
||
//
|
||
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
|
||
// 落下(`temporal` 列)。**它只落库、不参与判定**(r1 复审裁决 D):`resolveRetention`
|
||
// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**——
|
||
// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线
|
||
// 的唯一证据来源,见 temporal.go 头注的查询)。
|
||
//
|
||
// 🆕 切片 M63:`item.Sensitive`(敏感标记,迁移 0028)也随**这一笔** INSERT 落下。
|
||
// 它**不参与任何判定**:档位 / 时效 / 读路径的可见性集合与「没有这一位」逐字相同
|
||
// —— 唯一的消费者是受限车道的过滤判据(sensitive.go 的 SharedVisible),而主人
|
||
// 车道根本不过那道判据。写入侧另有一条**白名单兜底**(见下面 Add 内的判定)。
|
||
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||
if err := item.normalize(); err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
var err error
|
||
if item, err = applyTimestamps(item); err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
// 🆕 切片 M63(迁移 0028):敏感标记的**写入侧兜底** —— 判据靠代码,不靠提示词
|
||
// (AGENTS §9)。提取器的 `sensitive` 位是**类目级**判断(新私事的措辞无法穷举,
|
||
// 见 llmextractor.go 的正反例);这里是**已实证短语**的确定性兜底
|
||
// (IsPrivateContent,判据与理由见 sensitive.go)。合成只在 resolveSensitive
|
||
// 一处做(「或」,只加标不撤标),提取器产出侧与这里走同一个函数。
|
||
if resolved := resolveSensitive(item.Sensitive, item.Content); resolved != item.Sensitive {
|
||
item.Sensitive = resolved
|
||
s.log.Debug("memory: sensitive flag set by private-fact whitelist",
|
||
"id", item.ID, "scope", string(item.Scope))
|
||
}
|
||
// 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位,
|
||
// 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。
|
||
verdict, err := resolveRetention(item)
|
||
if err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
item.RetentionTier = verdict.Tier
|
||
if verdict.Rule != retentionNone {
|
||
// 具体命中的反例规则码只进日志(进留痕列的是闭集里的 t0_low_value):
|
||
// 「n1~n7 谁命中过」是 M27 体检缺的那一条观测线。
|
||
s.log.Debug("memory: retention judged temporary",
|
||
"id", item.ID, "scope", string(item.Scope), "rule", string(verdict.Rule))
|
||
}
|
||
tagsJSON, err := json.Marshal(item.Tags)
|
||
if err != nil {
|
||
return MemoryItem{}, fmt.Errorf("memory: encode tags: %w", err)
|
||
}
|
||
|
||
plan := s.planIngest(ctx, item)
|
||
item.ExpiresAt = plan.ExpiresAt
|
||
if err := s.insertItem(ctx, item, tagsJSON, plan); err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
s.finishIngest(ctx, item, plan)
|
||
if plan.Action != ingestPlanAbsorbNew {
|
||
s.indexItem(ctx, item)
|
||
s.ensureProjectIndex(ctx, item)
|
||
}
|
||
if r := s.ingestReviewer(); r != nil {
|
||
r.recordGate(ctx, item, plan, time.Now().UTC())
|
||
}
|
||
// 入库前审查(design/23 §15.1 D3)的**异步**那一层:闸没拦下的(真新 /
|
||
// 拿不准 / 闸关闭)照旧投入审查队列,由后台用语义召回再判一次;闸挡下的
|
||
// 那条**绝不再投**(同一件事不该有两条路径)。投递是非阻塞的。未接线审查
|
||
// 时是空操作。
|
||
if plan.Async {
|
||
s.enqueueReview(item)
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
// Delete 删除给定 id 的条目,id 为空或
|
||
// 没有删除任何行时返回 ErrNotFound。调用方只应传入经
|
||
// 角色作用域读取(ListByPersona/Get)得到的 id,使删除始终留在
|
||
// 调用方的角色边界内。
|
||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||
if strings.TrimSpace(id) == "" {
|
||
return ErrNotFound
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `DELETE FROM memory_items WHERE id = ?`, id)
|
||
if err != nil {
|
||
return fmt.Errorf("memory: delete item: %w", err)
|
||
}
|
||
n, err := res.RowsAffected()
|
||
if err != nil {
|
||
return fmt.Errorf("memory: delete row count: %w", err)
|
||
}
|
||
if n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RememberManual 为 content 文本写入一条长期记忆条目 —— design §1 中的
|
||
// 「记住 X」路径。条目默认取角色作用域,
|
||
// 并以满置信度记为关系记忆。返回已落库的条目。
|
||
//
|
||
// 🔴 **显式档位 = long**(迁移 0026):这是**人工**动作(有人明确说「记住 X」),
|
||
// 按主人 2026-10-26 的裁定「人工动作默认应为 long」,留存规则不许把它降成临时层
|
||
// (否则一条「记住 晚安」会在 7 天后被静默删掉)。resolveRetention 的第一条分支
|
||
// 就是「调用方显式档位不许被规则推翻」,这里据此钉死长期意图。
|
||
func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text string) (MemoryItem, error) {
|
||
item := MemoryItem{
|
||
PersonaID: personaID,
|
||
RoleID: roleID,
|
||
Scope: ScopePersona,
|
||
Kind: KindRelationship,
|
||
Content: strings.TrimSpace(text),
|
||
RetentionTier: RetentionLong,
|
||
}
|
||
if item.Content == "" {
|
||
return MemoryItem{}, ErrMissingContent
|
||
}
|
||
return s.Add(ctx, item)
|
||
}
|
||
|
||
// normalize 填充默认的 Scope/Kind/Confidence/DecayWeight,然后校验。
|
||
// 它由 Add 调用,因此调用方可以省略取值,同时仍能
|
||
// 捕获显式非法值。
|
||
//
|
||
// RetentionTier **不在这里填默认值**:空串是「还没分型」这一合法状态,它由 Add 的
|
||
// 单一判定(resolveRetention)落定 —— 在 normalize 里塞一个默认档会把「判定没跑」
|
||
// 与「判定结论是 long」变成同一件事。
|
||
func (it *MemoryItem) normalize() error {
|
||
if it.Scope == "" {
|
||
it.Scope = ScopePersona
|
||
}
|
||
if it.Kind == "" {
|
||
it.Kind = KindRelationship
|
||
}
|
||
if it.Confidence == 0 {
|
||
it.Confidence = defaultConfidence
|
||
}
|
||
if it.DecayWeight == 0 {
|
||
it.DecayWeight = defaultDecayWeight
|
||
}
|
||
return it.validate()
|
||
}
|
||
|
||
// validate 检查必填与枚举字段。它是纯函数:不施加
|
||
// 默认值,因此也可用于拒绝调用方提供的值。
|
||
func (it *MemoryItem) validate() error {
|
||
if strings.TrimSpace(it.PersonaID) == "" {
|
||
return ErrMissingPersona
|
||
}
|
||
if strings.TrimSpace(it.Content) == "" {
|
||
return ErrMissingContent
|
||
}
|
||
if !it.Scope.valid() {
|
||
return ErrInvalidScope
|
||
}
|
||
if !it.Kind.valid() {
|
||
return ErrInvalidKind
|
||
}
|
||
if !it.RetentionTier.valid() {
|
||
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
|
||
}
|
||
if err := it.validateTemporal(); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
|
||
func discardLogger(log *slog.Logger) *slog.Logger {
|
||
if log != nil {
|
||
return log
|
||
}
|
||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|