fix(role): 修好睡眠整理死鎖 —— 素材不再硬切、批次降為 4、錯誤訊息可診斷

角色整晚沒睡:05:03 到 08:33 每 10 分鐘試一次,連續 22 次全部失敗,
last_sleep 停在前一天 15:02,inbox 從 12 累積到 23 則。
錯誤訊息只有一句「整理結果無法套用」,看不出任何原因。

診斷過程中推論錯了三次,過程留在註解裡(每一次都很容易再犯):

1. 推論「輸出被 SLEEP_OUTPUT_LIMIT 截斷」→ 實測輸出僅 5772~6224,遠未達 8000。錯。
2. 推論「素材字元數過大」→ 實測素材 8815(6 則)失敗、8940(3 則)成功,
   字元數幾乎相同。錯。
3. 推論「純粹是則數問題」→ 對一半:另有一種失敗是 CLI 回傳 Execution error。

真相是兩種失敗混在一起,而錯誤訊息把它們蓋成同一句話:
- CLI 偶發 Execution error → 需要重試
- 一次要求模型輸出太多筆 JSON → 需要降批

修正:

- 錯誤訊息附上素材大小、輸出長度、批次與輸出前 200 字元(已 redact)
  —— 這是最先做的一步,沒有它只能靠猜
- cmdCollect 不再 slice() 硬切素材:改為逐則累加、超出預算留到下批,
  並替 EXISTING 保留固定比例預算。舊版會切在記憶中間、甚至切掉整個 EXISTING 區塊,
  而批次固定時每輪都收到同樣殘缺的素材 → 死鎖
- SLEEP_BATCH 12 → 4(實測 3~4 則穩定、6 則以上開始失敗)
- 失敗時逐次降批(4→2→1)作為保險,仍失敗才留到下個週期
- 批次預設值統一來源:role_sleep.sh 首次收集不傳 --batch,實際則數由素材反推
  —— 原本兩邊各寫一個預設,改了 memory.js 的 SLEEP_BATCH 卻不會生效

