#!/usr/bin/env python3 """ memory_sync.py — 耳耳蛋记忆库双向自动合并 背景:面板与终端共用同一个 CodeBuddy 项目库,但记忆有两份落点: ① 本地记忆库 ~/.codebuddy/projects/home-ls/memory/ ← 蛋蛋实际读写的 ② 仓库副本 ~/cang-ying/memory/eed/ ← 推送到仓库、跨机器带走的 两边都可能被写(终端会话写①、面板会话也写①,但爸爸手动或旧流程可能只更新②), 过去靠手动 cp 同步,一不留神就会覆盖掉另一边刚写的经验。 本脚本做双向合并: - 只在一边存在的文件 → 补到另一边 - 两边都有且内容不同 → 按 mtime 取新的,旧版备份到 BACKUP_memory_conflict/ - 内容相同 → 跳过 永不静默丢数据:任何被覆盖的版本都先备份。 用法: python3 memory_sync.py # 执行合并 python3 memory_sync.py --dry-run # 只看会怎么做,不动文件 python3 memory_sync.py --json # 输出 JSON(供面板调用) """ import filecmp import json import os import shutil import sys import time LOCAL_DIR = os.path.expanduser("~/.codebuddy/projects/home-ls/memory") REPO_DIR = os.path.expanduser("~/cang-ying/memory/eed") CONFLICT_BACKUP = os.path.expanduser("~/.codebuddy/BACKUP_memory_conflict") # 仓库侧独有、不该被同步回本地记忆库的文件(仓库自带说明等) REPO_ONLY = {"README.md"} def _md_files(d): if not os.path.isdir(d): return set() return {f for f in os.listdir(d) if f.endswith(".md")} def _backup(path, tag): """覆盖前先备份,返回备份路径。""" os.makedirs(CONFLICT_BACKUP, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") dst = os.path.join(CONFLICT_BACKUP, f"{stamp}_{tag}_{os.path.basename(path)}") shutil.copy2(path, dst) return dst def sync(dry_run=False): """执行双向合并,返回结果字典。""" result = {"ok": True, "copied_to_repo": [], "copied_to_local": [], "updated_local": [], "updated_repo": [], "backups": [], "skipped": 0, "error": ""} if not os.path.isdir(LOCAL_DIR): result["ok"] = False result["error"] = f"本地记忆库不存在:{LOCAL_DIR}" return result if not os.path.isdir(REPO_DIR): if dry_run: result["error"] = f"仓库副本目录不存在(将创建):{REPO_DIR}" else: os.makedirs(REPO_DIR, exist_ok=True) local = _md_files(LOCAL_DIR) repo = _md_files(REPO_DIR) # ① 只在本地 → 补到仓库 for name in sorted(local - repo): if not dry_run: shutil.copy2(os.path.join(LOCAL_DIR, name), os.path.join(REPO_DIR, name)) result["copied_to_repo"].append(name) # ② 只在仓库 → 补到本地(跳过仓库自带说明文件) for name in sorted(repo - local): if name in REPO_ONLY: continue if not dry_run: shutil.copy2(os.path.join(REPO_DIR, name), os.path.join(LOCAL_DIR, name)) result["copied_to_local"].append(name) # ③ 两边都有 → 比内容,不同则按 mtime 取新,旧版先备份 for name in sorted(local & repo): lp = os.path.join(LOCAL_DIR, name) rp = os.path.join(REPO_DIR, name) try: if filecmp.cmp(lp, rp, shallow=False): result["skipped"] += 1 continue except OSError as e: result["error"] += f"[{name} 比对失败: {e}] " continue lt, rt = os.path.getmtime(lp), os.path.getmtime(rp) if lt >= rt: # 本地更新 → 覆盖仓库,先备份仓库那版 if not dry_run: result["backups"].append(_backup(rp, "repo")) shutil.copy2(lp, rp) result["updated_repo"].append(name) else: # 仓库更新 → 覆盖本地,先备份本地那版 if not dry_run: result["backups"].append(_backup(lp, "local")) shutil.copy2(rp, lp) result["updated_local"].append(name) return result def summarize(r): """把结果压成一句人话,给面板/唤醒卡用。""" if not r["ok"]: return "⚠️ 记忆同步失败:" + r["error"] n = (len(r["copied_to_repo"]) + len(r["copied_to_local"]) + len(r["updated_local"]) + len(r["updated_repo"])) if n == 0: return f"✅ 记忆已同步({r['skipped']} 份一致,无变化)" parts = [] if r["copied_to_repo"]: parts.append(f"新增到仓库 {len(r['copied_to_repo'])}") if r["copied_to_local"]: parts.append(f"拉回本地 {len(r['copied_to_local'])}") if r["updated_repo"]: parts.append(f"本地较新覆盖仓库 {len(r['updated_repo'])}") if r["updated_local"]: parts.append(f"仓库较新覆盖本地 {len(r['updated_local'])}") s = f"✅ 记忆已合并({' · '.join(parts)})" if r["backups"]: s += f",旧版已备份 {len(r['backups'])} 份" return s def main(): dry = "--dry-run" in sys.argv as_json = "--json" in sys.argv r = sync(dry_run=dry) if as_json: print(json.dumps(r, ensure_ascii=False)) return 0 if r["ok"] else 1 print(("【预演,未改动任何文件】" if dry else "【记忆双向合并】")) print(f" 本地记忆库: {LOCAL_DIR}") print(f" 仓库副本: {REPO_DIR}") print() for key, label in (("copied_to_repo", "→ 补到仓库"), ("copied_to_local", "← 拉回本地"), ("updated_repo", "→ 本地较新,覆盖仓库"), ("updated_local", "← 仓库较新,覆盖本地")): for name in r[key]: print(f" {label}: {name}") if r["backups"]: print(f"\n 旧版备份 {len(r['backups'])} 份 → {CONFLICT_BACKUP}") print(f"\n{summarize(r)}") if r["error"]: print(f"⚠️ {r['error']}") return 0 if r["ok"] else 1 if __name__ == "__main__": sys.exit(main())