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:
+59
-8
@@ -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 次讀檔、20ms;2000 節點時單輪 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user