Files
shared/scripts/role/role_lib.sh
T
JefferyandClaude Opus 5 f76915bc87 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>
2026-07-29 11:12:34 +08:00

418 lines
16 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# ==============================================================================
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
# 本檔僅供 source,不可直接執行。
# 更新時間:2026/07/28 12:21:11
# 相依:bash;摘要路徑需 README 定義的任一 headless CLI。
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
# ==============================================================================
ROLE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROLE_STAGE="${ROLE_STAGE:-role}"
ROLE_SUPPORTED_CLIS="claude codex agy opencode copilot"
ROLE_FALLBACK_MODEL="claude-haiku-4-5-20251001"
# ------------------------------------------------------------------------------
# 共用輸出
# ------------------------------------------------------------------------------
role_now() {
# 取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串
TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S'
}
role_log() {
# 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr
local level="$1" message="$2" stamp
stamp="$(role_now)"
printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >&2
if [ -n "${ROLE_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then
printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >> "${ROLE_ERRLOG}" 2>/dev/null
fi
}
role_quit() {
# 記錄原因後以 0 結束:hook 絕不可阻斷使用者流程
role_log "${2:-DBG}" "$1"
exit 0
}
# ------------------------------------------------------------------------------
# 路徑與啟用判斷
# ------------------------------------------------------------------------------
role_home() {
# 角色定義目錄(預設 ~/.roles)
printf '%s' "${ROLE_HOME:-${HOME}/.roles}"
}
role_memory_home() {
# 記憶根目錄(預設 ~/.memory),實際記憶放在 <root>/<角色>/
printf '%s' "${ROLE_MEMORY_HOME:-${HOME}/.memory}"
}
role_is_child() {
# 判斷本次執行是否來自摘要用的子 CLI 行程,避免 hook 遞迴
[ -n "${ROLE_CHILD:-}" ] || [ -n "${WORKLOG_CHILD:-}" ]
}
role_resolve_name() {
# 角色決定順序:ROLE_NAME 環境變數 → <角色目錄>/.active;皆無則輸出空字串
local name="" active
if [ -n "${ROLE_NAME:-}" ]; then
name="${ROLE_NAME}"
else
active="$(role_home)/.active"
[ -f "$active" ] && name="$(head -n 1 "$active" 2>/dev/null | tr -d '[:space:]')"
fi
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() {
# 角色「主定義檔」路徑:新格式回傳 identity,否則回傳舊的單一檔。
# 保留此函式是為了不動既有「檔案存在即代表角色存在」的判斷邏輯。
local id="$1"
if [ -f "$(role_identity_file "$id")" ]; then
role_identity_file "$id"
else
role_legacy_file "$id"
fi
}
role_enabled() {
# 總開關:ROLE_ENABLED=0 強制停用;=1 強制啟用;未設定時「有可解析且存在的角色」才啟用
case "${ROLE_ENABLED:-}" in
0|false|no) return 1 ;;
1|true|yes) return 0 ;;
esac
local name
name="$(role_resolve_name)"
[ -n "$name" ] && [ -f "$(role_file "$name")" ]
}
role_in_scope() {
# ROLE_SCOPE 為冒號分隔的路徑前綴,未設定則所有目錄都適用
local cwd="$1" scope
[ -n "${ROLE_SCOPE:-}" ] || return 0
IFS=':' read -r -a scopes <<< "${ROLE_SCOPE}"
for scope in "${scopes[@]}"; do
[ -n "$scope" ] || continue
case "$cwd" in "${scope%/}"*) return 0 ;; esac
done
return 1
}
# ------------------------------------------------------------------------------
# 睡眠時段
# ------------------------------------------------------------------------------
role_time_to_minutes() {
# 把 HH:MM 轉成當日分鐘數;格式不合法時回傳空字串
local value="$1" hour minute
case "$value" in
[0-9][0-9]:[0-9][0-9]) ;;
*) return 1 ;;
esac
hour="${value%%:*}"
minute="${value##*:}"
printf '%s' "$((10#${hour} * 60 + 10#${minute}))"
}
role_sleep_start() { printf '%s' "${ROLE_SLEEP_START:-22:00}"; }
role_sleep_end() { printf '%s' "${ROLE_SLEEP_END:-06:00}"; }
role_in_sleep_window() {
# 判斷現在是否落在睡眠時段(預設 22:00 至隔日 06:00,跨午夜)
local start end now
start="$(role_time_to_minutes "$(role_sleep_start)")" || return 1
end="$(role_time_to_minutes "$(role_sleep_end)")" || return 1
now="$(role_time_to_minutes "$(TZ='Asia/Taipei' date +'%H:%M')")" || return 1
if [ "$start" -lt "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
# ------------------------------------------------------------------------------
# AI 行程偵測(睡眠排程的前置檢查)
# ------------------------------------------------------------------------------
role_ai_running() {
# 偵測是否有 AI CLI 正在執行;偵測到任何一個即回傳成功(代表「還不能睡」)
local cli pid cmd self="$$"
for cli in $ROLE_SUPPORTED_CLIS; do
for pid in $(pgrep -x "$cli" 2>/dev/null); do
[ "$pid" = "$self" ] && continue
return 0
done
done
for pid in $(pgrep -f '(^|/)(claude|codex|agy|opencode|copilot)([[:space:]]|$)' 2>/dev/null); do
if [ "$pid" = "$self" ] || [ "$pid" = "$PPID" ]; then
continue
fi
cmd="$(ps -o args= -p "$pid" 2>/dev/null)"
case "$cmd" in
*role_sleep.sh*|*role_capture.sh*|*role_load.sh*|*pgrep*) continue ;;
esac
return 0
done
return 1
}
# ------------------------------------------------------------------------------
# 摘要 CLI 選擇與呼叫
# ------------------------------------------------------------------------------
role_detect_current_cli() {
# 判斷實際觸發本次執行的助理環境,避免 auto 因 PATH 順序誤選其他 CLI
if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_MANAGED_PACKAGE_ROOT:-}" ]; then
printf 'codex'; return 0
fi
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDE_CODE_SSE_PORT:-}" ]; then
printf 'claude'; return 0
fi
if [ -n "${AGY_SESSION_ID:-}" ] || [ -n "${AGY_WORKSPACE_ID:-}" ]; then
printf 'agy'; return 0
fi
if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_CONFIG:-}" ]; then
printf 'opencode'; return 0
fi
if [ -n "${COPILOT_AGENT_ID:-}" ] || [ -n "${GITHUB_COPILOT_TOKEN:-}" ]; then
printf 'copilot'; return 0
fi
return 0
}
role_select_cli() {
# 選擇摘要/整理用的 headless CLI;可用 ROLE_CLI 強制指定,預設 auto
local requested="${ROLE_CLI:-auto}" cli current
if [ "$requested" != "auto" ]; then
case " ${ROLE_SUPPORTED_CLIS} " in
*" ${requested} "*) ;;
*) role_log "WRN" "ROLE_CLI 不支援:${requested}(可用:auto ${ROLE_SUPPORTED_CLIS}"; return 1 ;;
esac
command -v "$requested" >/dev/null 2>&1 || { role_log "WRN" "找不到 ${requested} CLI"; return 1; }
printf '%s' "$requested"; return 0
fi
current="$(role_detect_current_cli)"
if [ -n "$current" ] && command -v "$current" >/dev/null 2>&1; then
printf '%s' "$current"; return 0
fi
for cli in $ROLE_SUPPORTED_CLIS; do
if command -v "$cli" >/dev/null 2>&1; then
printf '%s' "$cli"; return 0
fi
done
role_log "WRN" "找不到可用 CLI(需要其一:${ROLE_SUPPORTED_CLIS}"
return 1
}
role_run_cli() {
# 呼叫選定 CLI 執行提示詞;子行程一律帶 ROLE_CHILD=1 阻斷 hook 遞迴
local cli="$1" prompt="$2" seconds="${3:-45}" model="${ROLE_MODEL:-}"
[ "$cli" = "claude" ] && [ -z "$model" ] && model="$ROLE_FALLBACK_MODEL"
case "$cli" in
claude) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" claude -p "$prompt" --model "$model" 2>/dev/null ;;
codex) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" codex exec "$prompt" 2>/dev/null ;;
agy) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" agy -p "$prompt" 2>/dev/null ;;
opencode) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" opencode run "$prompt" 2>/dev/null ;;
copilot) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" copilot -p "$prompt" 2>/dev/null ;;
esac
}
# ------------------------------------------------------------------------------
# 記憶目錄鎖:避免睡眠整理與對話寫入同時改動同一份記憶
# ------------------------------------------------------------------------------
role_lock_acquire() {
# 以 mkdir 取得鎖(原子操作);逾時視為前次殘留鎖並強制接手
local role="$1" lock="$(role_memory_home)/$1/.lock" age
mkdir -p "$(dirname "$lock")" 2>/dev/null
if mkdir "$lock" 2>/dev/null; then
printf '%s' "$$" > "$lock/pid" 2>/dev/null
return 0
fi
age="$(find "$lock" -maxdepth 0 -mmin +30 2>/dev/null)"
if [ -n "$age" ]; then
role_log "WRN" "偵測到超過 30 分鐘的殘留鎖,強制接手:${lock}"
rm -rf "$lock" 2>/dev/null
mkdir "$lock" 2>/dev/null && { printf '%s' "$$" > "$lock/pid" 2>/dev/null; return 0; }
fi
return 1
}
role_lock_release() {
# 釋放記憶目錄鎖
rm -rf "$(role_memory_home)/$1/.lock" 2>/dev/null
}
# ------------------------------------------------------------------------------
# 角色單一載入實例
#
# 目的:同一角色同時只被一個工作階段載入,避免使用者同時與兩個相同人格對話。
#
# 為什麼以 transcript 檔的 mtime 判斷而非 pidSessionStart hook 無法可靠取得 CLI 主行程
# 的 pid,且沒有保證會觸發的 SessionEnd hook 可用來釋放鎖。活躍的工作階段會持續寫入
# transcript,因此「該檔多久沒被寫入」是最貼近真實狀態、也不需要清理程序的判斷依據。
#
# 設計原則:**寧可誤放行也不要誤鎖** —— 誤鎖的後果是使用者叫不出角色,比偶爾重複載入嚴重。
# 因此無法識別工作階段(例如 hook 未提供 transcript 路徑)時一律放行。
# ------------------------------------------------------------------------------
role_single_instance_enabled() {
case "${ROLE_SINGLE_INSTANCE:-1}" in
0|false|no|off) return 1 ;;
*) return 0 ;;
esac
}
# sub agent 等「非對話」情境要跳過鎖。
#
# 鎖的目的是避免**使用者同時與兩個相同人格對話**;被其他角色派去做事的 sub agent
# 並不是在跟使用者對話,因此不該因為使用者剛好在另一個視窗開著同一個角色而被擋下來
# —— 那會讓「爸爸正在跟西莉卡聊天時,結衣就不能請西莉卡幫忙」這種本該成立的情境失效。
role_skip_instance_lock() {
case "${ROLE_SKIP_INSTANCE_LOCK:-0}" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
role_instance_idle_minutes() { printf '%s' "${ROLE_INSTANCE_IDLE_MINUTES:-30}"; }
role_instance_lock_path() { printf '%s/%s.lock' "$(role_home)" "$1"; }
role_instance_lock_field() {
# 從鎖檔取出指定欄位
local lock="$1" key="$2"
[ -f "$lock" ] || return 1
sed -n "s/^${key}=//p" "$lock" 2>/dev/null | head -n 1
}
role_instance_write_lock() {
local role="$1" transcript="$2" cwd="$3" lock
lock="$(role_instance_lock_path "$role")"
mkdir -p "$(dirname "$lock")" 2>/dev/null
{
printf 'transcript=%s\n' "$transcript"
printf 'loaded=%s\n' "$(role_now)"
printf 'cwd=%s\n' "$cwd"
} > "$lock" 2>/dev/null
}
# 回傳 0=可載入(已取得或接手鎖);1=已被其他仍活躍的工作階段持有
role_instance_acquire() {
local role="$1" transcript="$2" cwd="$3" lock holder idle
role_single_instance_enabled || return 0
# 非對話情境(sub agent 等)一律放行且不寫鎖,避免佔用互動式對話的名額
role_skip_instance_lock && return 0
# 無法識別工作階段就放行,不寫鎖:寧可重複也不要把角色鎖死
[ -n "$transcript" ] || return 0
lock="$(role_instance_lock_path "$role")"
if [ ! -f "$lock" ]; then
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
holder="$(role_instance_lock_field "$lock" transcript)"
if [ -z "$holder" ] || [ "$holder" = "$transcript" ]; then
# 同一個工作階段(含 resume 後重新載入)或鎖檔損壞:更新後放行
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
if [ ! -f "$holder" ]; then
role_log "INF" "前一個工作階段的 transcript 已不存在,接手角色鎖"
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
idle="$(find "$holder" -maxdepth 0 -mmin "+$(role_instance_idle_minutes)" 2>/dev/null)"
if [ -n "$idle" ]; then
role_log "INF" "前一個工作階段已閒置超過 $(role_instance_idle_minutes) 分鐘,接手角色鎖"
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
return 1
}
# 列出可協作的其他角色(排除自己),每行「ID<TAB>顯示名稱<TAB>本質摘要」。
# 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。
role_list_peers() {
local self="$1" home file id name nature soul seen_ids=""
home="$(role_home)"
[ -d "$home" ] || return 0
for file in "$home"/*.identity.md "$home"/*.md; do
[ -f "$file" ] || continue
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)"
# 新格式的性格在 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
printf '%s\t%s\t%s\n' "$id" "${name:-$id}" "${nature:-(未設定)}"
done
}
role_instance_release() {
rm -f "$(role_instance_lock_path "$1")" 2>/dev/null
}
role_project_name() {
# 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
local cwd="$1" origin cleaned owner_repo
[ -d "$cwd" ] || { printf '-'; return 0; }
local project
project="$(basename "$cwd")"
if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
origin="$(git -C "$cwd" remote get-url origin 2>/dev/null)"
if [ -n "$origin" ]; then
cleaned="${origin%.git}"
cleaned="${cleaned##*://}"
cleaned="${cleaned#*@}"
owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')"
[ -n "$owner_repo" ] && project="$owner_repo"
fi
fi
printf '%s' "$project"
}