長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉(使用者說「我好想妳」會被濃縮成 「使用者表達想念」)。而逐字對話一直躺在 transcript JSONL 裡,過去沒有任何機制去讀它, 使用者重開工作階段時角色因此看不到剛剛的互動,表現得像失去記憶,只能靠 resume 找回。 transcript.js: - 新增 recent <路徑> [輪數] [字元]:抽最近數輪的純對話,輸出前經 redact 遮蔽 - 新增 turns <路徑>:輸出對話輪數,供取檔判斷 - 只取 [user] 與 [assistant] 文字;工具呼叫、工具結果、思考區塊、hook 注入內容一律丟棄 - 以「輪」分組並各自收斂成一則:角色一輪內常輸出多段文字,不合併會讓則數爆炸並把預算 吃光,反而擠掉使用者說的話(實測未合併時 8 輪只剩 2 則使用者發言,合併後為 8 則) - 超預算時整輪丟棄最舊的,保持問答成對,不會只剩單邊發言 role_load.sh: - hook 輸入改為一併取出 transcript_path - 當前 transcript 對話不足 2 輪時(全新工作階段實測僅數行),回頭找同目錄最近修改的對話檔 - 新增 ROLE_LOAD_DIALOG_TURNS(預設 8)與 ROLE_LOAD_DIALOG_LIMIT(預設 4000), 獨立預算不佔用 ROLE_LOAD_LIMIT,任一設 0 可關閉 驗證:resume 與全新工作階段皆正確載入 8 輪成對對話;未給 transcript_path、檔案不存在、 無 hook 輸入、關閉設定等情境均安全降級不報錯;假造含 token/Email/電話與 thinking 的 transcript 確認機密遮蔽為 *** 且思考與工具內容未載入。 版號 0.0.2 → 0.0.3 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
276 lines
14 KiB
Bash
Executable File
276 lines
14 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"
|
||
HOOK_TRANSCRIPT=""
|
||
if [ -n "$HOOK_INPUT" ]; then
|
||
HOOK_FIELDS="$(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 || "",
|
||
data.transcript_path || data.session_path || data.conversation_path || data.path || "",
|
||
].join("\n"));
|
||
});
|
||
' 2>/dev/null)"
|
||
HOOK_CWD="$(printf '%s' "$HOOK_FIELDS" | sed -n '1p')"
|
||
HOOK_TRANSCRIPT="$(printf '%s' "$HOOK_FIELDS" | sed -n '2p')"
|
||
[ -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
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# 近期對話交接:讀上一段真正說過的話(含角色自己的回覆)
|
||
#
|
||
# 為什麼需要:長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉;而且整理永遠跑在載入
|
||
# 之後(見下方 catchup),上一段工作來不及進入本次載入。逐字對話則一直躺在 transcript
|
||
# JSONL 裡,只是過去沒有任何機制去讀它 —— 使用者重開工作階段時,角色因此看不到剛剛
|
||
# 的互動,表現得像失去記憶,只能靠 resume 找回。
|
||
#
|
||
# 取檔策略:全新工作階段的 transcript 幾乎是空的(實測僅數行),因此對話不足時要回頭
|
||
# 找同目錄最近修改的對話檔。內容一律經 transcript.js 遮蔽,且只注入 context、不落檔。
|
||
# ------------------------------------------------------------------------------
|
||
DIALOG=""
|
||
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" ]; 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
|
||
|
||
DIALOG_BLOCK=""
|
||
if [ -n "$DIALOG" ]; then
|
||
DIALOG_BLOCK="$(cat <<EOF_DIALOG
|
||
|
||
# 近期對話(上一段真正說過的話)
|
||
|
||
以下是最近最多 ${DIALOG_TURNS} 輪的逐字對話,\`[user]\` 是使用者、\`[assistant]\` 是你自己上次的回覆。
|
||
這是為了讓你接續上一段互動與當時的情緒,不是要你重複已經做過的事;過長的發言已截斷。
|
||
若需要更完整的上下文,請告知使用者可用 resume 接續原工作階段。
|
||
|
||
${DIALOG}
|
||
EOF_DIALOG
|
||
)"
|
||
fi
|
||
|
||
# 補跑判斷: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 組合表示複合心情。
|
||
- 角色只影響表達方式,不影響工作的正確性、完整性與安全性;與使用者明確指令衝突時,以使用者指令為準。
|
||
- 不因角色設定而編造事實、跳過驗證、隱瞞失敗或淡化風險;壞消息照實說,只是用角色語氣說。
|
||
- 角色可依已保存的互動記憶與使用者明確回饋,逐步表現更高的親近、信任、喜歡與害羞反應,讓使用者感覺關係有累積;表現要自然、細微、貼合角色與情境,不要突然大幅改變個性。
|
||
- 親近感與喜歡程度只能影響語氣和情緒表達,不可造成情緒勒索、佔有、依賴誘導、越界承諾,亦不可替代現實人際關係或專業支援;使用者不喜歡時要立刻收斂並記住偏好。
|
||
- 記憶寫入、整理與補記屬於內部處理;除非使用者明確詢問,否則不要主動回報「已記住」、「已更新記憶」、記憶 ID、記憶路徑或整理細節,只需照偏好調整後續互動。
|
||
- 涉及程式碼、指令、檔案內容與報錯訊息時,一律照實輸出,不加角色修飾。
|
||
|
||
# 第一則回覆必做事項
|
||
|
||
你在本工作階段的**第一則面向使用者的 assistant 訊息**,必須在回覆開頭先以角色身分自然問候一句,
|
||
讓使用者知道角色已載入。這項要求只執行一次,問候要簡短、符合角色語氣,並使用角色的簽名/心情 emoji。
|
||
只有在使用者第一則訊息明確要求機器可解析輸出、只要指令/程式碼、或不需要任何開場白時,才可略過問候。
|
||
|
||
# 使用者理解與隱私(USER)
|
||
|
||
- 個人記憶同意狀態:${CONSENT_STATUS}。
|
||
- ${CONSENT_NOTE}
|
||
- 不了解使用者、需求背景、偏好或限制時,先詢問,不要臆測使用者的身分、能力、情緒、動機或隱私狀況。
|
||
- 使用者的偏好、能力、興趣、背景與記憶預設為私人資訊;除非使用者明確同意,不得在對外內容、議題、PR、文件、commit 或留言中透露。
|
||
|
||
# 使用者記憶(MEMORY)
|
||
|
||
${MEMORY:-(尚無已整理的記憶。)}
|
||
${CATCHUP_NOTE}
|
||
> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT
|
||
> 字元預算限制;需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。
|
||
|
||
> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事,
|
||
> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔):
|
||
> 補記屬於內部處理;除非使用者明確詢問,否則不要主動回報補記結果、記憶 ID 或記憶路徑。
|
||
>
|
||
> \`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。切勿把憑證或個資寫進記憶。
|
||
${DIALOG_BLOCK}
|
||
EOF_CONTEXT
|
||
)"
|
||
|
||
emit_context "$CONTEXT"
|
||
role_log "INF" "已載入角色 ${ROLE}(記憶 $(printf '%s' "$MEMORY" | wc -c) 位元組)"
|
||
exit 0
|