Files
shared/scripts/role/transcript.js
T
JefferyandClaude Opus 5 e10865ced7 feat(role): 精確資訊逐字保留、晨間狀態檢查、排除 skill 注入內容
三件事都源自 2026/07/28-29 實際踩到的問題。

一、整理與濃縮提示詞加「精確資訊逐字保留」規則
原本 role_sleep.sh 只要求「壓縮成 5 行、不超過 400 字」,沒有任何規則保護精確資訊,
導致一則含檔案路徑與網址的記憶被整理成純情感摘要,路徑與網址全部遺失且無法復原。
- role_sleep.sh 新增第 19 條:檔案路徑、網址、指令、環境變數名稱、版本號、識別碼、檔名
  一律逐字保留,不得摘要改寫;必要時可超過字數上限;憑證與個資的禁令仍優先
- role_capture.sh 第 8 條同步強化,並釐清與第 7 條「不要整段抄程式碼」的界線

二、新增 --brief 晨間狀態檢查
睡眠時段結束的整點執行使用者自訂檢查腳本,把有變化的結果寫成一則 daily 記憶,
讓角色當天第一次互動就能主動回報(例如 PR 還沒合併、CI 失敗),不必等使用者開口才查。
- 刻意不內建任何檢查邏輯,不假設使用者用 Gitea/GitHub:檢查內容放 ~/.roles/<角色>.checks/*.sh
- 目錄不存在時完全不動作,也不安裝排程條目,對沒設定的人零影響
- 只執行有 +x 的 *.sh;無執行權限記警告並略過
- 沒有輸出就不寫記憶(靜默即代表一切正常,不打擾使用者)
- 每個腳本受 ROLE_BRIEF_TIMEOUT 限制,輸出受 ROLE_BRIEF_EACH_LIMIT/ROLE_BRIEF_LIMIT 截斷
- 腳本輸出視為外部資料,寫入前一律經 transcript.js redact 遮蔽
- install_cron/remove_cron/show_status 一併支援;附 examples/check-gitea-prs.sh 範例

三、對話交接排除 skill 等注入內容
實測發現載入一個 skill 會插入一筆 isMeta 的 user 訊息(長度可達兩萬字元),
被誤認為使用者發言,既吃光字元預算也讓輪數計算失真(29 輪虛胖,實際 26 輪)。
- transcript.js 新增 isMetaEntry():以 isMeta 與 sourceToolUseID 判斷注入內容
- isRealUserMessage() 與 dialogLines() 皆排除,連帶讓 Stop hook 的本輪抽取更準確

驗證:晨間檢查涵蓋有輸出/無輸出/無執行權限/逾時/含機密五種腳本,
確認遮蔽生效、逾時腳本未納入、無權限腳本未執行、全部無輸出時不寫記憶、
ROLE_BRIEF_ENABLED=0 可停用;對話交接確認 skill 內容已排除且 8 則全為真實使用者發言。

版號 0.0.3 → 0.0.4

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:05:22 +08:00

415 lines
15 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;
if (isMetaEntry(entry)) return false; // skill 載入等注入內容不算一輪對話
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-]+>|^Base directory for this skill:/i.test(head);
}
// Claude Code 以 isMeta 標記非使用者輸入的注入內容(skill 載入、hook 附加內容等),
// sourceToolUseID 則代表該筆來自工具呼叫結果。兩者都不是使用者說的話,也不該算成一輪對話。
// 實測:載入一個 skill 會插入一筆 isMeta 的 user 訊息,長度可達兩萬字元,
// 若不排除會被當成使用者發言,既吃光字元預算也讓輪數計算失真。
function isMetaEntry(entry) {
return entry.isMeta === true || typeof entry.sourceToolUseID === "string";
}
// 只回傳對話文字;工具與思考一律丟棄。相容 Claude Code 與 Codex 兩種 JSONL。
function dialogLines(entry) {
const lines = [];
if (isMetaEntry(entry)) return 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)));