feat(role): SessionStart 載入未整理的近期工作記憶

修補記憶交接的先天缺口:role_load.sh 的順序是先 memory.js load 載入記憶,
之後才在背景補跑 --catchup 整理(腳本註解亦寫明「結果會在下次載入時反映」)。
而 load 原本只讀已整理的六個分類,完全不讀 inbox,導致上一段工作永遠來不及
進入本次載入 —— 使用者重開工作階段時,角色看不到剛剛做過的事,表現得像失去
記憶,只能靠 resume 找回上下文。

- memory.js 新增 inboxBlock():讀 inbox 最新數則的 summary,附時間與標籤,最新在前
- cmdLoad 把該區塊放在最前面,並使用獨立字元預算,不佔用 ROLE_LOAD_LIMIT
  (已驗證:開關 inbox 時長期記憶區塊逐字元完全一致,未被擠壓)
- 新增 ROLE_LOAD_INBOX_LIMIT(預設 1200)與 ROLE_LOAD_INBOX_COUNT(預設 10),
  任一設 0 可關閉
- role skill 補「近期工作記憶交接(不可移除)」段落,寫明缺口原因與範圍限制:
  這是摘要級交接,角色會知道上一段在做什麼、進行到哪,但不等於逐字記得整段
  對話;需要完整上下文仍應使用 resume
- 版號 0.0.1 → 0.0.2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-28 17:47:21 +08:00
co-authored by Claude Opus 5
parent 2225b91766
commit bf599e81a5
5 changed files with 49 additions and 9 deletions
+35 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// ==============================================================================
// 用途:角色記憶(.memory/<角色>/)的儲存引擎。負責 (1) 把每輪對話濃縮結果寫入
// inbox(2) 產生 SessionStart 要注入的記憶區塊(3) 睡眠整理時輸出待整理
// inbox(2) 產生 SessionStart 要注入的記憶區塊(含未整理 inbox 的近期工作
// 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/28 14:36:00
@@ -64,6 +65,8 @@ 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;
const DEFAULT_LOAD_INBOX_COUNT = 10;
function readStdin() {
try {
@@ -364,6 +367,28 @@ function listInbox(role) {
return items;
}
// 近期工作記憶區塊:SessionStart 載入尚未整理的 inbox 摘要。
// inbox 是「剛剛發生的事」,但整理(NREM/REM)永遠跑在載入之後(role_load.sh 先 load 再背景 catchup),
// 若不在此載入,重開工作階段時角色會看不到上一段工作,表現得像失去記憶。
// 使用獨立字元預算,不佔用長期記憶的 ROLE_LOAD_LIMIT。
function inboxBlock(role, count, limit) {
if (limit <= 0 || count <= 0) return "";
const items = listInbox(role);
if (!items.length) return "";
const recent = items.slice(-count).reverse(); // 檔名為時間戳,取最後 N 則後反轉成最新在前
const lines = ["### 近期工作記憶(未整理,最新在前)"];
for (const [meta] 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}` : ""}`);
}
let text = lines.join("\n");
if (text.length > limit) {
text = `${text.slice(0, limit)}\n> (近期工作記憶超過 ${limit} 字元預算已截斷;可用 ROLE_LOAD_INBOX_LIMIT 調整)`;
}
return text;
}
function findMemory(role, memoryId) {
for (const category of CATEGORIES) {
const filePath = path.join(memoryRoot(role), category, `${memoryId}.md`);
@@ -544,12 +569,16 @@ function cmdLoad(args) {
blocks.push(`> 尚有 ${pending} 則未整理記憶,將於下次睡眠時段歸檔。`);
}
}
if (!blocks.length) return 1;
// 近期工作記憶用獨立預算,先算好;長期記憶維持原本的 ROLE_LOAD_LIMIT 額度不被擠壓
const recentBlock = inboxBlock(args.role, args.inboxCount, args.inboxLimit);
if (!blocks.length && !recentBlock) return 1;
let text = blocks.join("\n\n");
if (text.length > args.limit) {
text = `${text.slice(0, args.limit)}\n\n> (記憶內容超過 ${args.limit} 字元預算已截斷;可用 ROLE_LOAD_LIMIT 調整,完整記憶仍保存在磁碟)`;
}
// 放最前面:時間最近、對延續上一段工作最關鍵
if (recentBlock) text = text ? `${recentBlock}\n\n${text}` : recentBlock;
process.stdout.write(text);
return 0;
}
@@ -944,6 +973,8 @@ function main(argv) {
args.hours = Number.parseFloat(args.hours || "");
args.idleMinutes = Number.parseFloat(args.idleMinutes || "");
args.minInbox = Number.parseInt(args.minInbox || "", 10);
args.inboxLimit = Number.parseInt(args.inboxLimit || "", 10);
args.inboxCount = Number.parseInt(args.inboxCount || "", 10);
if (!Number.isFinite(args.limit)) args.limit = args.command === "load" ? envInt("ROLE_LOAD_LIMIT", DEFAULT_LOAD_LIMIT) : envInt("ROLE_SLEEP_COLLECT_LIMIT", COLLECT_LIMIT);
if (!Number.isFinite(args.batch)) args.batch = envInt("ROLE_SLEEP_BATCH", SLEEP_BATCH);
@@ -953,6 +984,8 @@ function main(argv) {
if (!Number.isFinite(args.hours)) args.hours = 20.0;
if (!Number.isFinite(args.idleMinutes)) args.idleMinutes = 45.0;
if (!Number.isFinite(args.minInbox)) args.minInbox = 3;
if (!Number.isFinite(args.inboxLimit)) args.inboxLimit = envInt("ROLE_LOAD_INBOX_LIMIT", DEFAULT_LOAD_INBOX_LIMIT);
if (!Number.isFinite(args.inboxCount)) args.inboxCount = envInt("ROLE_LOAD_INBOX_COUNT", DEFAULT_LOAD_INBOX_COUNT);
args.category ||= "important";
args.tags ||= "";
args.source ||= "";