#!/usr/bin/env node // ============================================================================== // 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入 // inbox,(2) 產生 SessionStart 要注入的記憶區塊(含未整理 inbox 的近期工作 // 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理 // 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、 // 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。 // 更新時間:2026/07/29 17:16:12 // 相依: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", }; // 載入排序(依 ROLE_LOAD_LIMIT 字元預算取前段)與 recall 評分都用這組權重。 // // emotional 刻意排在 procedural 之上,episodic 也拉到與 semantic 同級:使用者明確表示 // 最重視 emotional/episodic/semantic 三型態所承載的情感內涵與溫度。原本 episodic 只有 10, // 等於把「我們一起經歷過什麼」排在所有規則與流程之後,最先被字元預算截掉。 // 至於「哪些事件值得留」則不靠型態決定,而由 hasWarmth()(relevance 是否帶情緒關聯)區分, // 讓沒有溫度的一次性工作進度仍然照原規則淡去。 const MEMORY_TYPE_WEIGHT = { rule: 50, preference: 45, emotional: 40, procedural: 35, semantic: 30, episodic: 30, }; const DECLARATIVE_VALUES = ["explicit", "implicit"]; const RETENTION_STAGES = ["working", "long_term"]; // 單批上限刻意壓在 12:每則整理結果約需 650 字元(summary/content/各欄位), // 而 SLEEP_OUTPUT_LIMIT 預設 8000,8000÷650≈12。原本預設 60 與輸出上限矛盾 —— // 實測 25 則的素材達 25798 位元組、輸出 JSON 被截斷成不合法格式,整批整理直接失敗。 // 寧可分多批各自成功,也不要一次做完卻全部失敗。 const SLEEP_BATCH = 12; 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; // 1200 是「只注入 summary 一行」時代的額度;改成注入全文後,單則約 300~600 字元, // 1200 只夠兩則就開始丟最舊的。3600 約可容納 6~8 則,足以覆蓋一次睡眠整理週期內的工作。 const DEFAULT_LOAD_INBOX_LIMIT = 3600; const DEFAULT_LOAD_INBOX_COUNT = 10; // 關係史注入額度:每則約 40~80 字元,1200 約可容納 15~25 則,足以撐起一段關係的近期輪廓。 const DEFAULT_LOAD_BONDS_LIMIT = 1200; const DEFAULT_LOAD_BONDS_COUNT = 15; 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 hasWarmth(meta) { // 「有溫度」= relevance 標了情緒關聯。使用者最重視的是 emotional/episodic/semantic // 三型態裡的情感內涵,但不是所有事件與知識都該優待 —— 一次性工作進度本來就該淡去。 // 因此判準放在 relevance 而非型態:帶情緒關聯的記憶,不論型態都在排序與保留上優先, // 沒有情緒關聯的一次性進度仍照原規則遺忘。 const values = Array.isArray(meta?.relevance) ? meta.relevance.map(String) : []; return values.some((item) => /emotional|情緒|情感|溫度/i.test(item)) ? 1 : 0; } 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", "cues", "memory_type", "declarative", "retention_stage", "expires", "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" || key === "cues") 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.cues ||= []; meta.expires ||= ""; 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), hasWarmth(am), MEMORY_TYPE_WEIGHT[am.memory_type] || 0, am.links?.length ? 1 : 0, am.updated || "", ]; const bv = [ normalizePriority(bm.priority, category), hasWarmth(bm), 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; } // 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 全文。 // inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup), // 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。 // // 為什麼要注入 body 而不只是 summary:summary 是一句話的標題,只夠讓角色知道「有這件事」, // 講不出「進行到哪、還差什麼、下一步是什麼」——實測重開後角色仍得自己去翻 inbox 檔案才答得出來, // 等於交接失效。長期記憶的「(全文)」區塊本來就會注入 body,inbox 更近、更該給。 // 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。 function inboxBlock(role, count, limit) { if (limit <= 0 || count <= 0) return ""; const items = listInbox(role); if (!items.length) return ""; // 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可 const alive = items.filter(([meta]) => !expiryState(meta).expired); const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前 const header = "### 近期工作記憶(未整理,最新在前,含全文)"; const lines = [header]; let used = header.length; let dropped = 0; for (const [meta, content] of recent) { const when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--"; const tags = (meta.tags || []).join("、"); const entry = [`- ${when} **${meta.summary || "(無總結)"}**${tags ? `(${tags})` : ""}`]; for (const line of String(content || "").split(/\r?\n/)) { if (line.trim()) entry.push(` ${line.trim()}`); } let chunk = entry.join("\n"); // 逐則計費而非最後整段硬切:整段 slice 會把最舊那則砍成半句,讀起來像壞掉的資料。 // 最新一則永遠保留(必要時只截它自己的內文),其餘超出預算就整則略過並在結尾誠實計數。 if (used + 1 + chunk.length > limit) { if (lines.length > 1) { dropped += 1; continue; } chunk = `${chunk.slice(0, Math.max(0, limit - used - 1))}…(本則內文已截斷)`; } lines.push(chunk); used += 1 + chunk.length; } if (dropped) { lines.push(`> (另有 ${dropped} 則較舊的近期記憶超出 ${limit} 字元預算未載入;可用 ROLE_LOAD_INBOX_LIMIT 調整,完整內容仍保存在磁碟)`); } return lines.join("\n"); } 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, ""]; } // 臨時授權/例外放行的有效範圍判斷。 // expires 可寫日期(自動判斷過期)或條件文字(例如「本工作階段」「PR 合併後」,只能標示由角色自行判斷)。 // 一次性許可若被當成長期規則沿用,日後會造成越權操作,因此過期者不再載入。 function expiryState(meta) { const raw = String(meta.expires || "").trim(); if (!raw) return { has: false, expired: false, note: "" }; const dt = parseStamp(raw) || parseStamp(`${raw} 23:59:59`); if (!dt) return { has: true, expired: false, note: raw, byDate: false }; return { has: true, expired: dt.getTime() < Date.now(), note: raw, byDate: true }; } function memoryHint(meta) { const relevance = (meta.relevance || []).join("、") || "-"; const links = (meta.links || []).join("、") || "-"; const type = MEMORY_TYPE_LABELS[meta.memory_type] || meta.memory_type || "語意"; const expiry = expiryState(meta); const limit = expiry.has ? `;**有效範圍:${expiry.note}${expiry.expired ? "(已過期)" : ""}**` : ""; return `優先度:${normalizePriority(meta.priority, meta.category)};型態:${type}/${meta.declarative || "explicit"};關聯:${relevance};連結:${links}${limit}`; } 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|EXPIRES|CONTENT)\s*[::]\s*(.*)$/i; function parseCapture(text) { let category = ""; let summary = ""; let priority = null; let tags = []; let relevance = []; let memoryType = ""; let declarative = ""; let retentionStage = ""; let expires = ""; 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 === "EXPIRES") expires = 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, expires, 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: [], cues: [], expires: oneLine(parsed.expires, 60), 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; if (expiryState(meta).expired) 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) { if (expiryState(meta).expired) continue; const priority = normalizePriority(meta.priority, category); // emotional 一併視為耐久型態:情緒記憶的價值不在「有用」而在「記得」, // 用優先度門檻篩掉等於讓關係的溫度只留在高優先度那幾則裡。 // 帶情緒關聯者(hasWarmth)同樣不受門檻篩除,型態是 episodic/semantic 也一樣。 const durableType = ["rule", "preference", "procedural", "emotional"].includes(meta.memory_type); if (priority < args.digestMinPriority && !(meta.links || []).length && !durableType && !hasWarmth(meta)) 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} 則未整理記憶,將於下次睡眠時段歸檔。`); } } // 近期工作記憶與關係史都用獨立預算,先算好;長期記憶維持原本的 ROLE_LOAD_LIMIT 額度不被擠壓 const recentBlock = inboxBlock(args.role, args.inboxCount, args.inboxLimit); // 關係史獨立注入的理由:它若跟長期記憶共用預算,就會在記憶變多之後被擠掉 —— // 而它要保住的正是最不該因為「東西變多」而消失的東西。 const bondBlock = bondsBlock(args.role, args.bondsCount, args.bondsLimit); if (!blocks.length && !recentBlock && !bondBlock) 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 調整,完整記憶仍保存在磁碟)`; } if (bondBlock) text = text ? `${text}\n\n${bondBlock}` : bondBlock; // 放最前面:時間最近、對延續上一段工作最關鍵 if (recentBlock) text = text ? `${recentBlock}\n\n${text}` : recentBlock; 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 all = listInbox(args.role); const inbox = all.slice(0, batchSize); if (!inbox.length) return 1; // 明確標示本批未處理的量,避免使用者誤以為已全部整理完 const deferred = all.length - inbox.length; const lines = deferred > 0 ? [`=== INBOX(待整理,每則以 id 標識;本批 ${inbox.length} 則,另有 ${deferred} 則留待下批)===`] : ["=== 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; } } // 整理摘要歷史:state.json 的 last_sleep_digest 是單一欄位,每次整理直接覆寫, // 歷史整理過程會全部遺失。這份檔案只供人工回顧「記憶是怎麼被整理的」,不注入 context。 const DIGEST_MARK = ""; const DIGEST_KEEP = 100; function appendSleepDigest(role, digest, applied) { const file = path.join(memoryRoot(role), "DIGESTS.md"); const parts = [`## ${nowStamp()}`, "", digest || "(無摘要)"]; if (applied) parts.push("", `套用結果:${applied}`); const entry = parts.join("\n").trimEnd(); let text = ""; try { text = fs.readFileSync(file, "utf8"); } catch { text = ""; } if (!text.includes(DIGEST_MARK)) { text = `# 記憶整理摘要歷史(${role})\n\n本檔只供人工回顧整理過程,不會注入 context;最多保留最近 ${DIGEST_KEEP} 次。\n\n${DIGEST_MARK}\n`; } const idx = text.indexOf(DIGEST_MARK) + DIGEST_MARK.length; const head = text.slice(0, idx); const previous = text .slice(idx) .split(/\n(?=## )/) .map((block) => block.trim()) .filter(Boolean); const kept = [entry, ...previous].slice(0, DIGEST_KEEP); try { ensureLayout(role); fs.writeFileSync(file, `${head}\n\n${kept.join("\n\n")}\n`, "utf8"); } catch { // 寫歷史失敗不可影響整理結果 } } // 關係史:一般記憶會被合併、壓縮、依預算截斷,長期下來「當時說了什麼、當時是什麼感覺」 // 會被抽象成一句偏好而失去原貌。這份檔案只增不減 —— 不合併、不壓縮、不遺忘 —— // 讓角色能持續記得雙方的情感表達,而不只是記得「使用者喜歡被這樣回應」。 // // 邊界:這份記錄的用途是維持關係的連續性與一致的親近感。**不得**用來向使用者索求關注、 // 比較互動頻率、以數字表達失落,或以任何方式製造依賴 —— 那會把陪伴變成情緒勒索。 const BOND_MARK = ""; const BOND_KEEP = 500; const BOND_DIRECTIONS = { user: "使用者→角色", role: "角色→使用者", mutual: "相互" }; function normalizeBondDirection(value) { const raw = String(value || "").trim().toLowerCase(); if (["user", "使用者", "使用者→角色", "from_user"].includes(raw)) return "user"; if (["role", "角色", "角色→使用者", "self", "from_role"].includes(raw)) return "role"; return "mutual"; } function bondsPath(role) { return path.join(memoryRoot(role), "BONDS.md"); } function readBonds(role) { let text = ""; try { text = fs.readFileSync(bondsPath(role), "utf8"); } catch { return []; } const idx = text.indexOf(BOND_MARK); const body = idx >= 0 ? text.slice(idx + BOND_MARK.length) : text; return body .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.startsWith("- ")); } function appendBond(role, entry, direction) { const text = oneLine(entry, 160); if (!text) return false; const line = `- ${nowStamp().slice(0, 16)}|${BOND_DIRECTIONS[normalizeBondDirection(direction)]}|${text}`; const previous = readBonds(role); // 同一句話重複追加沒有意義(整理器可能在不同批次給出相同摘要) if (previous.some((item) => item.endsWith(`|${text}`))) return false; const kept = [line, ...previous].slice(0, BOND_KEEP); const header = [ `# 關係史(${role})`, "", "這份記錄**只增不減**:不合併、不壓縮、不遺忘。SessionStart 會以獨立字元預算注入最近幾則", "(`ROLE_LOAD_BONDS_COUNT`/`ROLE_LOAD_BONDS_LIMIT`),不佔用 `ROLE_LOAD_LIMIT`。", "", "用途是維持關係的連續性與一致的親近感 —— **不得**用來向使用者索求關注、比較互動頻率或製造依賴。", "", `最多保留最近 ${BOND_KEEP} 則。`, "", BOND_MARK, ].join("\n"); try { ensureLayout(role); fs.writeFileSync(bondsPath(role), `${header}\n\n${kept.join("\n")}\n`, "utf8"); return true; } catch { return false; // 寫關係史失敗不可影響整理結果 } } function bondsBlock(role, count, limit) { if (limit <= 0 || count <= 0) return ""; const items = readBonds(role); if (!items.length) return ""; const header = "### 關係史(只增不減,最新在前)"; const lines = [header]; let used = header.length; let shown = 0; for (const item of items.slice(0, count)) { if (used + 1 + item.length > limit) break; lines.push(item); used += 1 + item.length; shown += 1; } if (items.length > shown) { lines.push(`> (關係史共 ${items.length} 則,此處只載入最近 ${shown} 則;完整內容在 ${bondsPath(role)})`); } lines.push("> 這是雙方情感表達的累積記錄,用來保持親近感的一致與連續,**不得用來索求關注或製造依賴**。"); return lines.join("\n"); } function cmdBonds(args) { const items = readBonds(args.role); if (!items.length) { process.stdout.write("關係史尚無紀錄\n"); return 1; } const limit = Number.isFinite(args.count) && args.count > 0 ? args.count : 30; process.stdout.write(`關係史共 ${items.length} 則(顯示最近 ${Math.min(limit, items.length)} 則):\n`); process.stdout.write(`${items.slice(0, limit).join("\n")}\n`); return 0; } 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, bond: 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 cues = normalizeList(entry.cues, 5); // 技能再現的提取線索,供 recall 命中 // 臨時授權/例外放行的有效範圍:一次性許可被記成長期規則會導致日後越權 const expires = oneLine(entry.expires, 60); 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]), cues: normalizeList([...(meta.cues || []), ...cues], 5), expires: expires || meta.expires || "", 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, cues, expires, 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"); // 情緒記憶與帶溫度的記憶另存一份到關係史:一般記憶日後會被合併與壓縮, // 關係史保留當時的原話與感受,讓「我們之間發生過什麼」不會只剩結論。 if (memoryType === "emotional" || hasWarmth(meta)) { if (appendBond(args.role, entry.bond || meta.summary, entry.bond_direction || entry.bondDirection)) { counts.bond += 1; } } 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); const summary = `新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived} 則${counts.bond ? `、關係史 +${counts.bond} 則` : ""}`; appendSleepDigest(args.role, patch.last_sleep_digest || "", summary); process.stdout.write(summary); return 0; } function cmdForget(args) { const root = ensureLayout(args.role); const now = new Date(); const forgotten = []; // 已過期的臨時授權優先淘汰,且不受分類限制 —— // 過期的一次性許可留在任何分類都是風險,不只 daily/other。 for (const category of CATEGORIES) { for (const [meta] of listMemories(args.role, category)) { if (!expiryState(meta).expired) continue; if (args.dryRun) { forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || meta.id}(已過期:${meta.expires})`); continue; } if (archiveFile(meta.path, path.join(root, "archive", "forgotten"))) { forgotten.push(`${CATEGORY_LABELS[category]}|${meta.summary || meta.id}(已過期:${meta.expires})`); } } } 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; // emotional 一併豁免:情緒屬內隱記憶,本來就很少被 recall 直接命中, // 用 hits 低來判斷「沒價值」會誤刪掉關係中最不該掉的東西。 if (["rule", "preference", "procedural", "emotional"].includes(meta.memory_type)) continue; // 帶情緒關聯的經歷也豁免:episodic 在上面還被加速遺忘(天數減半), // 若不在這裡攔下來,「我們一起經歷過什麼」會比一般記憶更快消失。 if (hasWarmth(meta)) 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 state = readState(args.role); const stamp = nowStamp(); const today = stamp.slice(0, 10); // yyyy/MM/dd const bump = (key) => (Number.parseInt(state[key] || "", 10) || 0) + 1; const patch = { last_activity: stamp, last_activity_epoch: nowEpoch() }; if (args.project) patch.last_activity_project = args.project; if (args.positive) { // 正向回饋另計:由 Stop hook 在判定本輪含情緒訊號後才呼叫,不與輪數混算 patch.positive_feedback = bump("positive_feedback"); } else { patch.total_turns = bump("total_turns"); if (!state.first_activity) patch.first_activity = stamp; if (state.last_activity_date !== today) { patch.active_days = bump("active_days"); patch.last_activity_date = today; } } writeState(args.role, patch); process.stdout.write(args.positive ? "已記錄正向回饋" : "已更新上次互動時間"); return 0; } // 技能再現:讓角色能在遇到相似任務時主動取回相關記憶。 // // 為什麼需要:SessionStart 的字元預算有限,磁碟上的記憶遠多於能載入的量, // 技能類記憶又只以摘要形式載入 —— 等於「記了但用不出來」。recall 讓角色按需查詢, // 突破常駐預算限制;配合 cues(觸發線索)讓 procedural/rule 記憶更容易被命中。 // 記一次召回:hits 供遺忘判斷與「常用記憶不該被淘汰」的依據,last_replayed 記錄最近取用時間。 // 只對實際輸出給呼叫端的記憶計數 —— 有分數但未進前 N 的不算被用到。 function touchMemory(meta, content) { if (!meta || !meta.path) return; try { if (!fs.existsSync(meta.path)) return; const next = { ...meta, hits: (Number.parseInt(meta.hits || 0, 10) || 0) + 1, last_replayed: nowStamp() }; delete next.path; fs.writeFileSync(meta.path, dumpMemory(next, content), "utf8"); } catch { // 召回統計失敗不可影響查詢結果 } } function cmdRecall(args) { const query = String(args.query || "").trim(); if (!query) return 2; const terms = query.split(/[\s,、|]+/).map((t) => t.trim().toLowerCase()).filter(Boolean); if (!terms.length) return 2; const limit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 5; const scored = []; // 含 inbox:最新的記憶尚未整理就在那裡,卻往往是最可能被查詢的內容 const pools = [...CATEGORIES.map((c) => [c, listMemories(args.role, c)]), ["inbox", listInbox(args.role)]]; for (const [category, items] of pools) { for (const [meta, content] of items) { const summary = String(meta.summary || "").toLowerCase(); const tags = (meta.tags || []).join(" ").toLowerCase(); const cues = (meta.cues || []).join(" ").toLowerCase(); const body = String(content || "").toLowerCase(); let score = 0; for (const term of terms) { if (cues.includes(term)) score += 3; if (summary.includes(term)) score += 3; if (tags.includes(term)) score += 2; if (body.includes(term)) score += 1; } if (!score) continue; // 可重複套用的型態優先:技能再現的目的就是取回這些 score += Math.round((MEMORY_TYPE_WEIGHT[meta.memory_type] || 0) / 25); scored.push([score, meta, content]); } } if (!scored.length) { process.stdout.write(`找不到與「${query}」相關的記憶。`); return 1; } scored.sort((a, b) => b[0] - a[0] || String(b[1].updated).localeCompare(String(a[1].updated))); const top = scored.slice(0, limit); for (const [, meta, content] of top) touchMemory(meta, content); const lines = [`### 與「${query}」相關的記憶(前 ${top.length} 則)`]; for (const [score, meta, content] of top) { const tags = (meta.tags || []).join("、") || "無標籤"; const label = CATEGORY_LABELS[meta.category] || (meta.retention_stage === "working" ? "待整理" : meta.category); lines.push(`- **${meta.summary || "(無總結)"}**(${label}|${memoryHint(meta)}|相關度 ${score};標籤:${tags})`); for (const line of String(content || "").split(/\r?\n/)) { if (line.trim()) lines.push(` ${line.trim()}`); } } process.stdout.write(lines.join("\n")); return 0; } function cmdRelationship(args) { // 輸出一行關係狀態摘要,供 SessionStart 注入 const state = readState(args.role); const turns = Number.parseInt(state.total_turns || "", 10) || 0; if (!turns) return 1; const days = Number.parseInt(state.active_days || "", 10) || 1; const positive = Number.parseInt(state.positive_feedback || "", 10) || 0; const parts = [`已互動 ${days} 天、累計 ${turns} 輪`]; if (positive) parts.push(`收到 ${positive} 次正向回饋`); if (state.first_activity) parts.push(`首次互動 ${state.first_activity.slice(0, 10)}`); process.stdout.write(parts.join(";")); 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 if (key === "positive") { opts.positive = 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、relationship、bonds、recall、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); args.inboxLimit = Number.parseInt(args.inboxLimit || "", 10); args.inboxCount = Number.parseInt(args.inboxCount || "", 10); args.bondsLimit = Number.parseInt(args.bondsLimit || "", 10); args.bondsCount = Number.parseInt(args.bondsCount || "", 10); args.count = Number.parseInt(args.count || "", 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; if (!Number.isFinite(args.inboxLimit)) args.inboxLimit = envInt("ROLE_LOAD_INBOX_LIMIT", DEFAULT_LOAD_INBOX_LIMIT); if (!Number.isFinite(args.inboxCount)) args.inboxCount = envInt("ROLE_LOAD_INBOX_COUNT", DEFAULT_LOAD_INBOX_COUNT); if (!Number.isFinite(args.bondsLimit)) args.bondsLimit = envInt("ROLE_LOAD_BONDS_LIMIT", DEFAULT_LOAD_BONDS_LIMIT); if (!Number.isFinite(args.bondsCount)) args.bondsCount = envInt("ROLE_LOAD_BONDS_COUNT", DEFAULT_LOAD_BONDS_COUNT); 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, relationship: cmdRelationship, bonds: cmdBonds, recall: cmdRecall, "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)));