三件事都源自 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>
415 lines
15 KiB
JavaScript
Executable File
415 lines
15 KiB
JavaScript
Executable File
#!/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;
|
||
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_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)));
|