dsh-prime-memory 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -7,6 +7,26 @@
7
7
 
8
8
  This file covers the **0.12.0** release notes and the current **unreleased** changes in English. For the full history, see [CHANGELOG.md](./CHANGELOG.md) (Chinese).
9
9
 
10
+ ## [0.24.1] — 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - **Suffix invariant for the projection (`convergeDisabledSuffix`)**: `.memory.md` vs `.memory.md.disable` is decided solely by "is this record retired in the library" — independent of `name` and of which path retired it. Every record is reconciled through three checks (file existence, current suffix, fingerprint match). A hand-made `.disable` is a file-side retirement proof (the library cannot see it while P2 is off), so it is only reported as `both`, never touched.
15
+ - **The library→file hook moved into `L1Store` (`store/memory-md-sync.ts`)**: `setFileSyncHook` fires at the four confluence points (`appendNew` / `retire` / `restore` / `rename`) instead of the eight call sites where event hooks used to hang — on a real machine 920 retired records had only 8 `.disable` files, all from the recall panel path. Machine-side retirements (merge supersede, conflict loss) and model-side `memory_add` now follow automatically, and new call sites cannot be forgotten.
16
+ - **Rename follows the file**: after `rename()` the projection moves to the new name (the `.disable` sibling travels with it) and the ledger key is rewritten; if the old path had no file the new name is written instead, and an occupied target is reported rather than overwritten.
17
+ - **Rolling projection with a progress bar (`store/memory-md-run.ts`, new endpoint `dsh-memory/mem-run-status`)**: one click pushes the export cursor to the end of the table and then the suffix cursor, with cross-round `maxPasses` / `totalBudgetMs` guards; if the budget runs out it says how many files remain instead of stopping silently. The panel reuses the ruminate/rebuild progress bar.
18
+
19
+ ### Changed
20
+
21
+ - **Per-click file-update budget**: `memExport.maxRecords` default 300 → **2000**, plus a runtime knob `live.mdExportMaxRecords` (0 = follow the deployment default, no restart). The panel's misleading "single-action guardrail" is renamed to **"per-click file update budget"**, counted in files, and echoed from the server's actually applied `appliedMaxRecords`.
22
+
23
+ ### Fixed
24
+
25
+ - **The quota counted traversal, not disk actions** — unchanged entries consumed budget, so a second click rescanned the same first N; at 1210 records backfilling meant four clicks.
26
+ - **O(n²) ledger writes**: the converger flushed the ledger per rename (the ledger is one atomic JSON rewrite), so 300 records meant 300 full rewrites and the wall-clock budget split the job into several clicks. Now: mark dirty in the loop, flush once at the end.
27
+ - **Adjacent fires stepping on each other**: projection work is now serialized in the background. Previously each fire opened its own promise, so `appendNew` followed immediately by `retire` overlapped — the second task's converger saw "no such file" before the first one wrote it, and the retired record stayed on `.memory.md` until the next full export.
28
+ - **Permanent duplicates after revival**: the "both files exist" case for a live record only reported and never reconciled. The stale `.disable` is now removed only when all three hold: same ledger id, its bytes unchanged since we wrote it, and the `.memory.md` copy is exactly the current render.
29
+
10
30
  ## [0.24.0] — Unreleased (local line `0.24.0-local.0…3`, 2026-10-13)
11
31
 
12
32
  ### Added
package/CHANGELOG.ja.md CHANGED
@@ -9,6 +9,21 @@
9
9
 
10
10
  本ファイルは **0.12.0** リリースノートと現在の**未リリース**変更の日本語版です。全履歴は [CHANGELOG.md](./CHANGELOG.md)(中文)を参照してください。
11
11
 
12
+ ## [0.24.1] — 2026-10-14
13
+
14
+ ### 追加
15
+ - **接尾語の不変式収束(`convergeDisabledSuffix`)**:`.memory.md` / `.memory.md.disable` は「その記録がライブラリで退場済みか」だけで決まる。`name` とも、どの経路で退場させたかとも無関係。各件を「ファイル存在 + 現在の接尾語 + 指紋一致」の三条件で照合する。人が作った `.disable` はファイル側の退場証拠なので ``both`` と報告するだけ。
16
+ - **ライブラリ→ファイルの鈎子を `L1Store` に集約(`store/memory-md-sync.ts`)**:`appendNew` / `retire` / `restore` / `rename` の四つの合流点で `setFileSyncHook` を発火。これまで 8 か所の呼び出し点に散っていたイベント鈎子は実測 920 件中 8 件しか `.disable` を作っていなかった(リコールパネルの 1 経路のみ)。機械側の退場(統合置換・衝突敗北)もモデル側 `memory_add` も自動的に追随する。
17
+ - **リネーム追随**:`rename()` 後に投射ファイルを新名へ移動(`.disable` も一緒に)、台帳キーも差し替え。旧パスに無い場合は新名で書き出し、移動先が埋まっていれば上書きせず報告のみ。
18
+ - **長タスクの滚动実行と進捗バー(`store/memory-md-run.ts`、新エンドポイント `dsh-memory/mem-run-status`)**:1 クリックでエクスポート游標を表の末尾まで、続けて収束游標を表の末尾まで。総限度 `maxPasses` / `totalBudgetMs` を超えた分は `notice` に正直に残す。
19
+ ### 変更
20
+ - **1 クリックあたりのファイル更新予算**:`memExport.maxRecords` 既定 300 → **2000**、実行時つまみ `live.mdExportMaxRecords` を追加(0 = デプロイ既定、再起動不要)。パネルの「単次動作护栏」は **「単次ファイル更新予算」** に改称、単位は「個ファイル/回」、表示はサーバー側の `appliedMaxRecords` を採用。
21
+ ### 修正
22
+ - 予算が「走査件数」を数えていた(未変も枠を消費)→ 1210 件の復元は 4 クリック必要だった。
23
+ - 台帳の逐件 flush = O(n²)(300 件 = 全文 JSON 再書込 300 回)→ ループ中は dirty フラグのみ、末尾で 1 回 flush。
24
+ - 立て続けの 2 回の発火が互いに踏み合う → 投射作業を裏で**直列化**。従来は「`appendNew` 直後の `retire`」で第二タスクの収束器が「ファイル無し」と判定し、退場記録が `.memory.md` に残っていた。
25
+ - 復権後に二重化が恒久化 → 「同一 id の台帳 + `.disable` が書き込み後未編集 + `.memory.md` が現在のレンダリングそのもの」の 3 条件が揃った時だけ古い証憑を削除。
26
+
12
27
  ## [0.24.0] — 未リリース(ローカルライン `0.24.0-local.0…3`、2026-10-13)
