#!/usr/bin/env node // ============================================================================== // 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude Code/Codex // 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("")) continue; if (role === "user" && text.trimStart().startsWith("")) 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 抽出本輪內容並遮蔽機密後輸出到 stdout recent <路徑> [輪數] [字元] 抽出最近數輪的「純對話」(丟棄工具與注入內容)並遮蔽後輸出 turns 輸出該 transcript 的對話輪數(真實使用者訊息數) duration 估算本輪花費時間,無法判定時輸出「未判定」 redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout `; // --- 近期對話交接(recent)----------------------------------------------------- // 只取使用者與角色的對話文字,丟棄工具呼叫、工具結果、思考區塊與各種注入內容。 // 目的:SessionStart 時讓角色讀到「上一段真正說過的話」與自己當時的反應。 // 摘要式記憶會被模型濃縮掉語氣與溫度,逐字對話才留得住;但只取最近數輪以控制成本。 // 注入內容不是使用者說的話:hook 附加內容、skill 載入、環境說明、系統提醒、指令輸出。 function stripInjected(text) { return String(text) .replace(/[\s\S]*?<\/system-reminder>/g, "") .replace(/]*>[\s\S]*?<\/skill>/g, "") .replace(/[\s\S]*?<\/environment_context>/g, "") .replace(/[\s\S]*?<\/command-[a-z-]+>/g, "") .replace(/[\s\S]*?<\/local-command-[a-z-]+>/g, "") .replace(/[\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_call/function_call_output 不是對話,丟棄 } const role = entry.type; if (role !== "user" && role !== "assistant") return lines; for (const block of blocks(entry)) { // 只認 text:tool_use/tool_result/thinking 全部丟棄 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)));