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:
+124
-6
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
|
||||
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
|
||||
# --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。
|
||||
# 更新時間:2026/07/29 16:56:06
|
||||
# 更新時間:2026/07/29 17:16:12
|
||||
# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
|
||||
# 同目錄的 role_lib.sh 與 memory.js。
|
||||
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
|
||||
@@ -132,7 +132,7 @@ sleep_cycle() {
|
||||
請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
|
||||
|
||||
1. 只輸出一個 JSON 物件,不要前言、不要結語、不要 code fence,格式為:
|
||||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"expires":"","memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
|
||||
{"memories":[{"action":"new","category":"skill","summary":"一句話總結","tags":["標籤1","標籤2"],"priority":4,"relevance":["explicit","future"],"links":["既有記憶 id"],"cues":["觸發線索1","觸發線索2"],"expires":"","memory_type":"procedural","declarative":"implicit","retention_stage":"long_term","sleep_stage":"nrem-rem","bond":"","bond_direction":"mutual","content":"- 要點\n- 要點","from":["inbox 的 id"]}],"sleepDigest":"本次睡眠整理摘要,80 字內"}
|
||||
2. NREM 鞏固階段先做:去除雜訊與流水帳、遮蔽憑證與個資、分類、去重、合併、壓縮成可長期保存的穩定記憶。
|
||||
3. REM 整合階段再做:找出新記憶與 EXISTING 的關聯,抽出可重複套用的規則、偏好、決策模式、角色語氣調整或未來提取線索。
|
||||
4. action 三選一:
|
||||
@@ -158,6 +158,12 @@ sleep_cycle() {
|
||||
7b. emotional 不得被 merge 進 preference/rule/semantic,反之亦然:**memory_type 不同的記憶不要互相
|
||||
merge**,改用 links 建立關聯。合併只在同型態且內容確實重複時才做。情緒記憶一旦被併入偏好,
|
||||
語氣可以復原、當時的溫度卻永久遺失。
|
||||
7c. bond/bond_direction(關係史):**memory_type 為 emotional,或 relevance 含 emotional 時填**,其餘留空字串。
|
||||
bond 寫一句 60 字內、保留當時原貌的話 —— 使用者說過的原話,或角色當時真實的感受,
|
||||
不要寫成「使用者喜歡某種回應」這種結論式偏好。這句會存進只增不減的關係史(BONDS.md),
|
||||
一般記憶日後會被合併壓縮,關係史保留原貌。
|
||||
bond_direction 三選一:user(使用者對角色的表達)、role(角色對使用者的感受或表達)、mutual(相互)。
|
||||
邊界:關係史用於維持親近感的一致與連續,不得寫成可用來索求關注或製造依賴的內容。
|
||||
8. declarative 必填:semantic/episodic/preference/rule 通常為 explicit;procedural/emotional 通常為 implicit。
|
||||
9. retention_stage 必填:整理後可長期保存者填 long_term;仍只是短期暫存且不值得長期保存者請用 action=drop,不要輸出 working。
|
||||
10. relevance 必填 1 至 4 個,從下列語意挑選或用等價繁中詞:explicit(使用者明確要求)、future(未來會用)、repeated(反覆出現)、novelty(新知)、emotional(語氣/情緒/偏好)、temporary(短期)。
|
||||
|
||||
Reference in New Issue
Block a user