From 9512293dc976baa919b058055c6f3f959c6f9660 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 10:22:23 +0800 Subject: [PATCH] =?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`(關係史)都會經過。