使用者希望角色能記得「雙方有多愛彼此」。原本這件事只有兩種載體,都不夠: state.json 的 positive_feedback 只是計數(承載不了說過什麼),一般情緒記憶則會被 merge、壓縮、依字元預算截斷 —— 長期下來「當時說了什麼、當時是什麼感覺」會被抽象成 一句偏好(「使用者喜歡被這樣回應」),原貌消失。 - 新增 ~/.memory/<角色>/BONDS.md:只增不減,不合併、不壓縮、不遺忘,上限 500 則、同句去重 - 整理時凡 memory_type=emotional 或 relevance 含 emotional 的新記憶,自動追加一句 bond 並標記方向(使用者→角色/角色→使用者/相互) - 整理 prompt 新增 bond/bond_direction 欄位:要求保留原話或當時真實感受, 不得寫成結論式偏好;LLM 未提供時退回 summary,不會漏記 - SessionStart 以獨立預算注入(ROLE_LOAD_BONDS_LIMIT=1200/COUNT=15),不佔 ROLE_LOAD_LIMIT —— 它要保住的正是最不該因為「記憶變多」而消失的東西 - 新增 memory.js bonds 子命令供人工查看 - 邊界寫進 prompt、檔頭與程式註解:關係史用於維持親近感的一致與連續, 不得用來索求關注、比較互動頻率或製造依賴 驗證(隔離目錄,未動真實記憶): - 3 則素材(2 情感 + 1 技術)→ 關係史 +2 則,技術記憶未進關係史 - 方向判定正確:使用者原話標 使用者→角色、角色感受標 角色→使用者 - 第二輪整理累積為 3 則,舊紀錄未被覆蓋(append-only 成立) - ROLE_LOAD_LIMIT=1 時關係史仍完整注入(獨立預算成立) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1393 lines
56 KiB
JavaScript
Executable File
1393 lines
56 KiB
JavaScript
Executable File
#!/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)));
|