mempalace migrate-wings:修复 Wing 名称归一化引发的记忆分裂——数据恢复与实现详解
本文围绕 mempalace 的恢复文档 wing-name-migration.md 展开,讲解 mempalace migrate-wings 命令如何把因 wing(翼)名称归一化规则变更而"分裂"的历史记忆重新合并到同一个 wing 下。读完后你能掌握:如何识别"记忆变少、一个项目出现两个 wing"的分裂症状,如何用 dry-run 预览并安全执行迁移,以及该迁移在源码层面改了什么、刻意不改什么,从而在升级后自主完成数据恢复。
一、背景:wing 名称归一化规则与"分裂"从何而来
mempalace 中的 wing 是按项目(通常是路径编码的目录名)划分的记忆分区。wing 名在落库前会经过统一归一化,核心函数是 normalize_wing_name:
def normalize_wing_name(name: str) -> str:
"""Lower-case + collapse separators (`-`, ` `) to `_` for wing slugs. ..."""
return name.lower().replace(" ", "_").replace("-", "_").strip("_")
规则是:小写化,把空格和连字符折叠为下划线,并剥离首尾的分隔符。因此一个路径编码的目录名如 -home-user-proj,归一化后得到的 wing 是 home_user_proj,而不是旧规则下带前导下划线的 _home_user_proj。
问题出在升级前后的行为差异上:
- 升级前挖掘(mine)的宫殿,把抽屉(drawer,即记忆单元)归档在旧的分隔符填充名下,如
_home_user_proj; - 升级后新的挖掘和日记(diary)写入走新规则,落在
home_user_proj下。
两个名字不再相遇——历史记忆不是丢失了,而是"分裂"(split)成了两条平行线:搜索新名字只能命中新挖掘的记忆,旧抽屉留在旧 wing 里无人查询。mempalace migrate-wings 就是为消除这种分裂而设计的恢复命令。
为什么 MCP 写入会连带被拒
恢复文档还指出一个连带症状:MCP 向带前导下划线的旧 wing 写入时可能被拒绝。其根源在 sanitize_name,它对 wing/room/entity 名称执行安全字符集校验,而合法字符集由 config.py 中这条正则决定:
MAX_NAME_LENGTH = 128
_SAFE_NAME_RE = re.compile(r"^(?:[^\W_]|[^\W_][\w .'-]{0,126}[^\W_])$")
该正则要求名称首尾字符必须是"非下划线的字字符"([^\W_]),因此 _home_user_proj 这类以 _ 开头的名字会直接抛 ValueError。这正是 normalize_wing_name 剥离首尾分隔符的动机——让归一化结果能通过 MCP 写入工具链的校验(见 config.py 源码注释:leading-underscore slug 会被 sanitize_name 及 MCP write tools 拒绝)。
二、症状识别:如何判断自己的宫殿发生了分裂
按恢复文档 Symptom 一节,升级后出现以下现象即符合分裂特征:
- 一个项目的记忆"变少"了:以前能稳定命中的项目记忆,升级后搜索结果明显少于预期;
mempalace status显示一个项目对应两个 wing:例如同时存在_home_user_proj(旧抽屉)与home_user_proj(新挖掘的);- MCP 写入被拒:Agent 经 MCP 向带前导下划线的旧 wing 写记忆时报错,原因即上节的
sanitize_name校验。
判断是否分裂的关键动作就是用 mempalace status 对比项目目录与 wing 名的对应关系——一个项目出现"新旧两副面孔"即可确认。
三、恢复操作:先 dry-run 预览,再确认应用
3.1 预览(绝不修改任何数据)
恢复文档强调预览优先,--dry-run 模式不产生任何写入:
mempalace migrate-wings --dry-run
mempalace migrate-wings --dry-run --palace /path/to/palace
- 不带
--palace时,命令使用默认宫殿路径(取自 MempalaceConfig 的palace_path); --palace /path/to/palace用于指定非默认的特定宫殿目录。
预览会打印一份迁移计划,逐条列出每个改名,并标记将与已有 wing 发生碰撞(MERGE,合并进既有 wing)的条目:
Wing-name migration plan:
'_home_user_proj' -> 'home_user_proj': 1284 drawer(s), 96 closet(s) (MERGE into existing wing)
如果 topics_by_wing 实体注册表也有需要改键的条目,还会追加一行 topics_by_wing: N key(s) re-keyed(见 migrate.py 计划输出)。
3.2 应用迁移
mempalace migrate-wings # prompts for confirmation
mempalace migrate-wings --yes # no prompt
不带 --yes 时,命令会打印计划并交互式询问 Apply this wing-name migration? [y/N],输入 y/yes 才执行;在 EOF(如管道、CI)场景下会视为放弃并提示 Aborted(见 migrate.py 确认逻辑)。--yes 则跳过确认直接执行,适合脚本化场景。
3.3 参数一览
| 参数 | 作用 | 源码位置 |
|---|---|---|
--dry-run |
只显示将发生的变更,不做任何修改 | cli.py 参数定义 |
--palace <path> |
指定目标宫殿目录,缺省用配置的默认路径 | cmd_migrate_wings |
--yes |
跳过确认提示,直接应用迁移 | cli.py 参数定义 |
CLI 侧的入口是 cmd_migrate_wings,它仅做路径展开后直接委托给核心实现 migrate_wing_names;命令在子命令表中的注册见 cli.py。
四、源码剖析:migrate-wings 到底改了什么
核心实现在 migrate_wing_names(mempalace/migrate.py 中有一段专门注释说明该迁移是 #1675 的后续)。它的工作可拆为四步:
4.1 计算目标名:_normalized_wing_target
_normalized_wing_target 对每个 wing 值计算归一化目标:
target = normalize_wing_name(wing).strip("_")
if not target or target == wing:
return None
return target
三个值得注意的细节:
- 额外补一刀
.strip("_"):源码注释说明这是刻意设计——即使用户在运行迁移的构建版本里normalize_wing_name还是 #1675 之前的旧实现(不做首尾剥离),迁移结果依然正确; - 归一化后为空的 wing(如
"_")直接跳过,宁可不动也不能让抽屉"无家可归"(对应测试 test_plan_renames_ignores_empty_nonstring_and_all_separator 的注释never strand a drawer); - 已是规范名的 wing 返回
None,表示无需迁移,这保证了命令的幂等性。
4.2 规划改名:plan_wing_renames
plan_wing_renames 是一个纯函数规划器,对 (id, metadata) 序列产出两份结果:
summary:{(old_wing, new_wing): count},用于打印计划中的数量统计;updates:仅包含 wing 真正变化的记录(id, new_metadata),metadata 被整体拷贝后只重写wing一个键,room、source_file等其他字段原样保留(test_migrate_wings.py 中有by_id["d1"]["room"] == "r"的断言佐证)。
数据遍历由 _iter_collection_items 完成:按 1000 条一批分页拉取整个集合(col.count() + offset/limit),兼容 typed result 与原始 dict 两种后端返回形态。
4.3 落盘改名:批量 update 元数据
实际写入由 _apply_wing_updates 执行,以 500 条为一批调用后端的 col.update(ids=..., metadatas=...) 原地改写 wing 元数据字段——不改 ID、不动 document 内容、不重算向量。当改名目标已存在同 wing 的记录时,新记录只是与旧记录共享同一个 wing 值,效果上即完成"合并";这正是 dry-run 计划里 (MERGE into existing wing) 标记的来源——migrate.py 中通过判断 new in all_wings(该 wing 名是否已被其他抽屉占用)来决定是否打印该标记。
迁移同时对两个集合操作:
- drawers 集合:主记忆数据,总是处理;
- closets 集合(更高阶的归纳记忆):通过
get_closets_collection获取,处理失败时静默降级(closets = None),不阻断主流程。
4.4 同步 topics_by_wing 注册表
除向量集合外,迁移还会处理实体注册表中的 topics_by_wing 键:
- _plan_topics_by_wing_renames 从
known_entities.json(经 miner 的_load_known_entities_raw加载)读出注册表,挑出 wing 键需要归一化的条目; - _apply_topics_by_wing_renames 执行改键:碰撞时把旧 wing 的 topic 列表合并进新 wing 且去重(
if topic not in merged),无碰撞时直接改键;写入采用tempfile.mkstemp+os.replace的原子替换模式,避免半截 JSON 落盘。
五、它刻意不改什么:ID 与 Tunnels 的处理哲学
恢复文档专门用 "What it leaves alone" 一节划定了迁移边界,源码注释(mempalace/migrate.py)给出了理由:
5.1 Drawer/Closet ID 保持原样
抽屉 ID 形如 drawer_<wing>_<...>,其中的 wing 是一个不透明前缀(opaque prefix)——从源码结构看,仓库中没有任何代码会把 ID 里的 wing 前缀反解析回 wing 名(迁移注释明确写道 "verified: nothing splits a wing out of an ID")。因此不改 ID 带来两个关键好处:
- closet → drawer_id 指针继续有效:closet 记忆通过 ID 引用其来源抽屉,ID 不动则指针不失效;
- 挖掘幂等性不受破坏:未来的 mining 以
source_file等字段为幂等键跳过已挖掘文件,ID 前缀差异不会导致重复入库。
同时,drawer 的原始文本内容从不被读取或重写——迁移只碰元数据,向量与正文原样保留。
5.2 Tunnels 无需重写
Tunnels(跨 wing 的主题隧道)在读取路径上就做了归一化:palace_graph.py 的 _normalize_wing 对所有读路径过滤统一走 normalize_wing_name,主题隧道的 key 计算(compute_topic_tunnels 相关逻辑)与 hallways 构建(mempalace/palace_graph.py)也都先归一化再比较。因此旧名字的 tunnel 记录在新名字下依然能正确解析,无需逐条重写。
六、安全特性:幂等、后端无关与测试保障
恢复文档 "Notes" 一节的三条声明都能在代码与测试中找到对应:
- 幂等(Idempotent):第二次运行时,所有 wing 的归一化目标等于自身,
plan_wing_renames产出空更新,命令打印All wing names are already normalized -- nothing to migrate.并直接返回(migrate.py)。test_migrate_wings.py 用test_plan_renames_noop_for_clean_wings固定了这一行为; - 后端无关(Backend-agnostic):迁移只依赖后端接口的通用操作(
count/get/update)与palace.py的集合访问封装,不绑定 ChromaDB 等特定存储,任何已配置的存储后端都可执行; - 碰撞语义有测试钉住:
_gamma与gamma_同时存在时两者都归一到gamma(test_plan_renames_collision_maps_both_to_same_target);集成测试用真实后端集合验证端到端行为,且显式提供 embeddings 保证测试不依赖嵌入模型(test_migrate_wings.py)。
七、适用前提与操作建议
结合恢复文档与实现,最后给出可直接执行的检查清单:
- 适用对象:仅在升级跨越了
normalize_wing_name规则变更(即 #1675 前后)的旧宫殿上有意义。新建的宫殿天生使用归一化 wing 名,永远不需要这条命令——运行一次即可("Run it once per palace after upgrading"); - 标准流程:先
mempalace migrate-wings --dry-run审查计划(重点看 MERGE 标记与数量是否符合预期),确认后mempalace migrate-wings交互式应用,或脚本环境用--yes; - 多宫殿场景:用
--palace逐宫殿执行,每个宫殿各跑一次; - 误判自检:如果 dry-run 输出
All wing names are already normalized -- nothing to migrate.,说明该宫殿不存在分裂,无需任何操作; - 相关恢复文档:同目录下的 index-metadata-recovery.md 讲解索引元数据恢复,可与本文互为参照,覆盖 mempalace 的两类典型升级后数据修复场景。
综上,mempalace migrate-wings 用一个"只改元数据、不动 ID 与内容"的窄口径设计,把归一化规则变更造成的记忆分裂重新缝合:旧抽屉、closet 与 topic 注册表在同一个 wing 下重聚,指针、幂等键与隧道全部保持有效,且整个过程可预览、可确认、可重复执行。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00