@modusensus/dsh-mneme 0.11.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.
Files changed (174) hide show
  1. package/README.md +54 -21
  2. package/docs/CONFIGURATION.md +45 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +744 -26
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3752 -819
  16. package/lib/config.js +171 -2
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/document.js +9 -3
  28. package/lib/dream/clustering.js +6 -1
  29. package/lib/dream/decisions.js +56 -5
  30. package/lib/dream/sleep.js +616 -15
  31. package/lib/dream.js +45 -13
  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 +8 -9
  44. package/lib/index.js +131 -5
  45. package/lib/inject.js +192 -19
  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 +115 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/profile-facts.js +356 -0
  52. package/lib/semantic.js +347 -60
  53. package/lib/service.js +302 -70
  54. package/lib/settings.js +117 -14
  55. package/lib/store.js +830 -55
  56. package/lib/summarize.js +38 -11
  57. package/lib/tools.js +173 -25
  58. package/lib/user-settings.js +175 -23
  59. package/lib/vector-index.js +76 -6
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +264 -10
  62. package/lib/wiki/t1-jobs.js +232 -0
  63. package/lib/wiki/t1.js +7 -1
  64. package/package.json +25 -1
  65. package/scripts/sync-test-badge.mjs +1 -1
  66. package/scripts/test-count-sync.mjs +9 -5
  67. package/src/api-standalone.js +4 -1
  68. package/src/api.js +744 -26
  69. package/src/attachment-text.js +122 -0
  70. package/src/choro-command.js +9 -2
  71. package/src/config.js +171 -2
  72. package/src/continuity.js +7 -6
  73. package/src/diary/auto.js +190 -0
  74. package/src/diary/compose.js +440 -0
  75. package/src/diary/dir.js +59 -0
  76. package/src/diary/export.js +97 -0
  77. package/src/diary/index.js +26 -0
  78. package/src/diary/library.js +189 -0
  79. package/src/diary/qq-inbox.js +142 -0
  80. package/src/diary/rollover.js +61 -0
  81. package/src/diary/session-provider.js +122 -0
  82. package/src/document.js +9 -3
  83. package/src/dream/clustering.js +6 -1
  84. package/src/dream/decisions.js +56 -5
  85. package/src/dream/sleep.js +616 -15
  86. package/src/dream.js +45 -13
  87. package/src/dsh-go/host.js +162 -0
  88. package/src/dsh-go/panel.js +2091 -0
  89. package/src/dsh-go/text.js +342 -0
  90. package/src/dsh-go/views/common.js +68 -0
  91. package/src/dsh-go/views/entity.js +48 -0
  92. package/src/dsh-go/views/memory.js +198 -0
  93. package/src/dsh-go/views/root.js +30 -0
  94. package/src/dsh-go/views/settings.js +398 -0
  95. package/src/dsh-go/views/status.js +51 -0
  96. package/src/embedding.js +42 -8
  97. package/src/entities/extractor.js +36 -7
  98. package/src/hot-memory.js +8 -9
  99. package/src/index.js +131 -5
  100. package/src/inject.js +192 -19
  101. package/src/kb/index.js +309 -0
  102. package/src/kb/routes.js +220 -0
  103. package/src/kb/tools.js +148 -0
  104. package/src/lang.js +115 -4
  105. package/src/peak-hours.js +57 -2
  106. package/src/profile-facts.js +356 -0
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +302 -70
  109. package/src/settings.js +117 -14
  110. package/src/store.js +830 -55
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +173 -25
  113. package/src/user-settings.js +175 -23
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/cli-hint.js +90 -0
  116. package/src/wiki/queries.js +264 -10
  117. package/src/wiki/t1-jobs.js +232 -0
  118. package/src/wiki/t1.js +7 -1
  119. package/test/api-routes-gaps.test.js +63 -0
  120. package/test/api.test.js +377 -4
  121. package/test/attachment-text.test.js +140 -0
  122. package/test/bm25-corpus-cache.test.js +145 -0
  123. package/test/capacity-warning.test.js +262 -0
  124. package/test/client.test.js +908 -138
  125. package/test/config.test.js +79 -0
  126. package/test/conflict-freeze.test.js +45 -1
  127. package/test/continuity.test.js +8 -1
  128. package/test/diary-library.test.js +187 -0
  129. package/test/diary.test.js +576 -0
  130. package/test/document.test.js +32 -0
  131. package/test/dream-maxtokens-fallback.test.js +3 -3
  132. package/test/dream.test.js +62 -1
  133. package/test/dsh-go-host.test.js +134 -0
  134. package/test/dsh-go-panel-actions.test.js +854 -0
  135. package/test/dsh-go-panel.test.js +251 -0
  136. package/test/embedding-cache-f32.test.js +276 -0
  137. package/test/embedding-ready.test.js +33 -3
  138. package/test/entities.test.js +73 -0
  139. package/test/forget-corrections.test.js +70 -0
  140. package/test/image-memory.test.js +171 -0
  141. package/test/inject-pin-explicit.test.js +300 -0
  142. package/test/inject-pin-vector-crowd.test.js +225 -0
  143. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  144. package/test/kb-api-wiring.test.js +128 -0
  145. package/test/kb-collections.test.js +272 -0
  146. package/test/kb-routes.test.js +379 -0
  147. package/test/mint-source.test.js +26 -0
  148. package/test/peak-hours-observability.test.js +70 -0
  149. package/test/persona-api.test.js +3 -3
  150. package/test/persona-by-session.test.js +568 -0
  151. package/test/persona-settings.test.js +38 -7
  152. package/test/profile-facts.test.js +492 -0
  153. package/test/reflection-update-gate.test.js +129 -0
  154. package/test/reindex-backfill.test.js +334 -0
  155. package/test/reindex-scheduler.test.js +369 -0
  156. package/test/scope-candidates.test.js +249 -0
  157. package/test/session-gate.test.js +1 -1
  158. package/test/sleep-action-set.test.js +14 -0
  159. package/test/sleep-heat.test.js +49 -0
  160. package/test/sleep.test.js +598 -0
  161. package/test/standalone-api.test.js +24 -0
  162. package/test/store.test.js +20 -0
  163. package/test/summarize.test.js +26 -0
  164. package/test/test-count-sync.test.js +14 -3
  165. package/test/tool-state-ignore.test.js +65 -0
  166. package/test/tools-dto-schema-matrix.test.js +23 -2
  167. package/test/tools.test.js +144 -7
  168. package/test/user-settings-segments.test.js +528 -0
  169. package/test/vector-index.test.js +120 -6
  170. package/test/wiki-api.test.js +476 -1
  171. package/test/wiki-cli-hint.test.js +118 -0
  172. package/test/wiki-queries.test.js +91 -1
  173. package/test/wiki-t1-jobs.test.js +125 -0
  174. package/test/wiki-tools.test.js +131 -76
