@modusensus/dsh-mneme 0.12.0 → 0.13.0
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 +48 -19
- package/docs/CONFIGURATION.md +43 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +581 -29
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3537 -1205
- package/lib/config.js +139 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +92 -2
- package/lib/inject.js +189 -18
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +87 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +193 -66
- package/lib/settings.js +112 -14
- package/lib/store.js +347 -21
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +116 -2
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +581 -29
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +139 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +92 -2
- package/src/inject.js +189 -18
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +87 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +193 -66
- package/src/settings.js +112 -14
- package/src/store.js +347 -21
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +116 -2
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +375 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +779 -154
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +355 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +7 -0
- package/test/wiki-t1-jobs.test.js +125 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// 知识库第二层(src/kb/**)的回归锁。锁五件事:
|
|
2
|
+
// 1) 三张 kb_* 表在空库上打开即建(幂等迁移面),且 memories 表不受影响;
|
|
3
|
+
// 2) 集合建/改名/删除的正确语义(删集合连带删文档与分块,不留孤儿行);
|
|
4
|
+
// 3) 同一 rel_key + 同 sha256 重入库是 no-op —— 这条是幂等契约本身,回归了就是
|
|
5
|
+
// 「编辑器保存一次 → 全量重切块」的性能事故;
|
|
6
|
+
// 4) 内容变了要真替换:旧块不能残留(残留会让检索命中已删除的段落);
|
|
7
|
+
// 5) 检索能命中、readDoc 能把原文逐字节还原(切块必须严格连续按行)。
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
14
|
+
import { createStore } from "../src/store.js";
|
|
15
|
+
import { createKb } from "../src/kb/index.js";
|
|
16
|
+
|
|
17
|
+
// Windows 上 %TEMP% 里刚写完的文件会被 Defender 短暂持锁,rmSync 撞 EPERM 是
|
|
18
|
+
// 瞬时抖动不是用例失败(与 test/wiki-store.test.js 同一处踩坑)。
|
|
19
|
+
async function rmTempEventually(dir) {
|
|
20
|
+
for (let i = 0; i < 10; i++) {
|
|
21
|
+
try {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
return;
|
|
24
|
+
} catch {
|
|
25
|
+
await sleep(200);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeTempDir() {
|
|
31
|
+
return mkdtempSync(join(tmpdir(), "kb-test-"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeDoc(dir, name, content) {
|
|
35
|
+
const abs = join(dir, name);
|
|
36
|
+
writeFileSync(abs, content, "utf8");
|
|
37
|
+
return abs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function newKb() {
|
|
41
|
+
const store = createStore(":memory:");
|
|
42
|
+
return { store, kb: createKb({ store, cfg: {} }) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("新库打开即建三张 kb_* 表,且不碰 memories 表", () => {
|
|
46
|
+
const { store } = newKb();
|
|
47
|
+
for (const name of ["kb_collections", "kb_docs", "kb_chunks"]) {
|
|
48
|
+
const row = store.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
49
|
+
assert.ok(row, `缺表 ${name}`);
|
|
50
|
+
}
|
|
51
|
+
// 物理隔离的另一半:知识库的表不得出现在记忆行池的查询里。这里锁的是「memories
|
|
52
|
+
// 仍只有它自己那一份」,防止有人日后把 kb 行灌进 memories 图省事。
|
|
53
|
+
const memCount = store.db.prepare("SELECT COUNT(*) AS c FROM memories").get().c;
|
|
54
|
+
assert.equal(memCount, 0);
|
|
55
|
+
store.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("createKb 建/改名/列集合,计数随文档与分块变化", async () => {
|
|
59
|
+
const { store, kb } = newKb();
|
|
60
|
+
const dir = makeTempDir();
|
|
61
|
+
try {
|
|
62
|
+
kb.upsertCollection({ key: "manual", title: "手册", description: "内部手册" });
|
|
63
|
+
let list = kb.listCollections();
|
|
64
|
+
assert.equal(list.length, 1);
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
{ key: list[0].key, title: list[0].title, description: list[0].description, docs: list[0].docs, chunks: list[0].chunks },
|
|
67
|
+
{ key: "manual", title: "手册", description: "内部手册", docs: 0, chunks: 0 }
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// 同名 key 再 upsert 是「改名」不是「新建」:created_at 不动、行数不增。
|
|
71
|
+
kb.upsertCollection({ key: "manual", title: "手册v2", description: "" });
|
|
72
|
+
list = kb.listCollections();
|
|
73
|
+
assert.equal(list.length, 1);
|
|
74
|
+
assert.equal(list[0].title, "手册v2");
|
|
75
|
+
|
|
76
|
+
const src = writeDoc(dir, "a.md", "# 标题\n\n## 安装\n跑 npm install 就行。\n");
|
|
77
|
+
const r = await kb.ingest({ collection: "manual", kind: "file", source: src });
|
|
78
|
+
assert.equal(r.error, undefined);
|
|
79
|
+
assert.equal(r.skipped, false);
|
|
80
|
+
assert.ok(r.chunks >= 1);
|
|
81
|
+
|
|
82
|
+
list = kb.listCollections();
|
|
83
|
+
assert.equal(list[0].docs, 1);
|
|
84
|
+
assert.equal(list[0].chunks, r.chunks);
|
|
85
|
+
} finally {
|
|
86
|
+
await rmTempEventually(dir);
|
|
87
|
+
store.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("同 sha256 重入库 no-op;内容变了才重切块并清掉旧块", async () => {
|
|
92
|
+
const { store, kb } = newKb();
|
|
93
|
+
const dir = makeTempDir();
|
|
94
|
+
try {
|
|
95
|
+
kb.upsertCollection({ key: "c", title: "c" });
|
|
96
|
+
const src = writeDoc(dir, "doc.md", "## 甲\n第一版正文 alpha\n");
|
|
97
|
+
|
|
98
|
+
const first = await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
99
|
+
assert.equal(first.skipped, false);
|
|
100
|
+
|
|
101
|
+
// 同一份内容(哪怕 mtime 变了)不许重切块 —— 幂等契约。
|
|
102
|
+
await sleep(10);
|
|
103
|
+
writeFileSync(src, "## 甲\n第一版正文 alpha\n", "utf8");
|
|
104
|
+
const again = await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
105
|
+
assert.equal(again.skipped, true, "同 sha256 必须 skipped");
|
|
106
|
+
assert.equal(again.docId, first.docId, "skipped 时 docId 应指向既有文档");
|
|
107
|
+
assert.equal(again.chunks, first.chunks, "skipped 时 chunks 应是库里现有块数");
|
|
108
|
+
assert.equal(store.kbChunksByDoc(first.docId).length, first.chunks);
|
|
109
|
+
|
|
110
|
+
// 内容变了:替换,旧块不能残留(否则检索会命中已删除的段落)。
|
|
111
|
+
writeFileSync(src, "## 乙\n第二版正文 beta only\n", "utf8");
|
|
112
|
+
const changed = await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
113
|
+
assert.equal(changed.skipped, false);
|
|
114
|
+
assert.equal(changed.docId, first.docId, "同一 rel_key 复用文档行");
|
|
115
|
+
const texts = store.kbChunksByDoc(changed.docId).map((c) => c.text).join("\n");
|
|
116
|
+
assert.ok(texts.includes("beta"), "新内容要进来");
|
|
117
|
+
assert.ok(!texts.includes("alpha"), "旧分块必须被清掉");
|
|
118
|
+
assert.equal(kb.search({ query: "alpha", collection: "c" }).hits.length, 0);
|
|
119
|
+
assert.equal(kb.search({ query: "beta", collection: "c" }).hits.length, 1);
|
|
120
|
+
} finally {
|
|
121
|
+
await rmTempEventually(dir);
|
|
122
|
+
store.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("检索命中带集合/相对键/节标题/行号/摘录;集合过滤生效", async () => {
|
|
127
|
+
const { store, kb } = newKb();
|
|
128
|
+
const dir = makeTempDir();
|
|
129
|
+
try {
|
|
130
|
+
kb.upsertCollection({ key: "a", title: "A" });
|
|
131
|
+
kb.upsertCollection({ key: "b", title: "B" });
|
|
132
|
+
const f1 = writeDoc(dir, "one.md", "# 一\n\n## 部署\n部署用 docker compose 起服务。\n\n## 回滚\n回滚走 git revert。\n");
|
|
133
|
+
const f2 = writeDoc(dir, "two.md", "## 数据库\n数据库是 SQLite,用 WAL 模式。\n");
|
|
134
|
+
await kb.ingest({ collection: "a", kind: "file", source: f1 });
|
|
135
|
+
await kb.ingest({ collection: "b", kind: "file", source: f2 });
|
|
136
|
+
|
|
137
|
+
const hit = kb.search({ query: "docker" });
|
|
138
|
+
assert.equal(hit.hits.length, 1);
|
|
139
|
+
assert.equal(hit.hits[0].collection, "a");
|
|
140
|
+
assert.equal(hit.hits[0].relKey, f1);
|
|
141
|
+
assert.equal(hit.hits[0].heading, "部署");
|
|
142
|
+
// 块从它自己那节的 `## 部署` 行起算(第 3 行)——第一节是 "# 一" + 空行。
|
|
143
|
+
assert.equal(hit.hits[0].line, 3, "命中块起始行按行连续切回算");
|
|
144
|
+
assert.ok(hit.hits[0].excerpt.includes("docker"));
|
|
145
|
+
assert.deepEqual(hit.unreadable, []);
|
|
146
|
+
|
|
147
|
+
// 单集合过滤:查 B 才有的词,限定 a 应无命中。
|
|
148
|
+
assert.equal(kb.search({ query: "SQLite", collection: "a" }).hits.length, 0);
|
|
149
|
+
assert.equal(kb.search({ query: "SQLite", collection: "b" }).hits.length, 1);
|
|
150
|
+
|
|
151
|
+
// 中文查询也能命中(tokenize 的 CJK bigram 路径)。
|
|
152
|
+
const zh = kb.search({ query: "回滚" });
|
|
153
|
+
assert.equal(zh.hits.length, 1);
|
|
154
|
+
assert.equal(zh.hits[0].heading, "回滚");
|
|
155
|
+
|
|
156
|
+
// 空 query 不报错也不返回全库。
|
|
157
|
+
assert.deepEqual(kb.search({ query: " " }), { hits: [], unreadable: [] });
|
|
158
|
+
} finally {
|
|
159
|
+
await rmTempEventually(dir);
|
|
160
|
+
store.close();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("readDoc 逐字节还原原文(切块必须严格连续、不剥空行)", async () => {
|
|
165
|
+
const { store, kb } = newKb();
|
|
166
|
+
const dir = makeTempDir();
|
|
167
|
+
try {
|
|
168
|
+
kb.upsertCollection({ key: "c", title: "c" });
|
|
169
|
+
const original = [
|
|
170
|
+
"# 大标题",
|
|
171
|
+
"",
|
|
172
|
+
"引言段落,含**markdown**。",
|
|
173
|
+
"",
|
|
174
|
+
"## 第一节",
|
|
175
|
+
"",
|
|
176
|
+
"第一段。",
|
|
177
|
+
"",
|
|
178
|
+
"第二段。",
|
|
179
|
+
"",
|
|
180
|
+
].join("\n");
|
|
181
|
+
const src = writeDoc(dir, "big.md", original);
|
|
182
|
+
await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
183
|
+
|
|
184
|
+
const doc = kb.readDoc({ collection: "c", relKey: src });
|
|
185
|
+
assert.ok(doc, "readDoc 应命中");
|
|
186
|
+
assert.equal(doc.content, original);
|
|
187
|
+
assert.equal(doc.title, "big.md");
|
|
188
|
+
assert.equal(doc.status, "ok");
|
|
189
|
+
|
|
190
|
+
// 查不到的组合返回 null(面板据此走空态),不抛。
|
|
191
|
+
assert.equal(kb.readDoc({ collection: "c", relKey: "/nope.md" }), null);
|
|
192
|
+
assert.equal(kb.readDoc({ collection: "nope", relKey: src }), null);
|
|
193
|
+
} finally {
|
|
194
|
+
await rmTempEventually(dir);
|
|
195
|
+
store.close();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("listDocs 给出面板要的文档列表;删集合连带清文档与分块", async () => {
|
|
200
|
+
const { store, kb } = newKb();
|
|
201
|
+
const dir = makeTempDir();
|
|
202
|
+
try {
|
|
203
|
+
kb.upsertCollection({ key: "c", title: "c" });
|
|
204
|
+
const src = writeDoc(dir, "x.md", "## 节\n内容 content\n");
|
|
205
|
+
const r = await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
206
|
+
|
|
207
|
+
const docs = kb.listDocs({ collection: "c" });
|
|
208
|
+
assert.equal(docs.length, 1);
|
|
209
|
+
assert.deepEqual(Object.keys(docs[0]).sort(), ["bytes", "error", "indexedAt", "kind", "relKey", "status", "title"]);
|
|
210
|
+
assert.equal(docs[0].relKey, src);
|
|
211
|
+
assert.equal(docs[0].kind, "file");
|
|
212
|
+
assert.equal(docs[0].status, "ok");
|
|
213
|
+
assert.equal(docs[0].error, null);
|
|
214
|
+
assert.ok(docs[0].bytes > 0);
|
|
215
|
+
|
|
216
|
+
assert.equal(kb.deleteCollection("c"), true);
|
|
217
|
+
assert.equal(kb.deleteCollection("c"), false, "再删一次是 false 不是抛错");
|
|
218
|
+
assert.equal(kb.listCollections().length, 0);
|
|
219
|
+
assert.equal(kb.listDocs({ collection: "c" }).length, 0);
|
|
220
|
+
// 孤儿行是隐性占空间:文档行与分块行都要真没了。
|
|
221
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM kb_docs").get().c, 0);
|
|
222
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM kb_chunks").get().c, 0);
|
|
223
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM kb_docs WHERE id = ?").get(r.docId).c, 0);
|
|
224
|
+
} finally {
|
|
225
|
+
await rmTempEventually(dir);
|
|
226
|
+
store.close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("入库失败留痕不静默:来源不存在 / 集合不存在 / kind 非法都回 error", async () => {
|
|
231
|
+
const { store, kb } = newKb();
|
|
232
|
+
const dir = makeTempDir();
|
|
233
|
+
try {
|
|
234
|
+
kb.upsertCollection({ key: "c", title: "c" });
|
|
235
|
+
|
|
236
|
+
const missing = await kb.ingest({ collection: "c", kind: "file", source: join(dir, "nope.md") });
|
|
237
|
+
assert.equal(missing.error, "file-not-found");
|
|
238
|
+
// 失败要能出现在面板上(否则用户看到的是「点了没反应」)。
|
|
239
|
+
const docs = kb.listDocs({ collection: "c" });
|
|
240
|
+
assert.equal(docs.length, 1);
|
|
241
|
+
assert.equal(docs[0].status, "error");
|
|
242
|
+
assert.equal(docs[0].error, "file-not-found");
|
|
243
|
+
|
|
244
|
+
const noCol = await kb.ingest({ collection: "ghost", kind: "file", source: join(dir, "nope.md") });
|
|
245
|
+
assert.equal(noCol.error, "collection-not-found");
|
|
246
|
+
assert.equal(kb.listCollections().length, 1, "拼错的 key 不许凭空建集合");
|
|
247
|
+
|
|
248
|
+
const badKind = await kb.ingest({ collection: "c", kind: "pdf", source: join(dir, "nope.md") });
|
|
249
|
+
assert.equal(badKind.error, "bad-kind");
|
|
250
|
+
} finally {
|
|
251
|
+
await rmTempEventually(dir);
|
|
252
|
+
store.close();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("dispose 之后不再持有索引缓存(可安全重复调用)", async () => {
|
|
257
|
+
const { store, kb } = newKb();
|
|
258
|
+
const dir = makeTempDir();
|
|
259
|
+
try {
|
|
260
|
+
kb.upsertCollection({ key: "c", title: "c" });
|
|
261
|
+
const src = writeDoc(dir, "d.md", "## 节\n缓存测试 cache\n");
|
|
262
|
+
await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
263
|
+
assert.equal(kb.search({ query: "cache" }).hits.length, 1);
|
|
264
|
+
kb.dispose();
|
|
265
|
+
kb.dispose();
|
|
266
|
+
// 释放索引不等于丢数据:再查一次仍然命中(懒物化重建)。
|
|
267
|
+
assert.equal(kb.search({ query: "cache" }).hits.length, 1);
|
|
268
|
+
} finally {
|
|
269
|
+
await rmTempEventually(dir);
|
|
270
|
+
store.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// 知识库 HTTP 面的形状锁(契约 §3.6 的路径表)。这些用例防的是三类回归:
|
|
2
|
+
// 1) 路径/方法/请求体字段与 C 线面板对不上(两边各自发挥 → 面板「点了没反应」);
|
|
3
|
+
// 2) 漏声明 policy 或同一 kind+path 重复登记(src/api.js 的 registrar 会直接抛,
|
|
4
|
+
// 插件起不来——所以这里用真的 registrar 语义手搓一个会抛的假 register);
|
|
5
|
+
// 3) 应答把错误吞掉(来源找不到却回 200 + ok:true,面板就会显示「已入库」)。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
13
|
+
import { createStore } from "../src/store.js";
|
|
14
|
+
import { createKb } from "../src/kb/index.js";
|
|
15
|
+
import { registerKbRoutes } from "../src/kb/routes.js";
|
|
16
|
+
import { kbToolDefinitions } from "../src/kb/tools.js";
|
|
17
|
+
|
|
18
|
+
const POLICIES = ["open", "token", "token-write"];
|
|
19
|
+
|
|
20
|
+
async function rmTempEventually(dir) {
|
|
21
|
+
for (let i = 0; i < 10; i++) {
|
|
22
|
+
try {
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
return;
|
|
25
|
+
} catch {
|
|
26
|
+
await sleep(200);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 手搓一个与 createRouteRegistrar 同语义的假 register(校验 policy + 拒重复)。 */
|
|
32
|
+
function makeRegistrar() {
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
const routes = [];
|
|
35
|
+
const register = (route) => {
|
|
36
|
+
assert.ok(POLICIES.includes(route.policy), `路由 ${route.path} 漏了 policy`);
|
|
37
|
+
const key = `${route.kind}:${route.path}`;
|
|
38
|
+
assert.ok(!seen.has(key), `路由 ${key} 被重复登记`);
|
|
39
|
+
seen.set(key, route);
|
|
40
|
+
routes.push(route);
|
|
41
|
+
};
|
|
42
|
+
return { register, routes };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeReq({ method = "GET", url = "/", body = null } = {}) {
|
|
46
|
+
const req = Readable.from(body === null ? [] : [Buffer.from(body, "utf8")]);
|
|
47
|
+
req.method = method;
|
|
48
|
+
req.url = url;
|
|
49
|
+
return req;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeRes() {
|
|
53
|
+
const res = { statusCode: null, body: null, headersSent: false };
|
|
54
|
+
res.setHeader = () => {};
|
|
55
|
+
res.writeHead = (code) => { res.statusCode = code; res.headersSent = true; return res; };
|
|
56
|
+
res.end = (payload) => { if (payload) res.body = JSON.parse(String(payload)); };
|
|
57
|
+
return res;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sendJson = (res, status, payload) => {
|
|
61
|
+
res.writeHead(status);
|
|
62
|
+
res.setHeader("content-type", "application/json");
|
|
63
|
+
res.end(JSON.stringify(payload));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 装好路由并直接调 handler。install 的 readBody/parseBody 与 src/api.js 的同名
|
|
68
|
+
* 函数同语义(超限回 null、坏 JSON 回 {}),这样测的是真分支而不是理想化输入。
|
|
69
|
+
*/
|
|
70
|
+
function install({ kb, gate, cfg = {} } = {}) {
|
|
71
|
+
const { register, routes } = makeRegistrar();
|
|
72
|
+
registerKbRoutes(register, {
|
|
73
|
+
kb,
|
|
74
|
+
cfg,
|
|
75
|
+
sendJson,
|
|
76
|
+
gate,
|
|
77
|
+
readBody: async (req) => {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
for await (const c of req) chunks.push(c);
|
|
80
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
81
|
+
},
|
|
82
|
+
parseBody: (text) => {
|
|
83
|
+
try { return JSON.parse(text || "{}"); } catch { return {}; }
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const byPath = (path) => {
|
|
87
|
+
const found = routes.filter((r) => r.path === path);
|
|
88
|
+
assert.equal(found.length, 1, `路径 ${path} 应有且仅有一条`);
|
|
89
|
+
return found[0];
|
|
90
|
+
};
|
|
91
|
+
const call = async (path, opts) => {
|
|
92
|
+
// 注册表按无 query 的路径索引;调用点传的是「路径 + query」的完整 URL。
|
|
93
|
+
const route = byPath(path.split("?")[0]);
|
|
94
|
+
const req = makeReq({ url: path, ...opts });
|
|
95
|
+
const res = makeRes();
|
|
96
|
+
await route.handler(req, res);
|
|
97
|
+
return res;
|
|
98
|
+
};
|
|
99
|
+
return { routes, call, byPath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function newKb() {
|
|
103
|
+
const store = createStore(":memory:");
|
|
104
|
+
return { store, kb: createKb({ store, cfg: {} }) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("六条路由全在场,路径与 policy 严格照 §3.6 的表", () => {
|
|
108
|
+
const { store, kb } = newKb();
|
|
109
|
+
try {
|
|
110
|
+
const { routes } = install({ kb });
|
|
111
|
+
const table = routes.map((r) => `${r.kind}:${r.path}:${r.policy}`).sort();
|
|
112
|
+
assert.deepEqual(table, [
|
|
113
|
+
"exact:/api/dsh-mneme/kb/collections:token-write",
|
|
114
|
+
"exact:/api/dsh-mneme/kb/doc:token",
|
|
115
|
+
"exact:/api/dsh-mneme/kb/docs:token",
|
|
116
|
+
"exact:/api/dsh-mneme/kb/ingest:token-write",
|
|
117
|
+
"exact:/api/dsh-mneme/kb/search:token-write"
|
|
118
|
+
].sort());
|
|
119
|
+
} finally {
|
|
120
|
+
store.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("kb 未装配时全部 503,不抛也不静默 200", async () => {
|
|
125
|
+
const { call } = install({ kb: null });
|
|
126
|
+
for (const [path, opts] of [
|
|
127
|
+
["/api/dsh-mneme/kb/collections", { method: "GET" }],
|
|
128
|
+
["/api/dsh-mneme/kb/docs", { method: "GET", url: "/api/dsh-mneme/kb/docs?collection=c" }],
|
|
129
|
+
["/api/dsh-mneme/kb/doc", { method: "GET", url: "/api/dsh-mneme/kb/doc?collection=c&relKey=x" }],
|
|
130
|
+
["/api/dsh-mneme/kb/ingest", { method: "POST", body: "{}" }],
|
|
131
|
+
["/api/dsh-mneme/kb/search", { method: "POST", body: "{}" }]
|
|
132
|
+
]) {
|
|
133
|
+
const res = await call(path, opts);
|
|
134
|
+
assert.equal(res.statusCode, 503, `${path} 应 503`);
|
|
135
|
+
assert.equal(res.body.error, "unavailable");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("传入的 gate 原样生效(关着时 409 由 gate 回,路由自己不再判)", async () => {
|
|
140
|
+
const { store, kb } = newKb();
|
|
141
|
+
try {
|
|
142
|
+
let gateCalls = 0;
|
|
143
|
+
const { call } = install({
|
|
144
|
+
kb,
|
|
145
|
+
gate: (_req, res) => {
|
|
146
|
+
gateCalls += 1;
|
|
147
|
+
sendJson(res, 409, { error: "disabled", reason: "kb-not-enabled" });
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const res = await call("/api/dsh-mneme/kb/collections", { method: "GET" });
|
|
152
|
+
assert.equal(res.statusCode, 409);
|
|
153
|
+
assert.equal(res.body.reason, "kb-not-enabled");
|
|
154
|
+
assert.equal(gateCalls, 1, "每个请求过且只过一次 gate");
|
|
155
|
+
} finally {
|
|
156
|
+
store.close();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("集合:POST 建 → GET 列出 → DELETE 按 query key 删,重复删 deleted:false", async () => {
|
|
161
|
+
const { store, kb } = newKb();
|
|
162
|
+
try {
|
|
163
|
+
const { call } = install({ kb });
|
|
164
|
+
|
|
165
|
+
let res = await call("/api/dsh-mneme/kb/collections", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify({ key: "manual", title: "手册", description: "内部" })
|
|
168
|
+
});
|
|
169
|
+
assert.equal(res.statusCode, 200);
|
|
170
|
+
assert.deepEqual(res.body, { ok: true, key: "manual" });
|
|
171
|
+
|
|
172
|
+
res = await call("/api/dsh-mneme/kb/collections", { method: "GET" });
|
|
173
|
+
assert.equal(res.body.collections.length, 1);
|
|
174
|
+
assert.equal(res.body.collections[0].key, "manual");
|
|
175
|
+
assert.equal(res.body.collections[0].docs, 0);
|
|
176
|
+
|
|
177
|
+
// 缺 key 是 400,不是默默建一个空 key 的集合。
|
|
178
|
+
res = await call("/api/dsh-mneme/kb/collections", { method: "POST", body: "{}" });
|
|
179
|
+
assert.equal(res.statusCode, 400);
|
|
180
|
+
assert.equal(res.body.error, "key-required");
|
|
181
|
+
|
|
182
|
+
res = await call("/api/dsh-mneme/kb/collections?key=manual", { method: "DELETE" });
|
|
183
|
+
assert.deepEqual(res.body, { ok: true, deleted: true });
|
|
184
|
+
|
|
185
|
+
res = await call("/api/dsh-mneme/kb/collections?key=manual", { method: "DELETE" });
|
|
186
|
+
assert.deepEqual(res.body, { ok: true, deleted: false }, "重复删是 false 不是 500");
|
|
187
|
+
} finally {
|
|
188
|
+
store.close();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("ingest:成功回 ok:true + skipped 语义透出;坏 body / 缺字段 / 拼错集合各走各的码", async () => {
|
|
193
|
+
const { store, kb } = newKb();
|
|
194
|
+
const dir = mkdtempSync(join(tmpdir(), "kb-routes-"));
|
|
195
|
+
try {
|
|
196
|
+
const { call } = install({ kb });
|
|
197
|
+
await call("/api/dsh-mneme/kb/collections", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
body: JSON.stringify({ key: "c", title: "C" })
|
|
200
|
+
});
|
|
201
|
+
const src = join(dir, "a.md");
|
|
202
|
+
writeFileSync(src, "## 节\n路由测试内容 router\n", "utf8");
|
|
203
|
+
|
|
204
|
+
let res = await call("/api/dsh-mneme/kb/ingest", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
body: JSON.stringify({ collection: "c", kind: "file", source: src })
|
|
207
|
+
});
|
|
208
|
+
assert.equal(res.statusCode, 200);
|
|
209
|
+
assert.equal(res.body.ok, true);
|
|
210
|
+
assert.equal(res.body.skipped, false);
|
|
211
|
+
assert.ok(res.body.chunks >= 1);
|
|
212
|
+
assert.ok(res.body.docId > 0);
|
|
213
|
+
|
|
214
|
+
// 再入库:skipped 必须透传到 HTTP 面(面板据此不显示「重新索引了」)。
|
|
215
|
+
res = await call("/api/dsh-mneme/kb/ingest", {
|
|
216
|
+
method: "POST",
|
|
217
|
+
body: JSON.stringify({ collection: "c", kind: "file", source: src })
|
|
218
|
+
});
|
|
219
|
+
assert.equal(res.body.skipped, true);
|
|
220
|
+
|
|
221
|
+
// 缺字段 → 400。
|
|
222
|
+
res = await call("/api/dsh-mneme/kb/ingest", { method: "POST", body: "{}" });
|
|
223
|
+
assert.equal(res.statusCode, 400);
|
|
224
|
+
assert.equal(res.body.error, "collection-kind-source-required");
|
|
225
|
+
|
|
226
|
+
// 坏 JSON 不崩:parseBody 回 {} → 同样落到缺字段 400。
|
|
227
|
+
res = await call("/api/dsh-mneme/kb/ingest", { method: "POST", body: "{not json" });
|
|
228
|
+
assert.equal(res.statusCode, 400);
|
|
229
|
+
|
|
230
|
+
// 拼错集合 → 404(且不凭空建集合)。
|
|
231
|
+
res = await call("/api/dsh-mneme/kb/ingest", {
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: JSON.stringify({ collection: "ghost", kind: "file", source: src })
|
|
234
|
+
});
|
|
235
|
+
assert.equal(res.statusCode, 404);
|
|
236
|
+
assert.equal(res.body.error, "collection-not-found");
|
|
237
|
+
assert.equal(kb.listCollections().length, 1);
|
|
238
|
+
|
|
239
|
+
// kind 非法 → 400。
|
|
240
|
+
res = await call("/api/dsh-mneme/kb/ingest", {
|
|
241
|
+
method: "POST",
|
|
242
|
+
body: JSON.stringify({ collection: "c", kind: "pdf", source: src })
|
|
243
|
+
});
|
|
244
|
+
assert.equal(res.statusCode, 400);
|
|
245
|
+
assert.equal(res.body.error, "bad-kind");
|
|
246
|
+
|
|
247
|
+
// 来源不存在 → **200 + ok:false**:kb_docs 已留 error 行,面板要能显示原因;
|
|
248
|
+
// 回 4xx 会让面板当成「请求错了」把那条记录丢掉。
|
|
249
|
+
res = await call("/api/dsh-mneme/kb/ingest", {
|
|
250
|
+
method: "POST",
|
|
251
|
+
body: JSON.stringify({ collection: "c", kind: "file", source: join(dir, "nope.md") })
|
|
252
|
+
});
|
|
253
|
+
assert.equal(res.statusCode, 200);
|
|
254
|
+
assert.equal(res.body.ok, false);
|
|
255
|
+
assert.equal(res.body.error, "file-not-found");
|
|
256
|
+
|
|
257
|
+
const docs = await call("/api/dsh-mneme/kb/docs?collection=c", { method: "GET" });
|
|
258
|
+
assert.equal(docs.body.docs.length, 2);
|
|
259
|
+
assert.equal(docs.body.docs.filter((d) => d.status === "error").length, 1);
|
|
260
|
+
} finally {
|
|
261
|
+
await rmTempEventually(dir);
|
|
262
|
+
store.close();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("search / doc:命中形状与 readDoc 走 query 参数;查不到回 404", async () => {
|
|
267
|
+
const { store, kb } = newKb();
|
|
268
|
+
const dir = mkdtempSync(join(tmpdir(), "kb-routes-"));
|
|
269
|
+
try {
|
|
270
|
+
const { call } = install({ kb });
|
|
271
|
+
await call("/api/dsh-mneme/kb/collections", {
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: JSON.stringify({ key: "c", title: "C" })
|
|
274
|
+
});
|
|
275
|
+
const src = join(dir, "s.md");
|
|
276
|
+
const original = "# 题\n\n## 检索\n这里有 needle 这个词。\n";
|
|
277
|
+
writeFileSync(src, original, "utf8");
|
|
278
|
+
await call("/api/dsh-mneme/kb/ingest", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
body: JSON.stringify({ collection: "c", kind: "file", source: src })
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let res = await call("/api/dsh-mneme/kb/search", {
|
|
284
|
+
method: "POST",
|
|
285
|
+
body: JSON.stringify({ query: "needle" })
|
|
286
|
+
});
|
|
287
|
+
assert.equal(res.statusCode, 200);
|
|
288
|
+
assert.equal(res.body.hits.length, 1);
|
|
289
|
+
assert.deepEqual(res.body.unreadable, []);
|
|
290
|
+
assert.equal(res.body.hits[0].collection, "c");
|
|
291
|
+
assert.equal(res.body.hits[0].relKey, src);
|
|
292
|
+
assert.equal(res.body.hits[0].heading, "检索");
|
|
293
|
+
|
|
294
|
+
// 空 query 是 400:回 200 + 空 hits 会让模型以为「库里没这个」。
|
|
295
|
+
res = await call("/api/dsh-mneme/kb/search", { method: "POST", body: JSON.stringify({ query: " " }) });
|
|
296
|
+
assert.equal(res.statusCode, 400);
|
|
297
|
+
assert.equal(res.body.error, "query-required");
|
|
298
|
+
|
|
299
|
+
// GET 打 search 是 405(不是静默 200)。
|
|
300
|
+
res = await call("/api/dsh-mneme/kb/search", { method: "GET" });
|
|
301
|
+
assert.equal(res.statusCode, 405);
|
|
302
|
+
|
|
303
|
+
res = await call(
|
|
304
|
+
`/api/dsh-mneme/kb/doc?collection=c&relKey=${encodeURIComponent(src)}`,
|
|
305
|
+
{ method: "GET" }
|
|
306
|
+
);
|
|
307
|
+
assert.equal(res.statusCode, 200);
|
|
308
|
+
assert.equal(res.body.content, original);
|
|
309
|
+
assert.equal(res.body.title, "s.md");
|
|
310
|
+
|
|
311
|
+
res = await call("/api/dsh-mneme/kb/doc?collection=c&relKey=nope", { method: "GET" });
|
|
312
|
+
assert.equal(res.statusCode, 404);
|
|
313
|
+
assert.equal(res.body.error, "doc-not-found");
|
|
314
|
+
|
|
315
|
+
res = await call("/api/dsh-mneme/kb/doc?collection=c", { method: "GET" });
|
|
316
|
+
assert.equal(res.statusCode, 400);
|
|
317
|
+
} finally {
|
|
318
|
+
await rmTempEventually(dir);
|
|
319
|
+
store.close();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("三个工具的形状:名字/只读性/execute 在 kb 缺席时给人话", async () => {
|
|
324
|
+
const { store, kb } = newKb();
|
|
325
|
+
const dir = mkdtempSync(join(tmpdir(), "kb-tools-"));
|
|
326
|
+
try {
|
|
327
|
+
const defs = kbToolDefinitions({ kb, cfg: {} });
|
|
328
|
+
assert.deepEqual(defs.map((d) => d.name).sort(), ["kb_list_collections", "kb_read_doc", "kb_search"]);
|
|
329
|
+
for (const d of defs) {
|
|
330
|
+
assert.equal(typeof d.description, "string");
|
|
331
|
+
assert.ok(d.description.length > 20, `${d.name} 的描述要能指导模型`);
|
|
332
|
+
assert.equal(typeof d.parameters, "object");
|
|
333
|
+
assert.equal(typeof d.output?.schema, "object");
|
|
334
|
+
assert.equal(typeof d.output?.render, "function");
|
|
335
|
+
assert.equal(typeof d.execute, "function");
|
|
336
|
+
// 只读红线:参数里不许出现任何「写」味的东西(工具侧只有读口)。
|
|
337
|
+
assert.ok(!/ingest|upsert|delete|write/i.test(d.name));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await kb.upsertCollection({ key: "c", title: "C" });
|
|
341
|
+
const src = join(dir, "t.md");
|
|
342
|
+
writeFileSync(src, "## 节\ntool content\n", "utf8");
|
|
343
|
+
await kb.ingest({ collection: "c", kind: "file", source: src });
|
|
344
|
+
|
|
345
|
+
const [list, search, read] = [
|
|
346
|
+
defs.find((d) => d.name === "kb_list_collections"),
|
|
347
|
+
defs.find((d) => d.name === "kb_search"),
|
|
348
|
+
defs.find((d) => d.name === "kb_read_doc")
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const listed = await list.execute({});
|
|
352
|
+
assert.equal(listed.collections.length, 1);
|
|
353
|
+
assert.equal(listed.collections[0].key, "c");
|
|
354
|
+
|
|
355
|
+
const hit = await search.execute({ query: "tool" });
|
|
356
|
+
assert.equal(hit.hits.length, 1);
|
|
357
|
+
assert.deepEqual(hit.unreadable, []);
|
|
358
|
+
|
|
359
|
+
const doc = await read.execute({ collection: "c", relKey: src });
|
|
360
|
+
assert.equal(doc.content, "## 节\ntool content\n");
|
|
361
|
+
assert.equal(doc.title, "t.md");
|
|
362
|
+
|
|
363
|
+
// 查不到要抛(模型看到的是「不存在」而不是空串——空串会被当成空文档继续编)。
|
|
364
|
+
await assert.rejects(() => read.execute({ collection: "c", relKey: "/nope" }), /was not found/);
|
|
365
|
+
await assert.rejects(() => search.execute({ query: " " }), /query is required/);
|
|
366
|
+
await assert.rejects(() => read.execute({ collection: "c" }), /both required/);
|
|
367
|
+
|
|
368
|
+
// render 不能因为空结果就炸(回执里 hits/collections 可能是空数组)。
|
|
369
|
+
assert.ok(list.output.render({}, { collections: [] })[0].text.includes("0 collections"));
|
|
370
|
+
assert.ok(search.output.render({}, { hits: [] })[0].text.includes("0 hits"));
|
|
371
|
+
|
|
372
|
+
// 未装配:不返回 undefined.name,而是明确抛一句人话。
|
|
373
|
+
const orphan = kbToolDefinitions({ kb: null })[0];
|
|
374
|
+
await assert.rejects(() => orphan.execute({}), /not available/);
|
|
375
|
+
} finally {
|
|
376
|
+
await rmTempEventually(dir);
|
|
377
|
+
store.close();
|
|
378
|
+
}
|
|
379
|
+
});
|
package/test/mint-source.test.js
CHANGED
|
@@ -260,6 +260,32 @@ test("issue#512: only creation is recorded — edits to existing rows stay in re
|
|
|
260
260
|
store.close();
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
// #619 / Ephor 第七轮 ③:原地刷新不是出生。同一个 pattern 跑两轮,第一轮真铸了、
|
|
264
|
+
// 第二轮只是把同一行重写;memory_mints 的语义是「这条记忆出生自哪一轮」,第二行
|
|
265
|
+
// 就是「同一条记忆出生过两次」——面板上会显示两条出生记录(mintsFor 由
|
|
266
|
+
// provenance-http.js 读)。live 库当时还没显形,因为同题刷新刚上线。
|
|
267
|
+
test("issue#619r: a refreshed pattern does not record a second birth", () => {
|
|
268
|
+
const { store, service } = setup();
|
|
269
|
+
const decision = [{ action: "create", type: "pattern", title: "同题规律", content: "第一轮", importance: 3 }];
|
|
270
|
+
const first = applyDecisions(decision, service, null, null, { mintSource: "sleep", runId: "run-a" });
|
|
271
|
+
assert.equal(first.applied, 1, "第一轮真铸了");
|
|
272
|
+
const row = service.all().find((m) => m.type === "pattern");
|
|
273
|
+
assert.equal(store.mintsFor(row.id).length, 1);
|
|
274
|
+
|
|
275
|
+
const second = applyDecisions(
|
|
276
|
+
[{ action: "create", type: "pattern", title: "同题规律", content: "第二轮", importance: 3 }],
|
|
277
|
+
service,
|
|
278
|
+
null,
|
|
279
|
+
null,
|
|
280
|
+
{ mintSource: "sleep", runId: "run-b" }
|
|
281
|
+
);
|
|
282
|
+
assert.equal(second.applied, 0, "第二轮没有新铸");
|
|
283
|
+
assert.equal(second.merged, 1, "它刷新了一条");
|
|
284
|
+
assert.equal(second.committed[0].action, "create_merged");
|
|
285
|
+
assert.equal(store.mintsFor(row.id).length, 1, "刷新不算再一次出生");
|
|
286
|
+
store.close();
|
|
287
|
+
});
|
|
288
|
+
|
|
263
289
|
// 端到端:sleep 真的跑一轮,铸造出来的行要指回那一轮的 runId(不是随便一个 id)。
|
|
264
290
|
test("issue#512: sleep mints point back at the run that produced them", async () => {
|
|
265
291
|
const { store, service } = setup();
|