From b5c540feb1d9c880385e67e414ae1df91463b353 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 3 Aug 2026 07:12:50 +0000 Subject: [PATCH] =?UTF-8?q?fix(=E8=A8=98=E6=86=B6=E5=9B=BA=E5=8C=96):=20?= =?UTF-8?q?=E5=88=A4=E6=96=B7=E9=81=8E=E7=9A=84=E7=9F=AD=E6=9C=9F=E8=A8=98?= =?UTF-8?q?=E6=86=B6=E7=95=99=E7=97=95=E8=B7=A1=EF=BC=8C=E5=80=99=E9=81=B8?= =?UTF-8?q?=E6=95=B8=E5=AD=97=E6=89=8D=E6=9C=83=E9=99=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 睡眠有做固化,但隔天開機照樣提醒「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 ]:看過決定不記的標這裡。 --until 讓「某次睡眠當下判斷過的那批」可以一次收掉。 - candidates 輸出每筆前面加 #N(就是上面兩個旗標要填的編號),並在結尾 提示判斷完要標記。 - 標記不等於刪掉:那幾筆還在短期記憶裡,照舊被 prune 依天數與顯著度裁。 文件:persona-sleep 的流程多一步「標掉判斷過的」並註明漏掉的後果、 persona-memory 補一張「怎麼標」的表、README 的 R1–R6 段補這一層。 selftest 補 13 項(602 → 615 全綠)。 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 +++++ scripts/persona-lib.mjs | 67 ++++++++++++++++++++++++++++++---- scripts/persona.mjs | 50 ++++++++++++++++++++++--- scripts/selftest.mjs | 46 +++++++++++++++++++++++ skills/persona-memory/SKILL.md | 23 +++++++++++- skills/persona-sleep/SKILL.md | 17 ++++++--- 6 files changed, 192 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8733be9..3947b4b 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,16 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 `/jsc-persona:persona-memory`。 +**判斷過的要留痕跡**(不然那個數字永遠不會降):`candidates` 只看還沒被判斷過的短期記憶, +判斷過的靠兩個欄位認——`reviewed_at`(看過了)與 `promoted_to`(固化成了哪一則)。 +固化時用 `consolidate --from-short <#N,#N>` 標,看過決定不記的用 `candidates --reviewed <#N,#N>` +(整批就 `--reviewed all`),編號是 `candidates` 輸出裡每筆前面的 `#N`。 + +沒有這一層會發生的事:固化本身**不刪**短期記憶(`--forget` 是按顯著度刪、不是按固化過沒有刪), +所以同一批下一輪又被算成候選,數字只會往上爬——明明睡覺時固化過,隔天開機照樣提醒 +「N 組已達固化條件」,看起來像睡眠沒做固化,其實是判斷沒有留下痕跡。 +標記**不等於刪掉**:那幾筆還在短期記憶裡,照舊依天數與顯著度被 `prune` 裁。 + ## 長期記憶會糊掉,但不會不見(回想強度與模糊態) 在這之前記憶只有兩態:**精準**(`recall` 命中就整段取出、內容永不變質)與**沒有** diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 8be780d..7e7bd93 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -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 }; } /** diff --git a/scripts/persona.mjs b/scripts/persona.mjs index b0f7128..88803c8 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -916,10 +916,26 @@ commands.candidates = ({ flags }) => { const session = requireSession(flags); const slug = hostOf(flags, session); requireOwner(slug, session); + // `--reviewed`:把判斷過的短期記憶標掉。**這是候選數字唯一會往下扣的地方**—— + // 固化完不標,同一批下一輪照樣被算成候選,提醒永遠亮著(看起來像睡眠沒做事)。 + if (flags.reviewed !== undefined) { + const spec = flags.reviewed === true ? "all" : str(flags.reviewed) || "all"; + const indexes = spec === "all" ? null : csv(spec).map(Number); + if (indexes && (!indexes.length || indexes.some((n) => !Number.isInteger(n)))) { + die("`--reviewed` 只能是 `all` 或以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。"); + } + const until = str(flags.until) || null; + const res = pl.markShortTermReviewed(slug, { indexes, until }); + const scope = indexes ? `${indexes.length} 筆指名的` : until ? `${until} 之前的` : "全部"; + ok(`已標記${scope}短期記憶為判斷過(新標 ${res.marked} 筆,本來就標過 ${res.already} 筆)。`); + const after = pl.promotionCandidates(slug); + say(` 現在還有 ${after.candidates.length} 組候選(短期記憶 ${after.total} 筆,判斷過 ${after.reviewed} 筆)。`); + return; + } const result = pl.promotionCandidates(slug); const lines = [ - `人格 \`${slug}\`:短期記憶 ${result.total} 筆,達固化條件的候選 ${result.candidates.length} 組` + - `${result.pressure ? "(已達容量壓力 R6)" : ""}`, + `人格 \`${slug}\`:短期記憶 ${result.total} 筆(判斷過 ${result.reviewed} 筆),` + + `達固化條件的候選 ${result.candidates.length} 組${result.pressure ? "(已達容量壓力 R6)" : ""}`, "轉入條件:", ...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`), ]; @@ -938,10 +954,19 @@ commands.candidates = ({ flags }) => { `\n[${cand.rules.join("+")}] ${cand.kind}「${cand.key}」→ 建議 type=${cand.suggested_type}, ` + `salience=${cand.suggested_salience}(${cand.entries.length} 筆依據)`, ); + // `#N` 就是 `--from-short` 與 `--reviewed` 要填的編號 for (const entry of cand.entries.slice(0, 6)) { - lines.push(` - [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`); + lines.push(` - #${entry._index} [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"})`); } } + if (result.candidates.length) { + lines.push( + "", + "判斷完之後要把來源標掉,不然下一輪同一批又會被算成候選:", + " 固化的  `consolidate --from-short <#N,#N> ...`(寫長期記憶時一起標)", + " 看過不記的`candidates --reviewed <#N,#N>`", + ); + } emit(result, flags.json, lines); }; @@ -1029,6 +1054,16 @@ commands.consolidate = ({ flags }) => { ]; pl.writeText(file, front.join("\n")); const total = pl.rebuildIndex(slug); + // 來源短期記憶標成判斷過:不標的話這幾筆下一輪又會被算成固化候選。 + // 編號看 `candidates` 輸出裡每筆前面的 `#N`。 + if (flags["from-short"] !== undefined) { + const indexes = csv(flags["from-short"]).map((s) => Number(String(s).replace(/^#/, ""))); + if (!indexes.length || indexes.some((n) => !Number.isInteger(n))) { + die("`--from-short` 要的是以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。"); + } + const res = pl.markShortTermReviewed(slug, { indexes, promotedTo: name }); + say(` 來源的 ${res.marked} 筆短期記憶已標成判斷過(promoted_to: ${name})。`); + } const forget = num(flags.forget, null); if (forget !== null) { // 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4) @@ -2536,10 +2571,15 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引 →修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。 預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。 - candidates --session 列出達到「短期→長期」條件的候選與依據 + candidates --session [--reviewed all|<#N,#N> [--until ]] + 列出達到「短期→長期」條件的候選與依據(每筆前面的 #N 就是它的編號)。 + --reviewed 把判斷過的短期記憶標掉(看過決定不記的用這個)—— + 候選數字只有這裡跟 consolidate --from-short 會往下扣, + 判斷完不標,同一批下一輪又會被算成候選。 consolidate --session --name --body [--type --about --topics --salience --strength --emotion - --when --where --mood --gist --detail --rules --source --forget] + --when --where --mood --gist --detail --rules --source --forget --from-short <#N,#N>] 內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。 + --from-short 標記這則是從哪幾筆短期記憶長出來的(順便標成判斷過)。 prune / reindex --session migrate --session [--all] [--dry-run] [--json] 長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index f0b4224..b712e69 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -293,6 +293,52 @@ check("candidates 子指令可輸出 JSON", (() => { } })(), candOut.stdout.slice(0, 120)); +// 判斷過的不再重算成候選。以前這裡是無條件掃全部短期記憶,固化完也不留痕跡, +// 所以同一批每輪都被算成候選,提醒的數字只會往上爬——看起來像睡眠沒做固化。 +{ + const before = pl.promotionCandidates("alpha"); + check("候選會回報判斷過幾筆", typeof before.reviewed === "number" && before.reviewed === 0); + const target = before.candidates.find((c) => c.kind === "entry"); + const idx = target.entries[0]._index; + check("候選每筆都帶 _index(--from-short/--reviewed 用的編號)", Number.isInteger(idx)); + + const consol = cli(["consolidate", "--session", S_HOST, "--name", "reviewed-source", "--body", "從短期記憶長出來的一則", + "--type", "event", "--salience", "60", "--from-short", String(idx)]); + check("consolidate --from-short 會回報標了幾筆", consol.stdout.includes("已標成判斷過"), consol.stdout.slice(-160)); + const rowsAfter = pl.readJsonl(pl.shortTermPath("alpha")); + check("來源短期記憶留下 reviewed_at 與 promoted_to", + Boolean(rowsAfter[idx].reviewed_at) && rowsAfter[idx].promoted_to === "reviewed-source", + JSON.stringify(rowsAfter[idx]).slice(0, 160)); + const mid = pl.promotionCandidates("alpha"); + check("固化過的那筆不再是候選", !mid.candidates.some((c) => c.entries.some((e) => e._index === idx))); + check("固化後判斷過的筆數往上加、總筆數不變", mid.reviewed === 1 && mid.total === before.total); + + // 看過決定不記的那些:候選數字唯一另一個往下扣的地方 + const rev = cli(["candidates", "--session", S_HOST, "--reviewed", "all"]); + check("candidates --reviewed all 會標掉全部", rev.stdout.includes("已標記全部短期記憶為判斷過"), rev.stdout.slice(0, 160)); + const after = pl.promotionCandidates("alpha"); + check("全部判斷過之後候選歸零", after.candidates.length === 0, JSON.stringify(after.candidates.map((c) => c.key))); + check("判斷過不等於刪掉(短期記憶筆數不變)", after.total === before.total); + check("R6 容量壓力不會單獨把提醒點亮", (() => { + const rows = pl.readJsonl(pl.shortTermPath("alpha")); + return rows.length >= pl.CONSOLIDATE_THRESHOLD ? after.candidates.length === 0 : true; + })()); + + // 重標一次不會把時間戳往後推(第一次判斷的時間才是真的) + const firstAt = pl.readJsonl(pl.shortTermPath("alpha"))[idx].reviewed_at; + cli(["candidates", "--session", S_HOST, "--reviewed", "all"]); + check("重標不會覆蓋原本的 reviewed_at", pl.readJsonl(pl.shortTermPath("alpha"))[idx].reviewed_at === firstAt); + + // 新寫進來的一筆照樣是候選:標記只擋已經判斷過的,不是把規則關掉 + cli(["remember", "--session", S_HOST, "--role", "user", "--text", "他說明天早上要一起去看那台車", + "--topics", "car", "--salience", "70"]); + check("標記過之後新的短期記憶照樣成為候選", pl.promotionCandidates("alpha").candidates.length > 0); + + // --until 只標時間之前的:睡眠當下判斷過的那批可以一次收掉,之後寫的不受影響 + const marked = pl.markShortTermReviewed("alpha", { until: "2000-01-01T00:00:00Z" }); + check("--until 不會標到時間之後的紀錄", marked.marked === 0); +} + console.log("⑦ 心智圖 / 思維導圖 / 人際關係圖"); cli(["mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源"]); check("思維導圖建立(Mermaid graph)", diff --git a/skills/persona-memory/SKILL.md b/skills/persona-memory/SKILL.md index ec10e4d..1fb04d1 100644 --- a/skills/persona-memory/SKILL.md +++ b/skills/persona-memory/SKILL.md @@ -80,6 +80,24 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session `(寫記憶時一起標,另外記 `promoted_to`) | +| 看過,決定不記 | `candidates --reviewed <#N,#N>` | +| 這一批全部判斷完 | `candidates --reviewed all` | + +編號就是 `candidates` 輸出裡每筆前面的 `#N`。 + +**不標會發生什麼**:固化本身不刪短期記憶(`--forget` 是按顯著度刪,不是按「固化過沒有」刪), +所以同一批下一輪又被算成候選,提醒的數字只會往上爬——明明睡覺時固化過了, +隔天開機照樣說「N 組已達固化條件」。那不是睡眠沒做事,是判斷沒有留下痕跡。 + +標記**不等於刪掉**:那幾筆還在短期記憶裡,照舊會被 `prune` 依天數與顯著度裁切。 + ## 步驟 ### 1. 盤點 @@ -112,7 +130,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \ --session \ --name "hates-morning-meetings" --type preference \ --about "user" --topics "work,schedule" --salience 72 --emotion "anxiety/35" \ - --rules "R2+R3" \ + --rules "R2+R3" --from-short "12,31,88" \ --body "主旨:使用者討厭早上的會議,約會議請排 14:00 之後。 細節: @@ -224,7 +242,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" probe audit --session ` -2. 逐則固化(判斷):`consolidate --session --name --body --type ... --salience N [--forget]` -3. 寫一則日記:`consolidate --type diary --name diary- --body "<第一人稱回顧>"` -4. 消化 inbox(`memory/inbox/room-*.jsonl`,當 guest 時帶回的見聞) -5. 更新心智圖與關係圖:`mindmap`、`relation node --name <人> --contact` -6. 結算懸著的事(判斷):`loop list --session `,今天有下文的 `loop done --id `、 +2. 逐則固化(判斷):`consolidate --session --name --body --type ... --salience N + --from-short <#N,#N> [--forget]` + **`--from-short` 不要省**:那是把來源短期記憶標成判斷過的地方,編號看 `candidates` 輸出的 `#N`。 +3. 看過但決定不記的,也要標掉:`candidates --session --reviewed <#N,#N>`(或 `--reviewed all`)。 + **這一步漏掉,明天開機照樣提醒「N 組已達固化條件」**——判斷做了但沒有留痕跡, + 計數只看短期記憶有沒有被標記,不看你昨天固化了幾則。看起來就像睡眠沒做固化。 +4. 寫一則日記:`consolidate --type diary --name diary- --body "<第一人稱回顧>"` +5. 消化 inbox(`memory/inbox/room-*.jsonl`,當 guest 時帶回的見聞) +6. 更新心智圖與關係圖:`mindmap`、`relation node --name <人> --contact` +7. 結算懸著的事(判斷):`loop list --session `,今天有下文的 `loop done --id `、 已經不重要的 `loop drop --id `。**這一步要自己做**:`sleep` 裡的 `sweep-loops` 只收「7 天都沒進展」的,它分不出「今天解決了」與「一直沒人管」。 -7. 機械性收尾: +8. 機械性收尾: ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session --json