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:
@@ -85,7 +85,8 @@ flowchart TB
|
||||
│ ├── sync.json # Gitea 同步狀態(最後 push / pull)
|
||||
│ └── config.json # 含人格編號 code
|
||||
├── memory/
|
||||
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
|
||||
│ ├── short-term.jsonl # 短期記憶(語意分析後;軟上限 120 筆 / 硬上限 240 筆 / 14 天)
|
||||
│ ├── felt.jsonl # 每輪讀到的對方情緒 + 自己套用的 delta(走向與偏差稽核)
|
||||
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter)
|
||||
│ ├── INDEX.md # 長期記憶索引(自動產生)
|
||||
│ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化
|
||||
@@ -142,7 +143,7 @@ flowchart TB
|
||||
| **R3** | 單筆情緒變動總量 ≥ 25 | `event`(帶情緒錨點) |
|
||||
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `promise` / `boundary`(salience ≥ 80,不可遺忘) |
|
||||
| **R5** | 同一人物(entity)≥ 2 筆 | `relationship`(並更新關係圖) |
|
||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間 |
|
||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度清出空間。**真的會清**:超過軟上限 120 筆時從顯著度最低、最舊的開始裁,但顯著度 ≥ 80 或 `intent=commit`(承諾/界線)與 24 小時內的新紀錄一律不動;裁掉幾筆會寫進 `sleep` 的回報,不做無聲的裁切 |
|
||||
|
||||
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
|
||||
`/jsc-persona:persona-memory`。
|
||||
|
||||
+54
-7
@@ -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);
|
||||
|
||||
+5
-3
@@ -483,8 +483,9 @@ commands.sleep = async ({ flags }) => {
|
||||
const steps = [];
|
||||
const run = async (step, fn) => {
|
||||
try {
|
||||
await fn();
|
||||
steps.push({ step, ok: true });
|
||||
const detail = await fn();
|
||||
// 有細節就帶回去(例如裁掉幾筆短期記憶)——不做無聲的裁切
|
||||
steps.push(detail && typeof detail === "object" ? { step, ok: true, detail } : { step, ok: true });
|
||||
} catch (err) {
|
||||
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
|
||||
steps.push({ step, ok: false, error: String(err.message || err).slice(0, 300) });
|
||||
@@ -501,7 +502,8 @@ commands.sleep = async ({ flags }) => {
|
||||
for (const name of seen) pl.stampContact(slug, name);
|
||||
pl.renderRelations(slug);
|
||||
});
|
||||
await run("prune-short-term", () => pl.pruneShortTerm(slug));
|
||||
// 不做無聲的裁切:清掉幾筆、為什麼清、保護了幾筆,都要寫進回報
|
||||
await run("prune-short-term", () => pl.pruneShortTermDetail(slug));
|
||||
await run("archive-threads", () => pl.archiveStaleThreads(slug));
|
||||
await run("emotion-decay", () => {
|
||||
const state = pl.decayEmotionBy(pl.loadEmotion(slug), pl.SLEEP_DECAY_MINUTES);
|
||||
|
||||
@@ -508,6 +508,31 @@ check("標點只放大既有訊號,不憑空長出新情緒",
|
||||
check("每種情緒都有對應的「怎麼接」",
|
||||
pl.EMOTION_KEYS.every((k) => typeof pl.RESPONSE_STANCE[k] === "string" && pl.RESPONSE_STANCE[k].length > 4));
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 短期記憶的容量壓力:R6 要真的會動,但不能把承諾與今天的紀錄清掉
|
||||
console.log("\n短期記憶:容量壓力(R6)真的會清,而且有保護");
|
||||
{
|
||||
const now = Date.now();
|
||||
const rows = [];
|
||||
for (let i = 0; i < 100; i += 1) rows.push({ ts: new Date(now - 2 * 86_400_000).toISOString(), text: `雜訊${i}`, salience: 30 + (i % 40) });
|
||||
for (let i = 0; i < 20; i += 1) rows.push({ ts: new Date(now - 2 * 86_400_000).toISOString(), text: `承諾${i}`, salience: 85, intent: "commit" });
|
||||
for (let i = 0; i < 30; i += 1) rows.push({ ts: new Date(now - 3_600_000).toISOString(), text: `剛剛${i}`, salience: 50 });
|
||||
fs.mkdirSync(path.join(H, "prunee", "memory"), { recursive: true });
|
||||
fs.writeFileSync(path.join(H, "prunee", "memory", "short-term.jsonl"), rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
||||
const detail = pl.pruneShortTermDetail("prunee");
|
||||
const left = pl.readJsonl(path.join(H, "prunee", "memory", "short-term.jsonl"));
|
||||
check("超過軟上限會清出空間(以前只有 240 硬上限,40 筆就叫但永遠清不掉)",
|
||||
detail.kept === pl.SHORT_TERM_SOFT_CAP && detail.by_capacity === 30, JSON.stringify(detail));
|
||||
check("承諾/界線(顯著度 ≥ 80 或 intent=commit)一則都不清",
|
||||
left.filter((r) => r.salience === 85).length === 20);
|
||||
check("24 小時內的新紀錄一則都不清(還沒機會被固化)",
|
||||
left.filter((r) => String(r.text).startsWith("剛剛")).length === 30);
|
||||
check("清的是顯著度最低的那些",
|
||||
Math.max(...rows.filter((r) => !left.some((l) => l.text === r.text)).map((r) => r.salience)) < 45);
|
||||
check("回報帶細節,不做無聲的裁切",
|
||||
typeof detail.dropped === "number" && typeof detail.protected === "number", JSON.stringify(detail));
|
||||
}
|
||||
|
||||
console.log("\n情緒:飽和、單輪預算、走向、偏差稽核");
|
||||
let sat = pl.defaultEmotionState();
|
||||
for (let i = 0; i < 10; i += 1) sat = pl.applyEmotion(sat, { joy: +20 }, "連灌");
|
||||
|
||||
@@ -37,7 +37,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_S
|
||||
| **R3** | 單筆情緒變動總量 ≥ 25 | `--type event`,frontmatter 帶 `emotion:` 錨點 |
|
||||
| **R4** | `intent=commit` 或命中承諾/界線關鍵詞 | `--type promise` 或 `boundary`,salience ≥ 80,**不可遺忘** |
|
||||
| **R5** | 同一 `entity`(人)≥ 2 筆 | `--type relationship`,同時更新人際關係圖 |
|
||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度排序清出空間,低於 40 的直接淘汰 |
|
||||
| **R6** | 短期記憶 ≥ 40 筆(容量壓力) | 依顯著度排序清出空間,低於 40 的直接淘汰。`sleep` 的裁切會在超過**軟上限 120 筆**時自動執行(從顯著度最低、最舊的開始),但**顯著度 ≥ 80 或 `intent=commit` 的承諾與界線、以及 24 小時內的新紀錄一律不動**——沒經過判斷就把今天清掉是這裡最不能犯的錯 |
|
||||
|
||||
規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。
|
||||
`Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。
|
||||
|
||||
Reference in New Issue
Block a user