dsh-prime-memory 0.14.0-beta.3 → 0.14.0-beta.5

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;
@@ -47,3 +47,18 @@ export declare function withSourceAnchors(metadata: Record<string, unknown> | un
47
47
  * 宁可报告"无锚点",也不把半截坐标喂给下游的证据读取器。
48
48
  */
49
49
  export declare function readSourceAnchors(metadata: unknown): ConversationAnchor[] | undefined;
50
+ /**
51
+ * 锚点的人类可读标签:**UI 契约(`UiRecord.sourceAnchors`)要的字符串形态**。
52
+ *
53
+ * 格式即 `t<turn>` / `t<turn> s<step>`(与 `reconcile.ts` 的证据行同一写法,
54
+ * 见 contract.ts 对 `sourceAnchors` 的说明)。`step` 缺失就**不写**——
55
+ * 不得用 0 或相邻事件的 step 补齐(红线 2:坐标永不推算)。
56
+ *
57
+ * 刻意**不带 sessionId**:该字段用于跨会话归并排序,而 UI 一行里只有
58
+ * "这条记忆出在会话内哪个位置",带上 id 反而把可用信息挤掉。
59
+ */
60
+ export declare function anchorLabel(anchor: ConversationAnchor): string;
61
+ /** 读侧一步到位:`l1_records.metadata_json` → UI 契约的标签数组。
62
+ * 无锚点返回**空数组**(`UiRecord.sourceAnchors` 的契约语义是"空数组 = 无锚点",
63
+ * 与 `readSourceAnchors` 的 `undefined` 区分开,故此处完成转换)。 */
64
+ export declare function sourceAnchorLabels(metadata: unknown): string[];
@@ -88,3 +88,22 @@ export function readSourceAnchors(metadata) {
88
88
  }
89
89
  return out.length > 0 ? out : undefined;
90
90
  }
91
+ /**
92
+ * 锚点的人类可读标签:**UI 契约(`UiRecord.sourceAnchors`)要的字符串形态**。
93
+ *
94
+ * 格式即 `t<turn>` / `t<turn> s<step>`(与 `reconcile.ts` 的证据行同一写法,
95
+ * 见 contract.ts 对 `sourceAnchors` 的说明)。`step` 缺失就**不写**——
96
+ * 不得用 0 或相邻事件的 step 补齐(红线 2:坐标永不推算)。
97
+ *
98
+ * 刻意**不带 sessionId**:该字段用于跨会话归并排序,而 UI 一行里只有
99
+ * "这条记忆出在会话内哪个位置",带上 id 反而把可用信息挤掉。
100
+ */
101
+ export function anchorLabel(anchor) {
102
+ return anchor.step === undefined ? `t${anchor.turn}` : `t${anchor.turn} s${anchor.step}`;
103
+ }
104
+ /** 读侧一步到位:`l1_records.metadata_json` → UI 契约的标签数组。
105
+ * 无锚点返回**空数组**(`UiRecord.sourceAnchors` 的契约语义是"空数组 = 无锚点",
106
+ * 与 `readSourceAnchors` 的 `undefined` 区分开,故此处完成转换)。 */
107
+ export function sourceAnchorLabels(metadata) {
108
+ return (readSourceAnchors(metadata) ?? []).map(anchorLabel);
109
+ }
@@ -272,7 +272,11 @@ 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
+ * update/merge 取代掉的旧记录 → **取代它的**新记录 id。
277
+ * 用 Map 而非 Set:退场标记要带 `by`,否则"被谁取代"只能靠时间猜。
278
+ */
279
+ const supersededBy = new Map();
276
280
  const added = [];
277
281
  /** §C 本轮新冻结的冲突对(应用完新增记录后统一落盘)。 */
278
282
  const frozen = [];
@@ -323,11 +327,14 @@ anchorMap) {
323
327
  }
324
328
  continue;
325
329
  }
326
- // update / merge:目标记录从检索库删除,合并结果作为新记录追加(版本 +1)
330
+ // update / merge:目标记录**退场(软删)**,合并结果作为新记录追加(版本 +1)
327
331
  // 候选召回按族隔离,合并产物保持新记忆的族标签
328
332
  const targets = (decision.target_ids ?? []).filter((id) => byId.has(id));
