@modusensus/dsh-mneme 0.8.6 → 0.8.7
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/README.md +18 -148
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +289 -59
- package/lib/config.js +54 -6
- package/lib/content-hash.js +54 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/index.js +50 -11
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +2 -1
- package/lib/store.js +231 -21
- package/lib/tools.js +27 -9
- package/lib/write-admission.js +259 -0
- package/package.json +4 -4
- package/src/api-standalone.js +57 -1
- package/src/api.js +34 -11
- package/src/config.js +54 -6
- package/src/content-hash.js +54 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/index.js +50 -11
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +2 -1
- package/src/store.js +231 -21
- package/src/tools.js +27 -9
- package/src/write-admission.js +259 -0
- package/test/api.test.js +12 -0
- package/test/client.test.js +90 -5
- package/test/content-hash.test.js +204 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/llm-audit.test.js +42 -0
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/write-admission.test.js +345 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// #254 写入准入的确定性计量锚:内容归一化哈希(exact duplicate 判定)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么用哈希而不是相似度:计量阶段要的是一个零成本、可复算、不留争议的判据。
|
|
4
|
+
// 相似度阈值两边都是误判——同一件事换个说法就掉到线下,不同的事共享话题词又顶到
|
|
5
|
+
// 线上,阈值往哪边挪都只是换一种错法(#254 拍板:计量信号用内容哈希)。哈希只认
|
|
6
|
+
// 「归一化后逐字节相同」,算不出来就只有一种解释。
|
|
7
|
+
//
|
|
8
|
+
// 但它不等于「原文逐字节相同」:归一化会折掉格式,也会折掉标点,于是 `版本 3.5` 与
|
|
9
|
+
// `版本 35`、`a-b` 与 `ab` 落同一个键。折叠范围(大小写 / 空白 / 标点)是口径本身
|
|
10
|
+
// 定的,#254 已拍板——误报面在计量阶段可接受(只标记、不拦截),但「命中即精确重复」
|
|
11
|
+
// 这句话别升级成「命中即内容相同」。要更窄的判据就得改归一化口径,而那要带存量重算。
|
|
12
|
+
//
|
|
13
|
+
// 归一化口径一次定死(NFKC → 小写 → 去标点 → 空白折叠 → trim),动机是让「只差
|
|
14
|
+
// 格式」的两次写入落在同一个键上:全角/半角、大小写、行内换行与缩进、句末标点都
|
|
15
|
+
// 不该让同一件事变成两条。反过来它不折叠同义词、不排序词序——那些只能靠相似度,
|
|
16
|
+
// 而相似度已判定不可用于本信号。
|
|
17
|
+
//
|
|
18
|
+
// 改口径必须带存量重算策略:memories.content_hash 是派生列,口径一变,库里已有的
|
|
19
|
+
// 值全部按旧口径算——补 NULL 修不好,只能整列重算(UPDATE 全表 + 重建索引)。
|
|
20
|
+
// 所以口径只能在这里改,且改要单独成批,不要混进别的改动里。
|
|
21
|
+
//
|
|
22
|
+
// 空内容(标题与正文归一后都为空)返回 null:这类写入没有可比内容,落 NULL 让它们
|
|
23
|
+
// 不互相匹配成「全是重复」。
|
|
24
|
+
//
|
|
25
|
+
// 另有一处形状相近、口径不同的哈希:service.js 里镜像人工编辑的 digest 校验(两处)
|
|
26
|
+
// 用 sha256(title\0content),那里**不**归一化(要逐字节保真)。两把哈希用途不同,别互换。
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 归一化一段文本用于内容比对(口径见文件头)。
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeForHash(value) {
|
|
35
|
+
return String(value ?? "")
|
|
36
|
+
.normalize("NFKC")
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/\p{P}/gu, "")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 一行记忆的内容锚:标题与正文分别归一后用 NUL 拼接再取 sha256(十六进制全串)。
|
|
45
|
+
* NUL 分隔是防拼接歧义——('ab','c') 与 ('a','bc') 不能落到同一个键上。
|
|
46
|
+
* @param {{title?: string, content?: string}|null|undefined} memory
|
|
47
|
+
* @returns {string|null} 归一后无内容时返回 null
|
|
48
|
+
*/
|
|
49
|
+
export function contentHashOf(memory) {
|
|
50
|
+
const title = normalizeForHash(memory?.title);
|
|
51
|
+
const content = normalizeForHash(memory?.content);
|
|
52
|
+
if (!title && !content) return null;
|
|
53
|
+
return createHash("sha256").update(`${title}\u0000${content}`).digest("hex");
|
|
54
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// dsh-mneme/src/document-index.js
|
|
2
|
+
// documentDir 的索引(issue #296 第二批)。`<documentDir>/index.md` 整文件机器所有,
|
|
3
|
+
// 可从库随时重建:内容里没有任何「生成时间」,所以删掉再同步一次得到逐字节相同的
|
|
4
|
+
// 文件(test/document-dir.test.js 钉住这一条)。
|
|
5
|
+
//
|
|
6
|
+
// 与镜像侧 documents.md 的分工(#296 第 2 节表格):这里是 document 子系统自己的
|
|
7
|
+
// 索引(id + 标题 + 定位 + managed 标记),那份是带 frontmatter 的镜像只读视图
|
|
8
|
+
// (多一个摘要首句)。两份都只列指针行,都不含正文——正文永远在 doc_path 指向的
|
|
9
|
+
// 文件里,那是 agent 的东西。
|
|
10
|
+
import { readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { STR } from "./lang.js";
|
|
13
|
+
import { isManagedDocumentPath } from "./document.js";
|
|
14
|
+
|
|
15
|
+
function oneLine(text) {
|
|
16
|
+
return String(text ?? "").replace(/\s+/g, " ").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} dir resolved documentDir (see resolveDocumentDir)
|
|
21
|
+
* @param {"zh"|"en"} language instance language, same as the mirror
|
|
22
|
+
*/
|
|
23
|
+
export function createDocumentIndex(dir, language = "zh") {
|
|
24
|
+
const filePath = join(dir, "index.md");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the whole file from the given rows. Sorted updated_at DESC, id ASC:
|
|
28
|
+
* the same store state must always render the same bytes (rebuildable).
|
|
29
|
+
*/
|
|
30
|
+
function render(memories) {
|
|
31
|
+
const items = (memories ?? []).slice().sort((a, b) => {
|
|
32
|
+
if (a.updated_at !== b.updated_at) return a.updated_at < b.updated_at ? 1 : -1;
|
|
33
|
+
return a.id < b.id ? -1 : 1;
|
|
34
|
+
});
|
|
35
|
+
const lines = [STR.documentIndexHeader[language]()];
|
|
36
|
+
for (const m of items) {
|
|
37
|
+
const where = isManagedDocumentPath(dir, m.doc_path) ? "managed" : "external";
|
|
38
|
+
// doc_path 理论上必带(store.saveDocument 是唯一铸造口),缺了就只写定位
|
|
39
|
+
// 标记,不编造路径。
|
|
40
|
+
const path = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
|
|
41
|
+
lines.push(`- \`${m.id}\` **${oneLine(m.title)}** · ${where}${path}`);
|
|
42
|
+
}
|
|
43
|
+
return `${lines.join("\n")}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ensure the directory exists (recursive). Never throws: callers warn and move
|
|
48
|
+
* on — a managed directory that cannot be created must not fail plugin boot.
|
|
49
|
+
*/
|
|
50
|
+
function ensure() {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
return { ok: true };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write the index if it changed. Never throws: a failed index write must not
|
|
61
|
+
* fail the business write that triggered it (same fail-safe shape as
|
|
62
|
+
* mirror.sync). Returns { ok, changed } or { ok: false, error }.
|
|
63
|
+
*/
|
|
64
|
+
function sync(memories) {
|
|
65
|
+
const text = render(memories);
|
|
66
|
+
let prev = null;
|
|
67
|
+
try { prev = readFileSync(filePath, "utf8"); } catch { /* first write */ }
|
|
68
|
+
if (prev === text) return { ok: true, changed: false };
|
|
69
|
+
const ready = ensure();
|
|
70
|
+
if (!ready.ok) return { ok: false, error: ready.error };
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(filePath, text, "utf8");
|
|
73
|
+
return { ok: true, changed: true };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drop the index file (闸关时用)。闸关意味着 document 子系统整体退出,留一份
|
|
81
|
+
* 陈旧索引会列出已归档、已遗忘的指针行——正是 documents.md 在闸关时被镜像删掉
|
|
82
|
+
* 要避免的那种「看着还在、其实已关」的视图。删掉不从库取数,O(1),也不抛。
|
|
83
|
+
*/
|
|
84
|
+
function remove() {
|
|
85
|
+
try {
|
|
86
|
+
rmSync(filePath, { force: true });
|
|
87
|
+
return { ok: true };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { filePath, render, sync, ensure, remove };
|
|
94
|
+
}
|
package/src/document.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { statSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
17
17
|
import { scopeKeyOf } from "./scope.js";
|
|
18
18
|
import { cosineSimilarity } from "./dream/clustering.js";
|
|
19
19
|
|
|
@@ -23,26 +23,71 @@ import { cosineSimilarity } from "./dream/clustering.js";
|
|
|
23
23
|
// 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
|
|
24
24
|
const DOCUMENT_MIN_SIM = 0.92;
|
|
25
25
|
const CANDIDATE_LIMIT = 200;
|
|
26
|
+
const DOCUMENT_DIR_NAME = "documents";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
|
|
30
|
+
* `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
|
|
31
|
+
* memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
|
|
32
|
+
* 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
|
|
33
|
+
*/
|
|
34
|
+
export function resolveDocumentDir(memoryDir, configured = "") {
|
|
35
|
+
const raw = String(configured ?? "").trim();
|
|
36
|
+
if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
|
|
37
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
38
|
+
return resolve(homedir(), raw.slice(2));
|
|
39
|
+
}
|
|
40
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
|
|
45
|
+
* 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
|
|
46
|
+
* Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
|
|
47
|
+
* `c:\A\Documents\x.md` 会被判成两类。
|
|
48
|
+
*/
|
|
49
|
+
export function isManagedDocumentPath(dir, path) {
|
|
50
|
+
if (!dir || !path) return false;
|
|
51
|
+
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
52
|
+
const base = norm(resolve(dir));
|
|
53
|
+
const target = norm(resolve(path));
|
|
54
|
+
const prefix = base.endsWith(sep) ? base : base + sep;
|
|
55
|
+
return target === base || target.startsWith(prefix);
|
|
56
|
+
}
|
|
26
57
|
|
|
27
58
|
// 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
|
|
28
59
|
// 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
|
|
29
60
|
// id + 旧行 content_history 里的旧摘要(source=superseded)。
|
|
30
61
|
const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
|
|
31
62
|
|
|
63
|
+
// 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
|
|
64
|
+
// 行各有自己的生命周期,被归档会各自留下第二行——
|
|
65
|
+
// document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
|
|
66
|
+
// 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
|
|
67
|
+
// 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
|
|
68
|
+
// summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
|
|
69
|
+
// 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
|
|
70
|
+
// 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
|
|
71
|
+
const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
|
|
72
|
+
|
|
32
73
|
/**
|
|
33
74
|
* Build the document registrar. Injected deps are service.js closure members
|
|
34
75
|
* (store / embedQuery / transaction / finalize) so this module stays free of
|
|
35
76
|
* service-internal wiring; `finalize` is the single write epilogue (mirror
|
|
36
77
|
* sync + notify + re-embed) so callers never duplicate it.
|
|
37
78
|
*
|
|
38
|
-
*
|
|
79
|
+
* `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
|
|
80
|
+
* 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
|
|
81
|
+
* 方向反了会成环。
|
|
82
|
+
*
|
|
83
|
+
* @returns {(payload: object, opts?: object) => Promise<object>}
|
|
39
84
|
* `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
|
|
40
|
-
* evidence_dropped, degraded }`
|
|
85
|
+
* evidence_dropped, evidence_archived, degraded }`
|
|
41
86
|
* @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
|
|
42
87
|
* 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
|
|
43
88
|
*/
|
|
44
|
-
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize }) {
|
|
45
|
-
return async function registerDocument(payload, { hiddenEvidenceIds = [] } = {}) {
|
|
89
|
+
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
|
|
90
|
+
return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
|
|
46
91
|
// opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
|
|
47
92
|
// 指回配置键,agent 能准确转告用户去开。
|
|
48
93
|
if (config?.documentMemoryEnabled !== true) {
|
|
@@ -90,15 +135,14 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
90
135
|
const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
|
|
91
136
|
const kept = [];
|
|
92
137
|
const dropped = [];
|
|
138
|
+
// 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
|
|
139
|
+
// supersede 目标探测后的「认回」),不能一律按「不可用」处理。
|
|
140
|
+
const archivedRefs = new Set();
|
|
93
141
|
for (const id of wanted) {
|
|
94
142
|
const row = hidden.has(id) ? null : store.getById(id);
|
|
143
|
+
if (row?.archived) archivedRefs.add(id);
|
|
95
144
|
(row && !row.archived ? kept : dropped).push(id);
|
|
96
145
|
}
|
|
97
|
-
if (wanted.length > 0 && kept.length === 0) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
146
|
|
|
103
147
|
const title = String(payload?.title ?? "").trim();
|
|
104
148
|
if (!title) throw new Error("registerDocument: title is required");
|
|
@@ -131,6 +175,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
131
175
|
.filter((m) => scopeMatches(m));
|
|
132
176
|
const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
|
|
133
177
|
?? scopeMatched.find((m) => m.title.trim() === title);
|
|
178
|
+
// 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
|
|
179
|
+
// archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
|
|
180
|
+
// dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
|
|
181
|
+
// **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
|
|
182
|
+
// 背书;捏造与跨 scope 照旧拒绝。
|
|
183
|
+
if (explicitTarget) {
|
|
184
|
+
const own = new Set(explicitTarget.evidence ?? []);
|
|
185
|
+
for (const id of archivedRefs) {
|
|
186
|
+
if (!own.has(id)) continue;
|
|
187
|
+
const at = dropped.indexOf(id);
|
|
188
|
+
if (at >= 0) dropped.splice(at, 1);
|
|
189
|
+
kept.push(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (wanted.length > 0 && kept.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
134
197
|
if (!explicitTarget) {
|
|
135
198
|
// vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
|
|
136
199
|
// (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
|
|
@@ -206,7 +269,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
206
269
|
store.setArchived(loser.id, true);
|
|
207
270
|
superseded = store.getById(loser.id);
|
|
208
271
|
}
|
|
209
|
-
|
|
272
|
+
// #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
|
|
273
|
+
// 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
|
|
274
|
+
// 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
|
|
275
|
+
// 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
|
|
276
|
+
// ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
|
|
277
|
+
// (可恢复),opt-out 走 archiveEvidence。
|
|
278
|
+
// pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
279
|
+
let evidenceArchived = 0;
|
|
280
|
+
if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
|
|
281
|
+
for (const id of kept) {
|
|
282
|
+
// 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
|
|
283
|
+
const row = store.getById(id);
|
|
284
|
+
if (!row || row.archived || row.forgotten) continue;
|
|
285
|
+
if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
|
|
286
|
+
store.setArchived(id, true);
|
|
287
|
+
evidenceArchived += 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { created, superseded, evidenceArchived };
|
|
210
291
|
});
|
|
211
292
|
finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
|
|
212
293
|
return {
|
|
@@ -215,6 +296,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
215
296
|
...(result.superseded ? { superseded: result.superseded } : {}),
|
|
216
297
|
evidence_kept: kept.length,
|
|
217
298
|
evidence_dropped: dropped.length,
|
|
299
|
+
evidence_archived: result.evidenceArchived,
|
|
218
300
|
degraded: dropped.length > 0
|
|
219
301
|
};
|
|
220
302
|
};
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createStore } from "./store.js";
|
|
2
2
|
import { createMirror, TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { createDocumentIndex } from "./document-index.js";
|
|
4
|
+
import { resolveDocumentDir } from "./document.js";
|
|
3
5
|
import { createService } from "./service.js";
|
|
6
|
+
// #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。
|
|
7
|
+
import { createWriteAdmission } from "./write-admission.js";
|
|
4
8
|
import { createTools } from "./tools.js";
|
|
5
9
|
import { createInjector } from "./inject.js";
|
|
6
10
|
import { createSummarizer } from "./summarize.js";
|
|
@@ -8,6 +12,8 @@ import { createDreamScheduler } from "./dream.js";
|
|
|
8
12
|
import { createSleepScheduler, runSleep } from "./dream/sleep.js";
|
|
9
13
|
import { createApi } from "./api.js";
|
|
10
14
|
import { createStandaloneApi } from "./api-standalone.js";
|
|
15
|
+
// #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
|
|
16
|
+
import { createMaintenance } from "./maintenance.js";
|
|
11
17
|
import { createSettings } from "./settings.js";
|
|
12
18
|
import { createCommandManager } from "./commands.js";
|
|
13
19
|
import { createEmbedder } from "./embedding.js";
|
|
@@ -193,15 +199,6 @@ export const apply = (ctx, config) => {
|
|
|
193
199
|
try {
|
|
194
200
|
store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
|
|
195
201
|
} catch { /* non-fatal */ }
|
|
196
|
-
// Bug8: enforce llm_audit_logs retention on boot (config.llmAudit.retentionDays,
|
|
197
|
-
// default 90). Best-effort like the failure prune — the audit trail is
|
|
198
|
-
// bookkeeping and a failed purge must never block plugin boot.
|
|
199
|
-
try {
|
|
200
|
-
if (rawCfg.llmAudit?.enabled !== false) {
|
|
201
|
-
const retentionMs = Number.isInteger(rawCfg.llmAudit?.retentionDays) ? rawCfg.llmAudit.retentionDays : 90;
|
|
202
|
-
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
203
|
-
}
|
|
204
|
-
} catch { /* non-fatal */ }
|
|
205
202
|
|
|
206
203
|
// User-configurable settings (profile, rules, panel mode, standalone API
|
|
207
204
|
// token) share the same SQLite file in dedicated tables, isolated from
|
|
@@ -240,10 +237,46 @@ export const apply = (ctx, config) => {
|
|
|
240
237
|
cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
|
|
241
238
|
}
|
|
242
239
|
|
|
240
|
+
// Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
|
|
241
|
+
// cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
|
|
242
|
+
// rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
|
|
243
|
+
// (raw 说关 → 不清理,cfg 说开 → 照写,审计表就无保留期地长)。保留期默认 90 天、
|
|
244
|
+
// 失败只 warn,与失败表清理同款:账本清理绝不许挡住插件启动。
|
|
245
|
+
try {
|
|
246
|
+
if (cfg.llmAudit?.enabled !== false) {
|
|
247
|
+
const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
|
|
248
|
+
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
249
|
+
}
|
|
250
|
+
} catch { /* non-fatal */ }
|
|
251
|
+
|
|
243
252
|
// 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
|
|
244
253
|
// sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
|
|
245
254
|
const mirror = createMirror(memoryDir, langOf(cfg));
|
|
246
|
-
|
|
255
|
+
|
|
256
|
+
// #296 第二批:managed 文档目录。解析与 memoryDir 同一套(空 = 跟随 memoryDir 的
|
|
257
|
+
// <memoryDir>/documents/,~ 展开,相对路径落在 memoryDir 下)。索引对象总是建:
|
|
258
|
+
// 闸关时它只承担「清掉陈旧 index.md」这一件事,与 documents.md 在闸关时被镜像删
|
|
259
|
+
// 掉同一口径。建目录与写索引失败都只 warn,不阻断插件加载(它们是机器产物)。
|
|
260
|
+
const documentDir = resolveDocumentDir(memoryDir, cfg.documentDir);
|
|
261
|
+
const documentIndex = createDocumentIndex(documentDir, langOf(cfg));
|
|
262
|
+
if (cfg.documentMemoryEnabled === true) {
|
|
263
|
+
const ensured = documentIndex.ensure();
|
|
264
|
+
if (!ensured.ok) {
|
|
265
|
+
ctx.logger?.warn?.(`[dsh-mneme] documentDir is not writable: ${documentDir}: ${ensured.error}`);
|
|
266
|
+
}
|
|
267
|
+
// 启动即渲染一次:索引不该等到第一次业务写才存在(删掉它之后重启也能自愈)。
|
|
268
|
+
const synced = documentIndex.sync(store.list({ type: "document", limit: null }));
|
|
269
|
+
if (!synced.ok) ctx.logger?.warn?.(`[dsh-mneme] document index sync failed: ${synced.error}`);
|
|
270
|
+
} else {
|
|
271
|
+
documentIndex.remove();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 写入准入实例:本批次只做计量(决策恒放行、写审计行),所以不需要新开关——它
|
|
275
|
+
// 不改变任何写入行为,也不新增拦截分支;既有的 llmAudit.enabled 关掉时它同样
|
|
276
|
+
// 不写(那个开关连审计行的启动期清理一起关掉)。第二阶段把拦截打开时才按仓库
|
|
277
|
+
// 惯例引入 opt-in 默认关的配置键,届时只改这一个实例的构造与 service 的调用点。
|
|
278
|
+
const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
|
|
279
|
+
const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
|
|
247
280
|
|
|
248
281
|
// F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
|
|
249
282
|
// a safe re-render at boot so a stale mirror converges without needing a
|
|
@@ -549,13 +582,19 @@ export const apply = (ctx, config) => {
|
|
|
549
582
|
disposers.push(api.dispose);
|
|
550
583
|
}
|
|
551
584
|
|
|
585
|
+
// #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
|
|
586
|
+
// 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
|
|
587
|
+
// 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
|
|
588
|
+
// 抢锁的那条路走不通)。
|
|
589
|
+
const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
|
|
590
|
+
|
|
552
591
|
// Standalone external API (v0.7.12): plain node:http server for ecosystem
|
|
553
592
|
// integrations outside the DSH host. Persisted external_api settings win
|
|
554
593
|
// over the bundle config (enabled/port); the Bearer token lives in the same
|
|
555
594
|
// kv and is auto-generated on first boot by createStandaloneApi. Binding a
|
|
556
595
|
// non-loopback host is the operator's documented responsibility.
|
|
557
596
|
if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
|
|
558
|
-
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings });
|
|
597
|
+
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings, maintenance });
|
|
559
598
|
disposers.push(() => standalone.server.close());
|
|
560
599
|
standalone.ready.catch((error) => {
|
|
561
600
|
ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
|
package/src/inject.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHotMemory } from "./hot-memory.js";
|
|
|
3
3
|
import { STR, langOf } from "./lang.js";
|
|
4
4
|
import { MEMORY_GUIDE_SECTION } from "./guide.js";
|
|
5
5
|
import { adaptiveInjectBudget } from "./search/adaptive.js";
|
|
6
|
+
import { injectChildEnabled } from "./config.js";
|
|
6
7
|
|
|
7
8
|
// Issue #179:注入预览的数据底座。systemPrompt 渲染是同步回调,面板只能事后
|
|
8
9
|
// 拉取,所以在这里旁路缓存「最近一帧组装」——快照就是本次渲染用过的同一份
|
|
@@ -283,12 +284,15 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
283
284
|
return escapePromptVars(lines.join("\n"));
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
// #249
|
|
287
|
+
// #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例
|
|
287
288
|
// (ACP 的 ACP_SYSTEM_PROMPT 与 mnemon 的 routing 段都在 150)。文本是常量、
|
|
288
289
|
// 同会话内不随轮次变化,故不影响其后的前缀缓存,也不逐轮复读。宿主若不提供
|
|
289
290
|
// section seam 就静默跳过——#249 §3 的口径是「只用现成 seam,拿不到位的语义
|
|
290
291
|
// 降级处理」:能力说明仍落在工具描述上,不算失败。
|
|
291
|
-
|
|
292
|
+
// 走父/子闸门(#249 第二批):注入器本身只在 `autoInject` 开时挂载,这里是
|
|
293
|
+
// 第二道、也是同一个判据的显式落点——两处消费点共用 injectChildEnabled,
|
|
294
|
+
// 不在各自的地方重写「父关则子不生效」。
|
|
295
|
+
const guideSection = injectChildEnabled(config, "injectGuidanceEnabled") && typeof ctx.systemPrompt?.section === "function"
|
|
292
296
|
? ctx.systemPrompt.section({ name: "memory-guide", order: 150, text: MEMORY_GUIDE_SECTION })
|
|
293
297
|
: null;
|
|
294
298
|
|
package/src/lang.js
CHANGED
|
@@ -196,12 +196,23 @@ export const STR = {
|
|
|
196
196
|
|
|
197
197
|
// --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
|
|
198
198
|
mirrorLabel: {
|
|
199
|
-
zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源" },
|
|
200
|
-
en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source" }
|
|
199
|
+
zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源", scope: "作用域", sensitivity: "敏感度" },
|
|
200
|
+
en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source", scope: "Scope", sensitivity: "Sensitivity" }
|
|
201
201
|
},
|
|
202
202
|
mirrorHeader: {
|
|
203
|
-
zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!--
|
|
204
|
-
en: (name) => `# ${name} — dsh-mneme mirror\n\n<!--
|
|
203
|
+
zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!-- 条目标题与正文可编辑,会被合并回记忆库(人工优先);文件头与条目元数据行由机器维护,改动会在下次同步时被覆盖。 -->\n\n`,
|
|
204
|
+
en: (name) => `# ${name} — dsh-mneme mirror\n\n<!-- Entry titles and bodies are editable and merged back into the memory store (human edits win); the file header and entry metadata lines are machine-owned and get overwritten on the next sync. -->\n\n`
|
|
205
|
+
},
|
|
206
|
+
// document 的镜像只有指针行(#296 第二批):没有可编辑的正文,手工改动一律被
|
|
207
|
+
// 下次同步覆盖——所以不能复用上面那句「可编辑、会被合并回记忆库」。
|
|
208
|
+
mirrorReadonlyHeader: {
|
|
209
|
+
zh: (name) => `# ${name} — dsh-mneme 只读视图\n\n<!-- 只含指针行:id + 标题 + 摘要首句 + 文件路径,不含正文。正文在路径指向的文件里,批注请写进记忆库;本文件由机器维护,手工改动会在下次同步时被覆盖。 -->\n\n`,
|
|
210
|
+
en: (name) => `# ${name} — dsh-mneme read-only view\n\n<!-- Pointer rows only: id + title + first sentence of the summary + file path, never the full text. The document itself lives at that path, annotations belong in the memory store, and this file is machine-owned: hand edits are overwritten on the next sync. -->\n\n`
|
|
211
|
+
},
|
|
212
|
+
// documentDir 的 index.md(#296 第二批):整文件机器所有、可从库重建。
|
|
213
|
+
documentIndexHeader: {
|
|
214
|
+
zh: () => "# document 索引 — dsh-mneme\n\n<!-- 整文件机器所有,可从记忆库随时重建(所以这里不写生成时间);要批注请写进记忆库(memory_save)。managed = 文件在 documentDir 内。 -->\n\n",
|
|
215
|
+
en: () => "# document index — dsh-mneme\n\n<!-- Machine-owned as a whole and rebuildable from the memory store at any time (which is why it carries no generation timestamp). Annotations belong in the memory store (memory_save). managed = the file sits inside documentDir. -->\n\n"
|
|
205
216
|
},
|
|
206
217
|
|
|
207
218
|
prompts: PROMPTS
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// #275 存储生命周期第一批:无损回收的维护入口。
|
|
2
|
+
//
|
|
3
|
+
// 为什么是「手动入口 + dry-run + receipt」而不是后台任务:这两步都是不可逆的内容
|
|
4
|
+
// 丢弃(历史 run 的输入快照置空、归档行的向量置空),该由人在看得见数字的时候按下
|
|
5
|
+
// (维护者 2026-09-23 拍板:不挂启动自动跑,VACUUM 约 0.8s 说明它就该是个随手跑的
|
|
6
|
+
// 东西)。所以模块里没有任何定时器,挂点只有两个:standalone 数据面的
|
|
7
|
+
// `POST /maintenance/reclaim` 与它上面的 `dsh-mneme reclaim` 子命令。
|
|
8
|
+
//
|
|
9
|
+
// 两项都零价值判断、零条数变化:
|
|
10
|
+
// A `dream_runs.input` 置空。这一列的含义按 run_type 分叉:auto / sleep 的 run 存的
|
|
11
|
+
// 是当时的记忆库快照,可由记忆库重建;**organize 的 run 存的是 apply 的重放载荷**,
|
|
12
|
+
// 置空会让那次 apply 静默空转并把报告锁死,所以 organize 行不在可清范围内(谓词
|
|
13
|
+
// 在 store.dreamRunInputStats / clearDreamRunInputs 里)。run 的骨架、LLM 决策原文
|
|
14
|
+
// 与 receipt 一律保留,行永不删(拍板 3:裁列同意、删行不同意)。
|
|
15
|
+
// B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是
|
|
16
|
+
// 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。
|
|
17
|
+
//
|
|
18
|
+
// 报告口径(拍板 2):收益按 VACUUM 前后体积量,不按列字节估——实际释放来自溢出页与
|
|
19
|
+
// 索引页回收,列文本大小只是上界,所以两个数分开报,不混成一个「省了多少」。
|
|
20
|
+
//
|
|
21
|
+
// 与 #254 的分界(拍板 4):本入口止体积、不止重复。归档区里同主题堆积是出口问题,
|
|
22
|
+
// 归 #254 的写入准入与后续的整理动作面,这里一条记忆都不碰。
|
|
23
|
+
export const RECLAIM_TRIGGER_SOURCE = "maintenance";
|
|
24
|
+
export const RECLAIM_OPERATION_TYPE = "storage_reclaim";
|
|
25
|
+
// 输入快照的默认保留窗口:7 天内仍可能被离线回放(近期 run 的决策上下文),更早的
|
|
26
|
+
// 重建成本低于留存成本。窗口由调用方显式给,不做成配置键——配置键意味着有个周期性
|
|
27
|
+
// 消费者,而本批次刻意不接周期动作。
|
|
28
|
+
export const DEFAULT_INPUT_RETENTION_DAYS = 7;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 无损回收(#275 第一批)。返回一个函数:同一条代码路径跑 dry-run 与执行,dry-run
|
|
32
|
+
* 只是不落 UPDATE/VACUUM,所以「报出来的数字」与「执行时的数字」出自同一处统计。
|
|
33
|
+
*
|
|
34
|
+
* @param {object} deps
|
|
35
|
+
* @param {object} deps.store 存储层(dreamRunInputStats / clearDreamRunInputs /
|
|
36
|
+
* archivedEmbeddingStats / clearArchivedEmbeddings / storageStats / vacuum / saveLlmAudit)
|
|
37
|
+
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled:关掉 audit 时不落 receipt)
|
|
38
|
+
* @param {object} [deps.logger]
|
|
39
|
+
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
40
|
+
*/
|
|
41
|
+
export function createMaintenance({ store, config, logger, now = Date.now } = {}) {
|
|
42
|
+
const warn = (msg) => {
|
|
43
|
+
try {
|
|
44
|
+
logger?.warn?.(msg);
|
|
45
|
+
} catch { /* 日志故障不能影响回收本身 */ }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** receipt 落到 llm_audit_logs(既有 bookkeeping 表,不触发写钩子)。 */
|
|
49
|
+
function writeReceipt(report) {
|
|
50
|
+
if (config?.llmAudit?.enabled === false) return null;
|
|
51
|
+
try {
|
|
52
|
+
const row = store.saveLlmAudit({
|
|
53
|
+
trigger_source: RECLAIM_TRIGGER_SOURCE,
|
|
54
|
+
operation_type: RECLAIM_OPERATION_TYPE,
|
|
55
|
+
// 不是模型调用,但该列 NOT NULL——用显式占位串,免得混进按路由分组的花费统计。
|
|
56
|
+
model_id: "-",
|
|
57
|
+
status: "success",
|
|
58
|
+
metadata: {
|
|
59
|
+
older_than_days: report.olderThanDays,
|
|
60
|
+
cleared: report.cleared,
|
|
61
|
+
size_before: report.size.before_bytes,
|
|
62
|
+
size_after: report.size.after_bytes,
|
|
63
|
+
vacuum: report.vacuum.ran,
|
|
64
|
+
duration_ms: report.duration_ms
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return row?.id ?? null;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// 回执失败只 warn:清理已经发生了,不能因为记不上账就当作没发生(也不能反噬)。
|
|
70
|
+
warn(`[dsh-mneme] storage reclaim receipt failed: ${String(e)}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} [opts]
|
|
77
|
+
* @param {number} [opts.olderThanDays] 输入快照的保留窗口(天);0 = 清掉全部快照
|
|
78
|
+
* @param {boolean} [opts.vacuum] 是否在清理后 VACUUM(默认否:它要排他锁,由人决定)
|
|
79
|
+
* @param {boolean} [opts.dryRun] 默认 true——只报数字,不改一个字节
|
|
80
|
+
* @returns {object} 报告(dry-run 与执行同形状,执行时多 receipt 字段)
|
|
81
|
+
*/
|
|
82
|
+
function reclaim({ olderThanDays = DEFAULT_INPUT_RETENTION_DAYS, vacuum = false, dryRun = true } = {}) {
|
|
83
|
+
const days = Number.isFinite(olderThanDays) && olderThanDays >= 0 ? olderThanDays : DEFAULT_INPUT_RETENTION_DAYS;
|
|
84
|
+
const cutoff = new Date(now() - days * 86400000).toISOString();
|
|
85
|
+
const started = now();
|
|
86
|
+
const before = store.storageStats();
|
|
87
|
+
const inputs = store.dreamRunInputStats(cutoff);
|
|
88
|
+
const embeddings = store.archivedEmbeddingStats();
|
|
89
|
+
const report = {
|
|
90
|
+
dryRun,
|
|
91
|
+
olderThanDays: days,
|
|
92
|
+
cutoff,
|
|
93
|
+
// planned = 这次会动几行;cleared = 实际动了几行(dry-run 恒 0)
|
|
94
|
+
planned: { dream_run_inputs: inputs.runs, archived_embeddings: embeddings.rows },
|
|
95
|
+
// 列文本大小:上界,不是回收量(真实释放看下面的 size 差值)
|
|
96
|
+
column_bytes: { dream_run_inputs: inputs.bytes, archived_embeddings: embeddings.bytes },
|
|
97
|
+
cleared: { dream_run_inputs: 0, archived_embeddings: 0 },
|
|
98
|
+
size: {
|
|
99
|
+
before_bytes: before.fileBytes,
|
|
100
|
+
after_bytes: before.fileBytes,
|
|
101
|
+
// 空闲页就是「现在 VACUUM 能立刻收回来的那部分」,带上它 dry-run 才有个能
|
|
102
|
+
// 参照的量(真实数字仍然只有做完才知道)
|
|
103
|
+
freelist_pages: before.freelistCount,
|
|
104
|
+
freelist_bytes: before.freelistCount * before.pageSize,
|
|
105
|
+
page_count: before.pageCount
|
|
106
|
+
},
|
|
107
|
+
vacuum: { requested: vacuum === true, ran: false, duration_ms: null, error: null },
|
|
108
|
+
receipt: null,
|
|
109
|
+
duration_ms: 0
|
|
110
|
+
};
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
report.duration_ms = now() - started;
|
|
113
|
+
return report;
|
|
114
|
+
}
|
|
115
|
+
report.cleared.dream_run_inputs = store.clearDreamRunInputs(cutoff);
|
|
116
|
+
report.cleared.archived_embeddings = store.clearArchivedEmbeddings();
|
|
117
|
+
if (report.vacuum.requested) {
|
|
118
|
+
// VACUUM 失败不能把前面的清理一起吞掉:两个 UPDATE 已经各自提交了,此时抛出去
|
|
119
|
+
// 就是「数据已经不可逆地清了,但没有任何回执」。所以失败记进报告、回执照写,
|
|
120
|
+
// 让调用方看到「清了 N 行,VACUUM 没跑成」——锁被别的进程占着(SQLITE_BUSY)是
|
|
121
|
+
// 最常见的成因,重跑一次 --apply --vacuum 即可(清理是幂等的)。
|
|
122
|
+
try {
|
|
123
|
+
const result = store.vacuum();
|
|
124
|
+
report.vacuum.ran = true;
|
|
125
|
+
report.vacuum.duration_ms = result.duration_ms;
|
|
126
|
+
} catch (e) {
|
|
127
|
+
report.vacuum.error = String(e);
|
|
128
|
+
warn(`[dsh-mneme] storage reclaim vacuum failed: ${String(e)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const after = store.storageStats();
|
|
132
|
+
report.size.after_bytes = after.fileBytes;
|
|
133
|
+
report.size.freelist_pages = after.freelistCount;
|
|
134
|
+
report.size.freelist_bytes = after.freelistCount * after.pageSize;
|
|
135
|
+
report.size.page_count = after.pageCount;
|
|
136
|
+
report.duration_ms = now() - started;
|
|
137
|
+
report.receipt = writeReceipt(report);
|
|
138
|
+
return report;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { reclaim };
|
|
142
|
+
}
|