@modusensus/dsh-mneme 0.7.31 → 0.8.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 (93) hide show
  1. package/README.md +65 -27
  2. package/lib/api.js +139 -6
  3. package/lib/client.js +369 -32
  4. package/lib/commands.js +36 -5
  5. package/lib/config.js +101 -14
  6. package/lib/dream/decisions.js +147 -14
  7. package/lib/dream/sleep.js +84 -34
  8. package/lib/dream.js +250 -60
  9. package/lib/embedding.js +22 -2
  10. package/lib/index.js +68 -37
  11. package/lib/inject.js +54 -13
  12. package/lib/lang.js +168 -0
  13. package/lib/local-embedder.js +23 -9
  14. package/lib/mirror.js +17 -14
  15. package/lib/reranker.js +23 -8
  16. package/lib/runtime/adopt.js +176 -0
  17. package/lib/runtime/closure.js +172 -0
  18. package/lib/runtime/download.js +247 -0
  19. package/lib/runtime/layout.js +235 -0
  20. package/lib/runtime/loader.js +249 -0
  21. package/lib/runtime/provision.js +237 -0
  22. package/lib/runtime/tarball.js +130 -0
  23. package/lib/runtime/verify.js +191 -0
  24. package/lib/scope.js +83 -0
  25. package/lib/service.js +374 -55
  26. package/lib/settings.js +43 -2
  27. package/lib/store.js +126 -13
  28. package/lib/summarize.js +108 -35
  29. package/lib/tools.js +205 -12
  30. package/package.json +7 -2
  31. package/runtime-manifest.json +596 -0
  32. package/scripts/build-runtime-manifest.mjs +217 -0
  33. package/scripts/mneme-runtime.mjs +277 -0
  34. package/src/api.js +139 -6
  35. package/src/commands.js +36 -5
  36. package/src/config.js +101 -14
  37. package/src/dream/decisions.js +147 -14
  38. package/src/dream/sleep.js +84 -34
  39. package/src/dream.js +250 -60
  40. package/src/embedding.js +22 -2
  41. package/src/index.js +68 -37
  42. package/src/inject.js +54 -13
  43. package/src/lang.js +168 -0
  44. package/src/local-embedder.js +23 -9
  45. package/src/mirror.js +17 -14
  46. package/src/reranker.js +23 -8
  47. package/src/runtime/adopt.js +176 -0
  48. package/src/runtime/closure.js +172 -0
  49. package/src/runtime/download.js +247 -0
  50. package/src/runtime/layout.js +235 -0
  51. package/src/runtime/loader.js +249 -0
  52. package/src/runtime/provision.js +237 -0
  53. package/src/runtime/tarball.js +130 -0
  54. package/src/runtime/verify.js +191 -0
  55. package/src/scope.js +83 -0
  56. package/src/service.js +374 -55
  57. package/src/settings.js +43 -2
  58. package/src/store.js +126 -13
  59. package/src/summarize.js +108 -35
  60. package/src/tools.js +205 -12
  61. package/test/api.test.js +175 -9
  62. package/test/client.test.js +152 -10
  63. package/test/commands.test.js +40 -3
  64. package/test/config.test.js +17 -1
  65. package/test/dream-candidate-hybrid.test.js +281 -0
  66. package/test/dream.test.js +172 -0
  67. package/test/embedding-ready.test.js +196 -0
  68. package/test/helpers/runtime-source.js +60 -0
  69. package/test/helpers/tar-builder.js +80 -0
  70. package/test/inject.test.js +130 -1
  71. package/test/lang.test.js +87 -0
  72. package/test/quality-filter.test.js +68 -0
  73. package/test/reasoning-effort.test.js +106 -15
  74. package/test/reindex-backfill.test.js +62 -0
  75. package/test/runtime-adopt.test.js +206 -0
  76. package/test/runtime-cli.test.js +89 -0
  77. package/test/runtime-closure.test.js +193 -0
  78. package/test/runtime-download.test.js +302 -0
  79. package/test/runtime-layout.test.js +163 -0
  80. package/test/runtime-loader.test.js +237 -0
  81. package/test/runtime-manifest.test.js +158 -0
  82. package/test/runtime-provision.test.js +101 -0
  83. package/test/runtime-tarball.test.js +82 -0
  84. package/test/runtime-verify.test.js +214 -0
  85. package/test/scope-retrieval.test.js +197 -0
  86. package/test/scope-storage.test.js +247 -0
  87. package/test/scope-strict.test.js +165 -0
  88. package/test/service.test.js +76 -0
  89. package/test/sleep-action-set.test.js +422 -0
  90. package/test/store.test.js +14 -0
  91. package/test/summarize.test.js +224 -0
  92. package/test/tools.test.js +30 -5
  93. package/test/webServer-optional.test.js +34 -1