333
+ // 同一目标被多条新记录取代时保留**首个**取代者:与 retire 的幂等语义一致
334
+ // (已退场记录不重复写标记),故先到先得而不是被后者覆盖
329
335
  for (const id of targets)
330
- deletedIds.add(id);
336
+ if (!supersededBy.has(id))
337
+ supersededBy.set(id, m.record_id);
331
338
  const targetVersion = targets.reduce((max, id) => Math.max(max, byId.get(id)?.version ?? 0), 0);
332
339
  const mergedTs = (decision.merged_timestamps ?? [])
333
340
  .map((t) => Date.parse(t))
@@ -356,13 +363,27 @@ anchorMap) {
356
363
  });
357
364
  }
358
365
  await store.appendNew(added);
359
- if (deletedIds.size > 0)
360
- await store.deleteBatch([...deletedIds]);
361
- // §C 自动裁决的执行面:LLM 的 loser 从检索库退场(winner 存活)。
366
+ if (supersededBy.size > 0) {
367
+ // **软删**(取代):不是物理删除——主表行保留 + `valid_to` 闭合 + 取代标记,
368
+ // FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去 `records/*.jsonl` 手工捞。
369
+ // 按取代者分组落盘(一次事务一组),避免逐条开事务。
370
+ const retiredAt = new Date(now).toISOString();
371
+ const byNewRecord = new Map();
372
+ for (const [targetId, newId] of supersededBy) {
373
+ const arr = byNewRecord.get(newId) ?? [];
374
+ arr.push(targetId);
375
+ byNewRecord.set(newId, arr);
376
+ }
377
+ for (const [newId, ids] of byNewRecord) {
378
+ store.retire(ids, { at: retiredAt, reason: 'superseded', by: newId });
379
+ }
380
+ }
381
+ // §C 自动裁决的执行面:LLM 的 loser 从检索面退场(winner 存活),同为**软删**。
362
382
  // 排在 appendNew 之后——若 loser 恰是**本轮新记忆**(LLM 判定新记忆更差),
363
- // 也必须先让它进库再退场,以保证"本轮新增"与"本轮删除"的账面一致。
364
- if (autoLosers.size > 0)
365
- await store.deleteBatch([...autoLosers]);
383
+ // 也必须先让它进库再退场,以保证"本轮新增"与"本轮退场"的账面一致。
384
+ if (autoLosers.size > 0) {
385
+ store.retire([...autoLosers], { at: new Date(now).toISOString(), reason: 'conflict', verdict: 'auto' });
386
+ }
366
387
  // ── §C 冻结对落盘(排在 appendNew 之后) ──
367
388
  // 顺序有讲究:先让新记忆真正进 L1,再登记"它和谁构成待裁决对"。反过来的话,
368
389
  // 落盘失败会留下一条指向**不存在记录**的裁决请求,人工打开队列只会看到悬空 id。
@@ -404,7 +425,7 @@ anchorMap) {
404
425
  states[f].memoriesSinceL3 += addedByFamily[f];
405
426
  }
