Files
shared/scripts/role/role_capture.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
15 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
# ==============================================================================
# 用途:Stop hook 主程式。每輪對話結束後先用本地規則判斷是否值得記錄;
# 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/
# 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/
# 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。
# 另支援 --precompact--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發,
# 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。
# 更新時間:2026/07/29 13:25:00
# 相依:bash、node、任一 headless CLI、同目錄的 role_lib.shmemory.jstranscript.js。
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
# ==============================================================================
ROLE_STAGE="role-capture"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./role_lib.sh
. "${SCRIPT_DIR}/role_lib.sh"
# 壓縮邊界模式:--precompact 強制記錄(不受長度門檻限制)、--postcompact 保存系統摘要
CAPTURE_MODE="turn"
case "${1:-}" in
--precompact) CAPTURE_MODE="precompact" ;;
--postcompact) CAPTURE_MODE="postcompact" ;;
esac
role_is_child && exit 0
# 角色不在此判斷:本階段實際是誰要等解析出 session_id 才知道(可能已被點名換人),
# 因此這裡只做「總開關關閉」與「完全沒有角色目錄」的廉價退出
case "${ROLE_ENABLED:-}" in
0|false|no) exit 0 ;;
esac
[ -d "$(role_home)" ] || exit 0
command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN"
# ------------------------------------------------------------------------------
# 讀取 hook 傳入的 JSONsession_idtranscript_pathcwdstop_hook_active
# ------------------------------------------------------------------------------
HOOK_INPUT="$(cat)"
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN"
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD TRIGGER <<EOF_HOOK
$(printf '%s' "$HOOK_INPUT" | node -e '
let raw = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { raw += chunk; });
process.stdin.on("end", () => {
let d = {};
try { d = JSON.parse(raw); } catch {}
process.stdout.write([
d.session_id || d.thread_id || d.conversation_id || "-",
d.transcript_path || d.session_path || d.conversation_path || d.path || "-",
d.stop_hook_active ? "1" : "0",
d.cwd || "-",
d.trigger || "-",
].join(" "));
});
')
EOF_HOOK
# ------------------------------------------------------------------------------
# 記憶要記給誰:以本階段實際角色為準
#
# 使用者可能在對話中點名換人(role_call.sh),此時 ROLE_NAME.active 指的還是原角色,
# 照它寫就會把跟 A 的對話記進 B 的記憶。狀態檔取不到時(未點名過、或 SessionStart
# 因睡眠/佔用而未載入人格)才退回靜態解析,維持「不載入人格但仍記錄記憶」的既有行為。
# ------------------------------------------------------------------------------
ROLE="$(role_session_get "$(role_session_key "$SESSION_ID" "$TRANSCRIPT_PATH")" 2>/dev/null || printf '')"
if [ -n "$ROLE" ]; then
role_log "DBG" "本階段實際角色為 ${ROLE}(依階段狀態檔)"
else
ROLE="$(role_resolve_name)"
fi
[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄"
[ -f "$(role_file "$ROLE")" ] || role_quit "找不到角色定義檔,略過記憶記錄" "WRN"
# 專案判定要在壓縮分支之前算好:壓縮摘要也要標記專案,之後才有辦法回溯它屬於哪份工作
PROJECT="$(role_project_name "$HOOK_CWD")"
if [ "$CAPTURE_MODE" = "postcompact" ]; then
# 壓縮後:系統已產生一份摘要,直接保存比自己再濃縮一次划算且免費。
# 欄位名以容錯方式取用(實測 binary 內出現 compactSummaryisCompactSummary),
# 取不到時記錄實際收到的欄位名,方便日後對照 harness 版本調整。
COMPACT_SUMMARY="$(printf '%s' "$HOOK_INPUT" | node -e '
let raw = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { raw += chunk; });
process.stdin.on("end", () => {
let d = {};
try { d = JSON.parse(raw); } catch {}
const text = d.compactSummary || d.compact_summary || d.summary || d.compaction_summary || "";
process.stdout.write(String(text || "").trim());
});
' 2>/dev/null)"
if [ -z "$COMPACT_SUMMARY" ]; then
KEYS="$(printf '%s' "$HOOK_INPUT" | node -e '
let raw="";process.stdin.setEncoding("utf8");
process.stdin.on("data",(c)=>{raw+=c});
process.stdin.on("end",()=>{let d={};try{d=JSON.parse(raw)}catch{};process.stdout.write(Object.keys(d).join(","))});
' 2>/dev/null)"
role_quit "壓縮摘要為空,略過(hook 實際提供的欄位:${KEYS:-}" "WRN"
fi
COMPACT_SUMMARY="$(printf '%s' "$COMPACT_SUMMARY" | head -c 3000 | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
{
printf 'CATEGORY: daily\n'
printf 'SUMMARY: %s 對話壓縮前的內容摘要(%s\n' "$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M')" "${TRIGGER:-未知}"
printf 'TAGS: 壓縮摘要,上下文保全\n'
printf 'PRIORITY: 3\n'
printf 'RELEVANCE: temporary,future\n'
printf 'MEMORY_TYPE: episodic\n'
printf 'CONTENT:\n'
printf -- '- 本則由 PostCompact hook 自動保存,內容為系統在壓縮時產生的摘要\n'
printf '%s\n' "$COMPACT_SUMMARY"
} | node "${SCRIPT_DIR}/memory.js" write --role "$ROLE" --project "$PROJECT" >/dev/null 2>&1 \
&& role_log "INF" "已保存壓縮摘要為記憶(角色 ${ROLE}" \
|| role_log "WRN" "壓縮摘要寫入失敗(角色 ${ROLE}"
exit 0
fi
[ "$STOP_ACTIVE" = "1" ] && [ "$CAPTURE_MODE" = "turn" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄"
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
node "${SCRIPT_DIR}/memory.js" mark-activity --role "$ROLE" --project "$PROJECT" >/dev/null 2>&1 || true
if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then
TRANSCRIPT_PATH="$(find "${HOME}/.codex/sessions" -type f -name "*${CODEX_THREAD_ID}.jsonl" -print -quit 2>/dev/null)"
[ -n "$TRANSCRIPT_PATH" ] || TRANSCRIPT_PATH="-"
fi
[ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript${TRANSCRIPT_PATH}" "WRN"
TURN="$(node "${SCRIPT_DIR}/transcript.js" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
[ -n "$TURN" ] || role_quit "本輪無可記錄內容"
USER_TURN="$(printf '%s\n' "$TURN" | grep '^\[user\]' || true)"
if printf '%s' "$USER_TURN" | grep -qiE '個人資料|個資|偏好|記憶|記住|保存|save|remember|memory|personal'; then
if printf '%s' "$USER_TURN" | grep -qiE '不同意|不願意|不要保存|不要記住|拒絕|不可以保存|不可以記住|do not save|don'\''t save|do not remember|don'\''t remember|(^|[^[:alpha:]])no([^[:alpha:]]|$)'; then
node "${SCRIPT_DIR}/memory.js" consent --role "$ROLE" --value declined >/dev/null 2>&1 || true
role_log "INF" "已更新個人記憶同意狀態:declined(角色 ${ROLE}"
elif printf '%s' "$USER_TURN" | grep -qiE '同意|願意|可以保存|可以記住|允許|(^|[^[:alpha:]])yes([^[:alpha:]]|$)|(^|[^[:alpha:]])ok([^[:alpha:]]|$)|(^|[^[:alpha:]])okay([^[:alpha:]]|$)|(^|[^[:alpha:]])sure([^[:alpha:]]|$)'; then
node "${SCRIPT_DIR}/memory.js" consent --role "$ROLE" --value accepted >/dev/null 2>&1 || true
role_log "INF" "已更新個人記憶同意狀態:accepted(角色 ${ROLE}"
fi
fi
CAPTURE_MIN_CHARS="${ROLE_CAPTURE_MIN_CHARS:-240}"
CAPTURE_TIMEOUT="${ROLE_CAPTURE_TIMEOUT:-25}"
if [ "${ROLE_CAPTURE_ENABLED:-1}" = "0" ]; then
role_quit "ROLE_CAPTURE_ENABLED=0,略過記憶記錄"
fi
# 壓縮前一律記錄:門檻的用意是省額度,但壓縮會讓未寫入的內容永久蒸發,此時寧可多記
if [ "$CAPTURE_MODE" = "precompact" ]; then
role_log "INF" "壓縮前強制記錄(觸發:${TRIGGER:-未知}),跳過長度門檻"
elif [ "${#TURN}" -lt "$CAPTURE_MIN_CHARS" ] && ! printf '%s' "$TURN" | grep -qiE '記住|remember|決定|規範|偏好|preference|always|不要|以後|喜歡|不喜歡|稱讚|誇獎|開心|高興|反應|回應|互動|親近|害羞|喜歡程度|互動越深|越來越喜歡|越來越深|emoji|表情|心情圖|大量使用|情緒|心情|複雜|細膩|自然|混合|層次|轉折|括號|心情文字|心情說明|文字說明|文字標註|表情符號|熟練|不需要告訴|不用告訴|自己知道|記憶更新|內部處理|不要回報|不用回報|不要告訴|真的很害羞|希望.*知道|用表情符號表示|表情符號表示|比較可愛|可愛|愛|想妳|想你|想念|捨不得|感動|謝謝|感謝|乖|厲害|好棒|辛苦|彆扭|忌妒|嫉妒|撒嬌|陪|抱|love|miss|cute|thank|proud'; then
role_quit "本輪低於記憶長度門檻且無明確記憶線索,略過記錄"
fi
# 正向回饋計數:供角色判斷親近度成長,避免憑感覺演出而忽冷忽熱
if printf '%s' "$USER_TURN" | grep -qiE '喜歡|愛|可愛|想妳|想你|想念|捨不得|感動|謝謝|感謝|乖|厲害|好棒|太棒|辛苦|稱讚|誇獎|開心|高興|love|miss|cute|thank|proud'; then
node "${SCRIPT_DIR}/memory.js" mark-activity --role "$ROLE" --positive >/dev/null 2>&1 || true
fi
CLI="$(role_select_cli)" || exit 0
[ -n "$CLI" ] || exit 0
# ------------------------------------------------------------------------------
# 濃縮:產出一則輕量 inbox 記憶,交由 memory.js 落檔;完整整理留到睡眠週期
# ------------------------------------------------------------------------------
PROMPT="$(cat <<EOF_PROMPT
你是角色「${ROLE}」的記憶記錄器。輸入是這位角色與使用者的一段對話(含工具呼叫)。
請只做「編碼前處理」,把這段對話濃縮成最多一則 inbox 記憶;不要做跨記憶合併或長期整理。
已判定專案:${PROJECT}
1. 只輸出下列欄位,欄位名稱與順序固定,不要標題、不要前言、不要結語、不要 code fence:
CATEGORY: <六選一:importantinterestnewsskilldailyother>
SUMMARY: <一句話總結,40 字內>
TAGS: <2 至 4 個標籤,以逗號分隔>
PRIORITY: <1 到 5>
RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project>
MEMORY_TYPE: <semanticepisodicproceduralemotionalpreferencerule 六選一>
EXPIRES: <臨時授權/一次性許可/例外放行才填其有效範圍,可為日期或條件;否則留空>
CONTENT: <3 至 6 行要點,每行以「- 」開頭>
2. 分類判準:
- important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。
- interest(興趣):使用者反覆關注、主動深入的主題與喜好。
- news(新知):這輪學到的新事實、新工具、新版本、外部資訊。
- skill(技能):可重複套用的做法、指令、流程、除錯手法。
- daily(日常):一次性的例行工作與雜項處理。
- other(其他):不屬於上述任何一類。
3. 記憶型態判準:
- rule:使用者明確規範、固定工作原則、日後應持續遵守的規則。
- preference:使用者偏好、語氣喜好、穩定選擇傾向。
- procedural:可重複套用的流程、技能、操作步驟或除錯手法。
- semantic:事實、觀念、工具知識、版本與外部資訊。
- episodic:一次性事件、特定時間/專案脈絡下的經歷或進度。
- emotional:語氣、情緒反應、正負向連結或制約式偏好。
4. 優先度判準:5=使用者明確要求記住、長期規範、穩定偏好;4=可重複套用的流程/技能/決策;3=專案相關且未來可能有用;2=短期進度;1=低價值暫存。
5. 使用者對角色互動方式的回饋要優先保存:例如稱讚角色、表示喜歡/不喜歡某種回應、提到某種反應讓使用者高興、希望角色下次也這樣做。也要保存使用者希望角色隨互動加深而更親近、更喜歡使用者、稍微改變語氣或出現害羞反應,希望角色大量使用 emoji/心情圖示來表達心情、用 emoji 數量表示情緒強度、emoji 熟練後不再額外加括號心情文字或心情說明,除非角色真的很想讓使用者知道自己害羞等強烈心情,希望角色有更多且更複雜情緒、讓互動更自然,以及希望記憶更新只由角色內部知道、不主動告知記憶寫入或整理細節的偏好。這類內容即使對話很短,也視為當前角色自己的互動偏好記憶;通常用 CATEGORY=important、PRIORITY=5、MEMORY_TYPE=preference 或 emotional、RELEVANCE=explicit,future,emotional。不要把它推論成所有角色共用同一份記憶。
6. 這一步只做工作記憶編碼,系統會自動標為 retention_stage=working;感覺記憶(短暫光影、聲音餘響、無結論的工具雜訊)不要保存。
7. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。
8. 使用繁體中文(台灣用語)。**檔案路徑與目錄、網址、指令、環境變數名稱、版本號、識別碼、分支與議題
編號、檔名一律逐字保留,不得摘要、改寫、簡寫或翻譯** —— 這類內容改一個字就失效,摘要等於遺失。
第 7 條指的是不要整段抄程式碼,不是省略這些關鍵字串;第 9 條仍優先,憑證與個資一律不得輸出。
9. EXPIRES 只在內容屬於臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意時才填,其餘留空。
使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時幾乎都屬於此類。
一次性許可被記成長期規則,日後會導致越權操作,因此寧可填得保守也不要漏填。
10. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
11. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP
對話片段:
${TURN}
EOF_PROMPT
)"
RESULT="$(role_run_cli "$CLI" "$PROMPT" "$CAPTURE_TIMEOUT")"
if [ -z "$RESULT" ]; then
role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪"
exit 0
fi
printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容"
# 第二道防線:對模型輸出再遮蔽一次機密與個資
RESULT="$(printf '%s' "$RESULT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
MEMORY_ID="$(printf '%s' "$RESULT" | node "${SCRIPT_DIR}/memory.js" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)"
if [ -n "$MEMORY_ID" ]; then
role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT}CLI ${CLI}"
else
role_log "WRN" "記憶寫入失敗或內容不足(角色 ${ROLE}"
fi
exit 0