package memory import ( "sort" "strings" ) // 本文件是 `dedupe_group` 的**簇键**:一条记忆凭什么与另一条同簇。 // 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行), // 判据与注释**逐字未动**。 // dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立 // (tag 集不合格 / 内容短于 12 字),那时只返回另一条。 func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey { out := make([]tidyDedupeKey, 0, 2) if key := dedupeTagKey(it); key != "" { out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key}) } if key := dedupePrefixKey(it); key != "" { out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key}) } return out } // dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。 // // 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**: // - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键 // 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** —— // 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证; // - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉 // 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。 func dedupeTagKey(it MemoryItem) string { tags := normalizedTidyTags(it.Tags) if len(tags) < dedupeGroupMinTagCount { return "" } for _, tag := range tags { if len([]rune(tag)) < dedupeGroupMinTagRunes { return "" } } return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e") } // dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。 func dedupePartitionPrefix(it MemoryItem) string { return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e" } // normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定 // 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。 func normalizedTidyTags(tags []string) []string { if len(tags) == 0 { return nil } seen := make(map[string]struct{}, len(tags)) out := make([]string, 0, len(tags)) for _, tag := range tags { t := strings.TrimSpace(tag) if t == "" { continue } if _, dup := seen[t]; dup { continue } seen[t] = struct{}{} out = append(out, t) } sort.Strings(out) return out } // dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。 // // 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene` // 不该因为多一个空格就落到两个簇。 // // 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成 // 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档) // 已经在管的事,不需要第二个动作重复它。 func dedupePrefixKey(it MemoryItem) string { runes := []rune(dedupeKey(it.Content)) if len(runes) < dedupeGroupPrefixRunes { return "" } return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes]) }