Files
shared/scripts/role/role_sleep.sh
T
JefferyandClaude Opus 5 e10865ced7 feat(role): 精確資訊逐字保留、晨間狀態檢查、排除 skill 注入內容
三件事都源自 2026/07/28-29 實際踩到的問題。

一、整理與濃縮提示詞加「精確資訊逐字保留」規則
原本 role_sleep.sh 只要求「壓縮成 5 行、不超過 400 字」,沒有任何規則保護精確資訊,
導致一則含檔案路徑與網址的記憶被整理成純情感摘要,路徑與網址全部遺失且無法復原。
- role_sleep.sh 新增第 19 條:檔案路徑、網址、指令、環境變數名稱、版本號、識別碼、檔名
  一律逐字保留,不得摘要改寫;必要時可超過字數上限;憑證與個資的禁令仍優先
- role_capture.sh 第 8 條同步強化,並釐清與第 7 條「不要整段抄程式碼」的界線

二、新增 --brief 晨間狀態檢查
睡眠時段結束的整點執行使用者自訂檢查腳本,把有變化的結果寫成一則 daily 記憶,
讓角色當天第一次互動就能主動回報(例如 PR 還沒合併、CI 失敗),不必等使用者開口才查。
- 刻意不內建任何檢查邏輯,不假設使用者用 Gitea/GitHub:檢查內容放 ~/.roles/<角色>.checks/*.sh
- 目錄不存在時完全不動作,也不安裝排程條目,對沒設定的人零影響
- 只執行有 +x 的 *.sh;無執行權限記警告並略過
- 沒有輸出就不寫記憶(靜默即代表一切正常,不打擾使用者)
- 每個腳本受 ROLE_BRIEF_TIMEOUT 限制,輸出受 ROLE_BRIEF_EACH_LIMIT/ROLE_BRIEF_LIMIT 截斷
- 腳本輸出視為外部資料,寫入前一律經 transcript.js redact 遮蔽
- install_cron/remove_cron/show_status 一併支援;附 examples/check-gitea-prs.sh 範例

三、對話交接排除 skill 等注入內容
實測發現載入一個 skill 會插入一筆 isMeta 的 user 訊息(長度可達兩萬字元),
被誤認為使用者發言,既吃光字元預算也讓輪數計算失真(29 輪虛胖,實際 26 輪)。
- transcript.js 新增 isMetaEntry():以 isMeta 與 sourceToolUseID 判斷注入內容
- isRealUserMessage() 與 dialogLines() 皆排除,連帶讓 Stop hook 的本輪抽取更準確

驗證:晨間檢查涵蓋有輸出/無輸出/無執行權限/逾時/含機密五種腳本,
確認遮蔽生效、逾時腳本未納入、無權限腳本未執行、全部無輸出時不寫記憶、
ROLE_BRIEF_ENABLED=0 可停用;對話交接確認 skill 內容已排除且 8 則全為真實使用者發言。

版號 0.0.3 → 0.0.4

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:05:22 +08:00

601 lines
26 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ==============================================================================
# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run),
# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶:
# NREM 鞏固(分類/去噪/去重/合併/優先度)→ REM 整合(跨記憶
# 連結/抽象化/提取線索)→ 壓縮歸檔 → 日常與其他依使用頻率與優先度遺忘。
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
# --install-cron--remove-cron(排程安裝與移除)、--status(狀態)。
# 更新時間:2026/07/28 16:02:10
# 相依: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 晨間狀態檢查:執行使用者自訂檢查腳本並寫成一則記憶
--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"],"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 必填:semanticepisodicpreferencerule 通常為 explicitproceduralemotional 通常為 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,應保留並加上新關聯。
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 "${SCRIPT_DIR}/role_sleep.sh")" \
"$(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 "${SCRIPT_DIR}/role_sleep.sh")" \
"$(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 "${SCRIPT_DIR}/role_sleep.sh")" \
"$(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)"
}
install_cron() {
# 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程
command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; }
mkdir -p "$(role_home)" 2>/dev/null
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" "排程輸出:$(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 -
role_log "INF" "已移除睡眠、小睡與晨間狀態檢查排程"
return 0
}
show_status() {
# 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用)
local cron_state="未安裝" nap_state="未安裝" brief_state="未安裝" cron_service="未執行" window="否" checks_state
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"
printf '| 角色定義檔 | %s |\n' "$(role_file "$ROLE")"
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' "$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 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"
memory_dir="$(role_memory_home)/${ROLE}"
tmp="$(mktemp -d)" || { role_log "ERR" "無法建立暫存目錄"; return 1; }
mkdir -p "$tmp/.roles" "$tmp/.memory"
cp "$role_def" "$tmp/.roles/${ROLE}.md" || { rm -rf "$tmp"; role_log "ERR" "無法複製角色定義檔"; return 1; }
[ -d "$role_assets" ] && cp -a "$role_assets" "$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 "手動"
;;
--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