Claude Code 的记忆同步大法
我有两台机器:一台 MacBook Pro,还有家里的 Mac mini。有一些项目,会在两边都写。
前几天晚上在家里那台上开 Claude CLI,聊到一半我才反应过来——它什么都不记得。。。那些”这个坑我们上个月踩过””这个方案试过不行”的结论,全都留在公司那台上了。我这边还得从头解释一遍。
于是花了个把小时把这事儿彻底解决掉。方案本身不复杂,但中间挖出来的几个东西挺有意思,值得写一篇,分享给需要有需要的朋友。
先搞清楚:记忆到底存在哪?
Claude Code 的记忆不是存在云端的,就是本地文件:
|
1 2 3 |
~/.claude/projects/<路径slug>/memory/*.md # 每个项目的记忆,一条一个文件 ~/.claude/projects/<路径slug>/memory/MEMORY.md # 该项目的记忆索引 ~/.claude/CLAUDE.md # 全局偏好 |
关键在那个 slug。它是项目绝对路径转义出来的,规则很简单粗暴——非字母数字的字符统统换成短横线:
|
1 |
re.sub(r'[^a-zA-Z0-9]', '-', path) |
所以我的 /Users/luyang.li/dev/notchy 就变成了 -Users-luyang-li-dev-notchy。注意用户名里那个点也被转成了横线,@、_ 同理,全都一视同仁。
统计了一下,我这边一共有 111 条记忆。数量不算多,但都是真金白银试出来的结论,重新攒一遍的成本可不低。
哪些能同步,哪些碰都别碰
第一反应可能是”把整个 ~/.claude 丢进 iCloud 不就完了”。千万别。
我扫了一遍那个目录,能同步的其实就这么几样:
1. projects/*/memory/ —— 各项目记忆
2. skills/ —— 自定义 skills
3. CLAUDE.md —— 全局偏好
4. settings.json —— 全局设置
5. mcp.json —— MCP 配置
其余的全是本机运行时状态,同步过去只会互相打架:history.jsonl、sessions/、各项目下的 *.jsonl 会话记录(我这边光这项就 364M)、file-history/(30M)、shell-snapshots/、plugins/ 缓存、telemetry/、还有 daemon 的认证文件。
更要命的是 ~/.claude.json——注意是家目录根下那个,不是 .claude/ 里面的。我打开一看,好家伙,某个 MCP server 的明文 access token 就躺在里面。这玩意儿要是跟着仓库推上去,那可就热闹了!😅
顺带一提,插件不用同步。settings.json 里的 enabledPlugins 和 extraKnownMarketplaces 已经完整描述了装了哪些插件、从哪个 marketplace 拉,换台机器它自己会去下。我这边 plugins/ 目录 14M,绝大部分是缓存和 marketplace 的 clone,同步纯属浪费。
目录用软链接,单文件用拷贝
方案就是建个私有 git 仓库 ~/dev/claude-config,把要同步的东西搬进去,原地留软链接。
|
1 2 3 4 5 6 |
REPO=~/dev/claude-config mkdir -p $REPO && git -C $REPO init # 以 skills 为例 mv ~/.claude/skills $REPO/skills ln -s $REPO/skills ~/.claude/skills |
但这里有个分野,我一开始没想到:目录可以软链接,单个文件不行。
原因是 Claude Code 自己会重写 settings.json(你 /model 切一次它就重写一遍)。而很多程序的”原子写”是先写临时文件再 rename——那一 rename,你的软链接就被替换成实体文件了,链接直接断掉,后面的改动再也进不了仓库。目录被这样整个替换的概率极低,所以目录安全。
于是最终是混合方案:
|
1 2 3 4 |
skills/ -> 软链接,改动实时进仓库 projects/*/memory/ -> 软链接,同上 CLAUDE.md / settings.json / mcp.json -> 拷贝,按 mtime 双向同步,每次跑脚本时自愈 |
还有个细节让我挺满意的:我那 39 个 skills 里有 20 个本身就是指向另一个仓库的软链接。git 会把符号链接以 mode 120000 原样存下来(存的是目标路径,不是内容),clone 到另一台机器上它还是软链接。推完我特意查了一下远端:
|
1 2 3 4 |
gh api 'repos/bones7456/<repo名>/git/trees/main?recursive=1' \ | python3 -c "import json,sys; t=json.load(sys.stdin)['tree']; \ b=[x for x in t if x['type']=='blob']; \ print(len(b), '个 blob,', len([x for x in b if x['mode']=='120000']), '个符号链接')" |
|
1 |
548 个 blob, 20 个符号链接 |
两台机器用户名不一样怎么办
还记得前面那个 slug 规则吗,坑来了。
我这台的用户名是 luyang.li,另一台是 lly。同一个项目 ~/dev/notchy,在两台机器上算出来的 slug 完全不同:
|
1 2 |
-Users-luyang-li-dev-notchy # 这台 -Users-lly-dev-notchy # 那台 |
直接把仓库里的目录名照搬过去,记忆就对不上了。而且反过来从 slug 推路径也不行——横线可能对应 /,也可能对应 .、@,甚至本来就是横线,信息已经丢了。
解决办法是在仓库里存一份 origins.tsv,记录每份记忆对应的原始绝对路径:
|
1 2 |
-Users-luyang-li-dev-notchy /Users/luyang.li/dev/notchy -Users-luyang-li-dev-shi /Users/luyang.li/dev/shi |
安装脚本读这个文件,把路径里的 home 前缀换成本机的,再重算一遍 slug 建软链接。这样只要 home 之后的路径一致,两边就能对上同一份记忆。
(生成这个映射表也有讲究:我没去反推,而是拿 ~/.claude.json 里 projects 字典的 key——那些本来就是绝对路径——正向算 slug 建的对照,14 个全中。)
挂上 hooks,让它自己跑
同步脚本写好了,接下来是自动化。Claude Code 的 hooks 正好有两个合适的事件:
|
1 2 3 4 5 6 7 8 |
"hooks": { "SessionStart": [ { "hooks": [{ "type": "command", "command": "\"$HOME/dev/claude-config/sync.sh\" pull", "timeout": 30 }] } ], "SessionEnd": [ { "hooks": [{ "type": "command", "command": "\"$HOME/dev/claude-config/sync.sh\" push", "timeout": 30 }] } ] } |
这里用 $HOME 而不是绝对路径,因为 settings.json 这个文件本身也会被同步到另一台机器上去,写死路径那边就废了。
SessionEnd 到底什么时候触发?
配完我就犯嘀咕:SessionEnd 具体什么时候跑?关终端算不算?
翻了下 settings 的 schema,它只在事件名枚举里出现,没有说明。与其猜,不如直接从本机的 CLI 二进制里找。我这个版本是 2.1.247:
|
1 2 |
F=~/.local/share/claude/versions/2.1.247 grep -a -o -E '.{130}prompt_input_exit.{130}' "$F" | head -5 |
第一条就中了:
|
1 |
Vs=["clear","resume","logout","prompt_input_exit","other"],Qs=s(()=>a(Vs)),Zs=s(()=>h().and(t({hook_event_name:n("SessionEnd"),reason:Qs()}))) |
紧挨着 hook_event_name:"SessionEnd" 的就是 reason 的枚举,一共五个值:
1. clear —— 执行 /clear
2. resume —— 切走去别的会话
3. logout —— 执行 /logout
4. prompt_input_exit —— 从输入框正常退出
5. other —— 兜底
最意外的是 /clear 也算 SessionEnd。这意味着我的 push 不是”一天一次”,而是每次清上下文都会提交推送一遍,同步粒度比预想的细多了,挺好。
再往下挖,找到了执行时机:
|
1 2 3 4 5 6 |
async shutdown(e=0,t="other",n){ if(this.shutdownInProgress)return; this.shutdownInProgress=!0, process.exitCode=e, ... let{executeSessionEndHooks:r,getSessionEndHookTimeoutMs:o}=await import("..."); let s=o(); this.armShutdownFailsafe(Math.max(5000,s+5000),e); |
hooks 跑在 process.exitCode 设定之后、进程真正退出之前,而且有个失效保险:超过 max(5秒, 超时+5秒) 就强制退出。所以 SessionEnd hook 里别干重活,我这个只在本地 commit,真正的 git push 甩到后台跑。
那硬退出呢?我找到这么一行:
|
1 |
process.on("SIGTERM", () => process.exit()) |
裸的 process.exit(),不走 shutdown(),也就不跑 hook。SIGKILL 就更不用说了。(CLI 里 SIGTERM/SIGINT/SIGHUP 有好几处注册,分属不同上下文,我没法干净地判断直接关终端窗口走的是哪条路径,所以这条只能算”很可能”,不算证实。)
所以 pull 也得先 commit
上面这个结论直接推翻了我原本的脚本设计。
原来 pull 是这么写的:
|
1 |
git pull --rebase --autostash |
--autostash 会把未提交的改动 stash 起来、rebase 完再放回来——但放回来之后它还是未提交状态。万一某次 SessionEnd 没跑成(进程被硬杀了),那批记忆就一直悬在工作区里,没提交更没推送,另一台机器永远看不到。
改法很简单:pull 开头先 commit 一次。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
pull) link_dirs sync_files commit_local "recovered" # 先落袋为安 if has_remote; then git -C "$REPO" pull --rebase --autostash -q || echo "pull 失败,请手工查看" n=$(unpushed_count) [ "$n" -gt 0 ] && echo " ↑ 补推 $n 个未推送的提交" push_background fi sync_files # 把刚拉下来的内容拷出去 exit 0 ;; |
这样只要还能开一次会话,上次漏掉的东西就一定会被补上。提交信息前缀用 recovered,跟正常退出的 sync 区分开,翻 log 时一眼能看出这批改动是怎么进来的。
最后
整套跑下来,仓库 548 个文件、.git 才 2.9M,所有项目的记忆全接管了。现在的循环是这样:会话开始 pull,中间的改动经软链接实时落到仓库,会话结束(或者 /clear)commit 加后台 push。
顺带说一句,为什么不用 iCloud 或者 Dropbox——它俩当然也能跑,把仓库路径换成云盘目录、软链接照建就行,还省掉 hooks。但记忆这东西天然就是”一句话一个文件”,太适合版本化了;万一两台机器同时改了同一条,git 会明明白白报冲突让你处理,云盘则是悄悄给你生成一个”冲突副本”,你可能几个月都发现不了。。。
不说了,我去家里那台上 clone 一遍试试。要是 origins.tsv 那套换算真能对上,那 111 条记忆就算是彻底安全了——毕竟这里头有不少是当初排查内存泄露那种熬了半天才熬出来的结论,再丢一次我可真要哭了!😭
发表回复