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)。
This commit is contained in:
AskaEth committed 2026-10-26 13:09:30 +08:00
1 parent 240b59708d
commit cc6904b1b4
23 files changed
+1592 -181

No files matched your search

@@ -0,0 +1,17 @@
-- 0026 的回滚:先删索引、再删列(与 0019 同一顺序口径 —— SQLite 拒绝 DROP COLUMN
-- 删除一个仍被索引引用的列)。
--
-- 回滚的代价(如实登记):`retention_tier` 这一列的值**全部丢失**。它对本迁移之前
-- 写入的行本来就恒为 `''`(未分型),因此丢的只是「本迁移之后被判出来的档位」。
-- 真正不可逆的只有一条:**判成 temp 的行不会被自动改回长期层** —— 它们的
-- `expires_at` 是另一列(0019 就存在),本回滚**刻意不动它**:
--
-- - 若回滚时顺手清空 `expires_at`,一次 schema 回滚就会把已经判定的临时记忆
-- 全部升回永久(那是改数据,不是回滚结构);
-- - 若不清空,回滚后这些行仍是「有时效的行」,读侧时效闸(纯 SQL,不依赖本列)
-- 照旧隐藏它们,后台清扫照旧删除它们 —— 即**行为不变**。
--
-- 因此本回滚只撤结构、绝不改写任何一行的时效数据;要重判档位,重跑判定即可
-- (`retention_tier` 列会以默认值 `''` 重建,历史行回到「未分型」)。
DROP INDEX IF EXISTS idx_memory_items_retention_tier;
ALTER TABLE memory_items DROP COLUMN retention_tier;
@@ -0,0 +1,43 @@
-- 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);