Files
shared/scripts/role/transcript.js
T

258 lines
8.3 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 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
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
`;
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] === "redact") {
process.stdout.write(redact(readStdin()));
return 0;
}
process.stdout.write(USAGE);
return 2;
}
process.exit(main(process.argv.slice(2)));