13
28
 
14
29
  ### 追加
package/CHANGELOG.ko.md CHANGED
@@ -9,6 +9,21 @@
9
9
 
10
10
  이 파일은 **0.12.0** 릴리스 노트와 현재 **미출시** 변경 사항의 한국어판입니다. 전체 이력은 [CHANGELOG.md](./CHANGELOG.md)(中文)를 참조하세요.
11
11
 
12
+ ## [0.24.1] — 2026-10-14
13
+
14
+ ### 추가
15
+ - **투영 접미사 불변식 수렴(`convergeDisabledSuffix`)**: `.memory.md` / `.memory.md.disable` 는 "이 기록이 라이브러리에서 퇴장했는지"로만 결정된다. `name` 과도, 어떤 경로로 퇴장시켰 과도 무관. 각 항목을 "파일 존재 + 현재 접미사 + 지문 일치" 세 조건으로 맞춘다. 사람이 만든 `.disable` 는 파일 측 퇴장 증표이므로 ``both`` 로 보고만 하고 손대지 않는다.
16
+ - **라이브러리→파일 고리를 `L1Store` 에 수구(`store/memory-md-sync.ts`)**: `appendNew` / `retire` / `restore` / `rename` 네 합류점에서 `setFileSyncHook` 을 점화한다. 이전처럼 8 개 호출점에 흩어진 이벤트 고리는 실측 920 건 퇴장 중 8 건만 `.disable` 를 만들었다(리콜 패널 한 경로). 기계 측 퇴장(병합 치환, 충돌 패배)과 모델 `memory_add` 도 자동으로-follow 한다.
17
+ - **이름 바꾸기 추적**: `rename()` 이후 투영 파일을 새 이름으로 옮기고(`.disable` 도 함께) 대장 키를 갈아치운다. 옛 경로에 파일이 없으면 새 이름으로 쓰고, 목적지가 이미 쓰여 있으면 덮어쓰지 않고 보고만 한다.
18
+ - **장시간 작업의 굴림 실행과 진행 표시(`store/memory-md-run.ts`, 새 엔드포인트 `dsh-memory/mem-run-status`)**: 한 번의 클릭으로 내보내기 커서를 표 끝까지, 이어서 수렴 커서를 표 끝까지 밀고, 가로막는 종합 한도(`maxPasses` / `totalBudgetMs`) 때문에 남은 분량은 `notice` 에 그대로 남긴다.
19
+ ### 변경
20
+ - **1 회 클릭 파일 갱신 예산**: `memExport.maxRecords` 기본값 300 → **2000**, 실행 중 돌리기 `live.mdExportMaxRecords` 추가(0 = 배포 기본값, 재시작 불요). 패널의 이름 맞지 않던 "단차 동작 가드레일" 을 **"단차 파일 갱신 예산"** 으로, 단위를 "개 파일/회" 로, 표시는 서버가 실제로 쓴 `appliedMaxRecords` 를 쓴다.
21
+ ### 수정
22
+ - 예산이 "순회 건수"를 셌다(변화 없음도 할당 소비) → 1210 건 복원에 클릭 4 회가 필요했다.
23
+ - 항목별 대장 flush = O(n²)(300 건 = 통째 JSON 재작성 300 회) → 루프에서는 더티 표시만, 마무리에서 1 회 flush.
24
+ - 겹쳐 점화한 두 작업이 서로 밟았다 → 투영 작업을 뒷에서 **직렬화**. 종래에는 `appendNew` 직후 `retire` 에서 두 번째 수렴기가 "파일이 없다" 고 판해 퇴장 기록이 `.memory.md` 에 남았다.
25
+ - 복권 후 영구 이중화 → "대장 동일 id + `.disable` 가 기록 후 손대지 않음 + `.memory.md` 가 현재 렌더와 동일" 세 조건이 갖춰질 때만 오래된 증표를 지운다.
26
+
12
27
  ## [0.24.0] — 미출시(로컬 라인 `0.24.0-local.0…3`, 2026-10-13)
13
28
 
14
29
  ### 새로 추가
package/CHANGELOG.md CHANGED
@@ -17,6 +17,26 @@
17
17
  - **建议状态(`longtask-hint-get`)以纯端点提供**:判定 = 上下文占用达 `contextThresholdPct` 或 todo 漂移达 `driftThreshold`(部署配置可调);配合 `longtask-compress-tail` 作手动压缩入口。
18
18
  - **撤下浮动球 UI(`client/src/longtask-fab.ts`;beta.1 起曾随包发布)**:悬浮建议面板的归属本在 context-compression 侧(「建议启动压缩」是其领域),pm 属误载——2026-10-02 用户裁定撤下。撤下不违座位契约(settings.section 恰一个 + 输入栏 pill,body 单例不占座位),但全应用级 overlay 会与其他插件的悬浮 UI 抢占屏幕角落;长任务开关与手动压缩入口待长任务定版时落记忆设置分节。
19
19
  - **`session-mode-set` 的 `mode` 转为可选**:与 `longTask` 二选一必传——纯切长任务开关不再被迫发送档位(旧客户端恒传 mode,行为不变)。响应 `mode` 改回当前生效值。
