diff --git a/cmd/homed-kb-migrate/main.go b/cmd/homed-kb-migrate/main.go new file mode 100644 index 0000000..6caf05f --- /dev/null +++ b/cmd/homed-kb-migrate/main.go @@ -0,0 +1,100 @@ +// Command homed-kb-migrate 迁移存量知识库的目录名到规范名。 +// +// 背景:旧版 Add 对名字**整串** sanitize、对路径**逐段** sanitize, +// 于是知识名(内存键 / LLM 可见的名字)与盘上目录从第一次落盘起就对不上。 +// 典型残留: +// +// tech/_go_/note 分类段内的空格未被 TrimSpace 掉 +// Tech/Upper 未小写化 +// a/b with space 空格未替换成下划线 +// +// 迁移把它们重命名到规范名,使三者一致。 +// +// 安全设计: +// 1. **默认只报告**(-apply 才真改名)。批量 os.Rename 不可逆。 +// 2. 检出目标名冲突则**整批拒绝**,不做部分迁移——半迁移状态比不迁移更难收拾。 +// 3. 单条失败不中断整体,最后统一报告;执行前再查一次目标越界。 +// +// 与 homed 启动时的关系:`homed` 启动会调同一个 PlanMigration 并**只报告** +// (见 cmd/homed/bootstrap.go 的 defaultApply)。本命令是人工确认后真正执行 +// 的那一步。两者共用 internal/knowledge 里的同一份实现,避免口径漂移。 +// +// 用法: +// +// homed-kb-migrate -root /data/homeagent/knowledge # 报告 +// homed-kb-migrate -root /data/homeagent/knowledge -apply # 执行 +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" +) + +func main() { + root := flag.String("root", "", "知识库根目录(必填)") + apply := flag.Bool("apply", false, "真正执行重命名(缺省只报告)") + dryRun := flag.Bool("dry-run", false, "只报告(显式写法,与默认相同)") + limit := flag.Int("limit", 0, "单次最多改名条数,0 = 不限") + flag.Parse() + + if *root == "" { + fmt.Fprintln(os.Stderr, "错误:必须指定 -root <知识库根目录>") + flag.Usage() + os.Exit(2) + } + if *apply && *dryRun { + fmt.Fprintln(os.Stderr, "错误:-apply 与 -dry-run 互斥") + os.Exit(2) + } + + abs, err := filepath.Abs(*root) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:%v\n", err) + os.Exit(1) + } + abs = filepath.Clean(abs) + + items, err := knowledge.PlanMigration(abs) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:无法读取 %s:%v\n", abs, err) + os.Exit(1) + } + if len(items) == 0 { + fmt.Printf("未发现任何知识条目(%s)\n", abs) + return + } + + var need, illegal int + for _, it := range items { + switch { + case it.Illegal: + illegal++ + fmt.Printf(" [非法] %-40s 含 .. / 点段 / 隐藏段;写入与删除均已拒绝,需人工处理\n", it.OldName) + case it.NewName != "": + need++ + fmt.Printf(" [迁移] %-40s → %s\n", it.OldName, it.NewName) + } + } + fmt.Printf("\n共 %d 条:需迁移 %d,已规范 %d,非法 %d\n", + len(items), need, len(items)-need-illegal, illegal) + + if !*apply { + if need == 0 { + fmt.Println("\n无需迁移。加 -apply 不会改变任何东西。") + return + } + fmt.Println("\n这是报告(未改动任何文件)。确认无误后加 -apply 执行。") + return + } + + applied, failed := knowledge.ApplyMigration(abs, items, *limit) + fmt.Printf("\n迁移完成:成功 %d,失败 %d\n", applied, failed) + if failed > 0 { + fmt.Fprintln(os.Stderr, "存在失败项。若为名称冲突,请先人工处理冲突的目录再重跑。") + os.Exit(1) + } +} diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 9dc8a1f..683b17a 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -378,6 +378,61 @@ func initKnowledgeStore(cfg *types.Config) *knowledge.Store { return ks } +// initKnowledgeMigration 在知识库扫盘**之前**把存量目录名规范化。 +// +// 为何不靠 Store 内部自己做:规范名是「内存键 + 盘上目录 + LLM 可见名字」 +// 三者必须逐字一致,而磁盘重命名属于有破坏性的副作用,应该在 store 扫盘 +// 之前、在明确的边界上一次性做完,而不是散在 Store 的初始化路径里。 +// +// 为何默认只报告:os.Rename 不可逆,批量重命名生产数据必须由人确认。 +// 需要真正迁移时用 homed-kb-migrate -apply(或把下面 defaultApply 打开)。 +// +// 本函数体同样遵守 bootstrap 的平移原则。 +func initKnowledgeMigration(cfg *types.Config) { + root := filepath.Join(cfg.Daemon.DataDir, "knowledge") + const ( + // defaultApply = false ⇒ 启动时只扫描并报告,不改名。 + defaultApply = false + // maxRenamePerRun 限制单次重命名数:给失控的目录规模设一个上限, + // 避免启动阶段被一次大迁移拖住。 + maxRenamePerRun = 200 + ) + items, err := knowledge.PlanMigration(root) + if err != nil { + log.Printf("[homed] 知识库迁移扫描失败(跳过): %v", err) + return + } + need, illegal := 0, 0 + for _, it := range items { + if it.Illegal { + illegal++ + } else if it.NewName != "" { + need++ + } + } + if need == 0 && illegal == 0 { + return + } + if illegal > 0 { + log.Printf("[homed] 知识库迁移:%d 条名称非法(含 .. / 点段 / 隐藏段),写入与删除均已拒绝,需人工处理", illegal) + } + if need == 0 { + return + } + log.Printf("[homed] 知识库迁移:%d/%d 条目录名待规范化(例:%s → %s)", need, len(items), + items[0].OldName, items[0].NewName) + if !defaultApply { + log.Printf("[homed] 知识库迁移:当前为只报告模式。确认清单后执行:homed-kb-migrate -root %s -apply", root) + return + } + if need > maxRenamePerRun { + log.Printf("[homed] 知识库迁移:需改名 %d 条超过单次上限 %d,本次只处理前 %d 条", + need, maxRenamePerRun, maxRenamePerRun) + } + applied, failed := knowledge.ApplyMigration(root, items, maxRenamePerRun) + log.Printf("[homed] 知识库迁移完成:成功 %d,失败 %d", applied, failed) +} + // loadPersonality 按「个人文件 > 配置项」的优先级解析人格内容,并对腐坏内容告警。 // // 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变, diff --git a/cmd/homed/main.go b/cmd/homed/main.go index d5888cd..d4093d6 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -132,6 +132,9 @@ func main() { multimodalSpace, mmProviderName, mmErr, closeMultimodal := initMultimodalSpace(cfgReg) defer closeMultimodal() + // 迁移必须在 store 扫盘**之前**:改名后扫盘一次到位, + // 避免先以旧名建索引、再改名造成内存键与盘上目录短暂不一致。 + initKnowledgeMigration(cfg) ks := initKnowledgeStore(cfg) // ---- 人格设定 ----