#!/usr/bin/env bash # ============================================================================== # 用途:SessionStart hook 主程式。CLI 工具啟動時載入角色設定與記憶: # 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; # 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 # 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 # 更新時間:2026/07/28 16:18:00 # 相依:bash、node、同目錄的 role_lib.sh 與 memory.js。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 # ============================================================================== ROLE_STAGE="role-load" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./role_lib.sh . "${SCRIPT_DIR}/role_lib.sh" role_is_child && exit 0 role_enabled || exit 0 command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色載入" "WRN" ROLE="$(role_resolve_name)" [ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入" ROLE_DEF="$(role_file "$ROLE")" [ -f "$ROLE_DEF" ] || role_quit "找不到角色定義檔:${ROLE_DEF}" "WRN" # ------------------------------------------------------------------------------ # 讀取 hook 輸入(cwd/source),並套用 ROLE_SCOPE 範圍限制 # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat 2>/dev/null)" HOOK_CWD="$PWD" if [ -n "$HOOK_INPUT" ]; then HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | node -e ' let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("end", () => { let data = {}; try { data = JSON.parse(raw); } catch {} process.stdout.write(data.cwd || ""); }); ' 2>/dev/null)" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" fi role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" emit_context() { # 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式) printf '%s' "$1" | node -e ' let context = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { context += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context }, })); }); ' } SLEEP_START="$(role_sleep_start)" SLEEP_END="$(role_sleep_end)" # ------------------------------------------------------------------------------ # 睡眠時段:不載入角色,只說明目前狀態 # ------------------------------------------------------------------------------ if role_in_sleep_window; then emit_context "$(cat </dev/null const fs = require("fs"); const file = process.argv[2]; const raw = fs.readFileSync(file, "utf8"); function parseFrontmatter(text) { const match = text.match(/^---\n([\s\S]*?)\n---\n?/); const data = {}; if (!match) return data; for (const line of match[1].split(/\r?\n/)) { const idx = line.indexOf(":"); if (idx < 0) continue; data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } return data; } function section(text, title) { const re = new RegExp(`^##\\s+${title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+|$(?![\\s\\S]))`, "m"); const match = text.match(re); return match ? match[1].trim() : ""; } const fm = parseFrontmatter(raw); const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" "); const nature = section(raw, "本質(nature)") || fm.nature || ""; const vibe = section(raw, "氛圍(vibe)") || fm.vibe || ""; const emoji = section(raw, "簽名 emoji") || fm.emoji || ""; const lines = [ `- 角色 ID:${fm.id || ""}`, `- 顯示名稱:${fm.name || title || ""}`, `- 簽名 emoji:${fm.emoji || ""}`, "", "## 本質(nature)", "", nature || "(未設定)", "", "## 氛圍(vibe)", "", vibe || "(未設定)", "", "## 簽名 emoji", "", emoji || fm.emoji || "(未設定)", ]; process.stdout.write(lines.join("\n")); NODE_PROFILE )" [ -n "$ROLE_PROFILE" ] || role_quit "角色定義檔為空或無法解析:${ROLE_DEF}" "WRN" MEMORY="$(node "${SCRIPT_DIR}/memory.js" load --role "$ROLE" 2>/dev/null)" CONSENT_STATUS="$(node "${SCRIPT_DIR}/memory.js" consent-status --role "$ROLE" 2>/dev/null || printf 'unknown')" case "$CONSENT_STATUS" in accepted) CONSENT_NOTE="已告知並取得使用者同意保存非敏感個人資料與長期偏好;仍禁止保存憑證、token、密碼、API key、連線字串、身分證號、住址等機密或高敏感資料。" ;; declined) CONSENT_NOTE="使用者已拒絕保存個人資料;只能保存非個人化的操作規則與技術偏好,不保存可識別個人的背景。" ;; *) CONSENT_NOTE="尚未確認;第一則自然回覆後,請簡短告知記憶保存範圍並詢問是否同意保存非敏感個人資料。未取得同意前,只能保存非個人化的操作規則與技術偏好。" ;; esac # 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 CATCHUP_NOTE="" if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 & CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n' role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})" fi CONTEXT="$(cat < 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT > 字元預算限制;需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。 > 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事, > 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔): > > \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\` > > CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。 EOF_CONTEXT )" emit_context "$CONTEXT" role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)" exit 0