refactor(role): 將角色記憶工具改寫為 Node.js

This commit is contained in:
Jeffery
2026-07-28 12:30:28 +08:00
parent e71f165e9a
commit fec3e1d0cb
8 changed files with 1090 additions and 1125 deletions
+257
View File
@@ -0,0 +1,257 @@
#!/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)));