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
+25 -1
View File
@@ -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) {