feat(role): 新增角色記憶預算與心理學記憶型態 #9

Merged
admin merged 10 commits from develop into master 2026-07-28 04:53:08 +00:00
8 changed files with 1090 additions and 1125 deletions
Showing only changes of commit fec3e1d0cb - Show all commits
+727
View File
@@ -0,0 +1,727 @@
#!/usr/bin/env node
// ==============================================================================
// 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
// inbox(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/28 12:21:11
// 相依:Node.js 標準庫。
// 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
// ==============================================================================
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const zlib = require("zlib");
const CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"];
const CATEGORY_LABELS = {
important: "重要",
interest: "興趣",
news: "新知",
skill: "技能",
daily: "日常",
other: "其他",
};
const LABEL_TO_CATEGORY = Object.fromEntries(Object.entries(CATEGORY_LABELS).map(([key, value]) => [value, key]));
const FULL_CATEGORIES = ["important", "interest"];
const DIGEST_CATEGORIES = ["skill", "news", "daily", "other"];
const FORGET_RULES = { daily: [14, 1], other: [7, 1] };
const DEFAULT_PRIORITY = { important: 5, interest: 4, skill: 4, news: 3, daily: 2, other: 1 };
const SLEEP_BATCH = 60;
const COLLECT_LIMIT = 12000;
const EXISTING_INDEX_LIMIT = 120;
const CONTENT_LIMIT = 1200;
const DEFAULT_LOAD_LIMIT = 4000;
const DEFAULT_FULL_MIN_PRIORITY = 4;
const DEFAULT_DIGEST_MIN_PRIORITY = 3;
function readStdin() {
try {
return fs.readFileSync(0, "utf8");
} catch {
return "";
}
}
function pad(value) {
return String(value).padStart(2, "0");
}
function taipeiDate() {
return new Date(Date.now() + 8 * 60 * 60 * 1000);
}
function nowStamp() {
const d = taipeiDate();
return `${d.getUTCFullYear()}/${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
function nowEpoch() {
return Math.floor(Date.now() / 1000);
}
function parseStamp(value) {
if (typeof value !== "string" || !value.trim()) return null;
const m = value.trim().match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!m) return null;
const [, y, mo, d, h, mi, s] = m.map(Number);
const ms = Date.UTC(y, mo - 1, d, h - 8, mi, s);
return Number.isNaN(ms) ? null : new Date(ms);
}
function memoryRoot(role) {
const base = process.env.ROLE_MEMORY_HOME || path.join(process.env.HOME || "", ".memory");
return path.join(base, role);
}
function ensureLayout(role) {
const root = memoryRoot(role);
for (const sub of ["inbox", "archive/raw", "archive/forgotten", ...CATEGORIES]) {
fs.mkdirSync(path.join(root, sub), { recursive: true });
}
return root;
}
function statePath(role) {
return path.join(memoryRoot(role), "state.json");
}
function readState(role) {
try {
const data = JSON.parse(fs.readFileSync(statePath(role), "utf8"));
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
} catch {
return {};
}
}
function writeState(role, patch) {
const state = { ...readState(role), ...patch };
ensureLayout(role);
fs.writeFileSync(statePath(role), JSON.stringify(state, null, 2), "utf8");
return state;
}
function normalizeCategory(value) {
const raw = String(value || "").trim().toLowerCase();
if (CATEGORIES.includes(raw)) return raw;
return LABEL_TO_CATEGORY[String(value || "").trim()] || "other";
}
function uniquePush(items, value, fold = false) {
const text = String(value || "").trim().replace(/^[#\[]+|[\]]+$/g, "").trim();
if (!text) return;
const exists = fold
? items.some((item) => item.toLowerCase() === text.toLowerCase())
: items.includes(text);
if (!exists) items.push(text);
}
function normalizeTags(value) {
const parts = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(/[,、|]/) : [];
const tags = [];
for (const part of parts) uniquePush(tags, part, true);
return tags.slice(0, 6);
}
function normalizeList(value, limit = 8) {
const parts = Array.isArray(value) ? value.map(String) : typeof value === "string" ? value.split(/[,、|]/) : [];
const items = [];
for (const part of parts) uniquePush(items, part, false);
return items.slice(0, limit);
}
function normalizePriority(value, category = "other") {
const fallback = DEFAULT_PRIORITY[normalizeCategory(category)] || 1;
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
const priority = Number.isFinite(parsed) ? parsed : fallback;
return Math.max(1, Math.min(5, priority));
}
function oneLine(value, limit = 120) {
return String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
}
function dumpMemory(meta, content) {
const lines = ["---"];
for (const key of [
"id",
"category",
"summary",
"tags",
"priority",
"relevance",
"links",
"sleep_stage",
"created",
"updated",
"last_replayed",
"hits",
"sources",
]) {
if (!Object.prototype.hasOwnProperty.call(meta, key)) continue;
let value = meta[key];
if (Array.isArray(value)) value = `[${value.map(String).join(", ")}]`;
lines.push(`${key}: ${value}`);
}
lines.push("---", "", String(content || "").trim(), "");
return lines.join("\n");
}
function loadMemory(filePath) {
let raw;
try {
raw = fs.readFileSync(filePath, "utf8");
} catch {
return [null, ""];
}
const meta = { path: filePath, hits: 0, tags: [] };
let body = raw;
if (raw.startsWith("---")) {
const parts = raw.split("---");
if (parts.length >= 3) {
body = parts.slice(2).join("---");
for (const line of parts[1].split(/\r?\n/)) {
if (!line.includes(":")) continue;
const idx = line.indexOf(":");
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key === "tags" || key === "sources") meta[key] = normalizeTags(value.replace(/^\[|\]$/g, ""));
else if (key === "relevance" || key === "links") meta[key] = normalizeList(value.replace(/^\[|\]$/g, ""));
else if (key === "hits" || key === "priority") meta[key] = /^\d+$/.test(value) ? Number.parseInt(value, 10) : 0;
else meta[key] = value;
}
}
}
meta.id ||= path.basename(filePath, ".md");
meta.summary ||= "";
meta.created ||= "";
meta.updated ||= meta.created || "";
meta.relevance ||= [];
meta.links ||= [];
meta.priority = normalizePriority(meta.priority, meta.category || "other");
return [meta, body.trim()];
}
function newId(seed) {
const digest = crypto.createHash("sha1").update(seed, "utf8").digest("hex").slice(0, 6);
const d = taipeiDate();
const stamp = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
return `${stamp}-${digest}`;
}
function listMemories(role, category) {
const directory = path.join(memoryRoot(role), category);
if (!fs.existsSync(directory)) return [];
const items = [];
for (const name of fs.readdirSync(directory).sort()) {
if (!name.endsWith(".md")) continue;
const [meta, content] = loadMemory(path.join(directory, name));
if (!meta) continue;
meta.category = category;
items.push([meta, content]);
}
items.sort((a, b) => {
const am = a[0];
const bm = b[0];
const av = [normalizePriority(am.priority, category), am.links?.length ? 1 : 0, am.updated || ""];
const bv = [normalizePriority(bm.priority, category), bm.links?.length ? 1 : 0, bm.updated || ""];
for (let i = 0; i < av.length; i += 1) {
if (av[i] < bv[i]) return 1;
if (av[i] > bv[i]) return -1;
}
return 0;
});
return items;
}
function listInbox(role) {
const directory = path.join(memoryRoot(role), "inbox");
if (!fs.existsSync(directory)) return [];
const items = [];
for (const name of fs.readdirSync(directory).sort()) {
if (!name.endsWith(".md")) continue;
const [meta, content] = loadMemory(path.join(directory, name));
if (meta) items.push([meta, content]);
}
return items;
}
function findMemory(role, memoryId) {
for (const category of CATEGORIES) {
const filePath = path.join(memoryRoot(role), category, `${memoryId}.md`);
if (!fs.existsSync(filePath)) continue;
const [meta, content] = loadMemory(filePath);
if (meta) {
meta.category = category;
return [meta, content];
}
}
return [null, ""];
}
function memoryHint(meta) {
const relevance = (meta.relevance || []).join("、") || "-";
const links = (meta.links || []).join("、") || "-";
return `優先度:${normalizePriority(meta.priority, meta.category)};關聯:${relevance};連結:${links}`;
}
function archiveFile(filePath, destinationDir) {
fs.mkdirSync(destinationDir, { recursive: true });
const target = path.join(destinationDir, `${path.basename(filePath)}.gz`);
try {
const raw = fs.readFileSync(filePath);
fs.writeFileSync(target, zlib.gzipSync(raw));
fs.unlinkSync(filePath);
return true;
} catch {
return false;
}
}
const FIELD_PATTERN = /^\s*(CATEGORY|SUMMARY|TAGS|PRIORITY|RELEVANCE|CONTENT)\s*[:]\s*(.*)$/i;
function parseCapture(text) {
let category = "";
let summary = "";
let priority = null;
let tags = [];
let relevance = [];
const contentLines = [];
let inContent = false;
for (const line of String(text || "").split(/\r?\n/)) {
const match = line.match(FIELD_PATTERN);
if (match && !(inContent && match[1].toUpperCase() !== "CONTENT")) {
const field = match[1].toUpperCase();
const value = match[2];
if (field === "CATEGORY") category = value;
else if (field === "SUMMARY") summary = value;
else if (field === "TAGS") tags = normalizeTags(value);
else if (field === "PRIORITY") priority = value;
else if (field === "RELEVANCE") relevance = normalizeList(value);
else if (field === "CONTENT") {
inContent = true;
if (value.trim()) contentLines.push(value);
}
continue;
}
if (inContent) contentLines.push(line);
}
return { category, summary, tags, priority, relevance, content: contentLines.join("\n").trim() };
}
function cmdWrite(args) {
const parsed = parseCapture(readStdin());
if (!parsed.content && !parsed.summary) return 1;
const content = (parsed.content || parsed.summary).slice(0, CONTENT_LIMIT);
const stamp = nowStamp();
const meta = {
id: newId(content + stamp),
category: normalizeCategory(parsed.category),
summary: oneLine(parsed.summary) || oneLine(content),
tags: parsed.tags,
priority: normalizePriority(parsed.priority, parsed.category),
relevance: parsed.relevance.length ? parsed.relevance : ["inbox"],
links: [],
sleep_stage: "encoding",
created: stamp,
updated: stamp,
hits: 1,
};
if (args.project) meta.sources = [args.project];
ensureLayout(args.role);
fs.writeFileSync(path.join(memoryRoot(args.role), "inbox", `${meta.id}.md`), dumpMemory(meta, content), "utf8");
process.stdout.write(meta.id);
return 0;
}
function cmdSeed(args) {
const raw = readStdin().trim();
if (!raw && !args.summary) return 1;
const content = (raw || args.summary).slice(0, CONTENT_LIMIT);
const stamp = nowStamp();
const category = normalizeCategory(args.category);
const meta = {
id: newId(content + args.summary + stamp),
category,
summary: oneLine(args.summary) || oneLine(content),
tags: normalizeTags(args.tags),
priority: normalizePriority(args.priority, category),
relevance: normalizeList(args.relevance).length ? normalizeList(args.relevance) : ["explicit", "background"],
links: [],
sleep_stage: "seed",
created: stamp,
updated: stamp,
hits: 1,
};
if (args.source) meta.sources = [args.source];
ensureLayout(args.role);
fs.writeFileSync(path.join(memoryRoot(args.role), category, `${meta.id}.md`), dumpMemory(meta, content), "utf8");
process.stdout.write(meta.id);
return 0;
}
function cmdLoad(args) {
const blocks = [];
for (const category of FULL_CATEGORIES) {
const lines = [`### ${CATEGORY_LABELS[category]}記憶(全文)`];
for (const [meta, content] of listMemories(args.role, category)) {
if (normalizePriority(meta.priority, category) < args.fullMinPriority) continue;
const tags = (meta.tags || []).join("、") || "無標籤";
lines.push(`- **${meta.summary || "(無總結)"}**(標籤:${tags}${memoryHint(meta)}`);
for (const line of content.split(/\r?\n/)) {
if (line.trim()) lines.push(` ${line.trim()}`);
}
}
if (lines.length > 1) blocks.push(lines.join("\n"));
}
const digestLines = [];
for (const category of DIGEST_CATEGORIES) {
const items = listMemories(args.role, category);
if (!items.length) continue;
digestLines.push(`### ${CATEGORY_LABELS[category]}記憶(總結)`);
for (const [meta] of items) {
const priority = normalizePriority(meta.priority, category);
if (priority < args.digestMinPriority && !(meta.links || []).length) continue;
const tags = (meta.tags || []).join("、") || "無標籤";
digestLines.push(`- ${meta.summary || "(無總結)"}(標籤:${tags}${memoryHint(meta)}`);
}
}
if (digestLines.length) blocks.push(digestLines.join("\n"));
const digest = readState(args.role).last_sleep_digest;
if (digest) blocks.push(`> 上次睡眠摘要:${digest}`);
const pending = listInbox(args.role).length;
if (pending) blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`);
if (!blocks.length) return 1;
let text = blocks.join("\n\n");
if (text.length > args.limit) {
text = `${text.slice(0, args.limit)}\n\n> (記憶內容超過 ${args.limit} 字元預算已截斷;可用 ROLE_LOAD_LIMIT 調整,完整記憶仍保存在磁碟)`;
}
process.stdout.write(text);
return 0;
}
function cmdCollect(args) {
const batchSize = Math.max(1, args.batch);
const collectLimit = Math.max(2000, args.limit);
const existingLimit = Math.max(0, args.existingLimit);
const inbox = listInbox(args.role).slice(0, batchSize);
if (!inbox.length) return 1;
const lines = ["=== INBOX(待整理,每則以 id 標識)==="];
for (const [meta, content] of inbox) {
lines.push(`--- id: ${meta.id} | 時間: ${meta.created || "-"} ---`);
lines.push(`初判分類: ${CATEGORY_LABELS[meta.category || "other"] || "其他"}`);
lines.push(`初判總結: ${meta.summary || ""}`);
lines.push(`初判標籤: ${(meta.tags || []).join("、") || "無"}`);
lines.push(`初判優先度: ${normalizePriority(meta.priority, meta.category || "other")}`);
lines.push(`初判關聯: ${(meta.relevance || []).join("、") || "-"}`);
lines.push("內容:", content, "");
}
lines.push("=== EXISTING(既有記憶索引,供去重與合併判斷)===");
const rows = [];
for (const category of CATEGORIES) {
for (const [meta] of listMemories(args.role, category)) {
const tags = (meta.tags || []).join("、") || "無";
rows.push(`- id: ${meta.id} | 分類: ${CATEGORY_LABELS[category]} | 優先度: ${normalizePriority(meta.priority, category)} | 標籤: ${tags} | 關聯: ${(meta.relevance || []).join("、") || "-"} | links: ${(meta.links || []).join("、") || "-"} | 總結: ${meta.summary || ""}`);
}
}
if (rows.length) {
lines.push(...rows.slice(0, existingLimit));
if (rows.length > existingLimit) lines.push(`…(既有記憶索引超過 ${existingLimit} 則,已依優先度與更新時間截斷)…`);
} else {
lines.push("(尚無既有記憶)");
}
let text = lines.join("\n");
if (text.length > collectLimit) {
text = `${text.slice(0, collectLimit)}\n…(睡眠整理素材超過 ${collectLimit} 字元預算已截斷,其餘留待下個睡眠週期)…`;
}
process.stdout.write(text);
return 0;
}
function extractJson(text) {
let stripped = String(text || "").trim();
const fence = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) stripped = fence[1].trim();
const start = stripped.indexOf("{");
const end = stripped.lastIndexOf("}");
if (start < 0 || end <= start) return null;
try {
return JSON.parse(stripped.slice(start, end + 1));
} catch {
return null;
}
}
function cmdApply(args) {
const data = extractJson(readStdin());
if (!data || typeof data !== "object" || Array.isArray(data)) {
process.stderr.write("整理結果非合法 JSON\n");
return 1;
}
const entries = data.memories;
if (!Array.isArray(entries) || !entries.length) {
process.stderr.write("整理結果不含 memories\n");
return 1;
}
const root = ensureLayout(args.role);
const stamp = nowStamp();
const counts = { new: 0, merge: 0, drop: 0 };
const consumed = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
let action = String(entry.action || "new").trim().toLowerCase();
const sources = Array.isArray(entry.from) ? entry.from.map((item) => String(item).trim()).filter(Boolean) : [];
if (action === "drop") {
consumed.push(...sources);
counts.drop += 1;
continue;
}
const content = String(entry.content || "").trim().slice(0, CONTENT_LIMIT);
const summary = oneLine(entry.summary);
const tags = normalizeTags(entry.tags);
const entryCategory = normalizeCategory(entry.category);
const priority = normalizePriority(entry.priority, entryCategory);
const relevance = normalizeList(entry.relevance);
const links = normalizeList(entry.links);
const sleepStage = oneLine(entry.sleep_stage || entry.sleepStage || "nrem-rem", 40);
if (!content && !summary) continue;
if (action === "merge") {
const targetId = String(entry.target || "").trim();
const [meta, oldContent] = findMemory(args.role, targetId);
if (!meta) {
action = "new";
} else {
const category = normalizeCategory(entry.category || meta.category);
const newMeta = {
id: meta.id,
category,
summary: summary || meta.summary || "",
tags: normalizeTags([...(meta.tags || []), ...tags]),
priority: Math.max(normalizePriority(meta.priority, category), priority),
relevance: normalizeList([...(meta.relevance || []), ...relevance]),
links: normalizeList([...(meta.links || []), ...links]),
sleep_stage: sleepStage,
created: meta.created || stamp,
updated: stamp,
last_replayed: meta.last_replayed || "",
hits: Number.parseInt(meta.hits || 0, 10) + 1,
};
const oldPath = meta.path;
const newPath = path.join(root, category, `${meta.id}.md`);
fs.writeFileSync(newPath, dumpMemory(newMeta, content || oldContent), "utf8");
if (path.resolve(oldPath) !== path.resolve(newPath)) {
try {
fs.unlinkSync(oldPath);
} catch {}
}
consumed.push(...sources);
counts.merge += 1;
continue;
}
}
const category = entryCategory;
const meta = {
id: newId(content + summary + stamp),
category,
summary: summary || oneLine(content),
tags,
priority,
relevance,
links,
sleep_stage: sleepStage,
created: stamp,
updated: stamp,
hits: 1,
};
fs.writeFileSync(path.join(root, category, `${meta.id}.md`), dumpMemory(meta, content || summary), "utf8");
consumed.push(...sources);
counts.new += 1;
}
let archived = 0;
const d = taipeiDate();
const monthDir = path.join(root, "archive", "raw", `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`);
for (const sourceId of new Set(consumed)) {
const filePath = path.join(root, "inbox", `${sourceId}.md`);
if (fs.existsSync(filePath) && archiveFile(filePath, monthDir)) archived += 1;
}
const patch = { last_sleep: stamp, last_sleep_epoch: nowEpoch() };
if (typeof data.sleepDigest === "string" && data.sleepDigest.trim()) {
patch.last_sleep_digest = oneLine(data.sleepDigest, 300);
}
writeState(args.role, patch);
process.stdout.write(`新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived}`);
return 0;
}
function cmdForget(args) {
const root = ensureLayout(args.role);
const now = new Date();
const forgotten = [];
for (const [category, [days, maxHits]] of Object.entries(FORGET_RULES)) {
for (const [meta] of listMemories(args.role, category)) {
const updated = parseStamp(meta.updated) || parseStamp(meta.created);
if (!updated) continue;
if ((now - updated) / 86400000 < days) continue;
if (Number.parseInt(meta.hits || 0, 10) > maxHits) continue;
if (normalizePriority(meta.priority, category) > 2) continue;
if ((meta.links || []).length) continue;
if (args.dryRun) {
forgotten.push(`${CATEGORY_LABELS[category]}${meta.summary || ""}`);
continue;
}
if (archiveFile(meta.path, path.join(root, "archive", "forgotten"))) {
forgotten.push(`${CATEGORY_LABELS[category]}${meta.summary || ""}`);
}
}
}
if (!forgotten.length) {
process.stdout.write("沒有符合遺忘條件的記憶");
return 0;
}
process.stdout.write(`${args.dryRun ? "(預覽)" : ""}遺忘 ${forgotten.length} 則:\n${forgotten.map((item) => `- ${item}`).join("\n")}`);
if (!args.dryRun) writeState(args.role, { last_forget: nowStamp() });
return 0;
}
function cmdStats(args) {
ensureLayout(args.role);
const state = readState(args.role);
const rows = ["| 分類 | 筆數 | 平均優先度 |", "| --- | --- | --- |"];
for (const category of CATEGORIES) {
const items = listMemories(args.role, category);
let avg = "-";
if (items.length) {
const value = items.reduce((sum, [meta]) => sum + normalizePriority(meta.priority, category), 0) / items.length;
avg = value.toFixed(1);
}
rows.push(`| ${CATEGORY_LABELS[category]} | ${items.length} | ${avg} |`);
}
rows.push(`| 待整理(inbox | ${listInbox(args.role).length} | - |`);
rows.push("");
rows.push(`- 記憶目錄:${memoryRoot(args.role)}`);
rows.push(`- 上次睡眠整理:${state.last_sleep || "尚未整理"}`);
rows.push(`- 上次睡眠摘要:${state.last_sleep_digest || "尚無"}`);
rows.push(`- 上次遺忘:${state.last_forget || "尚未執行"}`);
process.stdout.write(rows.join("\n"));
return 0;
}
function cmdMarkSleep(args) {
writeState(args.role, { last_sleep: nowStamp(), last_sleep_epoch: nowEpoch() });
process.stdout.write("已更新上次整理時間");
return 0;
}
function cmdNeedSleep(args) {
if (!listInbox(args.role).length) {
process.stdout.write("no");
return 0;
}
const last = parseStamp(readState(args.role).last_sleep);
if (!last) {
process.stdout.write("yes");
return 0;
}
process.stdout.write((Date.now() - last.getTime()) / 3600000 >= args.hours ? "yes" : "no");
return 0;
}
function parseArgs(argv) {
const command = argv[0];
const opts = { command };
for (let i = 1; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (key === "dry-run") {
opts.dryRun = true;
} else {
opts[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = argv[i + 1] ?? "";
i += 1;
}
}
return opts;
}
function envInt(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(value) ? value : fallback;
}
function requireRole(args) {
if (!args.role) {
process.stderr.write("缺少 --role\n");
return false;
}
return true;
}
function main(argv) {
const args = parseArgs(argv);
if (!args.command || args.command === "-h" || args.command === "--help") {
process.stdout.write(`用法:memory.js <子命令> [參數]\n\n子命令:write、seed、load、collect、apply、forget、stats、mark-sleep、need-sleep\n`);
return 0;
}
if (!requireRole(args)) return 2;
args.limit = Number.parseInt(args.limit || "", 10);
args.batch = Number.parseInt(args.batch || "", 10);
args.existingLimit = Number.parseInt(args.existingLimit || "", 10);
args.fullMinPriority = Number.parseInt(args.fullMinPriority || "", 10);
args.digestMinPriority = Number.parseInt(args.digestMinPriority || "", 10);
args.hours = Number.parseFloat(args.hours || "");
if (!Number.isFinite(args.limit)) args.limit = args.command === "load" ? envInt("ROLE_LOAD_LIMIT", DEFAULT_LOAD_LIMIT) : envInt("ROLE_SLEEP_COLLECT_LIMIT", COLLECT_LIMIT);
if (!Number.isFinite(args.batch)) args.batch = envInt("ROLE_SLEEP_BATCH", SLEEP_BATCH);
if (!Number.isFinite(args.existingLimit)) args.existingLimit = envInt("ROLE_SLEEP_EXISTING_LIMIT", EXISTING_INDEX_LIMIT);
if (!Number.isFinite(args.fullMinPriority)) args.fullMinPriority = envInt("ROLE_LOAD_FULL_MIN_PRIORITY", DEFAULT_FULL_MIN_PRIORITY);
if (!Number.isFinite(args.digestMinPriority)) args.digestMinPriority = envInt("ROLE_LOAD_DIGEST_MIN_PRIORITY", DEFAULT_DIGEST_MIN_PRIORITY);
if (!Number.isFinite(args.hours)) args.hours = 20.0;
args.category ||= "important";
args.tags ||= "";
args.source ||= "";
args.priority ||= "4";
args.relevance ||= "explicit,background";
args.summary ||= "";
args.project ||= "";
const commands = {
write: cmdWrite,
seed: cmdSeed,
load: cmdLoad,
collect: cmdCollect,
apply: cmdApply,
forget: cmdForget,
stats: cmdStats,
"mark-sleep": cmdMarkSleep,
"need-sleep": cmdNeedSleep,
};
if (!commands[args.command]) {
process.stderr.write("未知子命令\n");
return 2;
}
return commands[args.command](args);
}
process.exit(main(process.argv.slice(2)));
-733
View File
@@ -1,733 +0,0 @@
#!/usr/bin/env python3
# ==============================================================================
# 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
# inbox(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
# 素材並套用整理結果(分類/去重/標籤/總結/壓縮歸檔),(4) 依使用頻率
# 遺忘日常與其他類記憶。
# 更新時間:2026/07/28 11:50:00
# 相依:Python 3 標準庫。
# 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
# ==============================================================================
import argparse
import gzip
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime, timedelta, timezone
# ------------------------------------------------------------------------------
# 常數:分類、載入策略、遺忘規則
# ------------------------------------------------------------------------------
# 六個分類的目錄名(英文,跨平台安全)與中文標籤
CATEGORIES = ["important", "interest", "news", "skill", "daily", "other"]
CATEGORY_LABELS = {
"important": "重要",
"interest": "興趣",
"news": "新知",
"skill": "技能",
"daily": "日常",
"other": "其他",
}
# 中文分類名反查(模型可能直接輸出中文)
LABEL_TO_CATEGORY = {label: key for key, label in CATEGORY_LABELS.items()}
# 載入策略:重要與興趣載入全文,其餘僅載入總結與標籤
FULL_CATEGORIES = ["important", "interest"]
# 摘要載入順序:技能 → 新知 → 日常 → 其他
DIGEST_CATEGORIES = ["skill", "news", "daily", "other"]
# 遺忘規則:(未更新天數門檻, 命中次數上限);只套用於日常與其他
FORGET_RULES = {"daily": (14, 1), "other": (7, 1)}
# 單次睡眠整理最多處理的 inbox 筆數,其餘留待下個睡眠週期
SLEEP_BATCH = 60
# 送進模型的素材字元上限
COLLECT_LIMIT = 40000
# 單則記憶壓縮後的內容字元上限
CONTENT_LIMIT = 1200
TAIPEI = timezone(timedelta(hours=8))
STAMP_FORMAT = "%Y/%m/%d %H:%M:%S"
# ------------------------------------------------------------------------------
# 路徑與時間
# ------------------------------------------------------------------------------
def now_stamp():
"""回傳台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
return datetime.now(TAIPEI).strftime(STAMP_FORMAT)
def parse_stamp(value):
"""把 yyyy/MM/dd HH:mm:ss 字串解析成帶時區的 datetime,失敗回 None。"""
if not isinstance(value, str) or not value.strip():
return None
try:
return datetime.strptime(value.strip(), STAMP_FORMAT).replace(tzinfo=TAIPEI)
except ValueError:
return None
def memory_root(role):
"""回傳指定角色的記憶根目錄(可用 ROLE_MEMORY_HOME 覆寫預設 ~/.memory)。"""
base = os.environ.get("ROLE_MEMORY_HOME") or os.path.join(os.path.expanduser("~"), ".memory")
return os.path.join(base, role)
def ensure_layout(role):
"""建立角色記憶目錄結構(inbox、六個分類、archive),回傳根目錄。"""
root = memory_root(role)
for sub in ["inbox", "archive/raw", "archive/forgotten"] + CATEGORIES:
os.makedirs(os.path.join(root, sub), exist_ok=True)
return root
def state_path(role):
"""回傳角色記憶狀態檔(state.json)的路徑。"""
return os.path.join(memory_root(role), "state.json")
def read_state(role):
"""讀取狀態檔;不存在或損壞時回空 dict。"""
try:
with open(state_path(role), encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def write_state(role, patch):
"""把 patch 併入狀態檔後寫回(整份覆寫,內容極小)。"""
state = read_state(role)
state.update(patch)
ensure_layout(role)
with open(state_path(role), "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
return state
# ------------------------------------------------------------------------------
# 記憶檔格式:YAML 風格 frontmatter 內文
# ------------------------------------------------------------------------------
def normalize_category(value):
"""把模型輸出的分類(英文或中文)正規化為分類鍵;無法判定時回 other。"""
raw = (value or "").strip().lower()
if raw in CATEGORIES:
return raw
return LABEL_TO_CATEGORY.get((value or "").strip(), "other")
def normalize_tags(value):
"""把標籤(list 或逗號分隔字串)正規化為去重後的小寫標籤 list,最多 6 個。"""
if isinstance(value, str):
parts = re.split(r"[,、|]", value)
elif isinstance(value, list):
parts = [str(item) for item in value]
else:
parts = []
tags = []
for part in parts:
tag = part.strip().strip("[]#").strip()
if tag and tag.lower() not in [t.lower() for t in tags]:
tags.append(tag)
return tags[:6]
def one_line(value, limit=120):
"""把文字壓成單行並截斷,用於 summary 欄位。"""
text = re.sub(r"\s+", " ", str(value or "")).strip()
return text[:limit]
def dump_memory(meta, content):
"""把 meta 與內文組成記憶檔全文(frontmatter + 內文)。"""
lines = ["---"]
for key in ["id", "category", "summary", "tags", "created", "updated", "hits", "sources"]:
if key not in meta:
continue
value = meta[key]
if isinstance(value, list):
value = "[" + ", ".join(str(item) for item in value) + "]"
lines.append(f"{key}: {value}")
lines.append("---")
lines.append("")
lines.append(content.strip())
lines.append("")
return "\n".join(lines)
def load_memory(path):
"""讀取單一記憶檔,回傳 (meta dict, 內文);讀取失敗回 (None, "")。"""
try:
with open(path, encoding="utf-8") as fh:
raw = fh.read()
except OSError:
return None, ""
meta = {"path": path, "hits": 0, "tags": []}
body = raw
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
body = parts[2]
for line in parts[1].splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if key in ("tags", "sources"):
meta[key] = normalize_tags(value.strip("[]"))
elif key == "hits":
meta[key] = int(value) if value.isdigit() else 0
else:
meta[key] = value
meta.setdefault("id", os.path.splitext(os.path.basename(path))[0])
meta.setdefault("summary", "")
meta.setdefault("created", "")
meta.setdefault("updated", meta.get("created", ""))
return meta, body.strip()
def new_id(seed):
"""以時間與內容雜湊產生記憶 id,確保同一秒多筆也不碰撞。"""
digest = hashlib.sha1(seed.encode("utf-8", "replace")).hexdigest()[:6]
return f"{datetime.now(TAIPEI).strftime('%Y%m%d-%H%M%S')}-{digest}"
def list_memories(role, category):
"""列出某分類下的所有記憶(依 updated 新到舊排序)。"""
directory = os.path.join(memory_root(role), category)
items = []
if not os.path.isdir(directory):
return items
for name in sorted(os.listdir(directory)):
if not name.endswith(".md"):
continue
meta, content = load_memory(os.path.join(directory, name))
if meta is None:
continue
meta["category"] = category
items.append((meta, content))
items.sort(key=lambda item: item[0].get("updated") or "", reverse=True)
return items
def list_inbox(role):
"""列出 inbox 內尚未整理的記憶(依檔名,即時間先後排序)。"""
directory = os.path.join(memory_root(role), "inbox")
items = []
if not os.path.isdir(directory):
return items
for name in sorted(os.listdir(directory)):
if not name.endswith(".md"):
continue
meta, content = load_memory(os.path.join(directory, name))
if meta is not None:
items.append((meta, content))
return items
def find_memory(role, memory_id):
"""依 id 在六個分類中尋找記憶檔,回傳 (meta, 內文);找不到回 (None, "")。"""
for category in CATEGORIES:
path = os.path.join(memory_root(role), category, f"{memory_id}.md")
if os.path.isfile(path):
meta, content = load_memory(path)
if meta is not None:
meta["category"] = category
return meta, content
return None, ""
def archive_file(path, destination_dir):
"""把檔案 gzip 後搬到歸檔目錄,原檔刪除;失敗時保留原檔。"""
os.makedirs(destination_dir, exist_ok=True)
target = os.path.join(destination_dir, os.path.basename(path) + ".gz")
try:
with open(path, "rb") as src, gzip.open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
os.remove(path)
return True
except OSError:
return False
# ------------------------------------------------------------------------------
# 子命令:write —— 由 Stop hook 寫入一則未整理記憶
# ------------------------------------------------------------------------------
FIELD_PATTERN = re.compile(r"^\s*(CATEGORY|SUMMARY|TAGS|CONTENT)\s*[:]\s*(.*)$", re.IGNORECASE)
def parse_capture(text):
"""
解析 Stop hook 濃縮器輸出的四欄格式(CATEGORYSUMMARYTAGSCONTENT)。
模型可能夾帶前後贅字,故逐行掃描欄位標記,CONTENT 之後的所有內容視為內文。
"""
category = summary = ""
tags = []
content_lines = []
in_content = False
for line in text.splitlines():
match = FIELD_PATTERN.match(line)
if match and not (in_content and match.group(1).upper() != "CONTENT"):
field = match.group(1).upper()
value = match.group(2)
if field == "CATEGORY":
category = value
elif field == "SUMMARY":
summary = value
elif field == "TAGS":
tags = normalize_tags(value)
elif field == "CONTENT":
in_content = True
if value.strip():
content_lines.append(value)
continue
if in_content:
content_lines.append(line)
return category, summary, tags, "\n".join(content_lines).strip()
def cmd_write(args):
"""把 stdin 的濃縮結果寫成一則 inbox 記憶。"""
raw = sys.stdin.read()
category, summary, tags, content = parse_capture(raw)
if not content and not summary:
return 1
if not content:
content = summary
content = content[:CONTENT_LIMIT]
stamp = now_stamp()
meta = {
"id": new_id(content + stamp),
"category": normalize_category(category),
"summary": one_line(summary) or one_line(content),
"tags": tags,
"created": stamp,
"updated": stamp,
"hits": 1,
}
if args.project:
meta["sources"] = [args.project]
ensure_layout(args.role)
path = os.path.join(memory_root(args.role), "inbox", f"{meta['id']}.md")
with open(path, "w", encoding="utf-8") as fh:
fh.write(dump_memory(meta, content))
sys.stdout.write(meta["id"])
return 0
# ------------------------------------------------------------------------------
# 子命令:seed —— 新建角色時寫入已整理的初始記憶
# ------------------------------------------------------------------------------
def cmd_seed(args):
"""把 stdin 寫成一則已整理記憶,用於新建角色時灌入背景資料。"""
content = sys.stdin.read().strip()
if not content and not args.summary:
return 1
if not content:
content = args.summary
content = content[:CONTENT_LIMIT]
stamp = now_stamp()
category = normalize_category(args.category)
meta = {
"id": new_id(content + args.summary + stamp),
"category": category,
"summary": one_line(args.summary) or one_line(content),
"tags": normalize_tags(args.tags),
"created": stamp,
"updated": stamp,
"hits": 1,
}
if args.source:
meta["sources"] = [args.source]
ensure_layout(args.role)
path = os.path.join(memory_root(args.role), category, f"{meta['id']}.md")
with open(path, "w", encoding="utf-8") as fh:
fh.write(dump_memory(meta, content))
sys.stdout.write(meta["id"])
return 0
# ------------------------------------------------------------------------------
# 子命令:load —— 產生 SessionStart 要注入的記憶區塊
# ------------------------------------------------------------------------------
def cmd_load(args):
"""
組出載入用記憶區塊:重要與興趣載入全文,其餘依技能→新知→日常→其他只載總結與標籤。
超過字元上限時截斷並標明,避免佔滿 context。
"""
limit = args.limit
blocks = []
total_full = 0
for category in FULL_CATEGORIES:
items = list_memories(args.role, category)
if not items:
continue
lines = [f"### {CATEGORY_LABELS[category]}記憶(全文)"]
for meta, content in items:
tags = "".join(meta.get("tags") or []) or "無標籤"
lines.append(f"- **{meta.get('summary') or '(無總結)'}**(標籤:{tags}")
for line in content.splitlines():
if line.strip():
lines.append(f" {line.strip()}")
total_full += 1
blocks.append("\n".join(lines))
digest_lines = []
digest_count = 0
for category in DIGEST_CATEGORIES:
items = list_memories(args.role, category)
if not items:
continue
digest_lines.append(f"### {CATEGORY_LABELS[category]}記憶(總結)")
for meta, _ in items:
tags = "".join(meta.get("tags") or []) or "無標籤"
digest_lines.append(f"- {meta.get('summary') or '(無總結)'}(標籤:{tags}")
digest_count += 1
if digest_lines:
blocks.append("\n".join(digest_lines))
pending = len(list_inbox(args.role))
if pending:
blocks.append(f"> 尚有 {pending} 則未整理記憶,將於下次睡眠時段歸檔。")
if not blocks:
return 1
text = "\n\n".join(blocks)
if len(text) > limit:
text = text[:limit] + f"\n\n> (記憶內容超過 {limit} 字元已截斷,完整記憶仍保存在磁碟)"
sys.stdout.write(text)
return 0
# ------------------------------------------------------------------------------
# 子命令:collect —— 睡眠整理前輸出待整理素材
# ------------------------------------------------------------------------------
def cmd_collect(args):
"""輸出送進模型的整理素材:inbox 待整理項目 + 既有記憶索引(供去重比對)。"""
inbox = list_inbox(args.role)[:SLEEP_BATCH]
if not inbox:
return 1
lines = ["=== INBOX(待整理,每則以 id 標識)==="]
for meta, content in inbox:
lines.append(f"--- id: {meta['id']} | 時間: {meta.get('created', '-')} ---")
lines.append(f"初判分類: {CATEGORY_LABELS.get(meta.get('category', 'other'), '其他')}")
lines.append(f"初判總結: {meta.get('summary', '')}")
lines.append(f"初判標籤: {''.join(meta.get('tags') or []) or ''}")
lines.append("內容:")
lines.append(content)
lines.append("")
lines.append("=== EXISTING(既有記憶索引,供去重與合併判斷)===")
existing = 0
for category in CATEGORIES:
for meta, _ in list_memories(args.role, category):
tags = "".join(meta.get("tags") or []) or ""
lines.append(
f"- id: {meta['id']} | 分類: {CATEGORY_LABELS[category]} | 標籤: {tags} | 總結: {meta.get('summary', '')}"
)
existing += 1
if not existing:
lines.append("(尚無既有記憶)")
text = "\n".join(lines)
if len(text) > COLLECT_LIMIT:
text = text[:COLLECT_LIMIT] + "\n…(素材過長已截斷,其餘留待下個睡眠週期)…"
sys.stdout.write(text)
return 0
# ------------------------------------------------------------------------------
# 子命令:apply —— 套用睡眠整理結果
# ------------------------------------------------------------------------------
def extract_json(text):
"""從模型輸出中取出第一個 JSON 物件(容忍 code fence 與前後贅字)。"""
stripped = text.strip()
fence = re.search(r"```(?:json)?\s*(.*?)```", stripped, re.DOTALL)
if fence:
stripped = fence.group(1).strip()
start = stripped.find("{")
end = stripped.rfind("}")
if start < 0 or end <= start:
return None
try:
return json.loads(stripped[start : end + 1])
except ValueError:
return None
def cmd_apply(args):
"""
讀取 stdin 的整理結果 JSON,寫入分類記憶並歸檔對應的 inbox 原始檔。
action 支援 new(新建)/merge(併入既有記憶)/drop(判定無保存價值)。
未被提及的 inbox 檔一律保留,留待下個睡眠週期,避免整理失敗造成記憶遺失。
"""
data = extract_json(sys.stdin.read())
if not isinstance(data, dict):
sys.stderr.write("整理結果非合法 JSON\n")
return 1
entries = data.get("memories")
if not isinstance(entries, list) or not entries:
sys.stderr.write("整理結果不含 memories\n")
return 1
ensure_layout(args.role)
root = memory_root(args.role)
stamp = now_stamp()
counts = {"new": 0, "merge": 0, "drop": 0}
consumed = []
for entry in entries:
if not isinstance(entry, dict):
continue
action = str(entry.get("action") or "new").strip().lower()
sources = [str(item).strip() for item in (entry.get("from") or []) if str(item).strip()]
if action == "drop":
consumed.extend(sources)
counts["drop"] += 1
continue
content = str(entry.get("content") or "").strip()[:CONTENT_LIMIT]
summary = one_line(entry.get("summary"))
tags = normalize_tags(entry.get("tags"))
if not content and not summary:
continue
if action == "merge":
target_id = str(entry.get("target") or "").strip()
meta, old_content = find_memory(args.role, target_id)
if meta is None:
action = "new"
else:
category = normalize_category(entry.get("category") or meta.get("category"))
merged_tags = normalize_tags((meta.get("tags") or []) + tags)
new_meta = {
"id": meta["id"],
"category": category,
"summary": summary or meta.get("summary", ""),
"tags": merged_tags,
"created": meta.get("created") or stamp,
"updated": stamp,
"hits": int(meta.get("hits") or 0) + 1,
}
old_path = meta["path"]
new_path = os.path.join(root, category, f"{meta['id']}.md")
with open(new_path, "w", encoding="utf-8") as fh:
fh.write(dump_memory(new_meta, content or old_content))
if os.path.abspath(old_path) != os.path.abspath(new_path):
try:
os.remove(old_path)
except OSError:
pass
consumed.extend(sources)
counts["merge"] += 1
continue
category = normalize_category(entry.get("category"))
meta = {
"id": new_id(content + summary + stamp),
"category": category,
"summary": summary or one_line(content),
"tags": tags,
"created": stamp,
"updated": stamp,
"hits": 1,
}
with open(os.path.join(root, category, f"{meta['id']}.md"), "w", encoding="utf-8") as fh:
fh.write(dump_memory(meta, content or summary))
consumed.extend(sources)
counts["new"] += 1
archived = 0
month_dir = os.path.join(root, "archive", "raw", datetime.now(TAIPEI).strftime("%Y-%m"))
for source_id in set(consumed):
path = os.path.join(root, "inbox", f"{source_id}.md")
if os.path.isfile(path) and archive_file(path, month_dir):
archived += 1
write_state(args.role, {"last_sleep": stamp, "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
sys.stdout.write(
f"新增 {counts['new']} 則、合併 {counts['merge']} 則、捨棄 {counts['drop']} 則、歸檔原始記憶 {archived}"
)
return 0
# ------------------------------------------------------------------------------
# 子命令:forget —— 依使用頻率遺忘日常與其他
# ------------------------------------------------------------------------------
def cmd_forget(args):
"""把日常/其他分類中久未更新且命中次數低的記憶壓縮到 archive/forgotten 後移除。"""
root = ensure_layout(args.role)
now = datetime.now(TAIPEI)
forgotten = []
for category, (days, max_hits) in FORGET_RULES.items():
for meta, _ in list_memories(args.role, category):
updated = parse_stamp(meta.get("updated")) or parse_stamp(meta.get("created"))
if updated is None:
continue
if (now - updated).days < days:
continue
if int(meta.get("hits") or 0) > max_hits:
continue
if args.dry_run:
forgotten.append(f"{CATEGORY_LABELS[category]}{meta.get('summary', '')}")
continue
if archive_file(meta["path"], os.path.join(root, "archive", "forgotten")):
forgotten.append(f"{CATEGORY_LABELS[category]}{meta.get('summary', '')}")
if not forgotten:
sys.stdout.write("沒有符合遺忘條件的記憶")
return 0
prefix = "(預覽)" if args.dry_run else ""
sys.stdout.write(f"{prefix}遺忘 {len(forgotten)} 則:\n" + "\n".join(f"- {item}" for item in forgotten))
if not args.dry_run:
write_state(args.role, {"last_forget": now_stamp()})
return 0
# ------------------------------------------------------------------------------
# 子命令:stats —— 供 skill 與診斷顯示記憶概況
# ------------------------------------------------------------------------------
def cmd_stats(args):
"""輸出記憶統計(各分類筆數、待整理筆數、上次整理時間)。"""
ensure_layout(args.role)
state = read_state(args.role)
rows = [f"| 分類 | 筆數 |", "| --- | --- |"]
for category in CATEGORIES:
rows.append(f"| {CATEGORY_LABELS[category]} | {len(list_memories(args.role, category))} |")
rows.append(f"| 待整理(inbox | {len(list_inbox(args.role))} |")
rows.append("")
rows.append(f"- 記憶目錄:{memory_root(args.role)}")
rows.append(f"- 上次睡眠整理:{state.get('last_sleep', '尚未整理')}")
rows.append(f"- 上次遺忘:{state.get('last_forget', '尚未執行')}")
sys.stdout.write("\n".join(rows))
return 0
# ------------------------------------------------------------------------------
# 子命令:need-sleep —— 判斷是否需要補跑睡眠整理
# ------------------------------------------------------------------------------
def cmd_mark_sleep(args):
"""把本次睡眠週期標記為已整理(inbox 為空、無素材可整理時使用)。"""
write_state(args.role, {"last_sleep": now_stamp(), "last_sleep_epoch": int(datetime.now(TAIPEI).timestamp())})
sys.stdout.write("已更新上次整理時間")
return 0
def cmd_need_sleep(args):
"""
判斷是否需要補跑整理:距上次整理超過門檻小時數且 inbox 有內容。
輸出 yes/no,供 shell 直接判斷(不用解析 JSON)。
"""
if not list_inbox(args.role):
sys.stdout.write("no")
return 0
state = read_state(args.role)
last = parse_stamp(state.get("last_sleep"))
if last is None:
sys.stdout.write("yes")
return 0
hours = (datetime.now(TAIPEI) - last).total_seconds() / 3600
sys.stdout.write("yes" if hours >= args.hours else "no")
return 0
# ------------------------------------------------------------------------------
# CLI
# ------------------------------------------------------------------------------
def build_parser():
"""建立子命令解析器。"""
parser = argparse.ArgumentParser(description="角色記憶儲存引擎")
sub = parser.add_subparsers(dest="command", required=True)
write = sub.add_parser("write", help="自 stdin 讀濃縮結果寫入 inbox")
write.add_argument("--role", required=True)
write.add_argument("--project", default="")
write.set_defaults(func=cmd_write)
seed = sub.add_parser("seed", help="自 stdin 寫入已整理的初始記憶")
seed.add_argument("--role", required=True)
seed.add_argument("--category", default="important")
seed.add_argument("--summary", required=True)
seed.add_argument("--tags", default="")
seed.add_argument("--source", default="")
seed.set_defaults(func=cmd_seed)
load = sub.add_parser("load", help="輸出 SessionStart 要注入的記憶區塊")
load.add_argument("--role", required=True)
load.add_argument("--limit", type=int, default=int(os.environ.get("ROLE_LOAD_LIMIT", "8000")))
load.set_defaults(func=cmd_load)
collect = sub.add_parser("collect", help="輸出睡眠整理素材")
collect.add_argument("--role", required=True)
collect.set_defaults(func=cmd_collect)
apply_cmd = sub.add_parser("apply", help="自 stdin 讀整理結果 JSON 並套用")
apply_cmd.add_argument("--role", required=True)
apply_cmd.set_defaults(func=cmd_apply)
forget = sub.add_parser("forget", help="依使用頻率遺忘日常與其他記憶")
forget.add_argument("--role", required=True)
forget.add_argument("--dry-run", action="store_true")
forget.set_defaults(func=cmd_forget)
stats = sub.add_parser("stats", help="輸出記憶統計")
stats.add_argument("--role", required=True)
stats.set_defaults(func=cmd_stats)
mark = sub.add_parser("mark-sleep", help="標記本次睡眠週期已整理")
mark.add_argument("--role", required=True)
mark.set_defaults(func=cmd_mark_sleep)
need = sub.add_parser("need-sleep", help="判斷是否需要補跑睡眠整理")
need.add_argument("--role", required=True)
need.add_argument("--hours", type=float, default=20.0)
need.set_defaults(func=cmd_need_sleep)
return parser
def main(argv):
"""CLI 進入點。"""
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+46 -30
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # ==============================================================================
# 用途:Stop hook 主程式。每輪對話結束後抽出本輪內容 → 呼叫 headless CLI 濃縮成 # 用途:Stop hook 主程式。每輪對話結束後先用本地規則判斷是否值得記錄;
# 一則記憶(分類/總結/標籤/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/ # 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(分類/總結/
# 等待睡眠時段整理。睡眠時段雖不載入角色,對話仍照常記錄。 # 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/
# 更新時間:2026/07/28 00:00:00 # 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。
# 相依:bash、python3、任一 headless CLI、同目錄的 role_lib.shmemory.pytranscript.py。 # 更新時間:2026/07/28 12:21:11
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.py redact 遮蔽一次 # 相依:bash、node、任一 headless CLI、同目錄的 role_lib.shmemory.jstranscript.js
# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。
# 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。
# ============================================================================== # ==============================================================================
@@ -16,7 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
role_is_child && exit 0 role_is_child && exit 0
role_enabled || exit 0 role_enabled || exit 0
command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過記憶記錄" "WRN" command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過記憶記錄" "WRN"
ROLE="$(role_resolve_name)" ROLE="$(role_resolve_name)"
[ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄" [ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄"
@@ -32,18 +33,20 @@ HOOK_INPUT="$(cat)"
[ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN"
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK
$(printf '%s' "$HOOK_INPUT" | python3 -c ' $(printf '%s' "$HOOK_INPUT" | node -e '
import json, sys let raw = "";
try: process.stdin.setEncoding("utf8");
d = json.load(sys.stdin) process.stdin.on("data", (chunk) => { raw += chunk; });
except ValueError: process.stdin.on("end", () => {
d = {} let d = {};
print( try { d = JSON.parse(raw); } catch {}
d.get("session_id") or d.get("thread_id") or d.get("conversation_id") or "-", process.stdout.write([
d.get("transcript_path") or d.get("session_path") or d.get("conversation_path") or d.get("path") or "-", d.session_id || d.thread_id || d.conversation_id || "-",
"1" if d.get("stop_hook_active") else "0", d.transcript_path || d.session_path || d.conversation_path || d.path || "-",
d.get("cwd", "") or "-", d.stop_hook_active ? "1" : "0",
) d.cwd || "-",
].join(" "));
});
') ')
EOF_HOOK EOF_HOOK
@@ -56,24 +59,36 @@ if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then
fi fi
[ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript${TRANSCRIPT_PATH}" "WRN" [ -f "$TRANSCRIPT_PATH" ] || role_quit "找不到 transcript${TRANSCRIPT_PATH}" "WRN"
TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)" TURN="$(node "${SCRIPT_DIR}/transcript.js" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
[ -n "$TURN" ] || role_quit "本輪無可記錄內容" [ -n "$TURN" ] || role_quit "本輪無可記錄內容"
PROJECT="$(role_project_name "$HOOK_CWD")" PROJECT="$(role_project_name "$HOOK_CWD")"
CAPTURE_MIN_CHARS="${ROLE_CAPTURE_MIN_CHARS:-240}"
CAPTURE_TIMEOUT="${ROLE_CAPTURE_TIMEOUT:-25}"
if [ "${ROLE_CAPTURE_ENABLED:-1}" = "0" ]; then
role_quit "ROLE_CAPTURE_ENABLED=0,略過記憶記錄"
fi
if [ "${#TURN}" -lt "$CAPTURE_MIN_CHARS" ] && ! printf '%s' "$TURN" | grep -qiE '記住|remember|決定|規範|偏好|preference|always|不要|以後'; then
role_quit "本輪低於記憶長度門檻且無明確記憶線索,略過記錄"
fi
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 濃縮:產出一則記憶(四欄固定格式),交由 memory.py 落檔 # 濃縮:產出一則輕量 inbox 記憶,交由 memory.js 落檔;完整整理留到睡眠週期
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
PROMPT="$(cat <<EOF_PROMPT PROMPT="$(cat <<EOF_PROMPT
你是角色「${ROLE}」的記憶記錄器。輸入是這位角色與使用者的一段對話(含工具呼叫)。 你是角色「${ROLE}」的記憶記錄器。輸入是這位角色與使用者的一段對話(含工具呼叫)。
把這段對話濃縮成「一則記憶」,規則: 只做「編碼前處理」,把這段對話濃縮成最多一則 inbox 記憶;不要做跨記憶合併或長期整理。
已判定專案:${PROJECT} 已判定專案:${PROJECT}
1. 只輸出下列四個欄位,欄位名稱與順序固定,不要標題、不要前言、不要結語、不要 code fence: 1. 只輸出下列欄位,欄位名稱與順序固定,不要標題、不要前言、不要結語、不要 code fence:
CATEGORY: <六選一:importantinterestnewsskilldailyother> CATEGORY: <六選一:importantinterestnewsskilldailyother>
SUMMARY: <一句話總結,40 字內> SUMMARY: <一句話總結,40 字內>
TAGS: <2 至 4 個標籤,以逗號分隔> TAGS: <2 至 4 個標籤,以逗號分隔>
PRIORITY: <1 到 5>
RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project>
CONTENT: <3 至 6 行要點,每行以「- 」開頭> CONTENT: <3 至 6 行要點,每行以「- 」開頭>
2. 分類判準: 2. 分類判準:
- important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。 - important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。
@@ -82,17 +97,18 @@ CONTENT: <3 至 6 行要點,每行以「- 」開頭>
- skill(技能):可重複套用的做法、指令、流程、除錯手法。 - skill(技能):可重複套用的做法、指令、流程、除錯手法。
- daily(日常):一次性的例行工作與雜項處理。 - daily(日常):一次性的例行工作與雜項處理。
- other(其他):不屬於上述任何一類。 - other(其他):不屬於上述任何一類。
3. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文 3. 優先度判準:5=使用者明確要求記住、長期規範、穩定偏好;4=可重複套用的流程/技能/決策;3=專案相關且未來可能有用;2=短期進度;1=低價值暫存
4. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號 4. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文
5. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 5. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。
6. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論),只輸出一行:SKIP 6. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
7. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP
對話片段: 對話片段:
${TURN} ${TURN}
EOF_PROMPT EOF_PROMPT
)" )"
RESULT="$(role_run_cli "$CLI" "$PROMPT" 45)" RESULT="$(role_run_cli "$CLI" "$PROMPT" "$CAPTURE_TIMEOUT")"
if [ -z "$RESULT" ]; then if [ -z "$RESULT" ]; then
role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪" role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪"
exit 0 exit 0
@@ -100,9 +116,9 @@ fi
printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容" printf '%s' "$RESULT" | grep -qiE '^\s*SKIP\s*$' && role_quit "判定本輪無值得記住的內容"
# 第二道防線:對模型輸出再遮蔽一次機密與個資 # 第二道防線:對模型輸出再遮蔽一次機密與個資
RESULT="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)" RESULT="$(printf '%s' "$RESULT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
MEMORY_ID="$(printf '%s' "$RESULT" | python3 "${SCRIPT_DIR}/memory.py" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)" MEMORY_ID="$(printf '%s' "$RESULT" | node "${SCRIPT_DIR}/memory.js" write --role "$ROLE" --project "$PROJECT" 2>/dev/null)"
if [ -n "$MEMORY_ID" ]; then if [ -n "$MEMORY_ID" ]; then
role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT}CLI ${CLI}" role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT}CLI ${CLI}"
else else
+2 -2
View File
@@ -3,8 +3,8 @@
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 # 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 # 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
# 本檔僅供 source,不可直接執行。 # 本檔僅供 source,不可直接執行。
# 更新時間:2026/07/28 00:00:00 # 更新時間:2026/07/28 12:21:11
# 相依:bash、python3;摘要路徑需 README 定義的任一 headless CLI。 # 相依:bash;摘要路徑需 README 定義的任一 headless CLI。
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。 # 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
# ============================================================================== # ==============================================================================
+27 -23
View File
@@ -4,8 +4,8 @@
# 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; # 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤;
# 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 # 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。
# 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 # 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。
# 更新時間:2026/07/28 11:28:29 # 更新時間:2026/07/28 12:21:11
# 相依:bash、python3、同目錄的 role_lib.sh 與 memory.py # 相依:bash、node、同目錄的 role_lib.sh 與 memory.js
# 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。
# ============================================================================== # ==============================================================================
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
role_is_child && exit 0 role_is_child && exit 0
role_enabled || exit 0 role_enabled || exit 0
command -v python3 >/dev/null 2>&1 || role_quit "找不到 python3,略過角色載入" "WRN" command -v node >/dev/null 2>&1 || role_quit "找不到 node,略過角色載入" "WRN"
ROLE="$(role_resolve_name)" ROLE="$(role_resolve_name)"
[ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入" [ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入"
@@ -29,27 +29,31 @@ ROLE_DEF="$(role_file "$ROLE")"
HOOK_INPUT="$(cat 2>/dev/null)" HOOK_INPUT="$(cat 2>/dev/null)"
HOOK_CWD="$PWD" HOOK_CWD="$PWD"
if [ -n "$HOOK_INPUT" ]; then if [ -n "$HOOK_INPUT" ]; then
HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | python3 -c ' HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | node -e '
import json, sys let raw = "";
try: process.stdin.setEncoding("utf8");
data = json.load(sys.stdin) process.stdin.on("data", (chunk) => { raw += chunk; });
except ValueError: process.stdin.on("end", () => {
data = {} let data = {};
print(data.get("cwd") or "") try { data = JSON.parse(raw); } catch {}
process.stdout.write(data.cwd || "");
});
' 2>/dev/null)" ' 2>/dev/null)"
[ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD"
fi fi
role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}"
emit_context() { emit_context() {
# 以 JSON 輸出 additionalContext(由 python 負責跳脫,避免內容含引號或換行破壞格式) # 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式)
printf '%s' "$1" | python3 -c ' printf '%s' "$1" | node -e '
import json, sys let context = "";
context = sys.stdin.read() process.stdin.setEncoding("utf8");
print(json.dumps( process.stdin.on("data", (chunk) => { context += chunk; });
{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}, process.stdin.on("end", () => {
ensure_ascii=False, process.stdout.write(JSON.stringify({
)) hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
}));
});
' '
} }
@@ -77,11 +81,11 @@ fi
DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)" DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)"
[ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN" [ -n "$DEFINITION" ] || role_quit "角色定義檔為空:${ROLE_DEF}" "WRN"
MEMORY="$(python3 "${SCRIPT_DIR}/memory.py" load --role "$ROLE" 2>/dev/null)" MEMORY="$(node "${SCRIPT_DIR}/memory.js" load --role "$ROLE" 2>/dev/null)"
# 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理 # 補跑判斷:cron 未執行(例如 WSL 沒開 cron 服務)時,白天啟動 CLI 補做一次整理
CATCHUP_NOTE="" CATCHUP_NOTE=""
if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" = "yes" ]; then
nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 & nohup "${SCRIPT_DIR}/role_sleep.sh" --catchup >/dev/null 2>&1 &
CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n' CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n'
role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE}" role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE}"
@@ -105,13 +109,13 @@ ${DEFINITION}
${MEMORY:-(尚無已整理的記憶。)} ${MEMORY:-(尚無已整理的記憶。)}
${CATCHUP_NOTE} ${CATCHUP_NOTE}
> 記憶載入規則:重要與興趣記憶載入全文;技能、新知、日常、其他僅載入總結與標籤, > 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT
> 需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。 > 字元預算限制;需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。
> 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事, > 主動補記:每輪對話結束後系統會自動記錄記憶,不需你動手。但若使用者明確要求記住某件事,
> 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔): > 或你察覺到值得長期記住的偏好、決策、規範,可執行下列指令補一則記憶(下次睡眠時整理歸檔):
> >
> \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | python3 "${SCRIPT_DIR}/memory.py" write --role "${ROLE}"\` > \`printf 'CATEGORY: important\nSUMMARY: <一句話總結>\nTAGS: <標籤1,標籤2>\nCONTENT:\n- <要點>\n' | node "${SCRIPT_DIR}/memory.js" write --role "${ROLE}"\`
> >
> CATEGORY 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。 > CATEGORY 六選一:importantinterestnewsskilldailyother。切勿把憑證或個資寫進記憶。
EOF_CONTEXT EOF_CONTEXT
+31 -23
View File
@@ -2,13 +2,13 @@
# ============================================================================== # ==============================================================================
# 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run), # 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run),
# 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶: # 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶:
# 分類(重要/興趣/新知/技能/日常/其他)→ 去重合併 → 設標籤與一句話 # NREM 鞏固(分類/去噪/去重/合併/優先度)→ REM 整合(跨記憶
# 總結 → 壓縮內容歸檔 → 日常與其他依使用頻率遺忘。 # 連結/抽象化/提取線索)→ 壓縮歸檔 → 日常與其他依使用頻率與優先度遺忘。
# 另提供 --catchupcron 未執行時的補跑)、--force(手動立即整理)、 # 另提供 --catchupcron 未執行時的補跑)、--force(手動立即整理)、
# --install-cron--remove-cron(排程安裝與移除)、--status(狀態)。 # --install-cron--remove-cron(排程安裝與移除)、--status(狀態)。
# 更新時間:2026/07/28 00:00:00 # 更新時間:2026/07/28 12:21:11
# 相依:bash、python3、任一 headless CLI、crontab(僅排程安裝需要)、 # 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
# 同目錄的 role_lib.sh 與 memory.py # 同目錄的 role_lib.sh 與 memory.js
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。 # 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
# ============================================================================== # ==============================================================================
@@ -19,6 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CRON_MARKER="# jsc-role-sleep" CRON_MARKER="# jsc-role-sleep"
SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}" SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}"
SLEEP_OUTPUT_LIMIT="${ROLE_SLEEP_OUTPUT_LIMIT:-8000}"
usage() { usage() {
# 印出用法 # 印出用法
@@ -46,9 +47,9 @@ require_role() {
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
sleep_cycle() { sleep_cycle() {
# 執行一次完整記憶整理:收集素材 → 模型分類去重 → 落檔歸檔 → 遺忘 # 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
local reason="$1" cli material prompt result applied forgotten local reason="$1" cli material prompt result applied forgotten
command -v python3 >/dev/null 2>&1 || { role_log "ERR" "找不到 python3,無法整理記憶"; return 1; } command -v node >/dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; }
if ! role_lock_acquire "$ROLE"; then if ! role_lock_acquire "$ROLE"; then
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE}" role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE}"
@@ -56,11 +57,11 @@ sleep_cycle() {
fi fi
trap 'role_lock_release "$ROLE"' EXIT trap 'role_lock_release "$ROLE"' EXIT
material="$(python3 "${SCRIPT_DIR}/memory.py" collect --role "$ROLE" 2>/dev/null)" material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
if [ -z "$material" ]; then if [ -z "$material" ]; then
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason}" role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason}"
python3 "${SCRIPT_DIR}/memory.py" mark-sleep --role "$ROLE" >/dev/null 2>&1 node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1
forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null)" forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)"
role_log "INF" "遺忘檢查:${forgotten}" role_log "INF" "遺忘檢查:${forgotten}"
role_lock_release "$ROLE" role_lock_release "$ROLE"
trap - EXIT trap - EXIT
@@ -71,21 +72,28 @@ sleep_cycle() {
prompt="$(cat <<EOF_PROMPT prompt="$(cat <<EOF_PROMPT
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。 你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
把 INBOX 整理成歸檔用的記憶,規則: 模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為: 1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"content":"- 要點\n- 要點","from":["inbox 的 id"]}]} {"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
2. action 三選一: 2. NREM 鞏固階段先做:去除雜訊與流水帳、遮蔽憑證與個資、分類、去重、合併、壓縮成可長期保存的穩定記憶。
3. REM 整合階段再做:找出新記憶與 EXISTING 的關聯,抽出可重複套用的規則、偏好、決策模式、角色語氣調整或未來提取線索。
4. action 三選一:
- new:新的一則記憶。多則 INBOX 講同一件事時合成一筆,from 列出全部來源 id。 - new:新的一則記憶。多則 INBOX 講同一件事時合成一筆,from 列出全部來源 id。
- merge:內容已被 EXISTING 中某則涵蓋或重複,填 target 為該既有 id,content 寫合併後的完整內容。 - merge:內容已被 EXISTING 中某則涵蓋或重複,填 target 為該既有 id,content 寫合併後的完整內容。
- drop:純雜訊、無保存價值,只需填 from。 - drop:純雜訊、無保存價值,只需填 from。
3. category 六選一:important(重要)/interest(興趣)/news(新知)/skill(技能)/daily(日常)/other(其他)。 5. category 六選一:important(重要)/interest(興趣)/news(新知)/skill(技能)/daily(日常)/other(其他)。
important 放長期偏好、規範、決策與身分背景;interest 放反覆關注的主題;news 放新事實與外部資訊; important 放長期偏好、規範、決策與身分背景;interest 放反覆關注的主題;news 放新事實與外部資訊;
skill 放可重複套用的做法;daily 放一次性例行工作;其餘歸 other。 skill 放可重複套用的做法;daily 放一次性例行工作;其餘歸 other。
4. **每一則 INBOX 的 id 都必須出現在某一筆的 from 中**,沒被提及的會留到下個睡眠週期重做 6. priority 必填,1 到 5:5=使用者明確要求、長期規範、穩定偏好或核心身分;4=可重複套用的技能/決策;3=有用新知;2=短期日常;1=低價值但暫存
5. content 壓縮成 5 行以內要點(每行以「- 」開頭),總長不超過 400 字,去除重複敘述與流水帳 7. relevance 必填 1 至 4 個,從下列語意挑選或用等價繁中詞:explicit(使用者明確要求)、future(未來會用)、repeated(反覆出現)、novelty(新知)、emotional(語氣/情緒/偏好)、temporary(短期)
6. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語) 8. links 可填 EXISTING 中相關記憶 id;沒有就填空陣列。merge 時若有舊 links,應保留並加上新關聯
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號 9. sleep_stage 填 "nrem"、"rem" 或 "nrem-rem"。只有純分類去噪用 nrem;有建立跨記憶連結或抽象規則用 rem 或 nrem-rem
10. **每一則 INBOX 的 id 都必須出現在某一筆的 from 中**,沒被提及的會留到下個睡眠週期重做。
11. content 壓縮成 5 行以內要點(每行以「- 」開頭),總長不超過 400 字,去除重複敘述與流水帳。
12. summary 一句話 40 字內;tags 2 至 4 個。全部使用繁體中文(台灣用語)。
13. sleepDigest 總結本次新增、合併、丟棄、抽象化或建立關聯的重點,80 字內。
14. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
素材: 素材:
${material} ${material}
@@ -100,8 +108,8 @@ EOF_PROMPT
return 1 return 1
fi fi
result="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)" result="$(printf '%s' "$result" | head -c "$SLEEP_OUTPUT_LIMIT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
applied="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/memory.py" apply --role "$ROLE" 2>/dev/null)" applied="$(printf '%s' "$result" | node "${SCRIPT_DIR}/memory.js" apply --role "$ROLE" 2>/dev/null)"
if [ -z "$applied" ]; then if [ -z "$applied" ]; then
role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期" role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期"
role_lock_release "$ROLE" role_lock_release "$ROLE"
@@ -110,7 +118,7 @@ EOF_PROMPT
fi fi
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}" role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null | tr '\n' '')" forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null | tr '\n' '')"
role_log "INF" "遺忘檢查:${forgotten}" role_log "INF" "遺忘檢查:${forgotten}"
role_lock_release "$ROLE" role_lock_release "$ROLE"
@@ -201,7 +209,7 @@ show_status() {
printf '| cron 服務 | %s |\n' "$cron_service" printf '| cron 服務 | %s |\n' "$cron_service"
printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')" printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')"
printf '\n' printf '\n'
python3 "${SCRIPT_DIR}/memory.py" stats --role "$ROLE" 2>/dev/null node "${SCRIPT_DIR}/memory.js" stats --role "$ROLE" 2>/dev/null
printf '\n' printf '\n'
} }
@@ -227,7 +235,7 @@ case "$MODE" in
--catchup) --catchup)
role_enabled || exit 0 role_enabled || exit 0
require_role require_role
if [ "$(python3 "${SCRIPT_DIR}/memory.py" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then if [ "$(node "${SCRIPT_DIR}/memory.js" need-sleep --role "$ROLE" 2>/dev/null)" != "yes" ]; then
role_log "DBG" "不需補跑整理" role_log "DBG" "不需補跑整理"
exit 0 exit 0
fi fi
+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)));
-314
View File
@@ -1,314 +0,0 @@
#!/usr/bin/env python3
# ==============================================================================
# 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude CodeCodex
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
# 作為寫入記憶檔前的第二道防線。
# 更新時間:2026/07/28 00:00:00
# 相依:Python 3 標準庫。抽取與遮蔽全程僅走 stdin/stdout,本檔不寫任何檔案。
# ==============================================================================
import json
import re
import sys
from datetime import datetime, timezone
# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
TOOL_RESULT_LIMIT = 200
TOOL_INPUT_LIMIT = 160
TOTAL_LIMIT = 24000
# ------------------------------------------------------------------------------
# 機密遮蔽規則:命中一律換成 ***
# ------------------------------------------------------------------------------
REDACT_PATTERNS = [
(r"[A-Za-z0-9_\-]*:[A-Za-z0-9_\-]{16,}@", "***@"), # URL 內嵌憑證 user:token@
(r"\b[0-9a-f]{40}\b", "***"), # Gitea 40 字元 token
(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b", "***"), # GitHub token
(r"\bsk-[A-Za-z0-9\-_]{16,}\b", "***"), # API key
(r"(?i)\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+", r"\1=***"),
(r"(?i)Authorization:\s*(token|bearer)\s+\S+", r"Authorization: \1 ***"),
(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "***"), # Email
(r"\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b", "***"), # 台灣手機
(r"\b[A-Z][12]\d{8}\b", "***"), # 身分證字號
]
def redact(text):
"""對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。"""
for pattern, replacement in REDACT_PATTERNS:
text = re.sub(pattern, replacement, text)
return text
def _is_real_user_message(entry):
"""判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。"""
payload = entry.get("payload")
if isinstance(payload, dict) and entry.get("type") == "event_msg":
return payload.get("type") == "user_message" and bool(str(payload.get("message") or "").strip())
if entry.get("type") != "user":
return False
content = entry.get("message", {}).get("content")
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(b.get("type") == "text" for b in content if isinstance(b, dict))
return False
def _blocks(entry):
"""取出條目的 content blocks,統一為 list 形式。"""
content = entry.get("message", {}).get("content")
if isinstance(content, str):
return [{"type": "text", "text": content}]
return content if isinstance(content, list) else []
def _payload_text_blocks(content):
"""把 Codex response_item 的 content blocks 轉成純文字片段。"""
if isinstance(content, str):
return [content]
if not isinstance(content, list):
return []
texts = []
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") in ("input_text", "output_text", "text"):
text = (block.get("text") or "").strip()
if text:
texts.append(text)
return texts
def _render_codex_payload(entry):
"""將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。"""
payload = entry.get("payload")
if not isinstance(payload, dict):
return []
lines = []
entry_type = entry.get("type")
payload_type = payload.get("type")
if entry_type == "event_msg":
if payload_type == "user_message":
message = (payload.get("message") or "").strip()
if message:
lines.append(f"[user] {message}")
elif payload_type == "agent_message":
message = (payload.get("message") or "").strip()
if message:
phase = payload.get("phase") or "assistant"
lines.append(f"[assistant:{phase}] {message}")
return lines
if entry_type != "response_item":
return lines
if payload_type == "message":
role = payload.get("role") or "assistant"
if role in ("system", "developer"):
return lines
for text in _payload_text_blocks(payload.get("content")):
# Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。
if role == "user" and text.lstrip().startswith("<skill>"):
continue
if role == "user" and text.lstrip().startswith("<environment_context>"):
continue
lines.append(f"[{role}] {text}")
elif payload_type == "function_call":
name = payload.get("name") or "?"
raw = str(payload.get("arguments") or "").strip().replace("\n", " ")
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
elif payload_type == "function_call_output":
raw = str(payload.get("output") or "").strip().replace("\n", " ")
if raw:
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
return lines
def _render(entry):
"""將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。"""
codex_lines = _render_codex_payload(entry)
if codex_lines:
return codex_lines
role = entry.get("type")
lines = []
for block in _blocks(entry):
if not isinstance(block, dict):
continue
kind = block.get("type")
if kind == "text":
text = (block.get("text") or "").strip()
if text:
lines.append(f"[{role}] {text}")
elif kind == "tool_use":
name = block.get("name", "?")
raw = json.dumps(block.get("input", {}), ensure_ascii=False)
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
elif kind == "tool_result":
raw = block.get("content")
if isinstance(raw, list):
raw = " ".join(
b.get("text", "") for b in raw if isinstance(b, dict) and b.get("type") == "text"
)
raw = str(raw or "").strip().replace("\n", " ")
if raw:
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
return lines
def _read_entries(path):
"""讀取 transcript JSONL,忽略無法解析的列。"""
try:
with open(path, encoding="utf-8") as fh:
entries = []
for line in fh:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except ValueError:
continue
except OSError:
return []
return entries
def _turn_start_index(entries):
"""找出本輪起點:最後一筆真正使用者訊息的位置。"""
start = 0
for index in range(len(entries) - 1, -1, -1):
if _is_real_user_message(entries[index]):
start = index
break
return start
def _parse_timestamp(value):
"""解析常見 transcript timestamp 格式,失敗回 None。"""
if not isinstance(value, str) or not value.strip():
return None
raw = value.strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(raw)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _entry_timestamp(entry):
"""取出 transcript 條目的時間欄位。"""
for key in ("timestamp", "created_at", "time"):
dt = _parse_timestamp(entry.get(key))
if dt:
return dt
message = entry.get("message")
if isinstance(message, dict):
for key in ("timestamp", "created_at", "time"):
dt = _parse_timestamp(message.get(key))
if dt:
return dt
return None
def format_duration(seconds):
"""把秒數格式化為精簡中文耗時。"""
if seconds < 0:
return "未判定"
minutes = int(round(seconds / 60))
if minutes <= 0:
return "1 分鐘內"
hours, mins = divmod(minutes, 60)
if hours and mins:
return f"{hours} 小時 {mins} 分鐘"
if hours:
return f"{hours} 小時"
return f"{mins} 分鐘"
def turn_duration(path):
"""
估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。
transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。
"""
entries = _read_entries(path)
if not entries:
return "未判定"
start = _turn_start_index(entries)
stamps = [dt for dt in (_entry_timestamp(e) for e in entries[start:]) if dt]
if len(stamps) < 2:
return "未判定"
return format_duration((max(stamps) - min(stamps)).total_seconds())
def extract_turn(path):
"""
從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。
不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。
回傳純文字字串;讀取失敗或無內容時回空字串。
"""
entries = _read_entries(path)
if not entries:
return ""
start = _turn_start_index(entries)
lines = []
for entry in entries[start:]:
lines.extend(_render(entry))
text = "\n".join(lines).strip()
if len(text) > TOTAL_LIMIT:
head = text[: TOTAL_LIMIT // 2]
tail = text[-TOTAL_LIMIT // 2 :]
text = f"{head}\n…(中段省略)…\n{tail}"
return text
USAGE = """用法:transcript.py <子命令> [參數]
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
"""
def main(argv):
"""CLI 進入點:解析子命令並執行抽取或遮蔽。"""
if not argv or argv[0] in ("-h", "--help"):
print(USAGE)
return 0
if argv[0] == "extract":
if len(argv) < 2:
return 2
text = extract_turn(argv[1])
if not text:
return 1
sys.stdout.write(redact(text))
return 0
if argv[0] == "duration":
if len(argv) < 2:
return 2
sys.stdout.write(turn_duration(argv[1]))
return 0
if argv[0] == "redact":
sys.stdout.write(redact(sys.stdin.read()))
return 0
print(USAGE)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))