#!/usr/bin/env node // ============================================================================== // 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入 // inbox,(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理 // 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、 // 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。 // 更新時間:2026/07/28 14:36:00 // 相依: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 MEMORY_TYPES = ["semantic", "episodic", "procedural", "emotional", "preference", "rule"]; const MEMORY_TYPE_LABELS = { semantic: "語意", episodic: "情節", procedural: "程序", emotional: "情緒", preference: "偏好", rule: "規範", }; const DEFAULT_MEMORY_TYPE = { important: "preference", interest: "emotional", news: "semantic", skill: "procedural", daily: "episodic", other: "semantic", }; const MEMORY_TYPE_WEIGHT = { rule: 50, preference: 45, procedural: 35, semantic: 30, emotional: 25, episodic: 10, }; const DECLARATIVE_VALUES = ["explicit", "implicit"]; const RETENTION_STAGES = ["working", "long_term"]; 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 normalizeMemoryType(value, category = "other") { const raw = String(value || "").trim().toLowerCase().replace(/-/g, "_"); const alias = { explicit: "semantic", declarative: "semantic", implicit: "procedural", non_declarative: "procedural", nondeclarative: "procedural", semantic_memory: "semantic", episodic_memory: "episodic", procedural_memory: "procedural", emotion: "emotional", affective: "emotional", pref: "preference", preference_memory: "preference", policy: "rule", guideline: "rule", 規範: "rule", 偏好: "preference", 程序: "procedural", 技能: "procedural", 語意: "semantic", 知識: "semantic", 情節: "episodic", 事件: "episodic", 情緒: "emotional", }; const normalized = alias[raw] || alias[String(value || "").trim()] || raw; if (MEMORY_TYPES.includes(normalized)) return normalized; return DEFAULT_MEMORY_TYPE[normalizeCategory(category)] || "semantic"; } function normalizeDeclarative(value, memoryType = "semantic") { const raw = String(value || "").trim().toLowerCase().replace(/-/g, "_"); const alias = { declarative: "explicit", explicit_memory: "explicit", non_declarative: "implicit", nondeclarative: "implicit", implicit_memory: "implicit", 外顯: "explicit", 陳述性: "explicit", 內隱: "implicit", 非陳述性: "implicit", }; const normalized = alias[raw] || alias[String(value || "").trim()] || raw; if (DECLARATIVE_VALUES.includes(normalized)) return normalized; return ["procedural", "emotional"].includes(memoryType) ? "implicit" : "explicit"; } function normalizeRetentionStage(value, fallback = "long_term") { const raw = String(value || "").trim().toLowerCase().replace(/-/g, "_"); const alias = { short_term: "working", working_memory: "working", inbox: "working", encoding: "working", long: "long_term", longterm: "long_term", long_term_memory: "long_term", 短期: "working", 工作記憶: "working", 長期: "long_term", }; const normalized = alias[raw] || alias[String(value || "").trim()] || raw; if (RETENTION_STAGES.includes(normalized)) return normalized; return fallback; } 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", "memory_type", "declarative", "retention_stage", "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"); meta.memory_type = normalizeMemoryType(meta.memory_type, meta.category || "other"); meta.declarative = normalizeDeclarative(meta.declarative, meta.memory_type); const retentionFallback = filePath.includes(`${path.sep}inbox${path.sep}`) || meta.sleep_stage === "encoding" ? "working" : "long_term"; meta.retention_stage = normalizeRetentionStage(meta.retention_stage, retentionFallback); 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), MEMORY_TYPE_WEIGHT[am.memory_type] || 0, am.links?.length ? 1 : 0, am.updated || "", ]; const bv = [ normalizePriority(bm.priority, category), MEMORY_TYPE_WEIGHT[bm.memory_type] || 0, 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("、") || "-"; const type = MEMORY_TYPE_LABELS[meta.memory_type] || meta.memory_type || "語意"; return `優先度:${normalizePriority(meta.priority, meta.category)};型態:${type}/${meta.declarative || "explicit"};關聯:${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|MEMORY_TYPE|DECLARATIVE|RETENTION_STAGE|CONTENT)\s*[::]\s*(.*)$/i; function parseCapture(text) { let category = ""; let summary = ""; let priority = null; let tags = []; let relevance = []; let memoryType = ""; let declarative = ""; let retentionStage = ""; 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 === "MEMORY_TYPE") memoryType = value; else if (field === "DECLARATIVE") declarative = value; else if (field === "RETENTION_STAGE") retentionStage = 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, memoryType, declarative, retentionStage, 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 memoryType = normalizeMemoryType(parsed.memoryType, parsed.category); 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: [], memory_type: memoryType, declarative: normalizeDeclarative(parsed.declarative, memoryType), retention_stage: "working", 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 memoryType = normalizeMemoryType(args.memoryType, 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: [], memory_type: memoryType, declarative: normalizeDeclarative(args.declarative, memoryType), retention_stage: "long_term", 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); const durableType = ["rule", "preference", "procedural"].includes(meta.memory_type); if (priority < args.digestMinPriority && !(meta.links || []).length && !durableType) 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) { if (pending >= args.batch) { blocks.push(`> 尚有 ${pending} 則未整理記憶,已達一批睡眠整理量;請以角色語氣主動提醒使用者「想睡覺」或需要整理記憶。這是建議整理/歸檔的提醒,不代表停止協助。`); } else { 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(`初判記憶型態: ${meta.memory_type} / ${meta.declarative} / ${meta.retention_stage}`); 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)} | 型態: ${meta.memory_type}/${meta.declarative}/${meta.retention_stage} | 標籤: ${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 memoryType = normalizeMemoryType(entry.memory_type || entry.memoryType, entryCategory); const declarative = normalizeDeclarative(entry.declarative, memoryType); const retentionStage = normalizeRetentionStage(entry.retention_stage || entry.retentionStage, "long_term"); 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 mergedMemoryType = normalizeMemoryType(entry.memory_type || entry.memoryType || meta.memory_type, 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]), memory_type: mergedMemoryType, declarative: normalizeDeclarative(entry.declarative || meta.declarative, mergedMemoryType), retention_stage: normalizeRetentionStage(entry.retention_stage || entry.retentionStage || meta.retention_stage, "long_term"), 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, memory_type: memoryType, declarative, retention_stage: retentionStage, 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; const ageDays = (now - updated) / 86400000; const effectiveDays = meta.memory_type === "episodic" ? Math.max(3, Math.ceil(days / 2)) : days; if (ageDays < effectiveDays) continue; if (Number.parseInt(meta.hits || 0, 10) > maxHits) continue; if (normalizePriority(meta.priority, category) > 2) continue; if ((meta.links || []).length) continue; if (["rule", "preference", "procedural"].includes(meta.memory_type)) 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 = ["| 分類 | 筆數 | 平均優先度 |", "| --- | --- | --- |"]; const typeCounts = Object.fromEntries(MEMORY_TYPES.map((type) => [type, 0])); for (const category of CATEGORIES) { const items = listMemories(args.role, category); for (const [meta] of items) typeCounts[meta.memory_type] = (typeCounts[meta.memory_type] || 0) + 1; 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(`- 個人記憶同意狀態:${consentStatusValue(args.role)}`); rows.push(`- 上次互動:${state.last_activity || "尚未記錄"}`); rows.push(`- 上次睡眠整理:${state.last_sleep || "尚未整理"}`); rows.push(`- 上次睡眠摘要:${state.last_sleep_digest || "尚無"}`); rows.push(`- 上次遺忘:${state.last_forget || "尚未執行"}`); rows.push(`- 記憶型態:${MEMORY_TYPES.map((type) => `${MEMORY_TYPE_LABELS[type]} ${typeCounts[type] || 0}`).join("、")}`); 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 cmdMarkActivity(args) { const patch = { last_activity: nowStamp(), last_activity_epoch: nowEpoch() }; if (args.project) patch.last_activity_project = args.project; writeState(args.role, patch); 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 cmdNeedNap(args) { const pending = listInbox(args.role).length; if (pending < args.minInbox) { process.stdout.write("no"); return 0; } const state = readState(args.role); const lastEpoch = Number.parseInt(state.last_activity_epoch || "", 10); let lastMs = Number.isFinite(lastEpoch) && lastEpoch > 0 ? lastEpoch * 1000 : null; if (lastMs === null) { const last = parseStamp(state.last_activity); lastMs = last ? last.getTime() : null; } if (lastMs === null) { process.stdout.write("no"); return 0; } const idleMinutes = (Date.now() - lastMs) / 60000; process.stdout.write(idleMinutes >= args.idleMinutes ? "yes" : "no"); return 0; } function cmdConsent(args) { const value = String(args.value || "").trim().toLowerCase(); const normalized = { accept: "accepted", accepted: "accepted", yes: "accepted", true: "accepted", "1": "accepted", decline: "declined", declined: "declined", no: "declined", false: "declined", "0": "declined", unknown: "unknown", }[value]; if (!normalized) { process.stderr.write("同意狀態只接受 accepted/declined/unknown\n"); return 2; } writeState(args.role, { personal_memory_consent: normalized, personal_memory_consent_updated: nowStamp(), }); process.stdout.write(normalized); return 0; } function cmdConsentStatus(args) { process.stdout.write(consentStatusValue(args.role)); return 0; } function consentStatusValue(role) { ensureLayout(role); const state = readState(role); const status = String(state.personal_memory_consent || "unknown").trim(); if (["accepted", "declined"].includes(status)) return status; const haystacks = []; for (const [meta, content] of listInbox(role)) haystacks.push(`${meta.summary}\n${content}`); for (const category of CATEGORIES) { for (const [meta, content] of listMemories(role, category)) haystacks.push(`${meta.summary}\n${content}`); } const consentText = haystacks .filter((text) => /個人資料|個資|記憶|記住|保存|同意|拒絕/.test(text)) .join("\n"); if (/不同意|不願意|不要保存|不要記住|拒絕|不可以保存|不可以記住/.test(consentText)) { return "declined"; } if (/已同意|明確同意|同意.*保存|同意.*記住|可以保存|可以記住|願意.*保存|願意.*記住/.test(consentText)) { return "accepted"; } return "unknown"; } 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、mark-activity、need-sleep、need-nap、consent、consent-status\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 || ""); args.idleMinutes = Number.parseFloat(args.idleMinutes || ""); args.minInbox = Number.parseInt(args.minInbox || "", 10); 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; if (!Number.isFinite(args.idleMinutes)) args.idleMinutes = 45.0; if (!Number.isFinite(args.minInbox)) args.minInbox = 3; args.category ||= "important"; args.tags ||= ""; args.source ||= ""; args.priority ||= "4"; args.relevance ||= "explicit,background"; args.memoryType ||= ""; args.declarative ||= ""; args.summary ||= ""; args.project ||= ""; const commands = { write: cmdWrite, seed: cmdSeed, load: cmdLoad, collect: cmdCollect, apply: cmdApply, forget: cmdForget, stats: cmdStats, "mark-sleep": cmdMarkSleep, "mark-activity": cmdMarkActivity, "need-sleep": cmdNeedSleep, "need-nap": cmdNeedNap, consent: cmdConsent, "consent-status": cmdConsentStatus, }; if (!commands[args.command]) { process.stderr.write("未知子命令\n"); return 2; } return commands[args.command](args); } process.exit(main(process.argv.slice(2)));