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 +60 -8
- package/dist/conflict-service.d.ts +4 -2
- package/dist/conflict-service.js +15 -4
- package/dist/contract.d.ts +70 -2
- package/dist/pipeline/l1.js +50 -11
- package/dist/prompts/l1-dedup.d.ts +1 -1
- package/dist/prompts/l1-dedup.js +3 -2
- package/dist/stats.d.ts +1 -1
- package/dist/stats.js +106 -5
- package/dist/store/conflicts.d.ts +15 -4
- package/dist/store/conflicts.js +19 -9
- package/dist/store/l1-snapshot.d.ts +32 -0
- package/dist/store/l1-snapshot.js +35 -0
- package/dist/store/l1.d.ts +35 -1
- package/dist/store/l1.js +58 -2
- package/dist/store/sqlite.d.ts +43 -0
- package/dist/store/sqlite.js +121 -4
- package/dist/store/supersede.d.ts +71 -0
- package/dist/store/supersede.js +74 -0
- package/dist/tools/index.js +45 -16
- package/package.json +1 -1
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
|
-
|
|
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("
|
|
3221
|
+
setError("一次最多退场 " + DELETE_LIMIT + " 条(当前勾选 " + ids.length + " 条),请分批操作。");
|
|
3222
3222
|
return;
|
|
3223
3223
|
}
|
|
3224
|
-
if (!window.confirm("
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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' | '
|
|
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 产出的是可选字段,故按部分取值渲染。 */
|
package/dist/conflict-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/contract.d.ts
CHANGED
|
@@ -610,14 +610,76 @@ export interface ListRecordsResponse {
|
|
|
610
610
|
/** 场景筛选下拉选项(仅 offset===0 时附带)。 */
|
|
611
611
|
scenes?: string[];
|
|
612
612
|
}
|
|
613
|
-
/** dsh-memory/records-delete(
|
|
613
|
+
/** dsh-memory/records-delete(面板高权限退场指定记忆;须 memoryMutate 开启)。 */
|
|
614
614
|
export interface RecordsDeleteRequest {
|
|
615
|
-
/**
|
|
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;
|
package/dist/pipeline/l1.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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} 条(
|
|
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\
|
|
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;
|
package/dist/prompts/l1-dedup.js
CHANGED
|
@@ -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"
|
|
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
|
-
* 端点全集运行时清单(
|
|
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
|
-
* 端点全集运行时清单(
|
|
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
|
-
|
|
763
|
-
deps.logger.info(`[memory]
|
|
764
|
-
return { deleted:
|
|
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
|
-
* ③
|
|
54
|
-
* (
|
|
55
|
-
*
|
|
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;
|