两台 Windows 机器和一台跑 Docker 的 Linux 小主机上装着同一个 AI Agent(Hermes),技能库与长期记忆各长各的,同一件事在不同机器上的处理方式会不一样。这套方案解决了三件事:技能按目录叠加合并、重名以较新者为准;记忆因为有硬字符预算,只能「并集、人工精修、压回预算」;SSH 凭据在无终端环境下靠一个派发式 askpass 自动取用,不再弹窗,也不把 A 机的口令发给陌生主机。适用环境:Windows 11 + git-bash 与 Ubuntu + Docker,2026-08 至 2026-09 实测。
一、症状与第一手数据 #
接中枢之前的差距是肉眼可见的:
| 项 | 第一手数据(2026-09-10 实测) |
|---|---|
| 技能数量 | 本机 132 个 SKILL.md,中枢 89 个 |
| 同名技能内容 | 6 个文件两端不一致,中枢侧停留在 2026-08-20,本机已改到 9 月 |
| 记忆(MEMORY.md) | 中枢 30 段,本机 19 段,互相都不覆盖对方的内容 |
| 第一次自动并集后 | 本机记忆 4254 字符(预算 2200),超预算实例直接被拒绝写记忆 |
| 无终端的 SSH | 每次执行都弹一个 GUI 密码框 |
两个现象最伤人。一个是记忆超预算:超预算的实例连记忆写入都被拒,报错形如 memory would be at 4,422/2,200 chars -- over the limit,你只知道「写不进去」,不知道是谁撑爆的。另一个是弹框:弹框的不是 Windows 凭据管理器,是 git-bash 默认安装的 git-askpass.exe。ssh 在无终端环境下要密钥口令时,会去调用 SSH_ASKPASS 指向的程序,git-bash 把这个变量默认指到了这个 GUI 程序上。同样一条 ssh 命令,在交互终端里不弹,在计划任务、后台进程、Agent 的终端里就弹。
二、根因:三类资产不能共用一套合并逻辑 #
技能是文件目录树。 每个技能是一个 <分类>/<技能名>/ 目录,里面是 SKILL.md 加若干参考文件。目录之间的合并天然是「叠加」:两边各取所有目录,重名的按文件比。难点在重名文件以哪边为准:无脑「同名者胜」会静默毁掉输掉的那边的修改;无脑「本地胜」会让另一台机器刚改过的文件被覆盖。这套方案在目的地比较 mtime,较新者覆盖,两边 mtime 恰好同秒而内容不同时判为冲突、不覆盖、报个数留给人工看。
记忆是有硬字符预算的正文文件。 记忆按 § 行分段落,实例预算约 2200 字符(USER.md 约 1375)。超预算的实例拒绝后续写入。所以「并集」和「在预算内」天然冲突:并集只会越并越大,必须有人把两条重复的长条目合成一条、把能放到技能里的细节搬走、再压回预算。而且并集会「回流」:中枢里任何一条历史遗留段,每次 --pull 都会重新注入各机器,把刚精修好的本地记忆再次撑爆。实测一次回流把本机记忆从 2179 字符撑到 4376。这就决定了中枢的记忆必须被精修成一份标准集,而不是当档案库用。
无终端的 SSH 认证走 askpass。 ssh 在非交互环境下取密钥口令,靠 SSH_ASKPASS 指向的可执行程序。两个细节决定成败:一是提示文本是作为参数(argv[1])传给 askpass 的,不是从 stdin 读;二是 SSH_ASKPASS_REQUIRE=force 才能让 ssh 在“本来有终端可用”的情况下也坚持走 askpass。还有个安全面:askpass 里那把错误的凭据一旦发给陌生主机,就是一次失败的 publickey 尝试,会被对方 fail2ban 记账。
三、处置过程 #
3.1 传输与合并语义 #
Windows git-bash 没有 rsync,传输用 tar 经 ssh 流式打包(几 MB 的小包,跨平台可靠)。每次运行生成独立 staging 目录和远端暂存名,两台机器同时跑也不会互删暂存。比较必须在「两边都看得见 mtime 的地方」执行:推送时把整棵暂存树传到目的地,由目的端跑比较器 apply-newest-wins.py(全文见文末折叠区)逐文件决定覆盖还是跳过。
技能同步的三个关键约束:
- 叠加,不是镜像:一端删除的文件不会在另一端消失,
--list-only-here只报告单边技能、不删任何东西。清理必须显式在对应那台机器上做。 - 收集必须
cp -a:普通cp -r不保留 mtime,会让本地所有文件「看起来更新」,反向把中枢的更新盖掉。 - 远端解包后 chown 回容器用户:Windows 的 tar 会把宿主合成 uid(如 197609)记进归档,远端 root 解包就原样落盘,技能树变成只读,症状是「Agent 总记不住新技能」而不是权限报错。
记忆合并是另一套逻辑:§ 段并集,只把目标端缺失的段补进去(60 字符前缀判重),绝不覆盖目标端已有段,两个方向都只增不改。合并前把数据落成文件、以文件参数传给 python,见下文 bug (a)。
3.2 记忆并集精修(本篇核心) #
第一次接入时走了一轮完整的「并集 → 超预算 → 手工重构」:自动合并把云端 6 段并入本地,记忆变成 30 段 / 4254 字符。手工重构的做法是删掉已被技能接管的英文旧段、压缩长条目、补入多机同步的新事实,最后落在 21 条 / 2193 字符。后来一次兜底通道拉取又并进 12 段,把本机顶到 3405 字符,实例从此写不了记忆。于是合并逻辑加上了预算感知:放不下预算的段不静默溢出,而是完整写入 <file>.deferred(如 MEMORY.md.deferred)并逐条报告,一个不丢。
正式精修一轮的实测数字:并集 30 段 MEMORY + 13 段 USER,精修后 20 段 / 2198 字符、12 段 / 1305 字符,本机与中枢逐字符一致,.deferred 清单清空。
精修的口径就一句:细节留技能、记忆留指针。动手删之前先 grep 整个技能库确认细节确实已在技能里,技能里没有的必须留在记忆里(删了就是真丢)。实际对照:
| 原内容 | 处理 | 去处(已核验存在) |
|---|---|---|
发布接口的 JWT 细节(bytes.fromhex、PUT 要 source=html)与发布隐私审计规则 |
删除,记忆只留「见技能」 | 发布技能 references/api-notes.md |
| mcp 库升级后需重打 pywin32 try/except 补丁 | 留指针 | 平台技能 references/native-mcp.md |
中继脚本同步后必做 py_compile+diff+grep、库路径 |
压缩 | 对应维护技能 |
| 某个局域网单通问题的排查方法 | 留指针 | 网络诊断技能 |
| 中枢的容器名/端口/UID/NPM 细节 | 不写入记忆 | 运维技能 hosts/ 档案、部署技能 |
| 旧建议「pull 前手工 export SSH_ASKPASS=…」 | 删除(已过时) | 现由同步脚本自接线 |
论坛插件的每日轮换哈希、某接口轮询路径、allowed_signers 规则、机器名、项目库名 |
全部保留(技能里没有) | 记忆内 |
中枢侧同步执行同一套预算:HUB_MEMORY_BUDGET/HUB_USER_BUDGET 默认与实例同值(2200/1375)。中枢记忆是各机拉取的「精修基准集」,必须保持可写;只有当你想让中枢刻意累积全量并集时,才把它们设成 0。
3.3 三个真 bug 与修法 #
(a) heredoc 独占 stdin,远端数据静默丢失。 第一版合并写的是 ssh host "python - dst <<'PYEOF' … sys.stdin.read() … PYEOF":heredoc 把 stdin 整个占走,python 从 stdin 读完自己的程序之后,sys.stdin.read() 得到的恒为空串。拉取两轮都报「added 0」,本来以为云端没有缺失,逐段比对才发现中枢 6 段确实都在、一条都没并进来,是报成功的数据丢失。修法是数据先落到接收端的临时文件,合并脚本一律两个文件参数(python merge.py dst src),不依赖 stdin 数据流。这个 bug 在改版时被重新引入过一次(老版本修过、重写时又写回去),2026-09-10 实测复现,所以现在脚本头部把这条教训写成注释。
(b) askpass 只读 stdin,拿到空口令。 初版 askpass 按习惯 cat 了 stdin,但 ssh 是把提示作为 argv[1] 传的,解析不到密钥名,口令为空,结果是 Permission denied (publickey)。弹框止住了,连不上。修法:先读 $1,保留 stdin 回退给少数走管道的包装调用。
(c) 派发式 askpass:从提示里的密钥名推出该用哪份凭据。 约定凭据名与密钥名一一对应:密钥 ~/.ssh/<名>-key 或 <名>-hermes,凭据就叫 hermes_ssh_<名>_ssh。askpass 从提示的 for key '…' 里抽出密钥名,按这个约定推凭据名去 Windows 凭据管理器取口令,实测 8 把密钥全部命中;CRED_TARGET 仍可显式覆盖,但优先用提示里认出的密钥名。走 ProxyJump 时会先后出现两把密钥的提示(跳板钥与目标机钥),若强制指定优先,会把 A 机口令送给 B 钥,直接认证失败。认不出凭据的密钥一律交回 git-askpass.exe:保留机器原来的行为,并且绝不把 A 机的口令发给陌生主机(错口令 = 一次失败的 publickey 尝试,可能触发对端 fail2ban)。全文见文末折叠区 askpass.sh。
3.4 自接线:不依赖调用者环境 #
「不依赖调用者先导出环境变量」是硬要求:同步脚本自己检查 SSH_ASKPASS,只有当它为空或仍指向 git-askpass 时才替换成派发脚本,并补 SSH_ASKPASS_REQUIRE=force 与 DISPLAY。这样计划任务、后台进程、Agent 的终端跑起来都不会弹窗,也不依赖某个 shell 之前手动 export 过。
同时把 askpass 写成持久默认:~/.bashrc 与 setx(用户级环境变量,新开的 cmd 和计划任务都继承)各写一份。注意两个坑:force 不能省,否则 ssh 在有 tty 时默认忽略 askpass 直接在终端上问;改完 .bashrc 之后已经开着的 shell 仍是旧值,所以脚本必须自己接线,不能指望调用者环境。
四、验证 #
验证分三层:
- 认证:全新登录 shell、零手工导出,直接
ssh连通;askpass 对 8 把密钥全部命中,无弹窗。 - 记忆:本机与中枢各 20 段 / 2198 字符、12 段 / 1305 字符,逐字符一致,无残留
.deferred。 - 端到端:
--sync一圈下来,拉取侧0 覆盖 / 671 跳过 / 0 冲突,记忆0 新增 / 0 延迟,脚本与文档两端 md5 一致。
怎么判断没解决:--pull 后跳过数是 0(比较器根本没看到暂存树,典型原因是路径没转成 Windows 形式);记忆字符数超过预算;ssh 出现 GUI 弹窗或 Permission denied (publickey)。
五、留给后来者的清单 #
| 事项 | 做法 | 怎么验证 |
|---|---|---|
| 中枢作为规范源 | 中枢记忆按「精修基准集」管理,预算默认与实例同值(2200/1375),保持可写;两边都能当源 | --sync 记忆 0 新增 / 0 延迟,两端逐字符一致 |
| 技能叠加合并 | 按目录合并、保留分类树;同名文件在目的地比 mtime,较新者胜;同秒同刻且内容不同算冲突,不覆盖 | 输出恒定三列 copied skipped conflicts,冲突数为 0 |
| 记忆并集 | 只补目标端缺失段,绝不覆盖;放不下预算的段延迟写入 .deferred,不静默溢出 |
.deferred 文件为空,字符数 ≤ 预算 |
| 精修口径 | 细节留技能、记忆留指针;删之前先 grep 技能库确认细节已在 | 逐条对照「原内容 → 处理 → 去处」表 |
| 凭据不弹窗 | 口令只在系统凭据管理器;askpass 从提示推凭据名,认不出的交回系统默认 | 全新 shell、零导出直接 ssh 成功,无弹窗 |
| 失败要响 | 合并/比较脚本逐份回读校验,异常非零退出,绝不静默「0 0」 | 脚本非零退出时日志可见具体失败文件 |
| fail2ban 意识 | 成功的连接不计数,失败的 publickey 才计数;别对同一主机短循环重试(被 tarpit 表现是 TCP 通、SSH banner 不来,持续 10–15 分钟) | 单次连接测试确认 banner 正常返回 |
| 什么不同步 | .env、auth.json、state.db 等凭据与运行时状态绝不随资产同步 |
归档清单里没有这些文件 |
| 部署端生效 | Docker 部署同步后重启容器加载新技能/记忆 | docker restart 后技能计数正确 |
六、碎碎念 #
这套体系里最值钱的一句话是「细节留技能、记忆留指针」。记忆是有预算的索引,技能是承载细节的正文;预算不是麻烦,是逼你维护索引质量的机制。每次并集都是一次索引审计:这条还值得留在索引里吗,还是该沉到技能里?
自动化里最贵的是静默成功。三个 bug 里有两个是「日志看起来成功了」:heredoc 丢数据报 added 0、路径没转换报 0 覆盖。所有合并与比较都做回读校验、失败非零退出,再小的脚本也值得这么做。
脚本全文 1/3:askpass.sh(派发式 askpass,占位符化)
#!/bin/sh
# 派发式 SSH askpass(自动化用)
# 1) 从 ssh 的提示里认出是哪个密钥,再去 Windows 凭据管理器取对应口令;
# 2) 约定:密钥 ~/.ssh/<name>-key 或 <name>-hermes <-> 凭据 hermes_ssh_<name>_ssh
# (<示例>-hermes -> hermes_ssh_<示例>_ssh);
# 3) CRED_TARGET 可强制指定(~/bin/<host> 包装脚本按老方式工作,仍生效);
# 但【优先用从提示里认出的密钥名】—— 走 ProxyJump 时会先后出现两把密钥的口令提示
# (跳板钥与目标机密钥),若 CRED_TARGET 优先会把 A 机口令送给 B 钥,直接认证失败。
# derived-first 后,包装脚本只设 CRED_TARGET 的老用法仍然有效(提示里没密钥名时回退到它)。
# 4) 认不出对应凭据时什么都不输出、退出 1 —— 绝不把 A 机器的口令送给 B 主机
# (错口令会变成一次失败的 publickey 尝试,可能触发对方 fail2ban)。
set -u
READ="${LOCALAPPDATA:-$HOME/AppData/Local}/hermes/scripts/credread.ps1"
prompt="${1:-}" # ssh 把提示作为【第一个参数】传入
[ -n "$prompt" ] || prompt="$(cat 2>/dev/null || true)" # 少数实现/包装脚本走 stdin
key="$(printf '%s' "$prompt" | sed -n "s/.*for key '\([^']*\)'.*/\1/p")"
base="$(basename "${key%.pub}" 2>/dev/null || true)"
derived=""
case "$base" in
<示例>-hermes) derived="hermes_ssh_<示例>_ssh" ;;
*-key) derived="hermes_ssh_${base%-key}_ssh" ;;
*-hermes) derived="hermes_ssh_${base%-hermes}_ssh" ;;
esac
# 提示里认不出密钥(老包装脚本直接调用) -> 沿用历史默认, 否则对陌生主机什么都不发
default=""
[ -z "$base" ] && default="hermes_ssh_<示例>_ssh" # 兜底:改成你自己的常见密钥名
for t in "$derived" "${CRED_TARGET:-}" "$default"; do
[ -n "$t" ] || continue
pw="$(powershell -NoProfile -ExecutionPolicy Bypass -File "$READ" -Target "$t" 2>/dev/null)" || pw=""
if [ -n "$pw" ]; then printf '%s' "$pw"; exit 0; fi
done
# 不是我们的密钥(没有对应凭据): 交回 git-bash 原本的 GUI askpass, 行为与从前一致
LEGACY='/mingw64/bin/git-askpass.exe'
if [ -x "$LEGACY" ]; then
printf '%s' "$prompt" | exec "$LEGACY" "$@"
fi
exit 1脚本全文 2/3:apply-newest-wins.py(最新者胜比较器)
#!/usr/bin/env python3
"""Apply a staged tree onto a destination with newest-wins semantics.
The sync script stages a whole tree (tar preserves mtimes) and then runs this
applier at the DESTINATION, so the comparison happens where both sides are
visible:
apply-newest-wins.py <staged_dir> <dest_dir> [owner[:group]]
A file is copied only when the destination lacks it or the staged copy is
strictly newer. Everything else is left alone and counted as skipped. This is
what stops a sync from trampling work done on either side — plain
"same name wins" destroys whatever the losing side wrote.
stdout is always "<copied> <skipped> <conflicts>" (shell-readable).
Exit status: 0 = clean, 3 = some file failed to copy or failed read-back,
4 = the staged dir is missing (a silent no-op is never acceptable).
With an owner argument (remote pushes only) the touched tree is chowned back to
the container user afterwards, because tar unpacked as root restores the packer's
uid verbatim (a Windows MSYS uid like 197609 lands on the VPS and makes the tree
unwritable for the container user).
"conflicts" counts files whose mtime is *identical* on both sides but whose
content differs — tar stores whole seconds, so this is the one case the mtime
rule cannot decide. Nothing is overwritten; the count tells a human to look.
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import sys
EXIT_OK, EXIT_FAILED, EXIT_NO_STAGE = 0, 3, 4
def digest(path: str, chunk: int = 1 << 20) -> str:
h = hashlib.md5()
with open(path, "rb") as f:
while True:
block = f.read(chunk)
if not block:
break
h.update(block)
return h.hexdigest()
def same_content(a: str, b: str) -> bool:
try:
if os.path.getsize(a) != os.path.getsize(b):
return False
return digest(a) == digest(b)
except OSError:
return False
def apply_newest_wins(staged: str, dest: str) -> tuple[int, int, int, list[str]]:
copied = skipped = conflicts = 0
problems: list[str] = []
for root, _dirs, files in os.walk(staged):
rel = os.path.relpath(root, staged)
for name in files:
src = os.path.join(root, name)
target = os.path.join(dest, name) if rel == "." else os.path.join(dest, rel, name)
try:
if os.path.exists(target):
smt, dmt = os.path.getmtime(src), os.path.getmtime(target)
if dmt > smt:
skipped += 1
continue
if dmt == smt:
# 同秒同刻:一样的算跳过,不一样的判为歧义(不覆盖,报数)
if same_content(src, target):
skipped += 1
else:
conflicts += 1
continue
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
shutil.copy2(src, target) # copy2 保留 mtime
# 回读校验:不信 copy 的返回值(静默半写是我们要消灭的故障类)
if not same_content(src, target):
problems.append(f"verify-mismatch: {target}")
else:
copied += 1
except OSError as exc:
print(f" ! {target}: {exc}", file=sys.stderr)
problems.append(f"{target}: {exc}")
return copied, skipped, conflicts, problems
def main() -> int:
if len(sys.argv) < 3:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 2
staged, dest = sys.argv[1], sys.argv[2]
owner = sys.argv[3] if len(sys.argv) > 3 else ""
if not os.path.isdir(staged):
print(f"! staged dir 不存在: {staged}(比较器不做任何事是故障, 故非零退出)", file=sys.stderr)
print("0 0 0")
return EXIT_NO_STAGE
os.makedirs(dest, exist_ok=True)
copied, skipped, conflicts, problems = apply_newest_wins(staged, dest)
if owner:
subprocess.run(["chown", "-R", owner, dest], check=False)
print(f"{copied} {skipped} {conflicts}")
if problems:
print(f"failed={len(problems)}", file=sys.stderr)
for p in problems[:20]:
print(f" - {p}", file=sys.stderr)
if len(problems) > 20:
print(f" ... 其余 {len(problems) - 20} 条已省略", file=sys.stderr)
return EXIT_FAILED
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())脚本全文 3/3:sync-hermes-to-vps.sh(同步主脚本,端点与文件名已占位符化)
#!/usr/bin/env bash
#
# sync-hermes-to-vps.sh — 把本地 Hermes 的 skills + memory 与 VPS 同步
#
# 用法:
# ./sync-hermes-to-vps.sh # 仅打包本地资产到 staging 目录(预览)
# ./sync-hermes-to-vps.sh --push # 本地 -> VPS (最新者胜技能 + 追加合并记忆)
# ./sync-hermes-to-vps.sh --pull # VPS -> 本地 (最新者胜技能 + 追加合并记忆)
# ./sync-hermes-to-vps.sh --sync # 双向一次跑完 (先 pull 再 push, 推荐日常使用)
#
# Windows 上推荐用同目录的 sync-hermes-to-vps.cmd 调用: 它负责填好端点、askpass
# 与 profile, 并规避 CMD/PowerShell 把 "$LOCALAPPDATA/..." 里的反斜杠吃掉的问题。
#
# 实测要点(2026-08 ~ 2026-09, Windows git-bash <-> Ubuntu Docker VPS):
# - Windows git-bash 默认没有 rsync, 所以用 tar 流式经 ssh 双向传输, 跨平台可靠。
# - VPS 若是 Docker 部署, HERMES_HOME 通常是 bind-mount 的 host 路径
# (宿主 bind-mount 路径 = 容器内数据目录), 不是 ~/.hermes。
# - 技能同步是「最新者胜」: 由比较器在目标端逐一比 mtime(整秒),
# 只覆盖更旧的同名文件; 目标端独有/更新的文件一律保留。
# collect_local 必须用 cp -a: 普通 cp -r 不保留 mtime, 会让本地文件全部
# "看起来更新"从而反过来覆盖目标端。
# - **同步是「叠加」语义, 不是镜像**: 一端删除的文件/技能不会在另一端被删除。
# 清理必须显式做(`--list-only-here` 只报数, 不删)。
# - 记忆同步是「追加合并」(MEMORY.md 与 USER.md 同一套): 基于 § 段落去重追加,
# 绝不覆盖目标端已有记忆。
# - **记忆合并的 python 一律用「文件参数」传 dst/src, 绝不走 stdin 数据流。**
# `cat src | ssh host "python3 - dst <<'EOF'"` 里 heredoc 独占 stdin, python
# 读完程序后 sys.stdin.read() 恒为空 → 静默"新增 0 段"(2026-09-10 实测复现)。
# - 合并后校验字符数: 超过实例预算(~2200/1375)时 Hermes 会拒绝后续记忆写入,
# 所以这里直接告警, 不留给下次出问题才发现。
# - 每次运行用独立的 run 目录与远端暂存名(含 run id): 两台机器/两个进程同时跑
# 不会互删暂存、也不会把别人的 staging 覆盖到自己目标上。
# - 拉取时排除 skills 根目录的 runtime 元数据(.bundled_manifest/.curator_*/
# .usage.json/.curator_backups/.hub) 与各机 python 缓存。
# - SSH_PORT 留空 = 不传 -p; 用 ~/.ssh/config 的 Host 别名时留空, 让别名决定端口。
# - 本机 python3 可能是 Store 占位(非真实解释器): 必须探测可用的解释器。
#
set -euo pipefail
# ============ CONFIG — 改成你自己的 ============
# VPS SSH 登录。推荐填 ~/.ssh/config 里的 Host 别名(如 hub),
# 这样 hostname/port/密钥/跳板都由 config 决定, 命令行不要再用 -i/-J 覆盖。
VPS_USER_HOST="${VPS_USER_HOST:-YOUR_SSH_ALIAS_OR_root@YOUR_VPS_HOST}"
# VPS 上 Hermes 的 HERMES_HOME(host 视角)。Docker 部署 = bind-mount 的 host 路径。
VPS_HERMES_HOME="${VPS_HERMES_HOME:-YOUR_VPS_HERMES_HOME}"
# SSH 端口: 非 22 且**不用** ssh 别名时填。留空 = 不加 -p(交给 ssh/config 决定)。
# 注意: 用别名时若这里填了别的端口, -p 会覆盖 config 的 Port。
SSH_PORT="${SSH_PORT:-}"
# SSH 私钥路径(可选; 从密码管理器导出的 .pem/.key, 用户在终端提供)。
# 「密钥带口令 + 没有 agent」时: 自己接好 askpass, 不依赖调用者的环境,
# 也不允许 ssh 退回终端/GUI 提示(自动化里那样就是挂死)。
# 派发脚本按提示里的密钥名去 Windows 凭据管理器取口令(约定 hermes_ssh_<name>_ssh)。
ASKPASS_SH="${LOCALAPPDATA:-$HOME/AppData/Local}/hermes/scripts/askpass.sh"
if [ -x "$ASKPASS_SH" ] || [ -f "$ASKPASS_SH" ]; then
case "${SSH_ASKPASS:-}" in
""|*git-askpass*) export SSH_ASKPASS="$ASKPASS_SH" ;;
esac
export SSH_ASKPASS_REQUIRE=force
: "${DISPLAY:=:0}"; export DISPLAY
fi
# 用 ssh 别名时留空, 让 config 的 IdentityFile(可能是带口令的, 配 askpass)生效。
SSH_KEY="${SSH_KEY:-}"
# 跳板机/堡垒机(可选): 形如 user@jump-host 或 user@jump-host:port。
# VPS 只允许经跳板访问时必须填; 留空 = 直连 VPS。
SSH_JUMP="${SSH_JUMP:-}"
# SSH 连接复用(ControlMaster)套接字: 默认关闭。
# 实测(2026-09, git-bash OpenSSH + ProxyJump): 复用与跳板组合下会
# "mux_client_request_session: read from master failed" 直接打断 tar 流,
# 所以宁可用普通连接。要开启就显式设 SSH_CONTROL_PATH。
# 注意: fail2ban 只统计**认证失败**, 成功连接多开不触发, 关掉复用是安全的。
SSH_CONTROL_PATH="${SSH_CONTROL_PATH:-}"
# 本地 HERMES_HOME(Windows git-bash 下是 $HOME/AppData/Local/hermes)
LOCAL_HERMES_HOME="${LOCAL_HERMES_HOME:-$HOME/AppData/Local/hermes}"
# 可选: 把某个本地 profile(如第二个档)也一起同步。留空则只同步 default。
EXTRA_PROFILE="${EXTRA_PROFILE:-}"
# staging 目录, 打包产物与每次运行的 run 目录放这里
STAGING="${STAGING:-$HOME/hermes-sync-staging}"
# staging 保留最近几次运行(避免系统盘无限增长): run 目录与 tar 包各留这么多份
KEEP_RUNS="${KEEP_RUNS:-3}"
# 目标端 Hermes 运行用户的 UID/GID(容器内 hermes 用户通常是 1000)。
# 关键: Windows 侧 tar 会把 MSYS 的合成 uid(如 197609)记进归档, 远端 root 解包时会
# 原样落盘, 导致技能树变成只读, 容器内 hermes 无法写 -> 必须解包后 chown 回该值。
TARGET_UID="${TARGET_UID:-1000}"
TARGET_GID="${TARGET_GID:-1000}"
# 实例的记忆预算(字符): 拉取合并到本机时超出就"延迟"而不是溢出(Hermes 会拒绝超预算写入)
MEMORY_BUDGET="${MEMORY_BUDGET:-2200}"
USER_BUDGET="${USER_BUDGET:-1375}"
# 汇聚端(VPS)的记忆预算: 默认与实例同值 —— 中枢记忆是各机拉取的「精修基准集」,
# 必须保持可写; 设 0 = 不限, 只在你故意让中枢累积全量并集时用。超出同样记延迟, 不会丢。
HUB_MEMORY_BUDGET="${HUB_MEMORY_BUDGET:-2200}"
HUB_USER_BUDGET="${HUB_USER_BUDGET:-1375}"
# 拉取时排除的 runtime 元数据(skills 根目录, 各机自有状态) + 各机 python 缓存
EXCLUDES=(--exclude=./.bundled_manifest --exclude=./.curator_ledger.jsonl --exclude=./.curator_state
--exclude=./.usage.json --exclude=./.usage.json.lock --exclude=./.curator_backups
--exclude=./.hub --exclude=./.sync-stage --exclude=./.sync-mem-src.tmp
--exclude=./.sync-run- --exclude=__pycache__)
# 本机 python 解释器: 显式覆盖用 PYTHON=<path>。默认探测一个真能跑的解释器
# (本机 python3 可能是 Store 占位, 不探测会静默不执行)。
PYTHON="${PYTHON:-}"
# ==============================================
NOW="$(date +%Y%m%d_%H%M%S)"
# 每次运行唯一的 run id: 本地 run 目录与远端暂存名都带它 -> 并发不互踩
RUNID="$(date +%s)-$$-$(hostname 2>/dev/null | tr -cd 'A-Za-z0-9')"
RUNDIR="$STAGING/run-$NOW-$RUNID"
TARBALL="$STAGING/hermes-assets-$NOW-$RUNID.tar.gz"
# 「最新者胜」比较器:两边都看得见 mtime 的那一端才跑它(见各 push_/pull_ 函数)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APPLIER="$SCRIPT_DIR/apply-newest-wins.py"
# 原生 Windows python 打不开 MSYS 风格路径(/c/...), 传参前必须转成 C:\...
APPLIER_WIN="$(cygpath -w "$APPLIER" 2>/dev/null || echo "$APPLIER")"
# 远端暂存目录:先整棵传上去,再由比较器逐文件决定是否覆盖。带 RUNID 防并发串扰。
REMOTE_STAGE="$VPS_HERMES_HOME/.sync-run-$RUNID.stage"
REMOTE_MEMSRC="$VPS_HERMES_HOME/.sync-run-$RUNID.memsrc"
REMOTE_MERGEPY="/tmp/hermes-md-merge-$RUNID.py"
# 本地/远端共用的记忆合并脚本(以文件参数取数据, 见头部说明)
MERGEPY="$RUNDIR/md-merge.py"
MERGEPY_WIN="$(cygpath -w "$MERGEPY" 2>/dev/null || echo "$MERGEPY")"
REMOTE_PY="python3"
MERGEPY_BODY='import sys, re, os
dst, src = sys.argv[1], sys.argv[2]
budget = int(sys.argv[3]) if len(sys.argv) > 3 else 0 # 0 = 不限(汇聚端当档案库)
norm = lambda s: re.sub(r"\s+", " ", s).strip()
def segs(t):
return [norm(x) for x in re.split(r"(?m)^[ \t]*\u00a7[ \t]*$", t)] if t.strip() else []
try:
existing = open(dst, encoding="utf-8", errors="replace").read()
except FileNotFoundError:
existing = ""
existing_segs = [s for s in segs(existing) if s]
cores = {s[:60] for s in existing_segs}
out = existing.rstrip("\n")
added = 0
deferred = []
deferred_full = []
for s in segs(open(src, encoding="utf-8", errors="replace").read()):
if not s:
continue
c = s[:60]
if c in cores or any(c in r for r in existing_segs):
continue
candidate = (out + ("\n\u00a7\n" if out.strip() else "") + s).strip()
if budget and len(candidate) + 1 > budget:
deferred.append(s[:48]) # 目的地预算优先: 不追加, 但明确报出来
deferred_full.append(s)
continue
out = candidate
cores.add(c)
added += 1
if not existing.strip():
out = out.lstrip("\n\u00a7")
out = out.strip() + "\n"
if existing.strip() and not out.startswith(existing.rstrip("\n")):
print("WARN: 目标文件在我读取后被别人改过(并发写入), 本次结果可能少一段", file=sys.stderr)
with open(dst, "w", encoding="utf-8", newline="\n") as f:
f.write(out)
side = dst + ".deferred"
if deferred_full:
with open(side, "w", encoding="utf-8", newline="\n") as f:
f.write("\n\u00a7\n".join(deferred_full) + "\n")
else:
try:
os.remove(side)
except OSError:
pass
print("added segments:", added, "| deferred:", len(deferred), "| chars:", len(out.strip()) + 1)
if deferred:
print("DEFERRED(超出预算未合并, 全文见 " + side + "): " + " || ".join(deferred[:5]), file=sys.stderr)
'
# ---------- 选一个真能跑的 python ----------
pick_python() {
[ -n "$PYTHON" ] && return 0
local c
for c in python3 python py; do
if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys' >/dev/null 2>&1; then
PYTHON="$c"; return 0
fi
done
echo "!! 找不到可用的本地 python(试过 python3/python/py); 可用 PYTHON=<path> 指定" >&2
return 1
}
ensure_helpers() {
mkdir -p "$RUNDIR"
printf '%s\n' "$MERGEPY_BODY" > "$MERGEPY"
pick_python
}
# 字符数: 本地文件用 $PYTHON, 远端文件用远端 python3(wc -m 受 locale 影响, 不用)
local_chars() {
"$PYTHON" -c "import sys;print(len(open(sys.argv[1],encoding='utf-8',errors='replace').read()))" \
"$(cygpath -w "$1" 2>/dev/null || echo "$1")" 2>/dev/null || echo 0
}
remote_chars() {
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" \
"$REMOTE_PY -c 'import sys;print(len(open(sys.argv[1],encoding=\"utf-8\",errors=\"replace\").read()))' '$1'" 2>/dev/null || echo 0
}
check_budget() {
# 用法: check_budget <label> <chars> <budget>
local label="$1" chars="$2" budget="$3"
if [ "$chars" -gt "$budget" ] 2>/dev/null; then
echo " !! $label 现有 $chars 字符, 超过实例预算 $budget —— 该实例会拒绝后续记忆写入;" >&2
echo " 需要在中枢/本机手工精简(§ 段落合并), 见 SKILL.md「Keep the hub's MEMORY.md curated」" >&2
else
echo " $label 字符数 $chars (预算 $budget)"
fi
}
validate_config() {
local bad=0
case "$VPS_USER_HOST" in *YOUR_*|*'<'*) echo "!! VPS_USER_HOST 仍是占位符: $VPS_USER_HOST" >&2; bad=1;; esac
case "$VPS_HERMES_HOME" in *YOUR_*|*'<'*) echo "!! VPS_HERMES_HOME 仍是占位符: $VPS_HERMES_HOME" >&2; bad=1;; esac
if [ "$bad" = 1 ]; then
echo " 模板把端点占位符化了: 先本地化填回本机真值," >&2
echo " 或用 VPS_USER_HOST=/VPS_HERMES_HOME= 环境变量显式给值。" >&2
exit 2
fi
}
# 远端暂存/临时文件的兜底清理(异常退出也不留垃圾)
cleanup() {
local rc=$?
trap - EXIT
if [ -n "${REMOTE_STAGE:-}" ] && [ -n "${SSH_ARGS[*]:-}" ]; then
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" \
"rm -rf '$REMOTE_STAGE' '$REMOTE_MEMSRC' '$REMOTE_MERGEPY'" >/dev/null 2>&1 || true
fi
exit "$rc"
}
prune_old_runs() {
ls -1dt "$STAGING"/run-* 2>/dev/null | tail -n +$((KEEP_RUNS + 1)) | xargs -r rm -rf
ls -1t "$STAGING"/hermes-assets-*.tar.gz 2>/dev/null | tail -n +$((KEEP_RUNS + 1)) | xargs -r rm -f
}
# ---------- 构造 SSH 参数 ----------
SSH_ARGS=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20)
if [ -n "$SSH_PORT" ]; then SSH_ARGS+=( -p "$SSH_PORT" ); fi
if [ -n "$SSH_JUMP" ]; then SSH_ARGS+=( -J "$SSH_JUMP" ); fi
if [ -n "$SSH_KEY" ]; then SSH_ARGS+=( -i "$SSH_KEY" ); fi
if [ -n "$SSH_CONTROL_PATH" ]; then
mkdir -p "${SSH_CONTROL_PATH%%%*}" 2>/dev/null || true
SSH_ARGS+=( -o ControlMaster=auto -o "ControlPath=$SSH_CONTROL_PATH" -o ControlPersist=120 )
fi
validate_config
trap cleanup EXIT
echo "==> 本地 HERMES_HOME: $LOCAL_HERMES_HOME"
[ -d "$LOCAL_HERMES_HOME" ] || mkdir -p "$LOCAL_HERMES_HOME"
mkdir -p "$RUNDIR"
prune_old_runs
# ---------- 收集 skills (default) ----------
collect_local() {
echo "==> 收集 skills (default) ..."
rm -rf "$RUNDIR/skills"
mkdir -p "$RUNDIR/skills"
if [ -d "$LOCAL_HERMES_HOME/skills" ]; then
( cd "$LOCAL_HERMES_HOME/skills" && \
find . -name SKILL.md -print0 | while IFS= read -r -d '' sk; do
d="$(dirname "$sk")"
mkdir -p "$RUNDIR/skills/$d"
cp -a "$LOCAL_HERMES_HOME/skills/$d/." "$RUNDIR/skills/$d/"
done )
fi
echo " 收集到 $(find "$RUNDIR/skills" -name SKILL.md 2>/dev/null | wc -l) 个 skill"
# 平铺事故(2026-08): SKILL.md 直接落在 skills/ 下会造成同名重复
if [ -e "$LOCAL_HERMES_HOME/skills/SKILL.md" ]; then
echo " !! 本机 skills/ 根目录存在 SKILL.md(平铺残留), 会同步成重复技能, 请先清理" >&2
fi
echo "==> 收集记忆 (MEMORY.md + USER.md) ..."
rm -rf "$RUNDIR/memories"
mkdir -p "$RUNDIR/memories"
local m
for m in MEMORY.md USER.md; do
if [ -f "$LOCAL_HERMES_HOME/memories/$m" ]; then
cp "$LOCAL_HERMES_HOME/memories/$m" "$RUNDIR/memories/$m"
fi
done
if [ -n "$EXTRA_PROFILE" ] && [ -d "$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE" ]; then
echo "==> 收集 extra profile: $EXTRA_PROFILE ..."
local prep="$RUNDIR/profile-$EXTRA_PROFILE"
rm -rf "$prep"; mkdir -p "$prep/skills" "$prep/memories"
if [ -d "$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE/skills" ]; then
( cd "$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE/skills" && \
find . -name SKILL.md -print0 | while IFS= read -r -d '' sk; do
d="$(dirname "$sk")"
mkdir -p "$prep/skills/$d"
cp -a "$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE/skills/$d/." "$prep/skills/$d/"
done )
fi
for m in MEMORY.md USER.md; do
if [ -f "$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE/memories/$m" ]; then
cp "$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE/memories/$m" "$prep/memories/$m"
fi
done
fi
echo "==> 打包 -> $TARBALL"
tar -C "$RUNDIR" -czf "$TARBALL" .
echo " 产物大小: $(du -h "$TARBALL" | cut -f1)"
}
# ---------- 推送 (本地 -> VPS) ----------
push_dir() {
local src="$RUNDIR/$1" dst="$2"
[ -d "$src" ] || { echo " (跳过: 本地无 $src)"; return 0; }
echo "==> 推送 $1 -> $dst (最新者胜) ..."
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "rm -rf '$REMOTE_STAGE'; mkdir -p '$REMOTE_STAGE' '$dst'"
( cd "$src" && tar czf - "${EXCLUDES[@]}" . ) | ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "tar xzf - -C '$REMOTE_STAGE'"
# 比较必须发生在目标端:只有那里同时看得见两边的 mtime
local res rc=0
res=$( ( cat "$APPLIER" ) | ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" \
"cat > $REMOTE_MERGEPY.nw && $REMOTE_PY $REMOTE_MERGEPY.nw '$REMOTE_STAGE' '$dst' '$TARGET_UID:$TARGET_GID'; rc=\$?; rm -rf '$REMOTE_STAGE' $REMOTE_MERGEPY.nw; exit \$rc" ) \
|| rc=$?
echo " 覆盖/跳过/冲突 = $res"
if [ "$rc" != 0 ]; then
echo " !! 远端比较器返回 $rc —— 有文件没写成功或回读校验不一致(见上方 stderr), 本次同步不可信" >&2
return "$rc"
fi
}
# ---------- 记忆合并(MEMORY.md / USER.md 共用, 一律文件参数) ----------
merge_md_remote() {
# 用法: merge_md_remote <本地源 .md> <远端目标 .md> <本机预算字符数(仅用于告警)>
local src="$1" dst="$2" budget="$3"
[ -f "$src" ] || { echo " (本地无 $src, 跳过)"; return 0; }
# 汇聚端预算: MEMORY/USER 各自可配, 默认与实例同值(中枢兼作精修基准集, 保持可写)
local hub_budget="$HUB_MEMORY_BUDGET"
case "$(basename "$dst")" in USER.md) hub_budget="$HUB_USER_BUDGET" ;; esac
ensure_helpers
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "mkdir -p '$(dirname "$dst")'"
echo "==> 推送合并 $(basename "$dst") -> $dst (基于 § 段落并集, 只补缺失) ..."
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "cat > '$REMOTE_MEMSRC'" < "$src"
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "cat > $REMOTE_MERGEPY" < "$MERGEPY"
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" \
"$REMOTE_PY $REMOTE_MERGEPY '$dst' '$REMOTE_MEMSRC' '$hub_budget'; rc=\$?; rm -f $REMOTE_MERGEPY '$REMOTE_MEMSRC'; exit \$rc"
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "chown $TARGET_UID:$TARGET_GID '$dst' 2>/dev/null || true"
check_budget "远端 $(basename "$dst")" "$(remote_chars "$dst")" "$budget"
}
merge_md_local() {
# 用法: merge_md_local <远端源 .md> <本地目标 .md> <预算字符数>
local rsrc="$1" ldst="$2" budget="$3"
ensure_helpers
mkdir -p "$(dirname "$ldst")"
local tmp="$RUNDIR/remote-$(basename "$ldst")"
echo "==> 拉取并合并 $(basename "$ldst") -> $ldst (基于 § 段落并集, 保留本地独有) ..."
if ! ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "cat '$rsrc' 2>/dev/null" > "$tmp"; then
echo " (远端无 $rsrc, 跳过)"; return 0
fi
if [ ! -s "$tmp" ]; then
echo " (远端 $rsrc 为空, 跳过)"; rm -f "$tmp"; return 0
fi
"$PYTHON" "$MERGEPY_WIN" \
"$(cygpath -w "$ldst" 2>/dev/null || echo "$ldst")" \
"$(cygpath -w "$tmp" 2>/dev/null || echo "$tmp")" "$budget"
rm -f "$tmp"
check_budget "本地 $(basename "$ldst")" "$(local_chars "$ldst")" "$budget"
}
# ---------- 拉取 (VPS -> 本地) ----------
pull_skills() {
local remote_src="$VPS_HERMES_HOME/skills"
local local_dst="$LOCAL_HERMES_HOME/skills"
echo "==> 从 VPS 拉取 skills -> $local_dst (最新者胜) ..."
local stage="$RUNDIR/pull-skills"
rm -rf "$stage"; mkdir -p "$stage" "$local_dst"
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "tar ${EXCLUDES[*]} -czf - -C '$remote_src' ." | tar xzf - -C "$stage"
# 两个路径参数都必须转成 Windows 形式: 原生 python 打不开 MSYS 的 /c/...
# (否则 os.path.isdir(staged) 为假 -> 静默"0 0 0" 什么也不做)
local staged_n res rc=0
staged_n=$(find "$stage" -type f 2>/dev/null | wc -l)
[ "$staged_n" -gt 0 ] || { echo " !! 暂存区为空: 拉取没拿到任何文件, 请检查远端路径/权限" >&2; return 1; }
res=$("$PYTHON" "$APPLIER_WIN" "$(cygpath -w "$stage")" "$(cygpath -w "$local_dst")") || rc=$?
rm -rf "$stage"
echo " 覆盖/跳过/冲突 = $res"
if [ "$rc" != 0 ]; then
echo " !! 比较器返回 $rc —— 有文件没写成功或回读校验不一致, 本次同步不可信" >&2
return "$rc"
fi
}
pull_extra_profile() {
[ -n "$EXTRA_PROFILE" ] || return 0
local remote_base="$VPS_HERMES_HOME/profiles/$EXTRA_PROFILE"
local local_base="$LOCAL_HERMES_HOME/profiles/$EXTRA_PROFILE"
local stage="$RUNDIR/pull-profile-$EXTRA_PROFILE"
# 远端没有这个 profile 时直接跳过: 不要让空 tar 流把整次拉取打断
if ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "test -d '$remote_base/skills'"; then
echo "==> 拉取 extra profile ($EXTRA_PROFILE) skills -> $local_base/skills (最新者胜) ..."
rm -rf "$stage"; mkdir -p "$stage" "$local_base/skills"
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "tar ${EXCLUDES[*]} -czf - -C '$remote_base/skills' ." | tar xzf - -C "$stage"
local rc=0
"$PYTHON" "$APPLIER_WIN" "$(cygpath -w "$stage")" "$(cygpath -w "$local_base/skills")" || rc=$?
rm -rf "$stage"
[ "$rc" = 0 ] || { echo " !! profile 比较器返回 $rc" >&2; return "$rc"; }
else
echo " (远端无 $remote_base/skills, 跳过 profile 技能)"
fi
merge_md_local "$remote_base/memories/MEMORY.md" "$local_base/memories/MEMORY.md" "$MEMORY_BUDGET"
merge_md_local "$remote_base/memories/USER.md" "$local_base/memories/USER.md" "$USER_BUDGET"
}
# ---------- 只看差异(不改任何东西): 一端独有、另一端没有的技能 ----------
list_only_here() {
local side="$1" # local | remote
local tmp_l tmp_r
tmp_l="$RUNDIR/.cmp-local"; tmp_r="$RUNDIR/.cmp-remote"
( cd "$LOCAL_HERMES_HOME/skills" && find . -name SKILL.md | LC_ALL=C sort ) > "$tmp_l" 2>/dev/null || : > "$tmp_l"
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "cd '$VPS_HERMES_HOME/skills' && find . -name SKILL.md | LC_ALL=C sort" > "$tmp_r" 2>/dev/null || : > "$tmp_r"
echo "==> 技能差异(仅报告, 不删任何东西)"
echo " 本机 $(wc -l < "$tmp_l") 个 / 远端 $(wc -l < "$tmp_r") 个"
echo " --- 只在本机有 ---"; LC_ALL=C comm -23 "$tmp_l" "$tmp_r" | sed 's/^/ /'
echo " --- 只在远端有 ---"; LC_ALL=C comm -13 "$tmp_l" "$tmp_r" | sed 's/^/ /'
echo " 说明: 本工具是叠加语义, 这些差异不会被自动删除; 确认要清理时手工删对应端。"
[ "$side" = "local" ] || true
}
# ---------- 动作 ----------
do_push() {
ensure_helpers
collect_local
echo ""
echo "==> 推送 skills 到 VPS (最新者胜覆盖同名, 保留远端独有) ..."
push_dir "skills" "$VPS_HERMES_HOME/skills"
echo ""
echo "==> 推送记忆到 VPS (追加合并, 不覆盖远端) ..."
merge_md_remote "$RUNDIR/memories/MEMORY.md" "$VPS_HERMES_HOME/memories/MEMORY.md" "$MEMORY_BUDGET"
merge_md_remote "$RUNDIR/memories/USER.md" "$VPS_HERMES_HOME/memories/USER.md" "$USER_BUDGET"
if [ -n "$EXTRA_PROFILE" ] && [ -d "$RUNDIR/profile-$EXTRA_PROFILE" ]; then
echo ""
echo "==> 推送 $EXTRA_PROFILE profile 到 VPS ..."
push_dir "profile-$EXTRA_PROFILE/skills" "$VPS_HERMES_HOME/profiles/$EXTRA_PROFILE/skills"
merge_md_remote "$RUNDIR/profile-$EXTRA_PROFILE/memories/MEMORY.md" \
"$VPS_HERMES_HOME/profiles/$EXTRA_PROFILE/memories/MEMORY.md" "$MEMORY_BUDGET"
merge_md_remote "$RUNDIR/profile-$EXTRA_PROFILE/memories/USER.md" \
"$VPS_HERMES_HOME/profiles/$EXTRA_PROFILE/memories/USER.md" "$USER_BUDGET"
fi
# 收尾: 把集中存放资产的目录整棵 chown 回目标属主, 顺带清掉历史遗留的合成 uid
echo ""
echo "==> 修正 VPS 侧属主 -> $TARGET_UID:$TARGET_GID (含历史遗留) ..."
ssh "${SSH_ARGS[@]}" "$VPS_USER_HOST" "for d in '$VPS_HERMES_HOME/skills' '$VPS_HERMES_HOME/memories' '$VPS_HERMES_HOME/profiles'; do [ -e \"\$d\" ] && chown -R $TARGET_UID:$TARGET_GID \"\$d\"; done; ls -ld '$VPS_HERMES_HOME/skills'"
echo ""
echo "==> 推送完成。如需使 VPS 生效,请在 VPS 上重启 hermes/gateway 容器。"
}
do_pull() {
ensure_helpers
echo ""
echo "==> 开始从 VPS ($VPS_USER_HOST) 拉取资产到本地 ($LOCAL_HERMES_HOME) ..."
pull_skills
echo ""
merge_md_local "$VPS_HERMES_HOME/memories/MEMORY.md" "$LOCAL_HERMES_HOME/memories/MEMORY.md" "$MEMORY_BUDGET"
merge_md_local "$VPS_HERMES_HOME/memories/USER.md" "$LOCAL_HERMES_HOME/memories/USER.md" "$USER_BUDGET"
if [ -n "$EXTRA_PROFILE" ]; then
echo ""
pull_extra_profile
fi
echo ""
echo "==> 拉取合并完成。本地 Hermes 已拥有 VPS 汇聚的完整技能与记忆。"
echo " 提醒: 同步是叠加语义, 远端删掉的技能不会在本机消失(需要时用 --list-only-here 看差异)。"
}
# ---------- 入口 ----------
MODE="${1:-}"
case "$MODE" in
--push)
do_push
;;
--pull)
do_pull
;;
--sync)
echo "==> 双向同步: 先 PULL 收敛其余端的增量, 再 PUSH 回传本端新增"
do_pull
echo ""
do_push
;;
--list-only-here)
ensure_helpers
list_only_here local
;;
*)
ensure_helpers
collect_local
echo ""
echo "==> 打包完成。"
echo " - 推送到 VPS: $0 --push"
echo " - 从 VPS 拉取: $0 --pull"
echo " - 双向同步: $0 --sync"
echo " - 只看技能差异(不删): $0 --list-only-here"
;;
esac