@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
@@ -271,6 +271,52 @@ test("external-api PUT applies valid config and echoes it", async () => {
271
271
  assert.ok(typeof cfg.token === "string");
272
272
  });
273
273
 
274
+ // 名字过滤必须下推到 store:面板的「搜索实体」此前是自己取回 limit 条再本地
275
+ // 过滤,排在 limit 之外的实体永远搜不到。q 走 SQL LIKE,% 与 _ 要转义,否则
276
+ // 用户输入一个 "%" 就把整库匹配出来。
277
+ test("entities q filters by name in the store, not after the limit", async () => {
278
+ const { routes, service } = setup();
279
+ service.createEntity({ name: "SQLite", type: "technology" });
280
+ service.createEntity({ name: "记忆", type: "concept" });
281
+ service.createEntity({ name: "postgres", type: "technology" });
282
+ const route = handler(routes, "/api/dsh-mneme/entities");
283
+
284
+ let res = new FakeRes();
285
+ await route.handler(req("/api/dsh-mneme/entities?q=lite"), res);
286
+ let data = JSON.parse(res.body);
287
+ assert.deepEqual(data.entities.map((e) => e.name), ["SQLite"], "substring match");
288
+
289
+ // q 命中排在 limit 之外的那一条:这正是本地过滤拿不到的情形
290
+ res = new FakeRes();
291
+ await route.handler(req("/api/dsh-mneme/entities?limit=1&q=postgres"), res);
292
+ data = JSON.parse(res.body);
293
+ assert.deepEqual(data.entities.map((e) => e.name), ["postgres"]);
294
+
295
+ // 通配符必须被转义,不能变成「匹配一切」
296
+ res = new FakeRes();
297
+ await route.handler(req("/api/dsh-mneme/entities?q=%25"), res);
298
+ assert.equal(JSON.parse(res.body).entities.length, 0, "% must not match everything");
299
+ });
300
+
301
+ // 手机面板只需要 enabled/port/host 来渲染表单,但默认的 GET 会顺手 materialize
302
+ // 一个 Bearer 写进 user_settings —— 纯读变成写路径。?token=omit 让它只读。
303
+ test("external-api GET with token=omit neither materializes nor returns a token", async () => {
304
+ const { routes, settings } = setup();
305
+ const route = handler(routes, "/api/dsh-mneme/external-api");
306
+
307
+ const res = new FakeRes();
308
+ await route.handler(req("/api/dsh-mneme/external-api?token=omit"), res);
309
+ assert.equal(res.statusCode, 200);
310
+ const cfg = JSON.parse(res.body).config;
311
+ assert.equal(cfg.token, "", "no plaintext token on a read-only call");
312
+ assert.equal(settings.getExternalApi()?.token ?? "", "", "nothing persisted either");
313
+
314
+ // 不带参数的老客户端行为不变:仍然发 token 并落库
315
+ const res2 = new FakeRes();
316
+ await route.handler(req("/api/dsh-mneme/external-api"), res2);
317
+ assert.ok(JSON.parse(res2.body).config.token.length >= 16);
318
+ });
319
+
274
320
  // ---------------------------------------------------------------------------
275
321
  // /api/dsh-mneme/create(#386 第 5 条:面板手动新增记忆)
276
322
  // ---------------------------------------------------------------------------
@@ -944,6 +990,23 @@ test("wiring: maintenance is built before the panel createApi call and passed in
944
990
  assert.match(callText(src, standaloneAt), /maintenance/);
945
991
  });
946
992
 
993
+ test("wiring: the panel createApi receives createSemantic's surface, not a bare literal", () => {
994
+ // 接线锁。#604 的诊断面(backfill / receipts)挂在 createSemantic() 的返回值上,
995
+ // 而面板这一处曾传字面量 { vectorIndex, reranker },embeddingDiagnostics 挂不上去,
996
+ // api.js 的 typeof 守卫每次都落空 —— 真机上 data.embeddings 恒为 null,
997
+ // 而且没有任何用例断言过它,所以测试全绿。这里锁住传参形状。
998
+ const src = readFileSync(join(root, "src", "index.js"), "utf8");
999
+ const callAt = src.indexOf("createApi(ctx, service, settings");
1000
+ assert.ok(callAt >= 0, "src/index.js 里找不到面板 createApi 调用");
1001
+ const call = callText(src, callAt);
1002
+ assert.match(
1003
+ call, /\.\.\.semantic/,
1004
+ "面板 createApi 必须展开 createSemantic() 的返回值,否则 embeddingDiagnostics 永远挂不上"
1005
+ );
1006
+ assert.match(call, /vectorIndex/, "createSemantic() 不带 vectorIndex,必须显式补上");
1007
+ assert.match(call, /reranker/, "reranker 仍要传,/semantic 的 reranker 状态靠它");
1008
+ });
1009
+
947
1010
 