@@ -121,10 +121,10 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
121
121
  );
122
122
  });
123
123
 
124
- // The entry lives ABOVE the workspaces region: the real button is portalled
125
- // just before the host's regionArea container (below New Session, above the
126
- // workspace list), while the sidebar.footer.action registration remains as
127
- // the React anchor and the in-place fallback when the host markup changes.
124
+ // The entry lives at the top of the plugin-entry group: the real button is
125
+ // portalled right after the host's New-Session row (above the entries other
126
+ // plugins inject there), while the sidebar.footer.action registration remains
127
+ // as the React anchor and the in-place fallback when the host markup changes.
128
128
  test("sidebar entry portals above the workspaces region with footer fallback", () => {
129
129
  assert.ok(
130
130
  clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
@@ -139,8 +139,8 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
139
139
  "the portal must anchor at the host's regionArea container"
140
140
  );
141
141
  assert.ok(
142
- clientSource.includes("insertBefore(created, region)"),
143
- "the entry must sit immediately above the workspaces region"
142
+ clientSource.includes("insertBefore(created, target)"),
143
+ "the entry must be placed through the idempotent place() helper"
144
144
  );
145
145
  assert.ok(
146
146
  clientSource.includes("if (!host) return fallback;"),
@@ -160,13 +160,40 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
160
160
  );
161
161
  });
162
162
 
163
+ // Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
164
+ // own entries right after the New-Session row, and late inserters land above
165
+ // earlier ones — a fixed "before regionArea" slot degrades into the tail of
166
+ // that group, detaching the memory button from its familiar top spot. The
167
+ // entry therefore (a) resolves the same anchor as the ecosystem's shared
168
+ // sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
169
+ // its slot from the existing observer, and (c) stays idempotent — the anchor
170
+ // check short-circuits before any DOM write, so observers never ping-pong.
171
+ test("entry re-asserts the New-Session-adjacent slot against ecosystem entries", () => {
172
+ assert.ok(
173
+ clientSource.includes(`btn.closest('[class*="logoRow"]')`),
174
+ "the anchor must resolve the New-Session logo row like the ecosystem core"
175
+ );
176
+ assert.ok(
177
+ clientSource.includes("const target = anchor ? anchor.nextSibling : region;"),
178
+ "the entry must sit right after the New-Session row, falling back before regionArea"
179
+ );
180
+ assert.ok(
181
+ clientSource.includes("if (created.parentElement === parent && created.nextSibling === target) return;"),
182
+ "placement must be idempotent so the observer never writes on steady state"
183
+ );
184
+ assert.ok(
185
+ /place\(cur\);\s*\n\s*const cls = findNative\(cur\)/.test(clientSource),
186
+ "the observer must re-place the entry on childList churn, not just re-sync the class"
187
+ );
188
+ });
189
+
163
190
  // Three alignment/softness guarantees born from field feedback: (a) the
164
191
  // portalled entry keeps tracking the live New-Session class (host and skin
165
192
  // rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
166
- // fills the same width as that button and inherits its native centering,
193
+ // inherits that button's width and margins along with its native centering,
167
194
  // (c) the toolbar dropdown escapes the transform stacking trap — the
168
195
  // container needs the z-index because transform creates the context.
