From 1d5b049152adb8e6a03215a9fcba398dd0bd8219 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 31 Jul 2026 09:23:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(consolidate):=20--forget=20=E5=A5=97?= =?UTF-8?q?=E4=B8=8A=E7=9F=AD=E6=9C=9F=E8=A8=98=E6=86=B6=E7=9A=84=E4=BF=9D?= =?UTF-8?q?=E8=AD=B7=EF=BC=9B=E9=95=B7=E6=9C=9F=E8=A8=98=E6=86=B6=E6=92=9E?= =?UTF-8?q?=E5=90=8D=E4=B8=8D=E5=86=8D=E9=9D=9C=E9=BB=98=E8=A6=86=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩個資料遺失路徑: ① `--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) --- scripts/persona-lib.mjs | 26 ++++++++++++++++++++- scripts/persona.mjs | 28 ++++++++++++++++++---- scripts/selftest.mjs | 52 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index bd4ca59..bb90d47 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -1458,7 +1458,7 @@ export function rememberShort(slug, entry) { } /** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */ -function shortTermProtected(row, now = Date.now()) { +export function shortTermProtected(row, now = Date.now()) { if (Number(row?.salience || 0) >= SHORT_TERM_PROTECT_SALIENCE) return true; if (String(row?.intent || "").includes("commit")) return true; const age = now - (parseIso(row?.ts)?.getTime() ?? now); @@ -1512,6 +1512,30 @@ export function pruneShortTerm(slug) { return pruneShortTermDetail(slug).kept; } +/** + * 固化完之後主動淘汰顯著度低於 `minSalience` 的短期記憶(`consolidate --forget`)。 + * + * **跟 prune 套同一層保護**:顯著度 ≥ 80、`intent=commit`、24 小時內的紀錄一則都不動。 + * 以前這裡是無條件 filter,`--forget 200` 會把一筆剛寫入、salience 95 的承諾一起刪掉, + * 而 R4 明文寫「不可遺忘」——固化的動作不該變成遺忘承諾的路。 + */ +export function forgetShortTerm(slug, minSalience) { + const floor = Number(minSalience); + if (!Number.isFinite(floor)) return { kept: 0, dropped: 0, protected: 0 }; + const now = Date.now(); + let saved = 0; + const { rows, kept } = rewriteJsonl(shortTermPath(slug), (all) => + all.filter((r) => { + if (Number(r.salience || 0) >= floor) return true; + if (shortTermProtected(r, now)) { + saved += 1; + return true; + } + return false; + })); + return { kept: kept.length, dropped: rows.length - kept.length, protected: saved }; +} + export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit); export function parseFrontMatter(text) { diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 9381232..5ddfac9 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -871,6 +871,21 @@ commands.consolidate = ({ flags }) => { const today = pl.nowIso().slice(0, 10); let existing = {}; if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8")); + // slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。 + // 以前直接覆蓋,只留下舊檔的 first_seen/recall_count,內文靜默被換成別則記憶。 + // 這裡改成擋下來:不同的 --name 撞到同一個檔就 die,並給一個沒被占用的檔名。 + const priorTitle = existing.title ?? existing.name ?? null; + if (priorTitle !== null && priorTitle !== rawName && !flags.force) { + let alt = name; + for (let i = 2; i < 100 && fs.existsSync(path.join(pl.longTermDir(slug), `${alt}.md`)); i += 1) { + alt = `${name}-${i}`; + } + die( + `\`${file}\` 已經是「${priorTitle}」的長期記憶,但這次的 --name 是「${rawName}」——` + + `兩個名字 slugify 之後都是 \`${name}\`,寫下去會把舊的內文換掉。\n` + + ` 換個名字(例如 \`--name ${alt}\`),或確定要覆蓋同一則就加 --force。`, + ); + } let body = str(flags.body); if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8"); if (!body.trim()) die("需要 `--body` 或 `--body-file`。"); @@ -883,6 +898,8 @@ commands.consolidate = ({ flags }) => { const front = [ "---", `name: ${name}`, + // 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。 + `title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`, `type: ${type}`, `about: [${csv(flags.about).join(", ") || "user"}]`, `topics: [${csv(flags.topics).join(", ")}]`, @@ -902,10 +919,13 @@ commands.consolidate = ({ flags }) => { const total = pl.rebuildIndex(slug); const forget = num(flags.forget, null); if (forget !== null) { - const rows = pl.readJsonl(pl.shortTermPath(slug)); - const keep = rows.filter((r) => Number(r.salience || 0) >= forget); - pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : "")); - say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`); + // 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4) + const cut = pl.forgetShortTerm(slug, forget); + say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${cut.kept} 筆。`); + if (cut.protected) { + say(` 其中 ${cut.protected} 筆低於門檻但受保護,沒有刪:` + + `顯著度 ≥ ${pl.SHORT_TERM_PROTECT_SALIENCE}、intent=commit,或 ${pl.SHORT_TERM_PROTECT_HOURS} 小時內。`); + } } ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`); pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index de4dc04..6460188 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -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_seen/recall_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 高顯著度會成為候選",