989 lines
35 KiB
JavaScript
Executable File
989 lines
35 KiB
JavaScript
Executable File
#!/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)));
|