dsh-prime-memory 0.14.0-beta.4 → 0.14.0-beta.6

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/dist/client.js CHANGED
@@ -606,7 +606,7 @@ var __defProp = Object.defineProperty;
606
606
  // client/src/tabs/ConflictsTab.tsx
607
607
  var import_jsx_runtime = require("react/jsx-runtime");
608
608
  var POLL_MS = 1e4;
609
- var GONE = "(该记录已不在检索库:被合并或删除掉了)";
609
+ var GONE = "(该记录正文不可得:已不在主表,或被更早的清理清掉了)";
610
610
  function ConflictsTab(props) {
611
611
  const rpc = props.rpc;
612
612
  const [view, setView] = (0, import_react2.useState)(null);
@@ -639,7 +639,7 @@ var __defProp = Object.defineProperty;
639
639
  将要退场的记忆:
640
640
  「${doomed}」
641
641
 
642
- 它会从检索库移除(事实源保留,可从 L0 重建找回)。本操作不可覆盖。`
642
+ 它会移出检索面(不再被召回),但记录仍保留 —— 可在「记忆」页的「已退场」区恢复。裁决结论本身不可覆盖。`
643
643
  );
644
644
  if (!ok) return;
645
645
  }
@@ -3214,24 +3214,25 @@ var __defProp = Object.defineProperty;
3214
3214
  const ids = Array.from(sel);
3215
3215
  if (ids.length === 0) return;
3216
3216
  if (!hiPriv) {
3217
- setError("高权限模式未开启:请在右上「高权限:关」或概览页开关中开启后,再删除记忆。");
3217
+ setError("高权限模式未开启:请在右上「高权限:关」或概览页开关中开启后,再退场记忆。");
3218
3218
  return;
3219
3219
  }
3220
3220
  if (ids.length > DELETE_LIMIT) {
3221
- setError("一次最多删除 " + DELETE_LIMIT + " 条(当前勾选 " + ids.length + " 条),请分批操作。");
3221
+ setError("一次最多退场 " + DELETE_LIMIT + " 条(当前勾选 " + ids.length + " 条),请分批操作。");
3222
3222
  return;
3223
3223
  }
3224
- if (!window.confirm("删除勾选的 " + ids.length + " 条记忆?本操作不可逆(完整重建可能从 L0 复活,为已知边界)。")) return;
3224
+ if (!window.confirm("退场勾选的 " + ids.length + " 条记忆?\n\n它们会移出检索面(不再被召回),但记录仍保留 —— 可在下方「已退场」区恢复。")) return;
3225
3225
  rpc("dsh-memory/records-delete", { ids }).then((r) => {
3226
3226
  if (r && r.ok) {
3227
3227
  setSel(/* @__PURE__ */ new Set());
3228
3228
  if (expandedId && ids.indexOf(expandedId) >= 0) setExpandedId(null);
3229
3229
  fetchPage(last, 0, false);
3230
- } else if (r) setError(r.error ? r.error.message : "删除失败");
3230
+ if (showRetired) loadRetired();
3231
+ } else if (r) setError(r.error ? r.error.message : "退场失败");
3231
3232
  }).catch((e) => setError(String(e && e.message || e)));
3232
3233
  };
3233
3234
  const deleteRecord = (id) => {
3234
- if (!window.confirm("删除该条记忆?本操作不可逆(完整重建可能从 L0 复活,为已知边界)。")) return;
3235
+ if (!window.confirm("退场该条记忆?\n\n它会移出检索面(不再被召回),但记录仍保留 —— 可在下方「已退场」区恢复。")) return;
3235
3236
  rpc("dsh-memory/records-delete", { ids: [id] }).then((r) => {
3236
3237
  if (r && r.ok) {
3237
3238
  setSel((prev) => {
@@ -3241,9 +3242,42 @@ var __defProp = Object.defineProperty;
3241
3242
  });
3242
3243
  if (expandedId === id) setExpandedId(null);
3243
3244
  fetchPage(last, 0, false);
3244
- } else if (r) setError(r.error ? r.error.message : "删除失败");
3245
+ if (showRetired) loadRetired();
3246
+ } else if (r) setError(r.error ? r.error.message : "退场失败");
3245
3247
  }).catch((e) => setError(String(e && e.message || e)));
3246
3248
  };
3249
+ const [retired, setRetired] = (0, import_react15.useState)([]);
3250
+ const [retiredTotal, setRetiredTotal] = (0, import_react15.useState)(0);
3251
+ const [showRetired, setShowRetired] = (0, import_react15.useState)(false);
3252
+ const [retiredBusy, setRetiredBusy] = (0, import_react15.useState)(false);
3253
+ const loadRetired = (0, import_react15.useCallback)(() => {
3254
+ setRetiredBusy(true);
3255
+ rpc("dsh-memory/records-retired", { limit: 100, offset: 0 }).then((r) => {
3256
+ if (r && r.ok) {
3257
+ setRetired(r.value.items);
3258
+ setRetiredTotal(r.value.total);
3259
+ } else if (r) setError(r.error ? r.error.message : "已退场列表加载失败");
3260
+ }).catch((e) => setError(String(e && e.message || e))).finally(() => setRetiredBusy(false));
3261
+ }, [rpc]);
3262
+ (0, import_react15.useEffect)(() => {
3263
+ if (showRetired) loadRetired();
3264
+ }, [showRetired, loadRetired]);
3265
+ const restoreRecords = (ids) => {
3266
+ if (ids.length === 0) return;
3267
+ setRetiredBusy(true);
3268
+ rpc("dsh-memory/records-restore", { ids }).then((r) => {
3269
+ if (r && r.ok) {
3270
+ loadRetired();
3271
+ fetchPage(last, 0, false);
3272
+ } else if (r) setError(r.error ? r.error.message : "恢复失败");
3273
+ }).catch((e) => setError(String(e && e.message || e))).finally(() => setRetiredBusy(false));
3274
+ };
3275
+ const RETIRE_REASON_LABEL = {
3276
+ conflict: "裁决退场",
3277
+ superseded: "被取代",
3278
+ manual: "人工退场",
3279
+ unknown: "已退场"
3280
+ };
3247
3281
  const countText = total !== null ? "共 " + total + " 条" : items.length + " 条" + (hasMore ? "+" : "");
3248
3282
  const selCount = sel.size;
3249
3283
  return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { children: [
@@ -3405,6 +3439,24 @@ var __defProp = Object.defineProperty;
3405
3439
  m.id
3406
3440
  );
3407
3441
  }),
