@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,171 @@
1
+ // Issue #611:图片记忆端到端——一张图真的走进蒸馏转录。
2
+ //
3
+ // 单元用例(attachment-text.test.js)只证明「块 → 文本」这一步是对的。这一份锁的是
4
+ // 另一半:**collectMessages 真的会把它拼进发给模型的那条消息里**,并且这个数会落进
5
+ // 审计 metadata。改动前三处 textOf 都返回空串,图-only 消息连一行都不会产出——所以
6
+ // 这条用例在旧代码上必然红,这是它存在的理由。
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { createStore } from "../src/store.js";
10
+ import { createService } from "../src/service.js";
11
+ import { createSummarizer } from "../src/summarize.js";
12
+
13
+ function setup(over = {}) {
14
+ const store = createStore(":memory:");
15
+ const service = createService({ store, mirror: null, config: {} });
16
+ const events = [];
17
+ const calls = [];
18
+ const ctx = {
19
+ on(name, fn) {
20
+ events.push({ name, fn });
21
+ return () => {};
22
+ },
23
+ llm: {
24
+ stream(options) {
25
+ calls.push(options);
26
+ const json = JSON.stringify([
27
+ { type: "history", title: "看过一张截图", content: "用户发了一张 1543x983 的截图并问数据对比", importance: 3 }
28
+ ]);
29
+ return (async function* () {
30
+ yield { type: "block-start", block: { type: "text" } };
31
+ yield { type: "text-delta", delta: json };
32
+ yield { type: "block-end", block: { type: "text" } };
33
+ yield { type: "finish", kind: "ok" };
34
+ })();
35
+ }
36
+ }
37
+ };
38
+ const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...over };
39
+ const summarizer = createSummarizer(ctx, service, config, {});
40
+ return { store, service, events, calls, summarizer };
41
+ }
42
+
43
+ function imageMessage(seq, text) {
44
+ const content = [];
45
+ if (text) content.push({ type: "text", text });
46
+ content.push({
47
+ type: "image",
48
+ attachment: {
49
+ attachmentId: "sha256:abc123",
50
+ mediaType: "image/png",
51
+ width: 1543,
52
+ height: 983,
53
+ name: "屏幕截图 2026-10-19 072000.png"
54
+ }
55
+ });
56
+ return { seq, type: "user/message", data: { source: { kind: "user" }, content } };
57
+ }
58
+
59
+
60
+ test("#611 图+文消息:蒸馏转录里同时有文字与图片占位", async () => {
61
+ const { events, calls } = setup();
62
+ const handler = events.find((e) => e.name === "session/event").fn;
63
+ const session = {
64
+ id: "s611-mixed",
65
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
66
+ events: [
67
+ imageMessage(1, "能有那种数据对比吗"),
68
+ { seq: 2, type: "turn/end" }
69
+ ]
70
+ };
71
+ await handler(session, { seq: 2, type: "turn/end" });
72
+
73
+ assert.equal(calls.length, 1, "这一窗应当发起一次蒸馏");
74
+ const transcript = calls[0].messages.find((m) => m.role === "user").content[0].text;
75
+ assert.ok(transcript.includes("能有那种数据对比吗"), "文字照旧进转录");
76
+ assert.ok(transcript.includes("[图:"), "图片必须留下占位,而不是消失");
77
+ assert.ok(transcript.includes("屏幕截图 2026-10-19 072000.png"), "文件名要能对上原始附件");
78
+ assert.ok(transcript.includes("1543x983"), "尺寸要留下");
79
+ });
80
+
81
+ test("#611 图-only 消息不再被整条丢弃(改动前窗口里一行都没有)", async () => {
82
+ const { events, calls } = setup();
83
+ const handler = events.find((e) => e.name === "session/event").fn;
84
+ const session = {
85
+ id: "s611-image-only",
86
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
87
+ events: [imageMessage(1, null), { seq: 2, type: "turn/end" }]
88
+ };
89
+ await handler(session, { seq: 2, type: "turn/end" });
90
+
91
+ assert.equal(calls.length, 1, "图-only 窗口现在也要蒸馏——旧版这里直接 noop");
92
+ const transcript = calls[0].messages.find((m) => m.role === "user").content[0].text;
93
+ assert.ok(transcript.includes("[图:"), "占位就是这一窗的全部可提炼内容");
94
+ });
95
+
96
+ test("#611 图片张数落进审计 metadata,off 档与静默丢弃可区分", async () => {
97
+ const { events, service } = setup();
98
+ const handler = events.find((e) => e.name === "session/event").fn;
99
+ const session = {
100
+ id: "s611-audit",
101
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
102
+ events: [
103
+ imageMessage(1, "第一张"),
104
+ imageMessage(2, "第二张"),
105
+ { seq: 3, type: "turn/end" }
106
+ ]
107
+ };
108
+ await handler(session, { seq: 3, type: "turn/end" });
109
+
110
+ const row = service.listLlmAudits().find((r) => r.status === "success");
111
+ assert.ok(row, "成功行要存在");
112
+ assert.equal(row.metadata?.imageCount, 2, "两张图必须被数出来");
113
+ });
114
+
115
+ test("#611 没有图时不写 imageCount(零行为变化)", async () => {
116
+ const { events, service } = setup();
117
+ const handler = events.find((e) => e.name === "session/event").fn;
118
+ const session = {
119
+ id: "s611-no-image",
120
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
121
+ events: [
122
+ { seq: 1, type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "只有文字" }] } },
123
+ { seq: 2, type: "turn/end" }
124
+ ]
125
+ };
126
+ await handler(session, { seq: 2, type: "turn/end" });
127
+
128
+ const row = service.listLlmAudits().find((r) => r.status === "success");
129
+ assert.ok(row);
130
+ assert.equal(row.metadata?.imageCount, undefined, "没图就别写这个键,老档位逐字节不变");
131
+ });
132
+ test("#611 被挡掉的注入消息里的图不计入(CodeRabbit 2026-11-08 指出)", async () => {
133
+ // 计量的口径必须是「真正进了转录的图」,不是「窗口事件里出现过几张图」。
134
+ // 这条用例造一条带图的插件注入消息:它会被 publicTranscriptParts 整条挡掉
135
+ // (转录里没有它),所以 imageCount 不该把它算进去——否则审计会报「这窗有 1 张图」
136
+ // 而模型根本没看到这张图。
137
+ const { events, service, calls } = setup();
138
+ const handler = events.find((e) => e.name === "session/event").fn;
139
+ const injected = {
140
+ seq: 1,
141
+ type: "user/message",
142
+ data: {
143
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" },
144
+ content: [
145
+ { type: "text", text: "插件注入的机器内容" },
146
+ {
147
+ type: "image",
148
+ attachment: { attachmentId: "sha256:zzz", mediaType: "image/png", width: 10, height: 10, name: "不该被数.png" }
149
+ }
150
+ ]
151
+ }
152
+ };
153
+ const session = {
154
+ id: "s611-injected",
155
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
156
+ events: [
157
+ injected,
158
+ { seq: 2, type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "真人的一句" }] } },
159
+ { seq: 3, type: "turn/end" }
160
+ ]
161
+ };
162
+ await handler(session, { seq: 3, type: "turn/end" });
163
+
164
+ assert.equal(calls.length, 1, "窗口里有真人文本,照常蒸馏");
165
+ const transcript = calls[0].messages.find((m) => m.role === "user").content[0].text;
166
+ assert.ok(transcript.includes("真人的一句"));
167
+ assert.ok(!transcript.includes("插件注入的机器内容"), "注入消息本来就不进转录");
168
+ const row = service.listLlmAudits().find((r) => r.status === "success");
169
+ assert.ok(row);
170
+ assert.equal(row.metadata?.imageCount, undefined, "被挡掉的注入消息里的图不该被数进去");
171
+ });
@@ -88,13 +88,16 @@ test("#249 第二批:面板的父子关系与后端同源(漏一处就静默
88
88
  // 渲染点:子项靠 flagRow 展开。只断言「声明与文案出现过」是恒真检查——把
89
89
  // 2236 行改回 g.items.map(boolRow),子项既不在 group.core 的 items 里、也不经
90
90
  // flagRow 渲染,面板上彻底消失,而字符串断言全绿。这一条才是那道门。
91
- assert.ok(/g\.items\.map\(flagRow\)/.test(client), "分组渲染必须走 flagRow,否则子项不渲染");
91
+ // #427 revamp:渲染形态从 map(flagRow) 升级为 map + subAfter(子块紧贴总闸),
92
+ // 门锁的是「每个 items 键都经 flagRow 渲染」这件事本身。
93
+ assert.ok(/g\.items\.map\(\(k\) => h\(react\.Fragment, \{ key: k \}, flagRow\(k\), subAfter\[k\]\)\)/.test(client),
94
+ "分组渲染必须走 flagRow(经 subAfter 内联),否则子项不渲染");
92
95
  const children = client.match(/const FEATURE_CHILDREN = (\{[^}]*\})/)?.[1] ?? "";
