@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,233 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { createDocumentIndex } from "../src/document-index.js";
|
|
8
|
+
import { isManagedDocumentPath, resolveDocumentDir } from "../src/document.js";
|
|
9
|
+
import { createMirror } from "../src/mirror.js";
|
|
10
|
+
import { createService } from "../src/service.js";
|
|
11
|
+
import { createStore } from "../src/store.js";
|
|
12
|
+
|
|
13
|
+
// 回归(issue #296 第二批):document 的落盘目录与归属。
|
|
14
|
+
// ①`documentDir` 默认跟随 memoryDir 的 `<memoryDir>/documents/`,`~` 与相对路径
|
|
15
|
+
// 按 memoryDir 同一套解析;②managed / external 的边界(目录内的文件是机器产物
|
|
16
|
+
// 的一部分,目录外只登记指针行);③`index.md` 整文件机器所有、可从库重建;
|
|
17
|
+
// ④镜像侧 documents.md 是只读指针视图(另见 test/mirror-types.test.js)。
|
|
18
|
+
|
|
19
|
+
function tempDir() {
|
|
20
|
+
return mkdtempSync(join(tmpdir(), "dsh-mneme-docdir-"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("#296: documentDir 默认为 memoryDir 下的 documents,三种路径写法都可解析", () => {
|
|
24
|
+
const memoryDir = join(tmpdir(), "dsh-mneme-memory");
|
|
25
|
+
assert.equal(resolveDocumentDir(memoryDir, ""), resolve(memoryDir, "documents"), "空 = 跟随 memoryDir");
|
|
26
|
+
assert.equal(resolveDocumentDir(memoryDir), resolve(memoryDir, "documents"), "缺省同空串");
|
|
27
|
+
assert.equal(resolveDocumentDir(memoryDir, "./notes"), resolve(memoryDir, "notes"), "相对路径落在 memoryDir 下");
|
|
28
|
+
assert.equal(resolveDocumentDir(memoryDir, "docs"), resolve(memoryDir, "docs"));
|
|
29
|
+
assert.equal(resolveDocumentDir(memoryDir, join(tmpdir(), "elsewhere")), resolve(join(tmpdir(), "elsewhere")), "绝对路径原样用");
|
|
30
|
+
assert.equal(resolveDocumentDir(memoryDir, "~/docs"), resolve(homedir(), "docs"), "~ 展开到 home");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("#296: managed 判定只认目录内的路径(前缀相像的不算)", () => {
|
|
34
|
+
const base = join(tmpdir(), "dsh-mneme-docroot");
|
|
35
|
+
const dir = join(base, "documents");
|
|
36
|
+
assert.equal(isManagedDocumentPath(dir, join(dir, "report.md")), true);
|
|
37
|
+
assert.equal(isManagedDocumentPath(dir, join(dir, "sub", "deep.md")), true, "子目录也算 managed");
|
|
38
|
+
assert.equal(isManagedDocumentPath(dir, dir), true);
|
|
39
|
+
assert.equal(isManagedDocumentPath(dir, join(base, "documents-old", "x.md")), false, "前缀相像的兄弟目录不是 managed");
|
|
40
|
+
assert.equal(isManagedDocumentPath(dir, join(base, "other.md")), false);
|
|
41
|
+
assert.equal(isManagedDocumentPath(dir, ""), false);
|
|
42
|
+
assert.equal(isManagedDocumentPath(dir, undefined), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("#296: index.md 只列指针行、可整文件重建、内容不变不落盘", () => {
|
|
46
|
+
const dir = tempDir();
|
|
47
|
+
try {
|
|
48
|
+
const index = createDocumentIndex(join(dir, "documents"));
|
|
49
|
+
const managed = join(dir, "documents", "report.md");
|
|
50
|
+
const rows = [
|
|
51
|
+
{ id: "d1", title: "报告", content: "第一句。第二句。", doc_path: managed, updated_at: "2026-02-01T00:00:00.000Z" },
|
|
52
|
+
{ id: "d2", title: "外部文档", content: "正文不必进索引。", doc_path: join(dir, "notes.md"), updated_at: "2026-01-01T00:00:00.000Z" }
|
|
53
|
+
];
|
|
54
|
+
assert.equal(index.sync(rows).changed, true);
|
|
55
|
+
const first = readFileSync(join(dir, "documents", "index.md"), "utf8");
|
|
56
|
+
assert.ok(first.includes("整文件机器所有"), "文件头说清归属");
|
|
57
|
+
assert.equal((first.match(/^- `/gm) ?? []).length, 2, "一行一个 document");
|
|
58
|
+
assert.ok(first.includes("d1") && first.includes("报告") && first.includes(managed), "带定位");
|
|
59
|
+
assert.ok(/\bd1\b.*managed/.test(first), "目录内的标 managed");
|
|
60
|
+
assert.ok(/\bd2\b.*external/.test(first), "目录外的标 external");
|
|
61
|
+
assert.ok(!first.includes("第一句"), "索引不带摘要正文(那是镜像视图的活)");
|
|
62
|
+
// 重建:删掉再同步得到逐字节相同的文件(所以文件里不写生成时间)
|
|
63
|
+
unlinkSync(index.filePath);
|
|
64
|
+
index.sync(rows);
|
|
65
|
+
assert.equal(readFileSync(index.filePath, "utf8"), first, "可从库重建,且逐字节一致");
|
|
66
|
+
// 内容没变就不重写(与镜像同一条「不无谓写盘」纪律)
|
|
67
|
+
assert.equal(index.sync(rows).changed, false);
|
|
68
|
+
// 空集也要有一份合法的索引(不是删文件)
|
|
69
|
+
assert.equal(index.sync([]).changed, true);
|
|
70
|
+
assert.ok(readFileSync(index.filePath, "utf8").includes("机器所有"));
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("#296: 索引写不进去只报失败,不抛(写失败不能连累业务写)", () => {
|
|
77
|
+
const dir = tempDir();
|
|
78
|
+
try {
|
|
79
|
+
// 让目标目录的「父级」是一个普通文件:mkdir 必失败,且不依赖平台错误码
|
|
80
|
+
const blocker = join(dir, "blocker");
|
|
81
|
+
writeFileSync(blocker, "not a directory", "utf8");
|
|
82
|
+
const index = createDocumentIndex(join(blocker, "documents"));
|
|
83
|
+
const ensured = index.ensure();
|
|
84
|
+
assert.equal(ensured.ok, false, "建目录失败要如实返回(调用方据此 warn)");
|
|
85
|
+
assert.ok(ensured.error, "失败要带原因");
|
|
86
|
+
const result = index.sync([{ id: "d1", title: "t", content: "c", doc_path: "x", updated_at: "2026-01-01T00:00:00.000Z" }]);
|
|
87
|
+
assert.equal(result.ok, false, "失败要如实返回,不能假装成功");
|
|
88
|
+
assert.ok(result.error, "失败要带原因");
|
|
89
|
+
assert.ok(!existsSync(index.filePath));
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("#296: index.md 删掉后从库里重建,逐字节一致", () => {
|
|
96
|
+
const dir = tempDir();
|
|
97
|
+
let store;
|
|
98
|
+
try {
|
|
99
|
+
const documentDir = join(dir, "documents");
|
|
100
|
+
store = createStore(join(dir, "memory.db"));
|
|
101
|
+
const index = createDocumentIndex(documentDir);
|
|
102
|
+
const service = createService({
|
|
103
|
+
store, mirror: createMirror(dir), config: { documentMemoryEnabled: true }, documentIndex: index
|
|
104
|
+
});
|
|
105
|
+
writeFileSync(join(dir, "report.md"), "x", "utf8");
|
|
106
|
+
const doc = store.saveDocument({
|
|
107
|
+
type: "document", title: "报告", content: "第一句。第二句。", tags: [], importance: 3,
|
|
108
|
+
source: "tool", evidence: [], doc_path: join(documentDir, "report.md")
|
|
109
|
+
});
|
|
110
|
+
service.saveWithDedupe({ type: "pattern", title: "触发", content: "x" });
|
|
111
|
+
const first = readFileSync(index.filePath, "utf8");
|
|
112
|
+
// 真删文件,再从库同步一次:读数必须一样(这才是「可从库重建」,不是拿同一份
|
|
113
|
+
// 内存数组重算)
|
|
114
|
+
unlinkSync(index.filePath);
|
|
115
|
+
assert.ok(!existsSync(index.filePath));
|
|
116
|
+
service.saveWithDedupe({ type: "pattern", title: "触发2", content: "y" });
|
|
117
|
+
assert.equal(readFileSync(index.filePath, "utf8"), first, "删掉重建后逐字节一致");
|
|
118
|
+
assert.ok(first.includes(doc.id) && first.includes("managed"));
|
|
119
|
+
} finally {
|
|
120
|
+
store?.close();
|
|
121
|
+
rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("#296: managed 文件走一次完整注册往返,字节哈希不变", async () => {
|
|
126
|
+
const dir = tempDir();
|
|
127
|
+
let store;
|
|
128
|
+
try {
|
|
129
|
+
const documentDir = join(dir, "documents");
|
|
130
|
+
mkdirSync(documentDir, { recursive: true });
|
|
131
|
+
const docFile = join(documentDir, "report.md");
|
|
132
|
+
writeFileSync(docFile, "# 报告\n\n正文由 agent 写。\n", "utf8");
|
|
133
|
+
const before = createHash("sha256").update(readFileSync(docFile)).digest("hex");
|
|
134
|
+
store = createStore(join(dir, "memory.db"));
|
|
135
|
+
const service = createService({
|
|
136
|
+
store, mirror: createMirror(dir), config: { documentMemoryEnabled: true },
|
|
137
|
+
documentIndex: createDocumentIndex(documentDir)
|
|
138
|
+
});
|
|
139
|
+
const result = await service.registerDocument({ path: docFile, title: "报告", summary: "第一句。第二句。" });
|
|
140
|
+
assert.equal(result.action, "created");
|
|
141
|
+
assert.equal(result.memory.doc_path, resolve(docFile), "指针行落在 managed 路径上");
|
|
142
|
+
assert.equal(
|
|
143
|
+
createHash("sha256").update(readFileSync(docFile)).digest("hex"), before,
|
|
144
|
+
"管线对正文零读零写:注册往返后 managed 文件必须逐字节不变"
|
|
145
|
+
);
|
|
146
|
+
// 同时也确认没有偷偷在 documentDir 里生成第二份「正文」(机器产物只有 index.md)
|
|
147
|
+
assert.deepEqual(readdirSync(documentDir).sort(), ["index.md", "report.md"]);
|
|
148
|
+
} finally {
|
|
149
|
+
store?.close();
|
|
150
|
+
rmSync(dir, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("#296: 两个落点共用 documentMemoryEnabled 闸,闸关时视图与索引一起清掉", () => {
|
|
155
|
+
const dir = tempDir();
|
|
156
|
+
let store;
|
|
157
|
+
try {
|
|
158
|
+
const documentDir = join(dir, "documents");
|
|
159
|
+
store = createStore(join(dir, "memory.db"));
|
|
160
|
+
const index = createDocumentIndex(documentDir);
|
|
161
|
+
const on = createService({
|
|
162
|
+
store, mirror: createMirror(dir), config: { documentMemoryEnabled: true }, documentIndex: index
|
|
163
|
+
});
|
|
164
|
+
writeFileSync(join(dir, "outside.md"), "x", "utf8");
|
|
165
|
+
const kept = store.saveDocument({
|
|
166
|
+
type: "document", title: "报告", content: "第一句。第二句。", tags: [], importance: 3,
|
|
167
|
+
source: "tool", evidence: [], doc_path: join(documentDir, "report.md")
|
|
168
|
+
});
|
|
169
|
+
store.saveDocument({
|
|
170
|
+
type: "document", title: "外部", content: "另一份。", tags: [], importance: 3,
|
|
171
|
+
source: "tool", evidence: [], doc_path: join(dir, "outside.md")
|
|
172
|
+
});
|
|
173
|
+
// 任意一次业务写触发写后语(镜像 + 索引一起)
|
|
174
|
+
on.saveWithDedupe({ type: "pattern", title: "触发", content: "x" });
|
|
175
|
+
assert.ok(existsSync(join(dir, "documents.md")), "闸开 → 镜像视图落盘");
|
|
176
|
+
const md = readFileSync(join(dir, "documents.md"), "utf8");
|
|
177
|
+
assert.ok(md.includes("报告") && md.includes(join(documentDir, "report.md")));
|
|
178
|
+
assert.ok(existsSync(index.filePath), "闸开 → 索引落盘");
|
|
179
|
+
assert.ok(readFileSync(index.filePath, "utf8").includes("managed"));
|
|
180
|
+
|
|
181
|
+
// 归档一行:索引只列活跃行(与镜像的 coverage: active-only 同口径)
|
|
182
|
+
store.setArchived(kept.id, true);
|
|
183
|
+
on.saveWithDedupe({ type: "pattern", title: "触发1b", content: "w" });
|
|
184
|
+
assert.ok(!readFileSync(index.filePath, "utf8").includes(kept.id), "已归档的行不进索引");
|
|
185
|
+
|
|
186
|
+
// 闸关:document 行不再进渲染集 → documents.md 被既有「空 type 删文件」清掉;
|
|
187
|
+
// index.md 归 syncDocumentIndex 清掉——留一份陈旧索引就是「看着还在、其实已关」
|
|
188
|
+
// 的视图,正是 documents.md 在闸关时要避免的那一类。
|
|
189
|
+
const off = createService({ store, mirror: createMirror(dir), config: {}, documentIndex: index });
|
|
190
|
+
off.saveWithDedupe({ type: "pattern", title: "触发2", content: "y" });
|
|
191
|
+
assert.ok(!existsSync(join(dir, "documents.md")), "闸关 → 镜像视图清掉");
|
|
192
|
+
assert.ok(!existsSync(index.filePath), "闸关 → 索引也清掉");
|
|
193
|
+
|
|
194
|
+
// 闸重开:下一次业务写从库重建索引(自愈,不需要手工删文件)
|
|
195
|
+
const back = createService({
|
|
196
|
+
store, mirror: createMirror(dir), config: { documentMemoryEnabled: true }, documentIndex: index
|
|
197
|
+
});
|
|
198
|
+
back.saveWithDedupe({ type: "pattern", title: "触发3", content: "z" });
|
|
199
|
+
assert.ok(readFileSync(index.filePath, "utf8").includes("外部"), "闸重开 → 索引从库重建");
|
|
200
|
+
} finally {
|
|
201
|
+
store?.close();
|
|
202
|
+
rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("#296: 两条新文案的英文分支也要能渲染(en 实例)", () => {
|
|
207
|
+
const dir = tempDir();
|
|
208
|
+
try {
|
|
209
|
+
// 新文案是 per-language 的 lambda:en 分支此前零调用点,缺键或笔误只在
|
|
210
|
+
// memory.language=en 的实例上炸,而全量测试照样绿(审查指出)。
|
|
211
|
+
const index = createDocumentIndex(join(dir, "documents"), "en");
|
|
212
|
+
index.sync([{
|
|
213
|
+
id: "d1", title: "Report", content: "First sentence. Second.", updated_at: "2026-01-01T00:00:00.000Z",
|
|
214
|
+
doc_path: join(dir, "documents", "report.md")
|
|
215
|
+
}]);
|
|
216
|
+
const indexText = readFileSync(index.filePath, "utf8");
|
|
217
|
+
assert.match(indexText, /document index — dsh-mneme/, "en 的索引头");
|
|
218
|
+
assert.match(indexText, /Machine-owned as a whole/, "en 的归属说明");
|
|
219
|
+
|
|
220
|
+
const mirror = createMirror(dir, "en");
|
|
221
|
+
mirror.sync([{
|
|
222
|
+
id: "doc1", type: "document", title: "Report", content: "First sentence. Second.", tags: [],
|
|
223
|
+
importance: 3, forgotten: false, created_at: "2026-01-01T00:00:00.000Z",
|
|
224
|
+
updated_at: "2026-01-01T00:00:00.000Z", doc_path: join(dir, "documents", "report.md")
|
|
225
|
+
}]);
|
|
226
|
+
const docs = readFileSync(join(dir, "documents.md"), "utf8");
|
|
227
|
+
assert.match(docs, /read-only view/, "en 的只读视图文件头");
|
|
228
|
+
assert.match(docs, /never the full text/, "en 的只读说明");
|
|
229
|
+
assert.ok(docs.includes("First sentence") && !docs.includes("Second."), "en 下同样只留摘要首句");
|
|
230
|
+
} finally {
|
|
231
|
+
rmSync(dir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
package/test/document.test.js
CHANGED
|
@@ -8,7 +8,7 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { createStore } from "../src/store.js";
|
|
11
|
-
import { createService } from "../src/service.js";
|
|
11
|
+
import { createService, PINNED_MEMORY_TYPES } from "../src/service.js";
|
|
12
12
|
import { createDocumentRegistrar } from "../src/document.js";
|
|
13
13
|
import { MEMORY_ITEM_SCHEMA } from "../src/tools.js";
|
|
14
14
|
import { TYPE_DECAY_DEFAULTS } from "../src/heat.js";
|
|
@@ -30,7 +30,7 @@ async function makeDocDir() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** 单元级 registrar:注入假 embedQuery,隔离 vector 档行为。 */
|
|
33
|
-
function makeRegistrar(store, config = {}, embedQuery = async () => null) {
|
|
33
|
+
function makeRegistrar(store, config = {}, embedQuery = async () => null, { pinnedTypes = PINNED_MEMORY_TYPES } = {}) {
|
|
34
34
|
const finalized = [];
|
|
35
35
|
const register = createDocumentRegistrar({
|
|
36
36
|
store,
|
|
@@ -42,7 +42,9 @@ function makeRegistrar(store, config = {}, embedQuery = async () => null) {
|
|
|
42
42
|
...(Array.isArray(existing?.content_history) ? existing.content_history : [])
|
|
43
43
|
].slice(0, 20),
|
|
44
44
|
transaction: (fn) => fn(),
|
|
45
|
-
finalize: (rows) => finalized.push(...rows)
|
|
45
|
+
finalize: (rows) => finalized.push(...rows),
|
|
46
|
+
// #275 拍板 5:service.js 注入的 #249 逐字保真池(这里同款注入,保持行为一致)
|
|
47
|
+
pinnedTypes
|
|
46
48
|
});
|
|
47
49
|
return { register, finalized };
|
|
48
50
|
}
|
|
@@ -147,6 +149,152 @@ test("evidence intersection: valid subset kept, unknown dropped + degraded tag,
|
|
|
147
149
|
}
|
|
148
150
|
});
|
|
149
151
|
|
|
152
|
+
// ============================ 升格吸收的 evidence 归档(#275 拍板 5,同事务)
|
|
153
|
+
|
|
154
|
+
// 拍板原文:registerDocument 成功后同一事务把 absorbed 的 evidence 行翻 archived
|
|
155
|
+
// (可恢复、审计全留),带 opt-out;pinned(constraint / preference)永不自动归档;
|
|
156
|
+
// supersede 的 loser 行同样翻 archived(后者本来就是本模块既有行为,这里一并锁住)。
|
|
157
|
+
|
|
158
|
+
test("absorbed evidence rows are archived with the document; pinned rows are exempt", async () => {
|
|
159
|
+
const { store, service, close } = makeService({ documentMemoryEnabled: true });
|
|
160
|
+
const { writeDoc, cleanup } = await makeDocDir();
|
|
161
|
+
try {
|
|
162
|
+
const pinned = service.saveWithDedupe({ type: "constraint", title: "C", content: "边界条件" }).memory;
|
|
163
|
+
const decision = service.saveWithDedupe({ type: "decision", title: "D", content: "一个决定" }).memory;
|
|
164
|
+
const p = await writeDoc("absorb.md");
|
|
165
|
+
|
|
166
|
+
const res = await service.registerDocument({
|
|
167
|
+
path: p, title: "Absorb", summary: "summary", evidence: [pinned.id, decision.id]
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.equal(res.evidence_kept, 2, "引用本身照旧全留下:doc 行的 evidence 数组是正向链");
|
|
171
|
+
assert.equal(res.evidence_archived, 1, "只有非 pinned 的那条被动");
|
|
172
|
+
const after = service.getById(decision.id);
|
|
173
|
+
assert.equal(after.archived, true, "被吸收的行退出活跃面(「21 行不是 1 行」)");
|
|
174
|
+
assert.equal(after.content, "一个决定", "只翻标志位:内容与审计全留(可恢复)");
|
|
175
|
+
assert.equal(service.getById(pinned.id).archived, false, "#249 的逐字保真池,升格不能绕过");
|
|
176
|
+
assert.equal(store.list().some((m) => m.id === decision.id), false, "默认面不再列出吸收行");
|
|
177
|
+
} finally {
|
|
178
|
+
cleanup();
|
|
179
|
+
close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("archiveEvidence: false(工具侧 keep_evidence_active)保留吸收行的活跃面", async () => {
|
|
184
|
+
const { service, close } = makeService({ documentMemoryEnabled: true });
|
|
185
|
+
const { writeDoc, cleanup } = await makeDocDir();
|
|
186
|
+
try {
|
|
187
|
+
const d = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
|
|
188
|
+
const res = await service.registerDocument(
|
|
189
|
+
{ path: await writeDoc("optout.md"), title: "OptOut", summary: "s", evidence: [d.id] },
|
|
190
|
+
{ archiveEvidence: false }
|
|
191
|
+
);
|
|
192
|
+
assert.equal(res.evidence_kept, 1);
|
|
193
|
+
assert.equal(res.evidence_archived, 0, "opt-out 时不归档");
|
|
194
|
+
assert.equal(service.getById(d.id).archived, false);
|
|
195
|
+
} finally {
|
|
196
|
+
cleanup();
|
|
197
|
+
close();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("re-registering a document whose evidence was absorbed keeps the references", async () => {
|
|
202
|
+
// 这条防的是「补丁把常规路径打坏」:吸收行一旦归档,重注册同一份文档(出新版)
|
|
203
|
+
// 时同一批 evidence 会全落 dropped,进而撞上捏造判据——最常规的路径反而报错。
|
|
204
|
+
const { service, close } = makeService({ documentMemoryEnabled: true });
|
|
205
|
+
const { writeDoc, cleanup } = await makeDocDir();
|
|
206
|
+
try {
|
|
207
|
+
const d = service.saveWithDedupe({ type: "decision", title: "D", content: "c" }).memory;
|
|
208
|
+
const p = await writeDoc("ver.md");
|
|
209
|
+
const first = await service.registerDocument({ path: p, title: "Ver", summary: "v1", evidence: [d.id] });
|
|
210
|
+
assert.equal(first.evidence_archived, 1);
|
|
211
|
+
|
|
212
|
+
const second = await service.registerDocument({ path: p, title: "Ver", summary: "v2", evidence: [d.id] });
|
|
213
|
+
assert.equal(second.action, "superseded");
|
|
214
|
+
assert.deepEqual(second.memory.evidence, [d.id], "吸收行仍为它所属的那份文档背书");
|
|
215
|
+
assert.equal(second.evidence_kept, 1);
|
|
216
|
+
assert.equal(second.degraded, false, "不误报成捏造证据");
|
|
217
|
+
// 认回是窄口径的:已归档行只为**吸收它的那份**文档背书。别的文档(新注册、无
|
|
218
|
+
// 同名目标)拿它当证据,照旧走既有的捏造判据——不因为这条补丁放宽。
|
|
219
|
+
const other = await writeDoc("other.md");
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
() => service.registerDocument({ path: other, title: "Other", summary: "s", evidence: [d.id] }),
|
|
222
|
+
/fabricated evidence is rejected/
|
|
223
|
+
);
|
|
224
|
+
} finally {
|
|
225
|
+
cleanup();
|
|
226
|
+
close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("registrar without the pinned pool wiring archives nothing (fail-safe)", async () => {
|
|
231
|
+
// 拿不到 pinned 集合就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
232
|
+
const store = createStore(":memory:");
|
|
233
|
+
const { writeDoc, cleanup } = await makeDocDir();
|
|
234
|
+
try {
|
|
235
|
+
const d = store.save({ type: "decision", title: "D", content: "c" });
|
|
236
|
+
const { register } = makeRegistrar(store, { documentMemoryEnabled: true }, async () => null, { pinnedTypes: null });
|
|
237
|
+
const res = await register({ path: await writeDoc("noset.md"), title: "NoSet", summary: "s", evidence: [d.id] });
|
|
238
|
+
assert.equal(res.evidence_archived, 0);
|
|
239
|
+
assert.equal(store.getById(d.id).archived, false);
|
|
240
|
+
} finally {
|
|
241
|
+
cleanup();
|
|
242
|
+
store.close();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("自有生命周期的类型不被吸收:document 行保持活跃,同 path 的 supersede 链不断", async () => {
|
|
247
|
+
// 回归点(单盲审查 H1):把另一份 document 行当 evidence 时,若照普通行吸收,它会
|
|
248
|
+
// 被静默归档——走不到 loser 分支(没有 [superseded by] 指针与 content_history),
|
|
249
|
+
// 而 supersede 探测只看活跃行,于是重注册同 path 会再铸一行、同一个文件留下两行。
|
|
250
|
+
const { store, service, close } = makeService({ documentMemoryEnabled: true });
|
|
251
|
+
const { writeDoc, cleanup } = await makeDocDir();
|
|
252
|
+
try {
|
|
253
|
+
const pA = await writeDoc("a.md");
|
|
254
|
+
const docA = (await service.registerDocument({ path: pA, title: "DocA", summary: "v1" })).memory;
|
|
255
|
+
const docB = await service.registerDocument({
|
|
256
|
+
path: await writeDoc("b.md"), title: "DocB", summary: "grounded in A", evidence: [docA.id]
|
|
257
|
+
});
|
|
258
|
+
assert.equal(docB.evidence_archived, 0, "document 行不参与吸收");
|
|
259
|
+
assert.equal(service.getById(docA.id).archived, false, "被引用不改变它的活跃状态");
|
|
260
|
+
|
|
261
|
+
const again = await service.registerDocument({ path: pA, title: "DocA", summary: "v2" });
|
|
262
|
+
assert.equal(again.action, "superseded", "指针行仍能被 supersede 探测看到");
|
|
263
|
+
assert.equal(again.superseded.id, docA.id);
|
|
264
|
+
assert.ok(again.superseded.content.includes(again.memory.id), "旧行拿到 [superseded by] 指针");
|
|
265
|
+
assert.equal(store.all().filter((m) => m.doc_path === pA).length, 2, "同一 path 一行新版 + 一行带指针的旧版,没有多余的第三行");
|
|
266
|
+
} finally {
|
|
267
|
+
cleanup();
|
|
268
|
+
close();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("自有生命周期的类型不被吸收:summary(dream 总览)保持活跃,不会被当成不存在重铸", async () => {
|
|
273
|
+
// 回归点:summary 按 source 身份去重(dream 总览)且按注入档位常驻。被吸收归档后
|
|
274
|
+
// 去重候选集(store.list 默认排除归档)看不到它 → 下一次做梦会再铸一行同源总览。
|
|
275
|
+
const { service, close } = makeService({ documentMemoryEnabled: true });
|
|
276
|
+
const { writeDoc, cleanup } = await makeDocDir();
|
|
277
|
+
try {
|
|
278
|
+
const digest = service.saveWithDedupe({
|
|
279
|
+
type: "summary", title: "当前项目状态", content: "resident digest", source: "dream", importance: 5
|
|
280
|
+
}).memory;
|
|
281
|
+
const res = await service.registerDocument({
|
|
282
|
+
path: await writeDoc("with-digest.md"), title: "DocW", summary: "s", evidence: [digest.id]
|
|
283
|
+
});
|
|
284
|
+
assert.equal(res.evidence_archived, 0, "summary 不参与吸收");
|
|
285
|
+
assert.equal(service.getById(digest.id).archived, false);
|
|
286
|
+
|
|
287
|
+
const again = service.saveWithDedupe({
|
|
288
|
+
type: "summary", title: "当前项目状态", content: "resident digest v2", source: "dream", importance: 5
|
|
289
|
+
});
|
|
290
|
+
assert.equal(again.action, "merged", "下一次做梦仍认得出这行,不会铸出第二行同源总览");
|
|
291
|
+
assert.equal(again.memory.id, digest.id);
|
|
292
|
+
} finally {
|
|
293
|
+
cleanup();
|
|
294
|
+
close();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
150
298
|
// ============================================================ supersede 记账(DoD 2)
|
|
151
299
|
|
|
152
300
|
test("re-registering the same path supersedes: old row archived + traceable, file untouched", async () => {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
import { createInjector } from "../src/inject.js";
|
|
7
|
+
import { createSettings, FEATURE_FLAG_SPEC } from "../src/settings.js";
|
|
8
|
+
import { createTools } from "../src/tools.js";
|
|
9
|
+
import { Config, INJECT_CHILD_FLAGS, applyLightModePreset, injectChildEnabled } from "../src/config.js";
|
|
10
|
+
|
|
11
|
+
// 回归(issue #249 第二批):注入形态的父/子开关。
|
|
12
|
+
// 父 = autoInject(既有挂载总闸,默认开);子项按 #249 §10 的判据分档——是否
|
|
13
|
+
// 引入新的注入时机/新表面/额外成本。只修正既有位的能力说明随父生效,本批
|
|
14
|
+
// 默认值由关翻到开(显式产品决定,见 CHANGELOG);父关 = 子项一律不生效,
|
|
15
|
+
// 但子项的持久值保留(父关是「不生效」,不是「重置用户配置」)。
|
|
16
|
+
//
|
|
17
|
+
// 能力说明有两个落点:系统提示段住在注入器内(父关时注入器根本不挂载,天然
|
|
18
|
+
// 不生效),工具描述住在注入器之外,是父闸唯一会漏的地方——本文件用真实工具
|
|
19
|
+
// 描述钉住它。
|
|
20
|
+
|
|
21
|
+
const CHILDREN = Object.values(INJECT_CHILD_FLAGS).flat();
|
|
22
|
+
const GUIDE_TOOLS = ["memory_search", "memory_save"];
|
|
23
|
+
|
|
24
|
+
function injectorSections(config) {
|
|
25
|
+
const store = createStore(":memory:");
|
|
26
|
+
const service = createService({ store, mirror: null, config });
|
|
27
|
+
const sections = [];
|
|
28
|
+
const ctx = {
|
|
29
|
+
systemPrompt: {
|
|
30
|
+
context() { return () => {}; },
|
|
31
|
+
section(def) { sections.push(def); return () => {}; }
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
createInjector(ctx, service, createSettings(store.db), config);
|
|
35
|
+
return sections;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toolDescriptions(config) {
|
|
39
|
+
const store = createStore(":memory:");
|
|
40
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
41
|
+
const registered = [];
|
|
42
|
+
const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } };
|
|
43
|
+
createTools(ctx, service, config, null);
|
|
44
|
+
return new Map(registered.map((t) => [t.name, t.description]));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("#249 第二批:解析后的默认配置里能力说明是开的", () => {
|
|
48
|
+
assert.equal(Config({}).injectGuidanceEnabled, true, "基础档默认开(随父生效)");
|
|
49
|
+
// 轻量档不因这次翻转多付常驻提示成本:预设把它压回 false。
|
|
50
|
+
assert.equal(applyLightModePreset({ lightMode: true }).injectGuidanceEnabled, false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("#249 第二批:父关时子项在两个落点上都不生效", () => {
|
|
54
|
+
assert.equal(injectorSections({ autoInject: true, injectGuidanceEnabled: true }).length, 1, "父开 → 注册提示段");
|
|
55
|
+
assert.equal(injectorSections({ autoInject: false, injectGuidanceEnabled: true }).length, 0, "父关 → 不注册提示段");
|
|
56
|
+
|
|
57
|
+
const plain = toolDescriptions({ injectGuidanceEnabled: false });
|
|
58
|
+
const guided = toolDescriptions({ autoInject: true, injectGuidanceEnabled: true });
|
|
59
|
+
const gated = toolDescriptions({ autoInject: false, injectGuidanceEnabled: true });
|
|
60
|
+
for (const name of GUIDE_TOOLS) {
|
|
61
|
+
assert.notEqual(guided.get(name), plain.get(name), `${name}:子项开 → 描述补了判断指引`);
|
|
62
|
+
assert.equal(gated.get(name), plain.get(name), `${name}:父关 → 描述逐字节回到未开状态`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("#249 第二批:闸门只认显式关掉的父,子项自己缺省即关", () => {
|
|
67
|
+
const key = "injectGuidanceEnabled";
|
|
68
|
+
assert.equal(injectChildEnabled({ [key]: true }, key), true, "父未出现 → 只按子项判");
|
|
69
|
+
assert.equal(injectChildEnabled({}, key), false, "子项未开");
|
|
70
|
+
assert.equal(injectChildEnabled({ autoInject: false, [key]: true }, key), false, "父显式关 → 子不生效");
|
|
71
|
+
assert.equal(injectChildEnabled({ autoInject: false, [key]: false }, key), false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("#249 第二批:父关只停用,不改写子项的持久值", () => {
|
|
75
|
+
const store = createStore(":memory:");
|
|
76
|
+
const settings = createSettings(store.db);
|
|
77
|
+
settings.setFeatureFlags({ autoInject: false, injectGuidanceEnabled: true });
|
|
78
|
+
const stored = settings.getFeatureFlags();
|
|
79
|
+
assert.equal(stored.autoInject, false);
|
|
80
|
+
assert.equal(stored.injectGuidanceEnabled, true, "父关不是重置用户配置");
|
|
81
|
+
assert.equal(injectChildEnabled(stored, "injectGuidanceEnabled"), false, "值留着,当下不生效");
|
|
82
|
+
settings.setFeatureFlags({ autoInject: true });
|
|
83
|
+
assert.equal(injectChildEnabled(settings.getFeatureFlags(), "injectGuidanceEnabled"), true, "重开父开关恢复上次选择");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("#249 第二批:面板的父子关系与后端同源(漏一处就静默消失)", () => {
|
|
87
|
+
const client = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
88
|
+
// 渲染点:子项靠 flagRow 展开。只断言「声明与文案出现过」是恒真检查——把
|
|
89
|
+
// 2236 行改回 g.items.map(boolRow),子项既不在 group.core 的 items 里、也不经
|
|
90
|
+
// flagRow 渲染,面板上彻底消失,而字符串断言全绿。这一条才是那道门。
|
|
91
|
+
assert.ok(/g\.items\.map\(flagRow\)/.test(client), "分组渲染必须走 flagRow,否则子项不渲染");
|
|
92
|
+
const children = client.match(/const FEATURE_CHILDREN = (\{[^}]*\})/)?.[1] ?? "";
|
|
93
|
+
assert.ok(children.includes("autoInject"), "面板未声明父开关 autoInject");
|
|
94
|
+
const core = client.match(/\{ key: "group\.core", items: \[([^\]]*)\]/)?.[1] ?? "";
|
|
95
|
+
for (const child of CHILDREN) {
|
|
96
|
+
assert.ok(children.includes(`"${child}"`), `面板 FEATURE_CHILDREN 漏了 ${child}`);
|
|
97
|
+
assert.ok(!core.includes(`"${child}"`), `${child} 同时在 items 与 FEATURE_CHILDREN 里,会被渲染两次`);
|
|
98
|
+
// 双语文案:与 test/client.test.js 的路由键同款计数(zh + en 各一份 = 2)。
|
|
99
|
+
const named = client.split(`"memory.features.${child}"`).length - 1;
|
|
100
|
+
const hinted = client.split(`"memory.features.${child}.hint"`).length - 1;
|
|
101
|
+
assert.ok(named >= 2, `${child} 的文案要中英各一份(got ${named})`);
|
|
102
|
+
assert.ok(hinted >= 2, `${child} 的提示要中英各一份(got ${hinted})`);
|
|
103
|
+
// 白名单:面板给的开关必须真能写进 kv,否则点了只会拿到 400。
|
|
104
|
+
assert.ok(FEATURE_FLAG_SPEC.booleans.includes(child), `${child} 不在 settings 白名单里`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
@@ -12,7 +12,9 @@ import { MEMORY_GUIDE_SECTION } from "../src/guide.js";
|
|
|
12
12
|
// 总则段(常驻段必须同会话稳定,不逐轮复读);
|
|
13
13
|
// ②B1 pin 池(pinnedInjectBudget):约束/偏好不进相关性竞争与跨轮轮换、逐字
|
|
14
14
|
// 保真、排在块内排序之前,且有独立小预算(不占 maxInjectedItems 名额)。
|
|
15
|
-
//
|
|
15
|
+
// 本文件给的是裸 config,两个键缺省时都按关/零算:能力说明的解析默认值由 config.js
|
|
16
|
+
// 的 schema 给出(#249 第二批起为开,另见 test/inject-parent-gate.test.js),pin 池
|
|
17
|
+
// 的默认值是 0。
|
|
16
18
|
|
|
17
19
|
const userMsg = (text) => ({
|
|
18
20
|
type: "user/message",
|
|
@@ -122,9 +124,12 @@ test("#249: pinned content does not eat the general block budget", () => {
|
|
|
122
124
|
assert.ok(body.includes("总览正文必须带出来"), "普通候选照旧拿完整正文:pin 不占共享预算");
|
|
123
125
|
});
|
|
124
126
|
|
|
125
|
-
test("#249: capability guide
|
|
127
|
+
test("#249: capability guide registers exactly once, and stays off when the key is absent", () => {
|
|
128
|
+
// 这里给的是裸 config(不经 schema 解析),所以「缺键 = 不生效」;生产默认值
|
|
129
|
+
// 由 config.js 的 schema 给出(#249 第二批起为 true),那条锁在
|
|
130
|
+
// test/inject-parent-gate.test.js。
|
|
126
131
|
const off = setup({});
|
|
127
|
-
assert.equal(off.sections.length, 0, "
|
|
132
|
+
assert.equal(off.sections.length, 0, "缺键 → 不注册提示段");
|
|
128
133
|
|
|
129
134
|
const on = setup({ injectGuidanceEnabled: true });
|
|
130
135
|
assert.equal(on.sections.length, 1, "exactly one guide section");
|
package/test/llm-audit.test.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { mkdtempSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
4
9
|
import { createStore } from "../src/store.js";
|
|
10
|
+
import * as mneme from "../src/index.js";
|
|
5
11
|
import { createService } from "../src/service.js";
|
|
6
12
|
import { createDreamScheduler } from "../src/dream.js";
|
|
7
13
|
import { createSummarizer } from "../src/summarize.js";
|
|
@@ -554,3 +560,39 @@ test("entity extraction writes no audit row when llmAudit.enabled === false", as
|
|
|
554
560
|
assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
|
|
555
561
|
store.close();
|
|
556
562
|
});
|
|
563
|
+
|
|
564
|
+
// 启动期保留清理(Bug8)必须与写入侧读**同一份**配置。面板把 llmAudit.enabled 写进
|
|
565
|
+
// kv、经 nestedFlags 合进 cfg,bundle 里可能是关的:清理若读 rawCfg,就会出现「面板
|
|
566
|
+
// 说开 → 各写入点照写,raw 说关 → 不清理」这种无保留期的增长(写入准入 #254 也是读
|
|
567
|
+
// cfg 的写入点之一)。所以这条锁的是「清理跟着装配后的 cfg 走」。
|
|
568
|
+
test("boot retention purge reads the assembled config, not rawCfg", async () => {
|
|
569
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-audit-retention-"));
|
|
570
|
+
const dbPath = join(dir, "memory.db");
|
|
571
|
+
|
|
572
|
+
const seed = createStore(dbPath);
|
|
573
|
+
createSettings(seed.db).setFeatureFlags({ "llmAudit.enabled": true });
|
|
574
|
+
seed.saveLlmAudit({
|
|
575
|
+
timestamp: new Date(Date.now() - 2 * 86400000).toISOString(),
|
|
576
|
+
trigger_source: "autoDream",
|
|
577
|
+
operation_type: "dream_consolidate",
|
|
578
|
+
model_id: "m",
|
|
579
|
+
status: "success"
|
|
580
|
+
});
|
|
581
|
+
assert.equal(seed.listLlmAudits().length, 1, "先放一行过期审计");
|
|
582
|
+
seed.close();
|
|
583
|
+
|
|
584
|
+
const ctx = new Context();
|
|
585
|
+
ctx.provide("tools", { register() { return () => {}; } });
|
|
586
|
+
ctx.provide("commands", { register() { return () => {}; } });
|
|
587
|
+
ctx.provide("systemPrompt", { context() { return () => {}; } });
|
|
588
|
+
ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
|
|
589
|
+
ctx.provide("llm", { async *stream() { yield { type: "finish", reason: { kind: "stop" } }; } });
|
|
590
|
+
ctx.provide("webServer", { register() { return () => {}; } });
|
|
591
|
+
// bundle 说关、面板说开,保留期 1 天而那行已过期 2 天:只有读装配后的 cfg 才会清掉它
|
|
592
|
+
await ctx.plugin(mneme, { memoryDir: dir, llmAudit: { enabled: false, retentionDays: 1 } });
|
|
593
|
+
|
|
594
|
+
const after = new DatabaseSync(dbPath);
|
|
595
|
+
const left = after.prepare("SELECT count(*) AS c FROM llm_audit_logs").get().c;
|
|
596
|
+
after.close();
|
|
597
|
+
assert.equal(left, 0, "面板开着的审计必须按 cfg 清理,否则审计行只增不减");
|
|
598
|
+
});
|