// Package memory 实现记忆系统. // // 对应原项目中同名模块的功能. // 每条记忆是一个独立的 markdown 文件(带 YAML frontmatter), // 存储在 ~/.flyto/projects//memory/ 目录下. // // 4 种记忆类型: // - User(用户画像):记住用户的偏好和习惯 // - Feedback(行为指导):根据用户反馈调整行为 // - Project(项目上下文):项目结构,技术栈,约定 // - Reference(外部指针):指向外部资源的链接/摘要 // // 核心流程: // - Save: 将记忆写为 markdown 文件(frontmatter + body) // - List: 扫描目录,解析 frontmatter,按 mtime 排序 // - FindRelevant: 基于文本相似度评分选出相关记忆 // - Delete: 删除记忆文件 // - UpdateIndex: 更新 MEMORY.md 索引文件 package memory import ( "context" "crypto/sha256" "fmt" "os" "path/filepath" "strings" "sync" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" "git.flytoex.net/yuanwei/flyto-agent/pkg/security" ) // L1326 (2026-04-16) 重构: memory 包直接消费 flyto.EventObserver 作为 Observer // 契约, 不再定义本地 MemoryObserver 接口. // // 历史包袱(LEGACY): 早期注释声称 "直接依赖 flyto.EventObserver 会循环依赖" 是误判 -- // flyto 是零外部依赖的契约层 (见 pkg/flyto/doc.go), 整个项目 29 个包 // import flyto, flyto 零反向 import. memory → flyto 单向依赖完全安全, // 不构成循环. 原本的方法集"逐字相同"+Go 结构化类型鸭子类型隐式满足模式 // 造成了 L1326 登记的隐性 coupling 债 (一方加方法另一方不编译报错). // 现在 memory.observer 字段直接是 flyto.EventObserver, 契约变化编译期强制同步. // // 替代方案: 保留 MemoryObserver 作为 flyto.EventObserver 的类型别名 // (type MemoryObserver = flyto.EventObserver) - 否决, 两个名字指同一 // 类型增加读者认知负担, 对消费者没有实际收益 (engine/observer.go:48 已经 // 是同样的类型别名模式, 只有引擎入口一层合理, 不应再层叠). // noopMemoryObserver 是 memory 包的内部 noop 实现, 在 Observer 未注入时兜底. // 它自然满足 flyto.EventObserver (Event + Error 两个空方法), 不引入对 // engine.NoopObserver 的依赖 (会构成 memory → engine 循环). type noopMemoryObserver struct{} func (n *noopMemoryObserver) Event(name string, data map[string]any) {} func (n *noopMemoryObserver) Error(err error, ctx map[string]any) {} // Backupper is the pre-write backup interface. // // Shape: synchronous callback. Store calls Snapshot synchronously before // mutating on-disk memory files, letting the consumer implementation // capture a version or rollback point. // // 形态: 同步回调. Store 在改动磁盘记忆文件前同步调 Snapshot, 让消费者 // 实现捕获版本或回滚点. // // Backupper 是写入前备份接口. // // 升华改进(ELEVATED): 用接口而非具体类型(*engine.FileHistory)打破循环依赖-- // memory 包不能 import engine 包(engine 已经 import memory). // engine.FileHistory 实现此接口,memory.fileStore 通过 WithFileHistory 注入. // 这和 observer 的消费思路一致:通过接口解耦,让 engine 注入具体实现. // // 精妙之处(CLEVER): 只暴露一个方法--Backup(ctx, path) error. // fileStore 不关心备份的内部实现(内容寻址,SHA256,最大快照数), // 只关心"在写入 path 前,帮我备份一下". // 替代方案:传入 *engine.FileHistory(循环依赖,直接否决). // 替代方案:传入 func(ctx, path) error(函数类型,但接口更可组合,可 mock). type Backupper interface { // Backup 在写入前备份指定路径的文件. // 如果文件不存在,实现应静默跳过(新建文件无需备份). // path 是绝对路径;ctx 用于超时/取消控制. Backup(ctx context.Context, path string) error } // Type 是记忆类型枚举. type Type string const ( TypeUser Type = "user" // 用户画像 TypeFeedback Type = "feedback" // 行为指导 TypeProject Type = "project" // 项目上下文 TypeReference Type = "reference" // 外部指针 ) // Entry 是一条记忆记录. type Entry struct { Name string `json:"name"` Description string `json:"description"` Type Type `json:"type"` Content string `json:"content"` Path string `json:"path,omitempty"` // 文件路径(文件存储) ModTime time.Time `json:"mod_time,omitempty"` } // Store 是记忆存储接口. // // Shape: synchronous callback for writes, pull for reads. Engine calls // Save / Delete synchronously after extraction; Get / List / FindRelevant // serve as pull API for UIs and the prompt assembly step. // // 形态: 写是同步回调, 读是 pull. 引擎在抽取后同步调 Save / Delete; Get / // List / FindRelevant 作为 pull API 供 UI 和 prompt 拼装使用. type Store interface { // Save 保存一条记忆. Save(ctx context.Context, entry *Entry) error // List 列出所有记忆(按修改时间倒序). List(ctx context.Context) ([]*Entry, error) // FindRelevant 查找与查询相关的记忆. FindRelevant(ctx context.Context, query string, limit int) ([]*Entry, error) // Delete 删除一条记忆. Delete(ctx context.Context, name string) error // UpdateIndex 更新 MEMORY.md 索引文件. UpdateIndex(ctx context.Context) error // Dir 返回记忆文件的存储目录(绝对路径). // 记忆提取子 agent 用此路径限制 Edit/Write 只能写入记忆目录, // hasMemoryWritesSince 用此路径判断主 agent 是否已写过记忆. Dir() string } // NewFileStore 创建基于文件系统的记忆存储. // // 存储路径策略: // 原项目用 ~/.flyto/projects//memory/, // 这里复用同样的路径规则,通过 cwd 的 SHA256 哈希生成项目标识. func NewFileStore(cwd string) Store { return &fileStore{ cwd: cwd, baseDir: memoryDirForProject(cwd), } } // NewFileStoreWithBaseDir 创建以指定 baseDir 为存储目录的文件存储. // 主要用于测试--测试可以用 t.TempDir() 作为 baseDir,而不需要依赖 HOME 目录. func NewFileStoreWithBaseDir(baseDir string) Store { return &fileStore{baseDir: baseDir} } // FileStoreOption 是 NewFileStoreWithOptions 的配置选项. type FileStoreOption func(*fileStore) // WithScorer 设置自定义评分器. func WithScorer(scorer RelevanceScorer) FileStoreOption { return func(fs *fileStore) { fs.scorer = scorer } } // WithTypeRegistry 设置自定义类型注册表. func WithTypeRegistry(reg *MemoryTypeRegistry) FileStoreOption { return func(fs *fileStore) { fs.typeRegistry = reg } } // WithObserver 设置可观测性接口. // 升华改进(ELEVATED): 通过 functional option 注入 observer, // 与 WithScorer/WithTypeRegistry 风格一致,不破坏现有构造函数. // 替代方案:加 SetObserver 方法(但 Store 接口返回的是接口类型,调用方拿不到具体类型调 SetObserver). func WithObserver(obs flyto.EventObserver) FileStoreOption { return func(fs *fileStore) { fs.observer = obs } } // WithMemorySelector 注入 AI 记忆选择器. // 设置后,FindRelevant 优先使用 AI 选择而非文本相似度评分. // 回退策略:AI 调用失败时自动降级为文本评分. func WithMemorySelector(sel MemorySelector) FileStoreOption { return func(fs *fileStore) { fs.selector = sel } } // WithStrictSymlink 启用严格符号链接保护模式. // 启用后,Save/Delete 在检测到符号链接时拒绝操作(返回错误),而非仅记录日志. // // 升华改进(ELEVATED): 这是可选的"强化模式"--默认宽松以兼容 NFS/Docker/WSL, // 高安全场景(CI 环境,服务端部署)可启用严格模式. // 精妙之处(CLEVER): 宽松/严格双模式比单一策略更灵活-- // 开发者本地环境有可能用符号链接组织目录结构,强制拒绝会破坏工作流; // 服务端环境则需要硬拒绝符号链接防止路径逃逸. // 反向思维:严格模式是否多余?若攻击者能在 ~/.flyto/ 创建符号链接, // 他们已有足够权限直接读写目标文件,符号链接只是多一跳. // 答:在 setuid/容器边界场景下,进程权限高于文件系统权限,符号链接依然危险. // 替代方案:始终拒绝符号链接(过于激进,NFS 挂载普遍用符号链接). func WithStrictSymlink() FileStoreOption { return func(fs *fileStore) { fs.strictSymlink = true } } // WithSecretGuard 设置秘密扫描器. // 设置后,Save() 在写入前扫描记忆内容--阻止将 API key 等敏感信息写入记忆文件. // // 升华改进(ELEVATED): 记忆文件存储在 ~/.flyto/projects//memory/, // 是用户数据,但也是 Agent 持久化的内容,可能被 Agent 自动写入敏感信息. // 通过 SecretGuard 保护记忆层是纵深防御的一部分. // 替代方案:<只保护 FileWrite/FileEdit 工具> - 否决原因:Memory.Save() 是独立写入路径, // 绕过了 FileWrite 工具,不在工具层保护范围内. func WithSecretGuard(g security.SecretGuard) FileStoreOption { return func(fs *fileStore) { fs.guard = g } } // WithFileHistory 注入写入前备份器. // 设置后,Save() 在覆盖已有记忆文件前自动调用 Backup()--确保记忆文件可回滚. // // 升华改进(ELEVATED): 记忆文件是 Agent 的长期知识库,一旦覆盖无法恢复. // 通过 Backupper 接口注入备份能力,与 engine.FileHistory 解耦(打破循环依赖). // 典型用法(在 Engine.New() 中): // // memory.WithFileHistory(e.fileHistory) // // 精妙之处(CLEVER): 只在文件已存在时备份(新建无需备份)-- // os.Stat 检查存在性,避免对新文件做无意义的备份调用. // 备份失败不阻断 Save--fail-open 原则:记忆写入比备份更重要, // 但会通过 observer 记录告警,让监控系统感知到备份异常. // 替代方案:备份失败阻断写入(过于保守,备份目录满时会让整个记忆系统不可用). // 替代方案:直接注入 *engine.FileHistory(循环依赖,否决). func WithFileHistory(bk Backupper) FileStoreOption { return func(fs *fileStore) { fs.fileHistory = bk } } // WithFreshness 启用记忆新鲜度警告功能. // // 升华改进(ELEVATED): 早期实现 硬编码 30 天阈值且不可配置. // 通过 functional option 注入 FreshnessConfig,各场景可自定义阈值: // - CLI 默认:WithFreshness(memory.DefaultFreshnessConfig()) → 24h // - 仓储场景:TypeOverrides["project"] = 2h // - 医疗场景:GlobalThreshold = 0(总是警告) // // 不调用此选项 = 不启用新鲜度功能,向后兼容. // 替代方案:<在 Config 里加 FreshnessDays int> - // 否决原因:int 无法表达 sub-day 阈值,也无法按类型差异化配置. func WithFreshness(cfg FreshnessConfig) FileStoreOption { return func(fs *fileStore) { fs.freshnessConfig = &cfg } } // WithSyncAdapter 设置同步适配器和策略配置. // // 升华改进(ELEVATED): 早期实现把同步硬编码为 // Anthropic OAuth + GitHub repo,无法在私有部署或 API 无状态模式使用. // 我们通过 SyncAdapter 接口 + SyncConfig 组合,让调用方完全控制同步行为: // - CLI 模式传 GitSyncAdapter + DefaultSyncConfig(session 开始 Pull 一次,本地胜) // - API 模式传 HTTPSyncAdapter + APISyncConfig(1*time.Minute)(TTL 缓存,服务器胜) // - 离线/不需要同步:不调用此选项(默认 NoopSyncAdapter,IsAvailable=false,零 overhead) // // 替代方案:<在 Store 接口上暴露 Sync() 方法,由调用方手动调用> - // 否决原因:调用方需要知道何时调用 Sync,而引擎更清楚读写时机(DRY 原则). // 反向思维:自动同步会不会在意外时机触发? // 是的,这就是 PullPolicy 存在的原因:PullNever + 手动调用 adapter 是 fallback 方案. func WithSyncAdapter(adapter SyncAdapter, cfg SyncConfig) FileStoreOption { return func(fs *fileStore) { fs.syncAdapter = adapter fs.syncCfg = cfg } } // NewFileStoreWithOptions 创建带配置选项的文件存储. // // 升华改进(ELEVATED): functional options 模式统一所有可配置项. // 比多个 NewFileStoreWithXxx 构造函数更可扩展-- // 新增配置项只需加一个 WithXxx,不用加新构造函数. // 替代方案:每个配置维度一个构造函数(组合爆炸). func NewFileStoreWithOptions(cwd string, opts ...FileStoreOption) Store { fs := &fileStore{ cwd: cwd, baseDir: memoryDirForProject(cwd), } for _, opt := range opts { opt(fs) } return fs } // fileStore 是基于文件系统的记忆存储实现. // // 并发安全:所有公开方法(Save/Delete/List/FindRelevant/UpdateIndex)通过 mu 保护. // // 锁粒度设计: // - Save/Delete/UpdateIndex 持写锁(修改文件系统状态) // - List/FindRelevant 持读锁(只读扫描,允许并发) // - maybePull/maybePush 在锁外调用(syncState.mu 自行保护,且 IO 操作放锁外 // 可避免长时间持有大锁;Pull/Push 只影响远端状态,不影响 fileStore 字段) // // TOCTOU 修复(UpdateIndex): // // 原来 ScanMemoryDir → 构建内容 → WriteFile 三步各自独立, // 并发 Save 可能在 Scan 之后写入新文件,导致索引与实际文件不一致. // 现在 UpdateIndex 持写锁,与 Save/Delete 互斥,消除竞争窗口. // // 精妙之处(CLEVER): 读写锁而非互斥锁--List/FindRelevant 是只读操作, // 持读锁允许多个 FindRelevant 并发运行(对 API 模式下高并发查询有收益). // 替代方案:sync.Mutex(更简单但读操作串行化,高并发场景吞吐量下降). type fileStore struct { mu sync.RWMutex // 保护所有公开方法,读写分离(CLEVER: 见上方并发安全说明) cwd string // 项目工作目录 baseDir string // 记忆文件存储目录 scorer RelevanceScorer // 可注入的评分器,nil 时用默认 TextScorer selector MemorySelector // AI 记忆选择器(可选),nil 时用文本评分回退 typeRegistry *MemoryTypeRegistry // 类型注册表,nil 时用 DefaultTypeRegistry observer flyto.EventObserver // 可观测性接口,nil 时用 noopMemoryObserver 兜底 guard security.SecretGuard // 秘密扫描(可选),nil 时不扫描 strictSymlink bool // 严格符号链接模式:检测到符号链接时拒绝操作 fileHistory Backupper // 写入前备份器(可选),nil 时不备份 // 同步相关(模块 10.2) // // syncAdapter 为 nil 或 IsAvailable()==false 时,所有同步逻辑完全跳过, // 不产生任何锁竞争或时间戳开销--向后兼容. syncAdapter SyncAdapter // 同步后端,nil 时不同步 syncCfg SyncConfig // 同步策略配置 syncSt syncState // 运行时同步状态(含锁,不可复制) // 新鲜度配置(模块 10.4) // // nil 表示不启用新鲜度功能--UpdateIndex 回落到早期方案行为(30天硬编码), // CheckMemoryRelevance 不注入 FreshnessNote. // 历史包袱(LEGACY): 现有调用方未传 FreshnessConfig 时,保留早期方案 30 天行为, // 直到所有调用点迁移到 WithFreshness 后再移除回落逻辑. freshnessConfig *FreshnessConfig } // Dir 返回记忆文件的存储目录(绝对路径). func (s *fileStore) Dir() string { return s.baseDir } // indexThreshold 返回 UpdateIndex 用于某类型记忆的年龄注记阈值. // // 历史包袱(LEGACY): freshnessConfig 为 nil 时回落到早期方案 30 天硬编码. // 当所有调用方都迁移到 WithFreshness 后,此回落逻辑可删除. func (s *fileStore) indexThreshold(t Type) time.Duration { if s.freshnessConfig != nil { return s.freshnessConfig.ThresholdFor(t) } return 30 * 24 * time.Hour // 历史包袱:早期方案 30 天硬编码 } // getObserver 获取 observer,nil 时返回 noop 兜底. func (s *fileStore) getObserver() flyto.EventObserver { if s.observer != nil { return s.observer } return &noopMemoryObserver{} } // memoryDirForProject 计算项目对应的记忆存储目录. // // 路径格式:~/.flyto/projects//memory/ // hash 是 cwd 的 SHA256 前 16 位十六进制字符, // 保证不同项目的记忆互不干扰. func memoryDirForProject(cwd string) string { home, err := os.UserHomeDir() if err != nil { // 极端情况回退到 /tmp home = os.TempDir() } // 精妙之处(CLEVER): 用 cwd 的 SHA256 哈希作为项目标识--不同目录的记忆完全隔离. // 前 8 字节(16 个十六进制字符)的碰撞概率极低(~2^-64),足以做唯一标识. // 比用目录名作为标识安全(目录名可能含特殊字符或过长). hash := sha256.Sum256([]byte(cwd)) hashStr := fmt.Sprintf("%x", hash[:8]) return filepath.Join(home, ".flyto", "projects", hashStr, "memory") } // Save 将一条记忆写入 markdown 文件. // // 文件名由 entry.Name 生成(sanitize 后加 .md 后缀). // 文件内容是 YAML frontmatter + body 的格式. // 如果文件已存在则覆盖(更新语义). func (s *fileStore) Save(ctx context.Context, entry *Entry) error { // 检查 context select { case <-ctx.Done(): return ctx.Err() default: } if err := validateEntryName(entry.Name); err != nil { return err } if entry.Type == "" { entry.Type = TypeProject // 默认类型 } // 写锁保护:从目录创建到文件写入的整个流程原子化. // maybePush 在锁外调用--它是远端 IO,不修改 fileStore 字段, // 持锁期间执行远端操作会导致其他协程的 List/FindRelevant 长时间阻塞. s.mu.Lock() err := s.saveUnlocked(ctx, entry) s.mu.Unlock() if err != nil { return err } // Push 在写锁释放后执行(失败只记录事件,不影响 Save 返回值) s.maybePush(ctx) return nil } // saveUnlocked 是 Save 的无锁核心实现,调用方负责持有 mu.Lock(). // // 精妙之处(CLEVER): 将加锁逻辑与业务逻辑分离-- // saveUnlocked 专注于"写入一条记忆",不关心谁在持锁; // Save 专注于"正确地持锁并调用 saveUnlocked". // 这样 UpdateIndex 也可以在已持写锁时调用 saveUnlocked(若未来有需要), // 不会产生死锁(Go 的 sync.RWMutex 不支持重入). // 替代方案:在 Save 内部直接加锁(代码更短但若 UpdateIndex 需要调用 saveUnlocked 则死锁). func (s *fileStore) saveUnlocked(ctx context.Context, entry *Entry) error { // 确保目录存在 if err := os.MkdirAll(s.baseDir, 0755); err != nil { return fmt.Errorf("memory: create dir: %w", err) } // 生成文件名并验证路径未逃逸 baseDir filename := sanitizeFilename(entry.Name) + ".md" rawPath := filepath.Join(s.baseDir, filename) path, err := confinedPath(s.baseDir, rawPath) if err != nil { return err } // 升华改进(ELEVATED): 符号链接检测--默认记日志,WithStrictSymlink 时拒绝写入. // sanitizeFilename 已经移除了所有路径分隔符,路径遍历不可能发生. // 符号链接的真实威胁很低:攻击者需要先有 ~/.flyto/ 的写权限. // 宽松模式兼容 NFS/Docker/WSL 等场景;严格模式用于高安全要求的服务端部署. // 替代方案:始终阻止写入(更安全但误报率不可接受). if symlinkErr := checkSymlinkWithMode(path, s.getObserver(), s.strictSymlink); symlinkErr != nil { return symlinkErr } // 构建文件内容 // Version 显式设置为 frontmatterCurrentVersion(INF-6): // 确保所有新写入的记忆文件都有正确的版本号,便于未来迁移识别. fm := Frontmatter{ Name: entry.Name, Description: entry.Description, Type: entry.Type, Version: frontmatterCurrentVersion, } content := FormatFrontmatter(fm, entry.Content) // ── 秘密扫描(写入前拦截)── // 精妙之处(CLEVER): 扫描整个序列化后的 content(frontmatter + body), // 而非只扫 entry.Content.因为 Description 字段也可能含敏感信息. // 扫描发生在原子写入之前--如果被拦截,不会产生 .tmp 文件. if s.guard != nil { matches, err := s.guard.Scan(path, content) if err != nil && err != security.ErrContentTooLarge { return fmt.Errorf("memory: secret scan failed: %w", err) } if len(matches) > 0 { labels := strings.Join(security.MatchLabels(matches), ", ") return fmt.Errorf("memory: secret detected in entry %q — %s — cannot save", entry.Name, labels) } // ErrContentTooLarge: 记忆文件超 512KB 极不正常,记日志但放行 if err == security.ErrContentTooLarge { s.getObserver().Event("memory_save_oversized", map[string]any{ "name": entry.Name, "size_bytes": len(content), }) } } // INF-2: 写入前备份--若文件已存在且注入了 Backupper,先备份再写入. // // 精妙之处(CLEVER): 只在文件已存在时备份(新建无需备份). // 备份失败不阻断 Save--fail-open 原则:记忆写入比备份更重要. // 备份异常通过 observer 告警,让监控感知到但不打断用户工作流. // 替代方案:备份失败阻断写入(过于保守,备份目录满时会让整个记忆系统不可用). if s.fileHistory != nil { if _, statErr := os.Stat(path); statErr == nil { // 文件已存在,备份 if backupErr := s.fileHistory.Backup(ctx, path); backupErr != nil { // fail-open:备份失败只记录告警,不阻断写入 s.getObserver().Error(backupErr, map[string]any{ "name": entry.Name, "path": path, "op": "memory_save_backup", }) } } } // 精妙之处(CLEVER): 原子写入--先写 .tmp 临时文件再 rename,避免写入中途崩溃导致 // 记忆文件损坏(半写状态).rename 在 POSIX 文件系统上是原子操作. tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil { return fmt.Errorf("memory: write file: %w", err) } if err := os.Rename(tmpPath, path); err != nil { // 清理临时文件 os.Remove(tmpPath) return fmt.Errorf("memory: rename file: %w", err) } entry.Path = path // 埋点说明:记忆保存是持久化操作--记录名称,类型和大小用于分析 // 记忆增长趋势,防止磁盘空间失控(每个项目的记忆数量没有硬限制). s.getObserver().Event("memory_saved", map[string]any{ "name": entry.Name, "type": string(entry.Type), "size_bytes": len(content), }) // 注意:maybePush 由外层 Save 在写锁释放后调用(非在此处). // Push 是远端 IO,若在锁内调用会导致其他协程的 List/FindRelevant 长时间阻塞. return nil } // List 扫描记忆目录,返回所有记忆(按修改时间倒序). // // 使用 scanner.go 的 ScanMemoryDir 做轻量级扫描(只读 frontmatter), // 然后对每个有效的记忆加载完整内容. func (s *fileStore) List(ctx context.Context) ([]*Entry, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } // Pull 在读锁外执行--Pull 是远端 IO,若在锁内执行会阻塞并发的 Save/Delete. // Pull 失败只记录事件,不阻断 List -- fail-open 原则. s.maybePull(ctx) s.mu.RLock() defer s.mu.RUnlock() headers, err := ScanMemoryDir(s.baseDir) if err != nil { return nil, fmt.Errorf("memory: scan dir: %w", err) } entries := make([]*Entry, 0, len(headers)) for _, h := range headers { // 加载完整文件内容 data, err := os.ReadFile(h.Path) if err != nil { continue // 跳过无法读取的文件 } _, body, _ := ParseFrontmatter(string(data)) entries = append(entries, &Entry{ Name: h.Frontmatter.Name, Description: h.Frontmatter.Description, Type: h.Frontmatter.Type, Content: body, Path: h.Path, ModTime: h.ModTime, }) } return entries, nil } // FindRelevant 查找与查询最相关的记忆. // // 设计决策:暂时用文本相似度代替模型调用. // 原项目在这里调用 Claude 模型评估相关性,但这会消耗 token 且增加延迟. // Go 版本的策略: // 1. 先用 ScanMemoryDir 获取所有记忆的 frontmatter(轻量级) // 2. 用 relevance.go 的 Score 函数计算文本相似度 // 3. 选出 top-N 个相关记忆 // 4. 只对选出的记忆加载完整内容 // // 后续可以替换为嵌入向量检索或模型评估. func (s *fileStore) FindRelevant(ctx context.Context, query string, limit int) ([]*Entry, error) { start := time.Now() select { case <-ctx.Done(): return nil, ctx.Err() default: } // Pull 在读锁外执行--与 List 对称,Pull 是远端 IO 不应持锁. // 精妙之处(CLEVER): List 和 FindRelevant 都调用 maybePull, // 但 PullOnSessionStart 策略下只有第一个调用者真正执行 Pull(syncState.pulled 标志保护). // 两处都调用是为了:无论用户先调 List 还是先调 FindRelevant,都能触发初始同步. s.maybePull(ctx) s.mu.RLock() defer s.mu.RUnlock() if limit <= 0 { limit = 5 } // 第一步:轻量级扫描获取所有记忆头信息 headers, err := ScanMemoryDir(s.baseDir) if err != nil { return nil, fmt.Errorf("memory: scan dir: %w", err) } if len(headers) == 0 { // 埋点说明:空结果搜索也需要记录--频繁的空结果搜索 // 说明记忆库为空或查询与记忆不匹配,需要优化引导策略. s.getObserver().Event("memory_search", map[string]any{ "query_length": len(query), "results": 0, "duration_ms": time.Since(start).Milliseconds(), }) return nil, nil } // 第二步:选出相关记忆 // 升华改进(ELEVATED): 优先使用 AI 选择器(精准但有延迟),降级为文本评分(快速但粗糙). // // 精妙之处(CLEVER): fallback 由 Store 层做 (而非 selector 内部) 才能访问 s.scorer. // 历史 bug (2026-04-14 修复): selector 内部 fallback 曾传 nil scorer, 静默忽略 // 用户通过 WithScorer 配置的自定义评分器. 修复后 selector 只在 ctx 取消时返回 // (nil, nil), 其他错误上抛, 此处接住并用 s.scorer 降级到文本评分. var relevant []MemoryHeader if s.selector != nil { selected, err := s.selector.Select(ctx, query, headers, SelectOpts{Limit: limit}) if err != nil { // ctx 取消: 向上传播, 不 fallback. if ctx.Err() != nil { return nil, fmt.Errorf("memory: selector: %w", err) } // 非 ctx 错误: 降级到 Store 层文本评分, 传 s.scorer (可能是 WithScorer 注入的自定义评分器). // 发埋点事件让调用方可感知 fallback 触发 - 之前的静默降级是另一个隐性问题, 一并修复. s.getObserver().Event("memory_selector_fallback", map[string]any{ "reason": err.Error(), "query_length": len(query), }) relevant = SelectRelevant(query, headers, limit, s.scorer) } else { relevant = selected } } else { relevant = SelectRelevant(query, headers, limit, s.scorer) } if len(relevant) == 0 { s.getObserver().Event("memory_search", map[string]any{ "query_length": len(query), "results": 0, "duration_ms": time.Since(start).Milliseconds(), }) return nil, nil } // 第三步:只对选出的记忆加载完整内容 entries := make([]*Entry, 0, len(relevant)) for _, h := range relevant { data, err := os.ReadFile(h.Path) if err != nil { continue } _, body, _ := ParseFrontmatter(string(data)) entries = append(entries, &Entry{ Name: h.Frontmatter.Name, Description: h.Frontmatter.Description, Type: h.Frontmatter.Type, Content: body, Path: h.Path, ModTime: h.ModTime, }) } // 埋点说明:记忆搜索是每次对话开始时的关键操作-- // 搜索耗时影响首次响应延迟,结果数量影响上下文质量. s.getObserver().Event("memory_search", map[string]any{ "query_length": len(query), "results": len(entries), "duration_ms": time.Since(start).Milliseconds(), }) return entries, nil } // Delete 删除指定名称的记忆文件. // // 查找逻辑:先尝试精确文件名匹配(sanitize(name) + ".md"), // 如果不存在则扫描目录查找 frontmatter.name 匹配的文件 // (因为文件名可能被 sanitize 改变). func (s *fileStore) Delete(ctx context.Context, name string) error { select { case <-ctx.Done(): return ctx.Err() default: } if err := validateEntryName(name); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() // 先尝试精确文件名匹配 filename := sanitizeFilename(name) + ".md" rawPath := filepath.Join(s.baseDir, filename) path, pathErr := confinedPath(s.baseDir, rawPath) if pathErr != nil { return pathErr } if _, err := os.Stat(path); err == nil { if symlinkErr := checkSymlinkWithMode(path, s.getObserver(), s.strictSymlink); symlinkErr != nil { return symlinkErr } if rmErr := os.Remove(path); rmErr == nil { // 埋点说明:记忆删除需要审计--谁删了什么记忆,用于安全审计和误删恢复. s.getObserver().Event("memory_deleted", map[string]any{ "name": name, }) return nil } else { return rmErr } } // 精确匹配失败,扫描目录查找 frontmatter.name 匹配的文件 // 精妙之处(CLEVER): 写锁内 stat+scan+remove 三步原子化-- // 若在锁外 stat 成功但锁内文件已被并发 Delete 删除,os.Remove 会返回 ErrNotExist. // 锁内做确保"确认存在→删除"是原子操作,消除 TOCTOU 竞争. headers, err := ScanMemoryDir(s.baseDir) if err != nil { return fmt.Errorf("memory: scan dir: %w", err) } for _, h := range headers { if h.Frontmatter.Name == name { if rmErr := os.Remove(h.Path); rmErr == nil { s.getObserver().Event("memory_deleted", map[string]any{ "name": name, }) return nil } else { return rmErr } } } return fmt.Errorf("memory: not found: %s", name) } // getTypeRegistry 返回当前使用的类型注册表. // nil 时回退到 DefaultTypeRegistry. func (s *fileStore) getTypeRegistry() *MemoryTypeRegistry { if s.typeRegistry != nil { return s.typeRegistry } return DefaultTypeRegistry } // UpdateIndex 更新 MEMORY.md 索引文件. // // 升华改进(ELEVATED): 从注册表动态获取类型列表,不再硬编码 4 种. // 新场景注册的类型自动出现在索引中. // 替代方案:硬编码 grouped map(只支持 4 种类型). // // MEMORY.md 是记忆目录的索引,列出所有记忆文件的链接和描述. // 原项目用它作为快速预览,也可以手动编辑. // // 格式: // // # Memory Index // // ## User // - [memory_name](memory_name.md) - description // // ## Feedback // - ... func (s *fileStore) UpdateIndex(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() default: } // 写锁保护:ScanMemoryDir → 构建索引内容 → WriteFile 三步原子化. // // TOCTOU 修复:若不加锁,并发 Save 可能在 ScanMemoryDir 之后写入新文件, // 导致索引比实际文件少一条;并发 Delete 可能导致索引比实际文件多一条. // 写锁与 Save/Delete 互斥,确保"扫描→索引"快照一致性. // // 精妙之处(CLEVER): UpdateIndex 用写锁而非读锁-- // 它最终要写 MEMORY.md,与 Save/Delete 都是写操作,需要互斥. // 读锁只允许与其他读操作并发,不允许与写操作并发,不适用此场景. // 替代方案:读锁扫描 + 写锁写文件(两次锁切换期间状态可能改变,仍有 TOCTOU). s.mu.Lock() defer s.mu.Unlock() headers, err := ScanMemoryDir(s.baseDir) if err != nil { return fmt.Errorf("memory: scan dir: %w", err) } // 动态收集:从实际文件中的类型分组 grouped := make(map[string][]MemoryHeader) for _, h := range headers { typeName := string(h.Frontmatter.Type) grouped[typeName] = append(grouped[typeName], h) } // 构建索引内容 var sb strings.Builder sb.WriteString("# Memory Index\n\n") sb.WriteString(fmt.Sprintf("_Auto-generated at %s. Do not edit manually._\n\n", time.Now().Format("2006-01-02 15:04:05"))) // 升华改进(ELEVATED): 按注册表排序输出. // 已注册的类型按 SortOrder 在前,未注册的在后. // 替代方案:硬编码 typeOrder 数组(每加一种类型改一处). registry := s.getTypeRegistry() registeredTypes := registry.All() // 已注册类型的输出集合(用于后面去重) written := make(map[string]bool) // 第一轮:已注册的类型,按 SortOrder 排序输出 for _, typeInfo := range registeredTypes { entries, exists := grouped[typeInfo.Name] if !exists || len(entries) == 0 { continue } written[typeInfo.Name] = true sb.WriteString("## ") sb.WriteString(typeLabel(typeInfo)) sb.WriteString("\n\n") for _, h := range entries { relPath, _ := filepath.Rel(s.baseDir, h.Path) if relPath == "" { relPath = filepath.Base(h.Path) } sb.WriteString(fmt.Sprintf("- [%s](%s)", h.Frontmatter.Name, relPath)) if h.Frontmatter.Description != "" { sb.WriteString(" - ") sb.WriteString(h.Frontmatter.Description) } // 记忆年龄注记(模块 10.4 新鲜度) // 升华改进(ELEVATED): 早期方案硬编码 30 天阈值,现在通过 FreshnessConfig 可配置. // nil freshnessConfig 时回落到 30 天,保持向后兼容. sb.WriteString(IndexAnnotation(h.ModTime, s.indexThreshold(h.Frontmatter.Type))) sb.WriteString("\n") } sb.WriteString("\n") } // 第二轮:未注册的类型归到"其他"分组 // 精妙之处(CLEVER): 未注册的类型不会丢失,归入"其他"分组. // 新场景先注册类型再使用是最佳实践,但遗留数据不会因此消失. var otherHeaders []MemoryHeader for typeName, entries := range grouped { if !written[typeName] { otherHeaders = append(otherHeaders, entries...) } } if len(otherHeaders) > 0 { sb.WriteString("## Other (其他)\n\n") for _, h := range otherHeaders { relPath, _ := filepath.Rel(s.baseDir, h.Path) if relPath == "" { relPath = filepath.Base(h.Path) } sb.WriteString(fmt.Sprintf("- [%s](%s)", h.Frontmatter.Name, relPath)) if h.Frontmatter.Description != "" { sb.WriteString(" - ") sb.WriteString(h.Frontmatter.Description) } sb.WriteString(IndexAnnotation(h.ModTime, s.indexThreshold(h.Frontmatter.Type))) sb.WriteString("\n") } sb.WriteString("\n") } // 确保目录存在 if err := os.MkdirAll(s.baseDir, 0755); err != nil { return fmt.Errorf("memory: create dir: %w", err) } // TruncateIndex 应用双重截断(200行 / 25KB),防止超大 MEMORY.md 压缩 context window. indexContent := TruncateIndex(sb.String()) indexPath := filepath.Join(s.baseDir, "MEMORY.md") if err := os.WriteFile(indexPath, []byte(indexContent), 0644); err != nil { return err } // 埋点说明:索引更新记录条目数--用于追踪记忆库增长趋势和更新频率. s.getObserver().Event("memory_index_updated", map[string]any{ "entries": len(headers), }) return nil } // validateEntryName 验证记忆条目名称是否合法. // // 规则: // - 名称不能为空 // - 名称不能包含路径分隔符(/ 和 \),防止路径遍历 // - 名称长度不超过 255 字节(文件系统普遍限制) // // 精妙之处(CLEVER): 把名称验证从 Save 内联逻辑提取为独立函数, // 便于在 Delete/测试中复用,也让 Save 的主流程更清晰. // 反向思维:sanitizeFilename 已经过滤路径分隔符,validateEntryName 是否多余? // 答:sanitizeFilename 是"处理"(静默丢弃非法字符),validateEntryName 是"拒绝"(明确报错). // 两层防御:前者保证文件名安全,后者保证输入意图合理(含路径分隔符通常是 bug). func validateEntryName(name string) error { if name == "" { return fmt.Errorf("memory: name is required") } if strings.ContainsAny(name, "/\\") { return fmt.Errorf("memory: name must not contain path separators: %q", name) } if len(name) > 255 { return fmt.Errorf("memory: name too long (%d bytes, max 255)", len(name)) } return nil } // validateBaseDir 验证 baseDir 路径安全性. // // 规则: // - baseDir 必须是绝对路径(相对路径容易被 CWD 变化影响) // - baseDir 不能包含 ".." 组件(防止上溯到系统目录) // // 升华改进(ELEVATED): 将 baseDir 验证从构造函数提取出来, // 同时也是 confinedPath 的前置验证--无效 baseDir 会导致 confinedPath 失效. // 替代方案:在 NewFileStore 中 panic(但 panic 不适合库代码). func validateBaseDir(baseDir string) error { if !filepath.IsAbs(baseDir) { return fmt.Errorf("memory: baseDir must be absolute path, got: %q", baseDir) } // 精妙之处(CLEVER): 检查原始路径(非 Clean 后)中是否含 ".." 路径组件. // filepath.Clean 会把 ".." 解析掉(如 /tmp/foo/../../etc → /etc), // 所以不能用 Clean 后的路径检查--必须在 Clean 之前扫描各个路径组件. // 含 ".." 的 baseDir 几乎都是误操作或注入,直接拒绝. for _, component := range strings.Split(baseDir, string(filepath.Separator)) { if component == ".." { return fmt.Errorf("memory: baseDir must not contain '..': %q", baseDir) } } return nil } // confinedPath 确认 path 在 baseDir 范围内(路径监禁). // // 精妙之处(CLEVER): 两层路径确认-- // 1. filepath.Clean 消除 "/../" 式遍历 // 2. strings.HasPrefix 检查最终路径是否在 baseDir 下 // // 返回清理后的绝对路径,或 error(如果路径逃逸出 baseDir). // // 反向思维:sanitizeFilename 已防路径遍历,confinedPath 是否过度防御? // 答:sanitizeFilename 只处理"文件名"部分;如果调用者传入的是拼接后的路径, // 中间的目录组件可能含 "..",需要 confinedPath 做最终把关. // 替代方案:<依赖 sanitizeFilename 就够了>--否决原因:深度防御,层层把关. func confinedPath(baseDir, path string) (string, error) { // 先清理双方路径 cleanBase := filepath.Clean(baseDir) cleanPath := filepath.Clean(path) // 确保 baseDir 以分隔符结尾再比较,防止 /tmp/foo 误匹配 /tmp/foobar prefix := cleanBase if !strings.HasSuffix(prefix, string(filepath.Separator)) { prefix += string(filepath.Separator) } if cleanPath != cleanBase && !strings.HasPrefix(cleanPath, prefix) { return "", fmt.Errorf("memory: path %q escapes baseDir %q", path, baseDir) } return cleanPath, nil } // checkSymlinkForAudit 检测文件是否是符号链接,如果是则记录日志供审计. // 不阻止操作--阻止会在 NFS/Docker/WSL 等环境下误报. // 安全依赖 sanitizeFilename 消除路径遍历,符号链接检测只是审计补充. func checkSymlinkForAudit(path string) { checkSymlinkWithObserver(path, nil) } // checkSymlinkWithObserver 带 Observer 的符号链接检测. // strict=true 时检测到符号链接返回非 nil 错误;false 时仅记录日志. // // 升华改进(ELEVATED): 增加 strict 参数支持双模式-- // 默认宽松(向后兼容),高安全场景可用 WithStrictSymlink 启用强制拒绝. // 替代方案:新建 checkSymlinkStrict 函数(代码重复). func checkSymlinkWithObserver(path string, obs flyto.EventObserver) { checkSymlinkWithMode(path, obs, false) } // checkSymlinkWithMode 符号链接检测核心,支持宽松/严格两种模式. // 返回 error(严格模式下检测到符号链接),或 nil(宽松模式/无符号链接). func checkSymlinkWithMode(path string, obs flyto.EventObserver, strict bool) error { info, err := os.Lstat(path) if err != nil { return nil // 文件不存在,新建场景,无需检查 } if info.Mode()&os.ModeSymlink == 0 { return nil // 不是符号链接 } realPath, resolveErr := filepath.EvalSymlinks(path) if resolveErr != nil { // 精妙之处(CLEVER): EvalSymlinks 失败说明是悬空链接或循环链接. // 记录但不阻止--后续写入会创建新文件覆盖链接. fmt.Fprintf(os.Stderr, "[memory:audit] symlink detected but cannot resolve: %s (%v)\n", path, resolveErr) if strict { return fmt.Errorf("memory: dangling symlink at %q: %w", path, resolveErr) } return nil } fmt.Fprintf(os.Stderr, "[memory:audit] symlink detected: %s -> %s\n", path, realPath) // 埋点说明:符号链接检测是安全审计事件-- // 攻击者可能通过符号链接将记忆文件指向敏感路径,需要告警并人工复查. if obs != nil { obs.Event("memory_symlink_detected", map[string]any{ "path": path, "target": realPath, }) } if strict { return fmt.Errorf("memory: symlink not allowed at %q (strict mode), target: %q", path, realPath) } return nil } // sanitizeFilename 将记忆名称转换为安全的文件名. // // 替换规则: // - 空格 → 下划线 // - 移除非字母/数字/下划线/连字符的字符 // - 转小写 // - 截断到 100 字符(避免文件名过长) func sanitizeFilename(name string) string { name = strings.ToLower(name) name = strings.ReplaceAll(name, " ", "_") var result []rune for _, r := range name { switch { case r >= 'a' && r <= 'z': result = append(result, r) case r >= '0' && r <= '9': result = append(result, r) case r == '_' || r == '-': result = append(result, r) // 保留 CJK 字符 case isCJK(r): result = append(result, r) default: // 跳过其他字符 } } s := string(result) if len(s) > 100 { s = s[:100] } if s == "" { s = "unnamed" } return s } // ───────────────────────────────────────────────────────────────────────────── // 同步辅助方法(模块 10.2) // ───────────────────────────────────────────────────────────────────────────── // maybePull 按 PullPolicy 决定是否执行 Pull. // // 设计约定: // - 失败时只记录 observer 事件,不向上传播错误(fail-open) // - 不阻塞调用方的读操作--即使 Pull 失败,List/FindRelevant 仍用本地缓存继续 // // 精妙之处(CLEVER): fail-open 在离线场景下尤其重要-- // 用户在飞机/地铁上使用 CLI 时,同步失败不应中断整个 session. // 反向思维:fail-open 会不会让用户一直用过期数据? // 是的,但相比"拒绝提供服务",用过期数据是更好的权衡. // 调用方可以监听 memory_sync_pull_error 事件,自行决定是否告警. func (s *fileStore) maybePull(ctx context.Context) { if !s.syncSt.shouldPull(s.syncAdapter, s.syncCfg) { return } pulled, err := s.syncAdapter.Pull(ctx, s.baseDir) if err != nil { s.getObserver().Error(err, map[string]any{ "op": "pull", "baseDir": s.baseDir, }) s.getObserver().Event("memory_sync_pull_error", map[string]any{ "error": err.Error(), "baseDir": s.baseDir, }) return } if pulled > 0 { s.getObserver().Event("memory_sync_pulled", map[string]any{ "files_pulled": pulled, "baseDir": s.baseDir, }) } } // maybePush 在写操作后尝试 Push. // // 设计约定: // - 同 maybePull,失败只记录事件,不影响 Save 返回值 // - Push 是同步的(不是 fire-and-forget),避免 goroutine 泄漏 // - 如果 adapter.IsAvailable() 为 false,立即返回(无锁,无 I/O) func (s *fileStore) maybePush(ctx context.Context) { if s.syncAdapter == nil || !s.syncAdapter.IsAvailable() { return } pushed, err := s.syncAdapter.Push(ctx, s.baseDir, s.syncCfg.ConflictPolicy) if err != nil { s.getObserver().Error(err, map[string]any{ "op": "push", "conflict_policy": s.syncCfg.ConflictPolicy.String(), "baseDir": s.baseDir, }) s.getObserver().Event("memory_sync_push_error", map[string]any{ "error": err.Error(), "conflict_policy": s.syncCfg.ConflictPolicy.String(), "baseDir": s.baseDir, }) return } if pushed > 0 { s.getObserver().Event("memory_sync_pushed", map[string]any{ "files_pushed": pushed, "conflict_policy": s.syncCfg.ConflictPolicy.String(), "baseDir": s.baseDir, }) } }