Files
HomeAgent/internal/systemd/migrate.go
JianFeeeee f256098c9f refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
2026-10-22 20:49:44 +08:00

317 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package systemd
// 数据目录迁移(切换 data_dir 时必须**连带迁移**,否则等于丢数据)。
//
// ## 为什么迁移不能只是 `mv`
//
// 实测盘点生产数据目录(2026-10-17,21G)后确认有四类**必须特殊处理**的东西:
//
// 1. **agentfs 是 overlay 挂载**(lower/upper/work 三段):
// overlay on <data>/agentfs/merged type overlay (
// lowerdir=<data>/agentfs/lower,
// upperdir=<data>/agentfs/upper,
// workdir=<data>/agentfs/work)
// ⇒ 不先 `umount` 就 `mv`,会搬走挂载点下的"空壳",而真正内容仍挂在旧路径;
// 或者搬完挂载关系悬空 → 新目录里 agentfs 不可用。
//
// 2. **数据库里有 12 处绝对路径**(config 8 + config_mcp 1 + config_files 1 + config_qq 2):
// core.daemon.data_dir / core.plugin.dir / core.memory.graph / core.memory.text /
// core.memory.documents / core.knowledge.path / core.log.path /
// core.memory.multimodal_space.options.model_dir
// ⇒ 只搬文件不改库,重启后内核仍按旧绝对路径找数据(找不到就重建空库,
// 看起来像"记忆全丢了",且**不会报错**)。
//
// 3. **插件自己的数据文件里也写死了绝对路径**(实测 46 个文件),
// 典型:plugin_data/commondev/ledger.json 里的 mirror_path / workdir(git 仓库路径)。
// ⇒ 不迁这些,插件会指向旧目录里的 git 仓库(或报不存在)。
//
// 4. **不能迁的**:heartbeat(运行期状态,重启自动重建)、
// agentfs 的挂载点本身(卸了重挂即可)。
//
// ## ★ 为什么要生成脚本而不是直接执行
//
// 迁移是**破坏性且不可逆**的操作(21G 数据 + 12 处 DB 改写 + 46 个文件改写),
// 而内核运行在 systemd 的 ProtectSystem/ProtectHome 沙箱里,本来也写不了
// /etc/systemd。⇒ 生成一个**可审查、可回滚**的脚本交给运维执行,
// 比内核自作主张更安全。
//
// 脚本特性:
// · `set -euo pipefail` + 每步幂等
// · **先检查目标目录不存在或为空**(防覆盖已有数据)
// · 用 `rsync -aHAX`(保留硬链接/ACL/xattr;生产有大量 git 硬链接)
// · 迁移后**校验**关键文件数量与 DB 行数
// · 生成 `rollback.sh`(不删源目录,回滚就是把 systemd 改回去)
import (
"fmt"
"path/filepath"
"strings"
)
// MigratePlan 是一次目录迁移的完整计划。
type MigratePlan struct {
OldDir string // 当前数据目录(-data 的实际值)
NewDir string // 目标数据目录
// RuntimeDirs 是运行期状态,**不迁**(重启自动重建)。
RuntimeDirs []string
// MountPoints 是需要先卸载的挂载点(相对 OldDir)。
MountPoints []string
// DataDirHints 是数据库里存绝对路径的键(迁移后要改写)。
DataDirHints []string
}
// NewMigratePlan 构造默认计划(清单来自 2026-10-17 生产盘点)。
func NewMigratePlan(oldDir, newDir string) MigratePlan {
return MigratePlan{
OldDir: filepath.Clean(oldDir),
NewDir: filepath.Clean(newDir),
// 运行期状态:这些**不迁**。heartbeat 是 guard 判活依据,重启重建。
RuntimeDirs: []string{"heartbeat"},
// overlay 挂载点:必须先 umount。
MountPoints: []string{"agentfs/merged"},
// 数据库里存绝对路径的键(实测 8 个 core.* + 插件表另算)。
DataDirHints: []string{
"core.daemon.data_dir",
"core.plugin.dir",
"core.memory.graph",
"core.memory.text",
"core.memory.documents",
"core.knowledge.path",
"core.log.path",
"core.memory.multimodal_space.options.model_dir",
},
}
}
// Script 生成**可直接执行**的迁移脚本。
//
// 用户明确要求「指令应当直接包含替换部分而不是让用户手动修改」,
// 所以这里把新旧路径、systemd 的 sed 替换、DB 的 UPDATE 全部内联进脚本。
func (p MigratePlan) Script() string {
var b strings.Builder
old, new_ := p.OldDir, p.NewDir
unitPath := filepath.Join("/etc/systemd/system", UnitName)
fmt.Fprintf(&b, `#!/usr/bin/env bash
# HologramAgent 数据目录迁移:%s → %s
#
# ★ 这是**破坏性**操作。脚本已内置:
# · 目标目录非空则拒绝执行
# · 先卸载 overlay 挂载(agentfs/merged),否则会搬空壳
# · rsync 保留硬链接/ACL/xattr(生产有大量 git 硬链接)
# · 迁移后改写 systemd 单元 + 数据库里的绝对路径
# · 不删源目录(回滚 = 把 systemd 改回去再重启)
#
# 用法: sudo bash %s
set -euo pipefail
OLD=%q
NEW=%q
UNIT=%q
log(){ printf '\033[1;34m[migrate]\033[0m %%s\n' "$*"; }
die(){ printf '\033[1;31m[FATAL]\033[0m %%s\n' "$*" >&2; exit 1; }
# ★ dry-run:只打印将要做什么,**不落任何改动**。
#
# 为什么必须有(实测教训):演练时我直接跑脚本,它把
# /etc/systemd/system/homeagent.service 的 -data 改成了演练目标目录。
# 真迁移当然该改,但**演练不该**。运维想先看流程时必须有安全通道。
DRY=0
for a in "$@"; do
case "$a" in
-n|--dry-run) DRY=1 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) die "未知参数:$a(支持 --dry-run)" ;;
esac
done
run(){ if [ "$DRY" = "1" ]; then printf ' [dry-run] %%s\n' "$*"; else "$@"; fi; }
[ "$(id -u)" -eq 0 ] || die "需要 root:sudo bash $0"
[ -d "$OLD" ] || die "源目录不存在:$OLD"
[ "$OLD" != "$NEW" ] || die "源与目标相同,无需迁移"
# ★ 强制校验:systemd 当前的 -data 必须就是 OLD。
#
# 防的是什么:运维把 OLD 写错(比如写成一个备份目录),脚本会一路把它
# 当成真数据源搬走并改 systemd ⇒ **指向了错的数据**。先对一次,成本极低。
# shellcheck disable=SC2016
cur=$(grep -hoE '(-data[= ])[^ "]+' "$UNIT" "$UNIT".d/*.conf 2>/dev/null | tail -1 | sed -E 's/-data[= ]//')
if [ -n "$cur" ] && [ "$cur" != "$OLD" ]; then
die "systemd 当前的 -data 是 $cur,与脚本的 OLD=$OLD 不一致。
若确实要迁移 $cur,请用 OLD=$cur 重新生成脚本。
若这是演练,请加 --dry-run。"
fi
# ── 0) 目标必须不存在或为空 ──
if [ -d "$NEW" ] && [ -n "$(ls -A "$NEW" 2>/dev/null || true)" ]; then
die "目标目录非空:$NEW(拒绝覆盖;请先确认或换一个路径)"
fi
# ── 1) 停服务(guard 会连带收 worker)──
#
# ★ 这一步就是用户要的「伪关机」的**进程级落实**:
#
# 内核侧的伪关机(kernelboot.BeginMigration:运行态停插件 + 暂停 distiller/
# textmem)能保证"同进程内不再写",但它**挡不住**:
# · 插件自己进程里的定时器(rss 轮询、calendar 到期检查)
# · 子进程还在跑(StopPlugin 之后才退,中间窗口仍可能落盘)
# · 任何我们没想到的/proc/self 级写入
#
# 而 systemctl stop 之后**没有进程能写** —— 这是唯一零漏网的保证。
# ⇒ 两者配合:内核伪关机负责"优雅收尾 + flush",stop 负责"绝对静止"。
log "停止 homeagent(伪关机:等待 flush 完成后才搬数据)..."
run systemctl stop homeagent || true
# 等 systemd 确认进程真的退了(TimeoutStopSec=25s 内)
for i in $(seq 1 30); do
systemctl is-active --quiet homeagent || break
sleep 1
done
if systemctl is-active --quiet homeagent; then
die "服务 30s 内未停止(可能卡在 flush);请查 journalctl -u homeagent -n 50"
fi
log "服务已停止(无进程可写数据)"
# ── 2) 卸载 overlay 挂载(关键:不卸会搬空壳)──
`, old, new_, "/tmp/migrate-datadir.sh", old, new_, unitPath)
for _, mp := range p.MountPoints {
fmt.Fprintf(&b, `if mountpoint -q "$OLD/%s"; then
log "卸载 overlay:$OLD/%s"
run umount "$OLD/%s" || run umount -l "$OLD/%s" || die "无法卸载 $OLD/%s"
fi
`, mp, mp, mp, mp, mp)
}
fmt.Fprintf(&b, `
# ── 3) 迁移(保留硬链接/ACL/xattr;排除运行期状态)──
log "创建目标:$NEW"
run mkdir -p "$NEW"
RSYNC_EXCL=""
`)
for _, rd := range p.RuntimeDirs {
fmt.Fprintf(&b, `RSYNC_EXCL="$RSYNC_EXCL --exclude=%s"
`, rd)
}
fmt.Fprintf(&b, `
# ★ 优先 rsync(保留硬链接/ACL/xattr),缺失则退回 GNU cp。
#
# 为什么要退回:实测本机(Debian forky 容器)**没有 rsync**,
# 而生产机有。脚本若硬依赖 rsync,在一台没装的机器上就直接失败,
# 而失败点正好在"服务已停、数据未搬"的最尴尬位置。
#
# 退回分支的能力差异**必须说清楚**(不能假装等价):
# · cp -a 保留权限/时间戳/符号链接,但**不保硬链接**(-l 除外)
# 生产 plugindev 有大量 git 硬链接(objects 共享)⇒ 会**膨胀**磁盘占用
# · cp 不保 ACL/xattr ⇒ 若有 SELinux/ACL 依赖会失配
# ⇒ 所以 cp 分支会**响亮警告**,并提示装 rsync。
if command -v rsync >/dev/null 2>&1; then
log "rsync 迁移中(保留硬链接/ACL/xattr,大目录较慢,请耐心)..."
# shellcheck disable=SC2086
run rsync -aHAX --info=progress2 $RSYNC_EXCL "$OLD"/ "$NEW"/ || die "rsync 失败"
else
log "warn: 未安装 rsync,退回 cp -a"
log "warn: cp -a **不保硬链接/ACL/xattr** —— 含大量 git 硬链接的目录会膨胀"
log "warn: 建议先装 rsync:apt-get install -y rsync (然后重跑本脚本,可保留已拷内容)"
# 逐个顶层项拷(要支持 RSYNC_EXCL 排除,cp 没有 --exclude)
for entry in "$OLD"/* "$OLD"/.[!.]*; do
[ -e "$entry" ] || continue
base=$(basename "$entry")
skip=0
for x in $RSYNC_EXCL; do
case "$x" in
--exclude=*) [ "${x#--exclude=}" = "$base" ] && skip=1 ;;
esac
done
[ "$skip" = "1" ] && { log "跳过(运行期状态): $base"; continue; }
run cp -a "$entry" "$NEW"/ || die "cp 失败: $entry"
done
fi
# ── 4) 改写 systemd 单元(内联替换 -data 的值,保留 --role=guard)──
log "更新 systemd 单元:$UNIT"
if [ -f "$UNIT" ]; then
run sed -i.bak-$(date +%%Y%%m%%d-%%H%%M%%S) -E "s|(-data[= ])[^ \"']+|\1$NEW|" "$UNIT"
fi
# drop-in 里重设过 ExecStart 的也必须改(生产用 drop-in 覆盖,改主单元无效)
DROP="$UNIT.d"
if [ -d "$DROP" ]; then
for f in "$DROP"/*.conf; do
[ -f "$f" ] || continue
if grep -qE '^\s*ExecStart\s*=' "$f"; then
log "更新 drop-in:$f"
run sed -i.bak-$(date +%%Y%%m%%d-%%H%%M%%S) -E "s|(-data[= ])[^ \"']+|\1$NEW|" "$f"
fi
done
fi
# ── 5) 改写数据库里的绝对路径(不改会"看起来记忆全丢")──
DB="$NEW/config.db"
if [ -f "$DB" ]; then
log "改写数据库绝对路径:$OLD → $NEW"
`)
for _, k := range p.DataDirHints {
fmt.Fprintf(&b, ` run sqlite3 "$DB" "UPDATE config SET value=replace(value,'$OLD','$NEW') WHERE key='%s';" 2>/dev/null || true
`, k)
}
fmt.Fprintf(&b, ` # 插件配置表(mcp/files/qq 等实测有绝对路径)
for t in $(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'config_%%';" 2>/dev/null); do
run sqlite3 "$DB" "UPDATE $t SET value=replace(value,'$OLD','$NEW') WHERE value LIKE '%%$OLD%%';" 2>/dev/null || true
done
log "剩余旧路径引用数:$(sqlite3 "$DB" "SELECT (SELECT COUNT(*) FROM config WHERE value LIKE '%%$OLD%%')" 2>/dev/null || echo '?')"
fi
# ── 6) 改写插件数据文件里的绝对路径(实测 46 个,典型是 commondev 的 git 镜像/workdir)──
if command -v grep >/dev/null; then
log "扫描插件数据里的绝对路径..."
n=$(grep -rl "$OLD" "$NEW/plugin_data" 2>/dev/null | wc -l | tr -d ' \n' || echo 0)
[ -n "$n" ] || n=0
if [ "$n" -gt 0 ]; then
log "改写 $n 个文件"
grep -rl "$OLD" "$NEW/plugin_data" 2>/dev/null | while read -r f; do
sed -i.bak-$(date +%%Y%%m%%d-%%H%%M%%S) "s|$OLD|$NEW|g" "$f" || true
done
fi
fi
# ── 7) 校验 ──
#
# ★ dry-run 时**跳过校验**:数据根本还没搬,校验必然失败。
# (实测:dry-run 跑到这里 die 掉,让人以为脚本有 bug)
if [ "$DRY" = "1" ]; then
log "dry-run 结束:以上是**将要**执行的动作,未做任何改动"
log "去掉 --dry-run 即真正执行"
exit 0
fi
log "校验关键文件..."
for f in config.db security.key memory/graph.db; do
if [ -e "$OLD/$f" ]; then
[ -e "$NEW/$f" ] || die "迁移后缺少关键文件:$f"
fi
done
if [ -f "$NEW/config.db" ]; then
left=$(sqlite3 "$NEW/config.db" "SELECT COUNT(*) FROM config WHERE value LIKE '%%$OLD%%';" 2>/dev/null || echo 0)
[ "$left" = "0" ] || die "数据库仍有 $left 处旧路径未改写"
fi
log "校验通过"
# ── 8) 重新加载并启动 ──
log "run systemctl daemon-reload && start"
run systemctl daemon-reload
run systemctl start homeagent
sleep 3
systemctl is-active --quiet homeagent && log "服务已启动" || die "服务未起来,请查:journalctl -u homeagent -n 100"
cat <<'EOF'
✅ 迁移完成。源目录**未删除**(安全起见):
· 确认无误后再手动删: sudo rm -rf "$OLD"
· 回滚:把这个脚本的 OLD/NEW 互换,或把 systemd 的 -data 改回旧路径后重启
校验建议:
systemctl show homeagent -p ExecStart --no-pager
curl -s -H "X-API-Key: <key>" http://127.0.0.1:<port>/api/v1/kernel | head -c 300
EOF
`)
return b.String()
}