- `UserPromptSubmit` hook(`role_call.sh`)比對訊息開頭的角色名稱/ID/`aliases` 別名, 命中即注入該角色人格與記憶並接手本輪;未點名時不輸出內容也不寫檔案。 - 抽出 `role_context.sh` 共用 context 組裝,SessionStart 與點名載入共用同一份人格與操作規則, 避免兩種載入方式漂移;實測 SessionStart 注入內容與改動前 byte-identical。 - 新增階段角色狀態(`~/.roles/.sessions/<工作階段>.role`),`Stop` hook 改以「本階段實際角色」 寫記憶,避免點名換人後把跟 A 的對話記進 B 的記憶。 - `SessionEnd` 改為釋放本階段名下所有角色鎖並清掉階段狀態檔。 - 切換時先確認取得目標角色鎖才釋放原角色鎖,避免出現兩個角色都沒有的空窗。 - 睡眠時段、目標角色已被其他活躍階段佔用、點名的是已在場的角色時,一律不切換。 - 新增 `ROLE_CALL_ENABLED`(總開關)與 `ROLE_CALL_MARKER_ONLY`(只認 `@名字`)。 - 順帶修正 `role_capture.sh` 的 `--postcompact` 分支在 `PROJECT` 賦值前就使用它, 導致壓縮摘要記憶的 `sources` 一直為空。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
524 lines
20 KiB
Bash
Executable File
524 lines
20 KiB
Bash
Executable File
#!/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),實際記憶放在 <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
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# 角色單一載入實例
|
||
#
|
||
# 目的:同一角色同時只被一個工作階段載入,避免使用者同時與兩個相同人格對話。
|
||
#
|
||
# 釋放分兩條路,兩者缺一不可:
|
||
# 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<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
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# 本階段實際角色(點名載入用)
|
||
#
|
||
# 為什麼需要: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 的 <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"
|
||
}
|