fix(consolidate): --forget 套上短期記憶的保護;長期記憶撞名不再靜默覆蓋

兩個資料遺失路徑:

① `--forget N` 無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected()
   (salience ≥ 80/intent=commit/24 小時內)。prune 有這層保護,這裡沒有。
   實測 `--forget 200` 把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉,
   而 R4 明文寫「不可遺忘」。改走新的 forgetShortTerm(),跟 prune 同一層保護,
   並回報「有幾筆低於門檻但受保護、沒有刪」。

② slugify() 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md`,
   writeText 直接覆蓋,只保留舊檔的 first_seen/recall_count,內文靜默被換掉。
   front matter 新增 `title:`(原本的 --name),撞名時 die 並給一個沒被占用的檔名
   (`--name 我的貓-2`);真的要覆蓋同一則就加 --force。

selftest +11:--forget 不刪承諾/界線/今天的紀錄但該刪的照刪、撞名被擋、
舊內文原封不動、訊息給得出替代檔名、同名再寫是更新、title 有寫入、--force 仍可覆蓋。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 09:23:35 +00:00
co-authored by Claude Opus 5
parent d9f865d7a4
commit 1d5b049152
3 changed files with 101 additions and 5 deletions
+52
View File
@@ -169,6 +169,58 @@ check("關鍵詞可檢索到",
pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings");
check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion"));
// --------------------------------------------------------------------------- //
// consolidate 的兩個資料遺失路徑
{
// ① `--forget N` 以前是無條件刪掉 salience < N 的全部短期記憶,完全不套 shortTermProtected()
// `--forget 200` 會把一筆 intent=commit、salience 95、剛寫入的承諾一起刪掉,而 R4 寫的是「不可遺忘」。
const stFile = path.join(H, "forgetter", "memory", "short-term.jsonl");
fs.mkdirSync(path.dirname(stFile), { recursive: true });
const stale = pl.iso(new Date(Date.now() - 5 * 86_400_000));
fs.writeFileSync(stFile, [
{ ts: pl.nowIso(), text: "我答應你週五之前把那件事做完", salience: 95, intent: "commit" },
{ ts: stale, text: "界線:不要替我做決定", salience: 88 },
{ ts: pl.nowIso(), text: "今天剛講的閒聊", salience: 10 },
{ ts: stale, text: "上週的閒聊", salience: 10 },
].map((r) => JSON.stringify(r)).join("\n") + "\n");
const cut = pl.forgetShortTerm("forgetter", 200);
const left = pl.readJsonl(stFile).map((r) => r.text);
check("`--forget` 不刪 intent=commit 的承諾(R4:不可遺忘)",
left.includes("我答應你週五之前把那件事做完"), left.join(""));
check("`--forget` 不刪顯著度 ≥ 80 的界線", left.includes("界線:不要替我做決定"));
check("`--forget` 不刪 24 小時內的新紀錄(還沒機會被固化)", left.includes("今天剛講的閒聊"));
check("`--forget` 該刪的還是有刪,而且會回報保護了幾筆",
!left.includes("上週的閒聊") && cut.dropped === 1 && cut.protected === 3, JSON.stringify(cut));
// ② slugify 把標點吃掉:「我的貓」「我的貓?」「我的貓!!」都落在同一個檔名上,
// 以前 writeText 直接覆蓋,只留舊檔的 first_seenrecall_count,內文靜默被換掉。
check("slugify 確實會讓這三個名字撞在一起",
pl.slugify("我的貓") === pl.slugify("我的貓?") && pl.slugify("我的貓") === pl.slugify("我的貓!!"));
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓", "--body", "牠叫小黑。"]);
const catFile = path.join(pl.longTermDir("alpha"), `${pl.slugify("我的貓")}.md`);
check("撞名的第二則會被擋下,不會靜默覆蓋",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "完全不同的內容。"], { expectOk: false }).status !== 0);
check("被擋下之後舊的內文原封不動",
fs.readFileSync(catFile, "utf8").includes("牠叫小黑。") &&
!fs.readFileSync(catFile, "utf8").includes("完全不同的內容。"));
check("擋下的訊息會給一個沒被占用的檔名",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "x"], { expectOk: false }).stderr.includes(`--name ${pl.slugify("我的貓")}-2`),
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "x"], { expectOk: false }).stderr.slice(0, 200));
check("同一個 --name 再寫一次是更新,不算撞名",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓",
"--body", "牠叫小黑,十二歲。"]).status === 0 &&
fs.readFileSync(catFile, "utf8").includes("十二歲"));
check("front matter 留著原本的 --name(下次靠這行認出不是同一則)",
pl.parseFrontMatter(fs.readFileSync(catFile, "utf8"))[0].title === "我的貓");
check("真的要覆蓋還是走得通(--force",
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "我的貓?",
"--body", "換成別則了。", "--force"]).status === 0 &&
fs.readFileSync(catFile, "utf8").includes("換成別則了。"));
}
console.log("⑥ 短期 → 長期的轉入條件");
check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
check("R1 高顯著度會成為候選",