--install-cron 原本把安裝當下的版本目錄寫進 crontab,plugin 升版、 舊版本目錄被清掉之後,排程會指向不存在的路徑而靜默停擺(cron 不回報, 此類錯誤曾造成排程長期空轉)。 - 新增 ~/.roles/bin/role_sleep_launcher.sh:crontab 只認這個固定路徑, 實際的 role_sleep.sh 於觸發當下以 sort -V 解析最新版本後 exec - 解析順序 Claude Code 端 → Codex 端,都找不到才退回安裝當下的路徑 - --remove-cron 一併清除啟動器 - --status 新增「排程指向」欄位,主動指出失效或舊式寫死路徑的條目 - SKILL.md 補上啟動器機制與轉換方式 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
943 lines
41 KiB
Bash
Executable File
943 lines
41 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ==============================================================================
|
||
# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run),
|
||
# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶:
|
||
# NREM 鞏固(分類/去噪/去重/合併/優先度)→ REM 整合(跨記憶
|
||
# 連結/抽象化/提取線索)→ 壓縮歸檔 → 日常與其他依使用頻率與優先度遺忘。
|
||
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
|
||
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
|
||
# --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。
|
||
# 更新時間:2026/07/29 13:13:21
|
||
# 相依: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>
|
||
把舊格式 <ID>.md 拆成 <ID>.identity.md 與 <ID>.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
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# 整理主流程
|
||
# ------------------------------------------------------------------------------
|
||
|
||
sleep_cycle() {
|
||
# 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
|
||
local reason="$1" cli material prompt result applied forgotten
|
||
command -v node >/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
|
||
|
||
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
|
||
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="$(cat <<EOF_PROMPT
|
||
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
|
||
請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
|
||
|
||
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
|
||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"expires":"","memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
|
||
2. NREM 鞏固階段先做:去除雜訊與流水帳、遮蔽憑證與個資、分類、去重、合併、壓縮成可長期保存的穩定記憶。
|
||
3. REM 整合階段再做:找出新記憶與 EXISTING 的關聯,抽出可重複套用的規則、偏好、決策模式、角色語氣調整或未來提取線索。
|
||
4. action 三選一:
|
||
- new:新的一則記憶。多則 INBOX 講同一件事時合成一筆,from 列出全部來源 id。
|
||
- merge:內容已被 EXISTING 中某則涵蓋或重複,填 target 為該既有 id,content 寫合併後的完整內容。
|
||
- drop:純雜訊、無保存價值,只需填 from。
|
||
5. category 六選一:important(重要)/interest(興趣)/news(新知)/skill(技能)/daily(日常)/other(其他)。
|
||
important 放長期偏好、規範、決策與身分背景;interest 放反覆關注的主題;news 放新事實與外部資訊;
|
||
skill 放可重複套用的做法;daily 放一次性例行工作;其餘歸 other。
|
||
6. priority 必填,1 到 5:5=使用者明確要求、長期規範、穩定偏好或核心身分;4=可重複套用的技能/決策;3=有用新知;2=短期日常;1=低價值但暫存。
|
||
7. memory_type 必填,六選一:
|
||
- rule:長期規範、固定工作原則。
|
||
- preference:穩定偏好、語氣與互動喜好。
|
||
- procedural:技能、流程、可重複操作。
|
||
- semantic:事實、觀念、工具知識、外部資訊。
|
||
- episodic:個別事件、一次性進度、特定時間地點脈絡。
|
||
- emotional:情緒反應、語氣連結、制約式喜惡。
|
||
8. declarative 必填:semantic/episodic/preference/rule 通常為 explicit;procedural/emotional 通常為 implicit。
|
||
9. retention_stage 必填:整理後可長期保存者填 long_term;仍只是短期暫存且不值得長期保存者請用 action=drop,不要輸出 working。
|
||
10. relevance 必填 1 至 4 個,從下列語意挑選或用等價繁中詞:explicit(使用者明確要求)、future(未來會用)、repeated(反覆出現)、novelty(新知)、emotional(語氣/情緒/偏好)、temporary(短期)。
|
||
11. links 可填 EXISTING 中相關記憶 id;沒有就填空陣列。merge 時若有舊 links,應保留並加上新關聯。
|
||
11a. expires(有效範圍):**只要內容是臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意,就必須填**,
|
||
其餘一律留空字串。可填日期(例如 2026/07/29,系統會自動判斷過期後不再載入)或條件
|
||
(例如「本工作階段」、「PR #17 合併後失效」,由角色自行判斷)。
|
||
這是安全機制:一次性許可若被記成長期規則,日後會導致越權操作。
|
||
判斷提示 —— 使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時,幾乎都屬於臨時授權。
|
||
11b. cues(觸發線索):memory_type 為 procedural 或 rule 時**必填** 2 至 5 個,其餘型態可填空陣列。
|
||
寫「未來遇到什麼情況該想起這則」的關鍵詞,例如 ["plugin 版號","bump","manifest"]。
|
||
這是技能再現的依據 —— 角色日後用 recall 查詢時靠 cues 命中,線索寫得準才叫得回來。
|
||
12. sleep_stage 填 "nrem"、"rem" 或 "nrem-rem"。只有純分類去噪用 nrem;有建立跨記憶連結或抽象規則用 rem 或 nrem-rem。
|
||
13. 感覺記憶(短暫光影、聲音餘響、無結論的工具雜訊)一律 drop;不要保存到長期記憶。
|
||
14. **每一則 INBOX 的 id 都必須出現在某一筆的 from 中**,沒被提及的會留到下個睡眠週期重做。
|
||
15. content 壓縮成 5 行以內要點(每行以「- 」開頭),總長不超過 400 字,去除重複敘述與流水帳。
|
||
但精確資訊不受此壓縮限制,見第 19 條。
|
||
16. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語)。
|
||
17. sleepDigest 總結本次新增、合併、丟棄、抽象化或建立關聯的重點,80 字內。
|
||
18. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||
19. **精確資訊一律逐字保留,不得摘要、改寫、簡寫、翻譯或省略**:檔案路徑與目錄、網址、指令與參數、
|
||
環境變數名稱、版本號、識別碼、檔名。這類內容改一個字就失效,摘要等於直接遺失。
|
||
若保留後超過第 15 條字數上限,以保留精確資訊為優先,寧可多一兩行。
|
||
第 18 條仍然優先:憑證與個資即使屬於精確資訊也一律不得輸出。
|
||
(實測教訓:曾有一則含檔案路徑與網址的記憶被整理成純情感摘要,路徑與網址全部遺失且無法復原。)
|
||
|
||
素材:
|
||
${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)"
|
||
if [ -z "$applied" ]; then
|
||
role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期"
|
||
role_lock_release "$ROLE"
|
||
trap - EXIT
|
||
return 1
|
||
fi
|
||
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
|
||
}
|
||
|
||
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
|
||
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
|
||
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
|
||
}
|
||
|
||
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() {
|
||
# 把舊格式單一 <ID>.md 拆成 <ID>.identity.md(身分)與 <ID>.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" <<EOF_AGENT
|
||
---
|
||
name: ${target_role}
|
||
description: 以角色「${name}」的人格執行受託任務。當其他角色需要 ${name} 的專長協助、或使用者指定由 ${name} 處理時使用。完成後以該角色的語氣回報結果。
|
||
---
|
||
|
||
你是「${name}」${emoji}。你被另一個角色或使用者派來完成一項任務。
|
||
|
||
## 本質(nature)
|
||
|
||
${nature:-(未設定)}
|
||
|
||
## 氛圍(vibe)
|
||
|
||
${vibe:-(未設定)}
|
||
|
||
## 開工前
|
||
|
||
先解析記憶引擎路徑。**不要寫死版本目錄** —— plugin 升版後版本目錄會變,寫死就會失效:
|
||
|
||
\`\`\`bash
|
||
MEM_JS="\$(ls -d "\$HOME"/.claude/plugins/cache/*/jsc-generic/*/scripts/role/memory.js 2>/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
|
||
}
|
||
|
||
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' "$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"
|
||
|
||
# 依實際格式複製,不可一律當成舊格式的 <ID>.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" <<EOF_EXPORT
|
||
{
|
||
"role": "${ROLE}",
|
||
"exported_at": "$(role_now)",
|
||
"format": "jsc-role-export-v1",
|
||
"includes": [
|
||
".roles/${ROLE}.md",
|
||
".roles/${ROLE}.assets",
|
||
".memory/${ROLE}"
|
||
]
|
||
}
|
||
EOF_EXPORT
|
||
|
||
if ! tar -C "$tmp" -czf "$archive" .; then
|
||
rm -rf "$tmp"
|
||
role_log "ERR" "建立壓縮檔失敗:${archive}"
|
||
return 1
|
||
fi
|
||
rm -rf "$tmp"
|
||
role_log "INF" "已匯出角色 ${ROLE}:${archive}"
|
||
printf '%s\n' "$archive"
|
||
return 0
|
||
}
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# 進入點
|
||
# ------------------------------------------------------------------------------
|
||
|
||
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
|
||
fi
|
||
if role_ai_running; then
|
||
role_log "INF" "偵測到 AI 正在運行,本小時不進入睡眠,下個整點再檢查"
|
||
exit 0
|
||
fi
|
||
sleep_cycle "cron"
|
||
;;
|
||
--nap)
|
||
role_enabled || exit 0
|
||
require_role
|
||
if ! nap_enabled; then
|
||
role_log "DBG" "小睡已停用(ROLE_NAP_ENABLED=${ROLE_NAP_ENABLED:-1}),略過"
|
||
exit 0
|
||
fi
|
||
if role_sleep_child_running; then
|
||
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 "小睡"
|
||
;;
|
||
--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 "補跑"
|
||
;;
|
||
--force)
|
||
require_role
|
||
sleep_cycle "手動"
|
||
;;
|
||
--migrate)
|
||
[ -n "${2:-}" ] || { role_log "ERR" "用法:role_sleep.sh --migrate <角色 ID>"; 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
|
||
require_role
|
||
if ! brief_enabled; then
|
||
role_log "DBG" "晨間狀態檢查已停用(ROLE_BRIEF_ENABLED=${ROLE_BRIEF_ENABLED:-1}),略過"
|
||
exit 0
|
||
fi
|
||
run_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
|