@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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.
Files changed (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -0,0 +1,128 @@
1
+ // 知识库第二层的接线回归锁(契约 §3.4 / §3.6)。
2
+ //
3
+ // A 线只负责「把已经写好的 kb 子系统接上闸与两条出口」——所以这里锁的是**闸的
4
+ // 分叉形状**,不是 kb 内部功能(那在 kb 自己的测试里)。两条闸必须可区分:
5
+ // 503 = 这个宿主没装配 kb;409 = 用户没开开关。两者混成一个状态码,面板就没法
6
+ // 分辨「去设置里打开」和「这台宿主不支持」。
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { EventEmitter } from "node:events";
10
+ import { Context } from "@deepseek-ai/cordis";
11
+ import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
12
+ import ToolRuntime from "@deepseek-ai/dsh-tools";
13
+ import { createStore } from "../src/store.js";
14
+ import { createService } from "../src/service.js";
15
+ import { createApi } from "../src/api.js";
16
+ import { createSettings } from "../src/settings.js";
17
+ import { createKb } from "../src/kb/index.js";
18
+ import { createTools } from "../src/tools.js";
19
+
20
+ class FakeRes extends EventEmitter {
21
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
22
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
23
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
24
+ }
25
+
26
+ function req(path, method = "GET") {
27
+ const r = new EventEmitter();
28
+ r.url = path;
29
+ r.method = method;
30
+ r.headers = {};
31
+ return r;
32
+ }
33
+
34
+ // 注意实参顺序:kb 是第 12 参,必须显式补到位置(facts 用 undefined 占位)。
35
+ function setup({ config = null, kb = null } = {}) {
36
+ const store = createStore(":memory:");
37
+ const service = createService({ store, mirror: null, config: {} });
38
+ const settings = createSettings(store.db);
39
+ const routes = [];
40
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
41
+ createApi(ctx, service, settings, { add: () => {}, remove: () => false, list: () => [] },
42
+ null, undefined, "", config, null, null, null, undefined, kb);
43
+ return { routes, store };
44
+ }
45
+
46
+ async function invoke(route, path) {
47
+ const res = new FakeRes();
48
+ await route.handler(req(path), res);
49
+ return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
50
+ }
51
+
52
+ test("kb 未装配 → 503 unavailable;装了但开关没开 → 409 disabled(两条闸不许混)", async () => {
53
+ const missing = setup({ config: { kbEnabled: true } });
54
+ const off = setup({ config: { kbEnabled: false }, kb: createKb({ store: missing.store }) });
55
+ try {
56
+ const a = await invoke(missing.routes.find((r) => r.path === "/api/dsh-mneme/kb/collections"), "/api/dsh-mneme/kb/collections");
57
+ assert.equal(a.status, 503);
58
+ assert.equal(a.body.error, "unavailable");
59
+
60
+ const b = await invoke(off.routes.find((r) => r.path === "/api/dsh-mneme/kb/collections"), "/api/dsh-mneme/kb/collections");
61
+ assert.equal(b.status, 409);
62
+ assert.equal(b.body.error, "disabled");
63
+ assert.equal(b.body.reason, "kb-not-enabled");
64
+ } finally {
65
+ missing.store.close();
66
+ off.store.close();
67
+ }
68
+ });
69
+
70
+ test("开关开 + 已装配 → kb 六条路由全在册且 200 通", async () => {
71
+ const s = setup({ config: { kbEnabled: true }, kb: createKb({ store: createStore(":memory:") }) });
72
+ try {
73
+ const paths = [
74
+ "/api/dsh-mneme/kb/collections",
75
+ "/api/dsh-mneme/kb/docs",
76
+ "/api/dsh-mneme/kb/doc",
77
+ "/api/dsh-mneme/kb/ingest",
78
+ "/api/dsh-mneme/kb/search"
79
+ ];
80
+ for (const p of paths) assert.ok(s.routes.find((r) => r.path === p), `${p} 在册`);
81
+
82
+ // 同一路径上混了 GET/POST,policy 只能声明一个,取更严的 token-write。
83
+ // 这条锁防的是「为了省事把集合路径拆成两条」——registrar 会直接抛重复路径。
84
+ const collections = s.routes.find((r) => r.path === "/api/dsh-mneme/kb/collections");
85
+ assert.equal(collections.policy, "token-write");
86
+
87
+ const list = await invoke(collections, "/api/dsh-mneme/kb/collections");
88
+ assert.equal(list.status, 200);
89
+ assert.ok(Array.isArray(list.body.collections), "空知识库也要回空数组,面板靠它渲染空态而不是报错");
90
+ } finally {
91
+ s.store.close();
92
+ }
93
+ });
94
+
95
+ // 用 tools.get(name) 逐个探存在性:宿主注册表没有 list(),而 wiki-tools 里 choro
96
+ // 三工具也是这么断言的(同一套探法,避免两处对「怎么算注册了」有两种理解)。
97
+ async function toolPresence(config, kb, names) {
98
+ const ctx = new Context();
99
+ const prompt = await ctx.plugin(SystemPrompt);
100
+ const runtime = await ctx.plugin(ToolRuntime);
101
+ const store = createStore(":memory:");
102
+ const service = createService({ store, mirror: null, config: {} });
103
+ const registry = ctx.get("tools");
104
+ createTools({ tools: registry }, service, config, null, null, null, kb);
105
+ const present = Object.fromEntries(names.map((n) => [n, Boolean(registry.get(n))]));
106
+ runtime.dispose(); prompt.dispose(); store.close();
107
+ return present;
108
+ }
109
+
110
+ test("kb 三工具:默认不注册,开关打开且装配到位才可见", async () => {
111
+ const KB_TOOLS = ["kb_list_collections", "kb_search", "kb_read_doc"];
112
+ const store = createStore(":memory:");
113
+ const kb = createKb({ store });
114
+
115
+ // 默认(kbEnabled 缺省 false):模型看不到就不会调——与 choro 三工具同款反向语义。
116
+ const off = await toolPresence({}, kb, KB_TOOLS);
117
+ for (const name of KB_TOOLS) assert.equal(off[name], false, `${name} 默认不该注册`);
118
+
119
+ // 开了但装配缺件:同样不可见。可区分性归路由层(503 / 409),模型侧不需要
120
+ // 看见一个调了必抛的半个知识库。
121
+ const noKb = await toolPresence({ kbEnabled: true }, null, KB_TOOLS);
122
+ for (const name of KB_TOOLS) assert.equal(noKb[name], false, `${name} 装配缺件时不该注册`);
123
+
124
+ const on = await toolPresence({ kbEnabled: true }, kb, KB_TOOLS);
125
+ for (const name of KB_TOOLS) assert.equal(on[name], true, `${name} 开关开 + 装配到位时必须注册`);
126
+
127
+ store.close();
128
+ });
@@ -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
+ });