Files
Cyrene_v2/internal/db/migrations/0026_memory_retention_tier.up.sql
T
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 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)。
2026-10-26 13:09:30 +08:00

44 lines
3.3 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 0026_memory_retention_tier:「留多久 / 干什么用」这根轴(主人 2026-10-26 裁定「两列」)。
--
-- 为什么是**新列**而不是复用 kind:`kind`(relationship / world_event)回答的是
-- 「关于什么」,`scope`(user / persona / project)回答的是「谁的」,本条新增的
-- `retention_tier` 回答的是「留多久 / 干什么用」。三根轴**正交、互不推导**:
-- 一条关系记忆可以是临时记忆,也可以是长期记忆;把「临时性」塞进 `kind` 会让
-- 「一条关系记忆又是临时的」无法表达(审计 `记忆存量体检-2026-10-26.md` 卷四的
-- 三条路对比,主人选了第 1 条)。
--
-- 🔴 默认值 `''` = **尚未分型**,不是第六档业务档位:
-- ① 本迁移之前的历史行全部落在 `''`(本迁移**刻意不做回填**:那是一次写全表的
-- 判定动作,属于整理侧的第二现场,不该藏在迁移里,也不该在启动路径上静默跑);
-- ② 受限来源(群聊 / 陌生人)的新行同样不分型(它们的留存由来源面决定,后台到期
-- 清扫也会跳过它们 —— 判成 temp 就是造一条「对读面隐藏却永不删除」的行)。
-- 读侧因此**必须**能区分「未分型」与「已分型」:缺省不过滤(历史行照常返回),
-- 显式按 `''` 过滤即「只看未分型的行」(回填分型的入口)。
--
-- 档位闭集(代码侧的定义是 internal/runtime/memory/retention.go 的 RetentionTier):
-- temp 临时层:唯一带时效的档位,`retention_tier='temp'` ⇔ `expires_at`
-- 非空且落在临时 TTL 内(两列由同一个判定、同一笔 INSERT 落下)
-- daily 日常层(一天之内的琐事 / 生活节奏;**不是** scope=persona 的「角色日常」)
-- work 工作层(任务推进 / 工单 / 项目过程)
-- long 长期层(稳定的偏好 / 关系 / 事实;入库判定的默认落点)
-- knowledge 知识库层(外部资料 / 规格 / 文档性质的记忆行)
-- '' 尚未分型(见上)
--
-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本
-- 迁移与 0019_memory_tidy 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚
-- (ensureRetentionTierColumn)走到 "duplicate column name" 是预期路径。反过来
-- (先自举、后迁移)也只在同一条账本跳一次。列与索引的形状必须与自举 DDL
-- (internal/runtime/memory/store_schema.go)**逐字同形**,由
-- internal/runtime/memory/retention_migration_test.go 逐列/逐索引钉住。
--
-- 命名口径:表名复数、主键 `id`、索引 `idx_{table}_{col}`(援引 AGENTS §10 已登记
-- 例外,与既有迁移一致)。
ALTER TABLE memory_items ADD COLUMN retention_tier TEXT NOT NULL DEFAULT '';
-- 三条列表读路径的可选档位过滤(`retention_tier IN (...)`)与「只看未分型的行」
-- (回填入口)都吃这条索引。单列而不是复合列:这一轴的查询形状是
-- 「跨角色按档位聚合」(面板 / 回填),不绑定 persona。
CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier
ON memory_items (retention_tier);