20
+ ## [0.24.1] — 2026-10-14
21
+
22
+ ### 新增
23
+
24
+ - **投射后缀不变式收敛(`convergeDisabledSuffix`)**:`.memory.md` / `.memory.md.disable` 只由「这条记录在库里是否退场」决定,与 `name` 无关、与「是哪条路径把它退场的」也无关。逐条按「文件存在性 + 当前后缀 + 指纹一致性」三项检查对齐;人手造的 `.disable` 是文件侧退场凭证(P2 关着时库里不知道),只报 `both` 不动盘。
25
+ - **库→文件挂钩收口到 `L1Store`(`store/memory-md-sync.ts`)**:`appendNew` / `retire` / `restore` / `rename` 四个汇流口注入 `setFileSyncHook`,取代此前散布在 8 个调用点的事件挂钩——真机 920 条退场记录只有 8 个 `.disable`,全部来自召回面板那一条路径。机器侧自动退场(合并取代、冲突判负)与模型 `memory_add` 从此自动跟随,新增调用点不会漏。
26
+ - **改名跟随**:`rename()` 之后投射文件搬到新名(`.disable` 兄弟一起走)、账本跟着换键;旧路径本就没这份时按新名补写,目标已被占用时只报不覆盖。
27
+ - **投影长任务滚动执行 + 进度条(`store/memory-md-run.ts`、新端点 `dsh-memory/mem-run-status`)**:一次点击自己把导出游标推到表尾、再把后缀收敛游标推到表尾(跨轮另有 `maxPasses` / `totalBudgetMs` 两道总闸),滚不完如实写进 `notice` 而不是停在半路;面板复用反刍/重建那根进度条。
28
+
29
+ ### 变更
30
+
31
+ - **单次文件更新预算**:`memExport.maxRecords` 默认 300 → **2000**,并新增运行时旋钮 `live.mdExportMaxRecords`(0 = 跟随部署默认,改完不必重启)。面板上名不副实的「单次动作护栏」改成 **「单次文件更新预算」**,单位说成「个文件/次」,结果行回显取自服务端**实际生效**的 `appliedMaxRecords`。
32
+
33
+ ### 修复
34
+
35
+ - **护栏数的是「遍历」而不是「碰盘动作」**:判为「未变」的条也吃配额,第二次点击扫的还是同一批前 N 条——装机量级 1210 条意味着回填要点 4 次。
36
+ - **批量落盘 O(n²)**:收敛器原本每条改名都 `ledger.flush()`(账本是整本 JSON 的原子重写),300 条 = 300 次整本重写,被墙钟 `timeBudgetMs` 拦成好几次点击。现统一「循环里只记脏、收尾落一次」。
37
+ - **紧邻两次点火互踩**:投射联动改为**后台串行**。旧写法每次点火自己开一个 promise,于是「先 `appendNew` 再 `retire`」会重叠——第二个任务的收敛器在第一个还没写出文件时判「盘上没这份」(`absent`),退场记录就永久停在 `.memory.md` 上,直到下次全量导出才被抬回来。
38
+ - **恢复活跃后永久双份**:有效记录的「两份都在」格子原先只报账不动盘。现只在「账本同 id + `.disable` 自写下后未被手改 + `.memory.md` 那份正是当前渲染」三条同时成立时删掉那份陈年凭证,任一不成立仍交人裁决。
39
+
20
40
  ## [0.24.0] — 未发布(本地线 `0.24.0-local.0…3`,2026-10-13)
21
41
 
22
42
  ### 新增
package/dist/client.js CHANGED
@@ -1675,8 +1675,13 @@ var __defProp = Object.defineProperty;
1675
1675
  const head = `已写 ${rep.written} · 未变 ${rep.unchanged} · 跳过 ${rep.skipped}`;
1676
1676
  const named = rep.named > 0 ? ` · 新起名 ${rep.named} 条` : "";
1677
1677
  const nameBad = rep.nameFailed > 0 ? ` · 起名失败 ${rep.nameFailed} 条(下轮重试)` : "";
1678
- const tail = rep.remaining > 0 ? ` · 剩余 ${rep.remaining} 条,再点一次继续` : "";
1679
- return head + named + nameBad + tail;
1678
+ const s = rep.suffix;
1679
+ const suffix = s && s.renamed + s.revived + s.both + s.edited > 0 ? ` · 后缀对齐 改名 ${s.renamed}${s.edited > 0 ? `(人改过 ${s.edited})` : ""} 改回 ${s.revived}${s.both > 0 ? ` · 冲突 ${s.both}` : ""}` : "";
1680
+ const suffixMore = s && s.capped && s.remaining > 0 ? ` · 后缀还有 ${s.remaining} 个文件待对齐,再点一次继续` : "";
1681
+ const tail = rep.remaining > 0 ? ` · 剩余 ${rep.remaining} 个文件,再点一次继续` : "";
1682
+ const budget = typeof rep.appliedMaxRecords === "number" && rep.appliedMaxRecords > 0 ? ` · 本次预算 ${rep.appliedMaxRecords} 个文件` : "";
1683
+ const passes = typeof rep.passes === "number" && rep.passes > 1 ? ` · 共滚 ${rep.passes} 轮` : "";
1684
+ return head + named + nameBad + suffix + suffixMore + budget + passes + tail;
1680
1685
  }
1681
1686
  function lineOfReconcile(rep) {
1682
1687
  const parts = [
@@ -1723,6 +1728,54 @@ var __defProp = Object.defineProperty;
1723
1728
  const [orphanArmed, setOrphanArmed] = (0, import_react7.useState)(false);
1724
1729
  const [armed, setArmed] = (0, import_react7.useState)(false);
1725
1730
  const [error, setError] = (0, import_react7.useState)(void 0);
1731
+ const [budgetDraft, setBudgetDraft] = (0, import_react7.useState)("");
1732
+ const [budgetNote, setBudgetNote] = (0, import_react7.useState)("");
1733
+ const [prog, setProg] = (0, import_react7.useState)(void 0);
1734
+ const pullProgress = () => {
1735
+ void rpc("dsh-memory/mem-run-status", {}).then((r) => {
1736
+ const v = r && r.ok ? r.value : void 0;
1737
+ if (v !== void 0) setProg(v);
1738
+ }).catch(() => {
1739
+ });
1740
+ };
1741
+ const runExport = () => {
1742
+ if (busy) return;
1743
+ setBusy(true);
1744
+ setError(void 0);
1745
+ const timer = setInterval(pullProgress, 500);
1746
+ pullProgress();
1747
+ void call(rpc, "dsh-memory/mem-export", {}).then((out) => {
1748
+ clearInterval(timer);
1749
+ setBusy(false);
1750
+ if (out.report !== void 0) {
1751
+ setExported(out.report);
1752
+ pullProgress();
1753
+ }
1754
+ if (out.error !== void 0) setError(out.error);
1755
+ });
1756
+ };
1757
+ (0, import_react7.useEffect)(() => {
1758
+ void rpc("dsh-memory/settings-get", {}).then((r) => {
1759
+ const v = r && r.ok ? r.value : void 0;
1760
+ const n = v?.settings?.mdExportMaxRecords;
1761
+ if (typeof n === "number") setBudgetDraft(String(n));
1762
+ }).catch(() => {
1763
+ });
1764
+ }, [rpc]);
1765
+ const commitBudget = () => {
1766
+ const raw = budgetDraft.trim();
1767
+ if (raw === "") return;
1768
+ const n = Math.floor(Number(raw));
1769
+ if (!Number.isFinite(n) || n < 0 || n > 1e4) {
1770
+ setBudgetNote("预算要 0~10000 的整数(0 = 跟随部署默认)");
1771
+ return;
1772
+ }
1773
+ void rpc("dsh-memory/settings-set", { mdExportMaxRecords: n }).then((r) => {
1774
+ setBudgetNote(r && r.ok ? n === 0 ? "已改为跟随部署默认" : `已设为每次最多 ${n} 个文件,下一次点击生效` : "写入失败:预算未改");
1775
+ }).catch(() => {
1776
+ setBudgetNote("写入失败:预算未改");
1777
+ });
1778
+ };
1726
1779
  const run = (method, sink, payload) => {
1727
1780
  if (busy) return;
1728
1781
  setBusy(true);
@@ -1734,6 +1787,7 @@ var __defProp = Object.defineProperty;
1734
1787
  });
1735
1788
  };
