refactor(role): 角色檔拆成身分(identity)與人格(soul)兩檔

使用者反映身分設定與性格語氣擠在同一段(例如西莉卡的「馴獸師、養畢娜」是身分,
「細心、努力」是性格),要求拆成兩個檔案且不得遺失內容。

格式(扁平式,與既有 .assets/.checks/.lock 命名一致):
- <ID>.identity.md:角色 ID、顯示名稱、來源作品、與使用者的關係定位、簽名 emoji
- <ID>.soul.md:本質(nature)、氛圍(vibe)

共用行為規則**不再寫入角色檔**:role_load.sh 從不讀角色檔裡那份,它是冗余副本,
只會多一個漏同步的機會。內容完整保留於 role_load.sh(實際生效)與 SKILL.md(文件)。

- role_lib.sh:新增 role_identity_file/role_soul_file/role_legacy_file/
  role_is_new_format;role_file 改為新格式優先、找不到退回舊檔,既有角色不受影響
- role_list_peers 支援兩種格式並避免同一角色重複列出
- role_load.sh:身分與人格分別讀取,新增「來源」與「關係定位」注入區塊
- 新增 --migrate <角色 ID>:逐字搬移本質、氛圍與簽名 emoji,來源與關係定位產生
  待填空白,舊檔保留不動,新檔已存在時中止不覆寫

同時修掉兩個會造成實際損失的錯:
- --export 原本硬編 cp 成 <ID>.md,新格式會被寫成舊檔名且遺失人格檔,備份救不回角色
- --export 從未備份 <ID>.checks(使用者自訂的晨間檢查腳本),一併補上
- --agent 原只讀 role_file(新格式即 identity),匯出的 sub agent 人格會是空的

驗證:遷移後逐字比對確認本質/氛圍/簽名 emoji/id/name/emoji/created 全部一致
且共用行為未寫入;新格式匯出的備份含 identity、soul、舊檔、assets、checks 與記憶;
--agent 取得人格非空;--status 正確顯示格式與兩檔路徑;舊格式角色載入完全不受影響。

版號沿用 0.0.5(master 為 0.0.4,同一 PR 不再累加)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-29 11:12:34 +08:00
co-authored by Claude Opus 5
parent 241261087b
commit f76915bc87
4 changed files with 276 additions and 117 deletions
+31 -5
View File
@@ -110,11 +110,24 @@ fi
# ------------------------------------------------------------------------------
# 非睡眠時段:組出角色人格 + 操作規則 + 記憶
# ------------------------------------------------------------------------------
ROLE_PROFILE="$(node - "$ROLE_DEF" <<'NODE_PROFILE' 2>/dev/null
ROLE_SOUL_FILE=""
if role_is_new_format "$ROLE"; then
ROLE_SOUL_FILE="$(role_soul_file "$ROLE")"
[ -f "$ROLE_SOUL_FILE" ] || role_log "WRN" "新格式缺少人格檔:${ROLE_SOUL_FILE}(本質與氛圍將為空)"
fi
ROLE_PROFILE="$(node - "$ROLE_DEF" "$ROLE_SOUL_FILE" <<'NODE_PROFILE' 2>/dev/null
const fs = require("fs");
// 新格式:第一個參數是 <ID>.identity.md(身分),第二個是 <ID>.soul.md(人格)。
// 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。
const file = process.argv[2];
const soulFile = process.argv[3] || "";
const raw = fs.readFileSync(file, "utf8");
let soulRaw = "";
if (soulFile) {
try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; }
}
function parseFrontmatter(text) {
const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
@@ -135,15 +148,28 @@ function section(text, title) {
}
const fm = parseFrontmatter(raw);
const soulFm = soulRaw ? parseFrontmatter(soulRaw) : {};
const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" ");
const nature = section(raw, "本質(nature") || fm.nature || "";
const vibe = section(raw, "氛圍(vibe") || fm.vibe || "";
// 人格優先取自 soul 檔;舊格式(無 soul 檔)則沿用原本從單一檔案抽取的行為
const natureSrc = soulRaw || raw;
const natureFm = soulRaw ? soulFm : fm;
const nature = section(natureSrc, "本質(nature") || natureFm.nature || "";
const vibe = section(natureSrc, "氛圍(vibe") || natureFm.vibe || "";
// 身分只可能在 identity/舊檔裡
const emoji = section(raw, "簽名 emoji") || fm.emoji || "";
const source = section(raw, "來源(source") || fm.source || "";
const relationship = section(raw, "關係定位(relationship") || fm.relationship || "";
const lines = [
`- 角色 ID${fm.id || ""}`,
`- 角色 ID${fm.id || soulFm.id || ""}`,
`- 顯示名稱:${fm.name || title || ""}`,
`- 簽名 emoji${fm.emoji || ""}`,
];
if (source) lines.push("", "## 來源(source", "", source);
if (relationship) lines.push("", "## 關係定位(relationship", "", relationship);
lines.push(
"",
"## 本質(nature",
"",
@@ -156,7 +182,7 @@ const lines = [
"## 簽名 emoji",
"",
emoji || fm.emoji || "(未設定)",
];
);
process.stdout.write(lines.join("\n"));
NODE_PROFILE