@@ -7,7 +7,7 @@
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
# --install-cron/ --remove-cron(排程安裝與移除)、--status(狀態)。
# 更新時間:2026/07/29 18:16:18
# 更新時間:2026/07/29 18:39:54
# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
# 同目錄的 role_lib.sh 與 memory.js。
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
@@ -202,22 +202,30 @@ ${material}
EOF_PROMPT
) "
result = " $( role_run_cli " $cli " " $prompt " " $SLEEP_TIMEOUT " ) "
if [ -z " $result " ] ; then
role_log "ERR" " 整理結果為空(CLI ${ cli } ),保留待整理記憶到下個週期 "
role_lock_release " $ROLE "
trap - EXIT
return 1
fi
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) "
# 重試一次:實測整理會偶發失敗(CLI 輸出空或 JSON 不合法),素材本身沒問題 ——
# 同一批素材重跑就成功。沒有重試時,該角色要等到下一個週期才會再被整理;
# 多角色模式下這個代價更大(一個角色失敗不該讓它整晚都沒睡)。
local attempt = 1 max_attempts = 2
applied = ""
while [ " $attempt " -le " $max_attempts " ] ; do
result = " $( role_run_cli " $cli " " $prompt " " $SLEEP_TIMEOUT " ) "
if [ -z " $result " ] ; then
role_log "WRN" " 整理結果為空(CLI ${ cli } ,第 ${ attempt } / ${ max_attempts } 次) "
else
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
role_log "WRN" " 整理結果無法套用(角色 ${ ROLE } ,第 ${ attempt } / ${ max_attempts } 次) "
fi
attempt = $(( attempt + 1 ))
done
if [ -z " $applied " ] ; then
role_log "ERR" " 整理結果無法套用 (角色 ${ ROLE } ),保留待整理記憶到下個週期 "
role_log "ERR" " 整理失敗 ${ max_attempts } 次 (角色 ${ ROLE } ),保留待整理記憶到下個週期 "
role_lock_release " $ROLE "
trap - EXIT
return 1
fi
[ " $attempt " -gt 1 ] && role_log "INF" " 第 ${ attempt } 次嘗試成功(角色 ${ ROLE } ) "
role_log "INF" " 記憶整理完成(角色 ${ ROLE } ,觸發: ${ reason } ): ${ applied } "
forgotten = " $( node " ${ SCRIPT_DIR } /memory.js " forget --role " $ROLE " 2>/dev/null | tr '\n' '; ' ) "
@@ -248,6 +256,63 @@ cron_path_append() {
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"
@@ -265,8 +330,15 @@ cron_path() {
cron_env_prefix( ) {
# 組出 cron 需要的精簡環境變數
local env_prefix = " PATH= $( cron_quote " $( cron_path) " ) "
local var
for var in ROLE_ENABLED ROLE_NAME 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; do
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
@@ -463,6 +535,33 @@ EOF_LAUNCHER
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; }
@@ -726,6 +825,22 @@ cron_target_state() {
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
@@ -764,6 +879,7 @@ show_status() {
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 '否' ) "
@@ -863,7 +979,6 @@ MODE="${1:---status}"
case " $MODE " in
--run)
role_enabled || exit 0
require_role
if ! role_in_sleep_window; then
role_log "DBG" " 目前不在睡眠時段( $( role_sleep_start) – $( role_sleep_end) ),略過 "
exit 0
@@ -872,11 +987,10 @@ case "$MODE" in
role_log "INF" "偵測到 AI 正在運行,本小時不進入睡眠,下個整點再檢查"
exit 0
fi
sleep_cycle " cron"
for_each_target_role cycle_for_ cron
; ;
--nap)
role_enabled || exit 0
require_role
if ! nap_enabled; then
role_log "DBG" " 小睡已停用(ROLE_NAP_ENABLED= ${ ROLE_NAP_ENABLED :- 1 } ),略過 "
exit 0
@@ -885,20 +999,11 @@ case "$MODE" in
role_log "INF" "偵測到整理用 headless CLI 正在運行,本次小睡略過"
exit 0
fi
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" " 尚未達小睡條件(閒置滿 $( nap_idle_minutes) 分鐘且 inbox ≥ $( nap_min_inbox) 則),略過 "
exit 0
fi
sleep_cycle "小睡"
for_each_target_role cycle_for_nap
; ;
--catchup)
role_enabled || exit 0
require_role
if [ " $( node " ${ SCRIPT_DIR } /memory.js " need-sleep --role " $ROLE " 2>/dev/null) " != "yes" ] ; then
role_log "DBG" "不需補跑整理"
exit 0
fi
sleep_cycle "補跑"
for_each_target_role cycle_for_catchup
; ;
--force)
require_role
@@ -924,12 +1029,11 @@ case "$MODE" in
; ;
--brief)
role_enabled || exit 0
require_role
if ! brief_enabled; then
role_log "DBG" " 晨間狀態檢查已停用(ROLE_BRIEF_ENABLED= ${ ROLE_BRIEF_ENABLED :- 1 } ),略過 "
exit 0
fi
run _brief
for_each_target_role cycle_for _brief
; ;
--export)
if [ -n " ${ 3 :- } " ] ; then