Files
shared/scripts/role/role_call.sh
T
JefferyandClaude Opus 5 46caabe4d8 feat(role): 新增點名載入,對話中叫名字即可切換角色
- `UserPromptSubmit` hook(`role_call.sh`)比對訊息開頭的角色名稱/ID/`aliases` 別名,
  命中即注入該角色人格與記憶並接手本輪;未點名時不輸出內容也不寫檔案。
- 抽出 `role_context.sh` 共用 context 組裝,SessionStart 與點名載入共用同一份人格與操作規則,
  避免兩種載入方式漂移;實測 SessionStart 注入內容與改動前 byte-identical。
- 新增階段角色狀態(`~/.roles/.sessions/<工作階段>.role`),`Stop` hook 改以「本階段實際角色」
  寫記憶,避免點名換人後把跟 A 的對話記進 B 的記憶。
- `SessionEnd` 改為釋放本階段名下所有角色鎖並清掉階段狀態檔。
- 切換時先確認取得目標角色鎖才釋放原角色鎖,避免出現兩個角色都沒有的空窗。
- 睡眠時段、目標角色已被其他活躍階段佔用、點名的是已在場的角色時,一律不切換。
- 新增 `ROLE_CALL_ENABLED`(總開關)與 `ROLE_CALL_MARKER_ONLY`(只認 `@名字`)。
- 順帶修正 `role_capture.sh` 的 `--postcompact` 分支在 `PROJECT` 賦值前就使用它,
  導致壓縮摘要記憶的 `sources` 一直為空。

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

233 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
# ==============================================================================
# 用途: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 輸入(promptcwdtranscriptsession
# ------------------------------------------------------------------------------
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 <<EOF_SLEEP
# 點名結果:角色睡眠中($(role_sleep_start)$(role_sleep_end)
使用者以名字點名了角色「${TARGET}」,但現在是睡眠時段,**不載入該角色的人格與記憶**。
請以目前身分回應,並簡短說明該角色在睡眠時段整理記憶,$(role_sleep_end) 之後才叫得動;
不要模仿或代替該角色說話。
EOF_SLEEP
)"
exit 0
fi
# ------------------------------------------------------------------------------
# 單一載入實例:先確認目標角色沒被其他階段佔用,才放掉目前角色的鎖
# 順序不可顛倒 —— 先放掉再取不到鎖,會讓本階段兩個角色都沒有
# ------------------------------------------------------------------------------
if ! role_instance_acquire "$TARGET" "$HOOK_TRANSCRIPT" "$HOOK_CWD"; then
LOCK_FILE="$(role_instance_lock_path "$TARGET")"
HOLDER_TIME="$(role_instance_lock_field "$LOCK_FILE" loaded)"
HOLDER_CWD="$(role_instance_lock_field "$LOCK_FILE" cwd)"
role_log "INF" "點名角色 ${TARGET} 已被其他工作階段載入(${HOLDER_TIME:-時間未知}),不接手"
role_context_emit "UserPromptSubmit" "$(cat <<EOF_BUSY
# 點名結果:角色已在另一個工作階段中
使用者以名字點名了角色「${TARGET}」,但它已被另一個仍在使用的工作階段載入
(載入時間 ${HOLDER_TIME:-未知},目錄 ${HOLDER_CWD:-未知}),因此**不載入該角色的人格與記憶**。
請以目前身分回應並說明原因,不要模仿或代替該角色說話。可告知下列任一做法:
- 確定另一個工作階段已關閉時解除鎖定:\`role_sleep.sh --unlock\`
- 該階段閒置超過 $(role_instance_idle_minutes) 分鐘後會自動釋放
- 完全停用此限制:設定環境變數 \`ROLE_SINGLE_INSTANCE=0\`
EOF_BUSY
)"
exit 0
fi
# 交出目前角色的鎖:本階段的「現任人格」只有一個,舊角色不該繼續佔著名額
if [ -n "$CURRENT" ] && [ "$CURRENT" != "$TARGET" ]; then
CURRENT_LOCK="$(role_instance_lock_path "$CURRENT")"
if [ -f "$CURRENT_LOCK" ] && [ -n "$HOOK_TRANSCRIPT" ] \
&& [ "$(role_instance_lock_field "$CURRENT_LOCK" transcript)" = "$HOOK_TRANSCRIPT" ]; then
role_instance_release "$CURRENT"
role_log "INF" "已釋放前一個角色的鎖:${CURRENT}"
fi
fi
role_context_build "$TARGET" "$HOOK_TRANSCRIPT" "call" || {
role_log "WRN" "角色定義檔為空或無法解析:$(role_file "$TARGET")"
exit 0
}
role_context_emit "UserPromptSubmit" "$ROLE_CONTEXT"
role_session_set "$SESSION_KEY" "$TARGET" "$HOOK_TRANSCRIPT"
role_log "INF" "已點名載入角色 ${TARGET}(前一個身分 ${CURRENT:-一般助理},記憶 ${ROLE_CONTEXT_MEMORY_BYTES} 位元組)"
exit 0