fix(role): 睡眠整理死鎖修復 + 繁簡防線(0.1.5) #29

Merged
admin merged 3 commits from develop into master 2026-07-30 02:28:24 +00:00
6 changed files with 238 additions and 75 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-generic",
"version": "0.1.4",
"version": "0.1.5",
"description": "JSC 跨 AI 助理共用規範 pluginClaude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。",
"skills": "./skills",
"author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-generic",
"version": "0.1.4",
"version": "0.1.5",
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。",
"skills": "./skills"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-generic",
"version": "0.1.4",
"version": "0.1.5",
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。",
"skills": "./skills/"
}
+129 -36
View File
@@ -5,7 +5,7 @@
// 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理
// 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、
// 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。
// 更新時間:2026/07/29 17:16:12
// 更新時間:2026/07/30 10:22:03
// 相依:Node.js 標準庫。
// 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。
// ==============================================================================
@@ -62,15 +62,68 @@ const MEMORY_TYPE_WEIGHT = {
semantic: 30,
episodic: 30,
};
// ── 繁簡防線(第二道)────────────────────────────────────────────────────────
// prompt 已明令輸出繁體(capture 第 8 條、sleep 第 16 條),但實測仍出現整則簡體記憶,
// 而且該則的 sources 指向另一個專案 —— 不同環境下 CLI 的行為並不一致,光靠 prompt 擋不住。
// 這裡是寫檔前的第二道防線。
//
// 只轉換「一簡對一繁、無歧義」的字。歧義字刻意**不自動轉換**:
// 发→發/髮、干→乾/幹、后→後/后、里→裡/里、复→復/複/覆、系→系/係/繫、余→余/餘…
// 機械替換會產生「頭發」這種比留著簡體更難發現的錯誤,改為警告並留待整理階段依上下文處理。
const ZH_SAFE_PAIRS = "这這个個们們说說时時间間过過还還没沒对對认認为為经經开開单單区區华華严嚴来來长長问問题題无無现現实實电電话話车車门門马馬东東书書学學习習应應该該让讓给給从從众眾会會体體万萬与與专專业業两兩广廣庆慶亚亞产產亲親亿億仅僅仓倉仪儀优優伟偉传傳伤傷伦倫伪偽侠俠侦偵侧側侨僑俭儉债債倾傾偿償储儲兰蘭关關兴興养養兽獸内內决決况況冻凍净淨减減凑湊几幾凭憑击擊刘劉则則刚剛创創删刪别別剧劇劳勞势勢动動励勵汇匯医醫协協卖賣卢盧卫衛却卻厂廠压壓厌厭厅廳县縣参參双雙变變叙敘叹嘆叶葉号號吗嗎员員响響唤喚团團园園围圍图圖圆圓圣聖场場坏壞块塊坚堅坛壇垒壘执執扩擴扫掃扬揚拟擬择擇挂掛挥揮损損换換据據搅攪摆擺摄攝撑撐敌敵数數斗鬥断斷旧舊显顯晓曉暂暫术術机機杂雜权權条條极極构構标標树樹样樣桥橋检檢楼樓横橫欢歡欧歐汉漢汤湯沟溝泪淚泼潑洁潔济濟测測浏瀏涛濤涨漲渐漸渔漁渗滲温溫满滿滚滾滤濾滨濱潜潛澜瀾灭滅灯燈灵靈灾災炉爐烂爛烦煩热熱爱愛爷爺牵牽牺犧犹猶独獨狮獅猪豬猫貓献獻环環玛瑪琼瓊画畫畅暢疗療痒癢瘫癱盘盤监監盖蓋盗盜瞒瞞矫矯码碼碍礙确確礼禮祸禍禅禪离離种種积積称稱稳穩穷窮窃竊窜竄竞競笔筆笼籠筑築简簡篮籃类類粮糧紧緊纠糾红紅约約级級纪紀纬緯纯純纱紗纲綱纳納纵縱纷紛纸紙纹紋纺紡纽紐线線练練组組绅紳细細织織终終绊絆绍紹绎繹绑綁绒絨结結绕繞绘繪络絡绝絕绞絞统統绢絹绣繡继繼绩績绪緒续續绳繩维維绵綿综綜绿綠缀綴缓緩缔締编編缘緣缚縛缝縫缠纏缩縮缴繳网網罚罰罢罷罗羅义義耻恥聋聾职職联聯聪聰肃肅肠腸肤膚胀脹胆膽脉脈脑腦脱脫腊臘腻膩脸臉舆輿舰艦舱艙艰艱艺藝节節芦蘆苏蘇苹蘋茎莖荐薦荣榮药藥莱萊获獲萧蕭蓝藍虏虜虑慮虫蟲蚀蝕蚁蟻蛮蠻蜡蠟蝇蠅补補衬襯装裝见見观觀觉覺览覽触觸誉譽计計订訂议議讯訊记記讲講许許论論讼訟讽諷设設访訪证證评評识識诉訴诊診词詞译譯试試诗詩诚誠详詳语語误誤诱誘诵誦请請诸諸诺諾读讀课課调調谅諒谈談谋謀谎謊谐諧谓謂谜謎谢謝谣謠谤謗谦謙谨謹谱譜贝貝贞貞负負贡貢财財责責贤賢败敗货貨质質贩販贪貪贫貧贬貶购購贯貫贱賤贴貼贵貴贷貸贸貿费費贺賀贼賊贾賈贿賄资資赁賃赃贓赋賦赌賭赎贖赏賞赐賜赔賠赖賴赘贅赚賺赛賽赞贊赠贈赡贍赢贏赶趕趋趨轧軋轨軌转轉轮輪软軟轰轟轴軸轻輕载載辅輔辆輛辈輩辉輝输輸边邊达達迁遷运運进進远遠违違连連迟遲适適选選逊遜递遞逻邏遗遺邓鄧邮郵邻鄰郑鄭释釋针針钉釘钢鋼钥鑰钦欽钩鉤钱錢钳鉗钻鑽铁鐵铃鈴铅鉛铜銅铝鋁银銀铸鑄铺鋪链鏈销銷锁鎖锅鍋锋鋒错錯锦錦键鍵锯鋸镇鎮镜鏡闪閃闭閉闯闖闲閒闷悶闹鬧闻聞阀閥阁閣阅閱阔闊队隊阳陽阴陰阵陣际際陆陸陈陳陕陝险險随隨隐隱难難雾霧韦韋韧韌韩韓页頁顶頂顷頃项項顺順须須顽頑顾顧顿頓颁頒预預领領颇頗颈頸频頻额額颤顫风風飘飄飞飛饥飢饭飯饮飲饰飾饱飽饲飼饺餃饼餅饿餓馆館馈饋驭馭驯馴驰馳驱驅驳駁驴驢驻駐驼駝驾駕骂罵骄驕验驗骑騎骗騙骚騷骤驟鱼魚鲁魯昵暱桩樁谘諮侣侶侥僥仑侖头頭点點总總当當张張报報买買乐樂气氣户戶态態忆憶军軍办辦听聽声聲处處虚虛丰豐宾賓龙龍鸟鳥丽麗齐齊庄莊农農乡鄉阶階颜顏尽盡毕畢状狀奖獎纤纖盐鹽础礎硕碩竖豎筹籌肿腫胁脅荡蕩苍蒼梦夢杀殺杨楊枪槍柜櫃栏欄洒灑泽澤浓濃润潤湾灣湿濕烛燭烧燒营營猎獵疯瘋护護挡擋拥擁挤擠捡撿举舉录錄归歸彻徹恶惡悬懸惊驚惧懼惯慣愿願戏戲战戰扰擾抚撫拨撥拦攔摊攤旷曠毁毀狱獄阐闡陨隕隶隸韵韻颂頌颖穎馊餿骏駿鲜鮮鸡雞鸣鳴鸿鴻鹅鵝鹏鵬鹰鷹麦麥黄黃齿齒龄齡遥遙静靜颅顱";
const ZH_SAFE = (() => {
const m = Object.create(null);
for (let i = 0; i < ZH_SAFE_PAIRS.length; i += 2) m[ZH_SAFE_PAIRS[i]] = ZH_SAFE_PAIRS[i + 1];
return m;
})();
const ZH_AMBIGUOUS = new Set(["丑", "么", "云", "伙", "余", "党", "准", "划", "卷", "历", "发", "只", "台", "后", "咨", "回", "复", "宁", "干", "扑", "朴", "签", "系", "脏", "著", "里", "钟", "雕"]);
function toTraditional(value) {
if (typeof value !== "string" || !value) return value;
let out = "";
for (const ch of value) out += ZH_SAFE[ch] || ch;
return out;
}
function ambiguousSimplified(value) {
const hit = new Set();
for (const ch of String(value || "")) if (ZH_AMBIGUOUS.has(ch)) hit.add(ch);
return [...hit];
}
function warnIfSimplified(role, where, ...texts) {
// 只警告不阻斷:記憶寧可帶著瑕疵留下,也不能因為用字問題而遺失
const hit = ambiguousSimplified(texts.join(" "));
if (hit.length) {
process.stderr.write(`[memory][WRN]: ${role}${where}含歧義簡體字(${hit.join("")}),未自動轉換,請於整理階段依上下文修正\n`);
}
}
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;
@@ -544,6 +597,11 @@ function parseCapture(text) {
function cmdWrite(args) {
const parsed = parseCapture(readStdin());
if (!parsed.content && !parsed.summary) return 1;
// 繁簡防線:prompt 已要求繁體,這裡在寫檔前再擋一次(實測有整則簡體記憶落檔)
warnIfSimplified(args.role, "待寫入記憶", parsed.summary || "", parsed.content || "", (parsed.tags || []).join(""));
parsed.summary = toTraditional(parsed.summary);
parsed.content = toTraditional(parsed.content);
parsed.tags = (parsed.tags || []).map(toTraditional);
const content = (parsed.content || parsed.summary).slice(0, CONTENT_LIMIT);
const stamp = nowStamp();
const memoryType = normalizeMemoryType(parsed.memoryType, parsed.category);
@@ -671,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) {
@@ -698,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;
}
@@ -800,7 +892,7 @@ function readBonds(role) {
}
function appendBond(role, entry, direction) {
const text = oneLine(entry, 160);
const text = toTraditional(oneLine(entry, 160));
if (!text) return false;
const line = `- ${nowStamp().slice(0, 16)}${BOND_DIRECTIONS[normalizeBondDirection(direction)]}${text}`;
const previous = readBonds(role);
@@ -889,9 +981,10 @@ function cmdApply(args) {
continue;
}
const content = String(entry.content || "").trim().slice(0, CONTENT_LIMIT);
const summary = oneLine(entry.summary);
const tags = normalizeTags(entry.tags);
warnIfSimplified(args.role, "整理結果", String(entry.summary || ""), String(entry.content || ""));
const content = toTraditional(String(entry.content || "").trim().slice(0, CONTENT_LIMIT));
const summary = toTraditional(oneLine(entry.summary));
const tags = normalizeTags(entry.tags).map(toTraditional);
const entryCategory = normalizeCategory(entry.category);
const priority = normalizePriority(entry.priority, entryCategory);
const relevance = normalizeList(entry.relevance);
+68 -36
View File
@@ -7,7 +7,7 @@
# 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、
# --force(手動立即整理)、--export(匯出角色壓縮檔)、
# --install-cron--remove-cron(排程安裝與移除)、--status(狀態)。
# 更新時間:2026/07/29 18:39:54
# 更新時間:2026/07/30 10:22:03
# 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、
# 同目錄的 role_lib.sh 與 memory.js。
# 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。
@@ -103,31 +103,9 @@ role_sleep_child_running() {
# 整理主流程
# ------------------------------------------------------------------------------
sleep_cycle() {
# 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
local reason="$1" cli material prompt result applied forgotten
command -v node >/dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; }
if ! role_lock_acquire "$ROLE"; then
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE}"
return 0
fi
trap 'role_lock_release "$ROLE"' EXIT
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
if [ -z "$material" ]; then
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason}"
node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)"
role_log "INF" "遺忘檢查:${forgotten}"
role_lock_release "$ROLE"
trap - EXIT
return 0
fi
cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; }
prompt="$(cat <<EOF_PROMPT
build_sleep_prompt() {
# 依目前的 $material 組出整理提示詞。抽成函式是為了讓降批重試能重新組 prompt。
cat <<EOF_PROMPT
你是角色「${ROLE}」的睡眠記憶整理器。輸入包含兩段:INBOX(本次待整理的記憶)與 EXISTING(既有記憶索引)。
請模擬睡眠中的兩階段記憶整理,但最後只輸出一個 JSON 物件。
@@ -200,32 +178,86 @@ sleep_cycle() {
素材:
${material}
EOF_PROMPT
)"
}
# 重試一次:實測整理會偶發失敗(CLI 輸出空或 JSON 不合法),素材本身沒問題 ——
# 同一批素材重跑就成功。沒有重試時,該角色要等到下一個週期才會再被整理;
# 多角色模式下這個代價更大(一個角色失敗不該讓它整晚都沒睡)。
local attempt=1 max_attempts=2
sleep_cycle() {
# 執行一次完整記憶整理:收集素材 → NREM 鞏固 → REM 整合 → 落檔歸檔 → 遺忘
local reason="$1" cli material prompt result applied forgotten
command -v node >/dev/null 2>&1 || { role_log "ERR" "找不到 node,無法整理記憶"; return 1; }
if ! role_lock_acquire "$ROLE"; then
role_log "WRN" "另一個整理程序正在執行,本次略過(角色 ${ROLE}"
return 0
fi
trap 'role_lock_release "$ROLE"' EXIT
# 首次收集刻意**不傳 --batch**:批次預設值只能有一個來源(memory.js 的 SLEEP_BATCH),
# 這裡再寫一個預設就會變成兩個各自為政的值 —— 實測就發生過:memory.js 已改成 4
# 但本檔硬編碼的 12 讓實際批次還是 12,改了等於沒改。
# 實際則數改由素材反推,降批時才明確指定。
local batch
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" 2>/dev/null)"
batch="$(printf '%s' "$material" | grep -c '^--- id:')"
[ "$batch" -ge 1 ] 2>/dev/null || batch=1
if [ -z "$material" ]; then
role_log "INF" "沒有待整理記憶(角色 ${ROLE},觸發:${reason}"
node "${SCRIPT_DIR}/memory.js" mark-sleep --role "$ROLE" >/dev/null 2>&1
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null)"
role_log "INF" "遺忘檢查:${forgotten}"
role_lock_release "$ROLE"
trap - EXIT
return 0
fi
cli="$(role_select_cli)" || { role_lock_release "$ROLE"; trap - EXIT; return 1; }
prompt="$(build_sleep_prompt)"
# 重試並**逐次降批**。
#
# 為什麼一定要降批:整理結果會被 head -c SLEEP_OUTPUT_LIMIT 截斷,截斷後 JSON 不合法、
# apply 必然失敗。若批次大小固定,下一輪又收集到同一批素材、產生同樣過長的輸出 ——
# 於是變成**死鎖**:實測連續 22 次全部失敗,inbox 從 12 累積到 23 則,
# 角色整晚沒睡而且錯誤訊息只寫「無法套用」,看不出原因。
# 單純重試只能救偶發失敗;素材過大屬於必然失敗,只有把批次變小才會前進。
local attempt=1 max_attempts=4 out_len=0
applied=""
while [ "$attempt" -le "$max_attempts" ]; do
while :; do
result="$(role_run_cli "$cli" "$prompt" "$SLEEP_TIMEOUT")"
if [ -z "$result" ]; then
role_log "WRN" "整理結果為空(CLI ${cli},第 ${attempt}/${max_attempts} 次)"
role_log "WRN" "整理結果為空(CLI ${cli},第 ${attempt}/${max_attempts},批次 ${batch}"
else
out_len="${#result}"
result="$(printf '%s' "$result" | head -c "$SLEEP_OUTPUT_LIMIT" | node "${SCRIPT_DIR}/transcript.js" redact 2>/dev/null)"
applied="$(printf '%s' "$result" | node "${SCRIPT_DIR}/memory.js" apply --role "$ROLE" 2>/dev/null)"
[ -n "$applied" ] && break
role_log "WRN" "整理結果無法套用(角色 ${ROLE},第 ${attempt}/${max_attempts} 次)"
# 明確指出「是不是被截斷」,否則下一個人只會看到「無法套用」而查不出原因
if [ "$out_len" -ge "$SLEEP_OUTPUT_LIMIT" ]; then
role_log "WRN" "整理結果達輸出上限 ${SLEEP_OUTPUT_LIMIT} 被截斷(角色 ${ROLE},素材 ${#material} 字元,批次 ${batch}),JSON 不完整"
else
# 附上輸出開頭:只寫「無法套用」的話,下一個人無從判斷是 JSON 壞了、
# 模型答非所問,還是模型根本拒絕處理。已經過 redact,不含憑證與個資。
role_log "WRN" "整理結果無法套用(角色 ${ROLE},輸出 ${out_len} 字元,素材 ${#material} 字元,批次 ${batch},第 ${attempt}/${max_attempts} 次):$(printf '%s' "$result" | tr '\n' ' ' | head -c 200)"
fi
fi
attempt=$((attempt + 1))
[ "$attempt" -gt "$max_attempts" ] && break
if [ "$batch" -gt 1 ]; then
batch=$((batch / 2))
[ "$batch" -lt 1 ] && batch=1
material="$(node "${SCRIPT_DIR}/memory.js" collect --role "$ROLE" --batch "$batch" 2>/dev/null)"
[ -n "$material" ] || break
prompt="$(build_sleep_prompt)"
role_log "INF" "改用較小批次重試(角色 ${ROLE},批次 ${batch},素材 ${#material} 字元)"
fi
done
if [ -z "$applied" ]; then
role_log "ERR" "整理失敗 ${max_attempts} 次(角色 ${ROLE}),保留待整理記憶到下個週期"
role_log "ERR" "整理失敗(角色 ${ROLE},已降到批次 ${batch}),保留待整理記憶到下個週期"
role_lock_release "$ROLE"
trap - EXIT
return 1
fi
[ "$attempt" -gt 1 ] && role_log "INF" "${attempt} 次嘗試成功(角色 ${ROLE}"
[ "$attempt" -gt 1 ] && role_log "INF" "${attempt} 次嘗試成功(角色 ${ROLE},批次 ${batch}"
role_log "INF" "記憶整理完成(角色 ${ROLE},觸發:${reason}):${applied}"
forgotten="$(node "${SCRIPT_DIR}/memory.js" forget --role "$ROLE" 2>/dev/null | tr '\n' '')"
+38
View File
@@ -786,6 +786,44 @@ updated: <yyyy/MM/dd HH:mm:ss>
判準刻意放在 `relevance` 而非型態:並非所有事件與知識都該優待 —— **沒有情感脈絡的一次性工作進度仍照原規則淡去**,被留下的是帶著溫度的那些。因此兩份 prompt 都明訂:只要內容承載情感、關係溫度或當時的心情,`relevance` **必須**含 `emotional``episodic` 與 `semantic` 最容易漏標,漏了就會被當成一般進度處理。
### 整理批次與失敗診斷
**單批則數預設 4**,並在失敗時逐次降批(4→2→1)。這個值被實測修正過三次,過程留在 `memory.js` 的註解裡,因為每一次的錯誤推論都很容易再犯:
| 推論 | 實測 |
| --- | --- |
| 輸出被 `SLEEP_OUTPUT_LIMIT` 截斷 | ❌ 輸出僅 57726224 字元,遠未達 8000 |
| 素材字元數過大 | ❌ 素材 8815(6 則)失敗、8940(3 則)成功 —— 字元數幾乎相同 |
| 純粹是則數問題 | ⚠️ 對一半 —— 另有一種失敗是 CLI 回傳 `Execution error` |
**限制因素是「一次要求模型輸出幾筆結構化 JSON」**,不是素材或輸出的字元量:則數越多,模型在中途寫壞引號或轉義的機率越高,而 JSON 壞一個字元整批就套用失敗。
失敗有兩類,對策不同 —— 舊的錯誤訊息只寫「無法套用」,把兩者蓋成同一句話,導致無法診斷:
| 現象 | 原因 | 對策 |
| --- | --- | --- |
| 輸出極短且為 `Execution error` | CLI 偶發執行錯誤 | 重試(同批次) |
| 輸出是不完整的 JSON | 一次要求輸出太多筆 | 降批 |
因此 `--force`/cron 的失敗訊息會附上**素材大小、輸出長度、批次,以及輸出的前 200 字元**(已 redact)。沒有這一步就只能靠猜。
**素材必須結構完整**`cmdCollect` 逐則累加、超出預算就留到下批,並替 `EXISTING` 區塊保留固定比例的預算。舊版是先組好再 `slice()` 硬切,會切在某則記憶中間、甚至把整個 `EXISTING` 區塊切掉 —— 而批次固定時每輪都收到同樣被截斷的素材,形成**死鎖**(實測連續 22 次失敗、`inbox` 從 12 累積到 23 則、角色整晚沒睡)。
**批次預設值只能有一個來源**`memory.js` 的 `SLEEP_BATCH`)。`role_sleep.sh` 首次收集刻意不傳 `--batch`,實際則數由素材反推 —— 曾經兩邊各寫一個預設,改了 `memory.js` 卻沒生效。
### 繁簡防線(寫檔前的第二道)
兩份 prompt 已明令輸出繁體,但**實測仍出現整則簡體記憶**(連 `summary` 與 `tags` 都是),而且該則的 `sources` 指向另一個專案 —— 不同環境下 CLI 的行為並不一致,光靠 prompt 擋不住。因此 `memory.js` 在寫檔前再擋一次:`cmdWrite`inbox)、`cmdApply`(整理落檔)、`appendBond`(關係史)都會經過。
| 字類 | 處理 |
| --- | --- |
| 一簡對一繁、無歧義(約 700 字) | **自動轉為繁體** |
| 一簡對多繁(``→發/髮、``→乾/幹、``→後/后、``→裡/里、``→復/複/覆、``→系/係/繫、``→臟/髒…) | **刻意不自動轉換**,改為 stderr 警告,留待整理階段依上下文處理 |
**為什麼歧義字不轉**:機械替換會把「头发」變成「頭發」—— 那比留著簡體更難發現,因為它看起來已經是繁體了。**寧可留下可偵測的瑕疵,也不要製造隱形的錯誤。**
轉換只在字形層,用語差異(例如「反饋」與「回饋」)仍由 prompt 的「台灣用語」條款負責。警告只警告、不阻斷 —— 記憶寧可帶著瑕疵留下,也不能因為用字問題而遺失。
### 排程涵蓋所有角色
睡眠、小睡與晨間檢查排程**預設服務角色目錄下的每一個角色**,而不只是 `.active` 指定的啟用角色。