93
96
  assert.ok(children.includes("autoInject"), "面板未声明父开关 autoInject");
94
- const core = client.match(/\{ key: "group\.core", items: \[([^\]]*)\]/)?.[1] ?? "";
97
+ const allItems = [...client.matchAll(/items: \[([^\]]*)\]/g)].map((m) => m[1]).join("\n");
95
98
  for (const child of CHILDREN) {
96
99
  assert.ok(children.includes(`"${child}"`), `面板 FEATURE_CHILDREN 漏了 ${child}`);
97
- assert.ok(!core.includes(`"${child}"`), `${child} 同时在 items 与 FEATURE_CHILDREN 里,会被渲染两次`);
100
+ assert.ok(!allItems.includes(`"${child}"`), `${child} 同时在 items 与 FEATURE_CHILDREN 里,会被渲染两次`);
98
101
  // 双语文案:与 test/client.test.js 的路由键同款计数(zh + en 各一份 = 2)。
99
102
  const named = client.split(`"memory.features.${child}"`).length - 1;
100
103
  const hinted = client.split(`"memory.features.${child}.hint"`).length - 1;
@@ -155,6 +155,65 @@ test("Issue #129: 扁平的 data.content 形状仍被兼容", () => {
155
155
  assert.ok(text.includes("扁平形状的回答"), "flat content shape still supported");
156
156
  });
157
157
 
158
+ // ---- issue #637: 轮内快照洪泛 ----
159
+ // 宿主对动态 systemPrompt context 的契约是「文本变了就追加一份 runtime-context
160
+ // 快照,旧份永久留在会话历史」。热记忆此前把「正在组装的这一轮」也配对渲染:
161
+ // 提问先以空 A: 出现(与 prompt 里的真实 user message 重复一遍),回答落地后
162
+ // 这一行再抖成正文——同一轮内注入块至少变两次,报告实测 4 轮 → 10 份快照、
163
+ // 75% 重复率。注入侧省略尾部 pending 轮后,一轮内热块文本恒定,快照数降到
164
+ // 每轮一份(回答落地时恰好变一次)。
165
+
166
+ test("issue #637: the in-flight turn's query is not duplicated into the hot block", () => {
167
+ const { contexts } = setup();
168
+ // 本轮刚开头:上一轮已完成,本轮提问已落日志、回答还没来。
169
+ const session = {
170
+ id: "s1",
171
+ snapshotEvents: () => [
172
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第一问" }] } },
173
+ { type: "assistant/message", data: { message: { content: [{ type: "text", text: "第一答" }] } } },
174
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第二问" }] } }
175
+ ]
176
+ };
177
+ const text = contexts[0].text({ agent: { session } });
178
+ assert.ok(text.includes("第一问") && text.includes("第一答"), "completed previous round replays");
179
+ assert.ok(!text.includes("第二问"), "the current turn's query stays only in the real user message — no empty-A duplicate");
180
+ assert.ok(!/\nA:\s*\n/.test(text) && !text.includes("A: \n"), "no empty-A line in the rendered block");
181
+ });
182
+
183
+ test("issue #637: renders within one turn are byte-identical (the snapshot-flood lock)", () => {
184
+ const { contexts } = setup();
185
+ const log = [
186
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第一问" }] } },
187
+ { type: "assistant/message", data: { message: { content: [{ type: "text", text: "第一答" }] } } }
188
+ ];
189
+ const render = (events) => contexts[0].text({ agent: { session: { id: "s1", snapshotEvents: () => events } } });
190
+ // 本轮的第一次组装:提问已落日志(回答未落地)。
191
+ const first = render([...log, { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第二问" }] } }]);
192
+ // 工具循环中的再次组装:日志里多了 tool/assistant 中间事件,但本轮仍未闭合——
193
+ // 修复前这里已经是第二份快照(回答一落地就是第三份)。
194
+ const midTurn = render([...log,
195
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第二问" }] } },
196
+ { type: "tool/result", data: { message: { content: [{ type: "text", text: "工具输出" }] } } }
197
+ ]);
198
+ assert.equal(first, midTurn, "every assembly of the same turn renders the identical block");
199
+ assert.ok(first.length > 0, "the hot block still carries the completed previous round");
200
+ });
201
+
202
+ test("issue #637: the reply landing is the one change per turn; the next turn starts stable", () => {
203
+ const { contexts } = setup();
204
+ const render = (events) => contexts[0].text({ agent: { session: { id: "s1", snapshotEvents: () => events } } });
205
+ const u = (t) => ({ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: t }] } });
206
+ const a = (t) => ({ type: "assistant/message", data: { message: { content: [{ type: "text", text: t }] } } });
207
+ const turnOpen = render([u("第一问")]);
208
+ const replyLanded = render([u("第一问"), a("第一答")]);
209
+ assert.ok(turnOpen === "", "the first turn renders no hot block (nothing completed yet)");
210
+ assert.ok(replyLanded.includes("第一问") && replyLanded.includes("第一答"), "the reply landing adds the round — the single snapshot of the turn");
211
+ // 下一轮开头:第三问此刻是 pending 被省略,块与上一轮收尾完全一致 → 宿主不再追快照。
212
+ const nextTurnOpen = render([u("第一问"), a("第一答"), u("第二问")]);
213
+ assert.equal(nextTurnOpen, replyLanded, "the block is stable across the turn boundary");
214
+ });
215
+
216
+
158
217
  // ---- issue #162: 注入边界花括号转义(v0.7.4 #40 修复被 v0.7.11 误删后恢复) ----
159
218
 
160
219
  test("issue #162: {{...}} in memory content is escaped at the injection boundary", () => {
@@ -0,0 +1,361 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { createInjector } from "../src/inject.js";
6
+ import { createSettings } from "../src/settings.js";
7
+ import { createVectorIndex } from "../src/vector-index.js";
8
+ import { createSessionGate } from "../src/session-gate.js";
9
+
10
+ // #617 回归。三条缺陷各自独立成组,组内都用「一正一反」两把尺子:正向锁
11
+ // 「改对了」,反向(关掉对应开关 / 换成对照配方)锁「这条断言真的是在测那条
12
+ // 通路」,免得断言退化成常量真。
13
+
14
+ // --- 组 1:Step 1 的当轮查询(根因 A)-------------------------------------
15
+ //
16
+ // 宿主 agent-loop 的每个 step 是 claim() → assemble() → step(),而 user/message
17
+ // 要到 step() 末尾才 append。注入的 text 回调在 assemble 里求值,所以只看事件流
18
+ // 必然慢一拍:Step 1 拿到 "",第 N 轮的 Step 1 拿到第 N-1 轮的旧输入。
19
+ // 票据(agent/inbox/claimed)在 claim 那一刻记原文,渲染时事件流还没长就用它。
20
+ function setupInjector(over = {}) {
21
+ const store = createStore(":memory:");
22
+ const service = createService({ store, mirror: null, config: {} });
23
+ const settings = createSettings(store.db);
24
+ const contexts = [];
25
+ const handlers = new Map();
26
+ const ctx = {
27
+ // 真实宿主 ctx 有 on();旧夹具没有,所以旧夹具永远测不到这条路径。
28
+ on(event, fn) {
29
+ handlers.set(event, fn);
30
+ return () => handlers.delete(event);
31
+ },
32
+ systemPrompt: {
33
+ context(def) {
34
+ contexts.push(def);
35
+ return () => {};
36
+ }
37
+ }
38
+ };
39
+ const config = { maxInjectedItems: 3, importanceThreshold: 3, ...over };
40
+ const injector = createInjector(ctx, service, settings, config);
41
+ // 查询词是注入链路里唯一能直接观测的输入:包一层记下每次渲染实际传了什么。
42
+ const queries = [];
43
+ const orig = service.injectCandidates.bind(service);
44
+ service.injectCandidates = (args) => {
45
+ queries.push(args.query);
46
+ return orig(args);
47
+ };
48
+ const fireClaim = (message, session) => handlers.get("agent/inbox/claimed")?.({ message, agent: { session } });
49
+ return { store, service, contexts, injector, queries, fireClaim, handlers };
50
+ }
51
+
52
+ const userMessage = (text) => ({ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text }] } });
53
+ const claimed = (text) => ({ content: [{ type: "text", text }], source: { kind: "user" }, id: "m1" });
54
+
55
+ test("#617-A: Step 1 在 user/message 落盘之前,用认领票据拿到当轮输入", () => {
56
+ const { contexts, queries, fireClaim } = setupInjector();
57
+ const session = { id: "s-617", events: [], snapshotEvents() { return this.events; } };
58
+ // claim 发生在 assemble 之前,此刻事件流里还没有本轮消息
59
+ fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), session);
60
+ assert.equal(session.events.length, 0, "夹具前提:认领时 user/message 尚未落盘");
61
+ contexts[0].text({ agent: { session } });
62
+ assert.equal(queries.at(-1), "夜航App令牌桶限流方案怎么设计", "Step 1 必须拿到当轮输入,而不是空串");
63
+ });
64
+
65
+ test("#617-A: claim 与 assemble 之间插入无关事件,票据不得提前退休", () => {
66
+ // 宿主 preStep 的顺序是 claim() → await tools.resolve() → assemble()。中间那个
67
+ // await 给了别的插件追加事件的机会;若按「事件条数变多」判票据失效,Step 1 就
68
+ // 又退回空串——正是本 issue 要修的那一拍。判据必须是文本,不是条数。
69
+ const { contexts, queries, fireClaim } = setupInjector();
70
+ const session = { id: "s-617i", events: [], snapshotEvents() { return this.events; } };
71
+ session.events.push(userMessage("上一轮的问题"));
72
+ fireClaim(claimed("这一轮的新问题"), session);
73
+ // tools.resolve 期间,别的插件追加了一个与用户消息无关的事件
74
+ session.events.push({ type: "system/message", data: { content: [{ type: "text", text: "插件上下文" }] } });
75
+ contexts[0].text({ agent: { session } });
76
+ assert.equal(queries.at(-1), "这一轮的新问题", "无关事件不得让票据提前失效");
77
+ });
78
+
79
+ test("#617-A: 事件流长过票据记录后,票据退休、以事件流为准", () => {
80
+ const { contexts, queries, fireClaim } = setupInjector();
81
+ const session = { id: "s-617b", events: [], snapshotEvents() { return this.events; } };
82
+ fireClaim(claimed("第一个问题"), session);
83
+ contexts[0].text({ agent: { session } });
84
+ assert.equal(queries.at(-1), "第一个问题");
85
+ // step() 末尾 append → 事件流变长
86
+ session.events.push(userMessage("第一个问题"));
87
+ contexts[0].text({ agent: { session } });
88
+ assert.equal(queries.at(-1), "第一个问题", "落盘后两者一致(此轮无差异)");
89
+ // 下一轮:新的 claim 覆盖票据,事件流里仍是上一轮的文本
90
+ fireClaim(claimed("第二个问题"), session);
91
+ contexts[0].text({ agent: { session } });
92
+ assert.equal(queries.at(-1), "第二个问题", "新一轮的 Step 1 不得拿到上一轮的旧输入");
93
+ });
94
+
95
+ test("#617-A: 非人类消息(source.kind 非 user)不产生票据", () => {
96
+ const { contexts, queries, fireClaim } = setupInjector();
97
+ const session = { id: "s-617c", events: [], snapshotEvents() { return this.events; } };
98
+ fireClaim({ content: [{ type: "text", text: "插件注入的上下文" }], source: { kind: "system" } }, session);
99
+ contexts[0].text({ agent: { session } });
100
+ assert.equal(queries.at(-1), "", "插件自己的话不得被当成用户问题");
101
+ });
102
+
103
+ test("#617-A: 卸载后摘掉 claimed 监听,不再写票据", () => {
104
+ const { contexts, injector, queries, fireClaim, handlers } = setupInjector();
105
+ const session = { id: "s-617d", events: [], snapshotEvents() { return this.events; } };
106
+ assert.ok(handlers.has("agent/inbox/claimed"), "挂载时注册监听");
107
+ injector();
108
+ assert.equal(handlers.has("agent/inbox/claimed"), false, "卸载即摘,不留孤儿监听");
109
+ fireClaim(claimed("卸载后不该记"), session);
110
+ // 注入器已卸载,context 也不再生效;这里只确认没有票据泄漏到下一次装配
111
+ assert.equal(queries.length, 0);
112
+ void contexts;
113
+ });
114
+
115
+ // --- 组 2:prefetch 去重与失败冷却(根因 B)-------------------------------
116
+ //
117
+ // 旧实现用 lastPrefetched 字符串去重,且置位在 await 之前 —— 同一条查询第一次
118
+ // 失败(模型没下好、超时)之后永不重试,而那恰好是最需要重试的一次。
119
+ function setupPrefetch(embedImpl) {
120
+ const store = createStore(":memory:");
121
+ const service = createService({ store, mirror: null, config: {} });
122
+ const settings = createSettings(store.db);
123
+ const contexts = [];
124
+ const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
125
+ const calls = [];
126
+ service.setEmbedder({
127
+ embedSingle: async (text) => {
128
+ calls.push(text);
129
+ return embedImpl(text);
130
+ }
131
+ });
132
+ const injector = createInjector(ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3 });
133
+ return { store, service, contexts, injector, calls };
134
+ }
135
+
136
+ const renderWith = (contexts, session) => contexts[0].text({ agent: { session } });
137
+ const tick = () => new Promise((r) => setTimeout(r, 0));
138
+
139
+ test("#617-B: 同一步的两次求值(assemble + refreshContext)只嵌一次", async () => {
140
+ const { contexts, calls } = setupPrefetch(async () => [1, 0, 0]);
141
+ const session = { id: "p1", events: [userMessage("查询文本")], snapshotEvents() { return this.events; } };
142
+ renderWith(contexts, session); // 第一次求值:发起 inflight
143
+ renderWith(contexts, session); // 第二次求值:必须复用 inflight,不再嵌
144
+ await tick();
145
+ renderWith(contexts, session); // 第三次:命中缓存
146
+ assert.equal(calls.length, 1, "同一条查询只应嵌入一次");
147
+ });
148
+
149
+ test("#617-B: 失败后进入冷却期,不在同一帧反复撞模型", async () => {
150
+ const { contexts, calls } = setupPrefetch(async () => { throw new Error("model not ready"); });
151
+ const session = { id: "p2", events: [userMessage("失败查询")], snapshotEvents() { return this.events; } };
152
+ renderWith(contexts, session);
153
+ await tick();
154
+ renderWith(contexts, session);
155
+ await tick();
156
+ renderWith(contexts, session);
157
+ assert.equal(calls.length, 1, "冷却期内不得重试(15s 内)");
158
+ });
159
+
160
+ test("#617-B: 冷却按查询分别计,别的查询照常嵌入", async () => {
161
+ const { contexts, calls } = setupPrefetch(async (text) => {
162
+ if (text.includes("失败")) throw new Error("boom");
163
+ return [1, 0, 0];
164
+ });
165
+ const s1 = { id: "p3", events: [userMessage("失败查询")], snapshotEvents() { return this.events; } };
166
+ renderWith(contexts, s1);
167
+ await tick();
168
+ renderWith(contexts, s1);
169
+ const s2 = { id: "p4", events: [userMessage("另一条查询")], snapshotEvents() { return this.events; } };
170
+ renderWith(contexts, s2);
171
+ await tick();
172
+ assert.equal(calls.length, 2, "一条查询的失败冷却不应连坐其它查询");
173
+ });
174
+
175
+ // --- 组 3:向量路与 BM25 路并存(根因 C,前半)----------------------------
176
+ function seedStalePreferences(service, n = 8) {
177
+ const ids = [];
178
+ for (let i = 0; i < n; i++) {
179
+ ids.push(service.saveWithDedupe({
180
+ type: "preference",
181
+ title: `旧偏好条目${i}`,
182
+ content: `用户喜欢深色界面与紧凑排版,这是第${i}条历史偏好存档`,
183
+ importance: 5
184
+ }).memory.id);
185
+ }
186
+ return ids;
187
+ }
188
+
189
+ function setupDualPath(config = {}) {
190
+ const store = createStore(":memory:");
191
+ const service = createService({ store, mirror: null, config });
192
+ return { store, service };
193
+ }
194
+
195
+ test("#617-C: 有向量命中时,BM25 的词法精确命中不再整批消失", () => {
196
+ const { service, store } = setupDualPath();
197
+ seedStalePreferences(service, 8);
198
+ // 与查询词法完全不相交,但向量最近
199
+ const vecOnly = service.saveWithDedupe({
200
+ type: "project", title: "星图渲染重构", content: "绘制管线迁移与图层合并", importance: 3
201
+ }).memory;
202
+ // 词法精确命中,但没有向量(真实库里大量老行就是这样)
203
+ const lexHit = service.saveWithDedupe({
204
+ type: "decision", title: "夜航限流选型", content: "决定采用令牌桶做夜航App的限流方案", importance: 3
205
+ }).memory;
206
+ const vectorIndex = createVectorIndex({ store });
207
+ service.setVectorIndex(vectorIndex);
208
+ vectorIndex.saveEmbedding(vecOnly.id, [1, 0, 0]);
209
+
210
+ const picked = service.injectCandidates({
211
+ query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3, queryVector: [1, 0, 0]
212
+ });
213
+ assert.equal(picked[0].id, vecOnly.id, "纯余弦命中仍是首位");
214
+ assert.ok(picked.some((m) => m.id === lexHit.id), "BM25 命中不得被向量路挤掉(旧代码:有向量则 BM25 整段跳过)");
215
+ assert.ok(picked.length <= 5, "池子仍按 maxItems 封顶");
216
+ store.close();
217
+ });
218
+
219
+ test("#617-C: 关掉 BM25 后同一条词法命中确实消失(反向对照)", () => {
220
+ const { service, store } = setupDualPath({ bm25SearchEnabled: false });
221
+ seedStalePreferences(service, 8);
222
+ const vecOnly = service.saveWithDedupe({
223
+ type: "project", title: "星图渲染重构", content: "绘制管线迁移与图层合并", importance: 3
224
+ }).memory;
225
+ const lexHit = service.saveWithDedupe({
226
+ type: "decision", title: "夜航限流选型", content: "决定采用令牌桶做夜航App的限流方案", importance: 3
227
+ }).memory;
228
+ const vectorIndex = createVectorIndex({ store });
229
+ service.setVectorIndex(vectorIndex);
230
+ vectorIndex.saveEmbedding(vecOnly.id, [1, 0, 0]);
231
+
232
+ const picked = service.injectCandidates({
233
+ query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3, queryVector: [1, 0, 0]
234
+ });
235
+ assert.equal(picked[0].id, vecOnly.id);
236
+ assert.equal(picked.some((m) => m.id === lexHit.id), false, "BM25 关 = 词法路不参与,正向用例才不是在测别的东西");
237
+ store.close();
238
+ });
239
+
240
+ // --- 组 4:auto 不再被 keyword 覆盖(根因 C,后半)------------------------
241
+ //
242
+ // 旧代码在 fuseRecall 收尾处,只要 recipe !== "blend" 且 mode === "auto",就用
243
+ // keyword.slice(0,lim) 覆盖 merged —— 于是配了 rrf 却拿到 keyword 的顺序,配方
244
+ // 白选。auto 的「关键词领位」语义只属于 blend。
245
+ function setupFusion(recipe) {
246
+ const store = createStore(":memory:");
247
+ const service = createService({ store, mirror: null, config: { recallFusion: recipe } });
248
+ // 标题命中(keyword 第一),但没有向量
249
+ const kwTitle = service.saveWithDedupe({
250
+ type: "preference", title: "令牌桶限流笔记", content: "随手记的限流笔记", importance: 2
251
+ }).memory;
252
+ // 正文命中(keyword 第二)+ 向量第一 —— 两路都靠前
253
+ const both = service.saveWithDedupe({
254
+ type: "decision", title: "夜航限流选型", content: "决定采用令牌桶限流方案,阈值按等级分层", importance: 5
255
+ }).memory;
256
+ const vectorIndex = createVectorIndex({ store });
257
+ service.setVectorIndex(vectorIndex);
258
+ vectorIndex.saveEmbedding(both.id, [1, 0, 0]);
259
+ service.setEmbedder({ embedSingle: async () => [1, 0, 0] });
260
+ return { service, store, kwTitle, both };
261
+ }
262
+
263
+ test("#617-C: recallFusion=rrf 时 auto 用 RRF 自己的排序,不再被 keyword 覆盖", async () => {
264
+ const { service, store, kwTitle, both } = setupFusion("rrf");
265
+ const rows = await service.searchMemories("令牌桶限流", { mode: "auto", topK: 2, useRerank: false, recordRecall: false });
266
+ assert.equal(rows.length, 2);
267
+ assert.equal(rows[0].id, both.id, "两路都命中的行必须排第一(keyword 只命中标题的行不该领位)");
268
+ assert.equal(rows[1].id, kwTitle.id);
269
+ store.close();
270
+ });
271
+
272
+ test("#617-C: blend 的 auto 关键词领位契约保持不变(反向对照)", async () => {
273
+ const { service, store, kwTitle, both } = setupFusion("blend");
274
+ const rows = await service.searchMemories("令牌桶限流", { mode: "auto", topK: 2, useRerank: false, recordRecall: false });
275
+ assert.equal(rows.length, 2);
276
+ assert.equal(rows[0].id, kwTitle.id, "blend 下 keyword 领位是既有契约,本次改动不该碰它");
277
+ assert.equal(rows[1].id, both.id);
278
+ store.close();
279
+ });
280
+
281
+ // --- 组 5:无痕会话的认领路径不得外发(#539 × #617)-----------------------
282
+ //
283
+ // claim 在宿主 pre-step 里同步触发,早于 assemble,因此也早于渲染侧的闸门判断。
284
+ // 这条路径既写票据(票据会成为注入查询文本)又预取向量(embedQuery 直接 POST
285
+ // 外部 /embeddings),闸门命中时两者都是该会话数据的外流;嵌入层自身没有任何
286
+ // 会话维度判断(embedding.js / service.js 里 sessionGate 零命中),这里是唯一
287
+ // 拦截点。两条反向对照(未命中名单 / 总开关关闭)保证断言不是退化成「嵌入器
288
+ // 恰好没跑」。
289
+ function setupGated(over = {}) {
290
+ const store = createStore(":memory:");
291
+ const service = createService({ store, mirror: null, config: {} });
292
+ const settings = createSettings(store.db);
293
+ const contexts = [];
294
+ const handlers = new Map();
295
+ const ctx = {
296
+ on(event, fn) {
297
+ handlers.set(event, fn);
298
+ return () => handlers.delete(event);
299
+ },
300
+ systemPrompt: { context(def) { contexts.push(def); return () => {}; } }
301
+ };
302
+ const calls = [];
303
+ service.setEmbedder({
304
+ embedSingle: async (text) => {
305
+ calls.push(text);
306
+ return [1, 0, 0];
307
+ }
308
+ });
309
+ const queries = [];
310
+ const orig = service.injectCandidates.bind(service);
311
+ service.injectCandidates = (args) => {
312
+ queries.push(args.query);
313
+ return orig(args);
314
+ };
315
+ const sessionGate = createSessionGate({
316
+ sessionGateEnabled: true,
317
+ sessionGateSessions: ["s-gated"],
318
+ sessionGateSubagents: true,
319
+ ...over
320
+ });
321
+ const injector = createInjector(
322
+ ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3 }, null, sessionGate
323
+ );
324
+ const fireClaim = (message, session) => handlers.get("agent/inbox/claimed")?.({ message, agent: { session } });
325
+ return { store, service, contexts, injector, calls, queries, fireClaim, sessionGate };
326
+ }
327
+
328
+ test("#617-D: 无痕会话的认领消息既不预取向量,也不留票据", async () => {
329
+ const { contexts, calls, queries, fireClaim, sessionGate } = setupGated();
330
+ const session = { id: "s-gated", events: [], snapshotEvents() { return this.events; } };
331
+ assert.equal(sessionGate.isOff(session), true, "夹具前提:闸门认得出这个会话");
332
+ fireClaim(claimed("桉桉的私人日记:我今天去了协和医院复查"), session);
333
+ await tick();
334
+ assert.equal(calls.length, 0, "闸门命中时不得把原文发往嵌入端点");
335
+ // 票据若被写下,渲染时事件流还没长,Step 1 就会拿到这段原文
336
+ contexts[0].text({ agent: { session } });
337
+ assert.deepEqual(queries, [], "闸门命中时连 injectCandidates 都不该被调到");
338
+ });
339
+
340
+ test("#617-D: 未命中名单的会话照常预取(反向对照)", async () => {
341
+ const { contexts, calls, queries, fireClaim, sessionGate } = setupGated();
342
+ const session = { id: "s-normal", events: [], snapshotEvents() { return this.events; } };
343
+ assert.equal(sessionGate.isOff(session), false);
344
+ fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), session);
345
+ await tick();
346
+ assert.equal(calls.length, 1, "普通会话的预取行为不变");
347
+ assert.equal(calls[0], "夜航App令牌桶限流方案怎么设计");
348
+ // 票据确实写下了:渲染拿到的就是认领的原文
349
+ contexts[0].text({ agent: { session } });
350
+ assert.deepEqual(queries, ["夜航App令牌桶限流方案怎么设计"]);
351
+ });
352
+
353
+ test("#617-D: 总开关关闭时闸门不生效(反向对照)", async () => {
354
+ const { calls, fireClaim, sessionGate } = setupGated({ sessionGateEnabled: false });
355
+ const session = { id: "s-gated", events: [], snapshotEvents() { return this.events; } };
356
+ assert.equal(sessionGate.isEnabled(), false);
357
+ assert.equal(sessionGate.isOff(session), false, "总开关关时名单不得生效");
358
+ fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), session);
359
+ await tick();
360
+ assert.equal(calls.length, 1, "未开启 #539 时不得凭空拦截(默认行为须与基线一致)");
361
+ });