948
1011
  // ---------------------------------------------------------------------------
949
1012
  // /api/dsh-mneme/semantic/consolidation-stats(8 周沉淀图的数据源)
package/test/api.test.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { EventEmitter } from "node:events";
4
- import { readFileSync } from "node:fs";
4
+ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
5
7
  import { createStore } from "../src/store.js";
6
8
  import { createService } from "../src/service.js";
7
9
  import { createApi } from "../src/api.js";
@@ -57,8 +59,12 @@ function setup(embedder, apiToken = "", config = null, semantic = undefined) {
57
59
  }
58
60
 
59
61
  function findHandler(routes, path) {
60
- const route = routes.find((r) => r.path === path || (r.kind === "prefix" && path.startsWith(r.path)));
61
- return route;
62
+ // 先找精确登记,再退回 prefix 兜底。不能只写一个 find:`/api/dsh-mneme` 那条
63
+ // prefix 404 兜底登记得很早,路径以它开头的任何一条都会先命中它,于是不管
64
+ // 断言什么都是 404。
65
+ const exact = routes.find((r) => r.kind === "exact" && r.path === path);
66
+ if (exact) return exact;
67
+ return routes.find((r) => r.kind === "prefix" && path.startsWith(r.path));
62
68
  }
63
69
 
64
70
  test("registers list, search, and get prefix routes", () => {
@@ -115,6 +121,56 @@ test("list total excludes forgotten entries", async () => {
115
121
  assert.equal(data.total, 1, "total matches visible items, forgotten excluded");
116
122
  });
117
123
 
124
+ test("list carries typeCounts: the full distribution without the type filter (#597)", async () => {
125
+ const { routes, service } = setup();
126
+ service.saveWithDedupe({ type: "preference", title: "p", content: "c" });
127
+ service.saveWithDedupe({ type: "project", title: "j1", content: "c" });
128
+ service.saveWithDedupe({ type: "project", title: "j2", content: "c" });
129
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
130
+ const res = new FakeRes();
131
+ await route.handler(req("/api/dsh-mneme/list?type=project"), res);
132
+ assert.equal(res.statusCode, 200);
133
+ const data = JSON.parse(res.body);
134
+ assert.equal(data.total, 2, "total honors the type filter");
135
+ assert.deepEqual(data.typeCounts, { preference: 1, project: 2 }, "distribution ignores the type filter — other classes survive a selection");
136
+
137
+ const resAll = new FakeRes();
138
+ await route.handler(req("/api/dsh-mneme/list"), resAll);
139
+ const all = JSON.parse(resAll.body);
140
+ assert.equal(
141
+ Object.keys(all.typeCounts).reduce((n, k) => n + all.typeCounts[k], 0),
142
+ all.total,
143
+ "distribution sums to the unfiltered total"
144
+ );
145
+ });
146
+
147
+ test("list?id= also excludes forgotten entries", async () => {
148
+ // getById 是裸 SELECT,不套 list 的 forgotten 谓词;这条路由是 open 的,
149
+ // 漏掉过滤等于让本机任意进程按 id 读到遗忘记忆的全文(评审 #1)。
150
+ const { routes, service } = setup();
151
+ const forgotten = service.saveWithDedupe({ type: "preference", title: "遗忘", content: "隐藏" });
152
+ service.setForget(forgotten.memory.id, true);
153
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
154
+ const res = new FakeRes();
155
+ await route.handler(req(`/api/dsh-mneme/list?id=${forgotten.memory.id}`), res);
156
+ assert.equal(res.statusCode, 200);
157
+ const data = JSON.parse(res.body);
158
+ assert.deepEqual(data.items, []);
159
+ assert.equal(data.total, 0);
160
+ });
161
+
162
+ test("list?id= still returns archived rows (archive is not forget)", async () => {
163
+ const { routes, service } = setup();
164
+ const archived = service.saveWithDedupe({ type: "preference", title: "归档", content: "仍可比对" });
165
+ service.setArchived(archived.memory.id, true);
166
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
167
+ const res = new FakeRes();
168
+ await route.handler(req(`/api/dsh-mneme/list?id=${archived.memory.id}`), res);
169
+ const data = JSON.parse(res.body);
170
+ assert.equal(data.items.length, 1);
171
+ assert.equal(data.items[0].title, "归档");
172
+ });
173
+
118
174
  test("list honors limit/offset", async () => {
119
175
  const { routes, service } = setup();
120
176
  service.saveWithDedupe({ type: "preference", title: "a", content: "1" });
@@ -595,6 +651,33 @@ test("GET /api/dsh-mneme/semantic counts stale-dimension rows as unsearchable wi
595
651
  assert.equal(data.vectors.unsearchable, 1, "yet only one is retrievable");
596
652
  });
597
653
 
654
+ test("GET /api/dsh-mneme/semantic surfaces the #604 backfill/receipt diagnostics", async () => {
655
+ // #604 的诊断面在 api.js 里一直有出口,但全仓没有一条用例断言过
656
+ // `data.embeddings`,所以「传参形状不对 → 真机恒为 null」这件事测试看不见。
657
+ // 这里从 handler 一侧锁住出口本身:semantic 面有 embeddingDiagnostics 就必须透出。
658
+ const diag = {
659
+ backfill: { indexed: 12, failed: 1, finishReason: "capped", stillStale: true },
660
+ receipts: { recent: [{ reason: "http-503" }], totals: { ok: 12, failed: 1 } }
661
+ };
662
+ const { routes } = setup(undefined, "", null, {
663
+ vectorIndex: { getStats: () => ({ embeddedCount: 12, totalCount: 13 }) },
664
+ embeddingDiagnostics: () => diag
665
+ });
666
+ const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
667
+ const res = new FakeRes();
668
+ await sem.handler(req("/api/dsh-mneme/semantic"), res);
669
+ assert.equal(res.statusCode, 200);
670
+ const data = JSON.parse(res.body);
671
+ assert.deepEqual(data.embeddings, diag, "the diagnostics must reach the panel verbatim");
672
+
673
+ // 老 semantic 面(没有这个方法)仍报 null,不伪造空对象。
674
+ const { routes: plain } = setup(undefined, "", null, { vectorIndex: { getStats: () => ({ embeddedCount: 0, totalCount: 0 }) } });
675
+ const plainSem = plain.find((r) => r.path === "/api/dsh-mneme/semantic");
676
+ const plainRes = new FakeRes();
677
+ await plainSem.handler(req("/api/dsh-mneme/semantic"), plainRes);
678
+ assert.equal(JSON.parse(plainRes.body).embeddings, null);
679
+ });
680
+
598
681
  // --- Bug8: llm-audit API (pagination + stats) --------------------------------
599
682
 
600
683
  test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
@@ -764,13 +847,36 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
764
847
  // 顶层扁平键,不需要 NESTED_FLAG_PATHS 条目)= 112。
765
848
  // chorography M3 新增 chorographyInject(开局注入档位;枚举键走 ENUMS 表,
766
849
  // 顶层扁平原值 "light",不需要 NESTED_FLAG_PATHS 条目)= 113。
767
- assert.equal(Object.keys(data.effective).length, 113);
850
+ // 「Agent 眼中的你」自动事实新增 profileFactsEnabled(dream 后逐卡维护 + 手动轮
851
+ // + 注入段的顶层总闸;顶层扁平键,不需要 NESTED_FLAG_PATHS 条目)= 114。
852
+ // 2026-11-08 kb-qoder parity(契约 §3.1)新增 chorographyAutoRefresh /
853
+ // chorographyT1FromPanel / kbEnabled(面板闭环的两个授权闸 + kb 子系统总闸;
854
+ // 三个都是顶层扁平键,不需要 NESTED_FLAG_PATHS 条目)= 117。
855
+ // issue #603 第 4 层再新增 conflictAutoResolveEnabled(自动裁决档开关;顶层布尔键)
856
+ // 与 conflictAutoResolveMinSimilarity(阈值,走 NUMBER_RANGES 表)= 119。
857
+ // v0.13 再新增 diaryEnabled(QQ 日记本:跨天判定 + 手写入口 + 面板页的总闸;
858
+ // 顶层扁平键,不需要 NESTED_FLAG_PATHS 条目)= 120。
859
+ // v0.13 diaryWriteEveryMinutes(日记定时写间隔,走 INT_RANGES 表)= 121。
860
+ // issue #557 新增 dreamInputSummaryEnabled(顶层布尔键,不需要 NESTED_FLAG_PATHS 条目)
861
+ // = +1;issue #275 新增 capacityWarning 三个嵌套点号键 = +3。两个 PR 各自把锁从
862
+ // 121 往上加,合并时只能做加法(选边会写回旧值,锁当场红)。
863
+ // issue #619 情感归因相位再新增 sleepAffectiveEnabled(顶层布尔键)、
864
+ // sleepAffectiveTraitMinSessions 与 sleepAffectiveMaxRelationsPerRun
865
+ // (两个都走 INT_RANGES 表)= +3,故 121 + 1 + 3 + 3 = 128。
866
+ assert.equal(Object.keys(data.effective).length, 128);
867
+ // 三个 kb 新键的默认值单独钉一遍:计数锁只钉数量,钉不住值(缺省值翻转时
868
+ // 面板会静默换上一套授权语义),这里补上值这一层。
869
+ assert.equal(data.effective.chorographyAutoRefresh, false);
870
+ assert.equal(data.effective.chorographyT1FromPanel, true);
871
+ assert.equal(data.effective.kbEnabled, false);
768
872
  assert.equal(data.effective.chorographyEnabled, false);
769
873
  assert.equal(data.effective.chorographyInject, "light");
770
874
  assert.equal(data.effective.dreamSkipInvalid, true);
771
875
  assert.equal(data.effective.allowCrossTypeMerge, false);
772
876
  assert.equal(data.effective.dreamMergeGuard, true);
773
877
  assert.equal(data.effective.injectHeatEnabled, false);
878
+ // issue #557:审计行快照瘦身默认关(默认审计行逐字节一致)
879
+ assert.equal(data.effective.dreamInputSummaryEnabled, false);
774
880
  // issue #380:注入前判定两级开关,默认全关(默认路径零行为变化)
775
881
  assert.equal(data.effective["preInjectGate.enabled"], false);
776
882
  assert.equal(data.effective["preInjectGate.enforce"], false);
@@ -951,6 +1057,12 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
951
1057
  // #471:政策轴的两个点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
952
1058
  "scopeState.enabled": true,
953
1059
  "scopeState.enforce": true,
1060
+ // #275:三个容量阈值点号键往返——两个 int 走 INT_RANGES、一个浮点走
1061
+ // NUMBER_RANGES,都必须同时进 settings 白名单与 api.js 的 NESTED_FLAG_PATHS
1062
+ //(成对缺一即静默缺键)。
1063
+ "capacityWarning.dbSizeWarnMb": 128,
1064
+ "capacityWarning.archivedRowsWarn": 6000,
1065
+ "capacityWarning.archivedRatioWarn": 0.9,
954
1066
  // #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
955
1067
  // 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
956
1068
  sensitiveScanEnabled: true,
@@ -974,6 +1086,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
974
1086
  "distillRecords.retentionDays": 30,
975
1087
  "scopeState.enabled": true,
976
1088
  "scopeState.enforce": true,
1089
+ "capacityWarning.dbSizeWarnMb": 128,
1090
+ "capacityWarning.archivedRowsWarn": 6000,
1091
+ "capacityWarning.archivedRatioWarn": 0.9,
977
1092
  sensitiveScanEnabled: true,
978
1093
  embedProvider: "local",
979
1094
  ollamaBaseUrl: "http://127.0.0.1:11434",
@@ -993,6 +1108,10 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
993
1108
  // #471:政策轴两个点号键同款往返
994
1109
  assert.equal(data.effective["scopeState.enabled"], true);
995
1110
  assert.equal(data.effective["scopeState.enforce"], true);
1111
+ // #275:容量阈值 int 与浮点两类点号键都读得回来
1112
+ assert.equal(data.effective["capacityWarning.dbSizeWarnMb"], 128, "整数点号键也要读得回来");
1113
+ assert.equal(data.effective["capacityWarning.archivedRowsWarn"], 6000);
1114
+ assert.equal(data.effective["capacityWarning.archivedRatioWarn"], 0.9, "浮点点号键同款往返");
996
1115
  assert.equal(data.effective.sensitiveScanEnabled, true);
997
1116
  assert.equal(data.effective.embedProvider, "local");
998
1117
  assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
@@ -1014,6 +1133,37 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
1014
1133
  assert.deepEqual(settings.getFeatureFlags(), data.overrides, "rejected writes persist nothing");
1015
1134
  });
1016
1135
 
1136
+ test("GET/PUT /api/dsh-mneme/features 带 invalidPeakHours(#622:时段串解析失败在面板可见)", async () => {
1137
+ const { routes } = setup(undefined, "", FLAGS_CFG);
1138
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
1139
+ // 默认配置(空串)与合法串:不报——不骚扰没配错峰的用户(验收第 2 条)
1140
+ const get0 = new FakeRes();
1141
+ await route.handler(req("/api/dsh-mneme/features"), get0);
1142
+ assert.deepEqual(JSON.parse(get0.body).invalidPeakHours, []);
1143
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { summarizePeakHours: "09:00-18:00" }), new FakeRes());
1144
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { summarizePeakHours: "" }), new FakeRes());
1145
+ const get1 = new FakeRes();
1146
+ await route.handler(req("/api/dsh-mneme/features"), get1);
1147
+ assert.deepEqual(JSON.parse(get1.body).invalidPeakHours, []);
1148
+ // 非法串(越界 / 点号当冒号——#621 归一救不了的那类)经 PUT 存进去后要被点名
1149
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { summarizePeakHours: "24:00-08:00" }), new FakeRes());
1150
+ const get2 = new FakeRes();
1151
+ await route.handler(req("/api/dsh-mneme/features"), get2);
1152
+ assert.deepEqual(JSON.parse(get2.body).invalidPeakHours, ["summarizePeakHours"]);
1153
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { dreamPeakHours: "09.00-18.00" }), new FakeRes());
1154
+ const get3 = new FakeRes();
1155
+ await route.handler(req("/api/dsh-mneme/features"), get3);
1156
+ assert.deepEqual([...JSON.parse(get3.body).invalidPeakHours].sort(), ["dreamPeakHours", "summarizePeakHours"]);
1157
+ // PUT 答复同走 featureSnapshot:保存后提示立即跟着换(改回合法值即消失)
1158
+ const put = new FakeRes();
1159
+ await route.handler(req("/api/dsh-mneme/features", "PUT", { dreamPeakHours: "23:00-06:00" }), put);
1160
+ assert.deepEqual(JSON.parse(put.body).invalidPeakHours, ["summarizePeakHours"]);
1161
+ // 校验只读 effective:不因提示改任何行为面(空 patch 依旧 400,语义零变化)
1162
+ const empty = new FakeRes();
1163
+ await route.handler(req("/api/dsh-mneme/features", "PUT", {}), empty);
1164
+ assert.equal(empty.statusCode, 400);
1165
+ });
1166
+
1017
1167
  test("PUT /api/dsh-mneme/features rejects unknown keys, bad types and bad ranges with 400", async () => {
1018
1168
  const { routes, settings } = setup(undefined, "", FLAGS_CFG);
1019
1169
  const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
@@ -2613,3 +2763,226 @@ test("GET /api/dsh-mneme/memory/provenance?sessionId=:limit 越界 → 400;
2613
2763
  assert.equal(data.windows.length, 2);
2614
2764
  assert.equal(data.truncated, true, "还有第三个窗口没回——必须说出来,否则半份转录会被当成全部");
2615
2765
  });
2766
+
2767
+ // --- 日记库两条路由(左侧「ta 的日记」面板的数据源)-----------------------
2768
+ // 这两条与上面按 project 定位的五条日记路由不是一回事:面板拿不到当前会话,
2769
+ // 只能整体扫 diaries 根目录。所以这里也必须不传 project 就能读。
2770
+
2771
+ /** 造一个带 diaries 目录的临时 memoryDir,并按 bucket/date 铺文件。 */
2772
+ function diaryFixture(buckets) {
2773
+ const memoryDir = mkdtempSync(join(tmpdir(), "mneme-api-diary-"));
2774
+ const root = join(memoryDir, "diaries");
2775
+ mkdirSync(root, { recursive: true });
2776
+ for (const [bucket, files] of Object.entries(buckets)) {
2777
+ mkdirSync(join(root, bucket), { recursive: true });
2778
+ for (const [date, text] of Object.entries(files)) {
2779
+ writeFileSync(join(root, bucket, `${date}.md`), text, "utf8");
2780
+ }
2781
+ }
2782
+ return { memoryDir, root };
2783
+ }
2784
+
2785
+ function diaryPage(agent, body = "正文") {
2786
+ return `---\ndate: 2026-11-08\nproject: p\npersona: settings (${agent})\n---\n\n${body}\n`;
2787
+ }
2788
+
2789
+ /** 只装配日记要的那一个位置参数(第 14 个),其余留空。 */
2790
+ function setupDiary(config, diary = { compose: async () => ({ ok: true }) }) {
2791
+ const store = createStore(":memory:");
2792
+ const service = createService({ store, mirror: null, config: {} });
2793
+ const settings = createSettings(store.db);
2794
+ const routes = [];
2795
+ const handlers = {};
2796
+ const ctx = {
2797
+ webServer: { register(route) { routes.push(route); return () => {}; } },
2798
+ // 用真的 session/event 通道:agentBySession 账本正是从这里喂料的,
2799
+ // 换成假函数就只测了「我自己的桩」,测不到真实接线。
2800
+ on(name, fn) { handlers[name] = fn; return () => { delete handlers[name]; }; }
2801
+ };
2802
+ createApi(ctx, service, settings, { add() {}, remove() {}, list: () => [] }, undefined,
2803
+ undefined, "", config, null, null, null, null, null, diary);
2804
+ const emitSession = (session) => {
2805
+ const fn = handlers["session/event"];
2806
+ if (fn) fn(session);
2807
+ };
2808
+ return { routes, emitSession };
2809
+ }
2810
+
2811
+ /**
2812
+ * 一个在本平台一定是绝对路径的项目根。
2813
+ *
2814
+ * 这里原先写死 `D:/proj`:Windows 上 isAbsolute() 为真、Linux/macOS 上为假,
2815
+ * 于是 src/api.js 的 diaryProject() 直接回 400 project-not-absolute,ubuntu 22/24
2816
+ * 与 test-host-legacy 两腿全红,而本机 Windows 全绿——产品代码没错,是断言不可移植。
2817
+ * tmpdir() 在两种平台上都返回绝对路径,用例才真正等价。
2818
+ */
2819
+ function portableProjectRoot() {
2820
+ return join(tmpdir(), "mneme-diary-compose");
2821
+ }
2822
+
2823
+ /** 抓一个 compose 调用参数的 spy:既要知道调没调、也要知道传了什么。 */
2824
+ function composeSpy() {
2825
+ const calls = [];
2826
+ const diary = {
2827
+ compose: async (args) => {
2828
+ calls.push(args);
2829
+ return { ok: true, status: "written", date: "2026-11-08", path: "x.md" };
2830
+ }
2831
+ };
2832
+ return { calls, diary };
2833
+ }
2834
+
2835
+ test("GET /api/dsh-mneme/diary/library:不传 project 也能列清单(面板拿不到会话)", async (t) => {
2836
+ const { memoryDir } = diaryFixture({
2837
+ "amarre-c836db6a": { "2026-11-08": diaryPage("amarre") },
2838
+ "attune-dd7a5103": { "2026-11-07": diaryPage("attune") }
2839
+ });
2840
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
2841
+
2842
+ const { routes } = setupDiary({ memoryDir, diaryEnabled: true });
2843
+ const route = findHandler(routes, "/api/dsh-mneme/diary/library");
2844
+ assert.ok(route, "路由必须注册在 /api/dsh-mneme/diary/library");
2845
+ const res = new FakeRes();
2846
+ await route.handler(req("/api/dsh-mneme/diary/library"), res);
2847
+ assert.equal(res.statusCode, 200);
2848
+ const data = JSON.parse(res.body);
2849
+ assert.equal(data.contract, "mneme.diary/v1");
2850
+ assert.equal(data.buckets.length, 2);
2851
+ });
2852
+
2853
+ test("GET /api/dsh-mneme/diary/library/entry:正常读到正文", async (t) => {
2854
+ const { memoryDir } = diaryFixture({ "amarre-c836db6a": { "2026-11-08": diaryPage("amarre", "今天很好") } });
2855
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
2856
+
2857
+ const { routes } = setupDiary({ memoryDir, diaryEnabled: true });
2858
+ const route = findHandler(routes, "/api/dsh-mneme/diary/library/entry");
2859
+ const res = new FakeRes();
2860
+ await route.handler(req("/api/dsh-mneme/diary/library/entry?bucket=amarre-c836db6a&date=2026-11-08"), res);
2861
+ assert.equal(res.statusCode, 200);
2862
+ const data = JSON.parse(res.body);
2863
+ assert.equal(data.bucket, "amarre-c836db6a");
2864
+ assert.equal(data.date, "2026-11-08");
2865
+ assert.match(data.text, /今天很好/);
2866
+ });
2867
+
2868
+ test("GET /api/dsh-mneme/diary/library/entry:越界桶名与坏日期都回 400,不伪装成 404", async (t) => {
2869
+ const { memoryDir } = diaryFixture({ ok: { "2026-11-08": diaryPage("x") } });
2870
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
2871
+
2872
+ const { routes } = setupDiary({ memoryDir, diaryEnabled: true });
2873
+ const route = findHandler(routes, "/api/dsh-mneme/diary/library/entry");
2874
+
2875
+ // 越界是一种**坏请求**,不是「这本不存在」——回 404 会让调用方以为换个名字再试就行。
2876
+ for (const bad of ["..", "../x", "ok/../ok", ""]) {
2877
+ const res = new FakeRes();
2878
+ await route.handler(req(`/api/dsh-mneme/diary/library/entry?bucket=${encodeURIComponent(bad)}&date=2026-11-08`), res);
2879
+ assert.equal(res.statusCode, 400, `桶名 ${JSON.stringify(bad)} 该回 400`);
2880
+ assert.equal(JSON.parse(res.body).error, "bucket-required");
2881
+ }
2882
+ for (const bad of ["2026-1-1", "../../etc/passwd", ""]) {
2883
+ const res = new FakeRes();
2884
+ await route.handler(req(`/api/dsh-mneme/diary/library/entry?bucket=ok&date=${encodeURIComponent(bad)}`), res);
2885
+ assert.equal(res.statusCode, 400, `日期 ${JSON.stringify(bad)} 该回 400`);
2886
+ assert.equal(JSON.parse(res.body).error, "date-required");
2887
+ }
2888
+ });
2889
+
2890
+ test("GET /api/dsh-mneme/diary/library/entry:合法但不存在 → 404 diary-not-found", async (t) => {
2891
+ const { memoryDir } = diaryFixture({ ok: { "2026-11-08": diaryPage("x") } });
2892
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
2893
+
2894
+ const { routes } = setupDiary({ memoryDir, diaryEnabled: true });
2895
+ const route = findHandler(routes, "/api/dsh-mneme/diary/library/entry");
2896
+ const res = new FakeRes();
2897
+ await route.handler(req("/api/dsh-mneme/diary/library/entry?bucket=ok&date=2026-01-01"), res);
2898
+ assert.equal(res.statusCode, 404);
2899
+ assert.equal(JSON.parse(res.body).error, "diary-not-found");
2900
+ });
2901
+
2902
+ test("日记库两条路由与右栏同源:装配缺件 503、开关关着 409", async (t) => {
2903
+ const { memoryDir } = diaryFixture({ ok: { "2026-11-08": diaryPage("x") } });
2904
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
2905
+ for (const path of ["/api/dsh-mneme/diary/library", "/api/dsh-mneme/diary/library/entry?bucket=ok&date=2026-11-08"]) {
2906
+ // 503:diary 位置参数没装配。
2907
+ const off = setupDiary({ memoryDir, diaryEnabled: true }, null);
2908
+ const r1 = new FakeRes();
2909
+ await findHandler(off.routes, "/api/dsh-mneme/diary/library").handler(req(path), r1);
2910
+ assert.equal(r1.statusCode, 503, `${path} 装配缺件该回 503`);
2911
+ assert.equal(JSON.parse(r1.body).reason, "diary-unavailable");
2912
+
2913
+ // 409:装配了但用户没开。
2914
+ const disabled = setupDiary({ memoryDir, diaryEnabled: false });
2915
+ const r2 = new FakeRes();
2916
+ await findHandler(disabled.routes, "/api/dsh-mneme/diary/library").handler(req(path), r2);
2917
+ assert.equal(r2.statusCode, 409, `${path} 开关关着该回 409`);
2918
+ assert.equal(JSON.parse(r2.body).reason, "diary-not-enabled");
2919
+ }
2920
+ });
2921
+
2922
+ test("中文桶名:清单里列得出来就必须读得到(否则就是点了没反应)", async (t) => {
2923
+ // 现场真有一个保留中文的历史桶。上一版这里两层用了不同判据——清单按路径语义、
2924
+ // 路由按 ASCII 白名单——结果是列得出来、点进去 400。这条用例锁住两者同源。
2925
+ const { memoryDir } = diaryFixture({ "QQ对话-4f061790": { "2026-11-07": diaryPage("amarre", "旧的那篇") } });
2926
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
2927
+
2928
+ const { routes } = setupDiary({ memoryDir, diaryEnabled: true });
2929
+ const list = new FakeRes();
2930
+ await findHandler(routes, "/api/dsh-mneme/diary/library").handler(req("/api/dsh-mneme/diary/library"), list);
2931
+ const buckets = JSON.parse(list.body).buckets.map((b) => b.key);
2932
+ assert.ok(buckets.includes("QQ对话-4f061790"), "中文桶必须出现在清单里");
2933
+
2934
+ const res = new FakeRes();
2935
+ await findHandler(routes, "/api/dsh-mneme/diary/library/entry")
2936
+ .handler(req(`/api/dsh-mneme/diary/library/entry?bucket=${encodeURIComponent("QQ对话-4f061790")}&date=2026-11-07`), res);
2937
+ assert.equal(res.statusCode, 200, "清单里列得出来的桶,点进去必须读得到");
2938
+ assert.match(JSON.parse(res.body).text, /旧的那篇/);
2939
+ });
2940
+
2941
+ // --- 手动写日记必须带身份(「设定好人格的 agent 只以这个人格的口吻写日记」)-----
2942
+ // 自动轮(src/diary/auto.js:47)一直传 agentId,手动轮的 /diary/compose 却不传,
2943
+ // 于是永远落 default 人格卡:同一天从两个入口写,是两张卡的两种声音。
2944
+ // 这三条锁住「带 ?agent= / ?session= 就传、都不带就不猜」。
2945
+
2946
+ test("POST /diary/compose 带 ?agent= 时把它作为 agentId 传下去", async () => {
2947
+ const { calls, diary } = composeSpy();
2948
+ const { routes } = setupDiary({ diaryEnabled: true }, diary);
2949
+ const route = findHandler(routes, "/api/dsh-mneme/diary/compose");
2950
+ const project = portableProjectRoot();
2951
+ const res = new FakeRes();
2952
+ await route.handler(
2953
+ req("/api/dsh-mneme/diary/compose?project=" + encodeURIComponent(project) + "&agent=amarre", "POST", {}),
2954
+ res
2955
+ );
2956
+ assert.equal(res.statusCode, 200);
2957
+ assert.equal(calls.length, 1);
2958
+ assert.equal(calls[0].agentId, "amarre");
2959
+ assert.equal(calls[0].trigger, "manual");
2960
+ assert.equal(calls[0].projectRoot, project);
2961
+ });
2962
+
2963
+ test("POST /diary/compose 只带 ?session= 时按会话账本反查身份", async () => {
2964
+ const { calls, diary } = composeSpy();
2965
+ const { routes, emitSession } = setupDiary({ diaryEnabled: true }, diary);
2966
+ // 账本由真实的 session/event 通道喂料,不手搓内部结构。
2967
+ emitSession({ id: "sess-1", header: { agentPreset: "attune" } });
2968
+ const route = findHandler(routes, "/api/dsh-mneme/diary/compose");
2969
+ const project = portableProjectRoot();
2970
+ const res = new FakeRes();
2971
+ await route.handler(
2972
+ req("/api/dsh-mneme/diary/compose?project=" + encodeURIComponent(project) + "&session=sess-1", "POST", {}),
2973
+ res
2974
+ );
2975
+ assert.equal(res.statusCode, 200);
2976
+ assert.equal(calls[0].agentId, "attune", "会话身份必须从账本里查出来,不能落 default");
2977
+ });
2978
+
2979
+ test("POST /diary/compose 两个身份参数都没有时传 null,不假装自己是 default", async () => {
2980
+ const { calls, diary } = composeSpy();
2981
+ const { routes } = setupDiary({ diaryEnabled: true }, diary);
2982
+ const route = findHandler(routes, "/api/dsh-mneme/diary/compose");
2983
+ const project = portableProjectRoot();
2984
+ const res = new FakeRes();
2985
+ await route.handler(req("/api/dsh-mneme/diary/compose?project=" + encodeURIComponent(project), "POST", {}), res);
2986
+ assert.equal(res.statusCode, 200);
2987
+ assert.equal(calls[0].agentId, null, "不知道是谁 ≠ 以为是 default");
2988
+ });