169
- test("entry tracks native class, fills native width, and lifts the toolbar dropdown", () => {
196
+ test("entry tracks native class, inherits native sizing, and lifts the toolbar dropdown", () => {
170
197
  assert.ok(
171
198
  clientSource.includes("new MutationObserver"),
172
199
  "the entry must re-sync the copied class via MutationObserver"
@@ -175,9 +202,14 @@ test("entry tracks native class, fills native width, and lifts the toolbar dropd
175
202
  clientSource.includes('attributeFilter: ["class"]'),
176
203
  "the observer must watch class attribute changes"
177
204
  );
205
+ assert.doesNotMatch(
206
+ clientSource,
207
+ /\.mneme-topentry-native\{(?:[^}]*;)?width:/,
208
+ "the entry must use the native button width, which reserves its horizontal margins"
209
+ );
178
210
  assert.ok(
179
- /\.mneme-topentry-native\{width:100%;/.test(clientSource),
180
- "the entry button must fill the same width as the New-Session row"
211
+ clientSource.includes(".mneme-topentry{display:flex;flex-direction:column}"),
212
+ "the entry wrapper must stretch the native button within a column flex layout"
181
213
  );
182
214
  assert.equal(
183
215
  clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
@@ -593,3 +625,113 @@ test("settings feedback card: prefilled issue + mailto + browse, version from /i
593
625
  assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
594
626
  }
595
627
  });
628
+
629
+ // UI 文案必须双语齐全(中英各一条)。用户明确要求所有 UI 文字都做 i18n,
630
+ // 这条守卫用「每个键恰好出现两次」兜住:出现奇数次就是漏译,>2 次说明重复。
631
+ test("i18n keys are bilingual: every key appears exactly twice (zh + en)", () => {
632
+ const counts = new Map();
633
+ for (const match of clientSource.matchAll(/^\s*"([a-zA-Z0-9_.]+)"\s*:\s*"/gm)) {
634
+ counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
635
+ }
636
+ const odd = [...counts.entries()].filter(([, n]) => n !== 2).map(([key, n]) => `${key}×${n}`);
637
+ assert.deepEqual(odd, [], `这些 i18n 键没有做到中英各一条:${odd.join(", ")}`);
638
+ assert.ok(counts.size > 100, `解析到的键太少(${counts.size}),正则可能没匹配到 i18n 表`);
639
+ });
640
+
641
+ // issue #135:向量状态卡必须区分「没配」与「配了却一条都没嵌上」。此前卡片只看
642
+ // ready,而未配置的 legacy embedder(ready 恒 true)一路显示成正常 provider。
643
+ test("vector status card surfaces the configured/degraded split", () => {
644
+ // 1. 读后端新字段,缺失(旧服务端)时退化成旧行为而不是误报未配置
645
+ assert.ok(
646
+ clientSource.includes('typeof j?.configured === "boolean" ? j.configured : null'),
647
+ "the card must read `configured` and tolerate older servers that omit it"
648
+ );
649
+ assert.ok(
650
+ clientSource.includes("j?.degraded === true"),
651
+ "the card must read `degraded`"
652
+ );
653
+ // 2. 三态互斥:未配置优先于初始化中,降级只在既非未配置也非初始化时成立
654
+ assert.ok(
655
+ clientSource.includes("const unconfigured = !off && state.configured === false;"),
656
+ "unconfigured must be its own state, not folded into ready"
657
+ );
658
+ assert.ok(
659
+ clientSource.includes("const pending = !off && !unconfigured && state.ready !== true;"),
660
+ "an unconfigured embedder must not be reported as initializing"
661
+ );
662
+ assert.ok(
663
+ clientSource.includes("const degraded = !off && !unconfigured && !pending && state.degraded === true;"),
664
+ "degraded must only apply to a configured, settled embedder"
665
+ );
666
+ // 3. 三个状态各自的标签/说明走 i18n,双语齐备
667
+ for (const key of ["vectorUnconfigured", "vectorUnconfiguredHint", "vectorDegradedHint"]) {
668
+ const occurrences = clientSource.split(`"memory.status.${key}"`).length - 1;
669
+ assert.ok(occurrences >= 2, `i18n key memory.status.${key} must exist in both zh and en (got ${occurrences})`);
670
+ }
671
+ assert.ok(
672
+ clientSource.includes('t("memory.status.vectorUnconfigured")'),
673
+ "the unconfigured state must render its own label"
674
+ );
675
+ assert.ok(
676
+ clientSource.includes('t("memory.status.vectorDegradedHint")'),
677
+ "the degraded state must render its own caption (0 of {m} indexed)"
678
+ );
679
+ });
680
+
681
+ // v0.8.0 A4(issue #17):作用域隔离的面板面——设置卡新组(scopeEnabled /
682
+ // strictScope 开关)+ 记忆条目的 scope provenance 展示。i18n 键必须中英双语
683
+ // 齐全;徽章/详情行沿用 heat 徽章的「字段缺省即不渲染」模式,前端不感知开关。
684
+ /**
685
+ * A4(issue #17)面板面回归:作用域隔离的设置卡分组、十个 i18n 键的中英双语
686
+ * 齐备(沿用 occurrences ≥ 2 模式),以及 ScopeBadge 组件与三处接线(cards
687
+ * 脚部 / timeline 行 / 详情抽屉 dmeta)的真实存在性——只做源码断言,不挂
688
+ * React 渲染环境,与文件内其他 clientSource 断言同一风格。
689
+ */
690
+ test("scope isolation ships panel switches and provenance surfaces (A4)", () => {
691
+ assert.ok(
692
+ /key: "group\.scope", items: \["scopeEnabled", "strictScope"\]/.test(clientSource),
693
+ "FEATURE_GROUPS must carry a dedicated scope group"
694
+ );
695
+ for (const key of [
696
+ "memory.features.group.scope",
697
+ "memory.features.scopeEnabled",
698
+ "memory.features.scopeEnabled.hint",
699
+ "memory.features.strictScope",
700
+ "memory.features.strictScope.hint",
701
+ "memory.explorer.scopeBadge",
702
+ "memory.explorer.detail.agentScope",
703
+ "memory.explorer.detail.workspaceScope",
704
+ "memory.explorer.detail.sensitivity",
705
+ "memory.explorer.detail.occurred"
706
+ ]) {
707
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
708
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
709
+ }
710
+ assert.ok(/const ScopeBadge = \(\{ m, t \}\)/.test(clientSource), "ScopeBadge component must exist");
711
+ assert.ok(clientSource.includes("h(ScopeBadge, { m, t })"), "ScopeBadge must be wired into browse views");
712
+ assert.ok(clientSource.includes('t("memory.explorer.detail.agentScope")'), "drawer must render agent scope row");
713
+ assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
714
+ });
715
+
716
+ // v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
717
+ // 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
718
+ test("conflict queue ships a central review surface on the status tab", () => {
719
+ for (const key of [
720
+ "memory.status.conflictQueue.reason",
721
+ "memory.status.conflictQueue.sideA",
722
+ "memory.status.conflictQueue.sideB",
723
+ "memory.status.conflictQueue.keepA",
724
+ "memory.status.conflictQueue.keepB",
725
+ "memory.status.conflictQueue.markReviewed",
726
+ "memory.status.conflictQueue.applyHint",
727
+ "memory.status.conflictQueue.missing",
728
+ "memory.status.conflictQueue.refresh"
729
+ ]) {
730
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
731
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
732
+ }
733
+ assert.ok(/function ConflictsQueue\(\{ t \}\)/.test(clientSource), "ConflictsQueue component must exist");
734
+ assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
735
+ assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
736
+ assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
737
+ });
@@ -40,14 +40,51 @@ test("add registers live; remove unregisters live", () => {
40
40
  store.close();
41
41
  });
42
42
 
43
- test("handler returns the instruction as success text", async () => {
43
+ test("handler submits the instruction to the agent and returns a receipt", async () => {
44
44
  const { store, manager, registered } = setup();
45
45
  manager.add({ name: "fmt", description: "格式化", instruction: "请按项目的 lint 规则格式化当前文件" });
46
46
  const def = registered.get("fmt");
47
47
  assert.ok(def, "definition registered");
48
- const result = await def.handler({ agent: {}, rawInput: "", signal: null });
48
+ assert.equal(def.input.hint, "可选:追加给模型的补充说明");
49
+ const submitted = [];
50
+ const agent = { followup: (message) => submitted.push(message) };
51
+ const result = await def.handler({ agent, rawInput: "", signal: null });
49
52
  assert.equal(result.kind, "success");
50
- assert.equal(result.text, "请按项目的 lint 规则格式化当前文件");
53
+ assert.equal(result.text, "已提交指令:fmt");
54
+ assert.equal(submitted.length, 1);
55
+ assert.equal(submitted[0].role, "user");
56
+ assert.deepEqual(submitted[0].content, [{ type: "text", text: "请按项目的 lint 规则格式化当前文件" }]);
57
+ assert.deepEqual(submitted[0].source, { kind: "user" });
58
+ store.close();
59
+ });
60
+
61
+ test("rawInput suffix is appended for the model", async () => {
62
+ const { store, manager, registered } = setup();
63
+ manager.add({ name: "mneme", instruction: "以「小墨」的口吻自我介绍" });
64
+ const def = registered.get("mneme");
65
+ const submitted = [];
66
+ await def.handler({ agent: { followup: (m) => submitted.push(m) }, rawInput: " 顺便列出归档数量 " });
67
+ assert.equal(submitted[0].content[0].text, "以「小墨」的口吻自我介绍\n\n顺便列出归档数量");
68
+ store.close();
69
+ });
70
+
71
+ test("handler without a receiving agent falls back to surfacing the instruction", () => {
72
+ const { store, manager, registered } = setup();
73
+ manager.add({ name: "fmt", instruction: "legacy text" });
74
+ const def = registered.get("fmt");
75
+ const result = def.handler({ rawInput: "" });
76
+ assert.equal(result.kind, "success");
77
+ assert.equal(result.text, "legacy text");
78
+ store.close();
79
+ });
80
+
81
+ test("handler returns an error result when the agent rejects the submission", () => {
82
+ const { store, manager, registered } = setup();
83
+ manager.add({ name: "boom", instruction: "x" });
84
+ const def = registered.get("boom");
85
+ const result = def.handler({ agent: { followup: () => { throw new Error("nope"); } } });
86
+ assert.equal(result.kind, "error");
87
+ assert.match(result.text, /boom/);
51
88
  store.close();
52
89
  });
53
90
 
@@ -46,5 +46,21 @@ test("startup probe: the reranker module never statically imports transformers/o
46
46
  !src.includes('from "@huggingface/transformers"'),
47
47
  "no static transformers.js import in reranker.js"
48
48
  );
49
- assert.match(src, /await import\("@huggingface\/transformers"\)/, "transformers.js loads lazily via dynamic import");
49
+ // issue #131 之后,懒加载的落点从 reranker 内部搬到了 src/runtime/loader.js
50
+ // (三层解析:自管 payload → 宿主裸 specifier)。判据随之改成两条,意图不变:
51
+ // reranker 不得静态导入,且必须经由 loader 间接加载;动态 import 只允许出现在
52
+ // loader 里(它是唯一知道「运行时从哪来」的模块)。
53
+ assert.match(src, /from "\.\/runtime\/loader\.js"/, "reranker 经 runtime/loader.js 取运行时");
54
+ assert.match(src, /loadTransformers\(/, "reranker 必须走三层解析,而不是自己拼 import");
55
+ assert.ok(
56
+ !/^\s*import[^\n]*@huggingface\/transformers/m.test(src),
57
+ "reranker 不允许任何形式的静态 transformers 导入"
58
+ );
59
+
60
+ const loaderSrc = readFileSync(new URL("../src/runtime/loader.js", import.meta.url), "utf8");
61
+ assert.match(loaderSrc, /import\(specifier\)/, "loader 用动态 import 保持懒加载");
62
+ assert.ok(
63
+ !/^\s*import[^\n]*@huggingface\/transformers/m.test(loaderSrc),
64
+ "loader 不得静态导入 transformers,否则惰性加载就失效了"
65
+ );
50
66
  });
@@ -0,0 +1,281 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createDreamScheduler } from "../src/dream.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createVectorIndex } from "../src/vector-index.js";
7
+ import { parseEntries } from "./helpers/dream-mock.js";
8
+
9
+ // Issue #125:候选集构造从"纯时间窗口"扩成"窗口 ∪ 向量翻出的高相似组"。
10
+ // 实测背景:45 对「双方活跃且 sim≥0.85」里 0 对能同时进窗口(窗口覆盖率 11.7%),
11
+ // 纯时间窗口让"该合并的一对"几乎永远碰不到面。
12
+ //
13
+ // 观察点统一用 mock LLM 收到的候选列表(listText 里的 id=/title= 行)——那就是
14
+ // snapshot 的真实内容,比读内部变量更接近"LLM 实际看到了什么"。
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ function setup() {
19
+ const store = createStore(":memory:");
20
+ const service = createService({ store, mirror: null, config: {} });
21
+ const vectorIndex = createVectorIndex({ store });
22
+ const embedder = {
23
+ embedSingle: async () => [1, 0, 0],
24
+ embed: async (texts) => texts.map(() => [1, 0, 0]),
25
+ schedule: () => {},
26
+ modelHash: "mock#1",
27
+ dimension: 3
28
+ };
29
+ service.setEmbedder(embedder);
30
+ service.setVectorIndex(vectorIndex);
31
+ return { store, service, vectorIndex, embedder };
32
+ }
33
+
34
+ /** Save a memory with a pinned embedding. */
35
+ function seed(service, vectorIndex, title, vec) {
36
+ const m = service.saveWithDedupe({ type: "project", title, content: `${title} 的内容`, importance: 3 }).memory;
37
+ vectorIndex.saveEmbedding(m.id, vec);
38
+ return m;
39
+ }
40
+
41
+ /** Capture the candidate titles the LLM was handed. */
42
+ // 注意:不能用 helpers 的 mockCtx —— 它靠 userText.startsWith("id=") 区分阶段,而
43
+ // semantic 可用时候选列表以「# 聚类 N」开头(语义预分组),会被误判成 summary。
44
+ // 这里改用 system prompt 判定,与 listText 的形态解耦。
45
+ function stubCtx(consolidationReply) {
46
+ const seen = { titles: [] };
47
+ const ctx = {
48
+ logger: { warn: () => {}, info: () => {} },
49
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
50
+ llm: {
51
+ async *stream(options) {
52
+ const sys = options.messages.find((m) => m.role === "system")?.content?.[0]?.text ?? "";
53
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
54
+ const isConsolidation = sys.includes("记忆巩固");
55
+ if (isConsolidation) {
56
+ seen.titles = parseEntries(userText).map((e) => e.title);
57
+ yield { type: "text-delta", index: 0, text: typeof consolidationReply === "function" ? consolidationReply() : consolidationReply };
58
+ } else {
59
+ yield { type: "text-delta", index: 0, text: "记忆库总览:测试桩。" };
60
+ }
61
+ yield { type: "finish", reason: { kind: "stop" } };
62
+ }
63
+ }
64
+ };
65
+ return { ctx, seen };
66
+ }
67
+
68
+ function capture() {
69
+ return stubCtx("[]");
70
+ }
71
+
72
+ function scheduler(config, semantic) {
73
+ return createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0, semantic });
74
+ }
75
+
76
+ const baseCfg = { dreamProvider: "mock", dreamModel: "mock-model" };
77
+
78
+ test("issue#125 window (default): candidates are exactly the newest dreamMaxSnapshotSize entries", async () => {
79
+ const { store, service } = setup();
80
+ // 同毫秒保存会让 updated_at 并列、排序退化到 id(UUID 随机)——插小延时保证严格递增。
81
+ for (let i = 0; i < 5; i++) {
82
+ service.saveWithDedupe({ type: "project", title: `记忆${i}`, content: `内容${i}`, importance: 3 });
83
+ await sleep(3);
84
+ }
85
+ const { ctx, seen } = capture();
86
+ await scheduler().runDream(ctx, service, { ...baseCfg, dreamMaxSnapshotSize: 3 });
87
+ assert.deepEqual(seen.titles, ["记忆4", "记忆3", "记忆2"], "newest three, updated_at desc — 与 v0.7.31 逐条一致");
88
+ store.close();
89
+ });
90
+
91
+ test("issue#125 hybrid: a highly similar pair sitting outside the window is pulled in", async () => {
92
+ const { store, service, vectorIndex, embedder } = setup();
93
+ // 窗口外(最老):一对高相似(同向量 → cos 1.0)
94
+ const a = seed(service, vectorIndex, "老旧方案A", [1, 0, 0]);
95
+ await sleep(3);
96
+ const b = seed(service, vectorIndex, "老旧方案B", [1, 0, 0]);
97
+ await sleep(3);
98
+ // 窗口内(最新 3 条):彼此不相似(正交)
99
+ seed(service, vectorIndex, "近期甲", [0, 1, 0]);
100
+ await sleep(3);
101
+ seed(service, vectorIndex, "近期乙", [0, 0, 1]);
102
+ await sleep(3);
103
+ seed(service, vectorIndex, "近期丙", [0.5, 0.5, 0.7]);
104
+
105
+ const semantic = { embedder, vectorIndex };
106
+ const { ctx, seen } = capture();
107
+ await scheduler({}, semantic).runDream(ctx, service, {
108
+ ...baseCfg, dreamMaxSnapshotSize: 3, dreamCandidateMode: "hybrid"
109
+ });
110
+ assert.ok(seen.titles.includes(a.title) && seen.titles.includes(b.title),
111
+ "the out-of-window similar pair reached the candidate list");
112
+ store.close();
113
+ });
114
+
115
+ test("issue#125 hybrid: dreamCandidateMax widens the cap, the window is still included", async () => {
116
+ const { store, service, vectorIndex, embedder } = setup();
117
+ const a = seed(service, vectorIndex, "外窗A", [1, 0, 0]);
118
+ await sleep(3);
119
+ const b = seed(service, vectorIndex, "外窗B", [1, 0, 0]);
120
+ await sleep(3);
121
+ seed(service, vectorIndex, "窗内甲", [0, 1, 0]);
122
+ await sleep(3);
123
+ seed(service, vectorIndex, "窗内乙", [0, 0, 1]);
124
+
125
+ const semantic = { embedder, vectorIndex };
126
+ const { ctx, seen } = capture();
127
+ await scheduler({}, semantic).runDream(ctx, service, {
128
+ ...baseCfg, dreamMaxSnapshotSize: 2, dreamCandidateMode: "hybrid", dreamCandidateMax: 4
129
+ });
130
+ assert.equal(seen.titles.length, 4, "candidate total capped at max(window, dreamCandidateMax)");
131
+ assert.ok(seen.titles.includes(a.title) && seen.titles.includes(b.title), "vector group kept");
132
+ assert.equal(new Set(seen.titles).size, seen.titles.length, "no duplicate members");
133
+ store.close();
134
+ });
135
+
136
+ test("issue#125 hybrid: three mutually similar entries arrive as one contiguous group", async () => {
137
+ const { store, service, vectorIndex, embedder } = setup();
138
+ // 一簇 3 条互相相似(同向量)
139
+ const c1 = seed(service, vectorIndex, "簇甲", [1, 0, 0]);
140
+ await sleep(3);
141
+ const c2 = seed(service, vectorIndex, "簇乙", [1, 0, 0]);
142
+ await sleep(3);
143
+ const c3 = seed(service, vectorIndex, "簇丙", [1, 0, 0]);
144
+ await sleep(3);
145
+ seed(service, vectorIndex, "窗外无关", [0, 1, 0]);
146
+
147
+ const semantic = { embedder, vectorIndex };
148
+ const { ctx, seen } = capture();
149
+ await scheduler({}, semantic).runDream(ctx, service, {
150
+ ...baseCfg, dreamMaxSnapshotSize: 1, dreamCandidateMode: "hybrid", dreamCandidateMax: 4
151
+ });
152
+ const idx = [c1.title, c2.title, c3.title].map((t) => seen.titles.indexOf(t));
153
+ assert.ok(idx.every((i) => i >= 0), "all cluster members are candidates");
154
+ const sorted = [...idx].sort((x, y) => x - y);
155
+ assert.deepEqual(sorted, [sorted[0], sorted[0] + 1, sorted[0] + 2],
156
+ "cluster members are contiguous — 一簇按一个候选单元展开,不会被拆成散落的对");
157
+ store.close();
158
+ });
159
+
160
+ test("issue#125 hybrid without an embedder falls back to the plain window (never fails)", async () => {
161
+ const { store, service } = setup();
162
+ for (let i = 0; i < 4; i++) {
163
+ service.saveWithDedupe({ type: "project", title: `回滚${i}`, content: `内容${i}`, importance: 3 });
164
+ await sleep(3);
165
+ }
166
+ const { ctx, seen } = capture();
167
+ // semantic = null → hybrid 分支不激活
168
+ const result = await scheduler({}, null).runDream(ctx, service, {
169
+ ...baseCfg, dreamMaxSnapshotSize: 2, dreamCandidateMode: "hybrid"
170
+ });
171
+ assert.equal(seen.titles.length, 2, "falls back to the window");
172
+ assert.equal(result.ok, true, "the run still succeeds");
173
+ assert.deepEqual(seen.titles, ["回滚3", "回滚2"]);
174
+ store.close();
175
+ });
176
+
177
+ test("issue#125 hybrid: a similarity cluster outside the window is resolvable in one run", async () => {
178
+ const { store, service, vectorIndex, embedder } = setup();
179
+ const a = seed(service, vectorIndex, "旧版本", [1, 0, 0]);
180
+ await sleep(3);
181
+ const b = seed(service, vectorIndex, "新版本", [1, 0, 0]);
182
+ await sleep(3);
183
+ seed(service, vectorIndex, "近期条目", [0, 1, 0]);
184
+
185
+ const semantic = { embedder, vectorIndex };
186
+ // LLM 只对这一对下一道 merge 决策——它在窗口外,window 档下永远不会被看到。
187
+ const { ctx } = stubCtx(() => JSON.stringify([
188
+ { action: "merge", ids: [b.id, a.id], keepSource: b.id, title: "版本总览", content: "合并后", importance: 4 }
189
+ ]));
190
+ const result = await scheduler({}, semantic).runDream(ctx, service, {
191
+ ...baseCfg, dreamMaxSnapshotSize: 2, dreamCandidateMode: "hybrid"
192
+ });
193
+ assert.equal(result.ok, true, "the out-of-window pair is adjudicated (not a whole-batch rejection)");
194
+ assert.equal(store.getById(a.id).archived, true, "the older version was merged away");
195
+ assert.equal(store.getById(b.id).title, "版本总览");
196
+ store.close();
197
+ });
198
+
199
+ // ------------------------------------------------------- backfill & 降级路径
200
+
201
+ test("issue#125 hybrid: entries without a cached vector are embedded on the fly", async () => {
202
+ const { store, service, vectorIndex, embedder } = setup();
203
+ // 故意不预设向量:collectCandidateVectors 必须自己补齐后再比较。
204
+ const a = service.saveWithDedupe({ type: "project", title: "无向量A", content: "内容A", importance: 3 }).memory;
205
+ await sleep(3);
206
+ const b = service.saveWithDedupe({ type: "project", title: "无向量B", content: "内容B", importance: 3 }).memory;
207
+ await sleep(3);
208
+ seed(service, vectorIndex, "窗内条目", [0, 1, 0]);
209
+ const { ctx, seen } = capture();
210
+ await scheduler({}, { embedder, vectorIndex }).runDream(ctx, service, {
211
+ ...baseCfg, dreamMaxSnapshotSize: 1, dreamCandidateMode: "hybrid", dreamCandidateMax: 4
212
+ });
213
+ assert.ok(seen.titles.includes(a.title) && seen.titles.includes(b.title),
214
+ "missing vectors are backfilled (embedder.embed), then the pair is compared");
215
+ store.close();
216
+ });
217
+
218
+ test("issue#125 hybrid: cross-type pairs join only when allowCrossTypeMerge is on", async () => {
219
+ const { store, service, vectorIndex, embedder } = setup();
220
+ const proj = service.saveWithDedupe({ type: "project", title: "项目侧", content: "x", importance: 3 }).memory;
221
+ vectorIndex.saveEmbedding(proj.id, [1, 0, 0]);
222
+ await sleep(3);
223
+ const pref = service.saveWithDedupe({ type: "preference", title: "偏好侧", content: "y", importance: 3 }).memory;
224
+ vectorIndex.saveEmbedding(pref.id, [1, 0, 0]);
225
+ await sleep(3);
226
+ seed(service, vectorIndex, "窗内条目", [0, 1, 0]);
227
+ const semantic = { embedder, vectorIndex };
228
+ const cfg = { ...baseCfg, dreamMaxSnapshotSize: 1, dreamCandidateMode: "hybrid", dreamCandidateMax: 4 };
229
+
230
+ const off = capture();
231
+ await scheduler({}, semantic).runDream(off.ctx, service, cfg);
232
+ assert.ok(!off.seen.titles.includes(pref.title), "cross-type pair stays out by default (same-type only)");
233
+
234
+ const on = capture();
235
+ await scheduler({}, semantic).runDream(on.ctx, service, { ...cfg, allowCrossTypeMerge: true });
236
+ assert.ok(on.seen.titles.includes(proj.title) && on.seen.titles.includes(pref.title),
237
+ "cross-type pair enters the candidate set once explicitly allowed");
238
+ store.close();
239
+ });
240
+
241
+ test("issue#125 hybrid: a throwing embedder degrades to the plain window", async () => {
242
+ const { store, service, vectorIndex } = setup();
243
+ for (let i = 0; i < 3; i++) {
244
+ service.saveWithDedupe({ type: "project", title: `降级${i}`, content: `内容${i}`, importance: 3 });
245
+ await sleep(3);
246
+ }
247
+ const boom = {
248
+ embedSingle: async () => { throw new Error("embed down"); },
249
+ embed: async () => { throw new Error("embed down"); },
250
+ schedule: () => {},
251
+ modelHash: "x",
252
+ dimension: 3
253
+ };
254
+ const { ctx, seen } = capture();
255
+ const result = await scheduler({}, { embedder: boom, vectorIndex }).runDream(ctx, service, {
256
+ ...baseCfg, dreamMaxSnapshotSize: 2, dreamCandidateMode: "hybrid"
257
+ });
258
+ assert.equal(result.ok, true, "向量层是增强而非依赖:嵌入失败不能让 run 失败");
259
+ assert.deepEqual(seen.titles, ["降级2", "降级1"], "falls back to the plain window");
260
+ store.close();
261
+ });
262
+
263
+ test("issue#125 hybrid: a throwing vector index degrades to the plain window", async () => {
264
+ const { store, service, embedder } = setup();
265
+ for (let i = 0; i < 3; i++) {
266
+ service.saveWithDedupe({ type: "project", title: `索引坏${i}`, content: `内容${i}`, importance: 3 });
267
+ await sleep(3);
268
+ }
269
+ // getEmbedding 在 hybridCandidateMembers 的 try 内抛错 → 整体回落纯窗口。
270
+ const badIndex = {
271
+ getEmbedding: () => { throw new Error("index down"); },
272
+ saveEmbedding: () => {}
273
+ };
274
+ const { ctx, seen } = capture();
275
+ const result = await scheduler({}, { embedder, vectorIndex: badIndex }).runDream(ctx, service, {
276
+ ...baseCfg, dreamMaxSnapshotSize: 2, dreamCandidateMode: "hybrid"
277
+ });
278
+ assert.equal(result.ok, true, "run still succeeds");
279
+ assert.deepEqual(seen.titles, ["索引坏2", "索引坏1"], "falls back to the plain window");
280
+ store.close();
281
+ });