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:
+25
-1
@@ -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 (Number(row?.salience || 0) >= SHORT_TERM_PROTECT_SALIENCE) return true;
|
||||||
if (String(row?.intent || "").includes("commit")) return true;
|
if (String(row?.intent || "").includes("commit")) return true;
|
||||||
const age = now - (parseIso(row?.ts)?.getTime() ?? now);
|
const age = now - (parseIso(row?.ts)?.getTime() ?? now);
|
||||||
@@ -1512,6 +1512,30 @@ export function pruneShortTerm(slug) {
|
|||||||
return pruneShortTermDetail(slug).kept;
|
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 const recentShort = (slug, limit = 8) => readJsonl(shortTermPath(slug), limit);
|
||||||
|
|
||||||
export function parseFrontMatter(text) {
|
export function parseFrontMatter(text) {
|
||||||
|
|||||||
+24
-4
@@ -871,6 +871,21 @@ commands.consolidate = ({ flags }) => {
|
|||||||
const today = pl.nowIso().slice(0, 10);
|
const today = pl.nowIso().slice(0, 10);
|
||||||
let existing = {};
|
let existing = {};
|
||||||
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
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);
|
let body = str(flags.body);
|
||||||
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
if (flags["body-file"]) body = fs.readFileSync(str(flags["body-file"]), "utf8");
|
||||||
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
|
||||||
@@ -883,6 +898,8 @@ commands.consolidate = ({ flags }) => {
|
|||||||
const front = [
|
const front = [
|
||||||
"---",
|
"---",
|
||||||
`name: ${name}`,
|
`name: ${name}`,
|
||||||
|
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
|
||||||
|
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
|
||||||
`type: ${type}`,
|
`type: ${type}`,
|
||||||
`about: [${csv(flags.about).join(", ") || "user"}]`,
|
`about: [${csv(flags.about).join(", ") || "user"}]`,
|
||||||
`topics: [${csv(flags.topics).join(", ")}]`,
|
`topics: [${csv(flags.topics).join(", ")}]`,
|
||||||
@@ -902,10 +919,13 @@ commands.consolidate = ({ flags }) => {
|
|||||||
const total = pl.rebuildIndex(slug);
|
const total = pl.rebuildIndex(slug);
|
||||||
const forget = num(flags.forget, null);
|
const forget = num(flags.forget, null);
|
||||||
if (forget !== null) {
|
if (forget !== null) {
|
||||||
const rows = pl.readJsonl(pl.shortTermPath(slug));
|
// 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4)
|
||||||
const keep = rows.filter((r) => Number(r.salience || 0) >= forget);
|
const cut = pl.forgetShortTerm(slug, forget);
|
||||||
pl.writeText(pl.shortTermPath(slug), keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : ""));
|
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${cut.kept} 筆。`);
|
||||||
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
|
if (cut.protected) {
|
||||||
|
say(` 其中 ${cut.protected} 筆低於門檻但受保護,沒有刪:` +
|
||||||
|
`顯著度 ≥ ${pl.SHORT_TERM_PROTECT_SALIENCE}、intent=commit,或 ${pl.SHORT_TERM_PROTECT_HOURS} 小時內。`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
||||||
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
|
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
|
||||||
|
|||||||
@@ -169,6 +169,58 @@ check("關鍵詞可檢索到",
|
|||||||
pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings");
|
pl.recall("alpha", "早上 會議").map((m) => m._name).join() === "hates-morning-meetings");
|
||||||
check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((r) => r.kind === "emotion"));
|
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("⑥ 短期 → 長期的轉入條件");
|
console.log("⑥ 短期 → 長期的轉入條件");
|
||||||
check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
|
check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
|
||||||
check("R1 高顯著度會成為候選",
|
check("R1 高顯著度會成為候選",
|
||||||
|
|||||||
Reference in New Issue
Block a user