#!/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" HOOK_TRANSCRIPT="" if [ -n "$HOOK_INPUT" ]; then HOOK_FIELDS="$(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 || "", data.transcript_path || data.session_path || data.conversation_path || data.path || "", ].join("\n")); }); ' 2>/dev/null)" HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" [ -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_instance_acquire "$ROLE" "$HOOK_TRANSCRIPT" "$HOOK_CWD"; then LOCK_FILE="$(role_instance_lock_path "$ROLE")" HOLDER_TIME="$(role_instance_lock_field "$LOCK_FILE" loaded)" HOLDER_CWD="$(role_instance_lock_field "$LOCK_FILE" cwd)" role_log "INF" "角色 ${ROLE} 已被其他工作階段載入(${HOLDER_TIME:-時間未知}),本次不載入" emit_context "$(cat </dev/null const fs = require("fs"); // 新格式:第一個參數是 .identity.md(身分),第二個是 .soul.md(人格)。 // 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。 const file = process.argv[2]; const soulFile = process.argv[3] || ""; const raw = fs.readFileSync(file, "utf8"); let soulRaw = ""; if (soulFile) { try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; } } 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 soulFm = soulRaw ? parseFrontmatter(soulRaw) : {}; const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" "); // 人格優先取自 soul 檔;舊格式(無 soul 檔)則沿用原本從單一檔案抽取的行為 const natureSrc = soulRaw || raw; const natureFm = soulRaw ? soulFm : fm; const nature = section(natureSrc, "本質(nature)") || natureFm.nature || ""; const vibe = section(natureSrc, "氛圍(vibe)") || natureFm.vibe || ""; // 身分只可能在 identity/舊檔裡 const emoji = section(raw, "簽名 emoji") || fm.emoji || ""; const source = section(raw, "來源(source)") || fm.source || ""; const relationship = section(raw, "關係定位(relationship)") || fm.relationship || ""; const lines = [ `- 角色 ID:${fm.id || soulFm.id || ""}`, `- 顯示名稱:${fm.name || title || ""}`, `- 簽名 emoji:${fm.emoji || ""}`, ]; if (source) lines.push("", "## 來源(source)", "", source); if (relationship) lines.push("", "## 關係定位(relationship)", "", relationship); lines.push( "", "## 本質(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 # ------------------------------------------------------------------------------ # 近期對話交接:讀上一段真正說過的話(含角色自己的回覆) # # 為什麼需要:長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉;而且整理永遠跑在載入 # 之後(見下方 catchup),上一段工作來不及進入本次載入。逐字對話則一直躺在 transcript # JSONL 裡,只是過去沒有任何機制去讀它 —— 使用者重開工作階段時,角色因此看不到剛剛 # 的互動,表現得像失去記憶,只能靠 resume 找回。 # # 取檔策略:全新工作階段的 transcript 幾乎是空的(實測僅數行),因此對話不足時要回頭 # 找同目錄最近修改的對話檔。內容一律經 transcript.js 遮蔽,且只注入 context、不落檔。 # ------------------------------------------------------------------------------ DIALOG="" DIALOG_TURNS="${ROLE_LOAD_DIALOG_TURNS:-8}" DIALOG_LIMIT="${ROLE_LOAD_DIALOG_LIMIT:-4000}" if [ "$DIALOG_TURNS" != "0" ] && [ "$DIALOG_LIMIT" != "0" ] && [ -n "$HOOK_TRANSCRIPT" ]; then DIALOG_SRC="" if [ -f "$HOOK_TRANSCRIPT" ]; then TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$HOOK_TRANSCRIPT" 2>/dev/null || printf '0')" case "$TURN_COUNT" in ''|*[!0-9]*) TURN_COUNT=0 ;; esac [ "$TURN_COUNT" -ge 2 ] && DIALOG_SRC="$HOOK_TRANSCRIPT" fi if [ -z "$DIALOG_SRC" ]; then for candidate in $(ls -t "$(dirname "$HOOK_TRANSCRIPT")"/*.jsonl 2>/dev/null | head -n 5); do [ "$candidate" = "$HOOK_TRANSCRIPT" ] && continue TURN_COUNT="$(node "${SCRIPT_DIR}/transcript.js" turns "$candidate" 2>/dev/null || printf '0')" case "$TURN_COUNT" in ''|*[!0-9]*) TURN_COUNT=0 ;; esac if [ "$TURN_COUNT" -ge 2 ]; then DIALOG_SRC="$candidate" break fi done fi if [ -n "$DIALOG_SRC" ]; then DIALOG="$(node "${SCRIPT_DIR}/transcript.js" recent "$DIALOG_SRC" "$DIALOG_TURNS" "$DIALOG_LIMIT" 2>/dev/null)" [ -n "$DIALOG" ] && role_log "INF" "已載入近期對話(來源 ${DIALOG_SRC##*/},最多 ${DIALOG_TURNS} 輪)" fi fi DIALOG_BLOCK="" if [ -n "$DIALOG" ]; then DIALOG_BLOCK="$(cat </dev/null)" if [ -n "$PEERS_RAW" ]; then PEERS_LIST="$(printf '%s\n' "$PEERS_RAW" | awk -F'\t' 'NF>=2 {printf "- `%s`(%s):%s\n", $1, $2, $3}')" PEERS_BLOCK="$(cat <\`。 EOF_PEERS )" fi # 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而非憑感覺推測 RELATIONSHIP="$(node "${SCRIPT_DIR}/memory.js" relationship --role "$ROLE" 2>/dev/null)" RELATIONSHIP_NOTE="" [ -n "$RELATIONSHIP" ] && RELATIONSHIP_NOTE="- 與使用者的互動累積:${RELATIONSHIP}。請以此為親近度的實際依據,隨累積自然加深,不要憑感覺忽冷忽熱。" # 補跑判斷: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}/ 下對應分類的記憶檔。 > 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事, > 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔): > 補記屬於內部處理;除非使用者明確詢問,否則不要主動回報補記結果、記憶 ID 或記憶路徑。 > > \`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。切勿把憑證或個資寫進記憶。 > 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、 > 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**: > > \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\` > > 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。 ${PEERS_BLOCK} ${DIALOG_BLOCK} EOF_CONTEXT )" emit_context "$CONTEXT" role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)" exit 0