3442
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: { ...S.flexRow, marginTop: 12 }, children: [
3443
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(NButton, { onClick: () => setShowRetired((v) => !v), children: (showRetired ? "收起" : "展开") + "「已退场」(可恢复)" }),
3444
+ showRetired && retiredBusy ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "加载中…" }) : null,
3445
+ showRetired && !retiredBusy && retiredTotal > 0 ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "共 " + retiredTotal + " 条可恢复" }) : null
3446
+ ] }),
3447
+ showRetired ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: { marginTop: 8 }, children: retired.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { style: S.hint, children: retiredBusy ? " " : "没有已退场的记忆。" }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { children: [
3448
+ retired.map((m) => /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "dsh-mem-card", style: S.card, children: [
3449
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: S.cardHead, children: [
3450
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: RETIRE_REASON_LABEL[m.retiredReason] || m.retiredReason }),
3451
+ m.verdict ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: "结论 " + m.verdict }) : null,
3452
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: S.grow }),
3453
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: S.muted, children: fmtTime(m.retiredAt) }),
3454
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(NButton, { disabled: retiredBusy, title: "恢复到检索面", onClick: () => restoreRecords([m.id]), children: "恢复" })
3455
+ ] }),
3456
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: S.content, children: m.content })
3457
+ ] }, m.id)),
3458
+ retiredTotal > retired.length ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { style: S.hint, children: "共 " + retiredTotal + " 条,此处显示 " + retired.length + " 条" }) : null
3459
+ ] }) }) : null,
3408
3460
  hasMore ? /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: S.flexRow, children: [
3409
3461
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: S.grow }),
3410
3462
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
@@ -12,7 +12,7 @@ import type { L1Store } from './store/l1.js';
12
12
  import type { ConflictPairView, ConflictsResponse as ConflictsView, ConflictResolveResponse as ConflictResolutionView } from './contract.js';
13
13
  export type { ConflictPairView, ConflictsView, ConflictResolutionView };
14
14
  export interface ConflictResolveDeps {
15
- l1: Pick<L1Store, 'listConflictPending' | 'resolveConflictPending' | 'deleteBatch' | 'syncGraphDisputed'>;
15
+ l1: Pick<L1Store, 'listConflictPending' | 'resolveConflictPending' | 'retire' | 'syncGraphDisputed'>;
16
16
  /** `conflictFreeze.enabled`。未开启时队列恒空,直接给出提示而非静默无操作。 */
17
17
  conflictFreezeEnabled: boolean;
18
18
  }
