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_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) {