fix(role): 近期工作記憶注入全文,不再只給一句 summary

重開工作階段後角色仍答不出「上一段進行到哪」: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) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-29 12:57:57 +08:00
co-authored by Claude Opus 5
parent cfa44dea7f
commit 4dd249f184
2 changed files with 37 additions and 12 deletions
+32 -9
View File
@@ -69,7 +69,9 @@ const CONTENT_LIMIT = 1200;
const DEFAULT_LOAD_LIMIT = 4000; const DEFAULT_LOAD_LIMIT = 4000;
const DEFAULT_FULL_MIN_PRIORITY = 4; const DEFAULT_FULL_MIN_PRIORITY = 4;
const DEFAULT_DIGEST_MIN_PRIORITY = 3; 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; const DEFAULT_LOAD_INBOX_COUNT = 10;
function readStdin() { function readStdin() {
@@ -375,9 +377,13 @@ function listInbox(role) {
return items; return items;
} }
// 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 摘要 // 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 全文
// inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup), // inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup),
// 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。 // 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。
//
// 為什麼要注入 body 而不只是 summary:summary 是一句話的標題,只夠讓角色知道「有這件事」,
// 講不出「進行到哪、還差什麼、下一步是什麼」——實測重開後角色仍得自己去翻 inbox 檔案才答得出來,
// 等於交接失效。長期記憶的「(全文)」區塊本來就會注入 body,inbox 更近、更該給。
// 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。 // 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。
function inboxBlock(role, count, limit) { function inboxBlock(role, count, limit) {
if (limit <= 0 || count <= 0) return ""; if (limit <= 0 || count <= 0) return "";
@@ -386,17 +392,34 @@ function inboxBlock(role, count, limit) {
// 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可 // 已過期的臨時授權即使還在 inbox 也不該注入,否則會被當成當下有效的許可
const alive = items.filter(([meta]) => !expiryState(meta).expired); const alive = items.filter(([meta]) => !expiryState(meta).expired);
const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前 const recent = alive.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前
const lines = ["### 近期工作記憶(未整理,最新在前)"]; const header = "### 近期工作記憶(未整理,最新在前,含全文";
for (const [meta] of recent) { 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 when = typeof meta.created === "string" && meta.created.length >= 16 ? meta.created.slice(11, 16) : "--:--";
const tags = (meta.tags || []).join("、"); 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 text = lines.join("\n"); let chunk = entry.join("\n");
if (text.length > limit) { // 逐則計費而非最後整段硬切:整段 slice 會把最舊那則砍成半句,讀起來像壞掉的資料。
text = `${text.slice(0, limit)}\n> (近期工作記憶超過 ${limit} 字元預算已截斷;可用 ROLE_LOAD_INBOX_LIMIT 調整)`; // 最新一則永遠保留(必要時只截它自己的內文),其餘超出預算就整則略過並在結尾誠實計數。
if (used + 1 + chunk.length > limit) {
if (lines.length > 1) {
dropped += 1;
continue;
} }
return text; chunk = `${chunk.slice(0, Math.max(0, limit - used - 1))}…(本則內文已截斷)`;
}
lines.push(chunk);
used += 1 + chunk.length;
}
if (dropped) {
lines.push(`> (另有 ${dropped} 則較舊的近期記憶超出 ${limit} 字元預算未載入;可用 ROLE_LOAD_INBOX_LIMIT 調整,完整內容仍保存在磁碟)`);
}
return lines.join("\n");
} }
function findMemory(role, memoryId) { function findMemory(role, memoryId) {
+5 -3
View File
@@ -102,7 +102,7 @@ ROLE_DIR="<skill base directory>/../../scripts/role" # 其他助理
| `ROLE_LOAD_LIMIT` | | SessionStart 注入**長期記憶**的字元上限,用來控制角色常駐 context 成本 | `4000` | | `ROLE_LOAD_LIMIT` | | SessionStart 注入**長期記憶**的字元上限,用來控制角色常駐 context 成本 | `4000` |
| `ROLE_LOAD_FULL_MIN_PRIORITY` | | 全文載入的最低優先度 | `4` | | `ROLE_LOAD_FULL_MIN_PRIORITY` | | 全文載入的最低優先度 | `4` |
| `ROLE_LOAD_DIGEST_MIN_PRIORITY` | | 摘要載入的最低優先度;低於門檻但有 links 的記憶仍可載入摘要 | `3` | | `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_INBOX_COUNT` | | 近期工作記憶最多載入幾則(取最新的,最新在前)。設 `0` 可關閉 | `10` |
| `ROLE_LOAD_DIALOG_TURNS` | | SessionStart 注入**近期逐字對話**的輪數(一輪=使用者一則+角色一則)。設 `0` 可關閉 | `8` | | `ROLE_LOAD_DIALOG_TURNS` | | SessionStart 注入**近期逐字對話**的輪數(一輪=使用者一則+角色一則)。設 `0` 可關閉 | `8` |
| `ROLE_LOAD_DIALOG_LIMIT` | | 近期逐字對話的字元上限;**獨立預算,不佔用 `ROLE_LOAD_LIMIT`**。設 `0` 可關閉 | `4000` | | `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 | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 | | SessionStop | 先用本地規則略過短回合與無記憶線索的對話,只有值得保存才呼叫模型做輕量編碼 |
| PreCompact | 壓縮前強制記錄一次,不受 `ROLE_CAPTURE_MIN_CHARS` 限制 —— 這是刻意的例外,因為壓縮後就再也補不回來 | | PreCompact | 壓縮前強制記錄一次,不受 `ROLE_CAPTURE_MIN_CHARS` 限制 —— 這是刻意的例外,因為壓縮後就再也補不回來 |
| PostCompact | 直接沿用 harness 已產生的摘要,**不再呼叫模型**,等於免費取得一份濃縮備份 | | PostCompact | 直接沿用 harness 已產生的摘要,**不再呼叫模型**,等於免費取得一份濃縮備份 |
@@ -555,12 +555,14 @@ updated: <yyyy/MM/dd HH:mm:ss>
| 層 | 來源 | 預算 | 解決什麼 | | 層 | 來源 | 預算 | 解決什麼 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 近期逐字對話 | transcript JSONL`transcript.js recent` | `ROLE_LOAD_DIALOG_LIMIT` | 上一段**真正說過的話**與角色自己當時的反應(高保真、含語氣) | | 近期逐字對話 | 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` 找回。 這不是可有可無的優化,而是修補一個先天缺口:`role_load.sh` 的執行順序是**先載入記憶,之後才在背景補跑 `--catchup` 整理**(腳本註解亦寫明「結果會在下次載入時反映」)。若只讀已整理的六個分類,則**上一段永遠來不及進入本次載入** —— 使用者重開工作階段時,角色會看不到剛剛的互動,表現得像失去記憶,只能靠 `resume` 找回。
逐字對話這一層特別重要,因為長期記憶是模型濃縮過的摘要,**語氣與情緒會被壓掉**(使用者說「我好想妳」會被濃縮成「使用者表達想念」)。而逐字對話一直躺在 transcript JSONL 裡,過去只是沒有任何機制去讀它。 逐字對話這一層特別重要,因為長期記憶是模型濃縮過的摘要,**語氣與情緒會被壓掉**(使用者說「我好想妳」會被濃縮成「使用者表達想念」)。而逐字對話一直躺在 transcript JSONL 裡,過去只是沒有任何機制去讀它。
近期工作記憶**必須注入全文,不可只給 `summary`**。`summary` 是一句話的標題,只夠讓角色知道「有這件事」,答不出「進行到哪、還差什麼、下一步是什麼」——實測重開後角色仍得自己去翻 `inbox/` 檔案才講得出內容,交接等於失效。超出預算時**整則略過並在結尾誠實計數**,不可對整段做 `slice` 硬切(會把最舊那則砍成半句,讀起來像壞掉的資料);最新一則永遠保留,必要時只截它自己的內文。
實作要點: 實作要點:
- 只取 `[user]` 與 `[assistant]` 的文字;**工具呼叫、工具結果、思考區塊、hook 注入內容一律丟棄**。 - 只取 `[user]` 與 `[assistant]` 的文字;**工具呼叫、工具結果、思考區塊、hook 注入內容一律丟棄**。