package memory import ( "context" "fmt" "strings" ) // 本文件是 `dedupe_group` 的**元信息查询**:按分区取出「合并次数」与「是否临时层」 // 两列 —— 它们是存活规则的次要判据与一条数据安全护栏(临时行绝不进簇)。 // 从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行), // 注释与判据**逐字未动**。 // dedupeItemMetaSQL 取一个分区内每条记忆的**分簇所需元信息**:合并次数 // (存活规则的次要判据)+ 是否是临时层(`expires_at` 非空)。 // // 两列都只读、只走分区条件,与 tidy 分区扫描同一组谓词(不含 archived/merged)。 const dedupeItemMetaSQL = `SELECT id, merge_count, expires_at FROM memory_items WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?` // tidyDedupeMeta 是一条记忆的额外元信息(`MemoryItem` 的读侧 SELECT 不含这两列)。 type tidyDedupeMeta struct { // mergeCount 是存活规则的次要判据(合并次数多的留)。 mergeCount int // temporary 报告这条记忆在**临时层**(`expires_at` 非空,design/03 §9.6)。 // // 🔴 临时层行**绝不进簇**,两个方向都不许:让一条长期事实并进一个 7 天后会被 // 后台**物理删除**(design/23 §19)的存活行,等于把那条事实一起删掉 —— // 被吸收行会永远停在 `status='merged'` 且 `merged_into` 指向一个不存在的 id。 // 临时行本身也不值得合并:它的寿命本来就只剩几天。 temporary bool } // dedupeItemMeta 读出一个分区的 id → 元信息映射。 // // 🔴 **失败方向是「这个分区这轮不分簇」**(不是「降级继续」):`temporary` 承载的是 // 「临时行绝不进簇」这条数据安全红线,而 nil map 会让 `temporary` 恒为 false —— // 那等于把红线绕开(r1 复审 P2-1 实测的正是这个口子)。merge_count 缺了确实只影响 // 「等长时留哪条」,但两列同源同一次查询,为一条次要判据去承担一条红线风险不划算。 func (s *Store) dedupeItemMeta(ctx context.Context, p tidyPartition) (map[string]tidyDedupeMeta, error) { rows, err := s.db.QueryContext(ctx, dedupeItemMetaSQL, p.personaID, string(p.scope), p.projectID, string(tidyStatusActive)) if err != nil { return nil, fmt.Errorf("memory/tidy: dedupe item meta: %w", err) } defer func() { _ = rows.Close() }() out := make(map[string]tidyDedupeMeta, 64) for rows.Next() { var ( id string mergeCnt int expiresAt string ) if err := rows.Scan(&id, &mergeCnt, &expiresAt); err != nil { return nil, fmt.Errorf("memory/tidy: scan dedupe item meta: %w", err) } out[id] = tidyDedupeMeta{mergeCount: mergeCnt, temporary: strings.TrimSpace(expiresAt) != ""} } if err := rows.Err(); err != nil { return nil, fmt.Errorf("memory/tidy: dedupe item meta rows: %w", err) } return out, nil }