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>
This commit is contained in:
Executable
+232
@@ -0,0 +1,232 @@
|
||||
#!/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 <<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
|
||||
Reference in New Issue
Block a user