// Package memory 的记忆目录扫描器. // // 递归扫描指定目录下的 .md 文件,只读前 30 行提取 frontmatter(性能优化), // 按 mtime 排序返回. package memory import ( "bufio" "io/fs" "os" "path/filepath" "sort" "strings" "time" ) // maxScanFiles 是单次扫描的最大文件数限制. // 防止极端情况下目录中有大量文件导致内存爆炸. const maxScanFiles = 200 // maxFrontmatterLines 是提取 frontmatter 时最多读取的行数. // 原项目用 30 行,足以覆盖任何合理的 frontmatter. const maxFrontmatterLines = 30 // MemoryHeader 是记忆文件的头部信息(轻量级,不含正文). // 用于列表展示和相关性评估,避免加载完整内容. type MemoryHeader struct { Frontmatter Frontmatter // 解析出的 frontmatter 元数据 Path string // 文件绝对路径 ModTime time.Time // 文件最后修改时间 } // ScanMemoryDir 递归扫描目录下的所有 .md 记忆文件. // // 设计决策: // - 排除 MEMORY.md 索引文件(它不是记忆本身) // - 只读前 30 行提取 frontmatter,不加载完整文件(性能优化) // - 最多扫描 200 个文件(防止意外扫描到巨大目录) // - 结果按 mtime 倒序排列(最新的在前面) // // 如果目录不存在,返回空切片而非错误(目录尚未创建是正常情况). func ScanMemoryDir(dir string) ([]MemoryHeader, error) { // 目录不存在时直接返回空结果 info, err := os.Stat(dir) if err != nil || !info.IsDir() { return nil, nil } var headers []MemoryHeader count := 0 err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { // 跳过无法访问的目录/文件 return nil } // 跳过目录本身 if d.IsDir() { return nil } // 只处理 .md 文件 if !strings.HasSuffix(strings.ToLower(d.Name()), ".md") { return nil } // 排除 MEMORY.md 索引文件 if strings.EqualFold(d.Name(), "MEMORY.md") { return nil } // 达到文件数上限,停止遍历 count++ if count > maxScanFiles { return filepath.SkipAll } // 获取文件修改时间 fileInfo, err := d.Info() if err != nil { return nil } // P2 路径确认:确保 WalkDir 返回的路径在 dir 范围内. // 正常情况下 WalkDir 只遍历子树,但如果 dir 本身是符号链接, // 或 EvalSymlinks 行为异常,WalkDir 可能越界. // 精妙之处(CLEVER): 用 filepath.Rel 而非 strings.HasPrefix 做路径确认-- // 后者在 /tmp/foo 匹配 /tmp/foobar 时会误判; // filepath.Rel 基于路径语义,不受字符串前缀的影响. rel, relErr := filepath.Rel(dir, path) if relErr != nil || strings.HasPrefix(rel, "..") { // 路径逃逸,静默跳过并记录 return nil } // 只读前 N 行提取 frontmatter fm, err := readFrontmatterFromFile(path) if err != nil { // 无法读取的文件跳过 return nil } if fm.Name == "" { // 没有有效 frontmatter 的文件跳过 return nil } // 精妙之处(CLEVER): Version == 0 是旧文件(无 version 字段)的标志. // 规范化为 1 而非跳过--旧文件是有效的 v1 数据,不应因为缺少 version 字段而丢失. // 这也保证迁移骨架能正确处理旧文件(migrateFrontmatter 从 v1 开始,而非 v0). if fm.Version == 0 { fm.Version = frontmatterCurrentVersion } // MaxSupportedVersion 保护(INF-6): // 若记忆文件的 frontmatter 版本高于本引擎支持的最高版本, // 跳过该文件而非静默读取--静默读取后以旧格式回写会丢失新字段. // 跳过而非报错:单条记忆损坏不应阻止整个记忆库的读取. if fm.Version > frontmatterMaxSupportedVersion { // 历史包袱(LEGACY): 此处无法注入 observer(ScanMemoryDir 不接受 observer 参数). // 理想做法:将警告发到 observer (flyto.EventObserver),让调用方感知. // 现阶段静默跳过,等 observer 注入重构完成后补充. return nil } // 运行迁移骨架(当前 frontmatterMigrations 为空,是 no-op). // 调用点预置在此处:未来注册迁移函数后,此处代码无需修改. if err := migrateFrontmatter(&fm); err != nil { // 迁移失败的文件跳过(不阻塞其他记忆读取) return nil } headers = append(headers, MemoryHeader{ Frontmatter: fm, Path: path, ModTime: fileInfo.ModTime(), }) return nil }) if err != nil { return nil, err } // 按 mtime 倒序排列(最新的在前面) sort.Slice(headers, func(i, j int) bool { return headers[i].ModTime.After(headers[j].ModTime) }) return headers, nil } // readFrontmatterFromFile 从文件中只读前 maxFrontmatterLines 行来提取 frontmatter. // // 性能优化:不加载完整文件内容,使用 bufio.Scanner 逐行读取, // 读到足够的行数就停止.对于大文件(如嵌入了代码片段的参考记忆)很有效. func readFrontmatterFromFile(path string) (Frontmatter, error) { f, err := os.Open(path) if err != nil { return Frontmatter{}, err } defer f.Close() scanner := bufio.NewScanner(f) var lines []string lineCount := 0 for scanner.Scan() { lines = append(lines, scanner.Text()) lineCount++ if lineCount >= maxFrontmatterLines { break } } if err := scanner.Err(); err != nil { return Frontmatter{}, err } content := strings.Join(lines, "\n") fm, _, ok := ParseFrontmatter(content) if !ok { return Frontmatter{}, nil } return fm, nil }