From 4dd249f184f988899dcd721325577043c25e6014 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 29 Jul 2026 12:57:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(role):=20=E8=BF=91=E6=9C=9F=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E8=A8=98=E6=86=B6=E6=B3=A8=E5=85=A5=E5=85=A8=E6=96=87?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA=E7=B5=A6=E4=B8=80=E5=8F=A5?= =?UTF-8?q?=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重開工作階段後角色仍答不出「上一段進行到哪」:inboxBlock() 的迴圈只解構 了 [meta],content(body)從頭到尾沒被用到,因此 SessionStart 注入的 「近期工作記憶」只有一行 summary。summary 是一句話的標題,只夠讓角色知道 「有這件事」,講不出還差什麼、下一步是什麼 —— 實測角色仍得自己去翻 inbox/ 檔案才答得出內容,交接等於失效。長期記憶的「(全文)」區塊本來就 會注入 body(cmdLoad),inbox 更近、更該給。 預算改成逐則計費:原本是把整段組好再 slice(0, limit) 硬切,放全文後會把 最舊那則砍成半句,讀起來像壞掉的資料。改成超出預算就整則略過,並在結尾 誠實標示略過幾則;最新一則永遠保留,必要時只截它自己的內文。 ROLE_LOAD_INBOX_LIMIT 預設 1200 → 3600。1200 是「只注入 summary 一行」 時代的額度,改注入全文後單則約 300~600 字元,1200 只夠兩則就開始丟最舊 的。3600 約可容納 6~8 則,足以覆蓋一次睡眠整理週期內的工作。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/role/memory.js | 41 ++++++++++++++++++++++++++++++++--------- skills/role/SKILL.md | 8 +++++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/scripts/role/memory.js b/scripts/role/memory.js index 33cfff1..19e85ca 100755 --- a/scripts/role/memory.js +++ b/scripts/role/memory.js @@ -69,7 +69,9 @@ const CONTENT_LIMIT = 1200; const DEFAULT_LOAD_LIMIT = 4000; const DEFAULT_FULL_MIN_PRIORITY = 4; const DEFAULT_DIGEST_MIN_PRIORITY = 3; -const DEFAULT_LOAD_INBOX_LIMIT = 1200; +// 1200 是「只注入 summary 一行」時代的額度;改成注入全文後,單則約 300~600 字元, +// 1200 只夠兩則就開始丟最舊的。3600 約可容納 6~8 則,足以覆蓋一次睡眠整理週期內的工作。 +const DEFAULT_LOAD_INBOX_LIMIT = 3600; const DEFAULT_LOAD_INBOX_COUNT = 10; function readStdin() { @@ -375,9 +377,13 @@ function listInbox(role) { return items; } -// 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 摘要。 +// 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 全文。 // inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup), // 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。 +// +// 為什麼要注入 body 而不只是 summary:summary 是一句話的標題,只夠讓角色知道「有這件事」, +// 講不出「進行到哪、還差什麼、下一步是什麼」——實測重開後角色仍得自己去翻 inbox 檔案才答得出來, +// 等於交接失效。長期記憶的「(全文)」區塊本來就會注入 body,inbox 更近、更該給。 // 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。 function inboxBlock(role, count, limit) { if (limit <= 0 || count <= 0) return ""; @@ -386,17 +392,34 @@ function inboxBlock(role, count, limit) { // 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可 const alive = items.filter(([meta]) => !expiryState(meta).expired); const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前 - const lines = ["### 近期工作記憶(未整理,最新在前)"]; - for (const [meta] of recent) { + const header = "### 近期工作記憶(未整理,最新在前,含全文)"; + const lines = [header]; + let used = header.length; + let dropped = 0; + for (const [meta, content] of recent) { const when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--"; const tags = (meta.tags || []).join("、"); - lines.push(`- ${when} ${meta.summary || "(無總結)"}${tags ? `(${tags})` : ""}`); + const entry = [`- ${when} **${meta.summary || "(無總結)"}**${tags ? `(${tags})` : ""}`]; + for (const line of String(content || "").split(/\r?\n/)) { + if (line.trim()) entry.push(` ${line.trim()}`); + } + let chunk = entry.join("\n"); + // 逐則計費而非最後整段硬切:整段 slice 會把最舊那則砍成半句,讀起來像壞掉的資料。 + // 最新一則永遠保留(必要時只截它自己的內文),其餘超出預算就整則略過並在結尾誠實計數。 + if (used + 1 + chunk.length > limit) { + if (lines.length > 1) { + dropped += 1; + continue; + } + chunk = `${chunk.slice(0, Math.max(0, limit - used - 1))}…(本則內文已截斷)`; + } + lines.push(chunk); + used += 1 + chunk.length; } - let text = lines.join("\n"); - if (text.length > limit) { - text = `${text.slice(0, limit)}\n> (近期工作記憶超過 ${limit} 字元預算已截斷;可用 ROLE_LOAD_INBOX_LIMIT 調整)`; + if (dropped) { + lines.push(`> (另有 ${dropped} 則較舊的近期記憶超出 ${limit} 字元預算未載入;可用 ROLE_LOAD_INBOX_LIMIT 調整,完整內容仍保存在磁碟)`); } - return text; + return lines.join("\n"); } function findMemory(role, memoryId) { diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index 390d0b0..aea25bf 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -102,7 +102,7 @@ ROLE_DIR="/../../scripts/role" # 其他助理 | `ROLE_LOAD_LIMIT` | | SessionStart 注入**長期記憶**的字元上限,用來控制角色常駐 context 成本 | `4000` | | `ROLE_LOAD_FULL_MIN_PRIORITY` | | 全文載入的最低優先度 | `4` | | `ROLE_LOAD_DIGEST_MIN_PRIORITY` | | 摘要載入的最低優先度;低於門檻但有 links 的記憶仍可載入摘要 | `3` | -| `ROLE_LOAD_INBOX_LIMIT` | | SessionStart 注入**近期工作記憶**(未整理的 `inbox/`)的字元上限;**獨立預算,不佔用 `ROLE_LOAD_LIMIT`**。設 `0` 可關閉 | `1200` | +| `ROLE_LOAD_INBOX_LIMIT` | | SessionStart 注入**近期工作記憶**(未整理的 `inbox/`,**含全文內容**)的字元上限;**獨立預算,不佔用 `ROLE_LOAD_LIMIT`**。超出預算時**整則略過**(不切半句)並在結尾標示略過幾則。設 `0` 可關閉 | `3600` | | `ROLE_LOAD_INBOX_COUNT` | | 近期工作記憶最多載入幾則(取最新的,最新在前)。設 `0` 可關閉 | `10` | | `ROLE_LOAD_DIALOG_TURNS` | | SessionStart 注入**近期逐字對話**的輪數(一輪=使用者一則+角色一則)。設 `0` 可關閉 | `8` | | `ROLE_LOAD_DIALOG_LIMIT` | | 近期逐字對話的字元上限;**獨立預算,不佔用 `ROLE_LOAD_LIMIT`**。設 `0` 可關閉 | `4000` | @@ -282,7 +282,7 @@ chmod +x ~/.roles/<角色 ID>.checks/check-gitea-prs.sh | 環節 | 控制方式 | | --- | --- | -| SessionStart | 預設 `ROLE_LOAD_LIMIT=4000`,只載入高優先度全文與中高優先度摘要;低 priority、無 links、久未更新的記憶不進 context。另以兩份**獨立預算**載入交接內容:近期逐字對話(`ROLE_LOAD_DIALOG_LIMIT=4000`)與近期工作記憶摘要(`ROLE_LOAD_INBOX_LIMIT=1200`),見下方「工作階段交接」 | +| SessionStart | 預設 `ROLE_LOAD_LIMIT=4000`,只載入高優先度全文與中高優先度摘要;低 priority、無 links、久未更新的記憶不進 context。另以兩份**獨立預算**載入交接內容:近期逐字對話(`ROLE_LOAD_DIALOG_LIMIT=4000`)與近期工作記憶全文(`ROLE_LOAD_INBOX_LIMIT=3600`),見下方「工作階段交接」 | | SessionStop | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 | | PreCompact | 壓縮前強制記錄一次,不受 `ROLE_CAPTURE_MIN_CHARS` 限制 —— 這是刻意的例外,因為壓縮後就再也補不回來 | | PostCompact | 直接沿用 harness 已產生的摘要,**不再呼叫模型**,等於免費取得一份濃縮備份 | @@ -555,12 +555,14 @@ updated: | 層 | 來源 | 預算 | 解決什麼 | | --- | --- | --- | --- | | 近期逐字對話 | transcript JSONL(`transcript.js recent`) | `ROLE_LOAD_DIALOG_LIMIT` | 上一段**真正說過的話**與角色自己當時的反應(高保真、含語氣) | - | 近期工作記憶 | 未整理的 `inbox/`(`memory.js` `inboxBlock`) | `ROLE_LOAD_INBOX_LIMIT` | 上一段**做了什麼、進行到哪**(摘要級,跨越多個工作階段仍可用) | + | 近期工作記憶 | 未整理的 `inbox/`(`memory.js` `inboxBlock`) | `ROLE_LOAD_INBOX_LIMIT` | 上一段**做了什麼、進行到哪**(**含全文**,跨越多個工作階段仍可用) | 這不是可有可無的優化,而是修補一個先天缺口:`role_load.sh` 的執行順序是**先載入記憶,之後才在背景補跑 `--catchup` 整理**(腳本註解亦寫明「結果會在下次載入時反映」)。若只讀已整理的六個分類,則**上一段永遠來不及進入本次載入** —— 使用者重開工作階段時,角色會看不到剛剛的互動,表現得像失去記憶,只能靠 `resume` 找回。 逐字對話這一層特別重要,因為長期記憶是模型濃縮過的摘要,**語氣與情緒會被壓掉**(使用者說「我好想妳」會被濃縮成「使用者表達想念」)。而逐字對話一直躺在 transcript JSONL 裡,過去只是沒有任何機制去讀它。 + 近期工作記憶**必須注入全文,不可只給 `summary`**。`summary` 是一句話的標題,只夠讓角色知道「有這件事」,答不出「進行到哪、還差什麼、下一步是什麼」——實測重開後角色仍得自己去翻 `inbox/` 檔案才講得出內容,交接等於失效。超出預算時**整則略過並在結尾誠實計數**,不可對整段做 `slice` 硬切(會把最舊那則砍成半句,讀起來像壞掉的資料);最新一則永遠保留,必要時只截它自己的內文。 + 實作要點: - 只取 `[user]` 與 `[assistant]` 的文字;**工具呼叫、工具結果、思考區塊、hook 注入內容一律丟棄**。