// Package memory 的 YAML frontmatter 解析器. // // 手写解析,不引入第三方 YAML 库. // Frontmatter 格式:文件以 "---" 开头,到下一个 "---" 结束, // 中间每行是 "key: value" 格式. // // 版本兼容(INF-6): // Frontmatter 加入 `version` 字段,用于未来 breaking change 的迁移路径. // 当前所有文件写入 version: 1.旧文件(无 version 字段)读取时默认为 1. package memory import ( "fmt" "strconv" "strings" ) // frontmatterCurrentVersion 是当前引擎写入 .md 记忆文件时使用的 frontmatter 版本号. // // 与 transcriptCurrentVersion 设计对称:具名常量,未来 bump 只改这一行. const frontmatterCurrentVersion = 1 // frontmatterMaxSupportedVersion 是本版本引擎能读取的最高 frontmatter 版本号. // // 读到 version > maxSupported 的记忆文件时,ScanMemoryDir 跳过该文件并记录警告, // 而非静默读取(防止新字段被忽略后写回旧格式造成数据损坏). // // 历史包袱(LEGACY): 当前 max == current == 1,此常量暂无实际拦截效果. // 未来引入 v2 格式前,务必先更新此值. const frontmatterMaxSupportedVersion = frontmatterCurrentVersion // Frontmatter 是记忆文件的元数据头部. // 对应原项目中每个 .md 文件的 YAML frontmatter 部分. // // 版本兼容(INF-6): // // Version 字段记录 frontmatter 格式版本. // 旧文件(无 version 行)解析后 Version == 0,ScanMemoryDir/ParseFrontmatter // 调用方将其规范化为 1(对应 frontmatterCurrentVersion). type Frontmatter struct { Name string // 记忆名称(唯一标识) Description string // 一行描述,用于相关性检索 Type Type // 记忆类型:user/feedback/project/reference // Version 是 frontmatter 格式 schema 版本(从 1 开始). // 0 = 未设置(旧文件),调用方应规范化为 1. Version int } // ParseFrontmatter 从 markdown 内容中解析出 frontmatter 和正文. // // 设计决策:手写解析而非引入 gopkg.in/yaml.v3,因为 // frontmatter 格式非常简单(几个 key),不值得引入依赖. // 解析逻辑:找到两个 "---" 分隔符之间的行,按 "key: value" 拆分. // // 返回值: // - fm: 解析出的 frontmatter,如果无法解析则返回零值 // - body: frontmatter 之后的正文内容 // - ok: 是否成功解析出 frontmatter // // 注意:fm.Version == 0 表示旧文件(无 version 字段),调用方应规范化为 1. func ParseFrontmatter(content string) (fm Frontmatter, body string, ok bool) { lines := strings.Split(content, "\n") // 第一行必须是 "---" if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { return Frontmatter{}, content, false } // 找第二个 "---" 的位置 endIdx := -1 for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == "---" { endIdx = i break } } if endIdx < 0 { // 没找到结束分隔符 return Frontmatter{}, content, false } // 解析 key: value 行 for i := 1; i < endIdx; i++ { line := strings.TrimSpace(lines[i]) if line == "" { continue } // 按第一个 ":" 分割(注意冒号后面跟空格,避免误切 URL) colonIdx := strings.Index(line, ":") if colonIdx < 0 { continue } key := strings.TrimSpace(line[:colonIdx]) value := strings.TrimSpace(line[colonIdx+1:]) switch key { case "name": fm.Name = value case "description": fm.Description = value case "type": fm.Type = Type(value) case "version": // 精妙之处(CLEVER): 版本解析失败时静默保留 0(旧文件语义), // 而非返回 error 导致整条记忆不可用. // 格式损坏的 version 字段比缺失 version 字段更少见, // 两者都应该被调用方规范化为 1. if v, err := strconv.Atoi(value); err == nil { fm.Version = v } } } // 正文是结束分隔符之后的所有内容 // 跳过紧跟分隔符的空行 bodyStart := endIdx + 1 if bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" { bodyStart++ } if bodyStart < len(lines) { body = strings.Join(lines[bodyStart:], "\n") } ok = fm.Name != "" return fm, body, ok } // FormatFrontmatter 将 frontmatter 和正文组合成完整的 markdown 内容. // // 输出格式: // // --- // name: xxx // description: xxx // type: xxx // version: 1 // --- // 正文内容 // // 升华改进(ELEVATED): 早期方案不写 version 字段,此处新增. // 向后兼容:旧版本引擎读到 version 字段时,ParseFrontmatter 的 switch // 会静默忽略未知 key(走到 default,不做任何事)-- // 实际上由于我们现在处理了 "version" key,旧引擎需要重新编译才能正确解析. // 但 JSON 层面旧引擎读不到 version 字段也不会报错(只是 fm.Version == 0). // 替代方案:<只在 v2 时才写 version 字段,v1 不写> - // 否决原因:如果 v1 文件不写版本,升级到 v2 时无法区分"v1 文件"和"v0 旧文件". func FormatFrontmatter(fm Frontmatter, body string) string { // 写入时总是使用 currentVersion(调用方传的 fm.Version 被忽略, // 确保写出的永远是当前版本格式) v := fm.Version if v <= 0 { v = frontmatterCurrentVersion } var sb strings.Builder sb.WriteString("---\n") sb.WriteString("name: ") sb.WriteString(fm.Name) sb.WriteString("\n") sb.WriteString("description: ") sb.WriteString(fm.Description) sb.WriteString("\n") sb.WriteString("type: ") sb.WriteString(string(fm.Type)) sb.WriteString("\n") sb.WriteString(fmt.Sprintf("version: %d\n", v)) sb.WriteString("---\n") if body != "" { sb.WriteString(body) // 确保文件以换行符结尾 if !strings.HasSuffix(body, "\n") { sb.WriteString("\n") } } return sb.String() }