406
427
  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}`);
428
+ 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
429
  return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added };
409
430
  }
410
431
  /** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
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
@@ -21,14 +21,14 @@ import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChai
21
21
  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
- // R7:读回锚点走 anchors.ts 的**唯一入口**(形状校验从严),不在 UI 层自行解析 metadata。
25
- import { readSourceAnchors } from './pipeline/anchors.js';
24
+ import { sourceAnchorLabels } from './pipeline/anchors.js';
25
+ import { readSupersedeMarker } from './store/supersede.js';
26
26
  import { errDetail } from './util/filelog.js';
27
27
  import { snapshotTokenCost } from './token-cost.js';
28
28
  const require = createRequire(import.meta.url);
29
29
  export const PLUGIN_VERSION = require('../package.json').version;
30
30
  /**
31
- * 端点全集运行时清单(33 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
31
+ * 端点全集运行时清单(36 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
32
32
  * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
33
33
  * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
34
34
  * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
@@ -72,6 +72,9 @@ export const MEMORY_ENDPOINTS = [
72
72
  'dsh-memory/embedding-runtime-cancel',
73
73
  'dsh-memory/embedding-reindex',
74
74
  'dsh-memory/embedding-reindex-cancel',
75
+ 'dsh-memory/records-retired',
76
+ 'dsh-memory/records-restore',
77
+ 'dsh-memory/cleanup-retired',
75
78
  ];
76
79
  /** HTTP 路由前缀(客户端 fetch `/dsh-memory/rpc/<短方法名>`)。 */
77
80
  const RPC_ROUTE_PREFIX = '/dsh-memory/rpc';
@@ -491,6 +494,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
491
494
  distillMode: '', directBaseURL: '', directApiKey: '',
492
495
  embedRemoteBaseURL: '', embedRemoteApiKey: '', embedRemoteModel: '', embedRemoteDimensions: 0,
493
496
  memoryMutate: false,
497
+ conflictFreeze: live?.get()?.conflictFreeze === true,
494
498
  }),
495
499
  // 静态部署上限(cordis.patch.yml):运行时开关与它取 AND
496
500
  ceilings: { capture: cfg.capture.enabled, distill: cfg.extract.enabled, recall: cfg.recall.enabled },
@@ -528,8 +532,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
528
532
  throw new Error('开关通道未初始化');
529
533
  const patch = (payload ?? {});
530
534
  const clean = {};
531
- // 布尔开关组:memoryMutate(高权限写删门)与主开关同列
532
- for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate']) {
535
+ // 布尔开关组:memoryMutate(高权限写删门)与主开关同列;conflictFreeze(§C 人工冲突裁决)
536
+ for (const key of ['enabled', 'capture', 'distill', 'recall', 'memoryMutate', 'conflictFreeze']) {
533
537
  if (typeof patch[key] === 'boolean')
534
538
  clean[key] = patch[key];
535
539
  }
@@ -729,11 +733,17 @@ export async function handleEndpoint(endpoint, payload, deps) {
729
733
  // 未开启冻结时同样走返回体(enabled:false + notice)而非抛错,理由同上。
730
734
  case 'dsh-memory/conflicts': {
731
735
  const p = (payload ?? {});
732
- return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: cfg.conflictFreeze?.enabled === true }, { limit: Number(p.limit) || undefined });
736
+ // 冻结开关只有**一个**事实源:与去重管线同一套 effectiveCfg 解析
737
+ // (live.conflictFreeze 覆盖静态 cfg.conflictFreeze.enabled)。此前这里直接读
738
+ // cfg.conflictFreeze.enabled —— 面板开关写的是 live,而部署静态值恒 false,
739
+ // 于是开关已开、settings.yaml 已落 true,本页仍报"矛盾冻结未开启"。
740
+ return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: effectiveCfg(cfg, live).conflictFreeze?.enabled === true }, { limit: Number(p.limit) || undefined });
733
741
  }
734
742
  // ── §C 矛盾冻结裁决(task_25):与 memory_resolve_conflict 工具共用同一形状 ──
735
743
  // 端点层同样不给"提示文案"出口的例外只有一条:**队列未开启**不是调用错误而是
736
744
  // 部署状态,故它走返回体(带 notice)而非抛错;pair_id/outcome 缺参才抛。
745
+ // 开关判定与上面 conflicts 同源(effectiveCfg):读端与写端必须看同一份状态,
746
+ // 否则会出现"列表说开着、裁决说没开"的自相矛盾。
737
747
  case 'dsh-memory/conflict-resolve': {
738
748
  const p = (payload ?? {});
739
749
  const pairId = typeof p.pairId === 'string' ? p.pairId.trim() : '';
@@ -742,10 +752,15 @@ export async function handleEndpoint(endpoint, payload, deps) {
742
752
  throw new Error('需要 pairId(待裁决对的 pair_id)');
743
753
  if (!outcome)
744
754
  throw new Error('需要 outcome(winner | loser | both)');
745
- return await resolveConflictPair({ l1: stores.l1, conflictFreezeEnabled: cfg.conflictFreeze?.enabled === true }, pairId, outcome);
755
+ return await resolveConflictPair({ l1: stores.l1, conflictFreezeEnabled: effectiveCfg(cfg, live).conflictFreeze?.enabled === true }, pairId, outcome);
746
756
  }
747
757
  case 'dsh-memory/records-delete': {
748
- // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御
758
+ // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御。
759
+ //
760
+ // **软删**(退场),不是物理删除:与裁决 / 取代共用同一原语。面板上的"删除"
761
+ // 因此可撤销;真要抹掉数据只能走 `cleanup-retired`(先落快照 + 校验通过才删)。
762
+ // 这样 `deleteL1Batch` 在整个代码里**只有一个调用方**(exportThenPurge),
763
+ // "物理删除必须先有可信导出物"就成了结构性事实,而不是一句约定。
749
764
  if (!live?.get().memoryMutate) {
750
765
  throw new Error('记忆写删未开放:请在记忆库面板开启高权限模式');
751
766
  }
@@ -753,9 +768,101 @@ export async function handleEndpoint(endpoint, payload, deps) {
753
768
  const ids = (Array.isArray(p.ids) ? p.ids : []).filter((x) => typeof x === 'string').slice(0, 200);
754
769
  if (ids.length === 0)
755
770
  throw new Error('ids 缺失');
756
- await stores.l1.deleteBatch(ids);
757
- deps.logger.info(`[memory] 高权限删除记忆 ${ids.length} 条(${ids.join(',')})`);
758
- 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;
759
866
  }
760
867
  // ── 知识图谱(面板图谱视图;graph 未装配时返空不报错) ──
761
868
  case 'dsh-memory/graph-search': {
@@ -1058,11 +1165,16 @@ export async function handleEndpoint(endpoint, payload, deps) {
1058
1165
  return { cancelled: embedManager.cancelRuntimeInstall() };
1059
1166
  }
1060
1167
  case 'dsh-memory/embedding-reindex': {
1168
+ // 手动触发重建(契约:`EmbeddingReindexStartResponse`,受理即返回,进度照旧轮询
1169
+ // embedding-state-get 的 reindex 字段)。此前只登记了 -cancel:start 既不在白名单
1170
+ // 也没有 case,于是 startReindex() 成了够不着的死代码,端点在面板上静默 404
1171
+ // (契约门禁 tests/contract-keys.test.ts 早已为此标红)。
1172
+ // 拒绝语义交给 startReindex 自己抛(已卸载/在跑/切源占锁/源未就绪),不在端点层复述。
1061
1173
  if (!embedManager)
1062
- throw new Error('嵌入管理器未初始化');
1063
- // 全部门槛判定收在 startReindex 里(含"未就绪时 reindex 会静默 0/0/0"这条),
1064
- // 此处不重复实现一遍——两处判定必然漂移,而这里漂移的后果是谎报成功。
1065
- return embedManager.startReindex();
1174
+ throw new Error('嵌入管理器未初始化(存储不可用)');
1175
+ const r = embedManager.startReindex();
1176
+ deps.logger.info('[memory] 收到嵌入重建指令(设置页按钮)');
1177
+ return r;
1066
1178
  }
1067
1179
  case 'dsh-memory/embedding-reindex-cancel': {
1068
1180
  if (!embedManager)
@@ -1087,19 +1199,14 @@ function hitToUiRecord(r) {
1087
1199
  createdAt: r.createdAt ? new Date(r.createdAt).toISOString() : null,
1088
1200
  updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null,
1089
1201
  version: r.version ?? 0,
1090
- sourceAnchors: (readSourceAnchors(r.metadata) ?? []).map(formatAnchor),
1202
+ // R7 来源锚点:契约字段是**标签数组**(`t12 s3`),由 metadata 的保留键读出。
1203
+ // 此前这里仍在填已废弃的 `sourceMessageIds`——`l1_records` 从不存那一列,
1204
+ // 该字段恒为 `[]`(死字段),而契约早已换成 `sourceAnchors`,于是
1205
+ // `sourceAnchors` 永远缺失、来源行在 UI 上从未显示过。
1206
+ sourceAnchors: sourceAnchorLabels(r.metadata),
1091
1207
  score: r.score ?? null,
1092
1208
  };
1093
1209
  }
1094
- /**
1095
- * 锚点的展示形态:`t12 s3` / 无 step 时 `t12`。
1096
- *
1097
- * 只做**可读化**,不携带 sessionId——单条记录的来源会话由记录自身语义决定,
1098
- * 把 sessionId 塞进这一行会把 12 个字符的坐标变成 40 个字符。
1099
- */
1100
- function formatAnchor(a) {
1101
- return typeof a.step === 'number' ? `t${a.turn} s${a.step}` : `t${a.turn}`;
1102
- }
1103
1210
  /**
1104
1211
  * 从文件尾反向分块读取最后 N 行:不整读全文件(轮转上限 2MB,整读会
1105
1212
  * 阻塞事件循环数毫秒)。原始 Buffer 拼接后再解码——分块边界可能切在