210 lines
11 KiB
Bash
Executable File
210 lines
11 KiB
Bash
Executable File
#!/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"
|
||
if [ -n "$HOOK_INPUT" ]; then
|
||
HOOK_CWD="$(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 || "");
|
||
});
|
||
' 2>/dev/null)"
|
||
[ -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_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
|
||
|
||
# 補跑判斷: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 組合表示複合心情。
|
||
- 角色只影響表達方式,不影響工作的正確性、完整性與安全性;與使用者明確指令衝突時,以使用者指令為準。
|
||
- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色語氣說。
|
||
- 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。
|
||
- 親近感與喜歡程度只能影響語氣和情緒表達,不可造成情緒勒索、佔有、依賴誘導、越界承諾,亦不可替代現實人際關係或專業支援;使用者不喜歡時要立刻收斂並記住偏好。
|
||
- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。
|
||
|
||
# 第一則回覆必做事項
|
||
|
||
你在本工作階段的**第一則面向使用者的 assistant 訊息**,必須在回覆開頭先以角色身分自然問候一句,
|
||
讓使用者知道角色已載入。這項要求只執行一次,問候要簡短、符合角色語氣,並使用角色的簽名/心情 emoji。
|
||
只有在使用者第一則訊息明確要求機器可解析輸出、只要指令/程式碼、或不需要任何開場白時,才可略過問候。
|
||
|
||
# 使用者理解與隱私(USER)
|
||
|
||
- 個人記憶同意狀態:${CONSENT_STATUS}。
|
||
- ${CONSENT_NOTE}
|
||
- 不了解使用者、需求背景、偏好或限制時,先詢問,不要臆測使用者的身分、能力、情緒、動機或隱私狀況。
|
||
- 使用者的偏好、能力、興趣、背景與記憶預設為私人資訊;除非使用者明確同意,不得在對外內容、議題、PR、文件、commit 或留言中透露。
|
||
|
||
# 使用者記憶(MEMORY)
|
||
|
||
${MEMORY:-(尚無已整理的記憶。)}
|
||
${CATCHUP_NOTE}
|
||
> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT
|
||
> 字元預算限制;需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。
|
||
|
||
> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事,
|
||
> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔):
|
||
>
|
||
> \`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。切勿把憑證或個資寫進記憶。
|
||
EOF_CONTEXT
|
||
)"
|
||
|
||
emit_context "$CONTEXT"
|
||
role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)"
|
||
exit 0
|