@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,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,90 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // 7 天柱状纵轴的回归锁(2026-11)。原实现有两处真错,都在小用量库上暴露:
8
+ // ① 标签写死 (v/1e6).toFixed(0)+"M" —— 750K / 500K 都变 1M、250K 变 0M,
9
+ // 于是轴永远是「1M / 1M / 1M / 0M」。四条线只剩两个不同值时,图就失去刻度。
10
+ // ② 上限被 Math.max(1e6, …) 钉死 —— 日用量 7 万时柱子只有 7% 高,用量越大反而
11
+ // 越正常,正好反了。
12
+ // 第 3 条用例锁的就是 ① 的形状(标签去重后必须仍有 4 个),第 4 条锁 ②(上限不许
13
+ // 比峰值高出一倍半以上)。第 6 条是源级守卫:纯函数绿了、渲染绕过去也等于没修。
14
+
15
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
16
+ const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
17
+
18
+ /* ---------- 切片:纯函数块(同 panel-geometry 的先例) ---------- */
19
+ const begin = clientSource.indexOf("/* panel-chart-scale:begin");
20
+ const end = clientSource.indexOf("/* panel-chart-scale:end */");
21
+ assert.ok(begin > 0 && end > begin, "lib/client.js 里找不到 panel-chart-scale 标记块(锚点变了?)");
22
+ const block = clientSource.slice(begin, end);
23
+ const { chartScale, chartAxisLabel } = new Function(
24
+ `${block}\nreturn { chartScale, chartAxisLabel };`
25
+ )();
26
+
27
+ test("无数据不画轴:0 / 负数 / 非数值一律返回 null", () => {
28
+ for (const v of [0, -1, -1e6, NaN, null, undefined, "abc", "", Infinity]) {
29
+ assert.equal(chartScale(v), null, `chartScale(${String(v)}) 应为 null(调用方画空态)`);
30
+ }
31
+ });
32
+
33
+ test("按量级挑步长:70K / 900K / 2.4M / 3.9M / 12M 的轴都是四条不同的线", () => {
34
+ const cases = [
35
+ [70000, [80000, 60000, 40000, 20000]],
36
+ [900000, [1e6, 750000, 500000, 250000]],
37
+ [2400000, [4e6, 3e6, 2e6, 1e6]],
38
+ [3900000, [4e6, 3e6, 2e6, 1e6]],
39
+ [12000000, [2e7, 1.5e7, 1e7, 5e6]]
40
+ ];
41
+ for (const [dayMax, labels] of cases) {
42
+ const s = chartScale(dayMax);
43
+ assert.deepEqual(s.labels, labels, `dayMax=${dayMax} 的轴不对`);
44
+ assert.equal(s.max, labels[0], "max 必须等于最高那条线");
45
+ assert.equal(s.step * 4, s.max, "step × 4 必须等于 max");
46
+ }
47
+ });
48
+
49
+ test("四条轴标签互不相同(原 bug 的形状:去重后只剩 1M / 0M)", () => {
50
+ const samples = [1, 2, 3, 4, 5, 9, 10, 99, 999, 1000, 4321, 70000, 250000, 900000,
51
+ 1500000, 2400000, 3900000, 12000000, 48000000, 1e9];
52
+ for (const dayMax of samples) {
53
+ const s = chartScale(dayMax);
54
+ const texts = s.labels.map(chartAxisLabel);
55
+ assert.equal(new Set(texts).size, 4, `dayMax=${dayMax} 的轴标签有重复:${texts.join(" / ")}`);
56
+ }
57
+ });
58
+
59
+ test("上限够高又不浪费:max >= 峰值,且峰值 >= 1000 时不超过 2.5 倍", () => {
60
+ for (const dayMax of [1, 2, 3, 5, 999, 1000, 4321, 70000, 900000, 2400000, 3.9e6, 12e6, 1e9]) {
61
+ const s = chartScale(dayMax);
62
+ assert.ok(s.max >= dayMax, `dayMax=${dayMax} 时上限 ${s.max} 装不下柱子`);
63
+ if (dayMax >= 1000) {
64
+ assert.ok(s.max < dayMax * 2.5, `dayMax=${dayMax} 时上限 ${s.max} 浪费过半(又退回 1M 地板了?)`);
65
+ }
66
+ for (let i = 1; i < s.labels.length; i += 1) {
67
+ assert.ok(s.labels[i] < s.labels[i - 1], "轴标签必须自上而下递减");
68
+ }
69
+ }
70
+ });
71
+
72
+ test("轴标签按 K / M 分档且整百万不带小数", () => {
73
+ assert.equal(chartAxisLabel(1e6), "1M");
74
+ assert.equal(chartAxisLabel(3e6), "3M");
75
+ assert.equal(chartAxisLabel(1.5e6), "1.5M");
76
+ assert.equal(chartAxisLabel(750000), "750K");
77
+ assert.equal(chartAxisLabel(250000), "250K");
78
+ assert.equal(chartAxisLabel(80000), "80K");
79
+ assert.equal(chartAxisLabel(1000), "1K");
80
+ assert.equal(chartAxisLabel(500), "500");
81
+ assert.equal(chartAxisLabel(0), "0");
82
+ });
83
+
84
+ test("渲染真的用了这两个函数(纯函数绿了但渲染绕过去等于没修)", () => {
85
+ assert.ok(!/toFixed\(0\)\}M/.test(clientSource), "client.js 里还留着 (v/1e6).toFixed(0)M 的旧标签写法");
86
+ assert.ok(!/Math\.max\(1e6,/.test(clientSource), "client.js 里还留着 Math.max(1e6, …) 的上限地板");
87
+ assert.ok(/chartAxisLabel\(v\)/.test(clientSource), "网格标签没走 chartAxisLabel");
88
+ assert.ok(/d\.total \/ scale\.max/.test(clientSource), "柱高没走 scale.max");
89
+ assert.ok(/memory\.status\.token\.daysEmpty/.test(clientSource), "全零七天没有空态文案");
90
+ });