#!/usr/bin/env bash # ============================================================================== # 用途:UserPromptSubmit hook 主程式(點名載入)。使用者在對話中以角色名稱/ID/別名 # 開頭點名時,即時把該角色的人格與記憶注入本輪 context,由它接手回應; # 同時把「本階段目前實際是誰」寫進狀態檔,讓 Stop hook 把記憶記到正確的角色。 # 未點名、點的是目前已在的角色、角色在睡覺或已被其他階段佔用時,一律不切換。 # 更新時間:2026/07/29 13:25:00 # 相依:bash、node、同目錄的 role_lib.sh、role_context.sh 與 memory.js。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者送出訊息。 # # 為什麼要有這支:人格原本只在 SessionStart 注入,換角色必須改 .active 再重開 CLI。 # 想在同一個終端臨時換人(或在原角色被別的視窗佔用時改叫別人)就只能重開工作階段。 # 點名載入把「換人」變成一句話的事,代價是每輪多一次極輕量的比對(未命中即結束)。 # # 設計原則:**沒點名就等於不存在** —— 未命中時不輸出任何 context、不寫任何檔案, # 避免每輪對話都被塞入內容或留下狀態;hook 只有在確定要切換角色時才有副作用。 # ============================================================================== ROLE_STAGE="role-call" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./role_lib.sh . "${SCRIPT_DIR}/role_lib.sh" # shellcheck source=./role_context.sh . "${SCRIPT_DIR}/role_context.sh" role_is_child && exit 0 role_call_enabled || exit 0 # 總開關明確關閉時不作用;未設定時不用 role_enabled 判斷,因為點名的目標角色 # 不一定等於 ROLE_NAME/.active 解析出的角色(甚至可能根本沒設 .active) case "${ROLE_ENABLED:-}" in 0|false|no) exit 0 ;; esac [ -d "$(role_home)" ] || exit 0 command -v node >/dev/null 2>&1 || exit 0 # ------------------------------------------------------------------------------ # 讀取 hook 輸入(prompt/cwd/transcript/session) # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat 2>/dev/null)" [ -n "$HOOK_INPUT" ] || exit 0 HOOK_PROMPT="$(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(String(data.prompt || data.user_prompt || data.message || "")); }); ' 2>/dev/null)" [ -n "$HOOK_PROMPT" ] || exit 0 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 || "", data.session_id || data.thread_id || data.conversation_id || "", ].join("\n")); }); ' 2>/dev/null)" HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')" HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')" HOOK_SESSION="$(printf '%s' "$HOOK_FIELDS" | sed -n '3p')" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" role_in_scope "$HOOK_CWD" || exit 0 # ------------------------------------------------------------------------------ # 比對點名:只認**訊息開頭**的角色名稱/ID/別名 # # 為什麼只認開頭:句中提到名字(「剛剛西莉卡說的做法」)是在談論那個角色,不是要叫他來, # 把兩者混為一談會讓角色莫名其妙被換掉。開頭點名是使用者唯一明確的「我在叫你」訊號。 # 半形名稱(角色 ID、英文別名)額外要求後面接的不是英數字,避免 SILICA01 命中 silica01x。 # 全形/中文名稱不要求分隔符,因為中文本來就不用空格斷詞(「西莉卡在嗎」必須算點名)。 # 名稱較長者優先命中,避免不同角色的名稱互相包含時判給錯的人。 # ------------------------------------------------------------------------------ # 提示詞以參數傳入而非 stdin:`node -` 的 stdin 已經被 heredoc 佔用來當程式碼, # 再從 stdin 讀資料會拿到空字串(此處曾因此完全比不到人)。 TARGET="$(node - "$(role_home)" "$(role_call_marker_only && printf '1' || printf '0')" "$HOOK_PROMPT" <<'NODE_MATCH' 2>/dev/null const fs = require("fs"); const path = require("path"); const home = process.argv[2]; const markerOnly = process.argv[3] === "1"; const prompt = process.argv[4] || ""; 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 candidates() { const out = []; let files = []; try { files = fs.readdirSync(home); } catch { return out; } for (const file of files) { if (file.endsWith(".soul.md")) continue; let id = ""; if (file.endsWith(".identity.md")) { id = file.slice(0, -".identity.md".length); } else if (file.endsWith(".md")) { id = file.slice(0, -".md".length); if (fs.existsSync(path.join(home, `${id}.identity.md`))) continue; } else { continue; } let raw = ""; try { raw = fs.readFileSync(path.join(home, file), "utf8"); } catch { continue; } const fm = parseFrontmatter(raw); const names = new Set([id]); if (fm.name) names.add(fm.name.trim()); // 別名可寫在 identity frontmatter 的 aliases,以逗號、頓號或空白分隔 for (const alias of String(fm.aliases || "").split(/[,,、\s]+/)) { if (alias) names.add(alias.trim()); } for (const name of names) if (name) out.push({ id, name }); } return out.sort((a, b) => b.name.length - a.name.length); } function match() { let text = prompt.replace(/^[\s ]+/, ""); const marker = text.match(/^[@@][\s ]*/); if (marker) text = text.slice(marker[0].length); if (markerOnly && !marker) return ""; const lower = text.toLowerCase(); for (const cand of candidates()) { const name = cand.name.toLowerCase(); if (!name || !lower.startsWith(name)) continue; const rest = text.slice(cand.name.length); const isAscii = /^[\x00-\x7f]*$/.test(cand.name); if (rest && isAscii && /^[0-9A-Za-z_]/.test(rest)) continue; return cand.id; } return ""; } process.stdout.write(match()); NODE_MATCH )" [ -n "$TARGET" ] || exit 0 [ -f "$(role_file "$TARGET")" ] || exit 0 # ------------------------------------------------------------------------------ # 判斷本階段目前實際是誰:狀態檔優先,其次 ROLE_NAME/.active # 已經是同一個角色就不重複注入 —— 每輪重灌一份人格只是白燒 context # ------------------------------------------------------------------------------ SESSION_KEY="$(role_session_key "$HOOK_SESSION" "$HOOK_TRANSCRIPT")" CURRENT="$(role_session_get "$SESSION_KEY" 2>/dev/null || printf '')" if [ -z "$CURRENT" ] && ! role_session_exists "$SESSION_KEY"; then # 完全沒有本階段狀態時(無法識別階段、或 SessionStart 未執行)退回靜態解析, # 至少不會把明明已經在的角色重載一次 CURRENT="$(role_resolve_name)" fi if [ "$CURRENT" = "$TARGET" ]; then role_log "DBG" "點名的角色 ${TARGET} 已在本階段,不重複注入" exit 0 fi # ------------------------------------------------------------------------------ # 睡眠時段:不接手,只說明狀態(與 SessionStart 一致,避免半夜把角色叫起來) # ------------------------------------------------------------------------------ if role_in_sleep_window; then role_log "INF" "點名角色 ${TARGET} 但目前為睡眠時段,不接手" role_context_emit "UserPromptSubmit" "$(cat <