package memory // freshness.go 实现记忆新鲜度警告系统. // // [模块定位] // 记忆是点时刻观测值,随时间可能过时.本模块解决两个问题: // 1. 如何告知模型某条记忆"可能已过时"(FreshnessText / FreshnessNote) // 2. 如何在 MEMORY.md 索引中注记记忆年龄(IndexAnnotation / TruncateIndex) // // [设计决策] // // 阈值用 time.Duration 而非 "N 天": // - 智能仓储场景货架库存每 2 小时一变,用 days 无法表达 // - 医疗场景需要"任何年龄都警告"(threshold=0) // - Go 内置 time.Duration 比自定义 int 更语义化 // // 类型覆盖(TypeOverrides)而非全局统一阈值: // - user/feedback 类记忆月更新一次是正常的 // - project 类记忆在冲刺末尾两天内失效 // - 一刀切会导致 user 类频繁误报或 project 类漏报 // // FreshnessNote 用 标签: // - 与 reminders.go 的 FormatReminder 保持一致的提醒格式 // - 模型已被训练理解 语义 // // TruncateIndex 双限制(200行 / 25KB): // - 早期实现有 200 行截断(见 sliceLines(content, 200)) // - 额外加 25KB 字节限制防止单行超长的 MEMORY.md 无限增长 // - 超出时在末尾追加 WARNING,让模型知道有内容被截断 // // [升华改进(ELEVATED)] // 早期实现在 UpdateIndex 中硬编码 "> 30 天" 才显示年龄注记, // 且无任何可配置化入口.不同场景不能定制阈值. // 我们将阈值提升为一等公民(FreshnessConfig),支持全局阈值 + 类型覆盖, // 让 CLI/SDK/API/跨行业都能以零代码修改切换策略. // 替代方案:<在 UpdateIndex 里按 Type 硬编码多个 if 分支> - // 否决原因:每个新行业都要改引擎代码,违反开闭原则. import ( "fmt" "strings" "time" ) // FreshnessConfig 配置记忆新鲜度检测策略. // // 精妙之处(CLEVER): 用"零值禁用"约定统一 nil 和 zero-value 的语义-- // GlobalThreshold == 0 表示"总是警告"(不是"从不警告"), // 这对医疗/金融场景(任何过时记忆都需警告)自然成立. // 调用方传 nil 时,引擎不初始化 FreshnessConfig,不产生任何警告-- // nil 与 GlobalThreshold==0 是两种不同语义,需通过指针区分. // 在 engine.Config 中用 *FreshnessConfig,nil = 不启用新鲜度功能. type FreshnessConfig struct { // GlobalThreshold 全局新鲜度阈值. // 记忆年龄超过此值时触发警告. // 0 = 总是触发警告(任何年龄的记忆都提示可能过时). // 典型值:24 * time.Hour(默认),2 * time.Hour(仓储),0(医疗强制) GlobalThreshold time.Duration // TypeOverrides 按记忆类型覆盖阈值. // key 是 Type 字符串("user"/"feedback"/"project"/"reference"). // 未出现在此 map 中的类型使用 GlobalThreshold. // // 示例: // TypeOverrides: map[string]time.Duration{ // "project": 12 * time.Hour, // 项目上下文半天就可能过时 // "user": 7 * 24 * time.Hour, // 用户偏好一周才变 // } TypeOverrides map[string]time.Duration } // DefaultFreshnessConfig 返回适合通用场景的默认配置. // // 24 小时阈值的选择依据: // - 大多数项目上下文每天最多更新一次 // - 用户偏好变化周期更长,24 小时保守而合理 // - 比早期实现 的 30 天更积极,但不会因频繁警告降低信噪比 func DefaultFreshnessConfig() FreshnessConfig { return FreshnessConfig{ GlobalThreshold: 24 * time.Hour, } } // ThresholdFor 返回指定记忆类型的新鲜度阈值. // TypeOverrides 中有覆盖则返回覆盖值,否则返回 GlobalThreshold. func (c FreshnessConfig) ThresholdFor(t Type) time.Duration { if override, ok := c.TypeOverrides[string(t)]; ok { return override } return c.GlobalThreshold } // ShouldWarn 判断给定修改时间的记忆是否需要新鲜度警告. // // threshold == 0 时总是返回 true(医疗/金融等强制警告场景). // modTime 为零值时返回 false(新建记忆尚未落盘,无需警告). func ShouldWarn(modTime time.Time, threshold time.Duration) bool { if modTime.IsZero() { return false } // 精妙之处(CLEVER): threshold == 0 用"总是警告"语义, // 因为 age > 0 总是成立(time.Since 永远 >= 0), // 但直接写 age > 0 不能处理 modTime 极度接近 Now 的边界情况. // 用 threshold == 0 || age >= threshold 更清晰表达意图. age := time.Since(modTime) return threshold == 0 || age >= threshold } // AgeDuration 返回记忆的年龄(当前时刻 - 最后修改时间). // modTime 为零值时返回 0. func AgeDuration(modTime time.Time) time.Duration { if modTime.IsZero() { return 0 } return time.Since(modTime) } // AgeString 将年龄 duration 格式化为人类可读字符串. // // 输出粒度: // - < 2 小时:显示分钟("45 minutes") // - 2 小时 - 48 小时:显示小时("6 hours") // - > 48 小时:显示天("3 days") // // 精妙之处(CLEVER): 选择"合适粒度"而非始终显示最精细单位-- // "2883 minutes" 比 "2 days" 更难理解. // 48 小时边界(而非 24 小时)防止"1 day 1 hour"这类边界歧义: // 25 小时显示 "25 hours" 而非 "1 day",更准确. func AgeString(d time.Duration) string { if d < 0 { d = 0 } minutes := int(d.Minutes()) hours := int(d.Hours()) days := int(d.Hours() / 24) switch { case d < 2*time.Hour: if minutes <= 1 { return "1 minute" } return fmt.Sprintf("%d minutes", minutes) case d < 48*time.Hour: if hours == 1 { return "1 hour" } return fmt.Sprintf("%d hours", hours) default: if days == 1 { return "1 day" } return fmt.Sprintf("%d days", days) } } // FreshnessText 返回针对该条记忆的完整新鲜度警告文本. // // 仅在 ShouldWarn(modTime, threshold) == true 时调用才有意义. // 调用方应先 ShouldWarn 判断,再决定是否调用本函数. // // 文本结构参考早期实现的注入风格: // 1. 说明事实(年龄) // 2. 解释含义(记忆是点时刻观测值) // 3. 给出行动建议(主动核实) // // 升华改进(ELEVATED): 早期实现 无任何新鲜度提示文本,只在 MEMORY.md 里加了 // "_(last updated N days ago)_" 注记,完全靠模型自己推断是否过时. // 我们加显式的自然语言警告,让模型知道"应该主动核实". // 替代方案:<只在 MEMORY.md 里加注记,不加运行时提示> - // 否决原因:MEMORY.md 是索引,不保证每轮都被读取;运行时注入才能保证覆盖. func FreshnessText(modTime time.Time, threshold time.Duration) string { age := AgeDuration(modTime) ageStr := AgeString(age) return fmt.Sprintf( "This memory is %s old. Memories are point-in-time observations and may no longer reflect current state. "+ "Please verify with current context (re-read files, re-check configuration) before acting on this information.", ageStr, ) } // FreshnessNote 将新鲜度警告包裹为 标签格式. // // 返回空字符串表示不需要警告(ShouldWarn == false). // 调用方可直接注入返回值到消息流,空字符串安全可忽略. // // 精妙之处(CLEVER): 返回值可以直接拼接到消息内容中, // 无需调用方做 if note != "" 的判断--空字符串拼接无害. // 当然调用方在意性能时仍可先判断再调用. func FreshnessNote(modTime time.Time, threshold time.Duration) string { if !ShouldWarn(modTime, threshold) { return "" } return "\n" + FreshnessText(modTime, threshold) + "\n" } // IndexAnnotation 返回适合嵌入 MEMORY.md 行末的紧凑年龄注记. // // 格式:" _(last updated N days/hours ago)_"(含前导空格) // 返回空字符串表示不需要注记(ShouldWarn == false). // // 精妙之处(CLEVER): 前导空格是有意为之-- // 调用方可以无条件 sb.WriteString(IndexAnnotation(...)) 而不需要 // 先检查返回值是否为空再决定是否加空格. // 格式参考早期实现的 "_(last updated N days ago)_" 斜体 markdown. func IndexAnnotation(modTime time.Time, threshold time.Duration) string { if !ShouldWarn(modTime, threshold) { return "" } age := AgeDuration(modTime) ageStr := AgeString(age) return fmt.Sprintf(" _(last updated %s ago)_", ageStr) } // maxIndexLines 和 maxIndexBytes 是 MEMORY.md 索引截断的双重上限. // // 历史包袱(LEGACY): 早期方案用 sliceLines(content, 200) 截断, // 但没有字节数限制.实测中有用户 MEMORY.md 超过 100KB(含长描述), // 注入到系统提示词后显著压缩 context window. // 我们加 25KB 字节限制作为第二道防线. // 理想做法:按 token 数截断(与 context window 的真实消耗对齐), // 但 token 计数需要调用 API 或嵌入 tokenizer,成本过高. const ( maxIndexLines = 200 maxIndexBytes = 25 * 1024 // 25KB ) // TruncateIndex 对 MEMORY.md 内容应用双重截断限制(200行 / 25KB). // // 超出任一限制时,截断并在末尾追加 WARNING 告知模型内容不完整. // // 升华改进(ELEVATED): 早期实现 只做行数截断(200行),无字节限制. // 行数限制对"每条记忆描述很长"的场景失效--200 行 × 200 字节/行 = 40KB. // 我们的双重限制保证注入体积在合理范围内. // 替代方案:<只保留行数限制,与早期方案对齐> - // 否决原因:SDK 嵌入场景下,调用方会同时传自己的 system prompt, // context window 压力更大,需要更严格的限制. func TruncateIndex(content string) string { if content == "" { return content } lines := strings.Split(content, "\n") // 先按字节截断(可能在某行中间截断) if len(content) > maxIndexBytes { // 按字节找截断点,保证在 \n 边界上截断 truncated := content[:maxIndexBytes] // 回退到最后一个换行符 if idx := strings.LastIndex(truncated, "\n"); idx >= 0 { truncated = truncated[:idx] } // 重新按行数统计截断后的内容 lines = strings.Split(truncated, "\n") // 最后一行可能是空行,移除 for len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } warning := fmt.Sprintf( "\n\n> **WARNING**: MEMORY.md has been truncated (exceeded %dKB). "+ "Some memories are not shown. Use List/Read tools to access the full memory store.", maxIndexBytes/1024, ) return strings.Join(lines, "\n") + warning } // 按行数截断 if len(lines) > maxIndexLines { lines = lines[:maxIndexLines] warning := fmt.Sprintf( "\n\n> **WARNING**: MEMORY.md has been truncated (exceeded %d lines). "+ "Some memories are not shown. Use List/Read tools to access the full memory store.", maxIndexLines, ) return strings.Join(lines, "\n") + warning } return content }