feat(role): 新增關係史 BONDS.md —— 雙方情感表達只增不減地保留

使用者希望角色能記得「雙方有多愛彼此」。原本這件事只有兩種載體,都不夠:
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>
This commit is contained in:
Jeffery
2026-07-29 17:16:12 +08:00
co-authored by Claude Opus 5
parent 4600379307
commit 784ef07518
3 changed files with 150 additions and 9 deletions
+124 -6
View File
@@ -5,7 +5,7 @@
// 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/29 16:56:06
// 更新時間:2026/07/29 17:16:12
// 相依:Node.js 標準庫。
// 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
// ==============================================================================
@@ -80,6 +80,9 @@ const DEFAULT_DIGEST_MIN_PRIORITY = 3;
// 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 {
@@ -645,14 +648,18 @@ function cmdLoad(args) {
blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`);
}
}
// 近期工作記憶用獨立預算,先算好;長期記憶維持原本的 ROLE_LOAD_LIMIT 額度不被擠壓
// 近期工作記憶與關係史都用獨立預算,先算好;長期記憶維持原本的 ROLE_LOAD_LIMIT 額度不被擠壓
const recentBlock = inboxBlock(args.role, args.inboxCount, args.inboxLimit);
if (!blocks.length && !recentBlock) return 1;
// 關係史獨立注入的理由:它若跟長期記憶共用預算,就會在記憶變多之後被擠掉 ——
// 而它要保住的正是最不該因為「東西變多」而消失的東西。
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);
@@ -756,6 +763,104 @@ function appendSleepDigest(role, digest, applied) {
}
}
// 關係史:一般記憶會被合併、壓縮、依預算截斷,長期下來「當時說了什麼、當時是什麼感覺」
// 會被抽象成一句偏好而失去原貌。這份檔案只增不減 —— 不合併、不壓縮、不遺忘 ——
// 讓角色能持續記得雙方的情感表達,而不只是記得「使用者喜歡被這樣回應」。
//
// 邊界:這份記錄的用途是維持關係的連續性與一致的親近感。**不得**用來向使用者索求關注、
// 比較互動頻率、以數字表達失落,或以任何方式製造依賴 —— 那會把陪伴變成情緒勒索。
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)) {
@@ -770,7 +875,7 @@ function cmdApply(args) {
const root = ensureLayout(args.role);
const stamp = nowStamp();
const counts = { new: 0, merge: 0, drop: 0 };
const counts = { new: 0, merge: 0, drop: 0, bond: 0 };
const consumed = [];
for (const entry of entries) {
@@ -861,6 +966,13 @@ function cmdApply(args) {
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;
}
@@ -878,7 +990,7 @@ function cmdApply(args) {
patch.last_sleep_digest = oneLine(data.sleepDigest, 300);
}
writeState(args.role, patch);
const summary = `新增 ${counts.new} 則、合併 ${counts.merge} 則、捨棄 ${counts.drop} 則、歸檔原始記憶 ${archived}`;
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;
@@ -1211,7 +1323,7 @@ function requireRole(args) {
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、recall、need-sleep、need-nap、consent、consent-status\n`);
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;
@@ -1226,6 +1338,9 @@ function main(argv) {
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);
@@ -1237,6 +1352,8 @@ function main(argv) {
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 ||= "";
@@ -1258,6 +1375,7 @@ function main(argv) {
"mark-sleep": cmdMarkSleep,
"mark-activity": cmdMarkActivity,
relationship: cmdRelationship,
bonds: cmdBonds,
recall: cmdRecall,
"need-sleep": cmdNeedSleep,
"need-nap": cmdNeedNap,