@@ -21,9 +21,11 @@ export interface ConflictResolveDeps {
21
21
  *
22
22
  * 顺序刻意如此:
23
23
  * ① **先打 `resolved_at` 再退场 loser**。反过来的话,退场成功但打标失败会留下
24
- * "记录已消失、队列里那条仍在待裁决"的状态——人再点一次才发现无据可依。
24
+ * "记录已退场、队列里那条仍在待裁决"的状态——人再点一次才发现无据可依。
25
25
  * 打标用 `WHERE resolved_at = ''`,天然防重复裁决:第二次调用拿到 0 行即中止。
26
26
  * ② 退场后才**重算**图谱 `disputed`(派生字段必须由当前事实重算,见 `syncDisputed`)。
27
+ * ③ 退场是**软删**(`retire`,可恢复),不是物理删除:主表行留着,`valid_to` 闭合 +
28
+ * 写取代标记,FTS/向量行撤掉。故"判错了"可以再恢复——裁决不可覆盖,但可以反悔。
27
29
  */
28
30
  export declare function resolveConflictPair(deps: ConflictResolveDeps, pairId: string, outcome: string): Promise<ConflictResolutionView>;
29
31
  /** 裁决结果的人类可读渲染(工具路径用)。schema 产出的是可选字段,故按部分取值渲染。 */
@@ -7,9 +7,11 @@ function view(partial) {
7
7
  *
8
8
  * 顺序刻意如此:
9
9
  * ① **先打 `resolved_at` 再退场 loser**。反过来的话,退场成功但打标失败会留下
10
- * "记录已消失、队列里那条仍在待裁决"的状态——人再点一次才发现无据可依。
10
+ * "记录已退场、队列里那条仍在待裁决"的状态——人再点一次才发现无据可依。
11
11
  * 打标用 `WHERE resolved_at = ''`,天然防重复裁决:第二次调用拿到 0 行即中止。
12
12
  * ② 退场后才**重算**图谱 `disputed`(派生字段必须由当前事实重算,见 `syncDisputed`)。
13
+ * ③ 退场是**软删**(`retire`,可恢复),不是物理删除:主表行留着,`valid_to` 闭合 +
14
+ * 写取代标记,FTS/向量行撤掉。故"判错了"可以再恢复——裁决不可覆盖,但可以反悔。
13
15
  */
14
16
  export async function resolveConflictPair(deps, pairId, outcome) {
15
17
  const clean = outcome.trim();
@@ -41,8 +43,17 @@ export async function resolveConflictPair(deps, pairId, outcome) {
41
43
  // 并发/重复调用:这一对在本次读取与本次写入之间被裁决了
42
44
  return view({ pair_id: pairId, outcome: clean, notice: '该对已被裁决,本次未生效(裁决不可覆盖)。' });
43
45
  }
44
- if (removedId)
45
- await deps.l1.deleteBatch([removedId]);
46
+ if (removedId) {
47
+ // **软删**(退场),不是物理删除:主表行保留 + `valid_to` 闭合 + 写取代标记,
48
+ // FTS/向量行撤掉使其退出检索面。于是"判错了"可以再恢复,而不必去
49
+ // `records/*.jsonl` 事实源里手工捞——那是本功能上线前唯一的后悔药。
50
+ deps.l1.retire([removedId], {
51
+ at: resolvedAt,
52
+ reason: 'conflict',
53
+ verdict: clean,
54
+ pairId,
55
+ });
56
+ }
46
57
  // 图谱 disputed 重算:此刻仍未裁决的对才是争议集,已了结的节点自动复原 active
47
58
  const ids = new Set();
48
59
  for (const p of deps.l1.listConflictPending()) {
@@ -59,7 +70,7 @@ export function renderConflictResolution(v) {
59
70
  const outcome = v.outcome ?? '';
60
71
  const label = outcome === 'winner' ? '判定 LLM 建议的胜方为真' : outcome === 'loser' ? '判定败方为真' : '两者都保留(判为各自独立的事实)';
61
72
  const removed = v.removed_record_id
62
- ? `\n退场记录:${v.removed_record_id}(已从检索中移除,事实源保留)`
73
+ ? `\n退场记录:${v.removed_record_id}(已移出检索面,**可恢复**——记忆列表里能找回)`
63
74
  : '\n未移除任何记录。';
64
75
  return `已裁决待裁决对 ${v.pair_id ?? ''}\n结论:${outcome}(${label})\n裁决时刻:${v.resolved_at ?? ''}${removed}`;
65
76
  }
@@ -610,14 +610,76 @@ export interface ListRecordsResponse {
610
610
  /** 场景筛选下拉选项(仅 offset===0 时附带)。 */
611
611
  scenes?: string[];
612
612
  }
613
- /** dsh-memory/records-delete(面板高权限删除指定记忆;须 memoryMutate 开启)。 */
613
+ /** dsh-memory/records-delete(面板高权限退场指定记忆;须 memoryMutate 开启)。 */
614
614
  export interface RecordsDeleteRequest {
615
- /** 要删除的 L1 record id 列表(≤200)。 */
615
+ /** 要**退场(软删)**的 L1 record id 列表(≤200)。不是物理删除,可恢复。 */
616
616
  ids: string[];
617
617
  }
618
618
  export interface RecordsDeleteResponse {
619
619
  deleted: number;
620
620
  }
621
+ /**
622
+ * 已退场记录的一条(在浏览卡片之上补退场信息)。
623
+ *
624
+ * `retiredReason` 用宽松 `string` 而非字面量联合:契约要能在**客户端**那一档
625
+ * (`types: []`)独立编译,不该反向依赖宿主 store 的类型;严格联合留在 store 侧,
626
+ * 面板只做展示。新增原因时面板不认识也能照常显示,不会编译失败。
627
+ */
628
+ export interface RetiredRecordView extends UiRecord {
629
+ /** 退场时刻(ISO 8601)。 */
630
+ retiredAt: string;
631
+ /** 退场原因:`conflict`(裁决) / `superseded`(去重取代) / `manual`(人工)。 */
632
+ retiredReason: string;
633
+ /** 裁决结论(仅 reason=conflict)。 */
634
+ verdict?: string;
635
+ /** 取代它的新记录 id(仅 reason=superseded)。 */
636
+ supersededBy?: string;
637
+ }
638
+ /** dsh-memory/records-retired(已退场列表;面板据此展示"可恢复"区)。 */
639
+ export interface RecordsRetiredRequest {
640
+ limit?: number;
641
+ offset?: number;
642
+ }
643
+ export interface RecordsRetiredResponse {
644
+ items: RetiredRecordView[];
645
+ total: number;
646
+ }
647
+ /** dsh-memory/records-restore(把已退场记录送回检索面)。 */
648
+ export interface RecordsRestoreRequest {
649
+ /** 要恢复的 record id 列表(≤200)。 */
650
+ ids: string[];
651
+ }
652
+ export interface RecordsRestoreResponse {
653
+ restored: number;
654
+ /** 成功补回向量的条数(嵌入不可用时可能小于 `restored`)。 */
655
+ vectorsWritten: number;
656
+ }
657
+ /**
658
+ * dsh-memory/cleanup-retired(物理清理已退场记录)。
659
+ *
660
+ * **默认干跑**:`dryRun` 省略即视为 `true`,只报"将要清理多少条"。
661
+ * 真要物理删除必须显式 `dryRun:false` —— 这是本插件唯一不可逆的动作。
662
+ * 即便显式执行,也要先落快照并校验通过,否则中止(`aborted:true`)。
663
+ */
664
+ export interface CleanupRetiredRequest {
665
+ /** 限定要清理的 id;省略 = 全部已退场记录。 */
666
+ ids?: string[];
667
+ /** 默认 true(干跑)。显式 false 才真正删除。 */
668
+ dryRun?: boolean;
669
+ }
670
+ export interface CleanupRetiredResponse {
671
+ dryRun: boolean;
672
+ /** 本次涉及(干跑)或实际处理(真跑)的条数。 */
673
+ targets: number;
674
+ /** 真正物理删除的条数(干跑恒为 0;中止恒为 0)。 */
675
+ purged: number;
676
+ /** 门禁未通过而中止。 */
677
+ aborted: boolean;
678
+ /** 快照目录(真跑时非空)。 */
679
+ dir: string;
680
+ /** 中止原因(仅 aborted 时非空)。 */
681
+ diffs: string[];
682
+ }
621
683
  /** dsh-memory/graph-search(图谱节点检索;紧凑节点卡)。 */
622
684
  export interface GraphSearchRequest {
623
685
  /** 自然语言查询(≤4096 字符;空查询返回空)。 */
@@ -903,6 +965,9 @@ export interface DshMemoryRequestMap {
903
965
  'dsh-memory/embedding-runtime-cancel': Record<string, never>;
904
966
  'dsh-memory/embedding-reindex': Record<string, never>;
905
967
  'dsh-memory/embedding-reindex-cancel': Record<string, never>;
968
+ 'dsh-memory/records-retired': RecordsRetiredRequest;
969
+ 'dsh-memory/records-restore': RecordsRestoreRequest;
970
+ 'dsh-memory/cleanup-retired': CleanupRetiredRequest;
906
971
  }
907
972
  export interface DshMemoryResponseMap {
908
973
  'dsh-memory/stats': StatsResponse;
@@ -938,6 +1003,9 @@ export interface DshMemoryResponseMap {
938
1003
  'dsh-memory/embedding-runtime-cancel': EmbeddingCancelResponse;
939
1004
  'dsh-memory/embedding-reindex': EmbeddingReindexStartResponse;
940
1005
  'dsh-memory/embedding-reindex-cancel': EmbeddingCancelResponse;
1006
+ 'dsh-memory/records-retired': RecordsRetiredResponse;
1007
+ 'dsh-memory/records-restore': RecordsRestoreResponse;
1008
+ 'dsh-memory/cleanup-retired': CleanupRetiredResponse;
941
1009
  }
942
1010
  /** 全部端点名(client 调用与 host case 表的共用字面量来源)。 */
943
1011
  export type DshMemoryEndpoint = keyof DshMemoryResponseMap;
@@ -272,7 +272,19 @@ anchorMap) {
272
272
  relatedIds.add(c.id);
273
273
  }
274
274
  const byId = new Map(store.getByIds([...relatedIds]).map((r) => [r.id, r]));
275
- const deletedIds = new Set();
275
+ /**
276
+ * 本批次**全部**新记忆的 record_id。两个用途:
277
+ * ① 作为 `validateConflictPair` 的对手集之一 —— 同批次两条新记忆互相矛盾时,
278
+ * 对方的 id 不在候选池里(本轮刚生成、尚未入库),没有这个集合就**必然被判
279
+ * 不成对而回落 store**(2026-09-18 取证确认,见 findings R6 / Agent A);
280
+ * ② 队满自动了结的护栏 —— 败方若属本批新记忆,不得自动退场(见下方分支)。
281
+ */
282
+ const batchIds = new Set(extracted.map((e) => e.record_id));
283
+ /**
284
+ * update/merge 取代掉的旧记录 → **取代它的**新记录 id。
285
+ * 用 Map 而非 Set:退场标记要带 `by`,否则"被谁取代"只能靠时间猜。
286
+ */
287
+ const supersededBy = new Map();
276
288
  const added = [];
277
289
  /** §C 本轮新冻结的冲突对(应用完新增记录后统一落盘)。 */
278
290
  const frozen = [];
@@ -295,7 +307,7 @@ anchorMap) {
295
307
  // 一个显式声明了"拿不准"的决策,绝不能被静默当成"新记忆更优"去覆盖旧记忆。
296
308
  if (action === 'conflict') {
297
309
  const pair = freezeEnabled
298
- ? validateConflictPair(m.record_id, decision.winner, decision.loser, new Set(byId.keys()))
310
+ ? validateConflictPair(m.record_id, decision.winner, decision.loser, new Set(byId.keys()), batchIds)
299
311
  : null;
300
312
  if (pair) {
301
313
  added.push(toStoreRecord(m, now, ts, anchorMap));
@@ -309,6 +321,16 @@ anchorMap) {
309
321
  // 判据含 frozen 中本轮已停放的未裁决数,否则同一轮内多条冲突会一起越界。
310
322
  const pendingNow = store.countConflictPendingUnresolved() + frozen.filter((p) => p.resolvedAt === '').length;
311
323
  if (pendingNow >= maxPending) {
324
+ // 护栏(2026-09-18 随同批次冻结一起加):**败方是本轮新记忆时不做自动了结**。
325
+ // 自动了结 = `retire(loser)`,而本轮的 `added` 里刚把这条新记忆写入 ——
326
+ // 那等于"刚抽取出来的产出立刻退场",且没有任何人被告知。
327
+ // 改为不停放这一对(该条已在上方 added 中照常入库,记忆不丢),
328
+ // 只放弃这条裁决请求;队列有界性因此仍然成立。
329
+ if (batchIds.has(built.loserId)) {
330
+ logger.warn(`[memory] 矛盾冻结:队列已满(${pendingNow}/${maxPending}),且该对的败方是本轮新记忆` +
331
+ `(${built.loserId})——不做自动了结(避免新记忆立即退场),改为不停放、照常入库`);
332
+ continue;
333
+ }
312
334
  built.resolvedAt = new Date(now).toISOString();
313
335
  built.resolution = 'auto';
314
336
  autoLosers.add(pair.loserId);
@@ -323,11 +345,14 @@ anchorMap) {
323
345
  }
324
346
  continue;
325
347
  }
326
- // update / merge:目标记录从检索库删除,合并结果作为新记录追加(版本 +1)
348
+ // update / merge:目标记录**退场(软删)**,合并结果作为新记录追加(版本 +1)
327
349
  // 候选召回按族隔离,合并产物保持新记忆的族标签
328
350
  const targets = (decision.target_ids ?? []).filter((id) => byId.has(id));
351
+ // 同一目标被多条新记录取代时保留**首个**取代者:与 retire 的幂等语义一致
352
+ // (已退场记录不重复写标记),故先到先得而不是被后者覆盖
329
353
  for (const id of targets)
330
- deletedIds.add(id);
354
+ if (!supersededBy.has(id))
355
+ supersededBy.set(id, m.record_id);
331
356
  const targetVersion = targets.reduce((max, id) => Math.max(max, byId.get(id)?.version ?? 0), 0);
332
357
  const mergedTs = (decision.merged_timestamps ?? [])
333
358
  .map((t) => Date.parse(t))
@@ -356,13 +381,27 @@ anchorMap) {
356
381
  });
357
382
  }
358
383
  await store.appendNew(added);
359
- if (deletedIds.size > 0)
360
- await store.deleteBatch([...deletedIds]);
361
- // §C 自动裁决的执行面:LLM 的 loser 从检索库退场(winner 存活)。
384
+ if (supersededBy.size > 0) {
385
+ // **软删**(取代):不是物理删除——主表行保留 + `valid_to` 闭合 + 取代标记,
386
+ // FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去 `records/*.jsonl` 手工捞。
387
+ // 按取代者分组落盘(一次事务一组),避免逐条开事务。
388
+ const retiredAt = new Date(now).toISOString();
389
+ const byNewRecord = new Map();
390
+ for (const [targetId, newId] of supersededBy) {
391
+ const arr = byNewRecord.get(newId) ?? [];
392
+ arr.push(targetId);
393
+ byNewRecord.set(newId, arr);
394
+ }
395
+ for (const [newId, ids] of byNewRecord) {
396
+ store.retire(ids, { at: retiredAt, reason: 'superseded', by: newId });
397
+ }
398
+ }
399
+ // §C 自动裁决的执行面:LLM 的 loser 从检索面退场(winner 存活),同为**软删**。
362
400
  // 排在 appendNew 之后——若 loser 恰是**本轮新记忆**(LLM 判定新记忆更差),
363
- // 也必须先让它进库再退场,以保证"本轮新增"与"本轮删除"的账面一致。
364
- if (autoLosers.size > 0)
365
- await store.deleteBatch([...autoLosers]);
401
+ // 也必须先让它进库再退场,以保证"本轮新增"与"本轮退场"的账面一致。
402
+ if (autoLosers.size > 0) {
403
+ store.retire([...autoLosers], { at: new Date(now).toISOString(), reason: 'conflict', verdict: 'auto' });
404
+ }
366
405
  // ── §C 冻结对落盘(排在 appendNew 之后) ──
367
406
  // 顺序有讲究:先让新记忆真正进 L1,再登记"它和谁构成待裁决对"。反过来的话,
368
407
  // 落盘失败会留下一条指向**不存在记录**的裁决请求,人工打开队列只会看到悬空 id。
@@ -404,7 +443,7 @@ anchorMap) {
404
443
  states[f].memoriesSinceL3 += addedByFamily[f];
405
444
  }
406
445
  markExtracted(states, mode, lastScene);
407
- logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条,去重后新增 ${added.length} 条(替换 ${deletedIds.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
446
+ logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条,去重后新增 ${added.length} 条(取代退场 ${supersededBy.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
408
447
  return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added };
409
448
  }
410
449
  /** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
@@ -18,7 +18,7 @@ export declare const ALL_CONFLICT_DETECTION_SYSTEM_PROMPT = "\u4F60\u662F\u8BB0\
18
18
  * 语义承自 mneme(dream layer **不**自动裁决 winner/loser):检测到矛盾后
19
19
  * 不是"拦住写入",而是"不自动裁决"——把冲突对停放到待审区。
20
20
  */
21
- export declare const CONFLICT_ACTION_CLAUSE = "## \u77DB\u76FE\u51BB\u7ED3\u52A8\u4F5C\uFF08\"conflict\"\uFF09\n\n\u4E0A\u9762\u56DB\u6761\u52A8\u4F5C\u5728**\u5224\u5B9A\u51B2\u7A81**\u65F6\u90FD\u4E0D\u53EF\u7528\u2014\u2014\"update\" \u4E0E \"merge\" \u90FD\u4F1A\u7531\u4F60\u76F4\u63A5\u6539\u5199\u8BB0\u5FC6\uFF0C**\u6CA1\u6709\"\u505C\u4E0B\u6765\u7B49\u4EBA\u88C1\u51B3\"\u8FD9\u4E2A\u9009\u9879**\u3002\u6545\u65B0\u589E\u7B2C\u4E94\u6761\u52A8\u4F5C\uFF1A\n\n- \"conflict\"\uFF1A\u65B0\u8BB0\u5FC6\u4E0E\u5019\u9009\u6C60\u4E2D\u67D0\u6761\u5DF2\u6709\u8BB0\u5FC6**\u63CF\u8FF0\u540C\u4E00\u4E8B\u5B9E/\u4E8B\u4EF6/\u5DE5\u4F5C\u5BF9\u8C61\uFF0C\u4F46\u5185\u5BB9\u4E92\u76F8\u77DB\u76FE**\uFF0C\u4E14\u4F60**\u65E0\u6CD5\u4F9D\u636E\u73B0\u6709\u4FE1\u606F\u5224\u5B9A\u54EA\u4E00\u65B9\u66F4\u53EF\u4FE1**\u65F6\u4F7F\u7528\u3002**\u4E0D\u8986\u76D6\u3001\u4E0D\u5408\u5E76**\uFF1A\u8BE5\u6761\u65B0\u8BB0\u5FC6\u7167\u5E38\u5199\u5165\uFF0C\u4E0E\u51B2\u7A81\u7684\u5DF2\u6709\u8BB0\u5FC6\u4F5C\u4E3A**\u4E00\u5BF9**\u505C\u653E\u5230\u5F85\u4EBA\u5DE5\u88C1\u51B3\u533A\uFF0C\u53CC\u65B9\u5185\u5BB9\u90FD\u4E0D\u88AB\u6539\u5199\u3002\n\n### conflict \u7684\u8FFD\u52A0\u8F93\u51FA\u5B57\u6BB5\n\n{\n \"record_id\": \"\u672C\u6761\u65B0\u8BB0\u5FC6\u7684 record_id\",\n \"action\": \"conflict\",\n \"winner\": \"\u5176\u4E2D\u4E00\u65B9\u7684 record_id\",\n \"loser\": \"\u53E6\u4E00\u65B9\u7684 record_id\"\n}\n\n- \"winner\" / \"loser\"\uFF1A**\u4E8C\u8005\u5FC5\u987B\u4E0D\u540C**\u3002\u53D6\u503C\u5747\u4E3A record_id\uFF0C\u6765\u81EA\u300C\u672C\u6761\u65B0\u8BB0\u5FC6\u7684 record_id\u300D\u6216\u300C\u5019\u9009\u6C60\u4E2D\u7684 record_id\u300D\u3002\n- \"winner\" \u53EA\u8868\u793A\u8FDB\u5165\u5F85\u88C1\u51B3\u5BF9\u65F6\u7684\u6392\u5E8F\u4F4D\uFF0C**\u4E0D\u4EE3\u8868\u6700\u7EC8\u7ED3\u8BBA**\uFF1B\u6700\u7EC8\u7ED3\u8BBA\u7531\u4EBA\u5DE5\u88C1\u51B3\u5199\u5165\u3002\n- action \u4E3A \"conflict\" \u65F6**\u4E0D\u8981**\u8F93\u51FA merged_content / merged_type / merged_priority / merged_timestamps\u2014\u2014\u5B83\u4EEC\u53EA\u5C5E\u4E8E update / merge\u3002\n\n### \u4EC0\u4E48\u65F6\u5019**\u4E0D**\u7528 conflict\n\n- \u65B0\u8BB0\u5FC6\u66F4\u5177\u4F53\u3001\u66F4\u65B0\u3001\u66F4\u6743\u5A01\uFF0C\u6216\u80FD\u660E\u786E\u7EA0\u6B63\u65E7\u8BB0\u5FC6\u7684\u9519\u8BEF \u2192 \u4ECD\u7528 \"update\"\u3002\n- \u65B0\u65E7\u8BB0\u5FC6\u4FE1\u606F\u4E92\u8865\u4E14**\u4E0D\u77DB\u76FE** \u2192 \u4ECD\u7528 \"merge\"\u3002\n- \u53EA\u662F\u540C\u5C5E\u4E00\u4E2A\u4E3B\u9898\u4F46\u63CF\u8FF0\u5BF9\u8C61\u4E0D\u540C \u2192 \u4ECD\u7528 \"store\"\u3002\n\nconflict \u53EA\u7559\u7ED9\"\u4E24\u8FB9\u90FD\u50CF\u662F\u5BF9\u7684\u3001\u673A\u5668\u5224\u4E0D\u4E86\"\u7684\u60C5\u51B5\u2014\u2014\u5B83\u6D88\u8017\u4EBA\u7684\u6CE8\u610F\u529B\uFF0C\u4E0D\u53EF\u6EE5\u7528\u3002";
21
+ export declare const CONFLICT_ACTION_CLAUSE = "## \u77DB\u76FE\u51BB\u7ED3\u52A8\u4F5C\uFF08\"conflict\"\uFF09\n\n\u4E0A\u9762\u56DB\u6761\u52A8\u4F5C\u5728**\u5224\u5B9A\u51B2\u7A81**\u65F6\u90FD\u4E0D\u53EF\u7528\u2014\u2014\"update\" \u4E0E \"merge\" \u90FD\u4F1A\u7531\u4F60\u76F4\u63A5\u6539\u5199\u8BB0\u5FC6\uFF0C**\u6CA1\u6709\"\u505C\u4E0B\u6765\u7B49\u4EBA\u88C1\u51B3\"\u8FD9\u4E2A\u9009\u9879**\u3002\u6545\u65B0\u589E\u7B2C\u4E94\u6761\u52A8\u4F5C\uFF1A\n\n- \"conflict\"\uFF1A\u65B0\u8BB0\u5FC6\u4E0E**\u53E6\u4E00\u6761\u8BB0\u5FC6**\u2014\u2014\u5019\u9009\u6C60\u4E2D\u7684\u5DF2\u6709\u8BB0\u5FC6\uFF0C**\u6216\u540C\u4E00\u6279\u6B21\u91CC\u7684\u53E6\u4E00\u6761\u65B0\u8BB0\u5FC6**\uFF08\u5B83\u7684 record_id \u540C\u6837\u5370\u5728\u3010\u65B0\u8BB0\u5FC6\u3011\u5217\u8868\u91CC\uFF09\u2014\u2014**\u63CF\u8FF0\u540C\u4E00\u4E8B\u5B9E/\u4E8B\u4EF6/\u5DE5\u4F5C\u5BF9\u8C61\uFF0C\u4F46\u5185\u5BB9\u4E92\u76F8\u77DB\u76FE**\uFF0C\u4E14\u4F60**\u65E0\u6CD5\u4F9D\u636E\u73B0\u6709\u4FE1\u606F\u5224\u5B9A\u54EA\u4E00\u65B9\u66F4\u53EF\u4FE1**\u65F6\u4F7F\u7528\u3002**\u4E0D\u8986\u76D6\u3001\u4E0D\u5408\u5E76**\uFF1A\u8BE5\u6761\u65B0\u8BB0\u5FC6\u7167\u5E38\u5199\u5165\uFF0C\u4E0E\u5BF9\u65B9\u4F5C\u4E3A**\u4E00\u5BF9**\u505C\u653E\u5230\u5F85\u4EBA\u5DE5\u88C1\u51B3\u533A\uFF0C\u53CC\u65B9\u5185\u5BB9\u90FD\u4E0D\u88AB\u6539\u5199\u3002\n\n### conflict \u7684\u8FFD\u52A0\u8F93\u51FA\u5B57\u6BB5\n\n{\n \"record_id\": \"\u672C\u6761\u65B0\u8BB0\u5FC6\u7684 record_id\",\n \"action\": \"conflict\",\n \"winner\": \"\u5176\u4E2D\u4E00\u65B9\u7684 record_id\",\n \"loser\": \"\u53E6\u4E00\u65B9\u7684 record_id\"\n}\n\n- \"winner\" / \"loser\"\uFF1A**\u4E8C\u8005\u5FC5\u987B\u4E0D\u540C**\uFF0C\u4E14**\u6070\u6709\u4E00\u65B9\u5FC5\u987B\u662F\u672C\u6761\u65B0\u8BB0\u5FC6\u7684 record_id**\uFF1B\u53E6\u4E00\u65B9\u53D6\u81EA\u300C\u5019\u9009\u6C60\u4E2D\u7684\u5DF2\u6709\u8BB0\u5FC6 record_id\u300D\u6216\u300C\u540C\u4E00\u6279\u6B21\u5176\u5B83\u65B0\u8BB0\u5FC6\u7684 record_id\u300D\u3002\u6307\u5411\u672C\u6279\u6B21\u4E4B\u5916\u7684\u3001\u4E0D\u5728\u5019\u9009\u6C60\u91CC\u7684 id \u4E00\u5F8B\u65E0\u6548\u3002\n- \"winner\" \u53EA\u8868\u793A\u8FDB\u5165\u5F85\u88C1\u51B3\u5BF9\u65F6\u7684\u6392\u5E8F\u4F4D\uFF0C**\u4E0D\u4EE3\u8868\u6700\u7EC8\u7ED3\u8BBA**\uFF1B\u6700\u7EC8\u7ED3\u8BBA\u7531\u4EBA\u5DE5\u88C1\u51B3\u5199\u5165\u3002\n- action \u4E3A \"conflict\" \u65F6**\u4E0D\u8981**\u8F93\u51FA merged_content / merged_type / merged_priority / merged_timestamps\u2014\u2014\u5B83\u4EEC\u53EA\u5C5E\u4E8E update / merge\u3002\n\n### \u4EC0\u4E48\u65F6\u5019**\u4E0D**\u7528 conflict\n\n- \u65B0\u8BB0\u5FC6\u66F4\u5177\u4F53\u3001\u66F4\u65B0\u3001\u66F4\u6743\u5A01\uFF0C\u6216\u80FD\u660E\u786E\u7EA0\u6B63\u65E7\u8BB0\u5FC6\u7684\u9519\u8BEF \u2192 \u4ECD\u7528 \"update\"\u3002\n- \u65B0\u65E7\u8BB0\u5FC6\u4FE1\u606F\u4E92\u8865\u4E14**\u4E0D\u77DB\u76FE** \u2192 \u4ECD\u7528 \"merge\"\u3002\n- \u53EA\u662F\u540C\u5C5E\u4E00\u4E2A\u4E3B\u9898\u4F46\u63CF\u8FF0\u5BF9\u8C61\u4E0D\u540C \u2192 \u4ECD\u7528 \"store\"\u3002\n- \u540C\u6279\u6B21\u4E24\u6761\u65B0\u8BB0\u5FC6\u53EA\u662F**\u540C\u4E00\u4E8B\u5B9E\u7684\u4E0D\u540C\u8868\u8FF0**\uFF08\u4E92\u8865\u3001\u7C92\u5EA6\u4E0D\u540C\u3001\u4E0D\u4E92\u65A5\uFF09\u2192 \u4ECD\u5404\u81EA \"store\"\u3002**\u53EA\u6709\u4E92\u76F8\u77DB\u76FE\u624D\u7B97 conflict\u3002**\n\nconflict \u53EA\u7559\u7ED9\"\u4E24\u8FB9\u90FD\u50CF\u662F\u5BF9\u7684\u3001\u673A\u5668\u5224\u4E0D\u4E86\"\u7684\u60C5\u51B5\u2014\u2014\u5B83\u6D88\u8017\u4EBA\u7684\u6CE8\u610F\u529B\uFF0C\u4E0D\u53EF\u6EE5\u7528\u3002";
22
22
  export interface DedupPromptOptions {
23
23
  /** §C 矛盾冻结总开关。**默认关**:关闭时 prompt 与改动前逐字一致。 */
24
24
  conflictFreeze?: boolean;
@@ -208,7 +208,7 @@ export const CONFLICT_ACTION_CLAUSE = `## 矛盾冻结动作("conflict")
208
208
 
209
209
  上面四条动作在**判定冲突**时都不可用——"update" 与 "merge" 都会由你直接改写记忆,**没有"停下来等人裁决"这个选项**。故新增第五条动作:
210
210
 
211
- - "conflict":新记忆与候选池中某条已有记忆**描述同一事实/事件/工作对象,但内容互相矛盾**,且你**无法依据现有信息判定哪一方更可信**时使用。**不覆盖、不合并**:该条新记忆照常写入,与冲突的已有记忆作为**一对**停放到待人工裁决区,双方内容都不被改写。
211
+ - "conflict":新记忆与**另一条记忆**——候选池中的已有记忆,**或同一批次里的另一条新记忆**(它的 record_id 同样印在【新记忆】列表里)——**描述同一事实/事件/工作对象,但内容互相矛盾**,且你**无法依据现有信息判定哪一方更可信**时使用。**不覆盖、不合并**:该条新记忆照常写入,与对方作为**一对**停放到待人工裁决区,双方内容都不被改写。
212
212
 
213
213
  ### conflict 的追加输出字段
214
214
 
@@ -219,7 +219,7 @@ export const CONFLICT_ACTION_CLAUSE = `## 矛盾冻结动作("conflict")
219
219
  "loser": "另一方的 record_id"
220
220
  }
221
221
 
222
- - "winner" / "loser":**二者必须不同**。取值均为 record_id,来自「本条新记忆的 record_id」或「候选池中的 record_id」。
222
+ - "winner" / "loser":**二者必须不同**,且**恰有一方必须是本条新记忆的 record_id**;另一方取自「候选池中的已有记忆 record_id」或「同一批次其它新记忆的 record_id」。指向本批次之外的、不在候选池里的 id 一律无效。
223
223
  - "winner" 只表示进入待裁决对时的排序位,**不代表最终结论**;最终结论由人工裁决写入。
224
224
  - action 为 "conflict" 时**不要**输出 merged_content / merged_type / merged_priority / merged_timestamps——它们只属于 update / merge。
225
225
 
@@ -228,6 +228,7 @@ export const CONFLICT_ACTION_CLAUSE = `## 矛盾冻结动作("conflict")
228
228
  - 新记忆更具体、更新、更权威,或能明确纠正旧记忆的错误 → 仍用 "update"。
229
229
  - 新旧记忆信息互补且**不矛盾** → 仍用 "merge"。
230
230
  - 只是同属一个主题但描述对象不同 → 仍用 "store"。
231
+ - 同批次两条新记忆只是**同一事实的不同表述**(互补、粒度不同、不互斥)→ 仍各自 "store"。**只有互相矛盾才算 conflict。**
231
232
 
232
233
  conflict 只留给"两边都像是对的、机器判不了"的情况——它消耗人的注意力,不可滥用。`;
233
234
  /**
package/dist/stats.d.ts CHANGED
@@ -22,7 +22,7 @@ export interface MemoryStatusSource {
22
22
  pending(): number;
23
23
  }
24
24
  /**
25
- * 端点全集运行时清单(33 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
25
+ * 端点全集运行时清单(36 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
26
26
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
27
27
  * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
28
28
  * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
package/dist/stats.js CHANGED
@@ -22,12 +22,13 @@ import { projectDistillChain, validateDistillChain } from './settings.js';
22
22
  import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/receipts.js';
23
23
  import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
24
24
  import { sourceAnchorLabels } from './pipeline/anchors.js';
25
+ import { readSupersedeMarker } from './store/supersede.js';
25
26
  import { errDetail } from './util/filelog.js';
26
27
  import { snapshotTokenCost } from './token-cost.js';
27
28
  const require = createRequire(import.meta.url);
28
29
  export const PLUGIN_VERSION = require('../package.json').version;
29
30
  /**
30
- * 端点全集运行时清单(33 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
31
+ * 端点全集运行时清单(36 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
31
32
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
32
33
  * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
33
34
  * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
@@ -71,6 +72,9 @@ export const MEMORY_ENDPOINTS = [
71
72
  'dsh-memory/embedding-runtime-cancel',
72
73
  'dsh-memory/embedding-reindex',
73
74
  'dsh-memory/embedding-reindex-cancel',
75
+ 'dsh-memory/records-retired',
76
+ 'dsh-memory/records-restore',
77
+ 'dsh-memory/cleanup-retired',
74
78
  ];
75
79
  /** HTTP 路由前缀(客户端 fetch `/dsh-memory/rpc/<短方法名>`)。 */
76
80
  const RPC_ROUTE_PREFIX = '/dsh-memory/rpc';
@@ -751,7 +755,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
751
755
  return await resolveConflictPair({ l1: stores.l1, conflictFreezeEnabled: effectiveCfg(cfg, live).conflictFreeze?.enabled === true }, pairId, outcome);
752
756
  }
753
757
  case 'dsh-memory/records-delete': {
754
- // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御
758
+ // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御。
759
+ //
760
+ // **软删**(退场),不是物理删除:与裁决 / 取代共用同一原语。面板上的"删除"
761
+ // 因此可撤销;真要抹掉数据只能走 `cleanup-retired`(先落快照 + 校验通过才删)。
762
+ // 这样 `deleteL1Batch` 在整个代码里**只有一个调用方**(exportThenPurge),
763
+ // "物理删除必须先有可信导出物"就成了结构性事实,而不是一句约定。
755
764
  if (!live?.get().memoryMutate) {
756
765
  throw new Error('记忆写删未开放:请在记忆库面板开启高权限模式');
757
766
  }
@@ -759,9 +768,101 @@ export async function handleEndpoint(endpoint, payload, deps) {
759
768
  const ids = (Array.isArray(p.ids) ? p.ids : []).filter((x) => typeof x === 'string').slice(0, 200);
760
769
  if (ids.length === 0)
761
770
  throw new Error('ids 缺失');
762
- await stores.l1.deleteBatch(ids);
763
- deps.logger.info(`[memory] 高权限删除记忆 ${ids.length} 条(${ids.join(',')})`);
764
- return { deleted: ids.length };
771
+ const n = stores.l1.retire(ids, { at: new Date().toISOString(), reason: 'manual' });
772
+ deps.logger.info(`[memory] 高权限退场(软删)记忆 ${n} 条(${ids.join(',')})`);
773
+ return { deleted: n };
774
+ }
775
+ // ── 记忆退场(软删)与清理:已退场列表 / 恢复 / 物理清理 ──
776
+ // 读方向**不开**权限门(与 conflicts 一致:看得见才知道要不要恢复);
777
+ // 恢复与物理清理由 memoryMutate 门控。
778
+ case 'dsh-memory/records-retired': {
779
+ const p = (payload ?? {});
780
+ const limit = Math.min(Math.max(Math.floor(Number(p.limit)) || 50, 1), 200);
781
+ const offset = Math.min(Math.max(Math.floor(Number(p.offset)) || 0, 0), 1_000_000);
782
+ const { items, total } = stores.l1.listRetired({ limit, offset });
783
+ const resp = {
784
+ items: items.map((r) => {
785
+ const mark = readSupersedeMarker(r.metadata);
786
+ const view = hitToUiRecord(r);
787
+ return {
788
+ ...view,
789
+ // 标记缺失时退回 `valid_to`(软删的两条判据任一成立即算已退场)
790
+ retiredAt: mark?.at ?? (r.validTo !== undefined ? new Date(r.validTo).toISOString() : ''),
791
+ retiredReason: mark?.reason ?? 'unknown',
792
+ ...(mark?.verdict ? { verdict: mark.verdict } : {}),
793
+ ...(mark?.by ? { supersededBy: mark.by } : {}),
794
+ };
795
+ }),
796
+ total,
797
+ };
798
+ return resp;
799
+ }
800
+ case 'dsh-memory/records-restore': {
801
+ if (!live?.get().memoryMutate) {
802
+ throw new Error('记忆写删未开放:请在记忆库面板开启高权限模式');
803
+ }
804
+ const p = (payload ?? {});
805
+ const ids = (Array.isArray(p.ids) ? p.ids : [])
806
+ .filter((x) => typeof x === 'string' && x !== '')
807
+ .slice(0, 200);
808
+ if (ids.length === 0)
809
+ throw new Error('ids 缺失');
810
+ const r = await stores.l1.restore(ids);
811
+ deps.logger.info(`[memory] 恢复已退场记忆 ${r.restored} 条(补向量 ${r.vectorsWritten} 条)`);
812
+ const resp = r;
813
+ return resp;
814
+ }
815
+ case 'dsh-memory/cleanup-retired': {
816
+ if (!live?.get().memoryMutate) {
817
+ throw new Error('记忆写删未开放:请在记忆库面板开启高权限模式');
818
+ }
819
+ const p = (payload ?? {});
820
+ // **默认干跑**:省略 `dryRun` 即视为 true。物理删除是本插件唯一不可逆的动作,
821
+ // 必须由调用方显式要求才做(与"所有破坏性动作必须默认可回滚"同一条纪律)。
822
+ const dryRun = p.dryRun !== false;
823
+ const explicit = (Array.isArray(p.ids) ? p.ids : [])
824
+ .filter((x) => typeof x === 'string' && x !== '')
825
+ .slice(0, 500);
826
+ let targets;
827
+ if (explicit.length > 0) {
828
+ // 显式 id 也要**复核**是否真的处于已退场态:防止调用方用一个 id 列表
829
+ // 把活动记忆绕过软删直接物理抹掉(那等于给了一条硬删后门)。
830
+ targets = explicit.filter((id) => stores.l1.getByIds([id]).some((r) => r.validTo !== undefined));
831
+ }
832
+ else {
833
+ targets = [];
834
+ for (let offset = 0;; offset += 200) {
835
+ const page = stores.l1.listRetired({ limit: 200, offset });
836
+ targets.push(...page.items.map((r) => r.id));
837
+ if (page.items.length < 200)
838
+ break;
839
+ }
840
+ }
841
+ if (dryRun) {
842
+ const resp = {
843
+ dryRun: true,
844
+ targets: targets.length,
845
+ purged: 0,
846
+ aborted: false,
847
+ dir: '',
848
+ diffs: [],
849
+ };
850
+ return resp;
851
+ }
852
+ if (targets.length === 0) {
853
+ const resp = { dryRun: false, targets: 0, purged: 0, aborted: false, dir: '', diffs: [] };
854
+ return resp;
855
+ }
856
+ const r = await stores.l1.purgeRetired(targets, 'cleanup-retired');
857
+ const resp = {
858
+ dryRun: false,
859
+ targets: targets.length,
860
+ purged: r.purged,
861
+ aborted: r.aborted,
862
+ dir: r.dir,
863
+ diffs: r.diffs,
864
+ };
865
+ return resp;
765
866
  }
766
867
  // ── 知识图谱(面板图谱视图;graph 未装配时返空不报错) ──
767
868
  case 'dsh-memory/graph-search': {
@@ -50,13 +50,24 @@ export declare function buildConflictPair(input: ConflictPairInput): ConflictPai
50
50
  * 三条都必需,缺一即无法停放,调用方须回落 `store`(信息绝不丢):
51
51
  * ① winner / loser 都是非空 id;
52
52
  * ② 二者**不同**——指向同一条记录是无效输出(承自 mneme 的 `validateDecisions`);
53
- * ③ 其中**恰有一方是本条新记忆**(`recordId`),另一方是候选池里的已有记录
54
- * (由调用方用 `knownIds` 判定存活)。否则"对"无从成立:要么新记忆没有对手,
55
- * 要么对侧是模型幻觉出来的 id。
53
+ * ③ **恰有一方是本条新记忆**(`recordId`),另一方是**可核实的对手**:
54
+ * 候选池里的已有记录(`knownIds`),**或同一批次里的另一条新记忆**(`batchIds`)。
55
+ *
56
+ * 关于 `batchIds`(2026-09-18 取证后放宽,见 findings R6 / Agent A):
57
+ * 此前另一方只认 `knownIds`(候选池 ∪ target_ids),而**同批次新记忆的 id 不在其中**
58
+ * ——它们是本轮刚生成的、尚未入库。于是"本轮两条新记忆互相矛盾"这种最典型的
59
+ * "机器判不了"情形,模型即便正确 emit 了 `conflict`,也**必然被判不成对而回落 store**。
60
+ * 实测:模型在生产 prompt 下对同批次矛盾 2/2 会 emit,但那一跳从未落库。
61
+ *
62
+ * 为何仍要求"恰有一方是 `recordId`":`conflict` 是**挂在某一条新记忆名下的决策**,
63
+ * 若允许"另外两条新记忆"配对,同一对会被每个兄弟重复申报一次。锁定一方为本条,
64
+ * 配对就唯一。
56
65
  *
57
66
  * 返回规范化后的 `{ winnerId, loserId }`,或 `null`(表示不构成冻结对)。
58
67
  */
59
- export declare function validateConflictPair(recordId: string, winner: unknown, loser: unknown, knownIds: ReadonlySet<string>): {
68
+ export declare function validateConflictPair(recordId: string, winner: unknown, loser: unknown, knownIds: ReadonlySet<string>,
69
+ /** 本批次其它新记忆的 record_id(同批次互斥也可冻结)。不传 = 维持旧行为。 */
70
+ batchIds?: ReadonlySet<string>): {
60
71
  winnerId: string;
61
72
  loserId: string;
62
73
  } | null;