Files
shared/scripts/role/role_sleep.sh
T

465 lines
20 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"
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 運行檢查)
--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 字,去除重複敘述與流水帳。
16. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語)。
17. sleepDigest 總結本次新增、合併、丟棄、抽象化或建立關聯的重點,80 字內。
18. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
素材:
${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"
}
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")"
if nap_enabled; then
new="$(printf '%s\n%s\n%s' "$current" "$(cron_line)" "$(nap_cron_line)" | sed '/^$/d')"
else
new="$(printf '%s\n%s' "$current" "$(cron_line)" | sed '/^$/d')"
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
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" | crontab -
role_log "INF" "已移除睡眠排程"
return 0
}
show_status() {
# 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用)
local cron_state="未安裝" nap_state="未安裝" cron_service="未執行" window="否"
crontab -l 2>/dev/null | grep -qF "$CRON_MARKER" && cron_state="已安裝"
crontab -l 2>/dev/null | grep -qF "$NAP_CRON_MARKER" && nap_state="已安裝"
{ 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' "$(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 "手動"
;;
--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