sage-mem 1.9.1 → 2.0.0

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
  /**
@@ -2911,6 +3127,317 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
2911
3127
  ]);
2912
3128
  }
2913
3129
 
3130
+ // ── 「冲突」页签(2.0.0 矛盾检测)────────────────────────────────────
3131
+ // 只做三件事:看(两份正文对照)、裁(标失效 / 标记已裁决 / 撤销 / 删除记录)、
3132
+ // 说清后果(写哪个文件、能不能撤销、失败时的出路在哪)。
3133
+ //
3134
+ // 状态全部由 Section 持有(与文件列表 / 体检同一套「组件只吃 props」的规矩):
3135
+ // 头部徽标与这一屏数的是同一批记录,两边各存一份就会不同步。
3136
+ //
3137
+ // 文案里反复写「只标不删」:这是 2.0.0 最容易被误解的一条 —— 标失效不删文件、
3138
+ // 不移动、不进 archive/,只是 frontmatter 多一行 invalid_at,随时可撤销。
3139
+ const CONFLICT_KIND_KEY = {
3140
+ contradiction: "conflict.kind.contradiction",
3141
+ stale_version: "conflict.kind.staleVersion",
3142
+ external_change: "conflict.kind.externalChange",
3143
+ unresolved_merge: "conflict.kind.unresolvedMerge",
3144
+ };
3145
+ const CONFLICT_STATUS_KEY = {
3146
+ open: "conflict.status.open",
3147
+ resolved: "conflict.status.resolved",
3148
+ dismissed: "conflict.status.dismissed",
3149
+ };
3150
+ const CONFLICT_DECISION_KEY = {
3151
+ invalidate: "conflict.decision.invalidate",
3152
+ dismiss: "conflict.decision.dismiss",
3153
+ keep_both: "conflict.decision.keepBoth",
3154
+ };
3155
+ // 失败码 → 出路。**每一条都必须说清下一步**:只写「失败了」等于让用户再点一次
3156
+ // 同一颗按钮(D25 修过的正是「点了没反应」这类事故)。
3157
+ const CONFLICT_CODE_KEY = {
3158
+ invalid: "conflict.code.invalid",
3159
+ missing: "conflict.code.missing",
3160
+ malformed: "conflict.code.malformed",
3161
+ invalid_transition: "conflict.code.invalidTransition",
3162
+ stale_version: "conflict.code.staleVersion",
3163
+ write_denied: "conflict.code.writeDenied",
3164
+ unsupported: "conflict.code.unsupported",
3165
+ io_error: "conflict.code.ioError",
3166
+ };
3167
+ /**
3168
+ * 每份正文默认只显示开头一段。
3169
+ *
3170
+ * 按**字符**截 2048(≈「2 KB」的同量级):按字节算的话中文只剩六百多字,
3171
+ * 对照两份说法时反而不够看;卡片本身还有 max-height 滚动,不会撑破布局。
3172
+ */
3173
+ const CONFLICT_BODY_MAX = 2048;
3174
+
3175
+ function conflictKindLabel(kind) {
3176
+ const key = CONFLICT_KIND_KEY[String(kind)];
3177
+ return key ? t(key) : String(kind == null ? "" : kind);
3178
+ }
3179
+ function conflictStatusLabel(status) {
3180
+ const key = CONFLICT_STATUS_KEY[String(status)];
3181
+ return key ? t(key) : String(status == null ? "" : status);
3182
+ }
3183
+ function conflictDecisionLabel(decision) {
3184
+ const key = CONFLICT_DECISION_KEY[String(decision)];
3185
+ return key ? t(key) : String(decision == null ? "" : decision);
3186
+ }
3187
+
3188
+ /**
3189
+ * 「冲突」面板(只读列表 + 裁决动作)。
3190
+ *
3191
+ * 能力降级是**逐动作**的:宿主有 list 没 invalidate(版本错配)时,对应按钮必须
3192
+ * 禁用并在同一屏里写明缺哪个接口 —— 不能让用户点了没反应。
3193
+ */
3194
+ function ConflictPanel(props) {
3195
+ const data = props.data;
3196
+ const items = data && Array.isArray(data.items) ? data.items : [];
3197
+ const broken = data && Array.isArray(data.broken) ? data.broken : [];
3198
+ const ignored = data && Array.isArray(data.ignored) ? data.ignored : [];
3199
+ const counts = (data && data.counts) || {};
3200
+ const openN = typeof counts.open === "number"
3201
+ ? counts.open
3202
+ : items.filter(function (x) { return x.status === "open"; }).length;
3203
+ const totalN = typeof counts.total === "number" ? counts.total : items.length;
3204
+ const tools = props.tools || {};
3205
+ const bodies = props.bodies || {};
3206
+ const expanded = props.expanded || {};
3207
+ const confirm = props.confirm || null;
3208
+ const busyId = props.busyId || null;
3209
+ // `removeRecord` 而不是 `remove`:后者是宿主 RemoteNamespaceService 的保留方法名
3210
+ // (声明即让整个 client 插件 activate 失败)。按钮文案的 i18n 键仍是 conflict.action.remove。
3211
+ const ACTIONS = ["invalidate", "dismiss", "undo", "removeRecord"];
3212
+ const missing = ACTIONS.filter(function (m) { return tools[m] !== true; })
3213
+ .map(function (m) { return "conflict." + m; });
3214
+
3215
+ /** 动作按钮:方法不存在时禁用 + title 写明缺什么,绝不留「点了没反应」。 */
3216
+ function actBtn(item, action, label, extraClass) {
3217
+ const ok = tools[action] === true;
3218
+ return h("button", {
3219
+ type: "button", key: action,
3220
+ className: "smem-btn" + (extraClass || ""),
3221
+ disabled: !ok || busyId === item.id,
3222
+ title: ok ? undefined : t("conflict.actionMissing", { method: action }),
3223
+ onClick: ok ? function () { props.onAsk(item, action); } : undefined,
3224
+ }, label);
3225
+ }
3226
+
3227
+ function bodyPre(text) {
3228
+ const s = typeof text === "string" ? text : "";
3229
+ const shown = s.slice(0, CONFLICT_BODY_MAX);
3230
+ const more = s.length > CONFLICT_BODY_MAX;
3231
+ return h("pre", { className: "smem-conf-pre", key: "pre" }, [
3232
+ shown || t("conflict.bodyEmpty"),
3233
+ more ? "\n… " + t("conflict.bodyLimit") : null,
3234
+ ]);
3235
+ }
3236
+
3237
+ function renderBodies(item, body) {
3238
+ if (!body || body.loading) {
3239
+ return [h("div", { className: "smem-status", key: "l" }, t("conflict.bodyLoading"))];
3240
+ }
3241
+ if (body.error) {
3242
+ return [h("div", { className: "smem-err", key: "e" }, "⚠ " + body.error)];
3243
+ }
3244
+ const rec = body.record || {};
3245
+ const contra = item.kind === "contradiction";
3246
+ const parts = [
3247
+ h("div", { className: "smem-conf-body", key: "in" }, [
3248
+ h("div", { className: "smem-conf-label", key: "l" },
3249
+ contra ? t("conflict.bodyIncomingContradiction") : t("conflict.bodyIncoming")),
3250
+ bodyPre(rec.incoming),
3251
+ Array.isArray(body.warnings) && body.warnings.length
3252
+ ? h("div", { className: "smem-ar-hint", key: "w" },
3253
+ t("conflict.warnings", { list: body.warnings.map(String).join(";") }))
3254
+ : null,
3255
+ ]),
3256
+ h("div", { className: "smem-conf-body", key: "on" }, [
3257
+ h("div", { className: "smem-conf-label", key: "l" },
3258
+ contra ? t("conflict.bodyOnDiskContradiction") : t("conflict.bodyOnDisk")),
3259
+ bodyPre(rec.onDisk),
3260
+ ]),
3261
+ ];
3262
+ if (rec.truncated || item.truncated) {
3263
+ parts.push(h("div", { className: "smem-conf-warn", key: "tr" }, t("conflict.truncatedHint")));
3264
+ }
3265
+ return parts;
3266
+ }
3267
+
3268
+ /** 危险动作的两段式确认条(照「彻底归档删除」先例:先问,再动手)。 */
3269
+ function confirmBlock(item) {
3270
+ if (!confirm || confirm.id !== item.id) return null;
3271
+ const act = confirm.action;
3272
+ const file = confirm.file || item.file;
3273
+ if (act === "invalidate") {
3274
+ return h("div", { className: "smem-conf-confirm", key: "cf" }, [
3275
+ h("div", { key: "what" }, t("conflict.confirmInvalidate", { file: file })),
3276
+ item.otherFile
3277
+ ? h("div", { className: "smem-chips", key: "pick" }, [
3278
+ h(Chip, {
3279
+ key: "f", label: item.file, on: file === item.file,
3280
+ onClick: function () { props.onPickFile(item.file); },
3281
+ }),
3282
+ h(Chip, {
3283
+ key: "o", label: item.otherFile, on: file === item.otherFile,
3284
+ onClick: function () { props.onPickFile(item.otherFile); },
3285
+ }),
3286
+ ])
3287
+ : null,
3288
+ h("div", { className: "smem-ar-hint", key: "hint" }, t("conflict.confirmInvalidateHint")),
3289
+ h("div", { className: "smem-row", key: "btns" }, [
3290
+ h("button", {
3291
+ type: "button", className: "smem-btn smem-btn-danger", key: "yes",
3292
+ disabled: busyId === item.id,
3293
+ onClick: props.onRun,
3294
+ }, busyId === item.id ? t("conflict.busy") : t("conflict.confirmYesInvalidate")),
3295
+ h("button", {
3296
+ type: "button", className: "smem-btn", key: "no",
3297
+ disabled: busyId === item.id, onClick: props.onCancelConfirm,
3298
+ }, t("conflict.cancel")),
3299
+ ]),
3300
+ ]);
3301
+ }
3302
+ if (act === "undo") {
3303
+ return h("div", { className: "smem-conf-confirm", key: "cf" }, [
3304
+ h("div", { key: "what" }, t("conflict.confirmUndo", { file: item.invalidatedFile || item.file })),
3305
+ h("div", { className: "smem-ar-hint", key: "hint" }, t("conflict.confirmUndoHint")),
3306
+ h("div", { className: "smem-row", key: "btns" }, [
3307
+ h("button", {
3308
+ type: "button", className: "smem-btn smem-btn-primary", key: "yes",
3309
+ disabled: busyId === item.id, onClick: props.onRun,
3310
+ }, busyId === item.id ? t("conflict.busy") : t("conflict.confirmYesUndo")),
3311
+ h("button", {
3312
+ type: "button", className: "smem-btn", key: "no",
3313
+ disabled: busyId === item.id, onClick: props.onCancelConfirm,
3314
+ }, t("conflict.cancel")),
3315
+ ]),
3316
+ ]);
3317
+ }
3318
+ if (act === "removeRecord") {
3319
+ return h("div", { className: "smem-conf-confirm", key: "cf" }, [
3320
+ h("div", { key: "what" }, t("conflict.confirmRemove")),
3321
+ h("div", { className: "smem-ar-hint", key: "hint" }, t("conflict.confirmRemoveHint")),
3322
+ h("div", { className: "smem-row", key: "btns" }, [
3323
+ h("button", {
3324
+ type: "button", className: "smem-btn smem-btn-danger", key: "yes",
3325
+ disabled: busyId === item.id, onClick: props.onRun,
3326
+ }, busyId === item.id ? t("conflict.busy") : t("conflict.confirmYesRemove")),
3327
+ h("button", {
3328
+ type: "button", className: "smem-btn", key: "no",
3329
+ disabled: busyId === item.id, onClick: props.onCancelConfirm,
3330
+ }, t("conflict.cancel")),
3331
+ ]),
3332
+ ]);
3333
+ }
3334
+ return null;
3335
+ }
3336
+
3337
+ function renderItem(item) {
3338
+ const bodyOpen = expanded[item.id] === true;
3339
+ const body = bodies[item.id] || null;
3340
+ const openRec = item.status === "open";
3341
+ // 撤销只对「当时真的写过文件」的记录有意义:dismiss / keep_both 没写盘,
3342
+ // 给一颗「撤销」只会让人以为要撤掉什么,其实没有。
3343
+ const canUndo = !openRec && (item.decision === "invalidate" || !!item.invalidatedFile);
3344
+ return h("div", { className: "smem-conf-item", key: item.id }, [
3345
+ h("div", { className: "smem-conf-head", key: "head" }, [
3346
+ h("span", { className: "smem-badge", key: "kind" }, conflictKindLabel(item.kind)),
3347
+ h("span", {
3348
+ className: "smem-badge" + (openRec ? " smem-conf-status--open" : ""),
3349
+ key: "st",
3350
+ }, conflictStatusLabel(item.status)),
3351
+ h("span", { className: "smem-muted", key: "at" },
3352
+ item.at ? t("conflict.time", { at: item.at }) : t("conflict.noTime")),
3353
+ item.truncated ? h("span", { className: "smem-conf-warn", key: "tr" }, t("conflict.truncated")) : null,
3354
+ h("span", { className: "smem-conf-id", key: "id" }, String(item.id)),
3355
+ ]),
3356
+ h("div", { className: "smem-conf-files", key: "files" }, [
3357
+ h("button", {
3358
+ type: "button", className: "smem-btn smem-conf-file", key: "f",
3359
+ title: t("conflict.openFileTitle"),
3360
+ onClick: function () { props.onOpenFile(item.file); },
3361
+ }, String(item.file)),
3362
+ item.otherFile ? h("span", { className: "smem-conf-arrow", key: "ar" }, "↔") : null,
3363
+ item.otherFile
3364
+ ? h("button", {
3365
+ type: "button", className: "smem-btn smem-conf-file", key: "o",
3366
+ title: t("conflict.openFileTitle"),
3367
+ onClick: function () { props.onOpenFile(item.otherFile); },
3368
+ }, String(item.otherFile))
3369
+ : null,
3370
+ ]),
3371
+ item.otherFile ? h("div", { className: "smem-ar-hint", key: "pair" }, t("conflict.pairHint")) : null,
3372
+ item.note ? h("div", { className: "smem-ar-hint", key: "note" }, t("conflict.note", { note: item.note })) : null,
3373
+ item.decision
3374
+ ? h("div", { className: "smem-ar-hint", key: "dec" },
3375
+ t("conflict.decisionLine", { decision: conflictDecisionLabel(item.decision), at: item.decidedAt || "—" }))
3376
+ : null,
3377
+ item.invalidatedFile
3378
+ ? h("div", { className: "smem-ar-hint", key: "inv" },
3379
+ t("conflict.invalidatedFile", { file: item.invalidatedFile }))
3380
+ : null,
3381
+ h("div", { className: "smem-conf-acts", key: "bodybar" }, [
3382
+ h("button", {
3383
+ type: "button", className: "smem-btn", key: "body",
3384
+ disabled: tools.read !== true,
3385
+ title: tools.read === true ? undefined : t("conflict.actionMissing", { method: "read" }),
3386
+ onClick: tools.read === true ? function () { props.onToggleBodies(item); } : undefined,
3387
+ }, bodyOpen ? t("conflict.bodiesHide") : t("conflict.bodiesToggle")),
3388
+ ]),
3389
+ bodyOpen ? h("div", { className: "smem-conf-bodies", key: "bodies" }, renderBodies(item, body)) : null,
3390
+ confirmBlock(item),
3391
+ h("div", { className: "smem-conf-acts", key: "acts" }, [
3392
+ openRec ? actBtn(item, "invalidate", t("conflict.action.invalidate"), " smem-btn-danger-ghost") : null,
3393
+ openRec ? actBtn(item, "dismiss", t("conflict.action.dismiss")) : null,
3394
+ canUndo ? actBtn(item, "undo", t("conflict.action.undo")) : null,
3395
+ actBtn(item, "removeRecord", t("conflict.action.remove"), " smem-btn-danger-ghost"),
3396
+ ]),
3397
+ ]);
3398
+ }
3399
+
3400
+ return h("div", { className: "smem-tool-panel smem-tool-panel--full smem-conf", key: "conflict" }, [
3401
+ h("div", { className: "smem-tool-head", key: "head" }, [
3402
+ h("span", { className: "smem-tool-title", key: "t" }, t("conflict.title")),
3403
+ // 数字只在**真的取到队列**之后才出现:加载中 / 失败时写「0 条」等于撒谎。
3404
+ data ? h("span", {
3405
+ className: "smem-tool-badge" + (openN > 0 ? " smem-tool-badge--bad" : ""), key: "n",
3406
+ }, t("conflict.openCount", { count: openN })) : null,
3407
+ data ? h("span", { className: "smem-muted", key: "total" }, t("conflict.totalCount", { count: totalN })) : null,
3408
+ h("button", {
3409
+ type: "button", className: "smem-btn", key: "re",
3410
+ disabled: props.loading || !!busyId, onClick: props.onReload,
3411
+ }, props.loading ? t("conflict.refreshing") : t("conflict.refresh")),
3412
+ ]),
3413
+ h("div", { className: "smem-ar-hint", key: "intro" }, t("conflict.intro")),
3414
+ props.error ? h("div", { className: "smem-err", key: "err", role: "alert" }, "⚠ " + props.error) : null,
3415
+ missing.length
3416
+ ? h("div", { className: "smem-ar-hint smem-conf-warn", key: "cap" },
3417
+ t("conflict.actionMissingHint", { methods: missing.join(" / ") }))
3418
+ : null,
3419
+ tools.read !== true
3420
+ ? h("div", { className: "smem-ar-hint smem-conf-warn", key: "readcap" }, t("conflict.readMissingHint"))
3421
+ : null,
3422
+ props.loading && !data
3423
+ ? h("div", { className: "smem-empty", key: "loading" }, t("conflict.loading"))
3424
+ : null,
3425
+ data && items.length === 0
3426
+ ? h("div", { className: "smem-empty smem-empty--ok", key: "empty" }, t("conflict.empty"))
3427
+ : null,
3428
+ data && items.length > 0
3429
+ ? h("div", { className: "smem-conf-list", key: "list" }, items.map(renderItem))
3430
+ : null,
3431
+ ignored.length
3432
+ ? h("div", { className: "smem-ar-hint", key: "ign" }, t("conflict.ignored", { count: ignored.length }))
3433
+ : null,
3434
+ broken.length
3435
+ ? h("div", { className: "smem-ar-hint smem-conf-warn", key: "brk" }, t("conflict.broken", { count: broken.length }))
3436
+ : null,
3437
+ h("div", { className: "smem-ar-hint", key: "foot" }, t("conflict.noApply")),
3438
+ ]);
3439
+ }
3440
+
2914
3441
  /**
2915
3442
  * 「归档候选」面板。
2916
3443
  *
@@ -3074,6 +3601,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3074
3601
  "tab.autodream": "自动做梦",
3075
3602
  "tab.archive": "自动归档",
3076
3603
  "tab.audit": "体检",
3604
+ "tab.conflict": "冲突",
3077
3605
 
3078
3606
  "type.user": "用户",
3079
3607
  "type.feedback": "反馈",
@@ -3218,6 +3746,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3218
3746
  "error.noAudit": "宿主侧还没有 audit 接口(插件版本较旧)",
3219
3747
  "error.noArchiveCandidates": "宿主侧还没有 archiveCandidates 接口(插件版本较旧)",
3220
3748
  "error.noGetSettings": "宿主侧还没有 getSettings 接口(插件版本较旧)",
3749
+ "error.noConflict": "宿主侧还没有 conflict 接口(插件版本较旧)",
3221
3750
  "error.noReadRaw": "宿主侧还没有 readRaw 接口(插件版本较旧)",
3222
3751
  "error.noWriteRaw": "宿主侧还没有 writeRaw 接口(插件版本较旧)",
3223
3752
  "error.readFailed": "读不到 {file}:{message}",
@@ -3244,6 +3773,88 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3244
3773
  "notice.restored": "已恢复 {file} —— 它回到了活动记忆里",
3245
3774
  "notice.restoredIndexed": "已恢复 {file} —— 它回到了活动记忆里,并补进了索引",
3246
3775
  "notice.purged": "已彻底删除 {file} —— 归档区里也没有它了,找不回来",
3776
+
3777
+ // ── 2.0.0 矛盾检测:冲突页签 / 徽标 / 裁决动作 / 失败码出路 ──
3778
+ // 文案口径:「失效」不是删除、不是归档;「记录」不是记忆本身;
3779
+ // 两种正文的语义按 kind 区分(矛盾 = 新说法 / 旧说法;写失败 = 待写入 / 盘上现行)。
3780
+ "header.conflictOpen": "冲突 {count} 待裁决",
3781
+ "header.conflictEmpty": "冲突 · 无",
3782
+ "header.conflictNotRun": "冲突 · 未载",
3783
+ "header.conflictSwitch": "切到冲突页裁决",
3784
+ "header.conflictClean": "没有待裁决的冲突",
3785
+ "header.conflictMissing": "冲突队列还没载入:点一下打开(失败会给出原因)",
3786
+ "conflict.title": "冲突队列",
3787
+ "conflict.openCount": "{count} 条待裁决",
3788
+ "conflict.totalCount": "共 {count} 条记录",
3789
+ "conflict.refresh": "刷新",
3790
+ "conflict.refreshing": "刷新中…",
3791
+ "conflict.loading": "读取冲突队列…",
3792
+ "conflict.intro": "「只标不删」:标失效只往记忆 frontmatter 写一行 invalid_at —— 文件仍在原目录、正文不变、随时可撤销。未裁决的矛盾两方本轮不注入正文(记忆本身仍可读)。",
3793
+ "conflict.noApply": "处理完只解除抑制:这里不会把任何一份正文写回记忆(待写内容可能基于过期版本,手工合并更安全)。",
3794
+ "conflict.empty": "没有待处理的冲突。矛盾会在自动整理记忆时被发现并落到这里;也可以直接手工编辑记忆。",
3795
+ "conflict.broken": "另有 {count} 条记录损坏,已跳过(其余照常显示)",
3796
+ "conflict.ignored": "另有 {count} 个文件不是冲突记录,已忽略",
3797
+ "conflict.truncated": "已截断",
3798
+ "conflict.truncatedHint": "宿主只保留了部分正文(已截断),完整内容在记忆文件里。",
3799
+ "conflict.time": "记录于 {at}",
3800
+ "conflict.noTime": "记录时间未知",
3801
+ "conflict.pairHint": "两条说法互斥:标失效时选哪一方作废。",
3802
+ "conflict.note": "说明:{note}",
3803
+ "conflict.decisionLine": "裁决:{decision} · {at}",
3804
+ "conflict.invalidatedFile": "已标失效:{file}",
3805
+ "conflict.openFileTitle": "在文件列表里查看这条记忆",
3806
+ "conflict.bodiesToggle": "展开两份正文",
3807
+ "conflict.bodiesHide": "收起两份正文",
3808
+ "conflict.bodyIncomingContradiction": "新说法(拟保留)",
3809
+ "conflict.bodyOnDiskContradiction": "旧说法(拟标失效)",
3810
+ "conflict.bodyIncoming": "待写入的那一版",
3811
+ "conflict.bodyOnDisk": "盘上现行内容",
3812
+ "conflict.bodyEmpty": "(空串)",
3813
+ "conflict.bodyLimit": "只显示前 2 KB",
3814
+ "conflict.bodyLoading": "读取正文…",
3815
+ "conflict.warnings": "读取提示:{list}",
3816
+ "conflict.action.invalidate": "标此条失效",
3817
+ "conflict.action.dismiss": "标记已裁决",
3818
+ "conflict.action.undo": "撤销失效",
3819
+ "conflict.action.remove": "删除记录",
3820
+ "conflict.actionMissing": "宿主侧还没有 conflict.{method} 接口(插件版本较旧),这个动作已禁用",
3821
+ "conflict.actionMissingHint": "版本较旧:缺少 {methods} 接口 —— 对应按钮已禁用。可升级插件,或手工编辑记忆 frontmatter(加 invalid_at)。",
3822
+ "conflict.readMissingHint": "版本较旧:没有 conflict.read 接口 —— 正文对照已禁用,仍可裁决。",
3823
+ "conflict.confirmInvalidate": "确认把「{file}」标为失效?会往它的 frontmatter 写 invalid_at(可撤销),文件仍在原目录、正文不变。",
3824
+ "conflict.confirmInvalidateHint": "只改 frontmatter 里的一行;不删除、不移动文件,也不改 updated。",
3825
+ "conflict.confirmUndo": "撤销「{file}」的失效标记?会删掉那行 invalid_at(以及 superseded_by),它重新参与注入。",
3826
+ "conflict.confirmUndoHint": "只删我们写的那一行,其他字节不动。",
3827
+ "conflict.confirmRemove": "删除这条冲突记录?记录会从队列里消失,不能恢复(记忆文件不受影响)。",
3828
+ "conflict.confirmRemoveHint": "只删状态目录里的记录文件;记忆本身一个字节都不动。",
3829
+ "conflict.confirmYesInvalidate": "确认标失效",
3830
+ "conflict.confirmYesUndo": "确认撤销",
3831
+ "conflict.confirmYesRemove": "确认删除记录",
3832
+ "conflict.cancel": "取消",
3833
+ "conflict.busy": "处理中…",
3834
+ "conflict.decision.invalidate": "标失效",
3835
+ "conflict.decision.dismiss": "标记已裁决",
3836
+ "conflict.decision.keepBoth": "两条都保留",
3837
+ "conflict.kind.contradiction": "矛盾",
3838
+ "conflict.kind.staleVersion": "版本冲突",
3839
+ "conflict.kind.externalChange": "外部改动",
3840
+ "conflict.kind.unresolvedMerge": "合并未完成",
3841
+ "conflict.status.open": "待裁决",
3842
+ "conflict.status.resolved": "已裁决",
3843
+ "conflict.status.dismissed": "已忽略",
3844
+ "conflict.code.invalid": "记录或文件名不合法:刷新列表后重试;若仍然失败,检查状态目录里的这条记录。",
3845
+ "conflict.code.missing": "记录或记忆文件已经不在了:刷新列表再看(记录可能已被别的窗口处理)。",
3846
+ "conflict.code.malformed": "这条记录格式损坏:可以删除记录,或手工检查状态目录里的 JSON。",
3847
+ "conflict.code.invalidTransition": "这条记录的状态已经变了:刷新列表后按当前状态再操作。",
3848
+ "conflict.code.staleVersion": "记忆文件在你打开之后被改过:先刷新列表、确认最新内容再裁决(本次不会覆盖你的改动)。",
3849
+ "conflict.code.writeDenied": "宿主拒绝了写入(只读 / 沙箱策略):把 DSH 的写入权限调宽后重试,或手工在记忆 frontmatter 里加 invalid_at。",
3850
+ "conflict.code.unsupported": "宿主没有可用的写入接口:升级插件,或手工在记忆 frontmatter 里加 invalid_at。",
3851
+ "conflict.code.ioError": "读写磁盘失败:稍后重试;记录仍是待裁决状态,不会留下半份文件。",
3852
+ "conflict.code.unknown": "操作失败(未知错误):刷新列表再试;若反复失败,查看宿主日志。",
3853
+ "conflict.code.detail": "(宿主说明:{detail})",
3854
+ "notice.conflictInvalidated": "已把 {file} 标为失效 —— 文件仍在原处,frontmatter 多了一行 invalid_at,可撤销",
3855
+ "notice.conflictDismissed": "已标记为已裁决 —— 没有改任何记忆文件",
3856
+ "notice.conflictUndone": "已撤销 {file} 的失效标记 —— 它重新参与注入",
3857
+ "notice.conflictRemoved": "已删除冲突记录(记忆文件没动)",
3247
3858
  };
3248
3859
 
3249
3860
  const EN = {
@@ -3256,6 +3867,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3256
3867
  "tab.autodream": "Autodream",
3257
3868
  "tab.archive": "Auto-archive",
3258
3869
  "tab.audit": "Audit",
3870
+ "tab.conflict": "Conflicts",
3259
3871
 
3260
3872
  "type.user": "User",
3261
3873
  "type.feedback": "Feedback",
@@ -3400,6 +4012,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3400
4012
  "error.noAudit": "The host has no audit API yet (older plugin version)",
3401
4013
  "error.noArchiveCandidates": "The host has no archiveCandidates API yet (older plugin version)",
3402
4014
  "error.noGetSettings": "The host has no getSettings API yet (older plugin version)",
4015
+ "error.noConflict": "The host has no conflict API yet (older plugin version)",
3403
4016
  "error.noReadRaw": "The host has no readRaw API yet (older plugin version)",
3404
4017
  "error.noWriteRaw": "The host has no writeRaw API yet (older plugin version)",
3405
4018
  "error.readFailed": "Cannot read {file}: {message}",
@@ -3426,6 +4039,86 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3426
4039
  "notice.restored": "Restored {file} — it is back among the active memories",
3427
4040
  "notice.restoredIndexed": "Restored {file} — it is back among the active memories and was added to the index",
3428
4041
  "notice.purged": "Permanently deleted {file} — it is gone from the archive as well and cannot be recovered",
4042
+
4043
+ // ── 2.0.0 contradiction detection: conflict tab / badge / decisions / error codes ──
4044
+ "header.conflictOpen": "Conflicts: {count} pending",
4045
+ "header.conflictEmpty": "Conflicts: none",
4046
+ "header.conflictNotRun": "Conflicts: not loaded",
4047
+ "header.conflictSwitch": "Switch to Conflicts to decide",
4048
+ "header.conflictClean": "No conflicts pending a decision",
4049
+ "header.conflictMissing": "The conflict queue is not loaded yet: click to open (a failure shows the reason)",
4050
+ "conflict.title": "Conflict queue",
4051
+ "conflict.openCount": "{count} pending",
4052
+ "conflict.totalCount": "{count} records in total",
4053
+ "conflict.refresh": "Refresh",
4054
+ "conflict.refreshing": "Refreshing…",
4055
+ "conflict.loading": "Reading the conflict queue…",
4056
+ "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).",
4057
+ "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).",
4058
+ "conflict.empty": "No conflicts to handle. Contradictions are discovered when memories are auto-organized and land here; you can also edit memories by hand.",
4059
+ "conflict.broken": "{count} records are broken and were skipped (the rest are listed normally)",
4060
+ "conflict.ignored": "{count} files are not conflict records and were ignored",
4061
+ "conflict.truncated": "truncated",
4062
+ "conflict.truncatedHint": "The host kept only part of the text (truncated); the full content is in the memory file.",
4063
+ "conflict.time": "recorded at {at}",
4064
+ "conflict.noTime": "recorded time unknown",
4065
+ "conflict.pairHint": "The two statements contradict each other: choose which side becomes invalid.",
4066
+ "conflict.note": "Note: {note}",
4067
+ "conflict.decisionLine": "Decision: {decision} · {at}",
4068
+ "conflict.invalidatedFile": "Marked invalid: {file}",
4069
+ "conflict.openFileTitle": "Open this memory in the file list",
4070
+ "conflict.bodiesToggle": "Show both bodies",
4071
+ "conflict.bodiesHide": "Hide both bodies",
4072
+ "conflict.bodyIncomingContradiction": "New statement (to keep)",
4073
+ "conflict.bodyOnDiskContradiction": "Old statement (to invalidate)",
4074
+ "conflict.bodyIncoming": "The version that was going to be written",
4075
+ "conflict.bodyOnDisk": "What is currently on disk",
4076
+ "conflict.bodyEmpty": "(empty string)",
4077
+ "conflict.bodyLimit": "showing the first 2 KB only",
4078
+ "conflict.bodyLoading": "Reading bodies…",
4079
+ "conflict.warnings": "Read warnings: {list}",
4080
+ "conflict.action.invalidate": "Invalidate this side",
4081
+ "conflict.action.dismiss": "Mark as decided",
4082
+ "conflict.action.undo": "Undo invalidation",
4083
+ "conflict.action.remove": "Delete record",
4084
+ "conflict.actionMissing": "The host has no conflict.{method} API yet (older plugin version), so this action is disabled",
4085
+ "conflict.actionMissingHint": "Older version: missing {methods} — the matching buttons are disabled. Upgrade the plugin, or edit the memory frontmatter by hand (add invalid_at).",
4086
+ "conflict.readMissingHint": "Older version: no conflict.read API — the body comparison is disabled, but decisions still work.",
4087
+ "conflict.confirmInvalidate": "Mark “{file}” as invalid? This writes invalid_at into its frontmatter (reversible); the file stays in place and the body is unchanged.",
4088
+ "conflict.confirmInvalidateHint": "Only one frontmatter line changes; nothing is deleted or moved, and updated is left alone.",
4089
+ "conflict.confirmUndo": "Undo the invalidation of “{file}”? The invalid_at line (and superseded_by) is removed and it takes part in injection again.",
4090
+ "conflict.confirmUndoHint": "Only the line we wrote is removed; every other byte stays the same.",
4091
+ "conflict.confirmRemove": "Delete this conflict record? It disappears from the queue and cannot be restored (the memory file is not affected).",
4092
+ "conflict.confirmRemoveHint": "Only the record file in the state directory is deleted; the memory itself is untouched.",
4093
+ "conflict.confirmYesInvalidate": "Confirm invalidation",
4094
+ "conflict.confirmYesUndo": "Confirm undo",
4095
+ "conflict.confirmYesRemove": "Confirm delete record",
4096
+ "conflict.cancel": "Cancel",
4097
+ "conflict.busy": "Working…",
4098
+ "conflict.decision.invalidate": "invalidated",
4099
+ "conflict.decision.dismiss": "decided",
4100
+ "conflict.decision.keepBoth": "kept both",
4101
+ "conflict.kind.contradiction": "Contradiction",
4102
+ "conflict.kind.staleVersion": "Version conflict",
4103
+ "conflict.kind.externalChange": "External change",
4104
+ "conflict.kind.unresolvedMerge": "Unfinished merge",
4105
+ "conflict.status.open": "Pending",
4106
+ "conflict.status.resolved": "Decided",
4107
+ "conflict.status.dismissed": "Dismissed",
4108
+ "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.",
4109
+ "conflict.code.missing": "The record or memory file is gone: refresh the list (another window may have handled it).",
4110
+ "conflict.code.malformed": "This record is malformed: delete the record, or inspect the JSON in the state directory by hand.",
4111
+ "conflict.code.invalidTransition": "This record already changed state: refresh the list and act on its current state.",
4112
+ "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).",
4113
+ "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.",
4114
+ "conflict.code.unsupported": "The host has no usable write API: upgrade the plugin, or add invalid_at to the memory frontmatter by hand.",
4115
+ "conflict.code.ioError": "Disk read/write failed: retry later; the record stays pending and no half-written file is left behind.",
4116
+ "conflict.code.unknown": "The action failed (unknown error): refresh and try again; if it keeps failing, check the host log.",
4117
+ "conflict.code.detail": " (host detail: {detail})",
4118
+ "notice.conflictInvalidated": "Marked {file} as invalid — the file stays in place, its frontmatter gained one invalid_at line, and this can be undone",
4119
+ "notice.conflictDismissed": "Marked as decided — no memory file was changed",
4120
+ "notice.conflictUndone": "Undid the invalidation of {file} — it takes part in injection again",
4121
+ "notice.conflictRemoved": "Deleted the conflict record (no memory file was touched)",
3429
4122
  };
3430
4123
 
3431
4124
  /**
@@ -3954,6 +4647,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3954
4647
  " · 结构问题 " + last.problemsBefore + " → " + last.problemsAfter +
3955
4648
  " · token " + last.tokensIn + " / " + last.tokensOut)
3956
4649
  : ("失败:" + (last.error || "未知"))) +
4650
+ // 1.9.2:残篇必须在状态行上就能看见 —— 只写「成功」正是 10-25 那次误判的来源。
4651
+ (last.ok && last.truncated ? " · ⚠ 报告被截断(残篇)" : "") +
4652
+ (last.ok && !last.truncated && last.retries ? (" · 零正文重试 " + last.retries + " 次后补齐") : "") +
4653
+ (last.continuations ? (" · 续写 " + last.continuations + " 轮") : "") +
3957
4654
  (last.report ? " · 报告 " + last.report : ""))
3958
4655
  : null,
3959
4656
  ]),
@@ -4068,6 +4765,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4068
4765
  h("input", { className: "smem-num", key: "v", type: "number", min: "1", max: "80", value: c.maxSteps, onChange: function (e) { patch({ maxSteps: Number(e.target.value) }); } }),
4069
4766
  h("span", { className: "smem-status", key: "s" }, "一轮 = 一次模型调用 + 它要的工具调用"),
4070
4767
  ]),
4768
+ // 1.9.2:单次输出上限(原写死 4096)。思考型模型下 4096 会让收尾轮只剩思考、零正文。
4769
+ h("div", { className: "smem-autodream-row", key: "maxout" }, [
4770
+ h("span", { className: "smem-autodream-key", key: "k" }, "单次输出上限"),
4771
+ h("input", { className: "smem-num", key: "v", type: "number", min: "1024", max: "131072", value: c.maxOutputTokens, onChange: function (e) { patch({ maxOutputTokens: Number(e.target.value) }); } }),
4772
+ h("span", { className: "smem-status", key: "s" }, "token(思考与正文共用;报告残篇时先调它)"),
4773
+ ]),
4071
4774
  h("div", { className: "smem-autodream-row", key: "keep" }, [
4072
4775
  h("span", { className: "smem-autodream-key", key: "k" }, "保留快照"),
4073
4776
  h("input", { className: "smem-num", key: "v", type: "number", min: "1", max: "50", value: c.maxSnapshotKeep, onChange: function (e) { patch({ maxSnapshotKeep: Number(e.target.value) }); } }),
@@ -4685,6 +5388,28 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4685
5388
  const dbg = sDbg[0], setDbg = sDbg[1];
4686
5389
  const sDbgBusy = react.useState(false);
4687
5390
  const dbgBusy = sDbgBusy[0], setDbgBusy = sDbgBusy[1];
5391
+ // ── 冲突页签(2.0.0 矛盾检测)──
5392
+ // 能力检测分两层:remote 整个不存在 → 页签与徽标都不出现(旧宿主);
5393
+ // remote 在、某个动作方法缺失 → 按钮禁用并在屏上写明缺哪个接口(版本错配时
5394
+ // 绝不能变成「点了没反应」—— D25 的教训)。
5395
+ const conflictRemote = stableRemote(props.ctx, "remote.conflict");
5396
+ const conflictOk = !!(conflictRemote && typeof conflictRemote.list === "function");
5397
+ const sConf = react.useState(null);
5398
+ const conf = sConf[0], setConf = sConf[1];
5399
+ const sConfLoad = react.useState(false);
5400
+ const confLoad = sConfLoad[0], setConfLoad = sConfLoad[1];
5401
+ const sConfErr = react.useState(null);
5402
+ const confErr = sConfErr[0], setConfErr = sConfErr[1];
5403
+ // 正文按 id 缓存(只在展开时去 read);记录一变(裁决 / 刷新)就整批丢掉。
5404
+ const sConfBodies = react.useState({});
5405
+ const confBodies = sConfBodies[0], setConfBodies = sConfBodies[1];
5406
+ const sConfExpanded = react.useState({});
5407
+ const confExpanded = sConfExpanded[0], setConfExpanded = sConfExpanded[1];
5408
+ // 危险动作的两段式确认:{id, action, file}(null = 没在问)。
5409
+ const sConfConfirm = react.useState(null);
5410
+ const confConfirm = sConfConfirm[0], setConfConfirm = sConfConfirm[1];
5411
+ const sConfBusy = react.useState(null);
5412
+ const confBusy = sConfBusy[0], setConfBusy = sConfBusy[1];
4688
5413
  // renderSlot:shell 会经 props 传进来(见 80-apply 的 children 声明),
4689
5414
  // 但本插件改用并列 tab 渲染星图,不再调用它 —— 声明保留给 shell,这里不取。
4690
5415
 
@@ -4723,6 +5448,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4723
5448
  // 1.7.0:只读问一次「调试开关开着吗」—— 关着时抽屉里不会多出「注入调试」。
4724
5449
  // 这是唯一一处为调试视图多付的调用(一次本地只读 remote,不碰磁盘)。
4725
5450
  loadRecallDebug();
5451
+ // 2.0.0:冲突徽标与体检徽标同一套道理 —— 不打开也知道有没有待裁决的。
5452
+ // 旧宿主没有 conflict 面时 conflictOk=false,这里一个调用都不发。
5453
+ loadConflicts();
4726
5454
  /**
4727
5455
  * 更新检查:**静默**跑,失败一律不打扰 ——
4728
5456
  * 网络不通、被 GitHub 限流、仓库还没建 release,都不该在面板上冒一条红。
@@ -5133,6 +5861,187 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5133
5861
  .then(function () { setDbgBusy(false); });
5134
5862
  }
5135
5863
 
5864
+ /**
5865
+ * 拉一次冲突队列(只读)。
5866
+ *
5867
+ * 头部徽标与「冲突」页签数的是同一批记录,所以挂载时就取一次(旧宿主没有
5868
+ * conflict 面 → conflictOk=false,这里整段不跑,界面一个像素都不变)。
5869
+ * 失败返回 `{ok:false, code}` 而不是抛 —— 契约的一部分,转成可读文案。
5870
+ */
5871
+ function loadConflicts() {
5872
+ if (!conflictOk) return;
5873
+ setConfLoad(true); setConfErr(null);
5874
+ Promise.resolve()
5875
+ .then(function () { return conflictRemote.list(); })
5876
+ .then(function (res) {
5877
+ // ⚠️ 必须过 unwrap:typert remote 的通用传输层把返回值包成
5878
+ // `{ok:true, value:<方法返回值>}`。**方法返回值里自带的 `ok` 是内层**,
5879
+ // 与信封的 `ok` 是两个东西 —— 2026-10-27 真机就栽在这:直接把信封当
5880
+ // payload 读,`items` 永远是 undefined,界面显示「0 条待裁决」,
5881
+ // 而网络层明明返回了记录。与 76-sidebar-entry 的 setSettings 不同:
5882
+ // 那是特例,这里是通用路径。
5883
+ const d = unwrap(res) || {};
5884
+ if (d.ok === false) { setConf(null); setConfErr(conflictErrText(d)); return; }
5885
+ setConf({
5886
+ items: Array.isArray(d.items) ? d.items : [],
5887
+ broken: Array.isArray(d.broken) ? d.broken : [],
5888
+ ignored: Array.isArray(d.ignored) ? d.ignored : [],
5889
+ counts: d.counts && typeof d.counts === "object" ? d.counts : { open: 0, total: 0 },
5890
+ });
5891
+ })
5892
+ .catch(function (e) { setConf(null); setConfErr(String((e && e.message) || e)); })
5893
+ .then(function () { setConfLoad(false); });
5894
+ }
5895
+
5896
+ /**
5897
+ * 冲突动作的失败文案:先按 `code` 给出路,再附上宿主给的一句细节。
5898
+ * 失败码是界面上**唯一**能把「为什么没成」翻译成下一步动作的地方 —— 尤其
5899
+ * stale_version / write_denied / unsupported 三条,不问清就只会反复点同一颗按钮。
5900
+ */
5901
+ function conflictErrText(err) {
5902
+ const code = err && typeof err.code === "string" ? err.code : "";
5903
+ const key = CONFLICT_CODE_KEY[code] || "conflict.code.unknown";
5904
+ const detail = err && typeof err.error === "string" ? err.error : "";
5905
+ return t(key) + (detail ? t("conflict.code.detail", { detail: detail }) : "");
5906
+ }
5907
+
5908
+ /** 打开「冲突」这一屏。数据没取过才取(挂载时已经取过一次,通常直接有内容)。 */
5909
+ function openConflictTab() {
5910
+ setTab("conflict");
5911
+ setError(null); setNotice("");
5912
+ if (!conf || confErr) loadConflicts();
5913
+ }
5914
+
5915
+ /**
5916
+ * 展开 / 收起一条记录的两份正文。
5917
+ *
5918
+ * 正文只在**展开那一刻**才 read:列表阶段不取(记录里可能带几十 KB 正文),
5919
+ * 而且展开后要把「宿主截断了」这件事显示出来。
5920
+ */
5921
+ function toggleConflictBodies(item) {
5922
+ if (!conflictOk || typeof conflictRemote.read !== "function") return;
5923
+ const was = confExpanded[item.id] === true;
5924
+ const next = Object.assign({}, confExpanded);
5925
+ if (was) {
5926
+ delete next[item.id];
5927
+ setConfExpanded(next);
5928
+ return;
5929
+ }
5930
+ next[item.id] = true;
5931
+ setConfExpanded(next);
5932
+ if (confBodies[item.id] && confBodies[item.id].record) return;
5933
+ const mark = function (patch) {
5934
+ setConfBodies(function (cur) {
5935
+ const nx = Object.assign({}, cur);
5936
+ nx[item.id] = Object.assign({}, cur[item.id] || {}, patch);
5937
+ return nx;
5938
+ });
5939
+ };
5940
+ mark({ loading: true, error: null });
5941
+ Promise.resolve()
5942
+ .then(function () { return conflictRemote.read({ id: item.id }); })
5943
+ .then(function (res) {
5944
+ const d = unwrap(res) || {};
5945
+ if (d.ok === false) { mark({ loading: false, error: conflictErrText(d) }); return; }
5946
+ mark({
5947
+ loading: false,
5948
+ record: d.record || null,
5949
+ warnings: Array.isArray(d.warnings) ? d.warnings : [],
5950
+ });
5951
+ })
5952
+ .catch(function (e) { mark({ loading: false, error: String((e && e.message) || e) }); });
5953
+ }
5954
+
5955
+ /**
5956
+ * 请求一个动作。dismiss 不写记忆文件、按设计表**不需要二次确认**;标失效 /
5957
+ * 撤销失效 / 删记录都会走两段式(前两个写文件,后一个不可恢复)。
5958
+ */
5959
+ function askConflict(item, action) {
5960
+ setError(null); setNotice("");
5961
+ if (action === "dismiss") { runConflictNow(item.id, action, ""); return; }
5962
+ // 标失效默认选记录里的 `file`(= 被提议标失效的一方);撤销时默认显示
5963
+ // 记录里真正被标过的那一方(invalidatedFile)。
5964
+ const file = action === "undo"
5965
+ ? (item.invalidatedFile || item.file || "")
5966
+ : (item.file || item.otherFile || "");
5967
+ setConfConfirm({ id: item.id, action: action, file: file });
5968
+ }
5969
+
5970
+ /** 两段式确认里改「标哪一方失效」(矛盾记录有两方)。 */
5971
+ function pickConflictFile(file) {
5972
+ setConfConfirm(function (cur) {
5973
+ return cur ? Object.assign({}, cur, { file: file }) : cur;
5974
+ });
5975
+ }
5976
+
5977
+ function cancelConflictConfirm() { setConfConfirm(null); }
5978
+
5979
+ function runConflictAction() {
5980
+ if (!confConfirm) return;
5981
+ runConflictNow(confConfirm.id, confConfirm.action, confConfirm.file);
5982
+ }
5983
+
5984
+ /**
5985
+ * 真正执行一个裁决动作。
5986
+ *
5987
+ * 顺序刻意固定:成功 → 清确认条 / 清正文缓存 → 重新拉队列 → 再刷记忆列表
5988
+ * (invalidate / undo 改了记忆文件,文件列表与星图都得跟着变)。
5989
+ * 失败**不动**记录之外的任何状态:错误文案(含出路)挂在全局错误条上。
5990
+ */
5991
+ function runConflictNow(id, action, file) {
5992
+ if (!conflictOk) { setError(t("error.noConflict")); return; }
5993
+ const fn = conflictRemote[action];
5994
+ if (typeof fn !== "function") {
5995
+ setError(t("conflict.actionMissing", { method: action }));
5996
+ return;
5997
+ }
5998
+ const opts = { id: id };
5999
+ if (action === "invalidate" && file) opts.file = file;
6000
+ setConfBusy(id); setError(null); setNotice("");
6001
+ Promise.resolve()
6002
+ .then(function () { return fn.call(conflictRemote, opts); })
6003
+ .then(function (res) {
6004
+ const d = unwrap(res) || {};
6005
+ if (d.ok === false) { setError(conflictErrText(d)); return; }
6006
+ const rec = d.record || {};
6007
+ setConfConfirm(null);
6008
+ // 记录变了:正文缓存与展开状态一起作废,避免展示已过期的正文。
6009
+ setConfBodies({});
6010
+ setConfExpanded({});
6011
+ if (action === "invalidate") {
6012
+ const wrote = d.wrote || {};
6013
+ setNotice(t("notice.conflictInvalidated", {
6014
+ file: rec.invalidatedFile || wrote.file || file,
6015
+ }));
6016
+ } else if (action === "dismiss") {
6017
+ setNotice(t("notice.conflictDismissed"));
6018
+ } else if (action === "undo") {
6019
+ const cleared = d.cleared || {};
6020
+ setNotice(t("notice.conflictUndone", {
6021
+ file: rec.invalidatedFile || cleared.file || file,
6022
+ }));
6023
+ } else {
6024
+ setNotice(t("notice.conflictRemoved"));
6025
+ }
6026
+ loadConflicts();
6027
+ // 只有真的写过记忆文件的动作才需要重扫文件列表。
6028
+ if (action === "invalidate" || action === "undo") load();
6029
+ })
6030
+ .catch(function (e) { setError(String((e && e.message) || e)); })
6031
+ .then(function () { setConfBusy(null); });
6032
+ }
6033
+
6034
+ /** 从冲突记录跳到文件列表看这条记忆(清掉筛选,否则可能被筛没)。 */
6035
+ function openConflictFile(name) {
6036
+ if (!name) return;
6037
+ setError(null);
6038
+ setTab("files");
6039
+ setStatusFilter("all");
6040
+ setTypeFilter([]);
6041
+ setTagFilter([]);
6042
+ setQuery(String(name));
6043
+ }
6044
+
5136
6045
  /**
5137
6046
  * 打开 / 收起一个工具面板。数据只在第一次打开时拉;已经拉过就复用
5138
6047
  * (「刷新」「重新体检」是显式动作,别替用户反复扫目录)。
@@ -5321,6 +6230,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5321
6230
  ? LIMIT_KEYS.filter(function (k) { return settings.limits[k] !== settings.pendingLimits[k]; }).length
5322
6231
  : 0;
5323
6232
  const restartPending = !!(settings && settings.restartRequired);
6233
+ // 冲突徽标数的是**待裁决**(open)条数 —— 与冲突页签里的「N 条待裁决」同源。
6234
+ // -1 = 还没取到队列(显示「未载」,不假装 0 条)。
6235
+ const conflictOpen = conf && conf.counts && typeof conf.counts.open === "number" ? conf.counts.open : -1;
5324
6236
 
5325
6237
  // 「文件列表」这一屏:状态筛选决定看哪一批,灰色卡片与留痕块自带「退役」语义。
5326
6238
  // banner 只在**专门看归档**时出现,切到全部时不重复说一遍。
@@ -5356,6 +6268,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5356
6268
  // 体检再排在「自动归档」之后、独立成一屏:它只报告不修改,和「文件列表」里的
5357
6269
  // 增删改是两件事,塞在工具抽屉里会让人以为它是个动作按钮。
5358
6270
  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")),
6271
+ // 2.0.0「冲突」:裁决是**多动作、要逐条看两份正文**的流程,所以独立成第 6 个
6272
+ // 页签,而不是塞进工具抽屉(抽屉方案的前提是「一年用不了几次」)。
6273
+ // 没有 conflict 面的旧宿主:整颗不出现(能力检测,不是渲染错误)。
6274
+ conflictOk
6275
+ ? 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"))
6276
+ : null,
5359
6277
  ]),
5360
6278
  // 四个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
5361
6279
  // 层级形状固定:导航=下划线(.smem-tabs)、筛选=chip、动作=按钮,三种不混用。
@@ -5371,6 +6289,20 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5371
6289
  title: audit ? (audit.problems ? t("header.auditSwitch") : t("header.auditClean")) : t("header.auditRun"),
5372
6290
  onClick: openAuditTab,
5373
6291
  }, audit ? (audit.problems ? t("header.auditProblems", { count: audit.problems }) : t("header.auditPass")) : t("header.auditNotRun")),
6292
+ // 冲突徽标:与体检徽标同一套「抽屉关着也一直看得见」。**没有 conflict 面
6293
+ // 的旧宿主整颗不出现**(页签也不出现),不是灰按钮 —— 那会让用户以为插件坏了。
6294
+ conflictOk
6295
+ ? h("button", {
6296
+ type: "button", key: "cf",
6297
+ className: "smem-tool-badge" + (conflictOpen > 0 ? " smem-tool-badge--bad" : ""),
6298
+ title: !conf
6299
+ ? t("header.conflictMissing")
6300
+ : conflictOpen > 0 ? t("header.conflictSwitch") : t("header.conflictClean"),
6301
+ onClick: openConflictTab,
6302
+ }, !conf ? t("header.conflictNotRun")
6303
+ : conflictOpen > 0 ? t("header.conflictOpen", { count: conflictOpen })
6304
+ : t("header.conflictEmpty"))
6305
+ : null,
5374
6306
  restartPending
5375
6307
  ? h("button", {
5376
6308
  type: "button", key: "rs", className: "smem-tool-badge smem-ad-badge--warn",
@@ -5548,6 +6480,34 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5548
6480
  // 体检面板在上面已经渲染过了 —— 这里必须显式给 null,
5549
6481
  // 否则会落进下面那个 `filesView` 兜底分支,变成「体检 + 一整套文件列表」同屏。
5550
6482
  ? null
6483
+ : tab === "conflict"
6484
+ ? h(ConflictPanel, {
6485
+ key: "conflict",
6486
+ data: conf,
6487
+ loading: confLoad,
6488
+ error: confErr,
6489
+ bodies: confBodies,
6490
+ expanded: confExpanded,
6491
+ confirm: confConfirm,
6492
+ busyId: confBusy,
6493
+ // 逐动作的能力检测:宿主有 list 没 invalidate(版本错配)时按钮要禁用,
6494
+ // 不是点了之后才报错。
6495
+ tools: {
6496
+ list: conflictOk,
6497
+ read: !!(conflictOk && typeof conflictRemote.read === "function"),
6498
+ invalidate: !!(conflictOk && typeof conflictRemote.invalidate === "function"),
6499
+ dismiss: !!(conflictOk && typeof conflictRemote.dismiss === "function"),
6500
+ undo: !!(conflictOk && typeof conflictRemote.undo === "function"),
6501
+ removeRecord: !!(conflictOk && typeof conflictRemote.removeRecord === "function"),
6502
+ },
6503
+ onReload: loadConflicts,
6504
+ onToggleBodies: toggleConflictBodies,
6505
+ onAsk: askConflict,
6506
+ onPickFile: pickConflictFile,
6507
+ onCancelConfirm: cancelConflictConfirm,
6508
+ onRun: runConflictAction,
6509
+ onOpenFile: openConflictFile,
6510
+ })
5551
6511
  : filesView,
5552
6512
  // 底部一行仓库链接(四个 tab 都在,不抢视线)
5553
6513
  h(StarLine, { key: "star-line" }),