實測:修正後第一次嘗試即成功,未觸發降批;連續整理清空 inbox(23 → 0),
情緒型態記憶由 1 增至 9,關係史累積 13 則。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-30 10:22:23 +08:00
co-authored by Claude Opus 5
parent adc5521302
commit 9512293dc9
6 changed files with 178 additions and 71 deletions
+82 -32
View File
@@ -5,7 +5,7 @@
// 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/29 18:56:33
// 更新時間:2026/07/30 10:22:03
// 相依:Node.js 標準庫。
// 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
// ==============================================================================
@@ -102,12 +102,28 @@ function warnIfSimplified(role, where, ...texts) {
const DECLARATIVE_VALUES = ["explicit", "implicit"];
const RETENTION_STAGES = ["working", "long_term"];
// 單批上限刻意壓在 12:每則整理結果約需 650 字元(summarycontent/各欄位),
// 而 SLEEP_OUTPUT_LIMIT 預設 80008000÷650≈12。原本預設 60 與輸出上限矛盾 ——
// 實測 25 則的素材達 25798 位元組、輸出 JSON 被截斷成不合法格式,整批整理直接失敗。
// 寧可分多批各自成功,也不要一次做完卻全部失敗
const SLEEP_BATCH = 12;
const COLLECT_LIMIT = 12000;
// 單批則數。這個值被實測修正過三次,過程值得留著:
//
// 1. 原本預設 60,與 SLEEP_OUTPUT_LIMIT 8000 矛盾 —— 25 則的素材達 25798 位元組,
// 輸出 JSON 被截斷成不合法格式,整批整理直接失敗。於是壓到 12
// 2. 12 仍然失敗,且連續 22 次全部失敗(inbox 從 12 累積到 23 則,角色整晚沒睡)。
// 當時推論是「輸出被截斷」,但實測輸出只有 5772~6224 字元、遠未達 8000 上限 —— 推論錯。
// 3. 再推論是「素材字元數過大」,把 COLLECT_LIMIT 降到 9000。結果素材 8815(6 則)失敗、
// 素材 8940(3 則)成功 —— 字元數幾乎相同,差別只在**則數**。推論再次錯。
//
// 結論:限制因素是「一次要求模型輸出幾筆結構化 JSON」,不是素材或輸出的字元量。
// 則數越多,模型在中途寫壞引號或轉義的機率越高,而 JSON 壞一個字元整批就套用失敗。
// 實測 3~4 則穩定成功、6 則以上開始失敗,因此預設 4。
//
// 批次變小的代價是清空 inbox 需要更多輪,但每輪成功率高得多,
// 總成本反而低於「一次做很多、失敗後靠降批重跑三趟」。
const SLEEP_BATCH = 4;
// 素材字元預算。實測界線約在 10000:素材 9739 與 4592 都成功,11009 與 11834 都失敗
// (失敗形式是模型輸出的 JSON 不合法,且輸出長度遠未達 SLEEP_OUTPUT_LIMIT
// 所以不是輸出被截斷,而是一次要求整理太多則時模型的結構化輸出品質下降)。
// 預設值原為 12000 —— 剛好落在必然失敗的區間,於是每輪都得靠降批重試才成功,
// 白跑兩趟 CLI。降到 9000 讓第一次嘗試就落在可成功的範圍,降批機制只作為保險。
const COLLECT_LIMIT = 9000;
const EXISTING_INDEX_LIMIT = 120;
const CONTENT_LIMIT = 1200;
const DEFAULT_LOAD_LIMIT = 4000;
@@ -713,25 +729,54 @@ function cmdCollect(args) {
const collectLimit = Math.max(2000, args.limit);
const existingLimit = Math.max(0, args.existingLimit);
const all = listInbox(args.role);
const inbox = all.slice(0, batchSize);
if (!inbox.length) return 1;
// 明確標示本批未處理的量,避免使用者誤以為已全部整理完
const deferred = all.length - inbox.length;
if (!all.length) return 1;
const lines = deferred > 0
? [`=== INBOX(待整理,每則以 id 標識;本批 ${inbox.length} 則,另有 ${deferred} 則留待下批)===`]
: ["=== INBOX(待整理,每則以 id 標識)==="];
for (const [meta, content] of inbox) {
lines.push(`--- id: ${meta.id} | 時間: ${meta.created || "-"} ---`);
lines.push(`初判分類: ${CATEGORY_LABELS[meta.category || "other"] || "其他"}`);
lines.push(`初判總結: ${meta.summary || ""}`);
lines.push(`初判標籤: ${(meta.tags || []).join("、") || "無"}`);
lines.push(`初判優先度: ${normalizePriority(meta.priority, meta.category || "other")}`);
lines.push(`初判關聯: ${(meta.relevance || []).join("、") || "-"}`);
lines.push(`初判記憶型態: ${meta.memory_type} / ${meta.declarative} / ${meta.retention_stage}`);
lines.push("內容:", content, "");
// 素材必須是**結構完整**的,寧可少收一則也不能切斷。
//
// 舊版是先把整份素材組好、最後 text.slice(0, collectLimit) 硬切 —— 實測會切在某則記憶
// 內容中間,甚至把整個 EXISTING 區塊切掉。LLM 拿到殘缺素材就產出不合法的 JSON,
// apply 必然失敗;而批次大小固定,下一輪又收到同樣被截斷的素材 → **死鎖**:
// 實測連續 22 次全部失敗、inbox 從 12 累積到 23 則,角色整晚沒睡。
//
// 因此改為逐則累加、超出預算就留到下批,並替 EXISTING 保留固定比例的預算。
const inboxBudget = Math.max(1000, Math.floor(collectLimit * 0.75));
const blocks = [];
let used = 0;
let taken = 0;
for (const [meta, content] of all.slice(0, batchSize)) {
let body = content;
const head = [
`--- id: ${meta.id} | 時間: ${meta.created || "-"} ---`,
`初判分類: ${CATEGORY_LABELS[meta.category || "other"] || "其他"}`,
`初判總結: ${meta.summary || ""}`,
`初判標籤: ${(meta.tags || []).join("、") || "無"}`,
`初判優先度: ${normalizePriority(meta.priority, meta.category || "other")}`,
`初判關聯: ${(meta.relevance || []).join("、") || "-"}`,
`初判記憶型態: ${meta.memory_type} / ${meta.declarative} / ${meta.retention_stage}`,
"內容:",
].join("\n");
// 單則就超出預算時(極長記憶)才截斷它自己的內容,並明講截斷了 ——
// 這是保底,否則批次降到 1 仍然過大就永遠無法前進。
if (taken === 0 && head.length + body.length > inboxBudget) {
body = `${body.slice(0, Math.max(200, inboxBudget - head.length - 40))}\n…(本則內容過長已截斷)…`;
}
const chunk = `${head}\n${body}\n`;
// 第一則一定收,否則整批永遠不會前進
if (taken > 0 && used + chunk.length > inboxBudget) break;
blocks.push(chunk);
used += chunk.length + 1;
taken += 1;
}
const deferred = all.length - taken;
const lines = [
deferred > 0
? `=== INBOX(待整理,每則以 id 標識;本批 ${taken} 則,另有 ${deferred} 則留待下批)===`
: "=== INBOX(待整理,每則以 id 標識)===",
...blocks,
];
lines.push("=== EXISTING(既有記憶索引,供去重與合併判斷)===");
const rows = [];
for (const category of CATEGORIES) {
@@ -740,18 +785,23 @@ function cmdCollect(args) {
rows.push(`- id: ${meta.id} | 分類: ${CATEGORY_LABELS[category]} | 優先度: ${normalizePriority(meta.priority, category)} | 型態: ${meta.memory_type}/${meta.declarative}/${meta.retention_stage} | 標籤: ${tags} | 關聯: ${(meta.relevance || []).join("、") || "-"} | links: ${(meta.links || []).join("、") || "-"} | 總結: ${meta.summary || ""}`);
}
}
if (rows.length) {
lines.push(...rows.slice(0, existingLimit));
if (rows.length > existingLimit) lines.push(`…(既有記憶索引超過 ${existingLimit} 則,已依優先度與更新時間截斷)…`);
} else {
if (!rows.length) {
lines.push("(尚無既有記憶)");
} else {
// EXISTING 用剩下的預算,逐行加到放不下為止;整行放不下就不放,不切半行
let remain = collectLimit - lines.join("\n").length;
let shown = 0;
for (const row of rows.slice(0, existingLimit)) {
if (row.length + 1 > remain) break;
lines.push(row);
remain -= row.length + 1;
shown += 1;
}
const omitted = Math.min(rows.length, existingLimit) - shown + Math.max(0, rows.length - existingLimit);
if (omitted > 0) lines.push(`…(既有記憶索引另有 ${omitted} 則未列入,已依優先度與更新時間排序)…`);
}
let text = lines.join("\n");
if (text.length > collectLimit) {
text = `${text.slice(0, collectLimit)}\n…(睡眠整理素材超過 ${collectLimit} 字元預算已截斷,其餘留待下個睡眠週期)…`;
}
process.stdout.write(text);
process.stdout.write(lines.join("\n"));
return 0;
}