1736
1789
  const root = exported?.root ?? recon?.root;
1790
+ const pct = prog !== void 0 && prog.total > 0 ? Math.min(100, Math.round(prog.done / (prog.total * 2) * 100)) : 0;
1737
1791
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--dsh-mem-border)" }, children: [
1738
1792
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { fontSize: 12, fontWeight: 600, color: "var(--dsh-mem-text-2)", marginBottom: 6 }, children: "外部存档(markdown 投射)" }),
1739
1793
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: S.flexRow, children: [
@@ -1741,10 +1795,8 @@ var __defProp = Object.defineProperty;
1741
1795
  NButton,
1742
1796
  {
1743
1797
  disabled: busy,
1744
- title: "把当前全部记忆渲染为 <根>/<类型>/<名称>.memory.md,并记下内容指纹(只写文件,不改库)",
1745
- onClick: () => {
1746
- run("dsh-memory/mem-export", setExported, {});
1747
- },
1798
+ title: "把当前全部记忆渲染为 <根>/<类型>/<名称>.memory.md,并记下内容指纹(只写文件,不改库)。一次点完:一轮预算不够时服务端自己续跑下几轮,进度看下方进度条",
1799
+ onClick: runExport,
1748
1800
  children: "导出为可携存档"
1749
1801
  }
1750
1802
  ),
@@ -1827,6 +1879,40 @@ var __defProp = Object.defineProperty;
1827
1879
  }
1828
1880
  )
1829
1881
  ] }),
