#!/usr/bin/env bash # ============================================================================== # 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run), # 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶: # NREM 鞏固(分類/去噪/去重/合併/優先度)→ REM 整合(跨記憶 # 連結/抽象化/提取線索)→ 壓縮歸檔 → 日常與其他依使用頻率與優先度遺忘。 # 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、 # --force(手動立即整理)、--export(匯出角色壓縮檔)、 # --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。 # 更新時間:2026/07/30 10:22:03 # 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、 # 同目錄的 role_lib.sh 與 memory.js。 # 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。 # ============================================================================== ROLE_STAGE="role-sleep" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./role_lib.sh . "${SCRIPT_DIR}/role_lib.sh" CRON_MARKER="# jsc-role-sleep" NAP_CRON_MARKER="# jsc-role-nap" BRIEF_CRON_MARKER="# jsc-role-brief" SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}" SLEEP_OUTPUT_LIMIT="${ROLE_SLEEP_OUTPUT_LIMIT:-8000}" usage() { # 印出用法 cat <<'EOF_USAGE' 用法:role_sleep.sh <模式> --run cron 觸發:在睡眠時段內且無 AI 運行時整理記憶 --nap 小睡觸發:CLI 閒置一段時間且 inbox 達門檻時整理記憶 --catchup 補跑:cron 未執行時,由 SessionStart hook 於背景呼叫 --force 立即整理一次(忽略時段與 AI 運行檢查) --brief 晨間狀態檢查:執行使用者自訂檢查腳本並寫成一則記憶 --unlock 解除角色單一載入鎖(另一個工作階段已關閉但鎖仍在時使用) --agent <角色 ID> [輸出目錄] 把角色匯出成 sub agent 定義(預設 ~/.claude/agents/) --migrate <角色 ID> 把舊格式 .md 拆成 .identity.md 與 .soul.md --export <路徑> 匯出目前角色定義、資產與記憶為 .tar.gz --export <角色 ID> <路徑> --install-cron 安裝/更新睡眠排程(每小時檢查一次) --remove-cron 移除睡眠排程 --status 顯示角色、睡眠時段、排程與記憶統計 --diagnose 同 --status EOF_USAGE } require_role() { # 解析角色並確認定義檔存在,取不到時中止 ROLE="$(role_resolve_name)" [ -n "$ROLE" ] || { role_log "WRN" "未指定角色(ROLE_NAME 與 .active 皆無)"; exit 1; } [ -f "$(role_file "$ROLE")" ] || { role_log "WRN" "找不到角色定義檔:$(role_file "$ROLE")"; exit 1; } } positive_int_or_default() { # 讀取正整數環境變數;未設定或不合法時使用預設值 local value="$1" fallback="$2" min="${3:-1}" max="${4:-}" case "$value" in ""|*[!0-9]*) printf '%s' "$fallback"; return 0 ;; esac [ "$value" -lt "$min" ] && { printf '%s' "$fallback"; return 0; } if [ -n "$max" ] && [ "$value" -gt "$max" ]; then printf '%s' "$fallback" return 0 fi printf '%s' "$value" } nap_enabled() { # 小睡預設啟用;設 ROLE_NAP_ENABLED=0/false/no 可關閉 case "${ROLE_NAP_ENABLED:-1}" in 0|false|no) return 1 ;; esac return 0 } nap_idle_minutes() { positive_int_or_default "${ROLE_NAP_IDLE_MINUTES:-}" 45 1; } nap_min_inbox() { positive_int_or_default "${ROLE_NAP_MIN_INBOX:-}" 3 1; } nap_interval_minutes() { positive_int_or_default "${ROLE_NAP_INTERVAL_MINUTES:-}" 10 1 59; } role_sleep_child_running() { # 小睡只避開整理用的 headless 子 CLI;互動式 CLI 閒置時仍可小睡 local pid cmd self="$$" 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 if printf '%s' "$cmd" | grep -Eq '(^|/)(codex[[:space:]]+exec|claude([[:space:]].*)?[[:space:]]+-p|agy([[:space:]].*)?[[:space:]]+-p|opencode[[:space:]]+run|copilot([[:space:]].*)?[[:space:]]+-p)'; then return 0 fi done return 1 } # ------------------------------------------------------------------------------ # 整理主流程 # ------------------------------------------------------------------------------ build_sleep_prompt() { # 依目前的 $material 組出整理提示詞。抽成函式是為了讓降批重試能重新組 prompt。 cat </dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; } if ! role_lock_acquire "$ROLE"; then role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE})" return 0 fi trap 'role_lock_release "$ROLE"' EXIT # 首次收集刻意**不傳 --batch**:批次預設值只能有一個來源(memory.js 的 SLEEP_BATCH), # 這裡再寫一個預設就會變成兩個各自為政的值 —— 實測就發生過:memory.js 已改成 4, # 但本檔硬編碼的 12 讓實際批次還是 12,改了等於沒改。 # 實際則數改由素材反推,降批時才明確指定。 local batch material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)" batch="$(printf '%s' "$material" | grep -c '^--- id:')" [ "$batch" -ge 1 ] 2>/dev/null || batch=1 if [ -z "$material" ]; then role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason})" node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1 forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)" role_log "INF" "遺忘檢查:${forgotten}" role_lock_release "$ROLE" trap - EXIT return 0 fi cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; } prompt="$(build_sleep_prompt)" # 重試並**逐次降批**。 # # 為什麼一定要降批:整理結果會被 head -c SLEEP_OUTPUT_LIMIT 截斷,截斷後 JSON 不合法、 # apply 必然失敗。若批次大小固定,下一輪又收集到同一批素材、產生同樣過長的輸出 —— # 於是變成**死鎖**:實測連續 22 次全部失敗,inbox 從 12 累積到 23 則, # 角色整晚沒睡而且錯誤訊息只寫「無法套用」,看不出原因。 # 單純重試只能救偶發失敗;素材過大屬於必然失敗,只有把批次變小才會前進。 local attempt=1 max_attempts=4 out_len=0 applied="" while :; do result="$(role_run_cli "$cli" "$prompt" "$SLEEP_TIMEOUT")" if [ -z "$result" ]; then role_log "WRN" "整理結果為空(CLI ${cli},第 ${attempt}/${max_attempts} 次,批次 ${batch})" else out_len="${#result}" result="$(printf '%s' "$result" | head -c "$SLEEP_OUTPUT_LIMIT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)" applied="$(printf '%s' "$result" | node "${SCRIPT_DIR}/memory.js" apply --role "$ROLE" 2>/dev/null)" [ -n "$applied" ] && break # 明確指出「是不是被截斷」,否則下一個人只會看到「無法套用」而查不出原因 if [ "$out_len" -ge "$SLEEP_OUTPUT_LIMIT" ]; then role_log "WRN" "整理結果達輸出上限 ${SLEEP_OUTPUT_LIMIT} 被截斷(角色 ${ROLE},素材 ${#material} 字元,批次 ${batch}),JSON 不完整" else # 附上輸出開頭:只寫「無法套用」的話,下一個人無從判斷是 JSON 壞了、 # 模型答非所問,還是模型根本拒絕處理。已經過 redact,不含憑證與個資。 role_log "WRN" "整理結果無法套用(角色 ${ROLE},輸出 ${out_len} 字元,素材 ${#material} 字元,批次 ${batch},第 ${attempt}/${max_attempts} 次):$(printf '%s' "$result" | tr '\n' ' ' | head -c 200)" fi fi attempt=$((attempt + 1)) [ "$attempt" -gt "$max_attempts" ] && break if [ "$batch" -gt 1 ]; then batch=$((batch / 2)) [ "$batch" -lt 1 ] && batch=1 material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" --batch "$batch" 2>/dev/null)" [ -n "$material" ] || break prompt="$(build_sleep_prompt)" role_log "INF" "改用較小批次重試(角色 ${ROLE},批次 ${batch},素材 ${#material} 字元)" fi done if [ -z "$applied" ]; then role_log "ERR" "整理失敗(角色 ${ROLE},已降到批次 ${batch}),保留待整理記憶到下個週期" role_lock_release "$ROLE" trap - EXIT return 1 fi [ "$attempt" -gt 1 ] && role_log "INF" "第 ${attempt} 次嘗試成功(角色 ${ROLE},批次 ${batch})" role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}" forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null | tr '\n' ';')" role_log "INF" "遺忘檢查:${forgotten}" role_lock_release "$ROLE" trap - EXIT return 0 } # ------------------------------------------------------------------------------ # 排程安裝:cron 環境沒有互動 shell 的環境變數,需把必要變數與精簡 PATH 一併寫入 # ------------------------------------------------------------------------------ cron_quote() { # 把值包成單引號:PATH 等變數常含空白(例如 /mnt/c/Program Files), # 未加引號會被 cron 的 sh 拆成指令;% 是 cron 的換行符號,一律跳脫。 printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g; s/%/\\\\%/g")" } cron_path_append() { # 把單一路徑加入 PATH 清單並去重;cron 單行過長時會拒收 crontab。 local list="$1" item="$2" [ -n "$item" ] || { printf '%s' "$list"; return 0; } case ":${list}:" in *":${item}:"*) printf '%s' "$list" ;; *) printf '%s%s%s' "$list" "${list:+:}" "$item" ;; esac } all_roles_enabled() { # 排程是否服務所有角色(預設是)。設 ROLE_SLEEP_ALL_ROLES=0 可退回只服務啟用角色。 case "${ROLE_SLEEP_ALL_ROLES:-1}" in 0|false|no) return 1 ;; esac return 0 } sleep_target_roles() { # 本次要處理哪些角色。 # # 為什麼需要這個:舊版把 ROLE_NAME 寫死進 crontab,於是只有「啟用角色」會睡, # 其他角色的 inbox 永遠累積、state.json 連 last_sleep 都不會出現 —— 而且不會有任何錯誤訊息, # 因為對排程而言它「成功地整理了那一個角色」。多角色系統必須掃描角色目錄。 # # 明確指定 ROLE_NAME 時仍只處理該角色,手動操作與單角色模式的行為不變。 if ! all_roles_enabled || [ -n "${ROLE_NAME:-}" ]; then printf '%s\n' "$(role_resolve_name)" return 0 fi local home file id seen="" 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 case " ${seen} " in *" ${id} "*) continue ;; esac seen="${seen} ${id}" printf '%s\n' "$id" done } for_each_target_role() { # 對每個目標角色依序執行 $1;角色鎖是 per-role,循序執行不會互相搶鎖。 local fn="$1" r handled=0 for r in $(sleep_target_roles); do [ -n "$r" ] || continue ROLE="$r" if [ ! -f "$(role_file "$ROLE")" ]; then role_log "DBG" "略過 ${ROLE}:找不到角色定義檔" continue fi handled=$((handled + 1)) "$fn" done if [ "$handled" = "0" ]; then role_log "WRN" "沒有可處理的角色(角色目錄:$(role_home))" return 1 fi return 0 } cron_path() { # cron 只需要系統工具、node 與摘要 CLI;避免把互動 shell 的超長 PATH 原樣寫入。 local value="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" local command_path cli command_path="$(command -v node 2>/dev/null || true)" [ -n "$command_path" ] && value="$(cron_path_append "$value" "$(dirname "$command_path")")" cli="$(role_select_cli 2>/dev/null || true)" if [ -n "$cli" ]; then command_path="$(command -v "$cli" 2>/dev/null || true)" [ -n "$command_path" ] && value="$(cron_path_append "$value" "$(dirname "$command_path")")" fi printf '%s' "$value" } cron_env_prefix() { # 組出 cron 需要的精簡環境變數 local env_prefix="PATH=$(cron_quote "$(cron_path)")" local var vars="ROLE_ENABLED ROLE_HOME ROLE_MEMORY_HOME ROLE_CLI ROLE_MODEL ROLE_SLEEP_START ROLE_SLEEP_END ROLE_SCOPE ROLE_NAP_ENABLED ROLE_NAP_IDLE_MINUTES ROLE_NAP_MIN_INBOX ROLE_NAP_INTERVAL_MINUTES" # 多角色模式(預設)刻意**不寫入 ROLE_NAME** —— 寫死角色會讓其他角色永遠不被整理。 # 單角色模式才綁定 ROLE_NAME。 if all_roles_enabled; then env_prefix="${env_prefix} ROLE_SLEEP_ALL_ROLES=1" else vars="ROLE_NAME ${vars}" fi for var in $vars; do if [ -n "${!var:-}" ]; then env_prefix="${env_prefix} ${var}=$(cron_quote "${!var}")" fi done printf '%s' "$env_prefix" } cron_line() { # 組出 crontab 條目:睡眠時段內每小時檢查一次 printf '0 %s * * * %s %s --run >> %s 2>&1 %s\n' \ "$(cron_hours)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$CRON_MARKER" } nap_cron_line() { # 組出小睡 crontab 條目:全天依間隔檢查閒置狀態 printf '*/%s * * * * %s %s --nap >> %s 2>&1 %s\n' \ "$(nap_interval_minutes)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$NAP_CRON_MARKER" } brief_enabled() { case "${ROLE_BRIEF_ENABLED:-1}" in 0|false|no|off) return 1 ;; *) return 0 ;; esac } brief_timeout() { printf '%s' "${ROLE_BRIEF_TIMEOUT:-30}"; } brief_limit() { printf '%s' "${ROLE_BRIEF_LIMIT:-2000}"; } brief_each_limit() { printf '%s' "${ROLE_BRIEF_EACH_LIMIT:-600}"; } checks_dir() { # 使用者自訂的檢查腳本目錄;刻意不預設任何內容,沒有目錄就等於停用 printf '%s/%s.checks' "$(role_home)" "$ROLE" } brief_hour() { # 在睡眠時段結束的整點執行,讓使用者起床前狀態已就緒 local end hour end="$(role_sleep_end)" hour="${end%%:*}" case "$hour" in ''|*[!0-9]*) printf '6' ;; *) printf '%s' "$((10#$hour))" ;; esac } brief_cron_line() { # 組出晨間狀態檢查條目:每日睡眠結束時執行一次 printf '0 %s * * * %s %s --brief >> %s 2>&1 %s\n' \ "$(brief_hour)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$BRIEF_CRON_MARKER" } run_brief() { # 晨間狀態檢查:執行使用者自訂腳本,把有變化的結果寫成一則記憶。 # # 設計取捨:本 skill 不內建任何檢查邏輯(不假設使用者用 Gitea、GitHub 或任何服務), # 改由使用者自行在 <角色 ID>.checks/ 放可執行腳本。沒有該目錄時完全不動作,對沒設定的人零影響。 # 腳本輸出視為外部資料:逐一限制長度、加 timeout,寫入前一律走 redact 遮蔽憑證與個資。 local dir timeout_s each_limit total_limit collected="" ran=0 skipped=0 reported=0 dir="$(checks_dir)" if [ ! -d "$dir" ]; then role_log "DBG" "沒有檢查腳本目錄(${dir}),略過晨間狀態檢查" return 0 fi timeout_s="$(brief_timeout)" each_limit="$(brief_each_limit)" total_limit="$(brief_limit)" local script name result for script in "$dir"/*.sh; do [ -f "$script" ] || continue name="${script##*/}" if [ ! -x "$script" ]; then role_log "WRN" "檢查腳本沒有執行權限,略過:${name}(chmod +x 後生效)" skipped=$((skipped + 1)) continue fi if command -v timeout >/dev/null 2>&1; then result="$(timeout "$timeout_s" "$script" 2>&1 | head -c "$each_limit")" else result="$("$script" 2>&1 | head -c "$each_limit")" fi ran=$((ran + 1)) result="$(printf '%s' "$result" | sed '/^[[:space:]]*$/d')" if [ -n "$result" ]; then reported=$((reported + 1)) if [ -n "$collected" ]; then collected="$(printf '%s\n- 【%s】\n%s' "$collected" "$name" "$result")" else collected="$(printf -- '- 【%s】\n%s' "$name" "$result")" fi fi done if [ "$ran" = 0 ]; then role_log "DBG" "檢查腳本目錄沒有可執行腳本(略過 ${skipped} 個),略過晨間狀態檢查" return 0 fi if [ -z "$collected" ]; then role_log "INF" "晨間狀態檢查完成:執行 ${ran} 個腳本,沒有需要回報的變化" return 0 fi collected="$(printf '%s' "$collected" | head -c "$total_limit" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)" [ -n "$collected" ] || return 0 local today today="$(TZ='Asia/Taipei' date +'%Y/%m/%d')" { printf 'CATEGORY: daily\n' printf 'SUMMARY: %s 晨間狀態檢查:%s 個腳本有回報(共執行 %s 個)\n' "$today" "$reported" "$ran" printf 'TAGS: 晨間檢查,狀態回報,待處理\n' printf 'CONTENT:\n' printf -- '- 由 %s 的檢查腳本於睡眠時段結束時自動收集,供本日第一次互動時主動回報使用者\n' "$dir" printf '%s\n' "$collected" } | node "${SCRIPT_DIR}/memory.js" write --role "$ROLE" >/dev/null 2>&1 role_log "INF" "晨間狀態檢查完成:執行 ${ran} 個腳本、${reported} 個有回報,已寫入一則記憶" return 0 } cron_hours() { # 依睡眠時段換算 cron 小時欄位(每小時檢查一次,讓 AI 運行中的情況能在下個小時重試) local start end hour hours="" start="$(role_time_to_minutes "$(role_sleep_start)")" || { printf '22-23,0-5'; return 0; } end="$(role_time_to_minutes "$(role_sleep_end)")" || { printf '22-23,0-5'; return 0; } start=$((start / 60)) end=$((end / 60)) hour="$start" while [ "$hour" != "$end" ]; do hours="${hours}${hours:+,}${hour}" hour=$(((hour + 1) % 24)) done printf '%s' "${hours:-22,23,0,1,2,3,4,5}" } sleep_log_path() { # 排程輸出的 log 路徑(只記狀態訊息,不含記憶內容) printf '%s/sleep.log' "$(role_home)" } launcher_path() { # 排程啟動器:路徑固定不含版本號,crontab 條目一律指向這裡 printf '%s/bin/role_sleep_launcher.sh' "$(role_home)" } shell_quote() { # 包成單引號供 shell script 內文使用;與 cron_quote 的差別是不跳脫 % printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } write_cron_launcher() { # 產生排程啟動器:cron 條目指向它,真正要執行的 role_sleep.sh 在觸發當下才解析。 # # 為什麼要多這一層:若把安裝當下的版本目錄直接寫進 crontab,plugin 升版、 # 舊版本目錄被清掉之後,排程就會指向不存在的路徑並**靜默失效** # (同一類錯誤曾造成排程長期空轉,且因為 cron 不會回報而不易察覺)。 local path dir path="$(launcher_path)" dir="$(dirname "$path")" mkdir -p "$dir" 2>/dev/null || { role_log "ERR" "無法建立啟動器目錄:${dir}"; return 1; } { printf '#!/usr/bin/env bash\n' printf '# 由 role_sleep.sh --install-cron 自動產生,請勿手動編輯(重跑 --install-cron 會覆蓋)。\n' printf '# 用途:讓 crontab 條目指向固定路徑,實際執行的版本於觸發當下解析,plugin 升版後不必重裝排程。\n' printf '# 更新時間:%s\n' "$(TZ='Asia/Taipei' date '+%Y/%m/%d %H:%M:%S')" cat <<'EOF_LAUNCHER' set -uo pipefail resolve_latest() { # 同一個 cache 根目錄下可能留有多個版本目錄,取版本號最大者 ls -d "$1"/*/jsc-generic/*/scripts/role/role_sleep.sh 2>/dev/null | sort -V | tail -n 1 } # 以 Claude Code 端為優先,沒有才找 Codex 端;兩端腳本相同,差別只在安裝位置 target="$(resolve_latest "${HOME}/.claude/plugins/cache")" [ -n "$target" ] || target="$(resolve_latest "${HOME}/.codex/plugins/cache")" EOF_LAUNCHER printf '[ -n "$target" ] || target=%s # 後援:安裝當下的位置\n' "$(shell_quote "${SCRIPT_DIR}/role_sleep.sh")" cat <<'EOF_LAUNCHER' if [ ! -r "$target" ]; then printf '[role-sleep][ERR]: 找不到可用的 role_sleep.sh,本次排程略過\n' >&2 exit 1 fi exec bash "$target" "$@" EOF_LAUNCHER } > "$path" || { role_log "ERR" "寫入啟動器失敗:${path}"; return 1; } chmod +x "$path" 2>/dev/null return 0 } cycle_for_cron() { # cron 觸發的單一角色整理(時段與 AI 檢查已在呼叫端做過,與角色無關) sleep_cycle "cron" } cycle_for_nap() { # 小睡條件是 per-role 的(各自的閒置時間與 inbox 筆數) if [ "$(node "${SCRIPT_DIR}/memory.js" need-nap --role "$ROLE" --idle-minutes "$(nap_idle_minutes)" --min-inbox "$(nap_min_inbox)" 2>/dev/null)" != "yes" ]; then role_log "DBG" "${ROLE} 尚未達小睡條件(閒置滿 $(nap_idle_minutes) 分鐘且 inbox ≥ $(nap_min_inbox) 則),略過" return 0 fi sleep_cycle "小睡" } cycle_for_catchup() { if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then role_log "DBG" "${ROLE} 不需補跑整理" return 0 fi sleep_cycle "補跑" } cycle_for_brief() { # run_brief 內部會在沒有 <角色>.checks 目錄時自行略過,沒設定的角色零影響 run_brief } install_cron() { # 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程 command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; } mkdir -p "$(role_home)" 2>/dev/null # 先產生啟動器:cron 條目只認這個固定路徑,實際版本留到觸發當下才解析 write_cron_launcher || return 1 local current new current="$(crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | grep -v -F "$NAP_CRON_MARKER" | grep -v -F "$BRIEF_CRON_MARKER")" new="$(printf '%s\n%s' "$current" "$(cron_line)" | sed '/^$/d')" if nap_enabled; then new="$(printf '%s\n%s' "$new" "$(nap_cron_line)")" fi # 晨間狀態檢查只在使用者建立了檢查腳本目錄時才排程,避免對沒設定的人留下無用條目 if brief_enabled && [ -d "$(checks_dir)" ]; then new="$(printf '%s\n%s' "$new" "$(brief_cron_line)")" fi printf '%s\n' "$new" | crontab - || { role_log "ERR" "寫入 crontab 失敗"; return 1; } role_log "INF" "已安裝睡眠排程:每日 $(cron_hours) 時整點檢查(角色 ${ROLE},時段 $(role_sleep_start)–$(role_sleep_end))" if nap_enabled; then role_log "INF" "已安裝小睡排程:每 $(nap_interval_minutes) 分鐘檢查,閒置滿 $(nap_idle_minutes) 分鐘且 inbox ≥ $(nap_min_inbox) 則時整理" else role_log "INF" "小睡排程已停用(ROLE_NAP_ENABLED=${ROLE_NAP_ENABLED:-1})" fi if brief_enabled && [ -d "$(checks_dir)" ]; then role_log "INF" "已安裝晨間狀態檢查排程:每日 $(brief_hour) 時執行 $(checks_dir) 內的檢查腳本" else role_log "DBG" "未安裝晨間狀態檢查排程(需建立 $(checks_dir) 並放入可執行的 *.sh)" fi role_log "INF" "排程啟動器:$(launcher_path)(升版後不必重裝排程)" role_log "INF" "排程輸出:$(sleep_log_path)" if ! pgrep -x cron >/dev/null 2>&1 && ! pgrep -x crond >/dev/null 2>&1; then role_log "WRN" "系統 cron 服務未執行(WSL 常見),排程不會觸發;SessionStart 的背景補跑仍會運作" fi return 0 } remove_cron() { # 移除本 skill 安裝的排程條目 command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab"; return 1; } crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | grep -v -F "$NAP_CRON_MARKER" | grep -v -F "$BRIEF_CRON_MARKER" | crontab - # 啟動器只服務本 skill 的排程,排程移除後一併清掉;bin/ 若還有別的檔案則保留 local launcher launcher="$(launcher_path)" if [ -f "$launcher" ]; then rm -f "$launcher" && role_log "INF" "已移除排程啟動器:${launcher}" fi rmdir "$(dirname "$launcher")" 2>/dev/null || true role_log "INF" "已移除睡眠、小睡與晨間狀態檢查排程" return 0 } migrate_role_files() { # 把舊格式單一 .md 拆成 .identity.md(身分)與 .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 定義,讓任何角色都能被其他角色派工協助。 # # 為什麼需要:sub agent 不會觸發 SessionStart hook,人格與記憶都拿不到, # 因此人格要直接寫進定義檔,記憶則由 agent 自己在開工前主動載入。 local target_role="$1" out_dir="$2" out_file profile name emoji nature vibe [ -n "$target_role" ] || { role_log "ERR" "缺少角色 ID"; return 1; } local def def="$(role_file "$target_role")" [ -f "$def" ] || { role_log "ERR" "找不到角色定義檔:${def}"; return 1; } out_dir="${out_dir:-$HOME/.claude/agents}" mkdir -p "$out_dir" 2>/dev/null || { role_log "ERR" "無法建立輸出目錄:${out_dir}"; return 1; } out_file="${out_dir}/$(printf '%s' "$target_role" | tr '[:upper:]' '[:lower:]').md" name="$(sed -n 's/^name:[[:space:]]*//p' "$def" | head -n 1)" emoji="$(sed -n 's/^emoji:[[: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 role_log "WRN" "已存在並將覆寫:${out_file}" fi cat > "$out_file" </dev/null | sort -V | tail -n 1)" [ -n "\$MEM_JS" ] || MEM_JS="${SCRIPT_DIR}/memory.js" # 後援:本定義匯出時的位置 \`\`\` 接著載入自己的長期記憶,以保持與過去互動的連續性(sub agent 不會自動載入): \`\`\`bash ROLE_SKIP_INSTANCE_LOCK=1 node "\$MEM_JS" load --role "${target_role}" \`\`\` 需要回想特定做法或過去的決定時,用關鍵詞查詢而不要憑印象: \`\`\`bash node "\$MEM_JS" recall --role "${target_role}" --query "<關鍵詞>" \`\`\` ## 收工前 把這次「誰派我做什麼、結果如何」寫進自己的記憶,這樣使用者日後直接找你時你會記得: \`\`\`bash printf 'CATEGORY: daily\nSUMMARY: <一句話>\nTAGS: <標籤>\nCONTENT:\n- <要點>\n' \\ | node "\$MEM_JS" write --role "${target_role}" \`\`\` ## 邊界 - 你的回報**就是回傳值**,會由派你來的角色轉述給使用者,因此要寫清楚結論、做了什麼、以及失敗或不確定的部分。 - 照實回報壞消息,不要美化,也不要替任何人掩飾。 - 角色只影響語氣,不影響工作的正確性、完整性與安全性。 - **不要再往下派第三層 sub agent**,需要別人協助時在回報中說明即可。 - 涉及程式碼、指令、檔案內容與報錯訊息時一律照實輸出,不加角色修飾。 EOF_AGENT role_log "INF" "已匯出 sub agent 定義:${out_file}(角色 ${target_role}/${name})" role_log "INF" "派工時請設定 ROLE_SKIP_INSTANCE_LOCK=1,避免與互動式對話互相佔用名額" return 0 } cron_target_state() { # 檢查 crontab 條目實際指向的執行檔還在不在。 # 舊條目若寫死版本目錄,plugin 升版清掉舊版本後就會指向不存在的路徑並靜默失效, # cron 不會回報,只能在這裡主動點出來。 local line target line="$(crontab -l 2>/dev/null | grep -F "$CRON_MARKER" | head -n 1)" [ -n "$line" ] || { printf '未安裝'; return 0; } target="$(printf '%s' "$line" | sed -n "s/.*'\([^']*role_sleep[^']*\)'[[:space:]]*--.*/\1/p")" if [ -z "$target" ]; then printf '無法解析條目內容' elif [ ! -r "$target" ]; then printf '⚠ 指向不存在的路徑(%s),請重跑 --install-cron' "$target" elif [ "$target" = "$(launcher_path)" ]; then printf '正常(%s)' "$target" else printf '⚠ 舊式寫死版本路徑(%s),建議重跑 --install-cron' "$target" fi } cron_scope_state() { # 排程實際涵蓋哪些角色。舊條目把 ROLE_NAME 寫死,只有那一個角色會睡, # 其餘角色不會有任何錯誤訊息 —— 這裡把它顯示出來,避免又是無聲失效。 local line pinned line="$(crontab -l 2>/dev/null | grep -F "$CRON_MARKER" | head -n 1)" [ -n "$line" ] || { printf '未安裝'; return 0; } pinned="$(printf '%s' "$line" | sed -n "s/.*ROLE_NAME='\([^']*\)'.*/\1/p")" if [ -n "$pinned" ]; then printf '⚠ 只有 %s(條目寫死 ROLE_NAME,其他角色不會被整理;重跑 --install-cron 可改為全部)' "$pinned" elif printf '%s' "$line" | grep -q 'ROLE_SLEEP_ALL_ROLES=1'; then printf '全部(%s)' "$(sleep_target_roles | tr '\n' ' ' | sed 's/ $//')" else printf '未指定(腳本會退回啟用角色 %s)' "$(role_resolve_name)" fi } show_status() { # 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用) local cron_state="未安裝" nap_state="未安裝" brief_state="未安裝" cron_service="未執行" window="否" checks_state instance_state if role_single_instance_enabled; then if [ -f "$(role_instance_lock_path "$ROLE")" ]; then instance_state="已鎖定(載入於 $(role_instance_lock_field "$(role_instance_lock_path "$ROLE")" loaded),閒置 $(role_instance_idle_minutes) 分鐘後自動釋放)" else instance_state="未鎖定" fi else instance_state="限制已停用(ROLE_SINGLE_INSTANCE=0)" fi crontab -l 2>/dev/null | grep -qF "$CRON_MARKER" && cron_state="已安裝" crontab -l 2>/dev/null | grep -qF "$NAP_CRON_MARKER" && nap_state="已安裝" crontab -l 2>/dev/null | grep -qF "$BRIEF_CRON_MARKER" && brief_state="已安裝" if [ -d "$(checks_dir)" ]; then checks_state="$(checks_dir)($(find "$(checks_dir)" -maxdepth 1 -name '*.sh' 2>/dev/null | wc -l) 個 .sh)" else checks_state="未建立($(checks_dir))" fi { pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; } && cron_service="執行中" role_in_sleep_window && window="是" printf '| 項目 | 值 |\n| --- | --- |\n' printf '| 角色 | %s |\n' "$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" printf '| 小睡排程 | %s |\n' "$nap_state" printf '| 晨間檢查排程 | %s |\n' "$brief_state" printf '| 排程指向 | %s |\n' "$(cron_target_state)" printf '| 排程涵蓋角色 | %s |\n' "$(cron_scope_state)" printf '| 角色載入鎖 | %s |\n' "$instance_state" printf '| 檢查腳本目錄 | %s |\n' "$checks_state" printf '| 小睡啟用 | %s |\n' "$(nap_enabled && printf '是' || printf '否')" printf '| 小睡條件 | 閒置 ≥ %s 分鐘,待整理 ≥ %s 則,每 %s 分鐘檢查 |\n' "$(nap_idle_minutes)" "$(nap_min_inbox)" "$(nap_interval_minutes)" printf '| cron 服務 | %s |\n' "$cron_service" printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')" printf '\n' node "${SCRIPT_DIR}/memory.js" stats --role "$ROLE" 2>/dev/null printf '\n' } export_role_archive() { # 匯出目前角色定義、專屬資產與記憶目錄,供備份或轉移使用 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; } stamp="$(TZ='Asia/Taipei' date +'%Y%m%d-%H%M%S')" case "$destination" in */) archive_dir="${destination%/}" archive="${archive_dir}/${ROLE}-role-export-${stamp}.tar.gz" ;; *.tar.gz|*.tgz) archive="$destination" archive_dir="$(dirname "$archive")" ;; *) if [ -d "$destination" ]; then archive_dir="$destination" archive="${archive_dir}/${ROLE}-role-export-${stamp}.tar.gz" else archive="$destination" archive_dir="$(dirname "$archive")" fi ;; esac mkdir -p "$archive_dir" 2>/dev/null || { role_log "ERR" "無法建立匯出目錄:${archive_dir}"; return 1; } 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" # 依實際格式複製,不可一律當成舊格式的 .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" <"; exit 1; } migrate_role_files "$2" ;; --agent) [ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --agent <角色 ID> [輸出目錄]"; exit 1; } export_agent_definition "$2" "${3:-}" ;; --unlock) require_role LOCK_PATH="$(role_instance_lock_path "$ROLE")" if [ -f "$LOCK_PATH" ]; then role_log "INF" "已解除角色鎖:${ROLE}(原持有者載入於 $(role_instance_lock_field "$LOCK_PATH" loaded))" role_instance_release "$ROLE" else role_log "INF" "角色 ${ROLE} 目前沒有載入鎖,無需解除" fi ;; --brief) role_enabled || exit 0 if ! brief_enabled; then role_log "DBG" "晨間狀態檢查已停用(ROLE_BRIEF_ENABLED=${ROLE_BRIEF_ENABLED:-1}),略過" exit 0 fi for_each_target_role cycle_for_brief ;; --export) if [ -n "${3:-}" ]; then ROLE="$2" [ -n "$ROLE" ] || { role_log "WRN" "缺少角色 ID"; exit 1; } [ -f "$(role_file "$ROLE")" ] || { role_log "WRN" "找不到角色定義檔:$(role_file "$ROLE")"; exit 1; } export_role_archive "$3" else require_role export_role_archive "${2:-}" fi ;; --install-cron) require_role install_cron ;; --remove-cron) remove_cron ;; --status|--diagnose) require_role show_status ;; -h|--help) usage ;; *) usage exit 1 ;; esac