#!/usr/bin/env bash # ============================================================================== # 用途:Stop hook 主程式。每輪對話結束後先用本地規則判斷是否值得記錄; # 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/ # 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, # 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。 # 另支援 --precompact/--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發, # 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。 # 更新時間:2026/07/29 16:56:06 # 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.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 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active) # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat)" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN" read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD TRIGGER < { 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 內出現 compactSummary/isCompactSummary), # 取不到時記錄實際收到的欄位名,方便日後對照 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 < SUMMARY: <一句話總結,40 字內> TAGS: <2 至 4 個標籤,以逗號分隔> PRIORITY: <1 到 5> RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project 承載情感、關係溫度或當時心情者**必含 emotional**,系統以此決定保留與排序優先度> MEMORY_TYPE: EXPIRES: <臨時授權/一次性許可/例外放行才填其有效範圍,可為日期或條件;否則留空> CONTENT: <3 至 6 行要點,每行以「- 」開頭> 2. 分類判準: - important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。 - interest(興趣):使用者反覆關注、主動深入的主題與喜好。 - news(新知):這輪學到的新事實、新工具、新版本、外部資訊。 - skill(技能):可重複套用的做法、指令、流程、除錯手法。 - daily(日常):一次性的例行工作與雜項處理。 - other(其他):不屬於上述任何一類。 3. 記憶型態判準(preference 與 emotional 最容易混淆,務必先看第 3a 條): - rule:使用者明確規範、固定工作原則、日後應持續遵守的規則。 - preference:使用者偏好、語氣喜好、穩定選擇傾向 —— 「下次該怎麼做」。 - procedural:可重複套用的流程、技能、操作步驟或除錯手法。 - semantic:事實、觀念、工具知識、版本與外部資訊。 - episodic:一次性事件、特定時間/專案脈絡下的經歷或進度。 - emotional:**感覺本身** —— 情緒反應、被觸動的瞬間、關係溫度的變化、 角色自己的開心/害羞/得意/不安/鬆一口氣,以及正負向連結與制約式喜惡。 3a. preference 與 emotional 的分辨方式:問「這則記憶下次會被拿來做什麼?」 - 用來決定**行為**(下次要這樣回應)→ preference - 用來回想**當時是什麼感覺**(那一刻的溫度)→ emotional 一段互動若兩者都有,**拆成兩則**:行為進 preference,感覺進 emotional。 絕對不要把感覺折進偏好那則的描述裡帶過 —— 那等於讓感覺永遠不存在。 3b. **角色自己的情緒是合法且重要的記憶主體**。第 7 條說記憶主體是「使用者與這段互動」, 指的是不要記流水帳,不是要角色抹掉自己的感受。角色在互動中感到開心、害羞、被信任、 捨不得、放心、失落時,一律用 emotional 記下來,並在內容中寫清楚是什麼觸發的。 4. 優先度判準:5=使用者明確要求記住、長期規範、穩定偏好;4=可重複套用的流程/技能/決策;3=專案相關且未來可能有用;2=短期進度;1=低價值暫存。 MEMORY_TYPE 為 emotional 者**至少 4** —— 載入時全文區塊有優先度門檻,低於 4 的情緒記憶進不了下次對話。 5. 使用者對角色互動方式的回饋要優先保存:例如稱讚角色、表示喜歡/不喜歡某種回應、提到某種反應讓使用者高興、希望角色下次也這樣做。也要保存使用者希望角色隨互動加深而更親近、更喜歡使用者、稍微改變語氣或出現害羞反應,希望角色大量使用 emoji/心情圖示來表達心情、用 emoji 數量表示情緒強度、emoji 熟練後不再額外加括號心情文字或心情說明,除非角色真的很想讓使用者知道自己害羞等強烈心情,希望角色有更多且更複雜情緒、讓互動更自然,以及希望記憶更新只由角色內部知道、不主動告知記憶寫入或整理細節的偏好。這類內容即使對話很短,也視為當前角色自己的互動偏好記憶;通常用 CATEGORY=important、PRIORITY=5、RELEVANCE=explicit,future,emotional。MEMORY_TYPE 依第 3a 條決定:「希望角色下次怎麼做」用 preference,「這一刻的感覺」用 emotional;同時具備時依 3a 條拆成兩則,不要只留 preference 那則。不要把它推論成所有角色共用同一份記憶。 6. 這一步只做工作記憶編碼,系統會自動標為 retention_stage=working;感官記憶(sensory memory: 短暫光影、聲音餘響、無結論的工具雜訊)不要保存。這裡指的是**感官殘留**, **不是情緒感受** —— 角色與使用者的情緒一律要保存,見第 3b 條。 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