package memory import ( "fmt" "strings" "time" ) // 本文件是 `memory_items` 的第三根轴「留多久 / 干什么用」:档位闭集、**唯一**的 // 判定入口、以及「档位 → 有效期」的**唯一**换算(迁移 0026、审计 // `DevDocs/audit/记忆存量体检-2026-10-26.md` 卷四)。 // // 🔴 正交性(本片最容易写错的一条):`kind` 是「关于什么」 // (relationship / world_event),`scope` 是「谁的」(user / persona / project), // `retention_tier` 是「留多久 / 干什么用」。三根轴**互不推导**:一条关系记忆可以 // 是临时,也可以是长期;一条世界事件也可以是知识库条目。任何「由 kind 推 tier」 // (或反过来)的写法都是把两根轴压回一根,那正是本片要避免的设计回归。 // // 🔴 空串不是第六档,是「**尚未分型**」。它只出现在两类行上: // // ① 0026 迁移之前的**历史行**(列默认值为空串,本片刻意不做回填判定); // ② **受限来源**(群聊 / 陌生人)的新行 —— 见 resolveRetention:它们的留存由 // 来源面决定,后台到期清扫也会跳过它们(expire.go 的 `nonOwnerSource` 谓词), // 判成 temp 就是造一条「对读面隐藏、却永远不会被删」的行。 // // 因此读侧**必须**能区分「未分型」与「已分型」:三条列表读路径缺省不过滤(历史行照常 // 返回),显式传 `RetentionTierUnset` 时**只**返回未分型的行。 // RetentionTier 是「留多久 / 干什么用」轴的档位。零值 = 尚未分型。 type RetentionTier string const ( // RetentionTierUnset 是「尚未分型」:历史行与受限来源行。**它不是一档业务档位** // —— 读侧绝不许把它当成 temp / long 之类的某一档(那会让「未分型」这个事实 // 当场丢失,而它正是将来回填分型的抓手)。 RetentionTierUnset RetentionTier = "" // RetentionTemp 是临时层:到期即从读面消失(读侧时效闸,timegate.go),随后被 // 后台清扫物理删除(design/23 §19)。它是**唯一带时效**的档位, // 且 `retention_tier='temp'` ⇔ `expires_at` 非空 —— 两者由同一个判定、同一处换算 // 落库(见 resolveRetention / tempDeadline),**不许分叉**。 RetentionTemp RetentionTier = "temp" // RetentionDaily 是日常层:一天之内的生活琐事 / 节奏。它与 `scope=persona` 的 // 「角色日常」**不是一回事** —— 后者回答「谁的记忆」,这一档回答「留多久 / 干什么用」。 RetentionDaily RetentionTier = "daily" // RetentionWork 是工作层:任务推进、工单、项目过程。 RetentionWork RetentionTier = "work" // RetentionLong 是长期层:稳定的偏好 / 关系 / 事实。它也是入库判定的**默认落点** // (判据没命中「显然不值得长期留」时,落到长期层 —— 后果只是多留一条,方向是 // fail-safe 的「宁可多留,不可误弃」,design/23 §18.3)。 RetentionLong RetentionTier = "long" // RetentionKnowledge 是知识库层:外部资料 / 规格 / 文档类条目。它与 // `internal/runtime/knowledge` 的独立存储无关(那一层不归记忆管),这里只说 // 「这条**记忆行**是知识库性质的」。 RetentionKnowledge RetentionTier = "knowledge" ) // valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是一个错误)。 func (t RetentionTier) valid() bool { switch t { case RetentionTierUnset, RetentionTemp, RetentionDaily, RetentionWork, RetentionLong, RetentionKnowledge: return true default: return false } } // assigned 报告 t 是否**已分型**(即非空串)。它是「未分型」的唯一判据, // 调用方不许用 `tier == RetentionLong` 之类的比较去替代它。 func (t RetentionTier) assigned() bool { return t != RetentionTierUnset } // retentionVerdict 是「留多久」轴的判定结果:档位 + 命中的反例规则码。 // // 规则码只进日志、不进 `memory_ingest_reviews.reason`(那一列的闭集是 // `{t0_low_value, t1_defer, …}`,见 extract_retention.go 的口径)—— 但它必须有 // 一个落点,否则「n1~n7 到底谁命中过」只能靠猜(M27 体检就是这么发现闸形同虚设的)。 type retentionVerdict struct { Tier RetentionTier Rule retentionRule } // resolveRetention 决定一条待落库条目最终落哪一档 —— **全仓唯一的档位判定入口**。 // // 判定顺序(每一档都有不可走样的理由): // // 1. **调用方显式给了档位** ⇒ 以调用方为准,规则/模型一律不许推翻它。这条对齐 // AGENTS §9「判据靠元数据,不靠提示词」:写记忆的调用方(手动「记住」、 // 知识库导入、整理侧回填)比一条词项规则更知道「这条该留多久」。 // 显式非 temp 档 + 显式有效期是**自相矛盾**的输入(会造出一行「标着长期却会 // 到期消失」的记忆),直接报错而不是替它挑一个。 // 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。 // 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。 // 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。 // 4. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。 // 5. 其余 ⇒ long(默认落点,fail-safe)。 // // 🔴 **`temporal`(迁移 0027 的时间性元数据位)不在这个列表里**(r1 复审裁决 D, // 2026-10-27):这一位目前**只落库、只观测,不参与任何判定**。它被接进判定的两个 // 候选设计都已被生产重放否掉 —— ①无条件 `one_off ⇒ temp` 会当天删掉 21 条长期约定; // ②只放开 N6 位置判据,最近 500 条里能翻的 13 条**逐条都是约定**、而复审点名的 // 三条目标行 0/3 救得了(详见 temporal.go 头注)。因此判定的结论集与「没有这一位」 // 逐字相同;temporal_test.go 有一条正断言钉住「喂 one_off 不影响任何结论」, // 另有一条变异靶子(把这一位重新接进本函数 ⇒ 那条正断言必红)。 // // 🔴 「判第五态 `defer`」与「判 `retention_tier=temp`」**就是这一步**:闸与异步审查 // 都只读 `item.RetentionTier`(preinsert_judge.go),不再各自跑一遍规则。M27/M37 的 // 实测缺口正是「两处各判一遍、上游几乎不触发」,合并之后只有一个判据。 func resolveRetention(item MemoryItem) (retentionVerdict, error) { if tier := item.RetentionTier; tier.assigned() { if tier != RetentionTemp && !item.ExpiresAt.IsZero() { return retentionVerdict{}, fmt.Errorf( "memory: retention_tier=%q with a non-empty expires_at: %w", tier, ErrRetentionConflict) } return retentionVerdict{Tier: tier}, nil } if !item.ExpiresAt.IsZero() { return retentionVerdict{Tier: RetentionTemp}, nil } if nonOwnerSource(item.SourceConversation) { return retentionVerdict{Tier: RetentionTierUnset}, nil } if rule := judgeExtractionRetention(item.Content); rule != retentionNone { return retentionVerdict{Tier: RetentionTemp, Rule: rule}, nil } return retentionVerdict{Tier: RetentionLong}, nil } // tempDeadline 给出落库时要写进 `expires_at` 的时刻,是「档位 → 有效期」的 // **唯一**换算点:temp ⇒ 一个时刻,其余档位 ⇒ 零值(落库为空串 ⇒ 永不过期)。 // // 因此「temp ⇔ expires_at 非空」不是一条靠自觉维持的约定,而是这条换算的结构性结果; // 任何新写路径只要拿这一行去 INSERT,就不可能写出「temp 却没有有效期」的行。 // // 时刻的三种来源(优先级从高到低): // // 1. 调用方显式给的 `item.ExpiresAt`(回填 / 测试夹具需要「已经过期」的行); // 2. 接了入库审查服务时用**它的**可注入时钟与面板 TTL(`deferDeadline`)—— // 闸、异步审查与写入侧因此用的是同一个算式; // 3. 没接审查服务时用墙钟 + 代码默认 `tempTtlSec`。 func (s *Store) tempDeadline(item MemoryItem) time.Time { if item.RetentionTier != RetentionTemp { return time.Time{} } if !item.ExpiresAt.IsZero() { return item.ExpiresAt } if r := s.ingestReviewer(); r != nil { return r.deferDeadline(r.now().UTC()) } return time.Now().UTC().Add(time.Duration(tempTtlSec) * time.Second) } // retentionTierClause 把可选的档位过滤渲染成参数化 WHERE 片段(SQL 全参数化)。 // // 缺省(未传任何档位)= **不过滤**:既有调用方零改动,历史行(空串)与其他档位一样 // 照常返回。传了档位就只返回这些档位;显式传 `RetentionTierUnset`(空串)是 // 「只看未分型的行」—— 这是回填分型的入口,必须与「不过滤」可区分。 func retentionTierClause(tiers []RetentionTier) (string, []any, error) { if len(tiers) == 0 { return "", nil, nil } seen := make(map[RetentionTier]bool, len(tiers)) args := make([]any, 0, len(tiers)) marks := make([]string, 0, len(tiers)) for _, tier := range tiers { if !tier.valid() { return "", nil, fmt.Errorf("%w: %q", ErrInvalidRetentionTier, tier) } if seen[tier] { continue } seen[tier] = true marks = append(marks, "?") args = append(args, string(tier)) } return " AND retention_tier IN (" + strings.Join(marks, ", ") + ")", args, nil } // retentionTierList 是三条列表读路径共用的拼装:把可选的档位过滤接到既有 WHERE // 之后、时效闸与 ORDER BY 之前,并按**子句出现顺序**排出参数。 // // 参数顺序是纯文本比较之外的唯一坑:SQLite 的 `?` 按出现位置绑定,档位片段的 // 占位符夹在 status 与时效闸之间,因此档位参数也必须夹在那两组之间 —— 拼错不会 // 报错,只会**静默按错误的档位过滤**(timegate 的历史事故同型)。 func retentionTierList(where string, args []any, tiers []RetentionTier) (string, []any, error) { clause, tierArgs, err := retentionTierClause(tiers) if err != nil { return "", nil, err } return where + clause, append(args, tierArgs...), nil }