Files
shared/scripts/role/transcript.js
T
JefferyandClaude Opus 5 48dc9ea74a 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>
2026-07-28 17:59:51 +08:00

405 lines
14 KiB
JavaScript
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 node
// ==============================================================================
// 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude CodeCodex
// JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
// (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
// 作為寫入記憶檔前的第二道防線。
// 更新時間:2026/07/28 12:21:11
// 相依:Node.js 標準庫。抽取與遮蔽全程僅走 stdin/stdout,本檔不寫任何檔案。
// ==============================================================================
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, "***@"],
[/\b[0-9a-f]{40}\b/g, "***"],
[/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"],
[/\bsk-[A-Za-z0-9\-_]{16,}\b/g, "***"],
[/\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+/gi, "$1=***"],
[/Authorization:\s*(token|bearer)\s+\S+/gi, "Authorization: $1 ***"],
[/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g, "***"],
[/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"],
[/\b[A-Z][12]\d{8}\b/g, "***"],
];
function readStdin() {
try {
return fs.readFileSync(0, "utf8");
} catch {
return "";
}
}
function redact(text) {
let output = String(text || "");
for (const [pattern, replacement] of REDACT_PATTERNS) {
output = output.replace(pattern, replacement);
}
return output;
}
function isObject(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
function isRealUserMessage(entry) {
const payload = entry.payload;
if (isObject(payload) && entry.type === "event_msg") {
return payload.type === "user_message" && Boolean(String(payload.message || "").trim());
}
if (entry.type !== "user") return false;
const content = entry.message?.content;
if (typeof content === "string") return Boolean(content.trim());
if (Array.isArray(content)) return content.some((block) => isObject(block) && block.type === "text");
return false;
}
function blocks(entry) {
const content = entry.message?.content;
if (typeof content === "string") return [{ type: "text", text: content }];
return Array.isArray(content) ? content : [];
}
function payloadTextBlocks(content) {
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
const texts = [];
for (const block of content) {
if (!isObject(block)) continue;
if (["input_text", "output_text", "text"].includes(block.type)) {
const text = String(block.text || "").trim();
if (text) texts.push(text);
}
}
return texts;
}
function renderCodexPayload(entry) {
const payload = entry.payload;
if (!isObject(payload)) return [];
const lines = [];
const entryType = entry.type;
const payloadType = payload.type;
if (entryType === "event_msg") {
if (payloadType === "user_message") {
const message = String(payload.message || "").trim();
if (message) lines.push(`[user] ${message}`);
} else if (payloadType === "agent_message") {
const message = String(payload.message || "").trim();
if (message) lines.push(`[assistant:${payload.phase || "assistant"}] ${message}`);
}
return lines;
}
if (entryType !== "response_item") return lines;
if (payloadType === "message") {
const role = payload.role || "assistant";
if (role === "system" || role === "developer") return lines;
for (const text of payloadTextBlocks(payload.content)) {
if (role === "user" && text.trimStart().startsWith("<skill>")) continue;
if (role === "user" && text.trimStart().startsWith("<environment_context>")) continue;
lines.push(`[${role}] ${text}`);
}
} else if (payloadType === "function_call") {
const raw = String(payload.arguments || "").trim().replace(/\n/g, " ");
lines.push(`[tool:${payload.name || "?"}] ${raw.slice(0, TOOL_INPUT_LIMIT)}`);
} else if (payloadType === "function_call_output") {
const raw = String(payload.output || "").trim().replace(/\n/g, " ");
if (raw) lines.push(`[result] ${raw.slice(0, TOOL_RESULT_LIMIT)}`);
}
return lines;
}
function render(entry) {
const codexLines = renderCodexPayload(entry);
if (codexLines.length) return codexLines;
const role = entry.type;
const lines = [];
for (const block of blocks(entry)) {
if (!isObject(block)) continue;
if (block.type === "text") {
const text = String(block.text || "").trim();
if (text) lines.push(`[${role}] ${text}`);
} else if (block.type === "tool_use") {
const raw = JSON.stringify(block.input || {});
lines.push(`[tool:${block.name || "?"}] ${raw.slice(0, TOOL_INPUT_LIMIT)}`);
} else if (block.type === "tool_result") {
let raw = block.content;
if (Array.isArray(raw)) {
raw = raw.map((item) => (isObject(item) && item.type === "text" ? item.text || "" : "")).join(" ");
}
raw = String(raw || "").trim().replace(/\n/g, " ");
if (raw) lines.push(`[result] ${raw.slice(0, TOOL_RESULT_LIMIT)}`);
}
}
return lines;
}
function readEntries(filePath) {
let raw;
try {
raw = fs.readFileSync(filePath, "utf8");
} catch {
return [];
}
const entries = [];
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line));
} catch {}
}
return entries;
}
function turnStartIndex(entries) {
for (let index = entries.length - 1; index >= 0; index -= 1) {
if (isRealUserMessage(entries[index])) return index;
}
return 0;
}
function parseTimestamp(value) {
if (typeof value !== "string" || !value.trim()) return null;
const ms = Date.parse(value.trim());
return Number.isNaN(ms) ? null : new Date(ms);
}
function entryTimestamp(entry) {
for (const key of ["timestamp", "created_at", "time"]) {
const dt = parseTimestamp(entry[key]);
if (dt) return dt;
}
if (isObject(entry.message)) {
for (const key of ["timestamp", "created_at", "time"]) {
const dt = parseTimestamp(entry.message[key]);
if (dt) return dt;
}
}
return null;
}
function formatDuration(seconds) {
if (seconds < 0) return "未判定";
const minutes = Math.round(seconds / 60);
if (minutes <= 0) return "1 分鐘內";
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours && mins) return `${hours} 小時 ${mins} 分鐘`;
if (hours) return `${hours} 小時`;
return `${mins} 分鐘`;
}
function turnDuration(filePath) {
const entries = readEntries(filePath);
if (!entries.length) return "未判定";
const start = turnStartIndex(entries);
const stamps = entries.slice(start).map(entryTimestamp).filter(Boolean);
if (stamps.length < 2) return "未判定";
const min = Math.min(...stamps.map((dt) => dt.getTime()));
const max = Math.max(...stamps.map((dt) => dt.getTime()));
return formatDuration((max - min) / 1000);
}
function extractTurn(filePath) {
const entries = readEntries(filePath);
if (!entries.length) return "";
const start = turnStartIndex(entries);
const lines = [];
for (const entry of entries.slice(start)) lines.push(...render(entry));
let text = lines.join("\n").trim();
if (text.length > TOTAL_LIMIT) {
const half = Math.floor(TOTAL_LIMIT / 2);
text = `${text.slice(0, half)}\n…(中段省略)…\n${text.slice(-half)}`;
}
return text;
}
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);
return 0;
}
if (argv[0] === "extract") {
if (argv.length < 2) return 2;
const text = extractTurn(argv[1]);
if (!text) return 1;
process.stdout.write(redact(text));
return 0;
}
if (argv[0] === "duration") {
if (argv.length < 2) return 2;
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;
}
process.stdout.write(USAGE);
return 2;
}
process.exit(main(process.argv.slice(2)));