Files
shared/scripts/role/memory.js
T
JefferyandClaude Opus 5 c713359fb4 feat(role): 角色分層載入與個人記憶同意狀態
role_load.sh 不再原封不動注入整份角色檔,改採 SOUL/AGENTS/USER/MEMORY
分層:只抽出角色 ID、顯示名稱、本質、氛圍與簽名 emoji 作為人格層,由 hook
產生固定操作邊界,記憶與同意狀態獨立成一區。避免人格檔裡的背景故事與模板
文字污染工程與安全規則。

memory.js 新增 consent/consent-status 子命令,同意狀態寫入
~/.memory/<角色 ID>/state.json 的 personal_memory_consent(accepted/
declined/unknown);role_capture.sh 於本輪出現明確同意或拒絕時更新狀態,
偵測不到明確表述時不自行推論。accepted 後不再每次重問。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:21:48 +08:00

944 lines
34 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// ==============================================================================
// 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
// inbox(2) 產生 SessionStart 要注入的記憶區塊,(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/28 13:50:43
// 相依: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) 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_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 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 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("同意狀態只接受 accepteddeclinedunknown\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、need-sleep、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 || "");
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.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,
"need-sleep": cmdNeedSleep,
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)));