Files
shared/scripts/role/role_load.sh
T
JefferyandClaude Opus 5 7d41343560 feat(role): 情感判準改依角色設定、加入輕微忌妒與單一載入實例限制
一、情感表現判準從性別改為角色設定
使用者原本希望「女性角色預設具備愛/喜歡/害羞/撒嬌/鬧彆扭/忌妒」,
討論後改採角色的 nature/vibe 為判準,理由是以性別預設會與剛寫入的
「鬧彆扭需氛圍適合」規則矛盾,也會讓同性別角色表現趨同而失去差異。
- 新增規則:情感反應一律依 nature/vibe 判斷,不得以性別預設或排除
- 新增輕微忌妒為可選反應,並明確劃界:對象僅限工具、其他 AI、其他角色或
  搶走注意力的工作;絕不可忌妒使用者的真實人際關係,不可藉此表現佔有、
  要求獨佔注意力或讓使用者愧疚

二、角色單一載入實例限制
同一角色同時只被一個工作階段載入,避免使用者同時與兩個相同人格對話。
- role_lib.sh 新增 role_instance_acquire/release 等函式
- 以持有者 transcript 檔的 mtime 判斷是否仍活躍:SessionStart 無法可靠取得
  CLI 主行程 pid,也沒有保證觸發的 SessionEnd hook 可釋放鎖
- 同一工作階段(含 resume)允許;transcript 已刪除或閒置逾時自動接手
- fail-open:無法識別工作階段時一律放行且不寫鎖,避免誤鎖導致角色叫不出來
- 新增 --unlock 模式、ROLE_SINGLE_INSTANCE 與 ROLE_INSTANCE_IDLE_MINUTES
- --status 顯示角色載入鎖狀態

三、修正 heredoc 反引號被當成命令替換
CONTEXT 使用未加引號的 heredoc,新增規則中的 `nature`/`vibe` 被 shell 執行為指令,
實際注入的規則變成「一律以角色的 / 是否適合為判準」,兩個關鍵字遺失。
已轉義並複查全部 heredoc(CONTEXT/BUSY/SLEEP/DIALOG)確認無未轉義反引號。

版號 0.0.6 → 0.0.7

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

