fix(memory): R6 容量壓力真的會清,而且不清承諾與今天的紀錄(v0.0.7 同批)

實際踩到的 bug:短期記憶連睡兩次都停在 70 筆、111 組候選,`prune-short-term`
每次都回報 ok 卻一筆都沒清掉。

原因:`pruneShortTerm()` 只有兩道——超過 14 天、超過硬上限 240 筆。但提示固化的門檻
(`CONSOLIDATE_THRESHOLD`)是 40 筆,中間那 200 筆沒有任何機制會動它。R6 在
`candidates` 裡被列為「依顯著度清出空間」,但那個清除動作**從來沒有實作**。

修法:加一道軟上限(`SHORT_TERM_SOFT_CAP`=120),超過就從顯著度最低、最舊的開始裁。
兩個保護,因為「沒經過判斷就把今天清掉」是這裡最不能犯的錯:

  - 顯著度 ≥ 80 或 `intent=commit`(承諾與界線都在這一層)→ 一則都不清
  - 24 小時內的新紀錄 → 一則都不清(還沒機會被固化)

順帶:`pruneShortTermDetail()` 回傳清了幾筆、為什麼清、保護了幾筆,`sleep` 的
`run()` 會把細節帶進回報——**不做無聲的裁切**。

測試:302 項全過(新增 5 項:軟上限會清、承諾不清、今天的不清、清的是顯著度最低的、
回報帶細節)。文件同步:README 的 R6 與目錄樹、persona-memory 的 R6。

版本:與 PR #10 同一批未合併的改動,維持 0.0.7。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 05:01:52 +00:00
co-authored by Claude Opus 5
parent 23751c2c05
commit 54d14eae68
5 changed files with 88 additions and 13 deletions
+54 -7
View File
@@ -1055,9 +1055,15 @@ export function gcRuntime() {
// 記憶:短期(滾動)/ 長期(一則一檔)
// --------------------------------------------------------------------------- //
export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數
export const SHORT_TERM_KEEP = 240; // 短期記憶保留筆數(硬上限:超過就從最舊的砍)
export const SHORT_TERM_DAYS = 14; // 短期記憶保留天數
export const CONSOLIDATE_THRESHOLD = 40; // 超過這個筆數就提示固化
// R6「容量壓力」的實際執行門檻。以前只有硬上限 240 與 14 天兩條,
// 結果是:提示在 40 筆就開始叫,但清除要到 240 筆才會發生——中間那 200 筆
// 只會越積越多,睡兩次也清不掉(實際踩到過)。這條讓 R6 真的會動。
export const SHORT_TERM_SOFT_CAP = 120; // 軟上限:超過就依顯著度清出空間
export const SHORT_TERM_PROTECT_SALIENCE = 80; // 這個顯著度以上不清(承諾/界線都在這一層)
export const SHORT_TERM_PROTECT_HOURS = 24; // 這麼新的一律不清(還沒機會被固化)
export const shortTermPath = (slug) => path.join(personaDir(slug), "memory", "short-term.jsonl");
export const inboxPath = (slug, room) =>
@@ -1076,18 +1082,59 @@ export function rememberShort(slug, entry) {
return entry;
}
/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */
export function pruneShortTerm(slug) {
/** 這一則短期記憶能不能被容量壓力清掉(承諾與界線永遠不能)。 */
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);
return age < SHORT_TERM_PROTECT_HOURS * 3_600_000;
}
/**
* 裁掉過舊/過多的短期記憶,回傳剩餘筆數(`--json` 時想看細節用 `pruneShortTermDetail`)。
*
* 三道:① 超過 14 天 ② 超過軟上限就依顯著度清出空間(R6 真正的執行) ③ 硬上限 240。
* 第二道有兩個保護:顯著度 ≥ 80(承諾與界線都在這一層)與 24 小時內的新紀錄一律不動——
* 「還沒經過判斷就把今天清掉」是這裡最不能犯的錯。
*/
export function pruneShortTermDetail(slug) {
const file = shortTermPath(slug);
const rows = readJsonl(file);
if (!rows.length) return 0;
const cutoff = Date.now() - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
if (!rows.length) return { kept: 0, dropped: 0, by_age: 0, by_capacity: 0, protected: 0 };
const now = Date.now();
const cutoff = now - SHORT_TERM_DAYS * 86_400_000;
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? now) >= cutoff);
const byAge = rows.length - kept.length;
// R6:超過軟上限,從「顯著度最低、最舊」開始清,但保護清單裡的不動
let byCapacity = 0;
if (kept.length > SHORT_TERM_SOFT_CAP) {
const droppable = kept
.map((row, i) => ({ row, i, protectedRow: shortTermProtected(row, now) }))
.filter((x) => !x.protectedRow)
.sort((a, b) => (Number(a.row.salience || 0) - Number(b.row.salience || 0)) || (a.i - b.i));
const need = kept.length - SHORT_TERM_SOFT_CAP;
const drop = new Set(droppable.slice(0, need).map((x) => x.i));
byCapacity = drop.size;
kept = kept.filter((_, i) => !drop.has(i));
}
const before = kept.length;
kept = kept.slice(-SHORT_TERM_KEEP);
const byHardCap = before - kept.length;
if (kept.length !== rows.length) {
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
}
return kept.length;
return {
kept: kept.length,
dropped: rows.length - kept.length,
by_age: byAge,
by_capacity: byCapacity + byHardCap,
protected: kept.filter((r) => shortTermProtected(r, now)).length,
};
}
/** 裁掉過舊/過多的短期記憶,回傳剩餘筆數。 */
export function pruneShortTerm(slug) {
return pruneShortTermDetail(slug).kept;
}
export const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit);