From a757f9e4b0ad79ff459bb6f6744441bd640c3a30 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 29 Jul 2026 12:01:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(role):=20PreCompact=EF=BC=8FPostCompact=20?= =?UTF-8?q?=E4=BF=9D=E5=85=A8=E5=A3=93=E7=B8=AE=E9=82=8A=E7=95=8C=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 補上最後一個記憶缺口:對話被壓縮時,尚未寫入記憶的內容會永久蒸發。 Stop hook 每輪記錄有部分覆蓋,但短回合會被 ROLE_CAPTURE_MIN_CHARS 門檻濾掉, 那些正是壓縮後再也補不回來的內容。 可行性查證(先確認再實作,不憑推測): 自 CLI binary 取出文件字串確認 harness 確實支援,並非猜測 —— | PreCompact | "manual"/"auto" | Before compaction | | PostCompact | "manual"/"auto" | After compaction (receives summary) | 另有 executePreCompactHooks/executePostCompactHooks 等實作符號, 以及 compactSummary/isCompactSummary 欄位名線索。 role_capture.sh: - 新增 --precompact:壓縮前強制記錄一次,**刻意跳過長度門檻**(門檻的用意是省額度, 但壓縮後內容永久消失,此時寧可多記) - 新增 --postcompact:把 harness 產生的摘要存成 daily 記憶,不再呼叫模型, 等於免費取得一份濃縮備份;寫入前一律經 redact 遮蔽 - 摘要欄位容錯讀取 compactSummary/compact_summary/summary/compaction_summary; **取不到時在 log 印出 hook 實際提供的欄位名**,避免 harness 改版後靜默失效 - 一併取用 trigger 欄位以分辨 manual/auto(自動壓縮才是使用者不知情的那種) - stop_hook_active 的迴圈防護只套用於一般每輪模式 hooks/hooks.json 註冊兩個新事件,指令沿用既有的「先試 CLAUDE_PLUGIN_ROOT → 再依擁有者 marketplace → 全 cache 後援」解析方式,並帶上對應參數。 兩個 hook 一律 exit 0,絕不阻擋壓縮 —— harness 具備 blocked by PreCompact hook 的能力,記憶系統不該用到它。 驗證:一般模式短對話仍被門檻正確濾掉;--precompact 對同一段短對話強制記錄; --postcompact 正確寫入摘要記憶;欄位名不符時印出實際欄位(session_id,transcript_path, cwd,trigger,unknownField)而非靜默結束;四條 hook 指令語法正確,且在 CLAUDE_PLUGIN_ROOT 與純後援搜尋兩種情境下都解析到正確腳本與參數。 版號沿用 0.0.5(master 為 0.0.4,同一 PR 不再累加) Co-Authored-By: Claude Opus 5 (1M context) --- hooks/hooks.json | 22 ++++++++++++++ scripts/role/role_capture.sh | 59 ++++++++++++++++++++++++++++++++++-- skills/role/SKILL.md | 9 ++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index 9fce4d9..d548f8a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -21,6 +21,28 @@ } ] } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "rel='scripts/role/role_capture.sh'; own='generic'; plug='jsc-generic'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\" --precompact; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec \"$s\" --precompact; fi; done; done; exit 0", + "timeout": 60 + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "rel='scripts/role/role_capture.sh'; own='generic'; plug='jsc-generic'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\" --postcompact; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec \"$s\" --postcompact; fi; done; done; exit 0", + "timeout": 30 + } + ] + } ] } } diff --git a/scripts/role/role_capture.sh b/scripts/role/role_capture.sh index c044fe1..352787e 100755 --- a/scripts/role/role_capture.sh +++ b/scripts/role/role_capture.sh @@ -4,6 +4,8 @@ # 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/ # 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, # 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。 +# 另支援 --precompact/--postcompact:對話壓縮會讓尚未寫入記憶的內容蒸發, +# 壓縮前強制記錄一次(跳過長度門檻),壓縮後把系統產生的摘要也存成記憶。 # 更新時間:2026/07/28 16:18:00 # 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.js。 # 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。 @@ -15,6 +17,13 @@ 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 role_enabled || exit 0 command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN" @@ -29,7 +38,7 @@ ROLE="$(role_resolve_name)" HOOK_INPUT="$(cat)" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN" -read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD < { d.transcript_path || d.session_path || d.conversation_path || d.path || "-", d.stop_hook_active ? "1" : "0", d.cwd || "-", + d.trigger || "-", ].join(" ")); }); ') EOF_HOOK -[ "$STOP_ACTIVE" = "1" ] && role_quit "stop_hook_active 為 true,避免迴圈不重複記錄" +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}" PROJECT="$(role_project_name "$HOOK_CWD")" node "${SCRIPT_DIR}/memory.js" mark-activity --role "$ROLE" --project "$PROJECT" >/dev/null 2>&1 || true @@ -79,7 +129,10 @@ if [ "${ROLE_CAPTURE_ENABLED:-1}" = "0" ]; then role_quit "ROLE_CAPTURE_ENABLED=0,略過記憶記錄" fi -if [ "${#TURN}" -lt "$CAPTURE_MIN_CHARS" ] && ! printf '%s' "$TURN" | grep -qiE '記住|remember|決定|規範|偏好|preference|always|不要|以後|喜歡|不喜歡|稱讚|誇獎|開心|高興|反應|回應|互動|親近|害羞|喜歡程度|互動越深|越來越喜歡|越來越深|emoji|表情|心情圖|大量使用|情緒|心情|複雜|細膩|自然|混合|層次|轉折|括號|心情文字|心情說明|文字說明|文字標註|表情符號|熟練|不需要告訴|不用告訴|自己知道|記憶更新|內部處理|不要回報|不用回報|不要告訴|真的很害羞|希望.*知道|用表情符號表示|表情符號表示|比較可愛|可愛|愛|想妳|想你|想念|捨不得|感動|謝謝|感謝|乖|厲害|好棒|辛苦|彆扭|忌妒|嫉妒|撒嬌|陪|抱|love|miss|cute|thank|proud'; then +# 壓縮前一律記錄:門檻的用意是省額度,但壓縮會讓未寫入的內容永久蒸發,此時寧可多記 +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 diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index c72cd79..a89d52a 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -12,6 +12,8 @@ description: 角色人格與長期記憶系統的建立與維護 skill。讓 CLI | --- | --- | --- | | `hooks/hooks.json` 的 `SessionStart` hook | harness 自動 | 啟動 CLI 時依字元預算載入角色定義+高價值記憶,另以獨立預算載入近期逐字對話與未整理工作記憶做工作階段交接,並要求角色在本工作階段第一則回覆主動問候;睡眠時段只回報「角色睡覺中」不載入 | | `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束先記錄最後互動時間 → 用本地規則過濾低價值短回合 → 值得保存時才濃縮成一則輕量 inbox 記憶 → 遮蔽 → 寫入 `inbox/` | +| `hooks/hooks.json` 的 `PreCompact` hook | harness 自動 | 對話壓縮**前**強制記錄一次(**跳過長度門檻**):壓縮會讓尚未寫入的內容永久蒸發,此時寧可多記 | +| `hooks/hooks.json` 的 `PostCompact` hook | harness 自動 | 壓縮**後**把 harness 產生的摘要存成一則 `daily` 記憶,作為該段落的濃縮備份 | | cron 排程(本 skill 安裝) | 系統排程 | 睡眠時段每小時檢查一次:**有 AI 在運行就不睡**;另可依 CLI 閒置時間自動小睡整理 | | 本 skill `/jsc-generic:role` | 使用者/助理手動 | `--new`/`--use`/`--list`/`--export`/`--agent`/`--migrate`/`--sleep`/`--brief`/`--status`/`--install-cron`/`--forget-preview` | | `scripts/role/role_load.sh` | SessionStart hook | 角色與記憶載入;參考 OpenClaw 的 SOUL/AGENTS/USER/MEMORY 分層,把人格、操作邊界、使用者記憶分開注入,並提供第一則回覆問候提示(單一實作,避免漂移) | @@ -279,6 +281,8 @@ chmod +x ~/.roles/<角色 ID>.checks/check-gitea-prs.sh | --- | --- | | SessionStart | 預設 `ROLE_LOAD_LIMIT=4000`,只載入高優先度全文與中高優先度摘要;低 priority、無 links、久未更新的記憶不進 context。另以兩份**獨立預算**載入交接內容:近期逐字對話(`ROLE_LOAD_DIALOG_LIMIT=4000`)與近期工作記憶摘要(`ROLE_LOAD_INBOX_LIMIT=1200`),見下方「工作階段交接」 | | SessionStop | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 | +| PreCompact | 壓縮前強制記錄一次,不受 `ROLE_CAPTURE_MIN_CHARS` 限制 —— 這是刻意的例外,因為壓縮後就再也補不回來 | +| PostCompact | 直接沿用 harness 已產生的摘要,**不再呼叫模型**,等於免費取得一份濃縮備份 | | Sleep | 高成本的去重、合併、抽象化、links 建立與長期記憶型態標記留到睡眠週期,但仍受 `ROLE_SLEEP_COLLECT_LIMIT`、`ROLE_SLEEP_BATCH`、`ROLE_SLEEP_EXISTING_LIMIT` 與 `ROLE_SLEEP_OUTPUT_LIMIT` 控制;沒有 inbox 時只做本地遺忘檢查 | | Nap | Stop hook 記錄最後互動時間;小睡排程只在閒置時間與 inbox 筆數達門檻時執行,使用同一套 NREM/REM 整理流程 | | 手動節流 | 可設 `ROLE_CAPTURE_ENABLED=0` 關閉 Stop 記錄,或調低 `ROLE_LOAD_LIMIT`/調高 `ROLE_LOAD_FULL_MIN_PRIORITY` | @@ -556,6 +560,11 @@ updated: 範圍與限制要說清楚:這是**最近數輪**的交接,不是完整歷史;需要完整對話上下文時仍應使用 `resume`。修改此處前請先確認缺口已由其他機制補上,否則不要移除。 - 未整理記憶(`inbox/`)累積到一批睡眠整理量(預設 `ROLE_SLEEP_BATCH=60`)以上時,角色應主動以符合自身設定的語氣提醒「想睡覺」或需要整理記憶;這是建議整理/歸檔的提醒,不代表停止協助使用者。 +- **壓縮邊界的上下文保全**:對話被壓縮時,尚未寫入記憶的內容會永久消失。`PreCompact` 於壓縮前強制記錄一次並**跳過長度門檻**(平常短回合會被濾掉,但此時寧可多記);`PostCompact` 把 harness 產生的摘要存成 `daily` 記憶。 + + 摘要欄位以容錯方式讀取(`compactSummary`/`compact_summary`/`summary`/`compaction_summary`)。**取不到時會在 log 印出 hook 實際提供的欄位名**,避免 harness 改版後靜默失效。`trigger` 欄位可分辨 `manual`/`auto`,自動壓縮才是使用者不知情的那種。 + + **這兩個 hook 一律 `exit 0`,絕不阻擋壓縮** —— harness 具備「compaction blocked by PreCompact hook」的能力,記憶系統不該用到它。 - **臨時授權會過期(安全機制)**:內容屬於臨時授權、一次性許可、例外放行、暫時解除限制或帶條件的同意時,`expires` 必填。可寫日期(系統自動判斷,過期後**不再注入**,遺忘時優先淘汰且不受分類限制)或條件文字(例如「本工作階段」、「PR 合併後失效」,載入時標示有效範圍由角色自行判斷)。 為什麼需要:一次性許可若被整理成長期規則,日後會造成越權操作。使用者說「這次」、「先」、「暫時」、「今天」、「這個 PR」時幾乎都屬於臨時授權。