feat(role): SessionStart 載入近期逐字對話做工作階段交接

長期記憶是模型濃縮過的摘要,語氣與情緒會被壓掉(使用者說「我好想妳」會被濃縮成
「使用者表達想念」)。而逐字對話一直躺在 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>
This commit is contained in:
Jeffery
2026-07-28 17:59:51 +08:00
co-authored by Claude Opus 5
parent bf599e81a5
commit 48dc9ea74a
6 changed files with 240 additions and 12 deletions
+66 -2
View File
@@ -28,17 +28,23 @@ ROLE_DEF="$(role_file "$ROLE")"
# ------------------------------------------------------------------------------
HOOK_INPUT="$(cat 2>/dev/null)"
HOOK_CWD="$PWD"
HOOK_TRANSCRIPT=""
if [ -n "$HOOK_INPUT" ]; then
HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | node -e '
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 || "");
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}"
@@ -145,6 +151,63 @@ case "$CONSENT_STATUS" in
;;
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
@@ -203,6 +266,7 @@ ${CATCHUP_NOTE}
> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\`
>
> CATEGORY 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。
${DIALOG_BLOCK}
EOF_CONTEXT
)"
+147
View File
@@ -13,6 +13,12 @@ const fs = require("fs");
const TOOL_RESULT_LIMIT = 200;
const TOOL_INPUT_LIMIT = 160;
const TOTAL_LIMIT = 24000;
const DIALOG_TURNS = 8;
const DIALOG_LIMIT = 4000;
// 使用者的話盡量完整保留;角色自己的回覆較長(常含表格與清單),截短並從開頭取,
// 因為情緒與反應通常寫在開頭,後段多是工作細節。
const DIALOG_USER_LIMIT = 600;
const DIALOG_ASSISTANT_LIMIT = 400;
const REDACT_PATTERNS = [
[/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"],
@@ -225,10 +231,139 @@ function extractTurn(filePath) {
const USAGE = `用法:transcript.js <子命令> [參數]
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
recent <路徑> [輪數] [字元] 抽出最近數輪的「純對話」(丟棄工具與注入內容)並遮蔽後輸出
turns <transcript 路徑> 輸出該 transcript 的對話輪數(真實使用者訊息數)
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
`;
// --- 近期對話交接(recent-----------------------------------------------------
// 只取使用者與角色的對話文字,丟棄工具呼叫、工具結果、思考區塊與各種注入內容。
// 目的:SessionStart 時讓角色讀到「上一段真正說過的話」與自己當時的反應。
// 摘要式記憶會被模型濃縮掉語氣與溫度,逐字對話才留得住;但只取最近數輪以控制成本。
// 注入內容不是使用者說的話:hook 附加內容、skill 載入、環境說明、系統提醒、指令輸出。
function stripInjected(text) {
return String(text)
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
.replace(/<skill[^>]*>[\s\S]*?<\/skill>/g, "")
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, "")
.replace(/<command-[a-z-]+>[\s\S]*?<\/command-[a-z-]+>/g, "")
.replace(/<local-command-[a-z-]+>[\s\S]*?<\/local-command-[a-z-]+>/g, "")
.replace(/<user-prompt-submit-hook>[\s\S]*?<\/user-prompt-submit-hook>/g, "")
.trim();
}
function isInjectedUserText(text) {
const head = String(text).trimStart().slice(0, 200);
return /hook additional context|^Caveat:|^<[a-z-]+>/i.test(head);
}
// 只回傳對話文字;工具與思考一律丟棄。相容 Claude Code 與 Codex 兩種 JSONL。
function dialogLines(entry) {
const lines = [];
const payload = entry.payload;
if (isObject(payload)) {
if (entry.type === "event_msg") {
if (payload.type === "user_message") {
const text = stripInjected(payload.message || "");
if (text && !isInjectedUserText(payload.message || "")) lines.push(["user", text]);
} else if (payload.type === "agent_message") {
const text = stripInjected(payload.message || "");
if (text) lines.push(["assistant", text]);
}
return lines;
}
if (entry.type === "response_item" && payload.type === "message") {
const role = payload.role === "user" ? "user" : "assistant";
if (payload.role === "system" || payload.role === "developer") return lines;
for (const raw of payloadTextBlocks(payload.content)) {
if (role === "user" && isInjectedUserText(raw)) continue;
const text = stripInjected(raw);
if (text) lines.push([role, text]);
}
}
return lines; // function_callfunction_call_output 不是對話,丟棄
}
const role = entry.type;
if (role !== "user" && role !== "assistant") return lines;
for (const block of blocks(entry)) {
// 只認 texttool_usetool_resultthinking 全部丟棄
if (!isObject(block) || block.type !== "text") continue;
const raw = String(block.text || "");
if (role === "user" && isInjectedUserText(raw)) continue;
const text = stripInjected(raw);
if (text) lines.push([role, text]);
}
return lines;
}
function recentDialog(filePath, turns, limit) {
const maxTurns = Number.isFinite(turns) && turns > 0 ? turns : DIALOG_TURNS;
const maxChars = Number.isFinite(limit) && limit > 0 ? limit : DIALOG_LIMIT;
const entries = readEntries(filePath);
if (!entries.length) return "";
// 由後往前數 maxTurns 個真實使用者訊息,作為起點;不足則從頭開始
let start = 0;
let seen = 0;
for (let index = entries.length - 1; index >= 0; index -= 1) {
if (!isRealUserMessage(entries[index])) continue;
seen += 1;
if (seen >= maxTurns) {
start = index;
break;
}
}
// 依「輪」分組:角色在一輪內常輸出多段文字(工具呼叫之間),若不合併會讓則數爆炸,
// 把預算全吃光,反而擠掉使用者說的話。一輪固定收斂成「使用者一則+角色一則」。
const grouped = [];
let current = null;
for (const entry of entries.slice(start)) {
if (isRealUserMessage(entry)) {
current = { user: [], assistant: [] };
grouped.push(current);
}
if (!current) continue; // 起點之前殘留的角色輸出不計入
for (const [role, text] of dialogLines(entry)) current[role].push(text);
}
if (!grouped.length) return "";
const clip = (text, max) => {
const one = text.replace(/\n{3,}/g, "\n\n").trim();
return one.length > max ? `${one.slice(0, max)}…(略)` : one;
};
const renderTurn = (turn) => {
const lines = [];
const user = turn.user.join("\n").trim();
const assistant = turn.assistant.join("\n").trim();
if (user) lines.push(`[user] ${clip(user, DIALOG_USER_LIMIT)}`);
if (assistant) lines.push(`[assistant] ${clip(assistant, DIALOG_ASSISTANT_LIMIT)}`);
return lines.join("\n");
};
// 總量超預算時整輪丟棄最舊的,保持問答成對,避免只剩單邊發言
const kept = grouped.slice();
let text = kept.map(renderTurn).filter(Boolean).join("\n");
let dropped = 0;
while (text.length > maxChars && kept.length > 1) {
kept.shift();
dropped += 1;
text = kept.map(renderTurn).filter(Boolean).join("\n");
}
if (text.length > maxChars) text = text.slice(-maxChars);
return dropped ? `…(更早的 ${dropped} 輪已省略)…\n${text}` : text;
}
function countDialogTurns(filePath) {
const entries = readEntries(filePath);
let count = 0;
for (const entry of entries) if (isRealUserMessage(entry)) count += 1;
return count;
}
function main(argv) {
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
process.stdout.write(USAGE);
@@ -246,6 +381,18 @@ function main(argv) {
process.stdout.write(turnDuration(argv[1]));
return 0;
}
if (argv[0] === "recent") {
if (argv.length < 2) return 2;
const text = recentDialog(argv[1], Number.parseInt(argv[2] || "", 10), Number.parseInt(argv[3] || "", 10));
if (!text) return 1;
process.stdout.write(redact(text)); // 對話原文未經模型過濾,一定要遮蔽
return 0;
}
if (argv[0] === "turns") {
if (argv.length < 2) return 2;
process.stdout.write(String(countDialogTurns(argv[1])));
return 0;
}
if (argv[0] === "redact") {
process.stdout.write(redact(readStdin()));
return 0;