diff --git a/scripts/role/memory.js b/scripts/role/memory.js new file mode 100755 index 0000000..3ea03ab --- /dev/null +++ b/scripts/role/memory.js @@ -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))); diff --git a/scripts/role/memory.py b/scripts/role/memory.py deleted file mode 100755 index cc25ae5..0000000 --- a/scripts/role/memory.py +++ /dev/null @@ -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 濃縮器輸出的四欄格式(CATEGORY/SUMMARY/TAGS/CONTENT)。 - - 模型可能夾帶前後贅字,故逐行掃描欄位標記,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:])) diff --git a/scripts/role/role_capture.sh b/scripts/role/role_capture.sh index f554b63..4f9d583 100755 --- a/scripts/role/role_capture.sh +++ b/scripts/role/role_capture.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash # ============================================================================== -# 用途:Stop hook 主程式。每輪對話結束後抽出本輪內容 → 呼叫 headless CLI 濃縮成 -# 一則記憶(分類/總結/標籤/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, -# 等待睡眠時段整理。睡眠時段雖不載入角色,對話仍照常記錄。 -# 更新時間:2026/07/28 00:00:00 -# 相依:bash、python3、任一 headless CLI、同目錄的 role_lib.sh/memory.py/transcript.py。 -# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.py redact 遮蔽一次。 +# 用途:Stop hook 主程式。每輪對話結束後先用本地規則判斷是否值得記錄; +# 值得記錄時才呼叫 headless CLI 輕量濃縮成一則 inbox 記憶(粗分類/總結/ +# 標籤/優先度/關聯/要點)→ 機密遮蔽 → 寫入 .memory/<角色>/inbox/, +# 等待睡眠時段做完整 NREM/REM 整理。睡眠時段雖不載入角色,對話仍照常記錄。 +# 更新時間:2026/07/28 12:21:11 +# 相依:bash、node、任一 headless CLI、同目錄的 role_lib.sh/memory.js/transcript.js。 +# 機密:濃縮提示詞明令不得輸出憑證與個資,寫檔前再以 transcript.js redact 遮蔽一次。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者流程。 # ============================================================================== @@ -16,7 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" role_is_child && 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)" [ -n "$ROLE" ] || role_quit "未指定角色,略過記憶記錄" @@ -32,18 +33,20 @@ HOOK_INPUT="$(cat)" [ -n "$HOOK_INPUT" ] || role_quit "hook 輸入為空,略過記憶記錄" "WRN" read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD < { raw += chunk; }); +process.stdin.on("end", () => { + let d = {}; + try { d = JSON.parse(raw); } catch {} + process.stdout.write([ + d.session_id || d.thread_id || d.conversation_id || "-", + d.transcript_path || d.session_path || d.conversation_path || d.path || "-", + d.stop_hook_active ? "1" : "0", + d.cwd || "-", + ].join(" ")); +}); ') EOF_HOOK @@ -56,24 +59,36 @@ if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then fi [ -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 "本輪無可記錄內容" 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 < SUMMARY: <一句話總結,40 字內> TAGS: <2 至 4 個標籤,以逗號分隔> +PRIORITY: <1 到 5> +RELEVANCE: <1 至 4 個,以逗號分隔;explicit/future/repeated/novelty/emotional/temporary/inbox/project> CONTENT: <3 至 6 行要點,每行以「- 」開頭> 2. 分類判準: - important(重要):使用者的長期偏好、規範、決策、身分背景、明確要求記住的事。 @@ -82,17 +97,18 @@ CONTENT: <3 至 6 行要點,每行以「- 」開頭> - skill(技能):可重複套用的做法、指令、流程、除錯手法。 - daily(日常):一次性的例行工作與雜項處理。 - other(其他):不屬於上述任何一類。 -3. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。 -4. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。 -5. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 -6. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論),只輸出一行:SKIP +3. 優先度判準:5=使用者明確要求記住、長期規範、穩定偏好;4=可重複套用的流程/技能/決策;3=專案相關且未來可能有用;2=短期進度;1=低價值暫存。 +4. 記憶主體是「使用者與這段互動」,不是流水帳:寫值得下次記起來的事,不要抄程式碼、不要貼指令全文。 +5. 使用繁體中文(台灣用語),保留關鍵事實:檔案/專案/指令/數量/分支/議題編號。 +6. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 +7. 若這段對話沒有任何值得記住的內容(純寒暄、純確認、無結論、只有簡短狀態回報),只輸出一行:SKIP 對話片段: ${TURN} EOF_PROMPT )" -RESULT="$(role_run_cli "$CLI" "$PROMPT" 45)" +RESULT="$(role_run_cli "$CLI" "$PROMPT" "$CAPTURE_TIMEOUT")" if [ -z "$RESULT" ]; then role_log "WRN" "記憶濃縮產出為空(CLI ${CLI}),略過本輪" exit 0 @@ -100,9 +116,9 @@ fi 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 role_log "INF" "已記錄記憶 ${MEMORY_ID}(角色 ${ROLE},專案 ${PROJECT},CLI ${CLI})" else diff --git a/scripts/role/role_lib.sh b/scripts/role/role_lib.sh index 699542f..c8d6573 100755 --- a/scripts/role/role_lib.sh +++ b/scripts/role/role_lib.sh @@ -3,8 +3,8 @@ # 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、 # 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。 # 本檔僅供 source,不可直接執行。 -# 更新時間:2026/07/28 00:00:00 -# 相依:bash、python3;摘要路徑需 README 定義的任一 headless CLI。 +# 更新時間:2026/07/28 12:21:11 +# 相依:bash;摘要路徑需 README 定義的任一 headless CLI。 # 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。 # ============================================================================== diff --git a/scripts/role/role_load.sh b/scripts/role/role_load.sh index ce1a325..2e65d14 100755 --- a/scripts/role/role_load.sh +++ b/scripts/role/role_load.sh @@ -4,8 +4,8 @@ # 非睡眠時段注入角色定義+重要/興趣記憶全文+其餘記憶的總結與標籤; # 睡眠時段(預設 22:00 至隔日 06:00)只回報角色正在睡覺,不載入角色。 # 白天發現昨夜未整理記憶時,於背景補跑一次睡眠整理。 -# 更新時間:2026/07/28 11:28:29 -# 相依:bash、python3、同目錄的 role_lib.sh 與 memory.py。 +# 更新時間:2026/07/28 12:21:11 +# 相依:bash、node、同目錄的 role_lib.sh 與 memory.js。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者啟動 CLI。 # ============================================================================== @@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" role_is_child && 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)" [ -n "$ROLE" ] || role_quit "未指定角色(ROLE_NAME 與 .active 皆無),略過角色載入" @@ -29,27 +29,31 @@ ROLE_DEF="$(role_file "$ROLE")" HOOK_INPUT="$(cat 2>/dev/null)" HOOK_CWD="$PWD" if [ -n "$HOOK_INPUT" ]; then - HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | python3 -c ' -import json, sys -try: - data = json.load(sys.stdin) -except ValueError: - data = {} -print(data.get("cwd") or "") + HOOK_CWD="$(printf '%s' "$HOOK_INPUT" | node -e ' +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { raw += chunk; }); +process.stdin.on("end", () => { + let data = {}; + try { data = JSON.parse(raw); } catch {} + process.stdout.write(data.cwd || ""); +}); ' 2>/dev/null)" [ -n "$HOOK_CWD" ] || HOOK_CWD="$PWD" fi role_in_scope "$HOOK_CWD" || role_quit "cwd 不在 ROLE_SCOPE 範圍內:${HOOK_CWD}" emit_context() { - # 以 JSON 輸出 additionalContext(由 python 負責跳脫,避免內容含引號或換行破壞格式) - printf '%s' "$1" | python3 -c ' -import json, sys -context = sys.stdin.read() -print(json.dumps( - {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}, - ensure_ascii=False, -)) + # 以 JSON 輸出 additionalContext(由 node 負責跳脫,避免內容含引號或換行破壞格式) + printf '%s' "$1" | node -e ' +let context = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { context += chunk; }); +process.stdin.on("end", () => { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context }, + })); +}); ' } @@ -77,11 +81,11 @@ fi DEFINITION="$(cat "$ROLE_DEF" 2>/dev/null)" [ -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 補做一次整理 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 & CATCHUP_NOTE=$'\n> 偵測到上個睡眠時段未整理記憶,已在背景補跑整理,結果會在下次載入時反映。\n' role_log "INF" "已於背景補跑記憶整理(角色 ${ROLE})" @@ -105,13 +109,13 @@ ${DEFINITION} ${MEMORY:-(尚無已整理的記憶。)} ${CATCHUP_NOTE} -> 記憶載入規則:重要與興趣記憶載入全文;技能、新知、日常、其他僅載入總結與標籤, -> 需要細節時可自行讀取 $(role_memory_home)/${ROLE}/ 下對應分類的記憶檔。 +> 記憶載入規則:為節省模型額度,只載入高優先度全文與中高優先度摘要,並受 ROLE_LOAD_LIMIT +> 字元預算限制;需要細節時可自行讀取 $(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 六選一:important/interest/news/skill/daily/other。切勿把憑證或個資寫進記憶。 EOF_CONTEXT diff --git a/scripts/role/role_sleep.sh b/scripts/role/role_sleep.sh index 0fa2578..c80a351 100755 --- a/scripts/role/role_sleep.sh +++ b/scripts/role/role_sleep.sh @@ -2,13 +2,13 @@ # ============================================================================== # 用途:角色的睡眠與記憶整理。由 cron 於睡眠時段每小時觸發(--run), # 先檢查是否有 AI 正在運行,沒有才進入睡眠並整理記憶: -# 分類(重要/興趣/新知/技能/日常/其他)→ 去重合併 → 設標籤與一句話 -# 總結 → 壓縮內容歸檔 → 日常與其他依使用頻率遺忘。 +# NREM 鞏固(分類/去噪/去重/合併/優先度)→ REM 整合(跨記憶 +# 連結/抽象化/提取線索)→ 壓縮歸檔 → 日常與其他依使用頻率與優先度遺忘。 # 另提供 --catchup(cron 未執行時的補跑)、--force(手動立即整理)、 # --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。 -# 更新時間:2026/07/28 00:00:00 -# 相依:bash、python3、任一 headless CLI、crontab(僅排程安裝需要)、 -# 同目錄的 role_lib.sh 與 memory.py。 +# 更新時間:2026/07/28 12:21:11 +# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、 +# 同目錄的 role_lib.sh 與 memory.js。 # 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。 # ============================================================================== @@ -19,6 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CRON_MARKER="# jsc-role-sleep" SLEEP_TIMEOUT="${ROLE_SLEEP_TIMEOUT:-180}" +SLEEP_OUTPUT_LIMIT="${ROLE_SLEEP_OUTPUT_LIMIT:-8000}" usage() { # 印出用法 @@ -46,9 +47,9 @@ require_role() { # ------------------------------------------------------------------------------ sleep_cycle() { - # 執行一次完整記憶整理:收集素材 → 模型分類去重 → 落檔歸檔 → 遺忘 + # 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘 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 role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE})" @@ -56,11 +57,11 @@ sleep_cycle() { fi 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 role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason})" - python3 "${SCRIPT_DIR}/memory.py" mark-sleep --role "$ROLE" >/dev/null 2>&1 - forgotten="$(python3 "${SCRIPT_DIR}/memory.py" forget --role "$ROLE" 2>/dev/null)" + node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1 + forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)" role_log "INF" "遺忘檢查:${forgotten}" role_lock_release "$ROLE" trap - EXIT @@ -71,21 +72,28 @@ sleep_cycle() { prompt="$(cat </dev/null)" - applied="$(printf '%s' "$result" | python3 "${SCRIPT_DIR}/memory.py" apply --role "$ROLE" 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" | node "${SCRIPT_DIR}/memory.js" apply --role "$ROLE" 2>/dev/null)" if [ -z "$applied" ]; then role_log "ERR" "整理結果無法套用(角色 ${ROLE}),保留待整理記憶到下個週期" role_lock_release "$ROLE" @@ -110,7 +118,7 @@ EOF_PROMPT fi 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_lock_release "$ROLE" @@ -201,7 +209,7 @@ show_status() { printf '| cron 服務 | %s |\n' "$cron_service" printf '| 摘要 CLI | %s |\n' "$(role_select_cli 2>/dev/null || printf '找不到可用 CLI')" 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' } @@ -227,7 +235,7 @@ case "$MODE" in --catchup) role_enabled || exit 0 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" "不需補跑整理" exit 0 fi diff --git a/scripts/role/transcript.js b/scripts/role/transcript.js new file mode 100755 index 0000000..f3ed8fc --- /dev/null +++ b/scripts/role/transcript.js @@ -0,0 +1,257 @@ +#!/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 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("")) continue; + if (role === "user" && text.trimStart().startsWith("")) 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 抽出本輪內容並遮蔽機密後輸出到 stdout + duration 估算本輪花費時間,無法判定時輸出「未判定」 + 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))); diff --git a/scripts/role/transcript.py b/scripts/role/transcript.py deleted file mode 100755 index 9068d26..0000000 --- a/scripts/role/transcript.py +++ /dev/null @@ -1,314 +0,0 @@ -#!/usr/bin/env python3 -# ============================================================================== -# 用途:角色記憶的 transcript 處理工具。負責 (1) 從 Claude Code/Codex -# 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(""): - continue - if role == "user" and text.lstrip().startswith(""): - 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 抽出本輪內容並遮蔽機密後輸出到 stdout - duration 估算本輪花費時間,無法判定時輸出「未判定」 - 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:]))