dsh-prime-memory 0.14.0-beta.4 → 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 +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 +31 -10
- package/dist/stats.d.ts +1 -1
- package/dist/stats.js +106 -5
- 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,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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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} 条(
|
|
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
|
-
* 端点全集运行时清单(
|
|
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': {
|
|
@@ -97,3 +97,35 @@ export declare function restoreL1Snapshot(db: SnapshotDbLike, dir: string, logge
|
|
|
97
97
|
* @returns 快照目录与清单;调用方拿到后才可以继续清空。
|
|
98
98
|
*/
|
|
99
99
|
export declare function snapshotBeforeClear(db: SnapshotDbLike, dataDir: string, reason: string, now?: Date): Promise<CreateSnapshotResult>;
|
|
100
|
+
/** 物理清理面(比快照面多一个删除能力)。 */
|
|
101
|
+
export interface PurgeDbLike extends SnapshotDbLike {
|
|
102
|
+
deleteL1Batch: (ids: string[]) => number;
|
|
103
|
+
}
|
|
104
|
+
export interface ExportThenPurgeResult {
|
|
105
|
+
/** 清理是否真的执行了。 */
|
|
106
|
+
ok: boolean;
|
|
107
|
+
/** 门禁未通过而**中止**(`purged` 必为 0)。 */
|
|
108
|
+
aborted: boolean;
|
|
109
|
+
/** 快照目录(中止时也给,便于人工查看失败现场)。 */
|
|
110
|
+
dir: string;
|
|
111
|
+
purged: number;
|
|
112
|
+
/** 校验差异(仅 aborted 时非空)。 */
|
|
113
|
+
diffs: string[];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* **先导出,后清理**——把这句话变成调用方绕不过去的一步。
|
|
117
|
+
*
|
|
118
|
+
* 顺序与理由:
|
|
119
|
+
* ① 建快照(写正文 + 清单);**写盘失败即中止**,绝不"先删了再说";
|
|
120
|
+
* ② `verifySnapshot` 按**内容哈希**比对快照与当前库。不一致说明两者之间有别的写入
|
|
121
|
+
* 发生(并发蒸馏、另一次清理),此时快照**不代表**将要被删的那批数据 → 中止;
|
|
122
|
+
* ③ 只有 ①② 都通过,才 `deleteL1Batch` 做物理删除。
|
|
123
|
+
*
|
|
124
|
+
* 为什么值得这么严:物理删除是本插件唯一**不可逆**的动作。软删(退场)可以恢复,
|
|
125
|
+
* 而清理一旦没有可信的导出物,就只剩 `records/*.jsonl` 事实源这一条后路,
|
|
126
|
+
* 且那条路只覆盖 L1 记录、不覆盖 receipts/conflicts 的当时快照。
|
|
127
|
+
*/
|
|
128
|
+
export declare function exportThenPurge(db: PurgeDbLike, dataDir: string, ids: readonly string[], reason: string, logger?: {
|
|
129
|
+
info: (m: string) => void;
|
|
130
|
+
warn: (m: string) => void;
|
|
131
|
+
}, now?: Date): Promise<ExportThenPurgeResult>;
|
|
@@ -187,3 +187,38 @@ export async function snapshotBeforeClear(db, dataDir, reason, now = new Date())
|
|
|
187
187
|
const dir = snapshotPathFor(dataDir, now, reason);
|
|
188
188
|
return createL1Snapshot(db, dir, reason, now);
|
|
189
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* **先导出,后清理**——把这句话变成调用方绕不过去的一步。
|
|
192
|
+
*
|
|
193
|
+
* 顺序与理由:
|
|
194
|
+
* ① 建快照(写正文 + 清单);**写盘失败即中止**,绝不"先删了再说";
|
|
195
|
+
* ② `verifySnapshot` 按**内容哈希**比对快照与当前库。不一致说明两者之间有别的写入
|
|
196
|
+
* 发生(并发蒸馏、另一次清理),此时快照**不代表**将要被删的那批数据 → 中止;
|
|
197
|
+
* ③ 只有 ①② 都通过,才 `deleteL1Batch` 做物理删除。
|
|
198
|
+
*
|
|
199
|
+
* 为什么值得这么严:物理删除是本插件唯一**不可逆**的动作。软删(退场)可以恢复,
|
|
200
|
+
* 而清理一旦没有可信的导出物,就只剩 `records/*.jsonl` 事实源这一条后路,
|
|
201
|
+
* 且那条路只覆盖 L1 记录、不覆盖 receipts/conflicts 的当时快照。
|
|
202
|
+
*/
|
|
203
|
+
export async function exportThenPurge(db, dataDir, ids, reason, logger, now = new Date()) {
|
|
204
|
+
if (ids.length === 0)
|
|
205
|
+
return { ok: true, aborted: false, dir: '', purged: 0, diffs: [] };
|
|
206
|
+
let dir = '';
|
|
207
|
+
try {
|
|
208
|
+
const snap = await snapshotBeforeClear(db, dataDir, reason, now);
|
|
209
|
+
dir = snap.dir;
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
213
|
+
logger?.warn(`[memory] 清理中止:快照写入失败(${msg})——未删除任何记录`);
|
|
214
|
+
return { ok: false, aborted: true, dir, purged: 0, diffs: [`快照写入失败:${msg}`] };
|
|
215
|
+
}
|
|
216
|
+
const verdict = await verifySnapshot(db, dir);
|
|
217
|
+
if (!verdict.ok) {
|
|
218
|
+
logger?.warn(`[memory] 清理中止:快照校验未通过(${verdict.diffs.join(';')})——未删除任何记录`);
|
|
219
|
+
return { ok: false, aborted: true, dir, purged: 0, diffs: verdict.diffs };
|
|
220
|
+
}
|
|
221
|
+
const purged = db.deleteL1Batch([...ids]);
|
|
222
|
+
logger?.info(`[memory] 已物理清理 ${purged} 条已退场记录(快照:${dir})`);
|
|
223
|
+
return { ok: true, aborted: false, dir, purged, diffs: [] };
|
|
224
|
+
}
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { L1Hit, MemoryFamily, MemoryLogger, MemoryRecord } from '../types.j
|
|
|
2
2
|
import type { GraphNodeSearchResult } from '../graph/types.js';
|
|
3
3
|
import type { L1Receipt, ReceiptQuery } from './receipts.js';
|
|
4
4
|
import type { ConflictPair, ConflictResolution } from './conflicts.js';
|
|
5
|
+
import type { SupersedeInfo } from './supersede.js';
|
|
6
|
+
import { type ExportThenPurgeResult } from './l1-snapshot.js';
|
|
5
7
|
import { type EmbeddingService } from './embedding.js';
|
|
6
8
|
import { type MemoryDb } from './sqlite.js';
|
|
7
9
|
export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
|
|
@@ -102,7 +104,39 @@ export declare class L1Store {
|
|
|
102
104
|
* (见本文件 `reindex` 首行),调用方必须自己问这里——否则"根本没跑"
|
|
103
105
|
* 会长得和"跑完了、零条待补"一模一样。 */
|
|
104
106
|
vectorsReady(): boolean;
|
|
105
|
-
|
|
107
|
+
/**
|
|
108
|
+
* **软删**(记忆退场):保留主表行 + 撤出检索面,可被 `restore` 找回。
|
|
109
|
+
*
|
|
110
|
+
* 三条退场路径 —— 裁决判负 / 去重取代(`update`/`merge`) / 人工删除 ——
|
|
111
|
+
* **共用这一个入口**。分成三份实现迟早会出现"某条路径还在硬删"的不一致语义,
|
|
112
|
+
* 而那种不一致只有在误删发生时才暴露。
|
|
113
|
+
*/
|
|
114
|
+
retire(ids: string[], info: SupersedeInfo): number;
|
|
115
|
+
/** 已退场(可恢复)记录列表(面板用)。 */
|
|
116
|
+
listRetired(opts: {
|
|
117
|
+
limit: number;
|
|
118
|
+
offset: number;
|
|
119
|
+
}): {
|
|
120
|
+
items: MemoryRecord[];
|
|
121
|
+
total: number;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* 恢复:清退场标记 → 重新 upsert 以重建 FTS(与向量)。
|
|
125
|
+
*
|
|
126
|
+
* 嵌入不可用/超时时**不抛**:向量补不上只是"暂时只能关键词召回",而"恢复失败"
|
|
127
|
+
* 会让人以为记录丢了 —— 后者严重得多。记录先回到检索面,向量留给后续 `reindex`。
|
|
128
|
+
*/
|
|
129
|
+
restore(ids: string[]): Promise<{
|
|
130
|
+
restored: number;
|
|
131
|
+
vectorsWritten: number;
|
|
132
|
+
}>;
|
|
133
|
+
/**
|
|
134
|
+
* 已退场记录的**物理清理**(不可逆):先落快照 + 校验,门禁不过即中止。
|
|
135
|
+
*
|
|
136
|
+
* 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
|
|
137
|
+
* 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
|
|
138
|
+
*/
|
|
139
|
+
purgeRetired(ids: string[], reason: string): Promise<ExportThenPurgeResult>;
|
|
106
140
|
/**
|
|
107
141
|
* 三策略检索(自动召回与 memory_search 工具共用接缝)。
|
|
108
142
|
* embedding 不可用时自动降级 keyword;type 后置过滤;
|
package/dist/store/l1.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, promises as fs } from 'node:fs';
|
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { familyForType, isScopeVisible } from '../types.js';
|
|
12
12
|
import { graphHitRecordIds } from '../graph/search.js';
|
|
13
|
+
import { exportThenPurge } from './l1-snapshot.js';
|
|
13
14
|
import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
|
|
14
15
|
import { appendJsonl, dayKey, ensureDir, readJsonl } from '../util/io.js';
|
|
15
16
|
import { applyDecayWeight, normalizeRrf, rrfMerge } from './search-utils.js';
|
|
@@ -185,8 +186,63 @@ export class L1Store {
|
|
|
185
186
|
vectorsReady() {
|
|
186
187
|
return this.helper.vectorReady();
|
|
187
188
|
}
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
// 这里**刻意没有** `deleteBatch`:物理删除(L1 三表同清)是不可逆的,
|
|
190
|
+
// 故它只能经由 `purgeRetired` → `exportThenPurge`(先落快照 + 校验通过)抵达。
|
|
191
|
+
// 曾经的 `deleteBatch(ids)` 是个无门禁的硬删入口,裁决 / 取代 / 面板删除都直接
|
|
192
|
+
// 调它 —— 那正是"删错了只能去 records/*.jsonl 手工捞"的根源。
|
|
193
|
+
// 若将来确需新增强删路径,请复用它下面的门禁,而不是重新暴露一个裸入口。
|
|
194
|
+
/**
|
|
195
|
+
* **软删**(记忆退场):保留主表行 + 撤出检索面,可被 `restore` 找回。
|
|
196
|
+
*
|
|
197
|
+
* 三条退场路径 —— 裁决判负 / 去重取代(`update`/`merge`) / 人工删除 ——
|
|
198
|
+
* **共用这一个入口**。分成三份实现迟早会出现"某条路径还在硬删"的不一致语义,
|
|
199
|
+
* 而那种不一致只有在误删发生时才暴露。
|
|
200
|
+
*/
|
|
201
|
+
retire(ids, info) {
|
|
202
|
+
return this.db.retireL1Batch(ids, info);
|
|
203
|
+
}
|
|
204
|
+
/** 已退场(可恢复)记录列表(面板用)。 */
|
|
205
|
+
listRetired(opts) {
|
|
206
|
+
return this.db.listRetiredL1(opts);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* 恢复:清退场标记 → 重新 upsert 以重建 FTS(与向量)。
|
|
210
|
+
*
|
|
211
|
+
* 嵌入不可用/超时时**不抛**:向量补不上只是"暂时只能关键词召回",而"恢复失败"
|
|
212
|
+
* 会让人以为记录丢了 —— 后者严重得多。记录先回到检索面,向量留给后续 `reindex`。
|
|
213
|
+
*/
|
|
214
|
+
async restore(ids) {
|
|
215
|
+
const records = this.db.clearRetireMarker(ids);
|
|
216
|
+
let vectorsWritten = 0;
|
|
217
|
+
for (const rec of records) {
|
|
218
|
+
if (!rec.family)
|
|
219
|
+
rec.family = familyForType(rec.type);
|
|
220
|
+
let vec;
|
|
221
|
+
try {
|
|
222
|
+
vec = (await this.helper.batch([rec.content]))[0];
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
this.logger?.warn(`[memory] 恢复时向量计算失败,先回关键词检索面(日后重建可补齐): ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
}
|
|
227
|
+
if (vec && !isZeroVector(vec))
|
|
228
|
+
vectorsWritten++;
|
|
229
|
+
this.db.upsertL1(rec, vec);
|
|
230
|
+
}
|
|
231
|
+
return { restored: records.length, vectorsWritten };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 已退场记录的**物理清理**(不可逆):先落快照 + 校验,门禁不过即中止。
|
|
235
|
+
*
|
|
236
|
+
* 门禁本体在 `l1-snapshot.exportThenPurge`(与"重建前必快照"同一套设施);
|
|
237
|
+
* 这里只把 L1Store 已知的 dataDir 与 logger 接上去,避免端点层自己去推路径。
|
|
238
|
+
*/
|
|
239
|
+
async purgeRetired(ids, reason) {
|
|
240
|
+
// **只清理确实处于退场态的记录**。这道复核必须在删除发生的地方(而不是调用方):
|
|
241
|
+
// 否则任何调用方传一个 id 列表就能绕过软删、把活动记忆直接物理抹掉——
|
|
242
|
+
// 那等于给"先导出后清理"留了一条硬删后门。
|
|
243
|
+
const known = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
|
|
244
|
+
const retiredIds = ids.filter((id) => known.get(id)?.validTo !== undefined);
|
|
245
|
+
return exportThenPurge(this.db, path.dirname(this.recordsDir), retiredIds, reason, this.logger);
|
|
190
246
|
}
|
|
191
247
|
/**
|
|
192
248
|
* 三策略检索(自动召回与 memory_search 工具共用接缝)。
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { CostByModel } from '../contract.js';
|
|
|
17
17
|
import { GraphStore } from './graph-store.js';
|
|
18
18
|
import type { L1Receipt, ReceiptQuery, ReceiptRetentionOptions } from './receipts.js';
|
|
19
19
|
import type { ConflictPair, ConflictResolution } from './conflicts.js';
|
|
20
|
+
import { type SupersedeInfo } from './supersede.js';
|
|
20
21
|
/** L1 检索命中(含 BM25/余弦归一分数)。 */
|
|
21
22
|
export interface L1SearchHit {
|
|
22
23
|
id: string;
|
|
@@ -145,6 +146,48 @@ export declare class MemoryDb {
|
|
|
145
146
|
/** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。IN 按 ≤900 分块(避变量数上限)。
|
|
146
147
|
* 删除成功后触发图谱删除传播(来源全失效的节点/边惰性标 archived;失败不影响删除结果)。 */
|
|
147
148
|
deleteL1Batch(ids: string[]): number;
|
|
149
|
+
/**
|
|
150
|
+
* **软删**(记忆退场):保留主表行,撤出检索面。
|
|
151
|
+
*
|
|
152
|
+
* 与 `deleteL1Batch` 的差别**只有一处**:不动 `l1_records` 行本身。
|
|
153
|
+
* `valid_to` 闭合 + `metadata_json` 写取代标记 → 记录仍能被 `listL1` 列出、
|
|
154
|
+
* 能被 `clearRetireMarker` + upsert 恢复;而 FTS 与向量行照旧删除,于是检索面
|
|
155
|
+
* (含去重候选召回)自然看不到它 —— **检索 SQL 一行都不用改**,活动记录零漂移
|
|
156
|
+
* 因此是构造性的,不是比对出来的。
|
|
157
|
+
*
|
|
158
|
+
* 顺序刻意如此:先打标记(可逆的那一半),再撤检索面,且整体在一个事务里。
|
|
159
|
+
* 反过来先撤索引而打标记失败,记录会落在"检索不到、也没被标记"的状态 ——
|
|
160
|
+
* 既查不出来也恢复不了,是最坏的一种中间态。
|
|
161
|
+
*
|
|
162
|
+
* **幂等**:已退场(`valid_to` 非空或已有标记)的 id 不再重复写标记,
|
|
163
|
+
* 保留首次退场的原因与时刻(「谁先取代了它」不该被后一次调用改写)。
|
|
164
|
+
*
|
|
165
|
+
* **不调** `graphStore.markSourcesDeleted`:那是"来源已物理消失"的传播,
|
|
166
|
+
* 而软删的记录仍活在主表里 —— 图谱侧的退役语义另计(见计划 findings R-a)。
|
|
167
|
+
*/
|
|
168
|
+
retireL1Batch(ids: string[], info: SupersedeInfo): number;
|
|
169
|
+
/**
|
|
170
|
+
* 撤出检索面(删 FTS + 向量行,**主表保留**)。
|
|
171
|
+
* 与 `deleteL1Batch` 的删除面同源,只是不动 `l1_records`。
|
|
172
|
+
*/
|
|
173
|
+
private detachL1FromRetrieval;
|
|
174
|
+
/**
|
|
175
|
+
* 清掉退场标记(恢复的**前半**)。返回清完标记的记录,供调用方 re-upsert 以重建
|
|
176
|
+
* FTS/向量 —— 那条路径(`upsertL1InTx`)已存在,不在这里重复实现。
|
|
177
|
+
*
|
|
178
|
+
* 只清 `valid_to` 与标记键,**不碰内容**:恢复不该修改记忆本身。
|
|
179
|
+
* 返回的 `validTo` 显式置 `undefined`(而非留着旧 epoch),否则 upsert 会
|
|
180
|
+
* 用 `toIso(旧值)` 把 `valid_to` 又写回去,恢复静默失败。
|
|
181
|
+
*/
|
|
182
|
+
clearRetireMarker(ids: string[]): MemoryRecord[];
|
|
183
|
+
/** 已退场记录列表(面板用):`valid_to` 非空即已退场。失败返回空。 */
|
|
184
|
+
listRetiredL1(opts: {
|
|
185
|
+
limit: number;
|
|
186
|
+
offset: number;
|
|
187
|
+
}): {
|
|
188
|
+
items: MemoryRecord[];
|
|
189
|
+
total: number;
|
|
190
|
+
};
|
|
148
191
|
private inStatement;
|
|
149
192
|
/**
|
|
150
193
|
* 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
|
package/dist/store/sqlite.js
CHANGED
|
@@ -33,6 +33,7 @@ import { CostLedger } from './cost-ledger.js';
|
|
|
33
33
|
// 图谱存储(graph_* 表族)同为独立职责类;init 失败仅图谱 no-op,不传染主库降级
|
|
34
34
|
import { GraphStore } from './graph-store.js';
|
|
35
35
|
import { RECEIPTS_MAX_RUNS, RECEIPTS_QUERY_LIMIT_MAX } from './receipts.js';
|
|
36
|
+
import { isRetired, readSupersedeMarker, stripSupersedeMarker, withSupersedeMarker } from './supersede.js';
|
|
36
37
|
/** vec0 KNN 对遗留零向量的补偿缓冲。 */
|
|
37
38
|
const ZERO_VEC_BUFFER = 10;
|
|
38
39
|
/** IN 查询/删除的分块大小(保守避开 SQLite 变量数上限:现代构建 32766,老版 999)。 */
|
|
@@ -826,20 +827,28 @@ export class MemoryDb {
|
|
|
826
827
|
// 全扫(批量写整体 O(N²))。只有主表已有该行(覆盖/合并)才可能有旧 FTS 行需要删。
|
|
827
828
|
// 同批重复 id 也能正确处理:首条插入后,第二条的点查在同一事务内已见新行。
|
|
828
829
|
const ftsExisted = this.ftsAvailable ? this.stmtL1Exists.get(record.id) !== undefined : false;
|
|
830
|
+
// **退场不变量必须在写入漏斗上强制**:已退场的记录(valid_to 闭合或带取代标记)
|
|
831
|
+
// 永不出现在检索面。只靠 `retireL1Batch` 保证是不够的——快照恢复 / 重建 /
|
|
832
|
+
// 旧版导入都会经这里写回记录,若照常重建 FTS/向量行,一条带退场标记的记录
|
|
833
|
+
// 会**悄悄回到检索结果里**(而检索侧刻意不看 valid_to,正是为了零漂移)。
|
|
834
|
+
const retiredNow = record.validTo !== undefined || readSupersedeMarker(record.metadata) !== undefined;
|
|
829
835
|
this.stmtUpsertL1.run(record.id, record.content, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), family, toIso(record.validFrom), toIso(record.validTo), normPersistence(record.persistence) ?? '', scope, workspaceId);
|
|
830
|
-
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
|
|
836
|
+
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)。
|
|
837
|
+
// 已退场则**只删不插**(见上方 retiredNow 的说明)。
|
|
831
838
|
if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
|
|
832
839
|
this.stmtDeleteL1Vec.run(record.id);
|
|
833
|
-
if (embedding && !isZeroVector(embedding)) {
|
|
840
|
+
if (!retiredNow && embedding && !isZeroVector(embedding)) {
|
|
834
841
|
this.stmtInsertL1Vec.run(record.id, vecToBuffer(embedding), toIso(record.updatedAt));
|
|
835
842
|
}
|
|
836
843
|
}
|
|
837
844
|
// FTS 删除/插入与元数据同事务:失败必须整体回滚——若只吞 FTS 错误照常 COMMIT,
|
|
838
845
|
// 已执行的 DELETE 会让该 id 的索引行被删未补,记录从此全文检索不可见(静默丢数据)。
|
|
839
846
|
if (this.ftsAvailable) {
|
|
840
|
-
if (ftsExisted)
|
|
847
|
+
if (ftsExisted || retiredNow)
|
|
841
848
|
this.stmtL1FtsDelete.run(record.id);
|
|
842
|
-
|
|
849
|
+
if (!retiredNow) {
|
|
850
|
+
this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, type, priority, sceneName, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), family, scope, workspaceId);
|
|
851
|
+
}
|
|
843
852
|
}
|
|
844
853
|
}
|
|
845
854
|
/** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。IN 按 ≤900 分块(避变量数上限)。
|
|
@@ -868,6 +877,114 @@ export class MemoryDb {
|
|
|
868
877
|
return 0;
|
|
869
878
|
}
|
|
870
879
|
}
|
|
880
|
+
/**
|
|
881
|
+
* **软删**(记忆退场):保留主表行,撤出检索面。
|
|
882
|
+
*
|
|
883
|
+
* 与 `deleteL1Batch` 的差别**只有一处**:不动 `l1_records` 行本身。
|
|
884
|
+
* `valid_to` 闭合 + `metadata_json` 写取代标记 → 记录仍能被 `listL1` 列出、
|
|
885
|
+
* 能被 `clearRetireMarker` + upsert 恢复;而 FTS 与向量行照旧删除,于是检索面
|
|
886
|
+
* (含去重候选召回)自然看不到它 —— **检索 SQL 一行都不用改**,活动记录零漂移
|
|
887
|
+
* 因此是构造性的,不是比对出来的。
|
|
888
|
+
*
|
|
889
|
+
* 顺序刻意如此:先打标记(可逆的那一半),再撤检索面,且整体在一个事务里。
|
|
890
|
+
* 反过来先撤索引而打标记失败,记录会落在"检索不到、也没被标记"的状态 ——
|
|
891
|
+
* 既查不出来也恢复不了,是最坏的一种中间态。
|
|
892
|
+
*
|
|
893
|
+
* **幂等**:已退场(`valid_to` 非空或已有标记)的 id 不再重复写标记,
|
|
894
|
+
* 保留首次退场的原因与时刻(「谁先取代了它」不该被后一次调用改写)。
|
|
895
|
+
*
|
|
896
|
+
* **不调** `graphStore.markSourcesDeleted`:那是"来源已物理消失"的传播,
|
|
897
|
+
* 而软删的记录仍活在主表里 —— 图谱侧的退役语义另计(见计划 findings R-a)。
|
|
898
|
+
*/
|
|
899
|
+
retireL1Batch(ids, info) {
|
|
900
|
+
if (this.degraded || ids.length === 0)
|
|
901
|
+
return 0;
|
|
902
|
+
try {
|
|
903
|
+
const existing = new Map(this.getL1ByIds(ids).map((r) => [r.id, r]));
|
|
904
|
+
const update = this.db.prepare('UPDATE l1_records SET valid_to = ?, metadata_json = ? WHERE record_id = ?');
|
|
905
|
+
const retired = [];
|
|
906
|
+
this.withTransaction(() => {
|
|
907
|
+
for (const id of ids) {
|
|
908
|
+
const rec = existing.get(id);
|
|
909
|
+
// 不存在的 id 静默跳过;已退场的跳过以保幂等
|
|
910
|
+
if (!rec || isRetired(rec))
|
|
911
|
+
continue;
|
|
912
|
+
update.run(info.at, JSON.stringify(withSupersedeMarker(rec.metadata, info)), id);
|
|
913
|
+
retired.push(id);
|
|
914
|
+
}
|
|
915
|
+
if (retired.length > 0)
|
|
916
|
+
this.detachL1FromRetrieval(retired);
|
|
917
|
+
});
|
|
918
|
+
return retired.length;
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
this.logger?.warn(`${TAG} L1 软删(退场)失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
922
|
+
return 0;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* 撤出检索面(删 FTS + 向量行,**主表保留**)。
|
|
927
|
+
* 与 `deleteL1Batch` 的删除面同源,只是不动 `l1_records`。
|
|
928
|
+
*/
|
|
929
|
+
detachL1FromRetrieval(ids) {
|
|
930
|
+
if (this.stmtDeleteL1Vec) {
|
|
931
|
+
for (const chunk of chunkIds(ids))
|
|
932
|
+
this.inStatement('l1_vec', 'delete', chunk.length).run(...chunk);
|
|
933
|
+
}
|
|
934
|
+
if (this.ftsAvailable) {
|
|
935
|
+
for (const chunk of chunkIds(ids))
|
|
936
|
+
this.inStatement('l1_fts', 'delete', chunk.length).run(...chunk);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* 清掉退场标记(恢复的**前半**)。返回清完标记的记录,供调用方 re-upsert 以重建
|
|
941
|
+
* FTS/向量 —— 那条路径(`upsertL1InTx`)已存在,不在这里重复实现。
|
|
942
|
+
*
|
|
943
|
+
* 只清 `valid_to` 与标记键,**不碰内容**:恢复不该修改记忆本身。
|
|
944
|
+
* 返回的 `validTo` 显式置 `undefined`(而非留着旧 epoch),否则 upsert 会
|
|
945
|
+
* 用 `toIso(旧值)` 把 `valid_to` 又写回去,恢复静默失败。
|
|
946
|
+
*/
|
|
947
|
+
clearRetireMarker(ids) {
|
|
948
|
+
if (this.degraded || ids.length === 0)
|
|
949
|
+
return [];
|
|
950
|
+
try {
|
|
951
|
+
const existing = this.getL1ByIds(ids);
|
|
952
|
+
const update = this.db.prepare('UPDATE l1_records SET valid_to = ?, metadata_json = ? WHERE record_id = ?');
|
|
953
|
+
const restored = [];
|
|
954
|
+
this.withTransaction(() => {
|
|
955
|
+
for (const rec of existing) {
|
|
956
|
+
const metadata = stripSupersedeMarker(rec.metadata);
|
|
957
|
+
if (isRetired(rec))
|
|
958
|
+
update.run('', JSON.stringify(metadata), rec.id);
|
|
959
|
+
restored.push({ ...rec, metadata, validTo: undefined });
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
return restored;
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
this.logger?.warn(`${TAG} L1 恢复(清退场标记)失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/** 已退场记录列表(面板用):`valid_to` 非空即已退场。失败返回空。 */
|
|
970
|
+
listRetiredL1(opts) {
|
|
971
|
+
if (this.degraded)
|
|
972
|
+
return { items: [], total: 0 };
|
|
973
|
+
try {
|
|
974
|
+
const totalRow = this.db
|
|
975
|
+
.prepare("SELECT COUNT(*) AS n FROM l1_records WHERE COALESCE(valid_to, '') <> ''")
|
|
976
|
+
.get();
|
|
977
|
+
const rows = this.db
|
|
978
|
+
.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family, valid_from, valid_to, persistence, scope, workspace_id
|
|
979
|
+
FROM l1_records WHERE COALESCE(valid_to, '') <> '' ORDER BY updated_time DESC LIMIT ? OFFSET ?`)
|
|
980
|
+
.all(opts.limit, opts.offset);
|
|
981
|
+
return { items: rows.map(rowToRecord), total: Number(totalRow?.n ?? 0) };
|
|
982
|
+
}
|
|
983
|
+
catch (err) {
|
|
984
|
+
this.logger?.warn(`${TAG} 已退场列表查询失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
|
|
985
|
+
return { items: [], total: 0 };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
871
988
|
inStatement(table, action, size) {
|
|
872
989
|
const key = `${table}:${action}:${size}`;
|
|
873
990
|
let stmt = this.inStmts.get(key);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆退场的**取代标记**(软删可恢复)——纯函数层。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 退场(裁决判负 / 去重取代 / 人工删除)此前一律 `deleteL1Batch` **物理删除**:
|
|
7
|
+
* 主表、FTS、向量三处同清,记录直接消失。于是"删错了"只能靠 `records/*.jsonl`
|
|
8
|
+
* 事实源捞回来——**检索库自身不可恢复**。
|
|
9
|
+
*
|
|
10
|
+
* 本模块承载"可恢复"里最纯的那一半:**标记的形状**。把它抽成独立纯函数层,
|
|
11
|
+
* 与 `anchors.ts` 同一理由——读写都是"输入 → 输出",可以是单测直接验的,
|
|
12
|
+
* 而不必起库、起宿主。
|
|
13
|
+
*
|
|
14
|
+
* ## 两条铁律
|
|
15
|
+
*
|
|
16
|
+
* 1. **不可变**:`with*` / `strip*` 一律返回**新对象**,绝不改入参。调用方的
|
|
17
|
+
* `record.metadata` 可能正被别处引用(检索命中、面板视图),就地改写会
|
|
18
|
+
* 造成"读一次数据被改一次"的隐性副作用。
|
|
19
|
+
* 2. **零漂移**:`strip` 在无标记时返回**原引用**;`with` 只在确有标记时才写键。
|
|
20
|
+
* 既有记录的 `metadata_json` 不该因为本功能上线而多出任何字节。
|
|
21
|
+
*/
|
|
22
|
+
import type { MemoryRecord } from '../types.js';
|
|
23
|
+
/**
|
|
24
|
+
* `l1_records.metadata_json` 里承载取代标记的**保留键**。
|
|
25
|
+
*
|
|
26
|
+
* 加 `dsh_` 前缀的理由与 `ANCHOR_METADATA_KEY` 完全相同:写库时本插件的保留键
|
|
27
|
+
* 会与 LLM 产出的 metadata(`hall` / `artifact_type` 等)合并进同一个 JSON 对象,
|
|
28
|
+
* 必须靠命名空间隔开,否则一次 LLM 幻觉输出 `superseded` 就能伪装成人工裁决。
|
|
29
|
+
*/
|
|
30
|
+
export declare const SUPERSEDE_METADATA_KEY = "dsh_superseded";
|
|
31
|
+
/**
|
|
32
|
+
* 退场原因。**穷举**而非自由字符串:面板要按原因给不同文案与图标,
|
|
33
|
+
* 自由字符串会让"未识别的原因"静默退化成无徽标。
|
|
34
|
+
*/
|
|
35
|
+
export type RetireReason = 'conflict' | 'superseded' | 'manual';
|
|
36
|
+
/** 取代标记(写进 metadata 的负载)。 */
|
|
37
|
+
export interface SupersedeInfo {
|
|
38
|
+
/** 退场时刻(ISO 8601)。 */
|
|
39
|
+
at: string;
|
|
40
|
+
reason: RetireReason;
|
|
41
|
+
/** 裁决结论 `winner` / `loser` / `both` / `auto`(仅 `reason='conflict'`)。 */
|
|
42
|
+
verdict?: string;
|
|
43
|
+
/** 产生该退场的待裁决对 id(仅 `reason='conflict'`,供交叉审计)。 */
|
|
44
|
+
pairId?: string;
|
|
45
|
+
/** **取代它的**新记录 id(仅 `reason='superseded'`)。 */
|
|
46
|
+
by?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 写入取代标记(返回**新对象**)。
|
|
50
|
+
*
|
|
51
|
+
* 与 `withSourceAnchors` 的差别:锚点"无锚点时不写键",而退场标记**永远写**——
|
|
52
|
+
* 调用它的前提就是"这条记录正在退场",没有"空标记"这种合法输入。
|
|
53
|
+
*/
|
|
54
|
+
export declare function withSupersedeMarker(metadata: Record<string, unknown> | undefined, info: SupersedeInfo): Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* 读回取代标记(读侧唯一入口)。
|
|
57
|
+
*
|
|
58
|
+
* 形状校验从严:缺 `at` 或 `reason` 非法 → 返回 `undefined`(当作**未退场**)。
|
|
59
|
+
* 宁可把一条坏标记的记录当活动记录,也不把它当"已退场"而藏起来——
|
|
60
|
+
* 后者会让记忆**悄悄消失**,正是本功能要根治的病。
|
|
61
|
+
*/
|
|
62
|
+
export declare function readSupersedeMarker(metadata: unknown): SupersedeInfo | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* 去标记(恢复时用)。返回**新对象**。
|
|
65
|
+
*
|
|
66
|
+
* 无标记时返回**原引用**——恢复一条从未退场的记录不该产生任何新对象,
|
|
67
|
+
* 这条判据让"恢复"对活动记录是彻底的 no-op。
|
|
68
|
+
*/
|
|
69
|
+
export declare function stripSupersedeMarker(metadata: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
70
|
+
/** 该记录是否处于**已退场**状态(读主表 `valid_to` 的语义封装)。 */
|
|
71
|
+
export declare function isRetired(record: Pick<MemoryRecord, 'validTo' | 'metadata'>): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `l1_records.metadata_json` 里承载取代标记的**保留键**。
|
|
3
|
+
*
|
|
4
|
+
* 加 `dsh_` 前缀的理由与 `ANCHOR_METADATA_KEY` 完全相同:写库时本插件的保留键
|
|
5
|
+
* 会与 LLM 产出的 metadata(`hall` / `artifact_type` 等)合并进同一个 JSON 对象,
|
|
6
|
+
* 必须靠命名空间隔开,否则一次 LLM 幻觉输出 `superseded` 就能伪装成人工裁决。
|
|
7
|
+
*/
|
|
8
|
+
export const SUPERSEDE_METADATA_KEY = 'dsh_superseded';
|
|
9
|
+
const REASONS = ['conflict', 'superseded', 'manual'];
|
|
10
|
+
/**
|
|
11
|
+
* 写入取代标记(返回**新对象**)。
|
|
12
|
+
*
|
|
13
|
+
* 与 `withSourceAnchors` 的差别:锚点"无锚点时不写键",而退场标记**永远写**——
|
|
14
|
+
* 调用它的前提就是"这条记录正在退场",没有"空标记"这种合法输入。
|
|
15
|
+
*/
|
|
16
|
+
export function withSupersedeMarker(metadata, info) {
|
|
17
|
+
const base = metadata ?? {};
|
|
18
|
+
const marker = { at: info.at, reason: info.reason };
|
|
19
|
+
// 可选字段缺省即不写键:空串会让"没有 pairId"与"pairId 是空串"无法区分
|
|
20
|
+
if (info.verdict)
|
|
21
|
+
marker.verdict = info.verdict;
|
|
22
|
+
if (info.pairId)
|
|
23
|
+
marker.pairId = info.pairId;
|
|
24
|
+
if (info.by)
|
|
25
|
+
marker.by = info.by;
|
|
26
|
+
return { ...base, [SUPERSEDE_METADATA_KEY]: marker };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 读回取代标记(读侧唯一入口)。
|
|
30
|
+
*
|
|
31
|
+
* 形状校验从严:缺 `at` 或 `reason` 非法 → 返回 `undefined`(当作**未退场**)。
|
|
32
|
+
* 宁可把一条坏标记的记录当活动记录,也不把它当"已退场"而藏起来——
|
|
33
|
+
* 后者会让记忆**悄悄消失**,正是本功能要根治的病。
|
|
34
|
+
*/
|
|
35
|
+
export function readSupersedeMarker(metadata) {
|
|
36
|
+
if (metadata === null || typeof metadata !== 'object')
|
|
37
|
+
return undefined;
|
|
38
|
+
const raw = metadata[SUPERSEDE_METADATA_KEY];
|
|
39
|
+
if (raw === null || typeof raw !== 'object')
|
|
40
|
+
return undefined;
|
|
41
|
+
const o = raw;
|
|
42
|
+
const at = o.at;
|
|
43
|
+
const reason = o.reason;
|
|
44
|
+
if (typeof at !== 'string' || !at)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (typeof reason !== 'string' || !REASONS.includes(reason))
|
|
47
|
+
return undefined;
|
|
48
|
+
const info = { at, reason: reason };
|
|
49
|
+
if (typeof o.verdict === 'string' && o.verdict)
|
|
50
|
+
info.verdict = o.verdict;
|
|
51
|
+
if (typeof o.pairId === 'string' && o.pairId)
|
|
52
|
+
info.pairId = o.pairId;
|
|
53
|
+
if (typeof o.by === 'string' && o.by)
|
|
54
|
+
info.by = o.by;
|
|
55
|
+
return info;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 去标记(恢复时用)。返回**新对象**。
|
|
59
|
+
*
|
|
60
|
+
* 无标记时返回**原引用**——恢复一条从未退场的记录不该产生任何新对象,
|
|
61
|
+
* 这条判据让"恢复"对活动记录是彻底的 no-op。
|
|
62
|
+
*/
|
|
63
|
+
export function stripSupersedeMarker(metadata) {
|
|
64
|
+
const base = metadata ?? {};
|
|
65
|
+
if (!(SUPERSEDE_METADATA_KEY in base))
|
|
66
|
+
return base;
|
|
67
|
+
const next = { ...base };
|
|
68
|
+
delete next[SUPERSEDE_METADATA_KEY];
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
/** 该记录是否处于**已退场**状态(读主表 `valid_to` 的语义封装)。 */
|
|
72
|
+
export function isRetired(record) {
|
|
73
|
+
return record.validTo !== undefined || readSupersedeMarker(record.metadata) !== undefined;
|
|
74
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -463,10 +463,18 @@ ruminate) {
|
|
|
463
463
|
// memory_delete:显式"忘了 X"——按语义检索命中后删除(高权限门控)。
|
|
464
464
|
ctx.tools.register(defineTool({
|
|
465
465
|
name: 'memory_delete',
|
|
466
|
-
description: '
|
|
466
|
+
description: '退场(软删)与查询相关的记忆(L1)。仅当用户显式要求"忘记/删除某条记忆"时用;需高权限模式开启。' +
|
|
467
|
+
'默认只退场**最贴近的 1 条**;可用 limit 放大(上限 10)。' +
|
|
468
|
+
'退场是**软删**:记录移出检索面但保留在主表,可在记忆列表恢复,不是物理删除。' +
|
|
469
|
+
'已知确切 record_id 时应走 ids 参数(精确退场,不做语义匹配)。',
|
|
467
470
|
parameters: {
|
|
468
|
-
query: { type: 'string',
|
|
469
|
-
|
|
471
|
+
query: { type: 'string', description: '要退场的记忆描述(自然语言,匹配最贴近的现存记忆);给出 ids 时可省略' },
|
|
472
|
+
ids: {
|
|
473
|
+
type: 'array',
|
|
474
|
+
items: { type: 'string' },
|
|
475
|
+
description: '确切的 record_id 列表(给了它就不做语义匹配,只退场这些 id;上限同 limit)',
|
|
476
|
+
},
|
|
477
|
+
limit: { type: 'number', description: '最多退场条数(默认 1,上限 10)' },
|
|
470
478
|
},
|
|
471
479
|
output: {
|
|
472
480
|
schema: {
|
|
@@ -479,27 +487,48 @@ ruminate) {
|
|
|
479
487
|
additionalProperties: false,
|
|
480
488
|
},
|
|
481
489
|
render: (_args, value) => [
|
|
482
|
-
{
|
|
490
|
+
{
|
|
491
|
+
type: 'text',
|
|
492
|
+
text: value.notice ??
|
|
493
|
+
`已退场(软删)${value.deleted ?? 0} 条记忆` +
|
|
494
|
+
(value.ids && value.ids.length ? `:${value.ids.join(',')}` : '') +
|
|
495
|
+
'——它们仍在主表,可在记忆列表恢复',
|
|
496
|
+
},
|
|
483
497
|
],
|
|
484
498
|
},
|
|
485
499
|
execute: async (args, exec) => {
|
|
486
500
|
if (!live.get().memoryMutate)
|
|
487
501
|
return { deleted: 0, ids: [], notice: MUTATE_OFF_NOTICE };
|
|
488
|
-
const query = String(args.query ?? '').trim();
|
|
489
|
-
if (!query)
|
|
490
|
-
return { deleted: 0, ids: [], notice: 'query 为空,未删除' };
|
|
491
502
|
const family = familyOfCaller(exec);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const
|
|
503
|
+
// 默认 1(原为 3):"忘记某条记忆"是一对一的意图,而 limit=3 会顺带退场
|
|
504
|
+
// 两条语义邻近但无关的记忆 —— 实测已发生过一次真实误删。
|
|
505
|
+
const limit = Math.min(Math.max(args.limit ?? 1, 1), 10);
|
|
506
|
+
// 精确路径优先:给了 ids 就**不做语义匹配**。语义匹配的"顺带多删几条"
|
|
507
|
+
// 正是误删的来源,而调用方一旦能给出 id,就没有理由再走模糊匹配。
|
|
508
|
+
const explicit = Array.isArray(args.ids)
|
|
509
|
+
? args.ids.filter((x) => typeof x === 'string' && x.trim() !== '').slice(0, limit)
|
|
510
|
+
: [];
|
|
511
|
+
let ids;
|
|
512
|
+
if (explicit.length > 0) {
|
|
513
|
+
ids = explicit;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
const query = String(args.query ?? '').trim();
|
|
517
|
+
if (!query)
|
|
518
|
+
return { deleted: 0, ids: [], notice: 'query 与 ids 均为空,未删除' };
|
|
519
|
+
const hits = await stores.l1.search(query, limit, {
|
|
520
|
+
family: family && family !== null ? family : undefined,
|
|
521
|
+
workspaceId: scopeFilterOf(cfg.scope, exec),
|
|
522
|
+
});
|
|
523
|
+
ids = hits.map((h) => h.id);
|
|
524
|
+
}
|
|
498
525
|
if (ids.length === 0)
|
|
499
526
|
return { deleted: 0, ids: [], notice: '未找到匹配的记忆,未删除' };
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
527
|
+
// **软删**(退场),不是物理删除:与裁决 / 取代共用同一原语,故"删错了"
|
|
528
|
+
// 可以在记忆列表里恢复,而不必去 records/*.jsonl 事实源手工捞。
|
|
529
|
+
const n = stores.l1.retire(ids, { at: new Date().toISOString(), reason: 'manual' });
|
|
530
|
+
logger.info(`[memory] 高权限退场(软删)记忆 ${n} 条(${ids.join(',')})`);
|
|
531
|
+
return { deleted: n, ids };
|
|
503
532
|
},
|
|
504
533
|
}));
|
|
505
534
|
// ── 图谱工具(读;受与 memory_search 同款的档位/注入拒读门 + 族过滤) ──
|