@@ -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
+ });
@@ -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();
@@ -0,0 +1,70 @@
1
+ // Issue #622 可观测性回归:错峰时段串解析失败不许静默。
2
+ //
3
+ // parsePeakSpec 的失败语义(整串视为未配置)是刻意的契约——排程是省钱手段,绝不
4
+ // 该因为写错格式把蒸馏/巩固停掉。但「不救错」不等于「不吭声」:#424 的排查只能靠
5
+ // 「高峰期还在巩固」倒推,正是静默的全貌。本文件锁三件事:
6
+ // ① invalidPeakSpecs 只报「非空且解析失败」——空串/未配置/合法串/全角归一救得了
7
+ // 的写法一律不报(验收第 2 条:不骚扰没配错峰的用户;#621 已落归一,两处共存);
8
+ // ② index.js 的启动告警真的接在 cfg 装配之后(接线锁,仿 failure-retention 的
9
+ // 写法):告警读的是装配后的 cfg——面板改的 flatFlags 合并进来的值也算数;
10
+ // ③ /features 答复的 invalidPeakHours 与 helper 同源(api.test.js 里另有往返)。
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import path from "node:path";
16
+ import { parsePeakSpec, invalidPeakSpecs, PEAK_SPEC_KEYS } from "../src/peak-hours.js";
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const indexSrc = readFileSync(path.join(here, "..", "src", "index.js"), "utf8");
20
+
21
+ test("invalidPeakSpecs: 只报非空且解析失败的键;空串/未配置/合法串不报", () => {
22
+ // 未配置(undefined)与空串:合法的「未配置」,不告警
23
+ assert.deepEqual(invalidPeakSpecs({}), {});
24
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "" }), {});
25
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: " " }), {});
26
+ assert.deepEqual(invalidPeakSpecs({ dreamPeakHours: undefined }), {});
27
+ // 合法串:不报
28
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "09:00-18:00" }), {});
29
+ assert.deepEqual(invalidPeakSpecs({ dreamPeakHours: "mon-fri 08:00-12:00,14:00-18:00" }), {});
30
+ // 全角等价写法:本批原基于未含 #621 归一的主线,当时 parsePeakSpec 解析失败、
31
+ // 「报」是真话。**#621 已并入 main**(全角逗号/冒号/数字归一),解析现在成功,
32
+ // 本断言随之翻转为不报——这正是 helper「只转述解析器、不自带第二套对错标准」
33
+ // 的意义:判据翻转由解析器驱动,本文件不需要改任何逻辑。
34
+ // ⚠️ 原注释里「两批共存无需联动改动」这句是错的:判据确实会翻,只是翻的位置
35
+ // 在这里而不在 src/。合并 #621 后必须同步翻这一行,否则就是假红。
36
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "09:00-18:00" }), {});
37
+ // 非法串:报,且带原串(告警要对得上配置文件里那一行)
38
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "24:00-08:00" }), { summarizePeakHours: "24:00-08:00" });
39
+ assert.deepEqual(invalidPeakSpecs({ dreamPeakHours: "09.00-18.00" }), { dreamPeakHours: "09.00-18.00" });
40
+ // 两个键互相独立,各查各的("ok" 没有冒号、没有时段段——解析器视为非法,报得对)
41
+ const both = invalidPeakSpecs({ summarizePeakHours: "09:00-18:00", dreamPeakHours: "24:00-01:00" });
42
+ assert.deepEqual(both, { dreamPeakHours: "24:00-01:00" });
43
+ // 非对象(裸调用方传 undefined 之类)不抛、返回空
44
+ assert.deepEqual(invalidPeakSpecs(undefined), {});
45
+ assert.deepEqual(invalidPeakSpecs(null), {});
46
+ // 非字符串值(垃圾输入)不抛、不报——typeof 守卫
47
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: 42 }), {});
48
+ });
49
+
50
+ test("PEAK_SPEC_KEYS 恰好覆盖两个时段键,parsePeakSpec 对它们的失败语义不变", () => {
51
+ // 键名单独成表:告警点与提示点都要认同一份,谁加键谁改表(不各写数组各自漂移)
52
+ assert.deepEqual(PEAK_SPEC_KEYS, ["summarizePeakHours", "dreamPeakHours"]);
53
+ // 本 issue 不动解析语义:非法 → null、空 → null、合法 → 结构(#239 契约原样)
54
+ assert.equal(parsePeakSpec("24:00-08:00"), null);
55
+ assert.equal(parsePeakSpec(""), null);
56
+ assert.ok(parsePeakSpec("09:00-18:00"));
57
+ });
58
+
59
+ test("接线锁:index.js 的启动告警读装配后的 cfg,且在 sessionGate 之前", () => {
60
+ const cfgAt = indexSrc.indexOf("const cfg = { ...applyLightModePreset({ ...rawCfg, lightMode }), ...flatFlags };");
61
+ const callAt = indexSrc.indexOf("invalidPeakSpecs(cfg)");
62
+ const gateAt = indexSrc.indexOf("const sessionGate = createSessionGate(cfg);");
63
+ assert.ok(cfgAt > 0, "装配行必须在");
64
+ assert.ok(callAt > cfgAt, "告警必须排在 cfg 装配之后——读 rawCfg 会漏掉面板改的 flatFlags");
65
+ assert.ok(gateAt > callAt, "告警在 sessionGate 之前(纯 log,不参与装配顺序的实质,锁在这里只为防它被挪进更早、读不到合并值的分支)");
66
+ // 只 log:告警分支里不许出现改写 cfg 的语句(行为零变化是本 issue 的前提)
67
+ const block = indexSrc.slice(callAt, gateAt);
68
+ assert.ok(!/cfg\s*=[^=]/.test(block), "告警分支不得改写 cfg");
69
+ assert.ok(block.includes("logger?.warn?.("), "失败可见性走 logger.warn");
70
+ });
@@ -75,7 +75,7 @@ test("internal PUT /persona without token is rejected and store untouched", asyn
75
75
  const res = new FakeRes();
76
76
  await route.handler(req("/api/dsh-mneme/persona", "PUT", { persona: { name: "evil" } }), res);
77
77
  assert.equal(res.statusCode, 401);
78
- assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
78
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {}, bySession: {} });
79
79
  store.close();
80
80
  });
81
81
 
@@ -140,7 +140,7 @@ test("standalone GET/PUT /persona behind Bearer gate round-trips", async () => {
140
140
  try {
141
141
  const get1 = await fetch(`${t.base}/persona`, { headers: t.auth });
142
142
  assert.equal(get1.status, 200);
143
- assert.deepEqual((await get1.json()).persona, { default: {}, byAgent: {} });
143
+ assert.deepEqual((await get1.json()).persona, { default: {}, byAgent: {}, bySession: {} });
144
144
  const put = await fetch(`${t.base}/persona`, {
145
145
  method: "PUT",
146
146
  headers: { ...t.auth, "content-type": "application/json" },
@@ -165,7 +165,7 @@ test("standalone /persona without Bearer is 401", async () => {
165
165
  try {
166
166
  const res = await fetch(`${t.base}/persona`, { method: "PUT", body: "{}" });
167
167
  assert.equal(res.status, 401);
168
- assert.deepEqual(t.settings.getPersona(), { default: {}, byAgent: {} });
168
+ assert.deepEqual(t.settings.getPersona(), { default: {}, byAgent: {}, bySession: {} });
169
169
  } finally {
170
170
  t.close();
171
171
  }