sage-mem 1.9.2 → 2.0.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/lib/client.js CHANGED
@@ -1589,6 +1589,119 @@ const StarmapRuntime = (function (require, _react) {
1589
1589
  },
1590
1590
  };
1591
1591
 
1592
+ // ── 冲突队列(2.0.0 矛盾检测)──
1593
+ // 与宿主 manifest 逐字段对齐的 strict 形状。记录字段「只增不改」,但客户端这一份
1594
+ // 必须写全 —— strict 校验缺字段会把整条调用当坏结果拒掉(additive 字段要从这里放行)。
1595
+ //
1596
+ // `invalidatedFile` 不是装饰:invalidate 允许标 `file` 或 `otherFile` 任一方失效,
1597
+ // undo 要知道当时标的是哪一方,只能从记录里读(设计稿 §7.3 只写了「撤销失效」,
1598
+ // 没写落点;D29 补这个字段)。空串 = 还没裁过。
1599
+ const conflictFields = {
1600
+ "id": isStr, "at": isStr, "kind": isStr, "status": isStr,
1601
+ "file": isStr, "otherFile": isStr, "note": isStr, "decision": isStr,
1602
+ "decidedAt": isStr, "appliedVersion": isStr, "invalidatedFile": isStr,
1603
+ "truncated": isBool,
1604
+ };
1605
+ const conflictItemSchema = parseSchema(conflictFields);
1606
+ // read 的 record = item 再带两份正文。正文可能为空串、可能被截断(truncated 为 true
1607
+ // 时 UI 要显示「已截断」)—— 所以正文只校类型,不校长度。
1608
+ const conflictRecordSchema = parseSchema(Object.assign({}, conflictFields, {
1609
+ "incoming": isStr, "onDisk": isStr,
1610
+ }));
1611
+ /**
1612
+ * 冲突方法的失败分支:`{ok:false, code, error?}`。
1613
+ * 契约里失败**不抛**、也不走 ok:true —— 所以每个结果 codec 都要先认这一支,
1614
+ * 否则「stale_version 要给出路」这件事在界面层会被 strict 校验拦成一句泛化错误。
1615
+ */
1616
+ function conflictFailure(v) {
1617
+ if (v.ok !== false) return false;
1618
+ isStr(v.code);
1619
+ if ("error" in v) isStr(v.error);
1620
+ return true;
1621
+ }
1622
+ const conflictListSchema = {
1623
+ parse(v) {
1624
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: invalid conflict list");
1625
+ if (conflictFailure(v)) return v;
1626
+ isBool(v.ok);
1627
+ // items 是列表的主体,成功时必须有;broken / ignored 允许宿主省略(空数组)。
1628
+ if (!Array.isArray(v.items)) throw new Error("sage-mem: invalid conflict list");
1629
+ v.items.forEach(function (x) { conflictItemSchema.parse(x); });
1630
+ if ("broken" in v && !Array.isArray(v.broken)) throw new Error("sage-mem: invalid broken list");
1631
+ if ("ignored" in v && !Array.isArray(v.ignored)) throw new Error("sage-mem: invalid ignored list");
1632
+ if (v.counts === null || typeof v.counts !== "object") throw new Error("sage-mem: missing counts");
1633
+ isNum(v.counts.open);
1634
+ isNum(v.counts.total);
1635
+ return v;
1636
+ },
1637
+ };
1638
+ const conflictReadSchema = {
1639
+ parse(v) {
1640
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: invalid conflict record");
1641
+ if (conflictFailure(v)) return v;
1642
+ isBool(v.ok);
1643
+ conflictRecordSchema.parse(v.record);
1644
+ // warnings 的元素形状没冻结(宿主 schema 也允许省略空数组),只校「是数组」。
1645
+ if ("warnings" in v && !Array.isArray(v.warnings)) throw new Error("sage-mem: invalid warnings");
1646
+ return v;
1647
+ },
1648
+ };
1649
+ const conflictInvalidateSchema = {
1650
+ parse(v) {
1651
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: invalid conflict result");
1652
+ if (conflictFailure(v)) return v;
1653
+ isBool(v.ok);
1654
+ conflictRecordSchema.parse(v.record);
1655
+ const w = v.wrote;
1656
+ if (w === null || typeof w !== "object") throw new Error("sage-mem: missing wrote");
1657
+ isStr(w.file);
1658
+ isStr(w.invalidAt);
1659
+ // 宿主 manifest 里 version 是 string(写入门返回的版本令牌)。
1660
+ isStr(w.version);
1661
+ return v;
1662
+ },
1663
+ };
1664
+ const conflictDismissSchema = {
1665
+ parse(v) {
1666
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: invalid conflict result");
1667
+ if (conflictFailure(v)) return v;
1668
+ isBool(v.ok);
1669
+ conflictRecordSchema.parse(v.record);
1670
+ return v;
1671
+ },
1672
+ };
1673
+ const conflictUndoSchema = {
1674
+ parse(v) {
1675
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: invalid conflict result");
1676
+ if (conflictFailure(v)) return v;
1677
+ isBool(v.ok);
1678
+ conflictRecordSchema.parse(v.record);
1679
+ const c = v.cleared;
1680
+ if (c === null || typeof c !== "object") throw new Error("sage-mem: missing cleared");
1681
+ isStr(c.file);
1682
+ return v;
1683
+ },
1684
+ };
1685
+ const conflictRemoveSchema = {
1686
+ parse(v) {
1687
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: invalid conflict result");
1688
+ if (conflictFailure(v)) return v;
1689
+ isBool(v.ok);
1690
+ return v;
1691
+ },
1692
+ };
1693
+ // 5 个方法都是「一个对象参数」:{id} / {id,file?,note?}。可选字段传了就必须是字符串,
1694
+ // 没传就交给宿主按空串处理 —— 不在这里编默认值。
1695
+ const conflictOptsSchema = {
1696
+ parse(v) {
1697
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: expected object");
1698
+ isStr(v.id);
1699
+ if ("file" in v) isStr(v.file);
1700
+ if ("note" in v) isStr(v.note);
1701
+ return v;
1702
+ },
1703
+ };
1704
+
1592
1705
  const TYPERT_REMOTE = {
1593
1706
  package: "sage-mem",
1594
1707
  descriptors: [
@@ -1995,6 +2108,89 @@ const StarmapRuntime = (function (require, _react) {
1995
2108
  result: { mode: "strict", typeSymbol: "sage-mem/types#AutodreamModelList", schema: objCodec, create: () => objCodec },
1996
2109
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1997
2110
  },
2111
+ // ── 冲突裁决(2.0.0 矛盾检测)──
2112
+ // 第四个 namespace。与 autodream 那批同一个坑:namespace **不会自动出现**
2113
+ // (宿主 remote 清单是构建期产物),不在这里声明 `ctx.get("remote.conflict")`
2114
+ // 就永远是 undefined —— 界面表现为「一整个页签消失」。
2115
+ // 6 条都在同一个 `conflict` 面上:列表 / 读正文 / 标失效 / 标记已裁决 / 撤销 / 删记录。
2116
+ {
2117
+ id: "sage-mem#conflict/list",
2118
+ service: "conflict",
2119
+ namespace: "conflict",
2120
+ method: "list",
2121
+ invocation: { kind: "direct" },
2122
+ parameters: [],
2123
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ConflictList", schema: conflictListSchema, create: () => conflictListSchema },
2124
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
2125
+ },
2126
+ {
2127
+ // 只读:拿一条记录的两份正文(record = item + incoming/onDisk)。
2128
+ id: "sage-mem#conflict/read",
2129
+ service: "conflict",
2130
+ namespace: "conflict",
2131
+ method: "read",
2132
+ invocation: { kind: "direct" },
2133
+ parameters: [
2134
+ { name: "opts", wire: "opts", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ConflictOpts", schema: conflictOptsSchema, create: () => conflictOptsSchema } },
2135
+ ],
2136
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ConflictReadResult", schema: conflictReadSchema, create: () => conflictReadSchema },
2137
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
2138
+ },
2139
+ {
2140
+ // 唯一会写记忆文件的动作(标 invalid_at);file 允许是记录的 file 或 otherFile。
2141
+ id: "sage-mem#conflict/invalidate",
2142
+ service: "conflict",
2143
+ namespace: "conflict",
2144
+ method: "invalidate",
2145
+ invocation: { kind: "direct" },
2146
+ parameters: [
2147
+ { name: "opts", wire: "opts", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ConflictOpts", schema: conflictOptsSchema, create: () => conflictOptsSchema } },
2148
+ ],
2149
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ConflictInvalidateResult", schema: conflictInvalidateSchema, create: () => conflictInvalidateSchema },
2150
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
2151
+ },
2152
+ {
2153
+ // 只改记录状态,不碰记忆文件。
2154
+ id: "sage-mem#conflict/dismiss",
2155
+ service: "conflict",
2156
+ namespace: "conflict",
2157
+ method: "dismiss",
2158
+ invocation: { kind: "direct" },
2159
+ parameters: [
2160
+ { name: "opts", wire: "opts", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ConflictOpts", schema: conflictOptsSchema, create: () => conflictOptsSchema } },
2161
+ ],
2162
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ConflictDismissResult", schema: conflictDismissSchema, create: () => conflictDismissSchema },
2163
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
2164
+ },
2165
+ {
2166
+ // 撤销失效:删掉 invalidate 写进记忆 frontmatter 的那两行。
2167
+ id: "sage-mem#conflict/undo",
2168
+ service: "conflict",
2169
+ namespace: "conflict",
2170
+ method: "undo",
2171
+ invocation: { kind: "direct" },
2172
+ parameters: [
2173
+ { name: "opts", wire: "opts", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ConflictOpts", schema: conflictOptsSchema, create: () => conflictOptsSchema } },
2174
+ ],
2175
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ConflictUndoResult", schema: conflictUndoSchema, create: () => conflictUndoSchema },
2176
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
2177
+ },
2178
+ {
2179
+ // 删除记录:不可恢复,但记忆文件一个字节都不动。
2180
+ // ⚠️ 方法名不能叫 `remove`:DSH 客户端 RemoteNamespaceService 的原型上有这个名字,
2181
+ // 客户端声明同名方法会在 $mount 的 assertMethodAvailable 抛错 —— **整个 client
2182
+ // 插件 activate 失败**(2026-10-27 真机复现)。两端统一用 `removeRecord`。
2183
+ id: "sage-mem#conflict/removeRecord",
2184
+ service: "conflict",
2185
+ namespace: "conflict",
2186
+ method: "removeRecord",
2187
+ invocation: { kind: "direct" },
2188
+ parameters: [
2189
+ { name: "opts", wire: "opts", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ConflictOpts", schema: conflictOptsSchema, create: () => conflictOptsSchema } },
2190
+ ],
2191
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ConflictRemoveResult", schema: conflictRemoveSchema, create: () => conflictRemoveSchema },
2192
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
2193
+ },
1998
2194
  ],
1999
2195
  };
2000
2196
 
@@ -2244,6 +2440,26 @@ const StarmapRuntime = (function (require, _react) {
2244
2440
  // ── 底部一行仓库链接(引流,不抢视线)──
2245
2441
  ".smem-star{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a1);padding:2px 2px 0;}",
2246
2442
  ".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
2443
+ // ── 冲突页签(2.0.0 矛盾检测)──
2444
+ // 一屏里要同时看「两份正文对照 + 动作 + 后果」,所以独立成套类名,不复用文件卡片:
2445
+ // 文件卡片的灰系 / 动作语义是「这条记忆怎么处置」,裁决是另一件事,混用会让
2446
+ // 「标失效」看起来像在编辑记忆本身。
2447
+ ".smem-conf{display:flex;flex-direction:column;gap:8px;}",
2448
+ ".smem-conf-list{display:flex;flex-direction:column;gap:10px;}",
2449
+ ".smem-conf-item{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:9px 11px;display:flex;flex-direction:column;gap:6px;}",
2450
+ ".smem-conf-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}",
2451
+ ".smem-conf-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;}",
2452
+ ".smem-conf-files{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}",
2453
+ ".smem-conf-file{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;max-width:100%;overflow-wrap:anywhere;white-space:normal;text-align:left;}",
2454
+ ".smem-conf-arrow{color:var(--dsw-alias-label-secondary);}",
2455
+ ".smem-conf-acts{display:flex;gap:6px;flex-wrap:wrap;align-items:center;}",
2456
+ ".smem-conf-confirm{border:1px solid var(--dsw-alias-state-error-primary,#ef4444);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-layer-2);font-size:12px;line-height:1.55;display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere;}",
2457
+ // 两份正文并排对照:窄窗口自动堆成一列(grid auto-fit,不用媒体查询)。
2458
+ ".smem-conf-bodies{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:8px;}",
2459
+ ".smem-conf-body{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:7px 9px;display:flex;flex-direction:column;gap:4px;min-width:0;}",
2460
+ ".smem-conf-label{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary);}",
2461
+ ".smem-conf-pre{margin:0;max-height:220px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--dsw-alias-label-primary);}",
2462
+ ".smem-conf-warn{font-size:11.5px;color:var(--dsw-alias-state-warn-primary,#d97706);}",
2247
2463
  ].join("\n");
2248
2464
 
2249
2465
  /**
@@ -2707,14 +2923,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
2707
2923
  : null);
2708
2924
 
2709
2925
  /**
2710
- * 「注入调试」面板(1.7.0)。
2926
+ * 「注入调试」面板(1.7.0;2.0.1 起显示有效性抑制明细)。
2711
2927
  *
2712
2928
  * 只读:没有「清空 / 重跑」按钮 —— 账是注入链在 `SAGE_MEM_DEBUG=1` 时自动记的,
2713
2929
  * 人能做的是看,以及点「刷新」再取一次。入口只在开关打开时出现(宿主告诉我们)。
2714
2930
  *
2715
- * 每一条记录的口径写在 `lib/recall/debug.js` 的 makeDebug 注释里;这里只负责把
2931
+ * 每一条记录的口径写在 `lib/recall/injector.js` 的 makeDebug 注释里;这里只负责把
2716
2932
  * 「为什么这一步没注入」翻译成人话 —— 那是这个面板存在的唯一理由。**尤其是
2717
2933
  * `empty + deduped`**:每轮多步里它占大多数,不解释就会被当成「插件坏了」。
2934
+ *
2935
+ * 2.0.0 让失效 / 未裁决矛盾的记忆不再进正文。2.0.1 把这两类的**文件名、理由与
2936
+ * 冲突 id** 单列一行:否则「这条记忆不见了」在面板上只能看到它不在 injected 里,
2937
+ * 分不清是没检索到还是被新规则挡住 —— 而那正是 2.0.0 之后最需要解释的一种消失。
2718
2938
  */
2719
2939
  function RecallDebugPanel(props) {
2720
2940
  const data = props.data || {};
@@ -2749,6 +2969,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
2749
2969
  }));
2750
2970
  }
2751
2971
  const injected = latest && Array.isArray(latest.injected) ? latest.injected : [];
2972
+ // 2.0.1:被有效性挡掉的条目。旧记录没有这个字段 → 空数组(面板显示「无」),
2973
+ // 不因为多一个可选字段就把老账读崩。
2974
+ const validitySuppressed = latest && Array.isArray(latest.validitySuppressed) ? latest.validitySuppressed : [];
2752
2975
  const histogram = recs.slice(1).map(function (r, i) {
2753
2976
  return h("div", { className: "smem-dbg-hist-row", key: "h" + i }, [
2754
2977
  h("span", { className: "smem-dbg-time", key: "t" }, clock(r.at)),
@@ -2792,6 +3015,23 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
2792
3015
  "正文字节 " + String(latest.chars || 0) + "(本代累计 " + String(latest.sessionChars || 0) + ")" +
2793
3016
  " · 状态卡 " + (latest.cardChars ? String(latest.cardChars) + " 字节" : "未发") +
2794
3017
  " · 预算挡掉 " + String(latest.skippedByBudget || 0) + " 条"),
3018
+ h("div", { className: "smem-dbg-row", key: "vs" }, [
3019
+ h("span", { className: "smem-label", key: "l" }, "有效性抑制"),
3020
+ validitySuppressed.length
3021
+ ? h("span", { className: "smem-dbg-scores", key: "v" }, validitySuppressed.map(function (x, i) {
3022
+ const reason = String((x && x.label) || (x && x.reason) || "未知理由");
3023
+ const cid = x && x.conflictId ? " · " + String(x.conflictId) : "";
3024
+ return h("span", { className: "smem-dbg-score", key: String((x && x.file) || i) + "-" + i }, [
3025
+ h("span", { className: "smem-dbg-file", key: "f" }, String((x && x.file) || "?")),
3026
+ h("span", { className: "smem-dbg-num", key: "r" }, reason + cid),
3027
+ ]);
3028
+ }))
3029
+ : h("span", { className: "smem-muted", key: "v" }, "无"),
3030
+ ]),
3031
+ validitySuppressed.length
3032
+ ? h("div", { className: "smem-ar-hint", key: "vshint" },
3033
+ "被标失效或待裁决矛盾挡掉的记忆不会进正文 —— 这不是检索没命中,是 2.0.0 的有效性规则生效;去「冲突」页签可裁决。")
3034
+ : null,
2795
3035
  h("div", { className: "smem-ar-hint", key: "hint" },
2796
3036
  "注入每走一步都会尝试一次;同题同命中集不会重复注入 —— 所以「空」多半是去重生效,不是坏了。"),
2797
3037
  histogram.length
@@ -2911,6 +3151,317 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
2911
3151
  ]);
2912
3152
  }
2913
3153
 
3154
+ // ── 「冲突」页签(2.0.0 矛盾检测)────────────────────────────────────
3155
+ // 只做三件事:看(两份正文对照)、裁(标失效 / 标记已裁决 / 撤销 / 删除记录)、
3156
+ // 说清后果(写哪个文件、能不能撤销、失败时的出路在哪)。
3157
+ //
3158
+ // 状态全部由 Section 持有(与文件列表 / 体检同一套「组件只吃 props」的规矩):
3159
+ // 头部徽标与这一屏数的是同一批记录,两边各存一份就会不同步。
3160
+ //
3161
+ // 文案里反复写「只标不删」:这是 2.0.0 最容易被误解的一条 —— 标失效不删文件、
3162
+ // 不移动、不进 archive/,只是 frontmatter 多一行 invalid_at,随时可撤销。
3163
+ const CONFLICT_KIND_KEY = {
3164
+ contradiction: "conflict.kind.contradiction",
3165
+ stale_version: "conflict.kind.staleVersion",
3166
+ external_change: "conflict.kind.externalChange",
3167
+ unresolved_merge: "conflict.kind.unresolvedMerge",
3168
+ };
3169
+ const CONFLICT_STATUS_KEY = {
3170
+ open: "conflict.status.open",
3171
+ resolved: "conflict.status.resolved",
3172
+ dismissed: "conflict.status.dismissed",
3173
+ };
3174
+ const CONFLICT_DECISION_KEY = {
3175
+ invalidate: "conflict.decision.invalidate",
3176
+ dismiss: "conflict.decision.dismiss",
3177
+ keep_both: "conflict.decision.keepBoth",
3178
+ };
3179
+ // 失败码 → 出路。**每一条都必须说清下一步**:只写「失败了」等于让用户再点一次
3180
+ // 同一颗按钮(D25 修过的正是「点了没反应」这类事故)。
3181
+ const CONFLICT_CODE_KEY = {
3182
+ invalid: "conflict.code.invalid",
3183
+ missing: "conflict.code.missing",
3184
+ malformed: "conflict.code.malformed",
3185
+ invalid_transition: "conflict.code.invalidTransition",
3186
+ stale_version: "conflict.code.staleVersion",
3187
+ write_denied: "conflict.code.writeDenied",
3188
+ unsupported: "conflict.code.unsupported",
3189
+ io_error: "conflict.code.ioError",
3190
+ };
3191
+ /**
3192
+ * 每份正文默认只显示开头一段。
3193
+ *
3194
+ * 按**字符**截 2048(≈「2 KB」的同量级):按字节算的话中文只剩六百多字,
3195
+ * 对照两份说法时反而不够看;卡片本身还有 max-height 滚动,不会撑破布局。
3196
+ */
3197
+ const CONFLICT_BODY_MAX = 2048;
3198
+
3199
+ function conflictKindLabel(kind) {
3200
+ const key = CONFLICT_KIND_KEY[String(kind)];
3201
+ return key ? t(key) : String(kind == null ? "" : kind);
3202
+ }
3203
+ function conflictStatusLabel(status) {
3204
+ const key = CONFLICT_STATUS_KEY[String(status)];
3205
+ return key ? t(key) : String(status == null ? "" : status);
3206
+ }
3207
+ function conflictDecisionLabel(decision) {
3208
+ const key = CONFLICT_DECISION_KEY[String(decision)];
3209
+ return key ? t(key) : String(decision == null ? "" : decision);
3210
+ }
3211
+
3212
+ /**
3213
+ * 「冲突」面板(只读列表 + 裁决动作)。
3214
+ *
3215
+ * 能力降级是**逐动作**的:宿主有 list 没 invalidate(版本错配)时,对应按钮必须
3216
+ * 禁用并在同一屏里写明缺哪个接口 —— 不能让用户点了没反应。
3217
+ */
3218
+ function ConflictPanel(props) {
3219
+ const data = props.data;
3220
+ const items = data && Array.isArray(data.items) ? data.items : [];
3221
+ const broken = data && Array.isArray(data.broken) ? data.broken : [];
3222
+ const ignored = data && Array.isArray(data.ignored) ? data.ignored : [];
3223
+ const counts = (data && data.counts) || {};
3224
+ const openN = typeof counts.open === "number"
3225
+ ? counts.open
3226
+ : items.filter(function (x) { return x.status === "open"; }).length;
3227
+ const totalN = typeof counts.total === "number" ? counts.total : items.length;
3228
+ const tools = props.tools || {};
3229
+ const bodies = props.bodies || {};
3230
+ const expanded = props.expanded || {};
3231
+ const confirm = props.confirm || null;
3232
+ const busyId = props.busyId || null;
3233
+ // `removeRecord` 而不是 `remove`:后者是宿主 RemoteNamespaceService 的保留方法名
3234
+ // (声明即让整个 client 插件 activate 失败)。按钮文案的 i18n 键仍是 conflict.action.remove。
3235
+ const ACTIONS = ["invalidate", "dismiss", "undo", "removeRecord"];
3236
+ const missing = ACTIONS.filter(function (m) { return tools[m] !== true; })
3237
+ .map(function (m) { return "conflict." + m; });
3238
+
3239
+ /** 动作按钮:方法不存在时禁用 + title 写明缺什么,绝不留「点了没反应」。 */
3240
+ function actBtn(item, action, label, extraClass) {
3241
+ const ok = tools[action] === true;
3242
+ return h("button", {
3243
+ type: "button", key: action,
3244
+ className: "smem-btn" + (extraClass || ""),
3245
+ disabled: !ok || busyId === item.id,
3246
+ title: ok ? undefined : t("conflict.actionMissing", { method: action }),
3247
+ onClick: ok ? function () { props.onAsk(item, action); } : undefined,
3248
+ }, label);
3249
+ }
3250
+
3251
+ function bodyPre(text) {
3252
+ const s = typeof text === "string" ? text : "";
3253
+ const shown = s.slice(0, CONFLICT_BODY_MAX);
3254
+ const more = s.length > CONFLICT_BODY_MAX;
3255
+ return h("pre", { className: "smem-conf-pre", key: "pre" }, [
3256
+ shown || t("conflict.bodyEmpty"),
3257
+ more ? "\n… " + t("conflict.bodyLimit") : null,
3258
+ ]);
3259
+ }
3260
+
3261
+ function renderBodies(item, body) {
3262
+ if (!body || body.loading) {
3263
+ return [h("div", { className: "smem-status", key: "l" }, t("conflict.bodyLoading"))];
3264
+ }
3265
+ if (body.error) {
3266
+ return [h("div", { className: "smem-err", key: "e" }, "⚠ " + body.error)];
3267
+ }
3268
+ const rec = body.record || {};
3269
+ const contra = item.kind === "contradiction";
3270
+ const parts = [
3271
+ h("div", { className: "smem-conf-body", key: "in" }, [
3272
+ h("div", { className: "smem-conf-label", key: "l" },
3273
+ contra ? t("conflict.bodyIncomingContradiction") : t("conflict.bodyIncoming")),
3274
+ bodyPre(rec.incoming),
3275
+ Array.isArray(body.warnings) && body.warnings.length
3276
+ ? h("div", { className: "smem-ar-hint", key: "w" },
3277
+ t("conflict.warnings", { list: body.warnings.map(String).join(";") }))
3278
+ : null,
3279
+ ]),
3280
+ h("div", { className: "smem-conf-body", key: "on" }, [
3281
+ h("div", { className: "smem-conf-label", key: "l" },
3282
+ contra ? t("conflict.bodyOnDiskContradiction") : t("conflict.bodyOnDisk")),
3283
+ bodyPre(rec.onDisk),
3284
+ ]),
3285
+ ];
3286
+ if (rec.truncated || item.truncated) {
3287
+ parts.push(h("div", { className: "smem-conf-warn", key: "tr" }, t("conflict.truncatedHint")));
3288
+ }
3289
+ return parts;
3290
+ }
3291
+
3292
+ /** 危险动作的两段式确认条(照「彻底归档删除」先例:先问,再动手)。 */
3293
+ function confirmBlock(item) {
3294
+ if (!confirm || confirm.id !== item.id) return null;
3295
+ const act = confirm.action;
3296
+ const file = confirm.file || item.file;
3297
+ if (act === "invalidate") {
3298
+ return h("div", { className: "smem-conf-confirm", key: "cf" }, [
3299
+ h("div", { key: "what" }, t("conflict.confirmInvalidate", { file: file })),
3300
+ item.otherFile
3301
+ ? h("div", { className: "smem-chips", key: "pick" }, [
3302
+ h(Chip, {
3303
+ key: "f", label: item.file, on: file === item.file,
3304
+ onClick: function () { props.onPickFile(item.file); },
3305
+ }),
3306
+ h(Chip, {
3307
+ key: "o", label: item.otherFile, on: file === item.otherFile,
3308
+ onClick: function () { props.onPickFile(item.otherFile); },
3309
+ }),
3310
+ ])
3311
+ : null,
3312
+ h("div", { className: "smem-ar-hint", key: "hint" }, t("conflict.confirmInvalidateHint")),
3313
+ h("div", { className: "smem-row", key: "btns" }, [
3314
+ h("button", {
3315
+ type: "button", className: "smem-btn smem-btn-danger", key: "yes",
3316
+ disabled: busyId === item.id,
3317
+ onClick: props.onRun,
3318
+ }, busyId === item.id ? t("conflict.busy") : t("conflict.confirmYesInvalidate")),
3319
+ h("button", {
3320
+ type: "button", className: "smem-btn", key: "no",
3321
+ disabled: busyId === item.id, onClick: props.onCancelConfirm,
3322
+ }, t("conflict.cancel")),
3323
+ ]),
3324
+ ]);
3325
+ }
3326
+ if (act === "undo") {
3327
+ return h("div", { className: "smem-conf-confirm", key: "cf" }, [
3328
+ h("div", { key: "what" }, t("conflict.confirmUndo", { file: item.invalidatedFile || item.file })),
3329
+ h("div", { className: "smem-ar-hint", key: "hint" }, t("conflict.confirmUndoHint")),
3330
+ h("div", { className: "smem-row", key: "btns" }, [
3331
+ h("button", {
3332
+ type: "button", className: "smem-btn smem-btn-primary", key: "yes",
3333
+ disabled: busyId === item.id, onClick: props.onRun,
3334
+ }, busyId === item.id ? t("conflict.busy") : t("conflict.confirmYesUndo")),
3335
+ h("button", {
3336
+ type: "button", className: "smem-btn", key: "no",
3337
+ disabled: busyId === item.id, onClick: props.onCancelConfirm,
3338
+ }, t("conflict.cancel")),
3339
+ ]),
3340
+ ]);
3341
+ }
3342
+ if (act === "removeRecord") {
3343
+ return h("div", { className: "smem-conf-confirm", key: "cf" }, [
3344
+ h("div", { key: "what" }, t("conflict.confirmRemove")),
3345
+ h("div", { className: "smem-ar-hint", key: "hint" }, t("conflict.confirmRemoveHint")),
3346
+ h("div", { className: "smem-row", key: "btns" }, [
3347
+ h("button", {
3348
+ type: "button", className: "smem-btn smem-btn-danger", key: "yes",
3349
+ disabled: busyId === item.id, onClick: props.onRun,
3350
+ }, busyId === item.id ? t("conflict.busy") : t("conflict.confirmYesRemove")),
3351
+ h("button", {
3352
+ type: "button", className: "smem-btn", key: "no",
3353
+ disabled: busyId === item.id, onClick: props.onCancelConfirm,
3354
+ }, t("conflict.cancel")),
3355
+ ]),
3356
+ ]);
3357
+ }
3358
+ return null;
3359
+ }
3360
+
3361
+ function renderItem(item) {
3362
+ const bodyOpen = expanded[item.id] === true;
3363
+ const body = bodies[item.id] || null;
3364
+ const openRec = item.status === "open";
3365
+ // 撤销只对「当时真的写过文件」的记录有意义:dismiss / keep_both 没写盘,
3366
+ // 给一颗「撤销」只会让人以为要撤掉什么,其实没有。
3367
+ const canUndo = !openRec && (item.decision === "invalidate" || !!item.invalidatedFile);
3368
+ return h("div", { className: "smem-conf-item", key: item.id }, [
3369
+ h("div", { className: "smem-conf-head", key: "head" }, [
3370
+ h("span", { className: "smem-badge", key: "kind" }, conflictKindLabel(item.kind)),
3371
+ h("span", {
3372
+ className: "smem-badge" + (openRec ? " smem-conf-status--open" : ""),
3373
+ key: "st",
3374
+ }, conflictStatusLabel(item.status)),
3375
+ h("span", { className: "smem-muted", key: "at" },
3376
+ item.at ? t("conflict.time", { at: item.at }) : t("conflict.noTime")),
3377
+ item.truncated ? h("span", { className: "smem-conf-warn", key: "tr" }, t("conflict.truncated")) : null,
3378
+ h("span", { className: "smem-conf-id", key: "id" }, String(item.id)),
3379
+ ]),
3380
+ h("div", { className: "smem-conf-files", key: "files" }, [
3381
+ h("button", {
3382
+ type: "button", className: "smem-btn smem-conf-file", key: "f",
3383
+ title: t("conflict.openFileTitle"),
3384
+ onClick: function () { props.onOpenFile(item.file); },
3385
+ }, String(item.file)),
3386
+ item.otherFile ? h("span", { className: "smem-conf-arrow", key: "ar" }, "↔") : null,
3387
+ item.otherFile
3388
+ ? h("button", {
3389
+ type: "button", className: "smem-btn smem-conf-file", key: "o",
3390
+ title: t("conflict.openFileTitle"),
3391
+ onClick: function () { props.onOpenFile(item.otherFile); },
3392
+ }, String(item.otherFile))
3393
+ : null,
3394
+ ]),
3395
+ item.otherFile ? h("div", { className: "smem-ar-hint", key: "pair" }, t("conflict.pairHint")) : null,
3396
+ item.note ? h("div", { className: "smem-ar-hint", key: "note" }, t("conflict.note", { note: item.note })) : null,
3397
+ item.decision
3398
+ ? h("div", { className: "smem-ar-hint", key: "dec" },
3399
+ t("conflict.decisionLine", { decision: conflictDecisionLabel(item.decision), at: item.decidedAt || "—" }))
3400
+ : null,
3401
+ item.invalidatedFile
3402
+ ? h("div", { className: "smem-ar-hint", key: "inv" },
3403
+ t("conflict.invalidatedFile", { file: item.invalidatedFile }))
3404
+ : null,
3405
+ h("div", { className: "smem-conf-acts", key: "bodybar" }, [
3406
+ h("button", {
3407
+ type: "button", className: "smem-btn", key: "body",
3408
+ disabled: tools.read !== true,
3409
+ title: tools.read === true ? undefined : t("conflict.actionMissing", { method: "read" }),
3410
+ onClick: tools.read === true ? function () { props.onToggleBodies(item); } : undefined,
3411
+ }, bodyOpen ? t("conflict.bodiesHide") : t("conflict.bodiesToggle")),
3412
+ ]),
3413
+ bodyOpen ? h("div", { className: "smem-conf-bodies", key: "bodies" }, renderBodies(item, body)) : null,
3414
+ confirmBlock(item),
3415
+ h("div", { className: "smem-conf-acts", key: "acts" }, [
3416
+ openRec ? actBtn(item, "invalidate", t("conflict.action.invalidate"), " smem-btn-danger-ghost") : null,
3417
+ openRec ? actBtn(item, "dismiss", t("conflict.action.dismiss")) : null,
3418
+ canUndo ? actBtn(item, "undo", t("conflict.action.undo")) : null,
3419
+ actBtn(item, "removeRecord", t("conflict.action.remove"), " smem-btn-danger-ghost"),
3420
+ ]),
3421
+ ]);
3422
+ }
3423
+
3424
+ return h("div", { className: "smem-tool-panel smem-tool-panel--full smem-conf", key: "conflict" }, [
3425
+ h("div", { className: "smem-tool-head", key: "head" }, [
3426
+ h("span", { className: "smem-tool-title", key: "t" }, t("conflict.title")),
3427
+ // 数字只在**真的取到队列**之后才出现:加载中 / 失败时写「0 条」等于撒谎。
3428
+ data ? h("span", {
3429
+ className: "smem-tool-badge" + (openN > 0 ? " smem-tool-badge--bad" : ""), key: "n",
3430
+ }, t("conflict.openCount", { count: openN })) : null,
3431
+ data ? h("span", { className: "smem-muted", key: "total" }, t("conflict.totalCount", { count: totalN })) : null,
3432
+ h("button", {
3433
+ type: "button", className: "smem-btn", key: "re",
3434
+ disabled: props.loading || !!busyId, onClick: props.onReload,
3435
+ }, props.loading ? t("conflict.refreshing") : t("conflict.refresh")),
3436
+ ]),
3437
+ h("div", { className: "smem-ar-hint", key: "intro" }, t("conflict.intro")),
3438
+ props.error ? h("div", { className: "smem-err", key: "err", role: "alert" }, "⚠ " + props.error) : null,
3439
+ missing.length
3440
+ ? h("div", { className: "smem-ar-hint smem-conf-warn", key: "cap" },
3441
+ t("conflict.actionMissingHint", { methods: missing.join(" / ") }))
3442
+ : null,
3443
+ tools.read !== true
3444
+ ? h("div", { className: "smem-ar-hint smem-conf-warn", key: "readcap" }, t("conflict.readMissingHint"))
3445
+ : null,
3446
+ props.loading && !data
3447
+ ? h("div", { className: "smem-empty", key: "loading" }, t("conflict.loading"))
3448
+ : null,
3449
+ data && items.length === 0
3450
+ ? h("div", { className: "smem-empty smem-empty--ok", key: "empty" }, t("conflict.empty"))
3451
+ : null,
3452
+ data && items.length > 0
3453
+ ? h("div", { className: "smem-conf-list", key: "list" }, items.map(renderItem))
3454
+ : null,
3455
+ ignored.length
3456
+ ? h("div", { className: "smem-ar-hint", key: "ign" }, t("conflict.ignored", { count: ignored.length }))
3457
+ : null,
3458
+ broken.length
3459
+ ? h("div", { className: "smem-ar-hint smem-conf-warn", key: "brk" }, t("conflict.broken", { count: broken.length }))
3460
+ : null,
3461
+ h("div", { className: "smem-ar-hint", key: "foot" }, t("conflict.noApply")),
3462
+ ]);
3463
+ }
3464
+
2914
3465
  /**
2915
3466
  * 「归档候选」面板。
2916
3467
  *
@@ -3074,6 +3625,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3074
3625
  "tab.autodream": "自动做梦",
3075
3626
  "tab.archive": "自动归档",
3076
3627
  "tab.audit": "体检",
3628
+ "tab.conflict": "冲突",
3077
3629
 
3078
3630
  "type.user": "用户",
3079
3631
  "type.feedback": "反馈",
@@ -3218,6 +3770,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3218
3770
  "error.noAudit": "宿主侧还没有 audit 接口(插件版本较旧)",
3219
3771
  "error.noArchiveCandidates": "宿主侧还没有 archiveCandidates 接口(插件版本较旧)",
3220
3772
  "error.noGetSettings": "宿主侧还没有 getSettings 接口(插件版本较旧)",
3773
+ "error.noConflict": "宿主侧还没有 conflict 接口(插件版本较旧)",
3221
3774
  "error.noReadRaw": "宿主侧还没有 readRaw 接口(插件版本较旧)",
3222
3775
  "error.noWriteRaw": "宿主侧还没有 writeRaw 接口(插件版本较旧)",
3223
3776
  "error.readFailed": "读不到 {file}:{message}",
@@ -3244,6 +3797,88 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3244
3797
  "notice.restored": "已恢复 {file} —— 它回到了活动记忆里",
3245
3798
  "notice.restoredIndexed": "已恢复 {file} —— 它回到了活动记忆里,并补进了索引",
3246
3799
  "notice.purged": "已彻底删除 {file} —— 归档区里也没有它了,找不回来",
3800
+
3801
+ // ── 2.0.0 矛盾检测:冲突页签 / 徽标 / 裁决动作 / 失败码出路 ──
3802
+ // 文案口径:「失效」不是删除、不是归档;「记录」不是记忆本身;
3803
+ // 两种正文的语义按 kind 区分(矛盾 = 新说法 / 旧说法;写失败 = 待写入 / 盘上现行)。
3804
+ "header.conflictOpen": "冲突 {count} 待裁决",
3805
+ "header.conflictEmpty": "冲突 · 无",
3806
+ "header.conflictNotRun": "冲突 · 未载",
3807
+ "header.conflictSwitch": "切到冲突页裁决",
3808
+ "header.conflictClean": "没有待裁决的冲突",
3809
+ "header.conflictMissing": "冲突队列还没载入:点一下打开(失败会给出原因)",
3810
+ "conflict.title": "冲突队列",
3811
+ "conflict.openCount": "{count} 条待裁决",
3812
+ "conflict.totalCount": "共 {count} 条记录",
3813
+ "conflict.refresh": "刷新",
3814
+ "conflict.refreshing": "刷新中…",
3815
+ "conflict.loading": "读取冲突队列…",
3816
+ "conflict.intro": "「只标不删」:标失效只往记忆 frontmatter 写一行 invalid_at —— 文件仍在原目录、正文不变、随时可撤销。未裁决的矛盾两方本轮不注入正文(记忆本身仍可读)。",
3817
+ "conflict.noApply": "处理完只解除抑制:这里不会把任何一份正文写回记忆(待写内容可能基于过期版本,手工合并更安全)。",
3818
+ "conflict.empty": "没有待处理的冲突。矛盾会在自动整理记忆时被发现并落到这里;也可以直接手工编辑记忆。",
3819
+ "conflict.broken": "另有 {count} 条记录损坏,已跳过(其余照常显示)",
3820
+ "conflict.ignored": "另有 {count} 个文件不是冲突记录,已忽略",
3821
+ "conflict.truncated": "已截断",
3822
+ "conflict.truncatedHint": "宿主只保留了部分正文(已截断),完整内容在记忆文件里。",
3823
+ "conflict.time": "记录于 {at}",
3824
+ "conflict.noTime": "记录时间未知",
3825
+ "conflict.pairHint": "两条说法互斥:标失效时选哪一方作废。",
3826
+ "conflict.note": "说明:{note}",
3827
+ "conflict.decisionLine": "裁决:{decision} · {at}",
3828
+ "conflict.invalidatedFile": "已标失效:{file}",
3829
+ "conflict.openFileTitle": "在文件列表里查看这条记忆",
3830
+ "conflict.bodiesToggle": "展开两份正文",
3831
+ "conflict.bodiesHide": "收起两份正文",
3832
+ "conflict.bodyIncomingContradiction": "新说法(拟保留)",
3833
+ "conflict.bodyOnDiskContradiction": "旧说法(拟标失效)",
3834
+ "conflict.bodyIncoming": "待写入的那一版",
3835
+ "conflict.bodyOnDisk": "盘上现行内容",
3836
+ "conflict.bodyEmpty": "(空串)",
3837
+ "conflict.bodyLimit": "只显示前 2 KB",
3838
+ "conflict.bodyLoading": "读取正文…",
3839
+ "conflict.warnings": "读取提示:{list}",
3840
+ "conflict.action.invalidate": "标此条失效",
3841
+ "conflict.action.dismiss": "标记已裁决",
3842
+ "conflict.action.undo": "撤销失效",
3843
+ "conflict.action.remove": "删除记录",
3844
+ "conflict.actionMissing": "宿主侧还没有 conflict.{method} 接口(插件版本较旧),这个动作已禁用",
3845
+ "conflict.actionMissingHint": "版本较旧:缺少 {methods} 接口 —— 对应按钮已禁用。可升级插件,或手工编辑记忆 frontmatter(加 invalid_at)。",
3846
+ "conflict.readMissingHint": "版本较旧:没有 conflict.read 接口 —— 正文对照已禁用,仍可裁决。",
3847
+ "conflict.confirmInvalidate": "确认把「{file}」标为失效?会往它的 frontmatter 写 invalid_at(可撤销),文件仍在原目录、正文不变。",
3848
+ "conflict.confirmInvalidateHint": "只改 frontmatter 里的一行;不删除、不移动文件,也不改 updated。",
3849
+ "conflict.confirmUndo": "撤销「{file}」的失效标记?会删掉那行 invalid_at(以及 superseded_by),它重新参与注入。",
3850
+ "conflict.confirmUndoHint": "只删我们写的那一行,其他字节不动。",
3851
+ "conflict.confirmRemove": "删除这条冲突记录?记录会从队列里消失,不能恢复(记忆文件不受影响)。",
3852
+ "conflict.confirmRemoveHint": "只删状态目录里的记录文件;记忆本身一个字节都不动。",
3853
+ "conflict.confirmYesInvalidate": "确认标失效",
3854
+ "conflict.confirmYesUndo": "确认撤销",
3855
+ "conflict.confirmYesRemove": "确认删除记录",
3856
+ "conflict.cancel": "取消",
3857
+ "conflict.busy": "处理中…",
3858
+ "conflict.decision.invalidate": "标失效",
3859
+ "conflict.decision.dismiss": "标记已裁决",
3860
+ "conflict.decision.keepBoth": "两条都保留",
3861
+ "conflict.kind.contradiction": "矛盾",
3862
+ "conflict.kind.staleVersion": "版本冲突",
3863
+ "conflict.kind.externalChange": "外部改动",
3864
+ "conflict.kind.unresolvedMerge": "合并未完成",
3865
+ "conflict.status.open": "待裁决",
3866
+ "conflict.status.resolved": "已裁决",
3867
+ "conflict.status.dismissed": "已忽略",
3868
+ "conflict.code.invalid": "记录或文件名不合法:刷新列表后重试;若仍然失败,检查状态目录里的这条记录。",
3869
+ "conflict.code.missing": "记录或记忆文件已经不在了:刷新列表再看(记录可能已被别的窗口处理)。",
3870
+ "conflict.code.malformed": "这条记录格式损坏:可以删除记录,或手工检查状态目录里的 JSON。",
3871
+ "conflict.code.invalidTransition": "这条记录的状态已经变了:刷新列表后按当前状态再操作。",
3872
+ "conflict.code.staleVersion": "记忆文件在你打开之后被改过:先刷新列表、确认最新内容再裁决(本次不会覆盖你的改动)。",
3873
+ "conflict.code.writeDenied": "宿主拒绝了写入(只读 / 沙箱策略):把 DSH 的写入权限调宽后重试,或手工在记忆 frontmatter 里加 invalid_at。",
3874
+ "conflict.code.unsupported": "宿主没有可用的写入接口:升级插件,或手工在记忆 frontmatter 里加 invalid_at。",
3875
+ "conflict.code.ioError": "读写磁盘失败:稍后重试;记录仍是待裁决状态,不会留下半份文件。",
3876
+ "conflict.code.unknown": "操作失败(未知错误):刷新列表再试;若反复失败,查看宿主日志。",
3877
+ "conflict.code.detail": "(宿主说明:{detail})",
3878
+ "notice.conflictInvalidated": "已把 {file} 标为失效 —— 文件仍在原处,frontmatter 多了一行 invalid_at,可撤销",
3879
+ "notice.conflictDismissed": "已标记为已裁决 —— 没有改任何记忆文件",
3880
+ "notice.conflictUndone": "已撤销 {file} 的失效标记 —— 它重新参与注入",
3881
+ "notice.conflictRemoved": "已删除冲突记录(记忆文件没动)",
3247
3882
  };
3248
3883
 
3249
3884
  const EN = {
@@ -3256,6 +3891,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3256
3891
  "tab.autodream": "Autodream",
3257
3892
  "tab.archive": "Auto-archive",
3258
3893
  "tab.audit": "Audit",
3894
+ "tab.conflict": "Conflicts",
3259
3895
 
3260
3896
  "type.user": "User",
3261
3897
  "type.feedback": "Feedback",
@@ -3400,6 +4036,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3400
4036
  "error.noAudit": "The host has no audit API yet (older plugin version)",
3401
4037
  "error.noArchiveCandidates": "The host has no archiveCandidates API yet (older plugin version)",
3402
4038
  "error.noGetSettings": "The host has no getSettings API yet (older plugin version)",
4039
+ "error.noConflict": "The host has no conflict API yet (older plugin version)",
3403
4040
  "error.noReadRaw": "The host has no readRaw API yet (older plugin version)",
3404
4041
  "error.noWriteRaw": "The host has no writeRaw API yet (older plugin version)",
3405
4042
  "error.readFailed": "Cannot read {file}: {message}",
@@ -3426,6 +4063,86 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3426
4063
  "notice.restored": "Restored {file} — it is back among the active memories",
3427
4064
  "notice.restoredIndexed": "Restored {file} — it is back among the active memories and was added to the index",
3428
4065
  "notice.purged": "Permanently deleted {file} — it is gone from the archive as well and cannot be recovered",
4066
+
4067
+ // ── 2.0.0 contradiction detection: conflict tab / badge / decisions / error codes ──
4068
+ "header.conflictOpen": "Conflicts: {count} pending",
4069
+ "header.conflictEmpty": "Conflicts: none",
4070
+ "header.conflictNotRun": "Conflicts: not loaded",
4071
+ "header.conflictSwitch": "Switch to Conflicts to decide",
4072
+ "header.conflictClean": "No conflicts pending a decision",
4073
+ "header.conflictMissing": "The conflict queue is not loaded yet: click to open (a failure shows the reason)",
4074
+ "conflict.title": "Conflict queue",
4075
+ "conflict.openCount": "{count} pending",
4076
+ "conflict.totalCount": "{count} records in total",
4077
+ "conflict.refresh": "Refresh",
4078
+ "conflict.refreshing": "Refreshing…",
4079
+ "conflict.loading": "Reading the conflict queue…",
4080
+ "conflict.intro": "Mark, never delete: invalidating only writes one invalid_at line into the memory frontmatter — the file stays where it is, the body is untouched, and it can be undone. Both sides of an undecided contradiction stay out of injection this round (the memory itself is still readable).",
4081
+ "conflict.noApply": "Deciding only lifts the suppression: no body is ever written back from here (the pending text may be based on a stale version; merging by hand is safer).",
4082
+ "conflict.empty": "No conflicts to handle. Contradictions are discovered when memories are auto-organized and land here; you can also edit memories by hand.",
4083
+ "conflict.broken": "{count} records are broken and were skipped (the rest are listed normally)",
4084
+ "conflict.ignored": "{count} files are not conflict records and were ignored",
4085
+ "conflict.truncated": "truncated",
4086
+ "conflict.truncatedHint": "The host kept only part of the text (truncated); the full content is in the memory file.",
4087
+ "conflict.time": "recorded at {at}",
4088
+ "conflict.noTime": "recorded time unknown",
4089
+ "conflict.pairHint": "The two statements contradict each other: choose which side becomes invalid.",
4090
+ "conflict.note": "Note: {note}",
4091
+ "conflict.decisionLine": "Decision: {decision} · {at}",
4092
+ "conflict.invalidatedFile": "Marked invalid: {file}",
4093
+ "conflict.openFileTitle": "Open this memory in the file list",
4094
+ "conflict.bodiesToggle": "Show both bodies",
4095
+ "conflict.bodiesHide": "Hide both bodies",
4096
+ "conflict.bodyIncomingContradiction": "New statement (to keep)",
4097
+ "conflict.bodyOnDiskContradiction": "Old statement (to invalidate)",
4098
+ "conflict.bodyIncoming": "The version that was going to be written",
4099
+ "conflict.bodyOnDisk": "What is currently on disk",
4100
+ "conflict.bodyEmpty": "(empty string)",
4101
+ "conflict.bodyLimit": "showing the first 2 KB only",
4102
+ "conflict.bodyLoading": "Reading bodies…",
4103
+ "conflict.warnings": "Read warnings: {list}",
4104
+ "conflict.action.invalidate": "Invalidate this side",
4105
+ "conflict.action.dismiss": "Mark as decided",
4106
+ "conflict.action.undo": "Undo invalidation",
4107
+ "conflict.action.remove": "Delete record",
4108
+ "conflict.actionMissing": "The host has no conflict.{method} API yet (older plugin version), so this action is disabled",
4109
+ "conflict.actionMissingHint": "Older version: missing {methods} — the matching buttons are disabled. Upgrade the plugin, or edit the memory frontmatter by hand (add invalid_at).",
4110
+ "conflict.readMissingHint": "Older version: no conflict.read API — the body comparison is disabled, but decisions still work.",
4111
+ "conflict.confirmInvalidate": "Mark “{file}” as invalid? This writes invalid_at into its frontmatter (reversible); the file stays in place and the body is unchanged.",
4112
+ "conflict.confirmInvalidateHint": "Only one frontmatter line changes; nothing is deleted or moved, and updated is left alone.",
4113
+ "conflict.confirmUndo": "Undo the invalidation of “{file}”? The invalid_at line (and superseded_by) is removed and it takes part in injection again.",
4114
+ "conflict.confirmUndoHint": "Only the line we wrote is removed; every other byte stays the same.",
4115
+ "conflict.confirmRemove": "Delete this conflict record? It disappears from the queue and cannot be restored (the memory file is not affected).",
4116
+ "conflict.confirmRemoveHint": "Only the record file in the state directory is deleted; the memory itself is untouched.",
4117
+ "conflict.confirmYesInvalidate": "Confirm invalidation",
4118
+ "conflict.confirmYesUndo": "Confirm undo",
4119
+ "conflict.confirmYesRemove": "Confirm delete record",
4120
+ "conflict.cancel": "Cancel",
4121
+ "conflict.busy": "Working…",
4122
+ "conflict.decision.invalidate": "invalidated",
4123
+ "conflict.decision.dismiss": "decided",
4124
+ "conflict.decision.keepBoth": "kept both",
4125
+ "conflict.kind.contradiction": "Contradiction",
4126
+ "conflict.kind.staleVersion": "Version conflict",
4127
+ "conflict.kind.externalChange": "External change",
4128
+ "conflict.kind.unresolvedMerge": "Unfinished merge",
4129
+ "conflict.status.open": "Pending",
4130
+ "conflict.status.resolved": "Decided",
4131
+ "conflict.status.dismissed": "Dismissed",
4132
+ "conflict.code.invalid": "The record or file name is invalid: refresh the list and try again; if it keeps failing, inspect that record in the state directory.",
4133
+ "conflict.code.missing": "The record or memory file is gone: refresh the list (another window may have handled it).",
4134
+ "conflict.code.malformed": "This record is malformed: delete the record, or inspect the JSON in the state directory by hand.",
4135
+ "conflict.code.invalidTransition": "This record already changed state: refresh the list and act on its current state.",
4136
+ "conflict.code.staleVersion": "The memory file changed after you opened it: refresh the list and check the latest content before deciding (your changes are not overwritten).",
4137
+ "conflict.code.writeDenied": "The host refused the write (read-only / sandbox policy): widen DSH write permissions and retry, or add invalid_at to the memory frontmatter by hand.",
4138
+ "conflict.code.unsupported": "The host has no usable write API: upgrade the plugin, or add invalid_at to the memory frontmatter by hand.",
4139
+ "conflict.code.ioError": "Disk read/write failed: retry later; the record stays pending and no half-written file is left behind.",
4140
+ "conflict.code.unknown": "The action failed (unknown error): refresh and try again; if it keeps failing, check the host log.",
4141
+ "conflict.code.detail": " (host detail: {detail})",
4142
+ "notice.conflictInvalidated": "Marked {file} as invalid — the file stays in place, its frontmatter gained one invalid_at line, and this can be undone",
4143
+ "notice.conflictDismissed": "Marked as decided — no memory file was changed",
4144
+ "notice.conflictUndone": "Undid the invalidation of {file} — it takes part in injection again",
4145
+ "notice.conflictRemoved": "Deleted the conflict record (no memory file was touched)",
3429
4146
  };
3430
4147
 
3431
4148
  /**
@@ -4695,6 +5412,28 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4695
5412
  const dbg = sDbg[0], setDbg = sDbg[1];
4696
5413
  const sDbgBusy = react.useState(false);
4697
5414
  const dbgBusy = sDbgBusy[0], setDbgBusy = sDbgBusy[1];
5415
+ // ── 冲突页签(2.0.0 矛盾检测)──
5416
+ // 能力检测分两层:remote 整个不存在 → 页签与徽标都不出现(旧宿主);
5417
+ // remote 在、某个动作方法缺失 → 按钮禁用并在屏上写明缺哪个接口(版本错配时
5418
+ // 绝不能变成「点了没反应」—— D25 的教训)。
5419
+ const conflictRemote = stableRemote(props.ctx, "remote.conflict");
5420
+ const conflictOk = !!(conflictRemote && typeof conflictRemote.list === "function");
5421
+ const sConf = react.useState(null);
5422
+ const conf = sConf[0], setConf = sConf[1];
5423
+ const sConfLoad = react.useState(false);
5424
+ const confLoad = sConfLoad[0], setConfLoad = sConfLoad[1];
5425
+ const sConfErr = react.useState(null);
5426
+ const confErr = sConfErr[0], setConfErr = sConfErr[1];
5427
+ // 正文按 id 缓存(只在展开时去 read);记录一变(裁决 / 刷新)就整批丢掉。
5428
+ const sConfBodies = react.useState({});
5429
+ const confBodies = sConfBodies[0], setConfBodies = sConfBodies[1];
5430
+ const sConfExpanded = react.useState({});
5431
+ const confExpanded = sConfExpanded[0], setConfExpanded = sConfExpanded[1];
5432
+ // 危险动作的两段式确认:{id, action, file}(null = 没在问)。
5433
+ const sConfConfirm = react.useState(null);
5434
+ const confConfirm = sConfConfirm[0], setConfConfirm = sConfConfirm[1];
5435
+ const sConfBusy = react.useState(null);
5436
+ const confBusy = sConfBusy[0], setConfBusy = sConfBusy[1];
4698
5437
  // renderSlot:shell 会经 props 传进来(见 80-apply 的 children 声明),
4699
5438
  // 但本插件改用并列 tab 渲染星图,不再调用它 —— 声明保留给 shell,这里不取。
4700
5439
 
@@ -4733,6 +5472,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4733
5472
  // 1.7.0:只读问一次「调试开关开着吗」—— 关着时抽屉里不会多出「注入调试」。
4734
5473
  // 这是唯一一处为调试视图多付的调用(一次本地只读 remote,不碰磁盘)。
4735
5474
  loadRecallDebug();
5475
+ // 2.0.0:冲突徽标与体检徽标同一套道理 —— 不打开也知道有没有待裁决的。
5476
+ // 旧宿主没有 conflict 面时 conflictOk=false,这里一个调用都不发。
5477
+ loadConflicts();
4736
5478
  /**
4737
5479
  * 更新检查:**静默**跑,失败一律不打扰 ——
4738
5480
  * 网络不通、被 GitHub 限流、仓库还没建 release,都不该在面板上冒一条红。
@@ -5143,6 +5885,187 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5143
5885
  .then(function () { setDbgBusy(false); });
5144
5886
  }
5145
5887
 
5888
+ /**
5889
+ * 拉一次冲突队列(只读)。
5890
+ *
5891
+ * 头部徽标与「冲突」页签数的是同一批记录,所以挂载时就取一次(旧宿主没有
5892
+ * conflict 面 → conflictOk=false,这里整段不跑,界面一个像素都不变)。
5893
+ * 失败返回 `{ok:false, code}` 而不是抛 —— 契约的一部分,转成可读文案。
5894
+ */
5895
+ function loadConflicts() {
5896
+ if (!conflictOk) return;
5897
+ setConfLoad(true); setConfErr(null);
5898
+ Promise.resolve()
5899
+ .then(function () { return conflictRemote.list(); })
5900
+ .then(function (res) {
5901
+ // ⚠️ 必须过 unwrap:typert remote 的通用传输层把返回值包成
5902
+ // `{ok:true, value:<方法返回值>}`。**方法返回值里自带的 `ok` 是内层**,
5903
+ // 与信封的 `ok` 是两个东西 —— 2026-10-27 真机就栽在这:直接把信封当
5904
+ // payload 读,`items` 永远是 undefined,界面显示「0 条待裁决」,
5905
+ // 而网络层明明返回了记录。与 76-sidebar-entry 的 setSettings 不同:
5906
+ // 那是特例,这里是通用路径。
5907
+ const d = unwrap(res) || {};
5908
+ if (d.ok === false) { setConf(null); setConfErr(conflictErrText(d)); return; }
5909
+ setConf({
5910
+ items: Array.isArray(d.items) ? d.items : [],
5911
+ broken: Array.isArray(d.broken) ? d.broken : [],
5912
+ ignored: Array.isArray(d.ignored) ? d.ignored : [],
5913
+ counts: d.counts && typeof d.counts === "object" ? d.counts : { open: 0, total: 0 },
5914
+ });
5915
+ })
5916
+ .catch(function (e) { setConf(null); setConfErr(String((e && e.message) || e)); })
5917
+ .then(function () { setConfLoad(false); });
5918
+ }
5919
+
5920
+ /**
5921
+ * 冲突动作的失败文案:先按 `code` 给出路,再附上宿主给的一句细节。
5922
+ * 失败码是界面上**唯一**能把「为什么没成」翻译成下一步动作的地方 —— 尤其
5923
+ * stale_version / write_denied / unsupported 三条,不问清就只会反复点同一颗按钮。
5924
+ */
5925
+ function conflictErrText(err) {
5926
+ const code = err && typeof err.code === "string" ? err.code : "";
5927
+ const key = CONFLICT_CODE_KEY[code] || "conflict.code.unknown";
5928
+ const detail = err && typeof err.error === "string" ? err.error : "";
5929
+ return t(key) + (detail ? t("conflict.code.detail", { detail: detail }) : "");
5930
+ }
5931
+
5932
+ /** 打开「冲突」这一屏。数据没取过才取(挂载时已经取过一次,通常直接有内容)。 */
5933
+ function openConflictTab() {
5934
+ setTab("conflict");
5935
+ setError(null); setNotice("");
5936
+ if (!conf || confErr) loadConflicts();
5937
+ }
5938
+
5939
+ /**
5940
+ * 展开 / 收起一条记录的两份正文。
5941
+ *
5942
+ * 正文只在**展开那一刻**才 read:列表阶段不取(记录里可能带几十 KB 正文),
5943
+ * 而且展开后要把「宿主截断了」这件事显示出来。
5944
+ */
5945
+ function toggleConflictBodies(item) {
5946
+ if (!conflictOk || typeof conflictRemote.read !== "function") return;
5947
+ const was = confExpanded[item.id] === true;
5948
+ const next = Object.assign({}, confExpanded);
5949
+ if (was) {
5950
+ delete next[item.id];
5951
+ setConfExpanded(next);
5952
+ return;
5953
+ }
5954
+ next[item.id] = true;
5955
+ setConfExpanded(next);
5956
+ if (confBodies[item.id] && confBodies[item.id].record) return;
5957
+ const mark = function (patch) {
5958
+ setConfBodies(function (cur) {
5959
+ const nx = Object.assign({}, cur);
5960
+ nx[item.id] = Object.assign({}, cur[item.id] || {}, patch);
5961
+ return nx;
5962
+ });
5963
+ };
5964
+ mark({ loading: true, error: null });
5965
+ Promise.resolve()
5966
+ .then(function () { return conflictRemote.read({ id: item.id }); })
5967
+ .then(function (res) {
5968
+ const d = unwrap(res) || {};
5969
+ if (d.ok === false) { mark({ loading: false, error: conflictErrText(d) }); return; }
5970
+ mark({
5971
+ loading: false,
5972
+ record: d.record || null,
5973
+ warnings: Array.isArray(d.warnings) ? d.warnings : [],
5974
+ });
5975
+ })
5976
+ .catch(function (e) { mark({ loading: false, error: String((e && e.message) || e) }); });
5977
+ }
5978
+
5979
+ /**
5980
+ * 请求一个动作。dismiss 不写记忆文件、按设计表**不需要二次确认**;标失效 /
5981
+ * 撤销失效 / 删记录都会走两段式(前两个写文件,后一个不可恢复)。
5982
+ */
5983
+ function askConflict(item, action) {
5984
+ setError(null); setNotice("");
5985
+ if (action === "dismiss") { runConflictNow(item.id, action, ""); return; }
5986
+ // 标失效默认选记录里的 `file`(= 被提议标失效的一方);撤销时默认显示
5987
+ // 记录里真正被标过的那一方(invalidatedFile)。
5988
+ const file = action === "undo"
5989
+ ? (item.invalidatedFile || item.file || "")
5990
+ : (item.file || item.otherFile || "");
5991
+ setConfConfirm({ id: item.id, action: action, file: file });
5992
+ }
5993
+
5994
+ /** 两段式确认里改「标哪一方失效」(矛盾记录有两方)。 */
5995
+ function pickConflictFile(file) {
5996
+ setConfConfirm(function (cur) {
5997
+ return cur ? Object.assign({}, cur, { file: file }) : cur;
5998
+ });
5999
+ }
6000
+
6001
+ function cancelConflictConfirm() { setConfConfirm(null); }
6002
+
6003
+ function runConflictAction() {
6004
+ if (!confConfirm) return;
6005
+ runConflictNow(confConfirm.id, confConfirm.action, confConfirm.file);
6006
+ }
6007
+
6008
+ /**
6009
+ * 真正执行一个裁决动作。
6010
+ *
6011
+ * 顺序刻意固定:成功 → 清确认条 / 清正文缓存 → 重新拉队列 → 再刷记忆列表
6012
+ * (invalidate / undo 改了记忆文件,文件列表与星图都得跟着变)。
6013
+ * 失败**不动**记录之外的任何状态:错误文案(含出路)挂在全局错误条上。
6014
+ */
6015
+ function runConflictNow(id, action, file) {
6016
+ if (!conflictOk) { setError(t("error.noConflict")); return; }
6017
+ const fn = conflictRemote[action];
6018
+ if (typeof fn !== "function") {
6019
+ setError(t("conflict.actionMissing", { method: action }));
6020
+ return;
6021
+ }
6022
+ const opts = { id: id };
6023
+ if (action === "invalidate" && file) opts.file = file;
6024
+ setConfBusy(id); setError(null); setNotice("");
6025
+ Promise.resolve()
6026
+ .then(function () { return fn.call(conflictRemote, opts); })
6027
+ .then(function (res) {
6028
+ const d = unwrap(res) || {};
6029
+ if (d.ok === false) { setError(conflictErrText(d)); return; }
6030
+ const rec = d.record || {};
6031
+ setConfConfirm(null);
6032
+ // 记录变了:正文缓存与展开状态一起作废,避免展示已过期的正文。
6033
+ setConfBodies({});
6034
+ setConfExpanded({});
6035
+ if (action === "invalidate") {
6036
+ const wrote = d.wrote || {};
6037
+ setNotice(t("notice.conflictInvalidated", {
6038
+ file: rec.invalidatedFile || wrote.file || file,
6039
+ }));
6040
+ } else if (action === "dismiss") {
6041
+ setNotice(t("notice.conflictDismissed"));
6042
+ } else if (action === "undo") {
6043
+ const cleared = d.cleared || {};
6044
+ setNotice(t("notice.conflictUndone", {
6045
+ file: rec.invalidatedFile || cleared.file || file,
6046
+ }));
6047
+ } else {
6048
+ setNotice(t("notice.conflictRemoved"));
6049
+ }
6050
+ loadConflicts();
6051
+ // 只有真的写过记忆文件的动作才需要重扫文件列表。
6052
+ if (action === "invalidate" || action === "undo") load();
6053
+ })
6054
+ .catch(function (e) { setError(String((e && e.message) || e)); })
6055
+ .then(function () { setConfBusy(null); });
6056
+ }
6057
+
6058
+ /** 从冲突记录跳到文件列表看这条记忆(清掉筛选,否则可能被筛没)。 */
6059
+ function openConflictFile(name) {
6060
+ if (!name) return;
6061
+ setError(null);
6062
+ setTab("files");
6063
+ setStatusFilter("all");
6064
+ setTypeFilter([]);
6065
+ setTagFilter([]);
6066
+ setQuery(String(name));
6067
+ }
6068
+
5146
6069
  /**
5147
6070
  * 打开 / 收起一个工具面板。数据只在第一次打开时拉;已经拉过就复用
5148
6071
  * (「刷新」「重新体检」是显式动作,别替用户反复扫目录)。
@@ -5331,6 +6254,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5331
6254
  ? LIMIT_KEYS.filter(function (k) { return settings.limits[k] !== settings.pendingLimits[k]; }).length
5332
6255
  : 0;
5333
6256
  const restartPending = !!(settings && settings.restartRequired);
6257
+ // 冲突徽标数的是**待裁决**(open)条数 —— 与冲突页签里的「N 条待裁决」同源。
6258
+ // -1 = 还没取到队列(显示「未载」,不假装 0 条)。
6259
+ const conflictOpen = conf && conf.counts && typeof conf.counts.open === "number" ? conf.counts.open : -1;
5334
6260
 
5335
6261
  // 「文件列表」这一屏:状态筛选决定看哪一批,灰色卡片与留痕块自带「退役」语义。
5336
6262
  // banner 只在**专门看归档**时出现,切到全部时不重复说一遍。
@@ -5366,6 +6292,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5366
6292
  // 体检再排在「自动归档」之后、独立成一屏:它只报告不修改,和「文件列表」里的
5367
6293
  // 增删改是两件事,塞在工具抽屉里会让人以为它是个动作按钮。
5368
6294
  h("button", { type: "button", className: "smem-tab" + (tab === "audit" ? " smem-tab--on" : ""), key: "a", role: "tab", "aria-selected": tab === "audit" ? "true" : "false", onClick: openAuditTab }, t("tab.audit")),
6295
+ // 2.0.0「冲突」:裁决是**多动作、要逐条看两份正文**的流程,所以独立成第 6 个
6296
+ // 页签,而不是塞进工具抽屉(抽屉方案的前提是「一年用不了几次」)。
6297
+ // 没有 conflict 面的旧宿主:整颗不出现(能力检测,不是渲染错误)。
6298
+ conflictOk
6299
+ ? h("button", { type: "button", className: "smem-tab" + (tab === "conflict" ? " smem-tab--on" : ""), key: "c", role: "tab", "aria-selected": tab === "conflict" ? "true" : "false", onClick: openConflictTab }, t("tab.conflict"))
6300
+ : null,
5369
6301
  ]),
5370
6302
  // 四个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
5371
6303
  // 层级形状固定:导航=下划线(.smem-tabs)、筛选=chip、动作=按钮,三种不混用。
@@ -5381,6 +6313,20 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5381
6313
  title: audit ? (audit.problems ? t("header.auditSwitch") : t("header.auditClean")) : t("header.auditRun"),
5382
6314
  onClick: openAuditTab,
5383
6315
  }, audit ? (audit.problems ? t("header.auditProblems", { count: audit.problems }) : t("header.auditPass")) : t("header.auditNotRun")),
6316
+ // 冲突徽标:与体检徽标同一套「抽屉关着也一直看得见」。**没有 conflict 面
6317
+ // 的旧宿主整颗不出现**(页签也不出现),不是灰按钮 —— 那会让用户以为插件坏了。
6318
+ conflictOk
6319
+ ? h("button", {
6320
+ type: "button", key: "cf",
6321
+ className: "smem-tool-badge" + (conflictOpen > 0 ? " smem-tool-badge--bad" : ""),
6322
+ title: !conf
6323
+ ? t("header.conflictMissing")
6324
+ : conflictOpen > 0 ? t("header.conflictSwitch") : t("header.conflictClean"),
6325
+ onClick: openConflictTab,
6326
+ }, !conf ? t("header.conflictNotRun")
6327
+ : conflictOpen > 0 ? t("header.conflictOpen", { count: conflictOpen })
6328
+ : t("header.conflictEmpty"))
6329
+ : null,
5384
6330
  restartPending
5385
6331
  ? h("button", {
5386
6332
  type: "button", key: "rs", className: "smem-tool-badge smem-ad-badge--warn",
@@ -5558,6 +6504,34 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5558
6504
  // 体检面板在上面已经渲染过了 —— 这里必须显式给 null,
5559
6505
  // 否则会落进下面那个 `filesView` 兜底分支,变成「体检 + 一整套文件列表」同屏。
5560
6506
  ? null
6507
+ : tab === "conflict"
6508
+ ? h(ConflictPanel, {
6509
+ key: "conflict",
6510
+ data: conf,
6511
+ loading: confLoad,
6512
+ error: confErr,
6513
+ bodies: confBodies,
6514
+ expanded: confExpanded,
6515
+ confirm: confConfirm,
6516
+ busyId: confBusy,
6517
+ // 逐动作的能力检测:宿主有 list 没 invalidate(版本错配)时按钮要禁用,
6518
+ // 不是点了之后才报错。
6519
+ tools: {
6520
+ list: conflictOk,
6521
+ read: !!(conflictOk && typeof conflictRemote.read === "function"),
6522
+ invalidate: !!(conflictOk && typeof conflictRemote.invalidate === "function"),
6523
+ dismiss: !!(conflictOk && typeof conflictRemote.dismiss === "function"),
6524
+ undo: !!(conflictOk && typeof conflictRemote.undo === "function"),
6525
+ removeRecord: !!(conflictOk && typeof conflictRemote.removeRecord === "function"),
6526
+ },
6527
+ onReload: loadConflicts,
6528
+ onToggleBodies: toggleConflictBodies,
6529
+ onAsk: askConflict,
6530
+ onPickFile: pickConflictFile,
6531
+ onCancelConfirm: cancelConflictConfirm,
6532
+ onRun: runConflictAction,
6533
+ onOpenFile: openConflictFile,
6534
+ })
5561
6535
  : filesView,
5562
6536
  // 底部一行仓库链接(四个 tab 都在,不抢视线)
5563
6537
  h(StarLine, { key: "star-line" }),