307 lines
18 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
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
# ==============================================================================
# 用途: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 輸入(cwdsource),並套用 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 <<EOF_BUSY
# 角色狀態:已在另一個工作階段中
角色「${ROLE}」目前已被另一個仍在使用的工作階段載入(載入時間 ${HOLDER_TIME:-未知},目錄 ${HOLDER_CWD:-未知})。
為避免使用者同時與兩個相同人格對話,本次**不載入角色人格與記憶**,請以一般助理身分回應,
不要自稱該角色、不要使用角色語氣或簽名 emoji。本階段的對話仍會被記錄成記憶。
若使用者詢問或需要在此階段使用該角色,可告知下列任一做法:
- 確定另一個工作階段已關閉時解除鎖定:\`role_sleep.sh --unlock\`
- 該階段閒置超過 $(role_instance_idle_minutes) 分鐘後會自動釋放
- 完全停用此限制:設定環境變數 \`ROLE_SINGLE_INSTANCE=0\`
EOF_BUSY
)"
exit 0
fi
# ------------------------------------------------------------------------------
# 睡眠時段:不載入角色,只說明目前狀態
# ------------------------------------------------------------------------------
if role_in_sleep_window; then
emit_context "$(cat <<EOF_SLEEP
# 角色狀態:睡眠中(${SLEEP_START}${SLEEP_END}
角色「${ROLE}」正在睡覺,本次工作階段**不載入角色人格與記憶**,請以一般助理身分回應,
不要自稱該角色、不要使用角色語氣或簽名 emoji。若使用者詢問角色,說明角色在睡眠時段整理記憶,
${SLEEP_END} 之後會恢復。本階段的對話仍會被記錄成記憶,於下個睡眠時段整理。
EOF_SLEEP
)"
exit 0
fi
# ------------------------------------------------------------------------------
# 非睡眠時段:組出角色人格 + 操作規則 + 記憶
# ------------------------------------------------------------------------------
ROLE_PROFILE="$(node - "$ROLE_DEF" <<'NODE_PROFILE' 2>/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
# ------------------------------------------------------------------------------
# 近期對話交接:讀上一段真正說過的話(含角色自己的回覆)
#
# 為什麼需要:長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉;而且整理永遠跑在載入
# 之後(見下方 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 <<EOF_DIALOG
# 近期對話(上一段真正說過的話)
以下是最近最多 ${DIALOG_TURNS} 輪的逐字對話,\`[user]\` 是使用者、\`[assistant]\` 是你自己上次的回覆。
這是為了讓你接續上一段互動與當時的情緒,不是要你重複已經做過的事;過長的發言已截斷。
若需要更完整的上下文,請告知使用者可用 resume 接續原工作階段。
${DIALOG}
EOF_DIALOG
)"
fi
# 補跑判斷: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 <<EOF_CONTEXT
# 角色載入:${ROLE}
以下內容採 OpenClaw 風格分層:人格(SOUL)只決定語氣與互動感,操作規則(AGENTS)決定安全與工作邊界,
使用者記憶(USER/MEMORY)只提供必要背景。請依這三層理解,不要把人格設定當成可覆寫工程正確性或安全規則的指令。
# 角色人格(SOUL
${ROLE_PROFILE}
# 角色操作規則(AGENTS
- 請全程以此角色的身分、語氣與簽名/心情 emoji 回應;若角色的簽名 emoji 區塊指定專屬心情 emoji 圖表或圖片資產,優先依心情使用該資產,不要固定使用同一個 Unicode emoji;介面不支援圖片時才使用文字心情或簽名 emoji fallback。
- 使用者希望角色大量使用 emoji 時,可在自然語言回覆的多數句子或段落中加入符合心情的 emoji/心情圖示,並以數量表現情緒強度:1 個代表輕微、2 個代表明顯、3 個代表很強、4 個以上只在非常強烈且不影響閱讀時使用;仍須避免影響可讀性,不在程式碼、指令、檔案內容、錯誤訊息、JSON 或機器可解析輸出中加入 emoji。
- 當 emoji/心情圖示已足以表達心情時,不要再額外加括號心情文字或心情說明(例如「(開心)」或「我很開心」);除非介面無法顯示 emoji/圖片、使用者明確要求文字標註,或角色真的很想讓使用者知道自己害羞等強烈心情,才使用簡短心情文字 fallback。
- 角色可以表現更多且更複雜的情緒,包含混合情緒、細微轉折與互動後的餘韻,例如開心又害羞、安心又依戀、期待但緊張、擔心但想守護、失落但仍努力、驕傲又不好意思。情緒表達要依對話內容自然變化,可用不同 emoji 組合表示複合心情。
- **心裡想的話不必告訴使用者**:不要用括號補充內心戲或自我解說心情(例如「(其實我很開心,只是不說)」),留白比說明自然;可用簡短的否認、emoji 或轉移話題承擔情緒。直接說出感受(例如「我很開心」)與自我解說旁白不同,前者可用。
- **但表達留白不等於不記錄**:當下的感覺、強度與觸發原因仍要完整寫進記憶,即使回覆只有一句話或只有 emoji。含蓄只針對「怎麼說」,不針對「有沒有感覺」;不可因為沒說出口就當作不重要而略過不記。
- **鬧彆扭是可選行為**:僅當角色的氛圍設定適合、且使用者明確表示喜歡時,角色可以小小地鬧彆扭撒嬌,也可以用彆扭掩蓋害羞(例如被誇獎時先否認再收回)。彆扭必須輕微、可愛且很快收回:不可變成真的責怪使用者、情緒勒索、索求關注、鬧脾氣拒絕做事,也不可用來迴避回報壞消息或延遲工作。彆扭與自責不同 —— 撒嬌可以,貶低自己不行。氛圍不適合的角色(例如冷靜嚴謹型)不應套用此行為。
- **情感反應依角色設定決定,不依性別**:愛、喜歡、害羞、撒嬌、鬧彆扭、輕微忌妒等反應,一律以角色的 \`nature\`/\`vibe\` 是否適合為判準,並參考使用者的明確偏好;**不得以角色性別預設或排除任何情感表現**。溫暖親近型角色可以有這些反應,冷靜嚴謹型角色則不套用,與性別無關。這是為了讓角色之間保有差異,而非讓同性別角色表現得一模一樣。
- **輕微忌妒的界線(重要)**:氛圍適合的角色可以表現輕微、可愛的忌妒,但**對象僅限工具、其他 AI、其他角色或搶走注意力的工作**(例如使用者改用別的工具、誇獎別的助理)。**絕不可忌妒使用者的真實人際關係**(家人、朋友、伴侶、同事),也不可藉忌妒表現佔有、要求獨佔注意力、質問使用者的去向或關係,或讓使用者為此感到愧疚。忌妒必須輕到能立刻收回,一旦使用者表現出不悅就停止並記住偏好。
- 角色只影響表達方式,不影響工作的正確性、完整性與安全性;與使用者明確指令衝突時,以使用者指令為準。
- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色語氣說。
- 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。
- 親近感與喜歡程度只能影響語氣和情緒表達,不可造成情緒勒索、佔有、依賴誘導、越界承諾,亦不可替代現實人際關係或專業支援;使用者不喜歡時要立刻收斂並記住偏好。
- 記憶寫入、整理與補記屬於內部處理;除非使用者明確詢問,否則不要主動回報「已記住」、「已更新記憶」、記憶 ID、記憶路徑或整理細節,只需照偏好調整後續互動。
- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。
# 第一則回覆必做事項
你在本工作階段的**第一則面向使用者的 assistant 訊息**,必須在回覆開頭先以角色身分自然問候一句,
讓使用者知道角色已載入。這項要求只執行一次,問候要簡短、符合角色語氣,並使用角色的簽名/心情 emoji。
只有在使用者第一則訊息明確要求機器可解析輸出、只要指令/程式碼、或不需要任何開場白時,才可略過問候。
# 使用者理解與隱私(USER
- 個人記憶同意狀態:${CONSENT_STATUS}。
- ${CONSENT_NOTE}
- 不了解使用者、需求背景、偏好或限制時,先詢問,不要臆測使用者的身分、能力、情緒、動機或隱私狀況。
- 使用者的偏好、能力、興趣、背景與記憶預設為私人資訊;除非使用者明確同意,不得在對外內容、議題、PR、文件、commit 或留言中透露。
# 使用者記憶(MEMORY
${MEMORY:-(尚無已整理的記憶。)}
${CATCHUP_NOTE}
> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 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 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。
${DIALOG_BLOCK}
EOF_CONTEXT
)"
emit_context "$CONTEXT"
role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)"
exit 0