#!/usr/bin/env bash # ============================================================================== # 用途:角色 context 組裝共用函式庫。把「角色人格(SOUL)+操作規則(AGENTS)+ # 使用者理解(USER)+記憶(MEMORY)+同伴清單+近期對話」組成一份注入文字, # 供 SessionStart(role_load.sh)與點名載入(role_call.sh)共用。 # 本檔僅供 source,不可直接執行。 # 更新時間:2026/07/29 18:43:23 # 相依:bash、node、同目錄的 role_lib.sh(須先 source)/memory.js/transcript.js。 # 機密:角色與記憶內容只組進字串交給呼叫端注入 context,不落檔。 # # 為什麼要獨立一支:兩個 hook(啟動載入、對話中點名載入)必須注入**完全一致**的人格與 # 規則,否則同一個角色會因為「怎麼被叫出來的」而表現不同。共用一份組裝邏輯是唯一能保證 # 一致的做法;差異只以 MODE 參數表達(見 role_context_build)。 # ============================================================================== ROLE_CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" role_context_emit() { # 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式) # $1=hook 事件名稱(SessionStart/UserPromptSubmit)、$2=要注入的內容 printf '%s' "$2" | node -e ' let context = ""; const event = process.argv[2] || "SessionStart"; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { context += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: context }, })); }); ' -- "$1" } role_context_profile() { # 從角色定義檔組出人格區塊(身分+本質+氛圍+自由章節+簽名 emoji) # $1=角色 ID;解析失敗或內容為空時回傳 1 local role="$1" def soul def="$(role_file "$role")" soul="" if role_is_new_format "$role"; then soul="$(role_soul_file "$role")" [ -f "$soul" ] || role_log "WRN" "新格式缺少人格檔:${soul}(本質與氛圍將為空)" fi local profile profile="$(node - "$def" "$soul" <<'NODE_PROFILE' 2>/dev/null const fs = require("fs"); // 新格式:第一個參數是 .identity.md(身分),第二個是 .soul.md(人格)。 // 舊格式:只有第一個參數,身分與人格都在同一個檔案裡。 const file = process.argv[2]; const soulFile = process.argv[3] || ""; const raw = fs.readFileSync(file, "utf8"); let soulRaw = ""; if (soulFile) { try { soulRaw = fs.readFileSync(soulFile, "utf8"); } catch { soulRaw = ""; } } 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 soulFm = soulRaw ? parseFrontmatter(soulRaw) : {}; const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || [fm.name, fm.emoji].filter(Boolean).join(" "); // 人格優先取自 soul 檔;舊格式(無 soul 檔)則沿用原本從單一檔案抽取的行為 const natureSrc = soulRaw || raw; const natureFm = soulRaw ? soulFm : fm; const nature = section(natureSrc, "本質(nature)") || natureFm.nature || ""; const vibe = section(natureSrc, "氛圍(vibe)") || natureFm.vibe || ""; // soul 檔的其餘章節(例如核心信念、語氣與風格、邊界與規範)也要注入。 // 只抽固定的兩節會讓使用者在人格檔裡寫的其他章節被靜默丟棄。 function extraSections(text, skip) { if (!text) return ""; const body = text.replace(/^---\n[\s\S]*?\n---\n?/, ""); const out = []; const re = /^##\s+(.+)$/gm; const marks = []; let m; while ((m = re.exec(body)) !== null) marks.push([m.index, m[0].length, m[1].trim()]); for (let i = 0; i < marks.length; i += 1) { const [idx, len, title] = marks[i]; if (skip.some((s) => title.startsWith(s))) continue; const end = i + 1 < marks.length ? marks[i + 1][0] : body.length; const content = body.slice(idx + len, end).trim(); if (content) out.push(`## ${title}`, "", content); } return out.join("\n"); } const extra = extraSections(soulRaw, ["本質", "氛圍"]); // 標題後、第一個 ## 之前的前言段落(使用者常在此寫存在本質、角色原型等摘要條目)。 // 只抽 frontmatter 與具名章節會讓這段被靜默丟棄。 function preamble(text) { if (!text) return ""; const body = text.replace(/^---\n[\s\S]*?\n---\n?/, "").replace(/^#\s+[^\n]*\n/, ""); const idx = body.search(/^##\s+/m); return (idx < 0 ? body : body.slice(0, idx)).trim(); } const intro = preamble(raw); // 身分只可能在 identity/舊檔裡 const emoji = section(raw, "簽名 emoji") || fm.emoji || ""; const source = section(raw, "來源(source)") || fm.source || ""; const relationship = section(raw, "關係定位(relationship)") || fm.relationship || ""; const lines = [ `- 角色 ID:${fm.id || soulFm.id || ""}`, `- 顯示名稱:${fm.name || title || ""}`, `- 簽名 emoji:${fm.emoji || ""}`, ]; if (intro) lines.push("", intro); if (source) lines.push("", "## 來源(source)", "", source); if (relationship) lines.push("", "## 關係定位(relationship)", "", relationship); lines.push( "", "## 本質(nature)", "", nature || "(未設定)", "", "## 氛圍(vibe)", "", vibe || "(未設定)", ); if (extra) lines.push("", extra); lines.push( "", "## 簽名 emoji", "", emoji || fm.emoji || "(未設定)", ); process.stdout.write(lines.join("\n")); NODE_PROFILE )" [ -n "$profile" ] || return 1 printf '%s' "$profile" } # ------------------------------------------------------------------------------ # 組出完整注入內容 # # $1=角色 ID、$2=本階段 transcript 路徑(可空)、$3=模式: # load(預設):CLI 啟動時載入,本階段從一開始就是這個角色。 # call:對話中被使用者點名接手,本階段先前的回覆屬於別的角色或一般助理。 # # 兩種模式共用同一份人格與規則,只有「怎麼交接」與「近期對話怎麼理解」不同。 # # 結果寫進全域 ROLE_CONTEXT,記憶大小寫進 ROLE_CONTEXT_MEMORY_BYTES(供 log 使用), # 而不是印到 stdout —— 呼叫端用 $() 接會開子行程,這類附帶資訊就傳不回來,只能再跑一次 # memory.js 才拿得到,那是白花的成本。解析失敗時回傳 1 且不改動 ROLE_CONTEXT。 # ------------------------------------------------------------------------------ role_context_build() { local ROLE="$1" HOOK_TRANSCRIPT="$2" MODE="${3:-load}" local SCRIPT_DIR="$ROLE_CONTEXT_DIR" local ROLE_PROFILE ROLE_PROFILE="$(role_context_profile "$ROLE")" || return 1 local MEMORY CONSENT_STATUS CONSENT_NOTE MEMORY="$(node "${SCRIPT_DIR}/memory.js" load --role "$ROLE" 2>/dev/null)" ROLE_CONTEXT_MEMORY_BYTES="$(printf '%s' "$MEMORY" | wc -c)" 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、不落檔。 # ---------------------------------------------------------------------------- local DIALOG="" DIALOG_TURNS DIALOG_LIMIT DIALOG_SRC TURN_COUNT candidate 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" ] && [ "$MODE" != "call" ]; 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 local DIALOG_BLOCK="" DIALOG_SPEAKER_NOTE if [ "$MODE" = "call" ]; then DIALOG_SPEAKER_NOTE="\`[user]\` 是使用者、\`[assistant]\` 是**你接手之前**的回覆(可能來自其他角色或一般助理身分),不要當成自己說過的話。" else DIALOG_SPEAKER_NOTE="\`[user]\` 是使用者、\`[assistant]\` 是你自己上次的回覆。" fi if [ -n "$DIALOG" ]; then DIALOG_BLOCK="$(cat </dev/null)" if [ -n "$PEERS_RAW" ]; then PEERS_LIST="$(printf '%s\n' "$PEERS_RAW" | awk -F'\t' 'NF>=2 {printf "- `%s`(%s):%s\n", $1, $2, $3}')" PEERS_BLOCK="$(cat <\`。 EOF_PEERS )" fi # 關係狀態:讓「隨互動加深逐漸更親近」有實際依據,而非憑感覺推測 local RELATIONSHIP RELATIONSHIP_NOTE="" RELATIONSHIP="$(node "${SCRIPT_DIR}/memory.js" relationship --role "$ROLE" 2>/dev/null)" [ -n "$RELATIONSHIP" ] && RELATIONSHIP_NOTE="- 與使用者的互動累積:${RELATIONSHIP}。請以此為親近度的實際依據,隨累積自然加深,不要憑感覺忽冷忽熱。" # 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 local 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 # 交接說明與問候要求:啟動載入與對話中被點名接手,兩者的處境不同 local HEADING HANDOVER_BLOCK="" GREETING_BLOCK if [ "$MODE" = "call" ]; then HEADING="# 角色切換(點名載入):${ROLE}" HANDOVER_BLOCK="$(cat < > CATEGORY 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。 > 技能再現:上面只載入了部分記憶,磁碟上還有更多。遇到似乎做過的任務、需要回想做法、 > 或使用者問起過去的決定與細節(路徑、網址、指令)時,**先查詢再回答,不要憑印象**: > > \`node "${SCRIPT_DIR}/memory.js" recall --role "${ROLE}" --query "<關鍵詞>" [--limit 5]\` > > 查詢會比對總結、標籤、內容與提取線索(cues),含尚未整理的記憶。查詢屬內部處理,不必回報。 ${PEERS_BLOCK} ${DIALOG_BLOCK} EOF_CONTEXT )" }