@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,174 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createStore } from "../src/store.js";
6
+ import { createService } from "../src/service.js";
7
+ import { createDreamScheduler } from "../src/dream.js";
8
+
9
+ // 回归(issue #634):阈值基线只在成功轮前进(dream_runs 落库 + 调度器闭包),而
10
+ // 归档/删除/合并把活跃量压下去——活跃量一旦跌破基线,overBase 恒为假,从此再
11
+ // 也不会巩固,且全程静默(报告实测停摆 13 天 / 22 小时零轮:末轮基线 1,475 条,
12
+ // 归档后活跃 798 条,门槛 ≥1,485 永不满足)。修法是判定口夹回:活跃量跌破基线的
13
+ // 轴取 min,阈值从新的低点重新起算,归掉的量不必重新长回来。本文件锁四个面:
14
+ // 1. 活着的调度器亲历收缩:归档压破基线后,重新增长阈值量仍能触发(停摆反例);
15
+ // 2. 重启后旧峰值种子在第一次判定被夹回——不落库也能自愈(收缩不触发
16
+ // notifyWrite,调度器本来就无从在归档当下得知,判定口是唯一可靠的收敛处);
17
+ // 3. 夹回事件走 auditBaselineClamp 记账:每次收缩周期至多一次、不收缩不记;
18
+ // 4. 两轴独立夹回:只缩字符不缩条数时,条数轴的基线不动。
19
+
20
+ /** 与阈值判定同口径的活跃库规模(测试侧独立实现,避免与被测代码共用一份过滤条件)。 */
21
+ function activeSize(store) {
22
+ const rows = store.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
23
+ return {
24
+ count: rows.length,
25
+ chars: rows.reduce((sum, m) => sum + m.title.length + m.content.length, 0)
26
+ };
27
+ }
28
+
29
+ function seedMemories(service, n, tag = "a") {
30
+ // 标题必须全局唯一(saveWithDedupe 的去重键是 type+title+scope,同名会合并成
31
+ // 同一条),「累计到阈值」的断言才会真涨。
32
+ for (let i = 0; i < n; i++) {
33
+ service.saveWithDedupe({ type: "history", title: `${tag}-${i}`, content: "x".repeat(300), importance: 3 });
34
+ }
35
+ }
36
+
37
+ /** 把「上次成功轮的库规模」落进 dream_runs——调度器重启后读到的就是这份种子。 */
38
+ function persistBaseline(store) {
39
+ const size = activeSize(store);
40
+ store.saveDreamRun({
41
+ status: "ok", snapshot_hash: "h", input_count: 1, receipt: "r",
42
+ created_at: new Date(Date.now() - 10).toISOString(), run_type: "auto",
43
+ store_count: size.count, store_chars: size.chars
44
+ });
45
+ return size;
46
+ }
47
+
48
+ function makeOpts(store, overrides = {}) {
49
+ return {
50
+ onRun: async () => ({ ok: false }),
51
+ thresholdCount: 10, thresholdChars: 5000, delayMs: 0,
52
+ baselineSeed: store.lastDreamBaseline("auto"),
53
+ logger: { warn: () => {} },
54
+ ...overrides
55
+ };
56
+ }
57
+
58
+ /** 把活跃行里除 excludeTag 外的前 n 条归档(归档不触发 notifyWrite,这正是 #634 的一部分)。 */
59
+ function archiveRows(service, store, n, excludeTag = null) {
60
+ const rows = store.all().filter((m) => !m.archived && (excludeTag === null || !m.title.startsWith(excludeTag)));
61
+ for (const m of rows.slice(0, n)) service.setArchived(m.id, true);
62
+ }
63
+
64
+ test("issue#634: a live scheduler survives an archive below the baseline (the freeze, unlocked)", async () => {
65
+ const store = createStore(":memory:");
66
+ const service = createService({ store, mirror: null, config: {} });
67
+ seedMemories(service, 12);
68
+ const baseline = persistBaseline(store);
69
+ const dream = createDreamScheduler(makeOpts(store));
70
+ assert.equal(dream.maybeSchedule(service), false, "no growth since the last run → no trigger");
71
+ // 收缩发生在两次写入之间:归档 10 条,活跃 2 条 < 基线 12 条。
72
+ archiveRows(service, store, 10);
73
+ assert.ok(activeSize(store).count < baseline.count, "precondition: archive pulled active below the baseline");
74
+ // 收缩后的第一次写入把基线夹回当前水平(2+1=3),此后按新的低点起算。
75
+ seedMemories(service, 1, "g");
76
+ assert.equal(dream.maybeSchedule(service), false, "the first post-shrink write clamps instead of triggering");
77
+ seedMemories(service, 9, "p");
78
+ assert.equal(dream.maybeSchedule(service), false, "+9 from the clamped level is still below the threshold");
79
+ seedMemories(service, 1, "q"); // 夹回后的第 +10 条
80
+ assert.equal(dream.maybeSchedule(service), true, "+10 past the clamp fires — pre-fix this stayed silent forever");
81
+ await dream.dispose();
82
+ store.close();
83
+ });
84
+
85
+ test("issue#634: a stale peak seed from dream_runs is clamped on the first judgement after restart", async () => {
86
+ const store = createStore(":memory:");
87
+ const service = createService({ store, mirror: null, config: {} });
88
+ seedMemories(service, 12);
89
+ persistBaseline(store);
90
+ // 收缩发生在上一进程末轮之后、重启之前(归档不 notifyWrite,调度器无从得知):
91
+ // dream_runs 里的种子还是那个峰值。
92
+ archiveRows(service, store, 10);
93
+ const dream = createDreamScheduler(makeOpts(store));
94
+ // 重启后第一条写入:先把旧峰值种子夹回当前水平,再谈阈值。
95
+ seedMemories(service, 1, "r");
96
+ assert.equal(dream.maybeSchedule(service), false, "the restart write clamps the stale seed instead of bypassing");
97
+ seedMemories(service, 9, "s");
98
+ assert.equal(dream.maybeSchedule(service), false, "+9 from the clamped level is still below the threshold");
99
+ seedMemories(service, 1, "t");
100
+ assert.equal(dream.maybeSchedule(service), true, "the clamp makes the threshold reachable again without re-growing archived rows");
101
+ await dream.dispose();
102
+ store.close();
103
+ });
104
+
105
+ test("issue#634: the clamp is audited exactly once per shrink episode, and never without one", async () => {
106
+ const store = createStore(":memory:");
107
+ const service = createService({ store, mirror: null, config: {} });
108
+ const clamps = [];
109
+ seedMemories(service, 12);
110
+ const baseline = persistBaseline(store);
111
+ const dream = createDreamScheduler(makeOpts(store, {
112
+ auditBaselineClamp: ({ previous, count, chars }) => clamps.push({ previous, count, chars })
113
+ }));
114
+ // 不收缩的写入:正常增长(13 > 12),既不夹回也不记账。
115
+ seedMemories(service, 1, "n");
116
+ assert.equal(dream.maybeSchedule(service), false);
117
+ assert.equal(clamps.length, 0, "no clamp audit without a shrink");
118
+ // 归档压破基线(12→2),收缩后第一次判定夹回并记一次账。
119
+ archiveRows(service, store, 10, "n-");
120
+ seedMemories(service, 1, "p");
121
+ assert.equal(dream.maybeSchedule(service), false);
122
+ assert.equal(clamps.length, 1, "the shrink episode produces exactly one clamp audit");
123
+ assert.deepEqual(clamps[0].previous, baseline, "previous = the stale peak baseline");
124
+ assert.equal(clamps[0].count, activeSize(store).count, "count/chars = the clamped (current active) level");
125
+ assert.equal(clamps[0].chars, activeSize(store).chars, "chars axis is clamped to the same level");
126
+ // 同一收缩周期内活跃只增不减:不再重复记账(夹回后基线 == 活跃量)。
127
+ seedMemories(service, 5, "u");
128
+ assert.equal(dream.maybeSchedule(service), false, "still below the threshold from the clamped level");
129
+ assert.equal(clamps.length, 1, "no repeated clamp audits while active only grows");
130
+ await dream.dispose();
131
+ store.close();
132
+ });
133
+
134
+ test("issue#634: the clamp is per-axis — a chars-only shrink leaves the count baseline alone", async () => {
135
+ const store = createStore(":memory:");
136
+ const service = createService({ store, mirror: null, config: {} });
137
+ seedMemories(service, 12);
138
+ const baseline = persistBaseline(store);
139
+ const clamps = [];
140
+ const dream = createDreamScheduler(makeOpts(store, {
141
+ auditBaselineClamp: (event) => clamps.push(event)
142
+ }));
143
+ // 更新类写入(同 type+title+scope 命中去重键)把一条换成短内容:条数不动、字符缩。
144
+ // 去重命中默认 appendContent(追加),只有显式 _overwrite 才替换——不加的话
145
+ // 字符数反而变多,这条用例的前置就不成立了。
146
+ service.saveWithDedupe({ type: "history", title: "a-0", content: "短", _overwrite: true, importance: 3 });
147
+ assert.equal(activeSize(store).count, baseline.count, "precondition: the count axis did not move");
148
+ assert.ok(activeSize(store).chars < baseline.chars, "precondition: the chars axis shrank");
149
+ assert.equal(dream.maybeSchedule(service), false, "the threshold itself is not met yet");
150
+ assert.equal(clamps.length, 1, "a chars-only shrink still clamps");
151
+ assert.equal(clamps[0].previous.count, baseline.count, "the count baseline was intact before the clamp");
152
+ assert.equal(clamps[0].count, baseline.count, "the count baseline survives — only the shrunken axis moves");
153
+ assert.ok(clamps[0].chars < clamps[0].previous.chars, "the chars baseline is clamped down");
154
+ await dream.dispose();
155
+ store.close();
156
+ });
157
+
158
+ // --- CodeRabbit 在 #291 上的同类要求(源码级锁):index.js 的接线是单行注入,仓库
159
+ // 没有插件级挂载 harness(apply 需要完整宿主 ctx),删掉该注入行不会让任何单测变
160
+ // 红——所以用正则锁注入本身。import 用 URL 相对本文件解析,不依赖测试进程 CWD。
161
+ const indexSrc = readFileSync(fileURLToPath(new URL("../src/index.js", import.meta.url)), "utf8");
162
+
163
+ test("issue#634: index.js wires the baseline-clamp audit into llm_audit_logs", () => {
164
+ assert.match(
165
+ indexSrc,
166
+ /auditBaselineClamp:/,
167
+ "the clamp callback must be wired at assembly — otherwise clamp events stay unobservable (the #634 silence)"
168
+ );
169
+ assert.match(
170
+ indexSrc,
171
+ /error_message:\s*"baseline-clamp"/,
172
+ "the audit row must carry the baseline-clamp marker so it can be told apart from peak-hours skips"
173
+ );
174
+ });
@@ -113,7 +113,7 @@ test("withMaxTokensFallback: 无关报错不触发重试(stream 与 throw 双
113
113
  // 接线锁:单测绿不代表真接上了。#478 的第一次实现只挂了 dream 侧三个点,
114
114
  // sleep.js 两处仍原样发 config.sleepMaxTokens,而面板 hint 已对外承诺
115
115
  // 「超上限同样自动降级」——这种「函数有、没人用」的缺口只靠行为测试抓不到。
116
- test("接线锁:dreamMaxTokens/sleepMaxTokens 的五个使用点全部包在 withMaxTokensFallback 里", async () => {
116
+ test("接线锁:dreamMaxTokens/sleepMaxTokens 的六个使用点全部包在 withMaxTokensFallback 里", async () => {
117
117
  const { readFile } = await import("node:fs/promises");
118
118
  const srcDir = new URL("../src/", import.meta.url);
119
119
  const files = { "dream.js": await readFile(new URL("dream.js", srcDir), "utf8"), "dream/sleep.js": await readFile(new URL("dream/sleep.js", srcDir), "utf8") };
@@ -124,9 +124,9 @@ test("接线锁:dreamMaxTokens/sleepMaxTokens 的五个使用点全部包在 w
124
124
  `${name}: still passes config.*MaxTokens straight into maxTokens — wrap it in withMaxTokensFallback`
125
125
  );
126
126
  }
127
- // 五个调用点:dream 三条(叙述/巩固/摘要)+ sleep 两条(冲突/模式)。
127
+ // 调用点:dream 三条(叙述/巩固/摘要)+ sleep 三条(冲突/模式/情感归因,#619 新增)。
128
128
  const dreamHits = files["dream.js"].match(/withMaxTokensFallback\(ctx, config\.dreamMaxTokens/g) ?? [];
129
129
  const sleepHits = files["dream/sleep.js"].match(/withMaxTokensFallback\(ctx, config\.sleepMaxTokens/g) ?? [];
130
130
  assert.equal(dreamHits.length, 3, `dream.js must wrap all 3 dreamMaxTokens sites (got ${dreamHits.length})`);
131
- assert.equal(sleepHits.length, 2, `dream/sleep.js must wrap both sleepMaxTokens sites (got ${sleepHits.length})`);
131
+ assert.equal(sleepHits.length, 3, `dream/sleep.js must wrap all 3 sleepMaxTokens sites (got ${sleepHits.length})`);
132
132
  });
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { validateDecisions, applyDecisions, createDreamScheduler } from "../src/dream.js";
4
4
  import { createStore } from "../src/store.js";
5
5
  import { createService } from "../src/service.js";
6
- import { mockCtx } from "./helpers/dream-mock.js";
6
+ import { mockCtx, sessionDecisions } from "./helpers/dream-mock.js";
7
7
 
8
8
  function snapshot(ids, type = "project") {
9
9
  return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
@@ -905,6 +905,67 @@ test("runDream sliding window keeps the newest N memories and excludes the oldes
905
905
  store.close();
906
906
  });
907
907
 
908
+ // --- issue #557:dream 审计行输入快照瘦身(dreamInputSummaryEnabled)-----------
909
+ //
910
+ // dream_runs.input 单行体积随记忆量涨(#557 实测平均 40-79 KB/轮,大头是
911
+ // content),轮数又随天数涨,超线性。本组用例锁三件事:
912
+ // ① 默认关 = 审计行逐字节一致(content 原样落库);
913
+ // ② 开启后 auto 轮只落骨架(id/type/title/importance/updated_at),且
914
+ // input_count / snapshot_hash / receipt / decisions 都不变——瘦身的只是
915
+ // 落库副本,LLM 输入与 receipt 完整性不受影响;
916
+ // ③ organize 轮的重放载荷不经过 dream.js 的快照构造(结构性分叉),这里锁
917
+ // auto 路径的形状,organize 侧由 organize.test.js 的 apply 重放用例兜住。
918
+
919
+ test("issue#557: 默认关 = 审计行快照带 content(行为与改动前一致)", async () => {
920
+ const { store, service } = dreamSetup();
921
+ const content = "很长的正文,瘦身前应原样落库。".repeat(20);
922
+ for (let i = 0; i < 3; i++) {
923
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content, importance: 3 });
924
+ }
925
+ const ctx = mockCtx({ onConsolidation: sessionDecisions });
926
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
927
+ await dream.runDream(ctx, service, {
928
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat"
929
+ });
930
+ const run = store.listDreamRuns()[0];
931
+ assert.equal(run.input_count, 3);
932
+ assert.deepEqual(
933
+ Object.keys(run.input[0]).sort(),
934
+ ["content", "id", "importance", "title", "type", "updated_at"],
935
+ "默认快照含 content"
936
+ );
937
+ assert.equal(run.input[0].content, content, "content 原样落库");
938
+ store.close();
939
+ });
940
+
941
+ test("issue#557: dreamInputSummaryEnabled 开启后快照只留骨架,判定与回执不变", async () => {
942
+ const { store, service } = dreamSetup();
943
+ for (let i = 0; i < 3; i++) {
944
+ service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `很长的正文${i}`.repeat(20), importance: 3 });
945
+ }
946
+ const ctx = mockCtx({ onConsolidation: sessionDecisions });
947
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
948
+ const result = await dream.runDream(ctx, service, {
949
+ dreamProvider: "deepseek", dreamModel: "deepseek-chat",
950
+ dreamInputSummaryEnabled: true
951
+ });
952
+ const run = store.listDreamRuns()[0];
953
+ assert.equal(run.input_count, 3, "input_count 语义不变");
954
+ for (const m of run.input) {
955
+ assert.deepEqual(
956
+ Object.keys(m).sort(),
957
+ ["id", "importance", "title", "type", "updated_at"],
958
+ "瘦身快照不含 content"
959
+ );
960
+ }
961
+ assert.deepEqual(run.input.map((m) => m.title).sort(), ["主题0", "主题1", "主题2"], "骨架字段带出标题");
962
+ assert.ok(run.snapshot_hash && run.snapshot_hash.length === 64, "snapshot_hash 形状不变(内存里对含 content 快照计算)");
963
+ assert.match(run.receipt, /^dsh-mneme:run:/, "receipt 形状不变");
964
+ assert.ok(Array.isArray(run.decisions) && run.decisions.length > 0, "决策不受影响");
965
+ assert.ok(JSON.stringify(run.input).length < 2048, "骨架行确实小(content 不在)");
966
+ store.close();
967
+ });
968
+
908
969
  // --- v0.4.4 fix: 决策 JSON schema 固化(kimi 等模型输出合规) -----------------
