#!/usr/bin/env bash # ============================================================================== # 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 # 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 # 本檔僅供 source,不可直接執行。 # 更新時間:2026/07/29 13:25:00 # 相依: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),實際記憶放在 /<角色>/ 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)分離 # # 新格式把「我是誰」與「我怎麼想」拆開,避免身分設定(來源作品、關係定位)與 # 性格語氣擠在同一段裡: # <角色目錄>/.identity.md 角色 ID、顯示名稱、來源、關係定位、簽名 emoji # <角色目錄>/.soul.md 本質(nature)、氛圍(vibe) # # 舊格式為單一 .md,仍完整支援:解析時新格式優先,找不到才退回舊檔, # 既有角色不會因升級而失效。可用 role_sleep.sh --migrate 拆成新格式。 # ------------------------------------------------------------------------------ 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 } # ------------------------------------------------------------------------------ # 角色單一載入實例 # # 目的:同一角色同時只被一個工作階段載入,避免使用者同時與兩個相同人格對話。 # # 釋放分兩條路,兩者缺一不可: # 1. 快速路徑:SessionEnd hook(role_unload.sh)在工作階段結束時刪掉自己的鎖,讓使用者 # 關掉 CLI 後可以立刻重開新階段叫回角色。 # 2. 後援:以下的 mtime 閒置逾時接手。SessionEnd **不保證觸發**(kill -9、直接關終端機 # 視窗、WSL 關機、當機都不會跑),少了它會在異常結束時把角色鎖死到下次手動解鎖。 # # 為什麼後援以 transcript 檔的 mtime 判斷而非 pid:SessionStart hook 無法可靠取得 CLI 主 # 行程的 pid。活躍的工作階段會持續寫入 transcript,因此「該檔多久沒被寫入」是最貼近真實 # 狀態、也不需要清理程序的判斷依據。 # # 已知取捨:正常關閉才有快速路徑;異常結束仍需等 ROLE_INSTANCE_IDLE_MINUTES(預設 30 分鐘) # 過期,或手動 role_sleep.sh --unlock。 # # 設計原則:**寧可誤放行也不要誤鎖** —— 誤鎖的後果是使用者叫不出角色,比偶爾重複載入嚴重。 # 因此無法識別工作階段(例如 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顯示名稱本質摘要」。 # 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。 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 } # ------------------------------------------------------------------------------ # 本階段實際角色(點名載入用) # # 為什麼需要:Stop hook 原本一律以 ROLE_NAME/.active 決定記憶寫給誰。使用者在對話中 # 點名另一個角色接手後,這個判斷就錯了 —— 跟被點名的角色聊的內容會被寫進原角色的記憶, # 兩份記憶一起髒掉。因此點名時把「這個工作階段目前實際是誰」記進狀態檔, # Stop 與 SessionEnd 一律以它為準,取不到才退回 ROLE_NAME/.active。 # # 為什麼用 session_id 當 key:UserPromptSubmit 與 Stop 兩個 hook 都拿得到同一個 # session_id;取不到時退回 transcript 檔名。兩者皆無則不寫狀態 —— 無法識別工作階段時 # 寧可維持原本行為,也不要把記憶寫給錯的角色。 # ------------------------------------------------------------------------------ role_session_dir() { printf '%s/.sessions' "$(role_home)"; } role_session_key() { # $1=session_id、$2=transcript 路徑;輸出可安全當檔名的 key,皆無則輸出空字串 local sid="$1" transcript="$2" key="" if [ -n "$sid" ] && [ "$sid" != "-" ]; then key="$sid" elif [ -n "$transcript" ] && [ "$transcript" != "-" ]; then key="$(basename "$transcript")" key="${key%.jsonl}" fi [ -n "$key" ] || return 0 printf '%s' "$key" | tr -c 'A-Za-z0-9_.-' '_' } role_session_file() { local key="$1" [ -n "$key" ] || return 1 printf '%s/%s.role' "$(role_session_dir)" "$key" } role_session_set() { # $1=key、$2=角色 ID、$3=transcript;key 為空代表無法識別階段,不寫 local key="$1" role="$2" transcript="$3" file file="$(role_session_file "$key")" || return 1 mkdir -p "$(role_session_dir)" 2>/dev/null { printf 'role=%s\n' "$role" printf 'transcript=%s\n' "$transcript" printf 'updated=%s\n' "$(role_now)" } > "$file" 2>/dev/null role_session_prune } role_session_get() { # $1=key;輸出本階段實際角色。無狀態檔、欄位空白或角色定義已不存在時回傳 1 local key="$1" file role file="$(role_session_file "$key")" || return 1 [ -f "$file" ] || return 1 role="$(sed -n 's/^role=//p' "$file" 2>/dev/null | head -n 1)" [ -n "$role" ] || return 1 [ -f "$(role_file "$role")" ] || return 1 printf '%s' "$role" } role_session_exists() { # 本階段是否已有明確狀態(含「刻意記成沒有人格」的空 role),用來區分 # 「這個階段確定沒載入角色」與「完全沒有資訊」—— 後者才該退回 ROLE_NAME/.active local key="$1" file file="$(role_session_file "$key")" || return 1 [ -f "$file" ] } role_session_clear() { local key="$1" file file="$(role_session_file "$key")" || return 0 rm -f "$file" 2>/dev/null } role_session_prune() { # 清掉 7 天以上未更新的狀態檔,避免長期累積殘留 find "$(role_session_dir)" -maxdepth 1 -name '*.role' -mtime +7 -delete 2>/dev/null || true } # ------------------------------------------------------------------------------ # 點名載入開關 # ------------------------------------------------------------------------------ role_call_enabled() { # 對話中以名字點名切換角色的總開關 case "${ROLE_CALL_ENABLED:-1}" in 0|false|no|off) return 1 ;; *) return 0 ;; esac } role_call_marker_only() { # 設 1 時只認 `@名字` 這種明確標記,避免話中提到名字就誤切 case "${ROLE_CALL_MARKER_ONLY:-0}" in 1|true|yes|on) return 0 ;; *) return 1 ;; esac } role_project_name() { # 專案判定:git remote 的 / 優先,其次目錄名 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" }