fix(記憶固化): 判斷過的短期記憶留痕跡,候選數字才會降

睡眠有做固化,但隔天開機照樣提醒「N 組已達固化條件」,數字只會往上爬
(實測 KIRITO-01:短期記憶 120 筆、候選 222 組)。原因不是睡眠沒做事,
是判斷沒有留下痕跡——promotionCandidates() 無條件掃全部短期記憶,而
consolidate 不在來源那筆上寫任何東西,也不刪它(--forget 是按顯著度刪,
不是按固化過沒有刪),所以同一批每輪都被重算成候選。

- 短期記憶多兩個欄位:reviewed_at(看過、判斷過了)與 promoted_to
  (固化成了哪一則)。兩者分開記——「看過決定不記」跟「已經記下來」都不該
  再進候選,但事後要查「這則長期記憶從哪幾筆長出來」只能靠 promoted_to。
- promotionCandidates() 只看沒有 reviewed_at 的那些;total 照舊算全部。
  R6 容量壓力仍看總筆數,但候選只從未判斷的挑,全部判斷完就不再出現
  (否則它會單獨把提醒永遠點亮)。
- consolidate --from-short "#N,#N":寫長期記憶時一起標來源。
- candidates --reviewed all|<#N,#N> [--until <ISO>]:看過決定不記的標這裡。
  --until 讓「某次睡眠當下判斷過的那批」可以一次收掉。
- candidates 輸出每筆前面加 #N(就是上面兩個旗標要填的編號),並在結尾
  提示判斷完要標記。
- 標記不等於刪掉:那幾筆還在短期記憶裡,照舊被 prune 依天數與顯著度裁。

文件:persona-sleep 的流程多一步「標掉判斷過的」並註明漏掉的後果、
persona-memory 補一張「怎麼標」的表、README 的 R1–R6 段補這一層。
selftest 補 13 項(602 → 615 全綠)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 07:12:50 +00:00
co-authored by Claude Opus 5
parent 0917e905c7
commit b5c540feb1
6 changed files with 192 additions and 21 deletions
+59 -8
View File
@@ -2503,13 +2503,25 @@ function emotionImpact(entry) {
return Object.values(entry.emotion_deltas || {}).reduce((sum, v) => sum + Math.abs(Number(v) || 0), 0);
}
/** 這筆短期記憶已經被判斷過了嗎(固化了、或看過決定不固化)。 */
export const shortTermReviewed = (row) => Boolean(row?.reviewed_at);
/**
* 掃短期記憶,依 PROMOTION_RULES 算出「該轉入長期記憶」的候選。
* 回傳 { total, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
* 回傳 { total, reviewed, pressure, candidates: [{ rules, key, kind, entries, suggested_type, suggested_salience }] }
*
* **只看還沒被判斷過的那些**(沒有 `reviewed_at`)。以前這裡是無條件掃全部,
* 而 `consolidate` 不會在來源那筆上留任何痕跡、也不刪它(`--forget` 是按顯著度刪、
* 不是按「固化過沒有」刪)——所以同一批短期記憶每輪都被重算成候選,
* 提醒的數字只會往上爬,睡再多次也不會降。看起來像睡眠沒做固化,其實是計數從來沒扣過。
*/
export function promotionCandidates(slug) {
const rows = readJsonl(shortTermPath(slug));
const total = rows.length;
const all = readJsonl(shortTermPath(slug));
const total = all.length;
const reviewed = all.filter(shortTermReviewed).length;
// 判斷過的不再進候選,但保留原本的位置:`_index` 是 `consolidate --from-short`
// 與 `candidates --reviewed` 用來指名哪幾筆的座標,錯位就會標到別人身上。
const rows = all.map((row, index) => ({ ...row, _index: index })).filter((row) => !shortTermReviewed(row));
// 關係圖只讀一次:這個函式在每次 `remember` 之後都會跑,以前是「每筆的每個 entity
// 各讀一遍 graph.json」(實測 30 節點/240 筆 = 720 次讀檔、20ms2000 節點時單輪 1.2 秒)。
const nodes = loadRelations(slug).nodes;
@@ -2517,8 +2529,8 @@ export function promotionCandidates(slug) {
const byEntity = new Map();
const singles = [];
rows.forEach((row, index) => {
const entry = { ...row, _index: index };
rows.forEach((row) => {
const entry = row;
const salience = Number(row.salience || 0);
const impact = emotionImpact(row);
const text = String(row.text || "");
@@ -2582,10 +2594,11 @@ export function promotionCandidates(slug) {
});
}
}
// 容量壓力看的是**總筆數**(那是真的容量),但候選只能從還沒判斷過的裡面挑——
// 全部判斷完之後這一組就不該再出現,不然它會單獨把提醒永遠點亮。
const pressure = total >= CONSOLIDATE_THRESHOLD;
if (pressure) {
if (pressure && rows.length) {
const top = [...rows]
.map((r, i) => ({ ...r, _index: i }))
.sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0))
.slice(0, 5);
candidates.push({
@@ -2609,7 +2622,45 @@ export function promotionCandidates(slug) {
merged.set(dedupeKey, { ...cand });
}
}
return { total, pressure, candidates: [...merged.values()] };
return { total, reviewed, pressure, candidates: [...merged.values()] };
}
/**
* 把短期記憶標成「判斷過了」。這是候選計數唯一會往下扣的地方。
*
* 兩種痕跡分開記,因為它們的意思不同:
* `reviewed_at` 看過、判斷過了(不管有沒有固化)——候選就是看這個欄位
* `promoted_to` 固化成了哪一則長期記憶(只有真的寫成長期記憶才有)
*
* 「看過但決定不記」跟「已經記下來了」都不該再被算成候選,但事後回頭查
* 「這則長期記憶是從哪幾筆長出來的」只能靠 `promoted_to`,所以不能合成一個欄位。
*
* @param {object} opts
* @param {number[]|null} opts.indexes 要標的筆(`promotionCandidates` 給的 `_index`);null=全部
* @param {string|null} opts.until 只標這個時間之前的(ISO),配 `indexes: null` 用
* @param {string|null} opts.promotedTo 固化成哪一則長期記憶(有值才寫 `promoted_to`
*/
export function markShortTermReviewed(slug, { indexes = null, until = null, promotedTo = null } = {}) {
const wanted = indexes === null ? null : new Set(indexes.map(Number).filter(Number.isInteger));
const cutoff = until ? parseIso(until)?.getTime() ?? null : null;
const at = nowIso();
let marked = 0;
let already = 0;
rewriteJsonl(shortTermPath(slug), (all) => all.map((row, index) => {
if (wanted && !wanted.has(index)) return row;
if (cutoff !== null && (parseIso(row.ts)?.getTime() ?? 0) > cutoff) return row;
if (shortTermReviewed(row) && !promotedTo) {
already += 1;
return row;
}
marked += 1;
return {
...row,
reviewed_at: row.reviewed_at || at,
...(promotedTo ? { promoted_to: promotedTo } : {}),
};
}), { force: true });
return { marked, already };
}
/**