1882
+ prog !== void 0 && prog.total > 0 ? /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { marginTop: 8 }, children: [
1883
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "dsh-mem-rb-bar", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "dsh-mem-rb-fill", style: { width: pct + "%" } }) }),
1884
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { ...HINT_STYLE, marginTop: 3 }, children: [
1885
+ prog.detail,
1886
+ prog.notice !== void 0 && prog.notice !== "" ? ` · ${prog.notice}` : ""
1887
+ ] })
1888
+ ] }) : null,
1889
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { ...S.flexRow, marginTop: 8, alignItems: "center" }, children: [
1890
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { fontSize: 12, color: "var(--dsh-mem-text-3)" }, children: "单次文件更新预算" }),
1891
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
1892
+ NInput,
1893
+ {
1894
+ type: "number",
1895
+ min: 0,
1896
+ max: 1e4,
1897
+ style: { width: 96 },
1898
+ title: "一次点击最多更新多少个投射文件(导出写入 + 后缀改名都占额度)。“没变过”的不占额度。0 = 跟随部署默认(2000);上限 10000。",
1899
+ placeholder: "0",
1900
+ value: budgetDraft,
1901
+ onChange: (e) => {
1902
+ setBudgetDraft(e.target.value);
1903
+ setBudgetNote("");
1904
+ },
1905
+ onBlur: () => {
1906
+ commitBudget();
1907
+ },
1908
+ onKeyDown: (e) => {
1909
+ if (e.key === "Enter") commitBudget();
1910
+ }
1911
+ }
1912
+ ),
1913
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { fontSize: 12, color: "var(--dsh-mem-text-3)" }, children: "个文件/次(0 = 默认 2000)" }),
1914
+ budgetNote !== "" ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { fontSize: 12, color: "var(--dsh-mem-text-3)" }, children: budgetNote }) : null
1915
+ ] }),
1830
1916
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { ...HINT_STYLE, marginTop: 6 }, children: error !== void 0 ? `失败:${error}` : cleanup !== void 0 ? lineOfCleanup(cleanup) : orphans !== void 0 ? lineOfOrphans(orphans) : synced !== void 0 ? lineOfSync(synced) : exported !== void 0 ? lineOf(exported) : recon !== void 0 ? lineOfReconcile(recon) : "导出=库→文件;对账=只读比较;同步=文件→库;孤儿=搬走没人认领的残片;清理=物理删除(不可逆,需高权限模式)。" }),
1831
1917
  recon !== void 0 && error === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: HINT_STYLE, children: [
1832
1918
  "最近差异样本:",
package/dist/config.d.ts CHANGED
@@ -169,7 +169,10 @@ export interface MemoryConfig {
169
169
  root: string;
170
170
  /** 单次导出的**动作数**护栏(照 wing-backfill / workspace-relink 口径):只数碰了磁盘的
171
171
  * 条(写入 / 补账 / 失败)。“未变且已记账”的不吃配额,否则第二次点击在同一批前 N 条上
172
- * 打转、`remaining` 永不递减;扫描总量由 `timeBudgetMs` 兜底。 */
172
+ * 打转、`remaining` 永不递减;遍历总量由 `timeBudgetMs` 兜底。
173
+ *
174
+ * 默认 2000:本库量级就是 1000+ 条(真机 1210),旧的 300 意味着一次回填要点 4 次,
175
+ * 而“再点一次继续”对人是隐形成本。面板上可经 `live.mdExportMaxRecords` 运行时改。 */
173
176
  maxRecords: number;
174
177
  /** 单次导出的墙钟预算(ms)。 */
175
178
  timeBudgetMs: number;
package/dist/config.js CHANGED
@@ -67,6 +67,15 @@ export function liveSettingsSchema() {
67
67
  embedRemoteDimensions: Schema.number().min(0).max(8192).default(0),
68
68
  // 记忆写删权限门:默认 false(模型写删风险高,须显式在面板开启高权限模式)
69
69
  memoryMutate: Schema.boolean().default(false),
70
+ /**
71
+ * md 投射/后缀收敛的**单次文件更新预算**运行时覆盖(`0` = 跟随启动配置 `memExport.maxRecords`)。
72
+ * 面板上就叫这个名字(设置页入口在 `client/src/tabs/ArchiveSection.tsx`)。
73
+ *
74
+ * 为什么要有这个键:投射面的量级随库增长(真机 1210 条),而它住在启动 YAML 里
75
+ * 就要人改 `cordis.patch.yml` + 重启才能调大——一个只在“回填历史”时用一下的旋钮
76
+ * 不该要求重启。生效路径在 `effectiveCfg`(与其他 live 键同一覆盖链)。
77
+ */
78
+ mdExportMaxRecords: Schema.number().min(0).max(10_000).default(0),
70
79
  // §C 人工冲突裁决总开关:默认 false(冻结消耗注意力,不可默认全开)
71
80
  conflictFreeze: Schema.boolean().default(false),
72
81
  // 轨2 技能结晶总开关(看板可拨):默认 false。开=注册 memory_skill_* 工具 + 注入 active 技能 + skills-* 端点可用。
@@ -203,7 +212,7 @@ export const memorySchema = Schema.object({
203
212
  memExport: Schema.object({
204
213
  enabled: Schema.boolean().default(true),
205
214
  root: Schema.string().default(''),
206
- maxRecords: Schema.number().min(1).max(10_000).default(300),
215
+ maxRecords: Schema.number().min(1).max(10_000).default(2000),
207
216
  timeBudgetMs: Schema.number().min(1000).max(1_800_000).default(120_000),
208
217
  }),
209
218
  // P2 反向同步:默认关。文件数护栏比导出的条数护栏宽一些——一次目录扫描的代价
@@ -13,7 +13,9 @@ import type { ConflictPairView, ConflictsResponse as ConflictsView, ConflictReso
13
13
  export type { ConflictPairView, ConflictsView, ConflictResolutionView };
14
14
  export interface ConflictResolveDeps {
15
15
  l1: Pick<L1Store, 'listConflictPending' | 'resolveConflictPending' | 'retire' | 'syncGraphDisputed' | 'markConflictReviewed'>;
16
- /** `conflictFreeze.enabled`。未开启时队列恒空,直接给出提示而非静默无操作。 */
16
+ /** `conflictFreeze.enabled`。未开启时队列恒空,直接给出提示而非静默无操作。
17
+ * 退场/恢复的文件跟随不在本模块点火:裁决败方走 `l1.retire()`,投射挂钩已收口在
18
+ * `L1Store` 的汇流口(`store/memory-md-sync.ts`),这里再挂一刀就是第二事实源。 */
17
19
  conflictFreezeEnabled: boolean;
18
20
  }
19
21
  /**
@@ -79,6 +79,7 @@ export async function resolveConflictPair(deps, pairId, outcome) {
79
79
  pairId,
80
80
  ...(pair.conflictType ? { conflictType: pair.conflictType } : {}),
81
81
  });
82
+ // 退场即上盘:`l1.retire()` 里已点火(机器侧与本条同一个入口),所以这里不重发。
82
83
  }
83
84
  // 图谱 disputed 重算:此刻仍未裁决的对才是争议集,已了结的节点自动复原 active
84
85
  const ids = new Set();
@@ -106,6 +106,9 @@ export interface MemoryLiveSettings {
106
106
  /** 轨2 技能结晶总开关(看板可拨):true = 注册 memory_skill_* 工具、注入 active 技能、skills-* 端点可用。
107
107
  * 默认 false(新功能默认关);与部署 cfg.skills.enabled 取或(任一开则生效)。 */
108
108
  skillsEnabled: boolean;
109
+ /** md 投射/后缀收敛的单次动作护栏运行时覆盖:0 = 跟随部署 `cfg.memExport.maxRecords`(默认 2000)。
110
+ * 存在的理由:一次点完多少条属于“回填历史时才改一下”的旋钮,不该要求改 YAML + 重启。 */
111
+ mdExportMaxRecords: number;
109
112
  }
110
113
  /** 召回停用原因(session-stats recall.enabled=false 时带出;短路序第一个为假的因子)。 */
111
114
  export type RecallDisabledReason = 'deploy' | 'global' | 'session' | 'mode';
@@ -739,6 +742,8 @@ export interface SettingsSetRequest {
739
742
  graph: number;
740
743
  };
741
744
  distillMaxInputChars?: number;
745
+ /** md 投射/后缀收敛的单次动作护栏覆盖:0 = 跟随部署 `cfg.memExport.maxRecords`。 */
746
+ mdExportMaxRecords?: number;
742
747
  /** 蒸馏通道运行时覆盖:'' = 跟随部署 config;'host' = 复用宿主;'direct' = 插件原生直连。 */
743
748
  distillMode?: '' | 'host' | 'direct';
744
749
  /** 运行时直连端点覆盖('direct' 用;非机密,可回显)。 */
@@ -1554,6 +1559,14 @@ export type MemExportRequest = Record<string, never>;
1554
1559
  export type MemExportResponse = import('./store/memory-md-types.js').MemExportResponse;
1555
1560
  export type MemReconcileRequest = Record<string, never>;
1556
1561
  export type MemReconcileResponse = import('./store/memory-md-types.js').ReconcileReport;
1562
+ /**
1563
+ * dsh-memory/mem-run-status:投影长任务的进度快照(只读、无入参)。
1564
+ *
1565
+ * 为什么要有它:`mem-export` 现在自己滚到完(多轮),一次点击可能跑十几秒—
1566
+ * 没有进度面就退化成人靠“再点一下”猜任务还在不在跑(真机反馈正是“点三下”)。
1567
+ */
1568
+ export type MemRunStatusRequest = Record<string, never>;
1569
+ export type MemRunStatusResponse = import('./store/memory-md-types.js').MdRunStatus;
1557
1570
  /**
1558
1571
  * dsh-memory/mem-sync:P2 反向同步(md 文件 → 索引)。
1559
1572
  *
@@ -1660,6 +1673,7 @@ export interface DshMemoryRequestMap {
1660
1673
  'dsh-memory/recall-feedback': RecallFeedbackRequest;
1661
1674
  'dsh-memory/workspace-relink': WorkspaceRelinkRequest;
1662
1675
  'dsh-memory/mem-export': MemExportRequest;
1676
+ 'dsh-memory/mem-run-status': MemRunStatusRequest;
1663
1677
  'dsh-memory/mem-reconcile': MemReconcileRequest;
1664
1678
  'dsh-memory/mem-sync': MemSyncRequest;
1665
1679
  'dsh-memory/mem-orphans': MemOrphansRequest;
@@ -1721,6 +1735,7 @@ export interface DshMemoryResponseMap {
1721
1735
  'dsh-memory/recall-feedback': RecallFeedbackResponse;
1722
1736
  'dsh-memory/workspace-relink': WorkspaceRelinkResponse;
1723
1737
  'dsh-memory/mem-export': MemExportResponse;
1738
+ 'dsh-memory/mem-run-status': MemRunStatusResponse;
1724
1739
  'dsh-memory/mem-reconcile': MemReconcileResponse;
1725
1740
  'dsh-memory/mem-sync': MemSyncResponse;
1726
1741
  'dsh-memory/mem-orphans': MemOrphansResponse;
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ import { registerSkillTools } from './tools/skills.js';
46
46
  import { RecallUsageStore } from './store/recall-usage.js';
47
47
  import { RecallFeedbackStore } from './store/recall-feedback.js';
48
48
  import { MdLedgerStore } from './store/memory-md-ledger.js';
49
+ import { makeFileSyncHook } from './store/memory-md-sync.js';
49
50
  import { ObsoleteNoticeStore } from './store/obsolete-notices.js';
50
51
  import { registerSlotsProjection } from './projection/slots.js';
51
52
  import { registerRecallProjection } from './projection/recall.js';
@@ -406,6 +407,27 @@ export async function apply(ctx, config) {
406
407
  const mdLedger = new MdLedgerStore(dataDir, logger);
407
408
  await mdLedger.init();
408
409
  stores.mdLedger = mdLedger;
410
+ // 库→文件投射的跟随钩子:**收口在 `L1Store` 的四个汇流口**(appendNew / retire / restore / rename),
411
+ // 不在这里往调用方分发。不变式本体在 `store/memory-md-sync.ts`。
412
+ // 为什么只能装配层建:工具面/冲突裁决各自看不到 cfg + ledger + dataDir 的完整视野。
413
+ const fileSync = makeFileSyncHook({
414
+ getByIds: (list) => stores.l1.getByIds([...list]),
415
+ // 现读生效配置(volatile 热更后不必重启):与 RPC 分发用同一个 `effectiveCfg` 口径。
416
+ exportCfg: () => {
417
+ const exp = effectiveCfg(config, live).memExport;
418
+ return {
419
+ enabled: exp?.enabled === true,
420
+ root: exp?.root,
421
+ maxRecords: exp?.maxRecords ?? 2000,
422
+ timeBudgetMs: exp?.timeBudgetMs ?? 120_000,
423
+ };
424
+ },
425
+ dataDir,
426
+ ledger: () => stores.mdLedger,
427
+ logger,
428
+ });
429
+ // 晚绑定:`stores.l1` 在 md 账本之前就建好了,这里只把钩子接上去。
430
+ stores.l1.setFileSyncHook(fileSync);
409
431
  const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
410
432
  runner.setAfterRun(recall.invalidateProfile);
411
433
  registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined);
@@ -142,7 +142,7 @@ export declare class RuminateController {
142
142
  */
143
143
  private snapshotMdBaseline;
144
144
  /**
145
- * 反刍收尾的增量 md 投射(task_2.8):库 → 文件 + 账本,**只碰本轮新增/变更的那批**。
145
+ * 反刍收尾的增量 md 投射(task_2.8):库 → 文件 + 账本 + 后缀对齐,**只碰本轮新增/变更的那批**。
146
146
  *
147
147
  * 与手动的 `dsh-memory/mem-export` 端点的区别:
148
148
  * - 不跑命名回填(`assignMissingNames` 会写库,反刍是只读旁路,不能在那里改库);
@@ -150,6 +150,9 @@ export declare class RuminateController {
150
150
  * - 不建类型目录骨架(`ensureTypeSkeleton`),那是面板显式触发的动作。
151
151
  * 护栏全部复用 `cfg.memExport` 既有值(`maxRecords`/`timeBudgetMs`),**不新增配置项**。
152
152
  *
153
+ * 一轮投射走 `syncFiles`(与 `L1Store` 钩子同一函数):导出之后紧跟后缀收敛,
154
+ * 所以本轮被合并取代/冲突判负的增量记录不会停在未改名的 `.memory.md` 上。
155
+ *
153
156
  * 失败面:整个方法自吞异常,只 warn + 计数,绝不抛给调用方、绝不改变反刍结果。
154
157
  * @param stage - 日志里的路径名(区分 finalize 与轻量刷新两条挂载点)。
155
158
  */
@@ -1,6 +1,6 @@
1
1
  import { resolveDataDir } from '../config.js';
2
2
  import { InProcMemoryBackend } from '../store/memory-backend.js';
3
- import { exportRecords } from '../store/memory-md-export.js';
3
+ import { syncFiles } from '../store/memory-md-sync.js';
4
4
  import { groupPendingBySession, loadPending, } from '../store/pending.js';
5
5
  import { errDetail } from '../util/filelog.js';
6
6
  import { runSceneConsolidation } from './l2.js';
@@ -469,7 +469,7 @@ export class RuminateController {
469
469
  }
470
470
  }
471
471
  /**
472
- * 反刍收尾的增量 md 投射(task_2.8):库 → 文件 + 账本,**只碰本轮新增/变更的那批**。
472
+ * 反刍收尾的增量 md 投射(task_2.8):库 → 文件 + 账本 + 后缀对齐,**只碰本轮新增/变更的那批**。
473
473
  *
474
474
  * 与手动的 `dsh-memory/mem-export` 端点的区别:
475
475
  * - 不跑命名回填(`assignMissingNames` 会写库,反刍是只读旁路,不能在那里改库);
@@ -477,6 +477,9 @@ export class RuminateController {
477
477
  * - 不建类型目录骨架(`ensureTypeSkeleton`),那是面板显式触发的动作。
478
478
  * 护栏全部复用 `cfg.memExport` 既有值(`maxRecords`/`timeBudgetMs`),**不新增配置项**。
479
479
  *
480
+ * 一轮投射走 `syncFiles`(与 `L1Store` 钩子同一函数):导出之后紧跟后缀收敛,
481
+ * 所以本轮被合并取代/冲突判负的增量记录不会停在未改名的 `.memory.md` 上。
482
+ *
480
483
  * 失败面:整个方法自吞异常,只 warn + 计数,绝不抛给调用方、绝不改变反刍结果。
481
484
  * @param stage - 日志里的路径名(区分 finalize 与轻量刷新两条挂载点)。
482
485
  */
@@ -498,16 +501,22 @@ export class RuminateController {
498
501
  }
499
502
  if (changed.length === 0)
500
503
  return;
501
- const rep = await exportRecords({
502
- records: changed,
503
- cfgRoot: exp.root,
504
+ const deps = {
505
+ exportCfg: () => ({
506
+ enabled: exp.enabled === true,
507
+ root: exp.root,
508
+ maxRecords: exp.maxRecords,
509
+ timeBudgetMs: exp.timeBudgetMs,
510
+ }),
504
511
  dataDir: resolveDataDir(this.cfg),
505
- ledger,
506
- maxRecords: exp.maxRecords,
507
- timeBudgetMs: exp.timeBudgetMs,
512
+ ledger: () => this.stores.mdLedger,
508
513
  logger: this.logger,
509
- });
510
- this.logger.info(`[memory] 反刍 md 投射(${stage}):增量 ${changed.length} 条,写 ${rep.written},未变 ${rep.unchanged},跳过 ${rep.skipped},落点 ${rep.root}${rep.capped ? `,护栏截断剩 ${rep.remaining} 条(下轮反刍继续)` : ''}`);
514
+ };
515
+ const out = await syncFiles(deps, changed);
516
+ if (out === undefined)
517
+ return;
518
+ const { exported: rep, suffix } = out;
519
+ this.logger.info(`[memory] 反刍 md 投射(${stage}):增量 ${changed.length} 条,写 ${rep.written},未变 ${rep.unchanged},跳过 ${rep.skipped},改名 ${suffix.renamed},落点 ${rep.root}${rep.capped ? `,预算用尽剩 ${rep.remaining} 个文件(下轮反刍继续)` : ''}`);
511
520
  }
512
521
  catch (err) {
513
522
  this.mdFailures++;
@@ -121,12 +121,17 @@ export function effectiveCfg(cfg, live) {
121
121
  const cfOverride = s?.conflictFreeze !== undefined
122
122
  ? { conflictFreeze: { ...(cfg.conflictFreeze ?? {}), enabled: s.conflictFreeze } }
123
123
  : null;
124
- if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride)
124
+ // md 投射/后缀收敛的单次动作护栏运行时覆盖(0 = 跟随部署 cfg.memExport.maxRecords)。
125
+ // 只覆盖条数护栏,不动 enabled/root——开关与根路径仍属部署配置。
126
+ const mdx = s?.mdExportMaxRecords ?? 0;
127
+ const memExportOverride = mdx > 0 ? { memExport: { ...(cfg.memExport ?? {}), maxRecords: mdx } } : null;
128
+ if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride && !memExportOverride)
125
129
  return cfg;
126
130
  return {
127
131
  ...cfg,
128
132
  ...(embedOverride ?? {}),
129
133
  ...(cfOverride ?? {}),
134
+ ...(memExportOverride ?? {}),
130
135
  llm: {
131
136
  ...cfg.llm,
132
137
  ...(effortInject ?? {}),
package/dist/settings.js CHANGED
@@ -81,6 +81,8 @@ const ALWAYS_ON = {
81
81
  memoryMutate: false,
82
82
  conflictFreeze: false,
83
83
  skillsEnabled: false,
84
+ // md 投射护栏运行时覆盖:0 = 跟随部署 cfg.memExport.maxRecords(面板上可改)
85
+ mdExportMaxRecords: 0,
84
86
  toolRoom: true,
85
87
  toolGraph: true,
86
88
  toolRuminate: true,
@@ -315,6 +317,8 @@ function resolveSettings(value) {
315
317
  conflictFreeze: v.conflictFreeze === true,
316
318
  // 轨2 技能结晶:严格 === true(默认关,新功能默认关)
317
319
  skillsEnabled: v.skillsEnabled === true,
320
+ // md 投射护栏钳 0~10000(与 live schema 同上限);0 = 跟随部署默认
321
+ mdExportMaxRecords: Math.min(10_000, Math.max(0, num(v.mdExportMaxRecords))),
318
322
  // 工具分组封印:严格 !== false(缺省/异常值 = 未封印,工具照常注册)
319
323
  toolRoom: v.toolRoom !== false,
320
324
  toolGraph: v.toolGraph !== false,
package/dist/stats.js CHANGED
@@ -38,7 +38,8 @@ import { snapshotTokenCost } from './token-cost.js';
38
38
  import { computeMaturity } from './util/maturity.js';
39
39
  import { isFeedbackScore } from './store/recall-feedback.js';
40
40
  import { relinkWorkspaces } from './pipeline/workspace-relink.js';
41
- import { exportRecords, reconcileLedger } from './store/memory-md-export.js';
41
+ import { reconcileLedger } from './store/memory-md-export.js';
42
+ import { mdRunStatus, runProjection } from './store/memory-md-run.js';
42
43
  import { disableProjectionFile } from './store/memory-md-disable.js';
43
44
  import { MEMORY_TYPE_DIRS, UNSORTED_DIR, resolveMemoriesRoot, targetFor } from './store/memory-md.js';
44
45
  import { ensureTypeSkeleton } from './store/memory-md-skeleton.js';
@@ -114,6 +115,7 @@ export const MEMORY_ENDPOINTS = [
114
115
  'dsh-memory/recall-feedback',
115
116
  'dsh-memory/workspace-relink',
116
117
  'dsh-memory/mem-export',
118
+ 'dsh-memory/mem-run-status',
117
119
  'dsh-memory/mem-reconcile',
118
120
  'dsh-memory/mem-sync',
119
121
  'dsh-memory/mem-orphans',
@@ -398,6 +400,8 @@ function sanitizeSettings(s) {
398
400
  /** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
399
401
  export async function handleEndpoint(endpoint, payload, deps) {
400
402
  const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry, memorySources, skillStore } = deps;
403
+ // 退场/恢复/新增的文件跟随已收口在 `L1Store` 的汇流口(`store/memory-md-sync.ts`),
404
+ // 本分发层不再自己建点火器——那 8 处事件挂钩已被真机证伪(会漏)。
401
405
  switch (endpoint) {
402
406
  case 'dsh-memory/stats':
403
407
  return buildStats(cfg, stores, status);
@@ -554,7 +558,9 @@ export async function handleEndpoint(endpoint, payload, deps) {
554
558
  case 'dsh-memory/mem-export': {
555
559
  // 单向导出:库 → md 文件 + 账本。**不写检索面**(用例钉住:导出前后 hashRecords 相同)。
556
560
  // 先查配置门再查装配门:“整面默认关”时该听到的是“没开”,不是“没装配”。
557
- const exp = cfg.memExport;
561
+ // 护栏读**生效**配置(含面板上的 `mdExportMaxRecords` 覆盖):只看启动快照
562
+ // 就会“调了没反应”,而这条路径的护栏正是给人调的。
563
+ const exp = effectiveCfg(cfg, live).memExport;
558
564
  if (exp?.enabled !== true)
559
565
  throw new Error('md 投射未开启(cfg.memExport.enabled)');
560
566
  const md = stores.mdLedger;
@@ -584,7 +590,10 @@ export async function handleEndpoint(endpoint, payload, deps) {
584
590
  skeletonRoots.add(t.root);
585
591
  }
586
592
  await ensureTypeSkeleton({ roots: [...skeletonRoots], countsByType, logger: deps.logger });
587
- const rep = await exportRecords({
593
+ // 滚动执行到清完:先把导出游标推到表尾,再把后缀收敛游标推到表尾。
594
+ // 单轮护栏(`maxRecords`/`timeBudgetMs`)不变,跨轮另有轮数与总墙钟兜底;
595
+ // 进度经 `dsh-memory/mem-run-status` 读(面板画进度条)。
596
+ const run = await runProjection({
588
597
  records: all,
589
598
  cfgRoot: exp.root,
590
599
  dataDir: deps.dataDir,
@@ -593,11 +602,17 @@ export async function handleEndpoint(endpoint, payload, deps) {
593
602
  timeBudgetMs: exp.timeBudgetMs,
594
603
  logger: deps.logger,
595
604
  });
596
- return { ...rep, named: naming.named, nameFailed: naming.failed };
605
+ return { ...run, named: naming.named, nameFailed: naming.failed };
606
+ }
607
+ case 'dsh-memory/mem-run-status': {
608
+ // 只读进度快照。故意不查配置门:任务没在跑时也要能读到上一次的 `notice`
609
+ // (“预算用尽、还剩 N 条”这句话必须在人下次点击之前也看得见)。
610
+ return mdRunStatus();
597
611
  }
598
612
  case 'dsh-memory/mem-reconcile': {
599
613
  // 只读对账:不写库、不写文件,只按账本基线出四态报告(所以不需高权限门)。
600
- const exp = cfg.memExport;
614
+ // 同样读生效配置:对账与导出共用护栏,两边半生效会让人看不出差异从哪来。
615
+ const exp = effectiveCfg(cfg, live).memExport;
601
616
  if (exp?.enabled !== true)
602
617
  throw new Error('md 投射未开启(cfg.memExport.enabled)');
603
618
  const md = stores.mdLedger;
@@ -981,7 +996,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
981
996
  const page = stores.l1.list({ tag: slug, retired: false, limit: 200, offset });
982
997
  if (page.items.length === 0)
983
998
  break;
984
- const n = stores.l1.retire(page.items.map((r) => r.id), { at: new Date().toISOString(), reason: 'manual' });
999
+ const pageIds = page.items.map((r) => r.id);
1000
+ const n = stores.l1.retire(pageIds, { at: new Date().toISOString(), reason: 'manual' });
985
1001
  retiredRecords += n;
986
1002
  if (page.items.length < 200)
987
1003
  break;
@@ -1233,6 +1249,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1233
1249
  memoryMutate: false,
1234
1250
  conflictFreeze: live?.get()?.conflictFreeze === true,
1235
1251
  skillsEnabled: live?.get()?.skillsEnabled === true,
1252
+ mdExportMaxRecords: live?.get()?.mdExportMaxRecords ?? 0,
1236
1253
  }),
1237
1254
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
1238
1255
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
@@ -1275,7 +1292,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
1275
1292
  if (typeof patch[key] === 'boolean')
1276
1293
  clean[key] = patch[key];
1277
1294
  }
1278
- // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
1295
+ // md 投射护栏运行时覆盖:数字才收,钳 0~10000(与 live schema 同上限);0 = 跟随部署默认。
1296
+ // 不加进白名单的话,面板上那个输入框会“写成功但被静默丢”——看得到数字、不生效。
1297
+ if (typeof patch.mdExportMaxRecords === 'number' && Number.isFinite(patch.mdExportMaxRecords)) {
1298
+ clean.mdExportMaxRecords = Math.min(10_000, Math.max(0, Math.floor(patch.mdExportMaxRecords)));
1299
+ }
1300
+ // 运行时统一路由链:结构校验后整体写入(空数组 = 回到跟随部署配置)
1279
1301
  if (patch.distillChain !== undefined) {
1280
1302
  const err = validateDistillChain(patch.distillChain);
1281
1303
  if (err)
@@ -1648,6 +1670,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1648
1670
  throw new Error('ids 缺失');
1649
1671
  const r = await stores.l1.restore(ids);
1650
1672
  deps.logger.info(`[memory] 恢复已退场记忆 ${r.restored} 条(补向量 ${r.vectorsWritten} 条)`);
1673
+ // 掉 `.disable` 后缀由 `l1.restore()` 里的钩子承接(与退场对称的那一半)。
1651
1674
  const resp = r;
1652
1675
  return resp;
1653
1676
  }