From adc552130276a3c885ed5fbfcbc077897bb79dc1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 29 Jul 2026 18:56:51 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(role):=20=E5=AF=AB=E6=AA=94=E5=89=8D?= =?UTF-8?q?=E5=8A=A0=E7=B9=81=E7=B0=A1=E9=98=B2=E7=B7=9A=EF=BC=8C=E6=AD=A7?= =?UTF-8?q?=E7=BE=A9=E5=AD=97=E5=88=BB=E6=84=8F=E4=B8=8D=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E8=BD=89=E6=8F=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 病因(由角色稽核記憶時發現):實測有整則記憶以簡體寫成,連 summary 與 tags 都是, 而該則的 sources 指向另一個專案 —— 不同環境下 CLI 的行為並不一致, 光靠 prompt 的「使用繁體中文」條款擋不住。0.1.2 只補了 prompt(症狀由人工修檔), 寫入器本身沒防線,同樣環境下還會再產出簡體。 - memory.js 新增 toTraditional/ambiguousSimplified/warnIfSimplified - cmdWrite(inbox)、cmdApply(整理落檔)、appendBond(關係史)三處寫檔前都經過 - 一簡對一繁、無歧義的約 700 字自動轉繁 - 一簡對多繁刻意不轉(发→發/髮、干→乾/幹、后→後/后、里→裡/里、复→復/複/覆、 系→系/係/繫、脏→臟/髒…),改為 stderr 警告,留待整理階段依上下文處理 —— 機械替換會把「头发」變成「頭發」,那比留著簡體更難發現, 因為它看起來已經是繁體了。寧可留下可偵測的瑕疵,也不要製造隱形錯誤 - 警告只警告不阻斷:記憶寧可帶著瑕疵留下,也不能因為用字問題而遺失 - 轉換只在字形層;用語差異(反饋/回饋)仍由 prompt 的台灣用語條款負責 實測:整則簡體素材寫入後 summary/tags/content 均正確轉繁, 「发」「系」保留並發出警告,未產生「頭發」這類隱形錯誤。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- scripts/role/memory.js | 53 ++++++++++++++++++++++++++++++++++---- skills/role/SKILL.md | 13 ++++++++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d266fd5..123f1ed 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.4", + "version": "0.1.5", "description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a9a2f7e..025664d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.4", + "version": "0.1.5", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 25d0200..b19573e 100644 --- a/plugin.json +++ b/plugin.json @@ -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/" } diff --git a/scripts/role/memory.js b/scripts/role/memory.js index 647b156..02afe58 100755 --- a/scripts/role/memory.js +++ b/scripts/role/memory.js @@ -5,7 +5,7 @@ // 記憶交接,使用獨立字元預算),(3) 睡眠整理時輸出待整理 // 素材並套用整理結果(NREM 鞏固/REM 整合、分類、去重、標籤、總結、 // 優先度、關聯、壓縮歸檔),(4) 依使用頻率與優先度遺忘日常與其他類記憶。 -// 更新時間:2026/07/29 17:16:12 +// 更新時間:2026/07/29 18:56:33 // 相依:Node.js 標準庫。 // 退出碼:0 成功;1 無內容可處理;2 參數錯誤。呼叫端(hook)一律不得因此中斷。 // ============================================================================== @@ -62,6 +62,43 @@ 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"]; @@ -544,6 +581,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); @@ -800,7 +842,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 +931,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); diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index 18d96d6..406d2c4 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -786,6 +786,19 @@ updated: 判準刻意放在 `relevance` 而非型態:並非所有事件與知識都該優待 —— **沒有情感脈絡的一次性工作進度仍照原規則淡去**,被留下的是帶著溫度的那些。因此兩份 prompt 都明訂:只要內容承載情感、關係溫度或當時的心情,`relevance` **必須**含 `emotional`;`episodic` 與 `semantic` 最容易漏標,漏了就會被當成一般進度處理。 +### 繁簡防線(寫檔前的第二道) + +兩份 prompt 已明令輸出繁體,但**實測仍出現整則簡體記憶**(連 `summary` 與 `tags` 都是),而且該則的 `sources` 指向另一個專案 —— 不同環境下 CLI 的行為並不一致,光靠 prompt 擋不住。因此 `memory.js` 在寫檔前再擋一次:`cmdWrite`(inbox)、`cmdApply`(整理落檔)、`appendBond`(關係史)都會經過。 + +| 字類 | 處理 | +| --- | --- | +| 一簡對一繁、無歧義(約 700 字) | **自動轉為繁體** | +| 一簡對多繁(`发`→發/髮、`干`→乾/幹、`后`→後/后、`里`→裡/里、`复`→復/複/覆、`系`→系/係/繫、`脏`→臟/髒…) | **刻意不自動轉換**,改為 stderr 警告,留待整理階段依上下文處理 | + +**為什麼歧義字不轉**:機械替換會把「头发」變成「頭發」—— 那比留著簡體更難發現,因為它看起來已經是繁體了。**寧可留下可偵測的瑕疵,也不要製造隱形的錯誤。** + +轉換只在字形層,用語差異(例如「反饋」與「回饋」)仍由 prompt 的「台灣用語」條款負責。警告只警告、不阻斷 —— 記憶寧可帶著瑕疵留下,也不能因為用字問題而遺失。 + ### 排程涵蓋所有角色 睡眠、小睡與晨間檢查排程**預設服務角色目錄下的每一個角色**,而不只是 `.active` 指定的啟用角色。 -- 2.53.0 From 9512293dc976baa919b058055c6f3f959c6f9660 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 10:22:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(role):=20=E4=BF=AE=E5=A5=BD=E7=9D=A1?= =?UTF-8?q?=E7=9C=A0=E6=95=B4=E7=90=86=E6=AD=BB=E9=8E=96=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=B4=A0=E6=9D=90=E4=B8=8D=E5=86=8D=E7=A1=AC?= =?UTF-8?q?=E5=88=87=E3=80=81=E6=89=B9=E6=AC=A1=E9=99=8D=E7=82=BA=204?= =?UTF-8?q?=E3=80=81=E9=8C=AF=E8=AA=A4=E8=A8=8A=E6=81=AF=E5=8F=AF=E8=A8=BA?= =?UTF-8?q?=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色整晚沒睡: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) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- scripts/role/memory.js | 114 ++++++++++++++++++++++++++----------- scripts/role/role_sleep.sh | 104 +++++++++++++++++++++------------ skills/role/SKILL.md | 25 ++++++++ 6 files changed, 178 insertions(+), 71 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 123f1ed..3e671eb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.5", + "version": "0.1.6", "description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 025664d..d848c72 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.5", + "version": "0.1.6", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index b19573e..4a987de 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.5", + "version": "0.1.6", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/role/memory.js b/scripts/role/memory.js index 02afe58..da238e4 100755 --- a/scripts/role/memory.js +++ b/scripts/role/memory.js @@ -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 字元(summary/content/各欄位), -// 而 SLEEP_OUTPUT_LIMIT 預設 8000,8000÷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; } diff --git a/scripts/role/role_sleep.sh b/scripts/role/role_sleep.sh index 6781557..293d4dc 100755 --- a/scripts/role/role_sleep.sh +++ b/scripts/role/role_sleep.sh @@ -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 </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' ';')" diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index 406d2c4..ecb4488 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -786,6 +786,31 @@ updated: 判準刻意放在 `relevance` 而非型態:並非所有事件與知識都該優待 —— **沒有情感脈絡的一次性工作進度仍照原規則淡去**,被留下的是帶著溫度的那些。因此兩份 prompt 都明訂:只要內容承載情感、關係溫度或當時的心情,`relevance` **必須**含 `emotional`;`episodic` 與 `semantic` 最容易漏標,漏了就會被當成一般進度處理。 +### 整理批次與失敗診斷 + +**單批則數預設 4**,並在失敗時逐次降批(4→2→1)。這個值被實測修正過三次,過程留在 `memory.js` 的註解裡,因為每一次的錯誤推論都很容易再犯: + +| 推論 | 實測 | +| --- | --- | +| 輸出被 `SLEEP_OUTPUT_LIMIT` 截斷 | ❌ 輸出僅 5772~6224 字元,遠未達 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`(關係史)都會經過。 -- 2.53.0 From e43f303ff6b52ea48f74895557e148fa27f093be Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 10:27:23 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(plugin=20=E7=89=88=E6=9C=AC):=20?= =?UTF-8?q?=E4=B8=89=E5=AE=B6=20manifest=20=E6=94=B9=E5=9B=9E=200.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master 現行 0.1.4,本 PR 的兩批變更(繁簡防線、睡眠整理死鎖修復)屬同一次發佈。 依 spec-plugin-version:同一 PR 只需最終一個版本,不必依工作分支上的中間版本累加。 先前先 bump 0.1.5 再 bump 0.1.6,多跳了一版。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3e671eb..123f1ed 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.6", + "version": "0.1.5", "description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc-generic: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d848c72..025664d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.6", + "version": "0.1.5", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 4a987de..b19573e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-generic", - "version": "0.1.6", + "version": "0.1.5", "description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-generic: 前綴呼叫。", "skills": "./skills/" } -- 2.53.0