909
970
 
910
971
  test("consolidation prompt pins the decision schema (action field, single-string winner/loser, single claim per id)", async () => {
@@ -0,0 +1,134 @@
1
+ // src/dsh-go/host.js —— 进程内派发的判据。
2
+ //
3
+ // 这个文件锁的是「面板能不能拿到宿主真的答复」,而不是某一屏长什么样:
4
+ // - 请求体必须在 handler 挂好监听之后再 flush,否则 readBody 永远等不到 end;
5
+ // - handler 返回 undefined 但稍后才 res.end(/version-check 就是这样)时,
6
+ // 调用方必须等到响应真的结束,不能拿着空 body 当成结果;
7
+ // - 配了 apiToken 之后写路由要带令牌,否则被自家路由注册器拦成 401;
8
+ // - 超时、未注册路径都要抛错,而不是返回一个空对象让面板显示成「0 条」。
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { createHostCall } from "../src/dsh-go/host.js";
12
+
13
+ function fakeServer(routes) {
14
+ return {
15
+ match(pathname) {
16
+ const handler = routes[pathname];
17
+ return handler === undefined ? undefined : { kind: "exact", path: pathname, handler };
18
+ },
19
+ };
20
+ }
21
+
22
+ function readAll(req) {
23
+ return new Promise((resolve) => {
24
+ let data = "";
25
+ req.on("data", (chunk) => {
26
+ data += String(chunk);
27
+ });
28
+ req.on("end", () => resolve(data));
29
+ });
30
+ }
31
+
32
+ const ctxWith = (routes) => ({ get: (name) => (name === "webServer" ? fakeServer(routes) : undefined) });
33
+
34
+ test("进程内调用能读到请求体,并且同步响应即可返回", async () => {
35
+ let seen = null;
36
+ const call = createHostCall(
37
+ ctxWith({
38
+ "/api/dsh-mneme/echo": (req, res) => {
39
+ seen = { method: req.method, url: req.url };
40
+ void readAll(req).then((body) => {
41
+ seen.body = body;
42
+ res.writeHead(200, { "content-type": "application/json" });
43
+ res.end(JSON.stringify({ ok: true, body: JSON.parse(body) }));
44
+ });
45
+ },
46
+ }),
47
+ );
48
+ const result = await call("/echo", { method: "POST", body: { title: "x" } });
49
+ assert.deepEqual(result, { ok: true, body: { title: "x" } });
50
+ assert.equal(seen.method, "POST");
51
+ assert.equal(seen.body, JSON.stringify({ title: "x" }));
52
+ });
53
+
54
+ test("handler 同步返回 undefined、稍后 res.end 时仍然等到响应结束", async () => {
55
+ // /version-check 的真实形状:handler 立即 return,答复在 promise 回调里写完。
56
+ const call = createHostCall(
57
+ ctxWith({
58
+ "/api/dsh-mneme/version-check": (req, res) => {
59
+ setTimeout(() => {
60
+ res.writeHead(200, { "content-type": "application/json" });
61
+ res.end(JSON.stringify({ version: "0.10.1", status: "latest" }));
62
+ }, 5);
63
+ return undefined;
64
+ },
65
+ }),
66
+ );
67
+ const result = await call("/version-check");
68
+ assert.equal(result.version, "0.10.1");
69
+ assert.equal(result.status, "latest");
70
+ });
71
+
72
+ test("配了 apiToken 就把令牌放进 x-dsh-mneme-token 头", async () => {
73
+ const call = createHostCall(
74
+ ctxWith({
75
+ "/api/dsh-mneme/update": (req, res) => {
76
+ const token = req.headers["x-dsh-mneme-token"] ?? "";
77
+ res.writeHead(token === "secret-token" ? 200 : 401);
78
+ res.end(JSON.stringify(token === "secret-token" ? { ok: true } : { error: "unauthorized" }));
79
+ },
80
+ }),
81
+ { apiToken: "secret-token" },
82
+ );
83
+ assert.deepEqual(await call("/update", { method: "POST", body: { id: "m1" } }), { ok: true });
84
+ });
85
+
86
+ test("没有令牌时写路由的 401 会变成异常,而不是空结果", async () => {
87
+ const call = createHostCall(
88
+ ctxWith({
89
+ "/api/dsh-mneme/update": (req, res) => {
90
+ res.writeHead(401);
91
+ res.end(JSON.stringify({ error: "unauthorized" }));
92
+ },
93
+ }),
94
+ );
95
+ await assert.rejects(call("/update", { method: "POST", body: { id: "m1" } }), /401/);
96
+ });
97
+
98
+ test("未注册的路径直接报错,不会静默返回空对象", async () => {
99
+ const call = createHostCall(ctxWith({}));
100
+ await assert.rejects(call("/list"), /mneme 未注册/);
101
+ });
102
+
103
+ test("宿主没有 webServer 时报错", async () => {
104
+ const call = createHostCall({ get: () => undefined });
105
+ await assert.rejects(call("/list"), /webServer 不可用/);
106
+ });
107
+
108
+ test("超时抛错(避免面板永远等着)", async () => {
109
+ const call = createHostCall(
110
+ ctxWith({
111
+ "/api/dsh-mneme/slow": () => {
112
+ // 永不响应
113
+ },
114
+ }),
115
+ );
116
+ await assert.rejects(call("/slow", { timeoutMs: 30 }), /超时/);
117
+ });
118
+
119
+ test("空响应体归一成 {},非 JSON 归一成 { text }", async () => {
120
+ const call = createHostCall(
121
+ ctxWith({
122
+ "/api/dsh-mneme/empty": (req, res) => {
123
+ res.writeHead(200);
124
+ res.end("");
125
+ },
126
+ "/api/dsh-mneme/plain": (req, res) => {
127
+ res.writeHead(200);
128
+ res.end("hello");
129
+ },
130
+ }),
131
+ );
132
+ assert.deepEqual(await call("/empty"), {});
133
+ assert.deepEqual(await call("/plain"), { text: "hello" });
134
+ });