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:
@@ -69,9 +69,36 @@ role_resolve_name() {
|
||||
printf '%s' "$name"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 角色定義檔:身分(IDENTITY)與人格(SOUL)分離
|
||||
#
|
||||
# 新格式把「我是誰」與「我怎麼想」拆開,避免身分設定(來源作品、關係定位)與
|
||||
# 性格語氣擠在同一段裡:
|
||||
# <角色目錄>/<ID>.identity.md 角色 ID、顯示名稱、來源、關係定位、簽名 emoji
|
||||
# <角色目錄>/<ID>.soul.md 本質(nature)、氛圍(vibe)
|
||||
#
|
||||
# 舊格式為單一 <ID>.md,仍完整支援:解析時新格式優先,找不到才退回舊檔,
|
||||
# 既有角色不會因升級而失效。可用 role_sleep.sh --migrate <ID> 拆成新格式。
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
role_identity_file() { printf '%s/%s.identity.md' "$(role_home)" "$1"; }
|
||||
role_soul_file() { printf '%s/%s.soul.md' "$(role_home)" "$1"; }
|
||||
role_legacy_file() { printf '%s/%s.md' "$(role_home)" "$1"; }
|
||||
|
||||
role_is_new_format() {
|
||||
# 只要有 identity 檔就視為新格式(soul 缺失時由呼叫端各自處理)
|
||||
[ -f "$(role_identity_file "$1")" ]
|
||||
}
|
||||
|
||||
role_file() {
|
||||
# 指定角色的定義檔路徑
|
||||
printf '%s/%s.md' "$(role_home)" "$1"
|
||||
# 角色「主定義檔」路徑:新格式回傳 identity,否則回傳舊的單一檔。
|
||||
# 保留此函式是為了不動既有「檔案存在即代表角色存在」的判斷邏輯。
|
||||
local id="$1"
|
||||
if [ -f "$(role_identity_file "$id")" ]; then
|
||||
role_identity_file "$id"
|
||||
else
|
||||
role_legacy_file "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
role_enabled() {
|
||||
@@ -336,16 +363,29 @@ role_instance_acquire() {
|
||||
# 列出可協作的其他角色(排除自己),每行「ID<TAB>顯示名稱<TAB>本質摘要」。
|
||||
# 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。
|
||||
role_list_peers() {
|
||||
local self="$1" home file id name nature
|
||||
local self="$1" home file id name nature soul seen_ids=""
|
||||
home="$(role_home)"
|
||||
[ -d "$home" ] || return 0
|
||||
for file in "$home"/*.md; do
|
||||
for file in "$home"/*.identity.md "$home"/*.md; do
|
||||
[ -f "$file" ] || continue
|
||||
id="$(basename "$file" .md)"
|
||||
case "$file" in
|
||||
*.soul.md) continue ;; # soul 不是主定義檔
|
||||
*.identity.md) id="$(basename "$file" .identity.md)" ;;
|
||||
*) id="$(basename "$file" .md)"
|
||||
# 舊檔若已有對應的新格式,避免同一角色列兩次
|
||||
[ -f "$(role_identity_file "$id")" ] && continue ;;
|
||||
esac
|
||||
[ "$id" = "$self" ] && continue
|
||||
case " ${seen_ids} " in *" ${id} "*) continue ;; esac
|
||||
seen_ids="${seen_ids} ${id}"
|
||||
name="$(sed -n 's/^name:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)"
|
||||
nature="$(sed -n 's/^nature:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)"
|
||||
# frontmatter 沒有 nature 時退回讀「## 本質」段落的第一句
|
||||
# 新格式的性格在 soul 檔;frontmatter 無 nature 時退回讀「## 本質」段落首句
|
||||
soul="$(role_soul_file "$id")"
|
||||
if [ -z "$nature" ] && [ -f "$soul" ]; then
|
||||
nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul" 2>/dev/null | head -n 1)"
|
||||
[ -n "$nature" ] || nature="$(sed -n '/^## 本質/,/^## /p' "$soul" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
|
||||
fi
|
||||
if [ -z "$nature" ]; then
|
||||
nature="$(sed -n '/^## 本質/,/^## /p' "$file" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
+145
-7
@@ -37,6 +37,8 @@ usage() {
|
||||
--unlock 解除角色單一載入鎖(另一個工作階段已關閉但鎖仍在時使用)
|
||||
--agent <角色 ID> [輸出目錄]
|
||||
把角色匯出成 sub agent 定義(預設 ~/.claude/agents/)
|
||||
--migrate <角色 ID>
|
||||
把舊格式 <ID>.md 拆成 <ID>.identity.md 與 <ID>.soul.md
|
||||
--export <路徑> 匯出目前角色定義、資產與記憶為 .tar.gz
|
||||
--export <角色 ID> <路徑>
|
||||
--install-cron 安裝/更新睡眠排程(每小時檢查一次)
|
||||
@@ -424,6 +426,107 @@ remove_cron() {
|
||||
return 0
|
||||
}
|
||||
|
||||
migrate_role_files() {
|
||||
# 把舊格式單一 <ID>.md 拆成 <ID>.identity.md(身分)與 <ID>.soul.md(人格)。
|
||||
#
|
||||
# 拆分判準:「我是誰」進 identity(ID、顯示名稱、來源、關係定位、簽名 emoji),
|
||||
# 「我怎麼想」進 soul(本質、氛圍)。共用行為區塊**不再寫入角色檔** ——
|
||||
# 它由 role_load.sh 直接注入且 SKILL.md 有完整文件,重複第三份只會增加漏同步的機會。
|
||||
local id="$1" legacy identity soul stamp
|
||||
[ -n "$id" ] || { role_log "ERR" "缺少角色 ID"; return 1; }
|
||||
legacy="$(role_legacy_file "$id")"
|
||||
identity="$(role_identity_file "$id")"
|
||||
soul="$(role_soul_file "$id")"
|
||||
|
||||
[ -f "$legacy" ] || { role_log "ERR" "找不到舊格式角色檔:${legacy}"; return 1; }
|
||||
if [ -f "$identity" ] || [ -f "$soul" ]; then
|
||||
role_log "ERR" "新格式檔案已存在,為避免覆寫請先自行備份或移除:${identity} / ${soul}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
stamp="$(role_now)"
|
||||
node - "$legacy" "$identity" "$soul" "$stamp" <<'NODE_MIGRATE' || { role_log "ERR" "拆檔失敗:${legacy}"; return 1; }
|
||||
const fs = require("fs");
|
||||
const [, , legacy, identityOut, soulOut, stamp] = process.argv;
|
||||
const raw = fs.readFileSync(legacy, "utf8");
|
||||
|
||||
function parseFrontmatter(text) {
|
||||
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
const data = {};
|
||||
if (!m) return data;
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const i = line.indexOf(":");
|
||||
if (i < 0) continue;
|
||||
data[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
function section(text, title) {
|
||||
const re = new RegExp(`^##\\s+${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "m");
|
||||
return (text.match(re) || [, ""])[1].trim();
|
||||
}
|
||||
|
||||
const fm = parseFrontmatter(raw);
|
||||
const id = fm.id || legacy.replace(/^.*\//, "").replace(/\.md$/, "");
|
||||
const name = fm.name || (raw.match(/^#\s+(.+)$/m) || [, id])[1].trim();
|
||||
const emoji = fm.emoji || "";
|
||||
const nature = section(raw, "本質(nature)") || fm.nature || "";
|
||||
const vibe = section(raw, "氛圍(vibe)") || fm.vibe || "";
|
||||
const emojiSection = section(raw, "簽名 emoji") || emoji;
|
||||
|
||||
const identity = [
|
||||
"---",
|
||||
`id: ${id}`,
|
||||
`name: ${name}`,
|
||||
`emoji: ${emoji}`,
|
||||
`created: ${fm.created || stamp}`,
|
||||
`updated: ${stamp}`,
|
||||
"---",
|
||||
"",
|
||||
`# ${name} ${emoji}`.trim(),
|
||||
"",
|
||||
"## 來源(source)",
|
||||
"",
|
||||
"(未設定:角色出自哪部作品、正式名稱或背景設定)",
|
||||
"",
|
||||
"## 關係定位(relationship)",
|
||||
"",
|
||||
"(未設定:與使用者的關係、偏好的稱呼、必須守住的邊界)",
|
||||
"",
|
||||
"## 簽名 emoji",
|
||||
"",
|
||||
emojiSection || "(未設定)",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const soul = [
|
||||
"---",
|
||||
`id: ${id}`,
|
||||
`updated: ${stamp}`,
|
||||
"---",
|
||||
"",
|
||||
"## 本質(nature)",
|
||||
"",
|
||||
nature || "(未設定)",
|
||||
"",
|
||||
"## 氛圍(vibe)",
|
||||
"",
|
||||
vibe || "(未設定)",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(identityOut, identity, "utf8");
|
||||
fs.writeFileSync(soulOut, soul, "utf8");
|
||||
NODE_MIGRATE
|
||||
|
||||
role_log "INF" "已拆分:${identity}"
|
||||
role_log "INF" "已拆分:${soul}"
|
||||
role_log "INF" "舊檔保留未動:${legacy}(確認新格式正常後可自行移除或備份)"
|
||||
role_log "INF" "共用行為未寫入角色檔:由 role_load.sh 注入,內容見 role skill 文件"
|
||||
role_log "WRN" "來源與關係定位為待填空白,請補上後再重開工作階段"
|
||||
return 0
|
||||
}
|
||||
|
||||
export_agent_definition() {
|
||||
# 把角色的 SOUL 匯出成 sub agent 定義,讓任何角色都能被其他角色派工協助。
|
||||
#
|
||||
@@ -441,10 +544,15 @@ export_agent_definition() {
|
||||
|
||||
name="$(sed -n 's/^name:[[:space:]]*//p' "$def" | head -n 1)"
|
||||
emoji="$(sed -n 's/^emoji:[[:space:]]*//p' "$def" | head -n 1)"
|
||||
nature="$(sed -n '/^## 本質/,/^## /p' "$def" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
|
||||
vibe="$(sed -n '/^## 氛圍/,/^## /p' "$def" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
|
||||
[ -n "$nature" ] || nature="$(sed -n 's/^nature:[[:space:]]*//p' "$def" | head -n 1)"
|
||||
[ -n "$vibe" ] || vibe="$(sed -n 's/^vibe:[[:space:]]*//p' "$def" | head -n 1)"
|
||||
# 新格式的人格在 soul 檔,只讀 identity 會得到空人格
|
||||
local soul_src="$def"
|
||||
if role_is_new_format "$target_role" && [ -f "$(role_soul_file "$target_role")" ]; then
|
||||
soul_src="$(role_soul_file "$target_role")"
|
||||
fi
|
||||
nature="$(sed -n '/^## 本質/,/^## /p' "$soul_src" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
|
||||
vibe="$(sed -n '/^## 氛圍/,/^## /p' "$soul_src" | sed '1d;/^##/d' | sed '/^[[:space:]]*$/d')"
|
||||
[ -n "$nature" ] || nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul_src" | head -n 1)"
|
||||
[ -n "$vibe" ] || vibe="$(sed -n 's/^vibe:[[:space:]]*//p' "$soul_src" | head -n 1)"
|
||||
name="${name:-$target_role}"
|
||||
|
||||
if [ -f "$out_file" ]; then
|
||||
@@ -535,7 +643,14 @@ show_status() {
|
||||
role_in_sleep_window && window="是"
|
||||
printf '| 項目 | 值 |\n| --- | --- |\n'
|
||||
printf '| 角色 | %s |\n' "$ROLE"
|
||||
printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")"
|
||||
if role_is_new_format "$ROLE"; then
|
||||
printf '| 角色格式 | 新格式(身分/人格分離) |\n'
|
||||
printf '| 身分檔 | %s |\n' "$(role_identity_file "$ROLE")"
|
||||
printf '| 人格檔 | %s%s |\n' "$(role_soul_file "$ROLE")" "$([ -f "$(role_soul_file "$ROLE")" ] || printf '(缺少)')"
|
||||
else
|
||||
printf '| 角色格式 | 舊格式(單一檔案,可用 --migrate 拆分) |\n'
|
||||
printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")"
|
||||
fi
|
||||
printf '| 睡眠時段 | %s–%s |\n' "$(role_sleep_start)" "$(role_sleep_end)"
|
||||
printf '| 目前是否睡眠中 | %s |\n' "$window"
|
||||
printf '| cron 排程 | %s |\n' "$cron_state"
|
||||
@@ -554,7 +669,7 @@ show_status() {
|
||||
|
||||
export_role_archive() {
|
||||
# 匯出目前角色定義、專屬資產與記憶目錄,供備份或轉移使用
|
||||
local destination="$1" stamp role_def role_assets memory_dir archive_dir archive tmp
|
||||
local destination="$1" stamp role_def role_assets role_checks memory_dir archive_dir archive tmp
|
||||
[ -n "$destination" ] || { role_log "ERR" "缺少匯出路徑"; return 1; }
|
||||
command -v tar >/dev/null 2>&1 || { role_log "ERR" "找不到 tar,無法建立壓縮檔"; return 1; }
|
||||
command -v mktemp >/dev/null 2>&1 || { role_log "ERR" "找不到 mktemp,無法建立暫存目錄"; return 1; }
|
||||
@@ -583,11 +698,30 @@ export_role_archive() {
|
||||
|
||||
role_def="$(role_file "$ROLE")"
|
||||
role_assets="$(role_home)/${ROLE}.assets"
|
||||
role_checks="$(role_home)/${ROLE}.checks"
|
||||
memory_dir="$(role_memory_home)/${ROLE}"
|
||||
tmp="$(mktemp -d)" || { role_log "ERR" "無法建立暫存目錄"; return 1; }
|
||||
mkdir -p "$tmp/.roles" "$tmp/.memory"
|
||||
cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; }
|
||||
|
||||
# 依實際格式複製,不可一律當成舊格式的 <ID>.md ——
|
||||
# 否則新格式會被寫成舊檔名且遺失人格檔,備份就救不回角色
|
||||
if role_is_new_format "$ROLE"; then
|
||||
cp "$(role_identity_file "$ROLE")" "$tmp/.roles/${ROLE}.identity.md" \
|
||||
|| { rm -rf "$tmp"; role_log "ERR" "無法複製身分檔"; return 1; }
|
||||
if [ -f "$(role_soul_file "$ROLE")" ]; then
|
||||
cp "$(role_soul_file "$ROLE")" "$tmp/.roles/${ROLE}.soul.md" \
|
||||
|| { rm -rf "$tmp"; role_log "ERR" "無法複製人格檔"; return 1; }
|
||||
else
|
||||
role_log "WRN" "新格式缺少人格檔,匯出將不含 ${ROLE}.soul.md"
|
||||
fi
|
||||
# 遷移後尚未移除的舊檔一併保留,方便回溯
|
||||
[ -f "$(role_legacy_file "$ROLE")" ] && cp "$(role_legacy_file "$ROLE")" "$tmp/.roles/${ROLE}.md"
|
||||
else
|
||||
cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; }
|
||||
fi
|
||||
|
||||
[ -d "$role_assets" ] && cp -a "$role_assets" "$tmp/.roles/"
|
||||
[ -d "$role_checks" ] && cp -a "$role_checks" "$tmp/.roles/"
|
||||
[ -d "$memory_dir" ] && cp -a "$memory_dir" "$tmp/.memory/"
|
||||
cat > "$tmp/role-export.json" <<EOF_EXPORT
|
||||
{
|
||||
@@ -662,6 +796,10 @@ case "$MODE" in
|
||||
require_role
|
||||
sleep_cycle "手動"
|
||||
;;
|
||||
--migrate)
|
||||
[ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --migrate <角色 ID>"; exit 1; }
|
||||
migrate_role_files "$2"
|
||||
;;
|
||||
--agent)
|
||||
[ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --agent <角色 ID> [輸出目錄]"; exit 1; }
|
||||
export_agent_definition "$2" "${3:-}"
|
||||
|
||||
Reference in New Issue
Block a user