@modusensus/dsh-mneme 0.9.4 → 0.10.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 (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
@@ -0,0 +1,79 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+
5
+ // Issue #529(B 项):冲突裁决台账(conflict_reviewed)的存储层契约。
6
+ //
7
+ // 为什么单开一张表而不是复用 conflict_pending:后者是**人工**队列——面板徽标数
8
+ // 就是 countConflictPending()(resolved_at IS NULL 的行数),机器每判一对就写一行
9
+ // 会把那个数字淹掉,人再也看不出"有哪些待我裁决"。台账是机器记账,零用户界面。
10
+ //
11
+ // 这些用例锁三件事,防的回归是:
12
+ // ① pair 键不归一化 → 同一对(A,B)与(B,A)会记成两行,下一轮照样重送;
13
+ // ② 重复记账不是幂等 upsert → 同一对判过 N 次就堆 N 行,表无界增长;
14
+ // ③ 记账误落 conflict_pending → 人工队列被机器行淹没(这是最贵的一种)。
15
+
16
+ test("issue#529 conflict_reviewed:写入后可读回,键归一化为 (a,b)", () => {
17
+ const store = createStore(":memory:");
18
+ store.saveConflictReviewed({
19
+ run_id: "run-1",
20
+ memory_a: "b-id",
21
+ memory_b: "a-id",
22
+ outcome: "conflict",
23
+ judged_at: "2026-10-31T00:00:00.000Z"
24
+ });
25
+ const all = store.loadConflictReviewed();
26
+ assert.equal(all.size, 1, "只落一行");
27
+ const key = [...all.keys()][0];
28
+ assert.equal(key, "a-id\u0000b-id", "键按字典序归一化——反着传也是同一个键");
29
+ const row = all.get(key);
30
+ assert.equal(row.outcome, "conflict");
31
+ assert.equal(row.run_id, "run-1");
32
+ assert.equal(row.judged_at, "2026-10-31T00:00:00.000Z");
33
+ store.close();
34
+ });
35
+
36
+ test("issue#529 conflict_reviewed:同一对重复记账是 upsert,不堆行", () => {
37
+ const store = createStore(":memory:");
38
+ for (let i = 0; i < 3; i++) {
39
+ store.saveConflictReviewed({
40
+ run_id: "run-" + i,
41
+ memory_a: "a-id",
42
+ memory_b: "b-id",
43
+ outcome: "keep",
44
+ judged_at: "2026-10-31T00:00:0" + i + ".000Z"
45
+ });
46
+ }
47
+ const all = store.loadConflictReviewed();
48
+ assert.equal(all.size, 1, "同一对只留一行");
49
+ assert.equal(all.get("a-id\u0000b-id").run_id, "run-2", "后写的覆盖先写的");
50
+ assert.equal(all.get("a-id\u0000b-id").judged_at, "2026-10-31T00:00:02.000Z");
51
+ store.close();
52
+ });
53
+
54
+ test("issue#529 conflict_reviewed:记账不进人工待裁决队列", () => {
55
+ const store = createStore(":memory:");
56
+ for (let i = 0; i < 5; i++) {
57
+ store.saveConflictReviewed({
58
+ run_id: "run-" + i,
59
+ memory_a: "a-" + i,
60
+ memory_b: "b-" + i,
61
+ outcome: "keep",
62
+ judged_at: "2026-10-31T00:00:00.000Z"
63
+ });
64
+ }
65
+ assert.equal(store.countConflictPending(), 0, "关键:机器记账不涨人工队列的徽标数");
66
+ assert.deepEqual(store.listConflictPending(), []);
67
+ assert.equal(store.listConflictReviewed().length, 5, "但台账里确实有 5 行");
68
+ store.close();
69
+ });
70
+
71
+ test("issue#529 conflict_reviewed:listConflictReviewed 按判时倒序", () => {
72
+ const store = createStore(":memory:");
73
+ store.saveConflictReviewed({ memory_a: "a", memory_b: "b", outcome: "keep", judged_at: "2026-10-30T00:00:00.000Z", run_id: "r1" });
74
+ store.saveConflictReviewed({ memory_a: "c", memory_b: "d", outcome: "conflict", judged_at: "2026-10-31T00:00:00.000Z", run_id: "r2" });
75
+ const rows = store.listConflictReviewed();
76
+ assert.equal(rows[0].judged_at, "2026-10-31T00:00:00.000Z", "最新的在前");
77
+ assert.equal(rows[1].judged_at, "2026-10-30T00:00:00.000Z");
78
+ store.close();
79
+ });
@@ -7,6 +7,7 @@ import { createStore } from "../src/store.js";
7
7
  import { createService } from "../src/service.js";
8
8
  import { createTools } from "../src/tools.js";
9
9
  import { Config, applyLightModePreset, injectChildEnabled } from "../src/config.js";
10
+ import { createSessionGate } from "../src/session-gate.js";
10
11
  import {
11
12
  CONTINUITY_KIND,
12
13
  CONTINUITY_NOTICE_PREFIX,
@@ -272,7 +273,7 @@ test("#249 N3:门控不调模型(静态锁)与挂载点的父开关在前"
272
273
  // 只剩 Config 里的默认值撑着,接线漏一次没人发现。
273
274
  const index = readFileSync(new URL("../src/index.js", import.meta.url), "utf8");
274
275
  assert.match(index, /if \(cfg\.autoInject && injectChildEnabled\(cfg, "continuityRescueEnabled"\)\) \{/);
275
- assert.match(index, /disposers\.push\(createContinuityRescue\(ctx, store\)\)/);
276
+ assert.match(index, /disposers\.push\(createContinuityRescue\(ctx, store, sessionGate\)\)/);
276
277
  });
277
278
 
278
279
  test("#249 N3:reject 放行且不消费边缘;缺 session 不抛", async () => {
@@ -295,6 +296,33 @@ test("#249 N3:reject 放行且不消费边缘;缺 session 不抛", async ()
295
296
  rescue.dispose();
296
297
  });
297
298
 
299
+ test("#539:等待 pre-step 期间切到无痕会清除先前边缘,不落提案也不注入", async () => {
300
+ const store = createStore(":memory:");
301
+ const ctx = mockCtx();
302
+ const config = { sessionGateEnabled: false, sessionGateSessions: ["s1"] };
303
+ const gate = createSessionGate(config);
304
+ const rescue = createContinuityRescue(ctx, store, gate);
305
+ const session = fakeSession("s1", [userEvent(1, "私有指令")]);
306
+
307
+ ctx.fire("session/event", session, { type: "compaction/end", seq: 7 });
308
+ const decision = await ctx.fire(
309
+ "agent/pre-step",
310
+ { agent: { session } },
311
+ async () => {
312
+ config.sessionGateEnabled = true;
313
+ await Promise.resolve();
314
+ return { kind: "enter", messages: [] };
315
+ }
316
+ );
317
+ assert.deepEqual(decision.messages, []);
318
+ assert.equal(store.getContinuityProposal("s1", KIND), null);
319
+
320
+ config.sessionGateEnabled = false;
321
+ const next = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] }));
322
+ assert.deepEqual(next.messages, [], "a stale edge from before the toggle is not consumed later");
323
+ rescue.dispose();
324
+ });
325
+
298
326
  test("#249 N3:契约——默认关、父关不生效、轻量档压掉、降级规则只在开启时进描述", () => {
299
327
  assert.equal(Config({}).continuityRescueEnabled, false, "新增注入表面 → opt-in 默认关");
300
328
  assert.equal(applyLightModePreset({ lightMode: true }).continuityRescueEnabled, false, "轻量档压掉(同 injectGuidanceEnabled)");
@@ -0,0 +1,180 @@
1
+ // Issue #505:saveWithDedupe 的去重候选窗口固定 100 条,库一变大就静默不再合并。
2
+ //
3
+ // 根因:候选集是 store.list({ type, limit: 100 }),order 默认 importance
4
+ // ⇒「该类型最重要的 100 行」,与「找出同标题的那一行」无关。某类型里 importance
5
+ // 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在窗口外,find 恒不命中,
6
+ // 于是走创建分支:不报错、不告警,合并语义随库增长而失效。
7
+ //
8
+ // 这组用例刻意复刻报告里的实测形状(类型 1047 行、其中 importance=5 有 180 行),
9
+ // 但用更小的数字达成同样的窗口溢出,避免测试本身变慢。
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { DatabaseSync } from "node:sqlite";
16
+ import { createStore } from "../src/store.js";
17
+ import { createService } from "../src/service.js";
18
+
19
+ function freshService() {
20
+ const store = createStore(":memory:");
21
+ const service = createService({ store, mirror: null, config: {} });
22
+ return { store, service };
23
+ }
24
+
25
+ // 往某个类型里灌 n 条 importance=5 的行,把窗口挤满。
26
+ function crowdType(store, type, n) {
27
+ for (let i = 0; i < n; i++) {
28
+ store.save({
29
+ type,
30
+ title: `crowd-${type}-${i}`,
31
+ content: `padding row ${i}`,
32
+ importance: 5
33
+ });
34
+ }
35
+ }
36
+
37
+ test("#505: 同类型 importance 更高的行超过窗口后,同标题第二次写入仍然合并", () => {
38
+ const { store, service } = freshService();
39
+ // 窗口是 100。灌 120 条 importance=5,保证待写行(importance=3)与它的同标题
40
+ // 行都排不进前 100。
41
+ crowdType(store, "project", 120);
42
+
43
+ const payload = {
44
+ type: "project",
45
+ title: "dedupe-target",
46
+ content: "first content",
47
+ importance: 3
48
+ };
49
+ const first = service.saveWithDedupe(payload);
50
+ assert.equal(first.action, "created", "第一次写入应当是新建");
51
+
52
+ const second = service.saveWithDedupe({ ...payload, content: "second content" });
53
+ assert.equal(
54
+ second.action,
55
+ "merged",
56
+ "第二次同标题写入必须并入已有行;返回 created 说明候选窗口把同标题行挡在窗口外(#505 静默降级)"
57
+ );
58
+ assert.equal(second.memory.id, first.memory.id, "合并必须落在同一行");
59
+
60
+ const rows = store.list({ type: "project", limit: null })
61
+ .filter((m) => m.title.trim() === "dedupe-target");
62
+ assert.equal(rows.length, 1, "同标题只应存在一行");
63
+ });
64
+
65
+ test("#505: 定向候选不受 importance 分布影响(低 importance 的同标题行也能命中)", () => {
66
+ const { store, service } = freshService();
67
+ // 先写一条 importance=1 的目标行,再用大量 importance=5 的行把它挤出窗口。
68
+ const first = service.saveWithDedupe({
69
+ type: "pattern",
70
+ title: "low-importance-target",
71
+ content: "seed",
72
+ importance: 1
73
+ });
74
+ assert.equal(first.action, "created");
75
+
76
+ crowdType(store, "pattern", 120);
77
+
78
+ const second = service.saveWithDedupe({
79
+ type: "pattern",
80
+ title: "low-importance-target",
81
+ content: "merged later",
82
+ importance: 1
83
+ });
84
+ assert.equal(second.action, "merged", "低 importance 的同标题行同样必须能被找到");
85
+ assert.equal(second.memory.id, first.memory.id);
86
+ });
87
+
88
+ test("#505: 标题首尾空白不影响去重命中(入库不 trim,比较 trim)", () => {
89
+ const { store, service } = freshService();
90
+ crowdType(store, "constraint", 120);
91
+
92
+ const first = service.saveWithDedupe({
93
+ type: "constraint",
94
+ title: " spaced title ",
95
+ content: "seed",
96
+ importance: 3
97
+ });
98
+ assert.equal(first.action, "created");
99
+
100
+ // 同一个标题、首尾空白写法不同——按 trim 口径应当命中同一行。
101
+ const second = service.saveWithDedupe({
102
+ type: "constraint",
103
+ title: "spaced title",
104
+ content: "merged",
105
+ importance: 3
106
+ });
107
+ assert.equal(second.action, "merged", "trim 后同标题必须合并,否则存量带空白的标题会退回静默降级");
108
+ assert.equal(second.memory.id, first.memory.id);
109
+ });
110
+
111
+ test("#505: 定向候选不跨类型命中", () => {
112
+ const { store, service } = freshService();
113
+ crowdType(store, "project", 120);
114
+
115
+ const a = service.saveWithDedupe({
116
+ type: "project",
117
+ title: "same-title-different-type",
118
+ content: "in project",
119
+ importance: 3
120
+ });
121
+ const b = service.saveWithDedupe({
122
+ type: "preference",
123
+ title: "same-title-different-type",
124
+ content: "in preference",
125
+ importance: 3
126
+ });
127
+ assert.equal(a.action, "created");
128
+ assert.equal(b.action, "created", "不同 type 的同标题行不得合并");
129
+ assert.notEqual(a.memory.id, b.memory.id);
130
+ });
131
+
132
+ test("#505: 归档行不作为合并目标(口径与 list 默认一致)", () => {
133
+ const { store, service } = freshService();
134
+ crowdType(store, "decision", 120);
135
+
136
+ const first = service.saveWithDedupe({
137
+ type: "decision",
138
+ title: "archived-target",
139
+ content: "seed",
140
+ importance: 3
141
+ });
142
+ // 归档必须走 store.setArchived——store.update 的 SET 子句里没有 archived 列,
143
+ // 用 update({archived:true}) 根本不会归档,测试会假绿。
144
+ store.setArchived(first.memory.id, true);
145
+
146
+ const second = service.saveWithDedupe({
147
+ type: "decision",
148
+ title: "archived-target",
149
+ content: "after archive",
150
+ importance: 3
151
+ });
152
+ assert.equal(second.action, "created", "已归档行不该被当作活跃合并目标");
153
+ assert.notEqual(second.memory.id, first.memory.id);
154
+ });
155
+
156
+
157
+ // #505 把「取该类型前 100 条」换成「按 type + TRIM(title) 精确取」:窗口没了、静默不
158
+ // 合并也没了,但 SQL 形状变了——代价随最大类型的行数线性长(20000 行时 4.6ms 一次,
159
+ // 每存一条付一次),所以 store.js 里配了 (type, TRIM(title)) 表达式索引。这条用例不测
160
+ // 快慢(机器有关节,数字不可复现),测的是**那条索引真的被这条查询选中**:表达式写歪
161
+ // 一点(漏 TRIM、列序不对)优化器就静默退回扫整个类型,而本文件其余用例全绿。
162
+ test("#505: 定向候选那条 SQL 命中表达式索引,而不是扫完整个类型", () => {
163
+ const dir = mkdtempSync(join(tmpdir(), "mneme-dedupe-idx-"));
164
+ const file = join(dir, "m.db");
165
+ const store = createStore(file);
166
+ store.save({ type: "project", title: " spaced ", content: "x", importance: 3 });
167
+ store.close();
168
+ const db = new DatabaseSync(file);
169
+ try {
170
+ // WHERE 与 store.js 的 activeByTypeAndTitle 逐字同形,只把列表换成单列。
171
+ const plan = db.prepare(
172
+ "EXPLAIN QUERY PLAN SELECT id FROM memories WHERE type = ? AND TRIM(title) = ? AND forgotten = 0 AND archived = 0"
173
+ ).all("project", "spaced").map((r) => r.detail).join(" ");
174
+ assert.match(plan, /idx_memories_type_trimmed_title/,
175
+ `定向候选必须走 (type, TRIM(title)) 表达式索引,实得:${plan}`);
176
+ } finally {
177
+ db.close();
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
@@ -0,0 +1,78 @@
1
+ // 两个 overrides 存在的唯一理由是 osv-scanner 的红灯,而红灯的判据是**已提交的
2
+ // lockfile**——所以这里锁的是 lockfile 的解析结果,不是 node_modules 里装了什么。
3
+ // 2026-10-31 的现场:同一条 lockfile 在 08:36 是绿的,04:42 那次 push 却是红的,
4
+ // 中间没有任何依赖变更——上游在 23:09 发了两条新公告(glob 超时后仍继续遍历、
5
+ // minimatch globparts 放大 DoS)。换句话说,这个文件里的断言会**因为上游多一条公告
6
+ // 而变红**,那是它应该做的事:红在本地,比红在 main 上便宜。
7
+ //
8
+ // 依赖链只有一条,且全部是 devDependency:
9
+ // c8@12 → test-exclude@8 → glob + minimatch
10
+ // 两个上游包都已经是 npm 上的最新版、其 deps 仍写旧范围,所以只能在本仓库抬 pin。
11
+ // 一旦上游跟上、这条链不再解析到旧版本,下面的 overrides 断言就会失败——那时应当
12
+ // 删掉这两条 overrides,而不是把断言删掉。
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
20
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
21
+ const lock = JSON.parse(readFileSync(join(PKG_ROOT, "package-lock.json"), "utf8"));
22
+
23
+ // 公告给出的修复下限。低于它 = osv-scanner 会在 main 上红,这里先红。
24
+ const FLOORS = {
25
+ glob: { fixed: [14, 0, 2], cve: "CVE-2026-118773 / GHSA-3g3m-2fjr-6pw4" },
26
+ minimatch: { fixed: [11, 0, 1], cve: "CVE-2026-118764 / GHSA-g3hv-75wg-m249" },
27
+ };
28
+
29
+ function parseVersion(v) {
30
+ return String(v).split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
31
+ }
32
+
33
+ function gte(a, b) {
34
+ for (let i = 0; i < 3; i++) {
35
+ if (a[i] !== b[i]) return a[i] > b[i];
36
+ }
37
+ return true;
38
+ }
39
+
40
+ test("glob 与 minimatch 在 lockfile 里都解析到公告的修复下限之上", () => {
41
+ for (const [name, { fixed, cve }] of Object.entries(FLOORS)) {
42
+ const entry = lock.packages[`node_modules/${name}`];
43
+ assert.ok(entry, `lockfile 里应当有 node_modules/${name}`);
44
+ const actual = parseVersion(entry.version);
45
+ assert.ok(
46
+ gte(actual, fixed),
47
+ `${name} 解析成 ${entry.version},低于 ${cve} 的修复下限 ${fixed.join(".")}——osv-scanner 会在 main 上红`,
48
+ );
49
+ }
50
+ });
51
+
52
+ // overrides 必须是**精确版本**,不能是 ^ / ~ 范围。
53
+ // 范围等于把选择权交回解析器:下一次上游发版,lockfile 会在某个「只更新 lockfile」
54
+ // 的 PR 里悄悄漂走,而红绿灯本该由人决定。brace-expansion 那次(PR #345)就是精确号。
55
+ test("overrides 是精确版本号,不是范围", () => {
56
+ for (const name of Object.keys(FLOORS)) {
57
+ const pin = pkg.overrides?.[name];
58
+ assert.equal(typeof pin, "string", `package.json overrides 里应当钉住 ${name}`);
59
+ assert.ok(
60
+ /^\d+\.\d+\.\d+$/.test(pin),
61
+ `${name} 的 override 应写精确版本(如 14.0.3),实际是 ${JSON.stringify(pin)}`,
62
+ );
63
+ }
64
+ });
65
+
66
+ // 钉版本是权宜之计,不是永久配置:这条链还在(c8 → test-exclude → glob)就说明
67
+ // 上游仍会解析到旧范围,overrides 必须留着。上游哪天收了,这条会红,提示来删 pin。
68
+ test("devDependency 链仍在:c8 → test-exclude → glob", () => {
69
+ const c8 = lock.packages["node_modules/c8"];
70
+ assert.ok(c8, "lockfile 里应当有 c8");
71
+ assert.ok(c8.dependencies?.["test-exclude"], "c8 仍应依赖 test-exclude");
72
+ const testExclude = lock.packages["node_modules/test-exclude"];
73
+ assert.ok(testExclude, "lockfile 里应当有 test-exclude");
74
+ assert.ok(
75
+ testExclude.dependencies?.glob,
76
+ "test-exclude 仍应依赖 glob——它一旦不再依赖,glob/minimatch 两条 overrides 就可以删了",
77
+ );
78
+ });
@@ -0,0 +1,248 @@
1
+ // Issue #533:differentiate 的正反馈死循环回归护栏。
2
+ //
3
+ // 现象(issue #533 实测):sleepActionSet="full" 下,一对高相似记忆被判成
4
+ // differentiate,旧实现把**同一段**差异注记同时追加到两侧正文,指望"注记进
5
+ // embedding 后下一轮不再判重复"。离线复现证明恰好相反——两侧共享同一段后缀会让
6
+ // 向量同向靠拢(0.876 → 0.960 只用 5 轮,把注记截到 0 字照样往上爬,因为推高相似
7
+ // 度的是共享前缀本身而不是注记长短),于是下一轮又被判重复、又追加一段,实测单条
8
+ // 堆到 19 段、正文 150 字膨胀到 2200+ 字。
9
+ //
10
+ // 现在的契约:differentiate **不改正文**,裁决落 differentiated_pairs 账本;候选
11
+ // 构造阶段读账本,已裁决且两侧正文未变就不送模型。本文件锁的就是这两件事——
12
+ // 「正文不变」和「第二轮不再花钱」。
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { applyDecisions, validateDecisions } from "../src/dream/decisions.js";
16
+ import { createStore } from "../src/store.js";
17
+ import { createService } from "../src/service.js";
18
+ import { createVectorIndex } from "../src/vector-index.js";
19
+ import { runSleep } from "../src/dream/sleep.js";
20
+
21
+ const embedder = {
22
+ // 批量接口要按输入条数返回(向量回填走这一条),返回单个向量会让 usable 判空。
23
+ embedSingle: async () => [1, 0, 0],
24
+ embed: async (texts) => (Array.isArray(texts) ? texts.map(() => [1, 0, 0]) : [1, 0, 0]),
25
+ schedule: () => {},
26
+ modelHash: "mock#1",
27
+ dimension: 3
28
+ };
29
+
30
+ function setup() {
31
+ const store = createStore(":memory:");
32
+ const service = createService({ store, mirror: null, config: {} });
33
+ const vectorIndex = createVectorIndex({ store });
34
+ service.setEmbedder(embedder);
35
+ service.setVectorIndex(vectorIndex);
36
+ return { store, service, vectorIndex };
37
+ }
38
+
39
+ function makeMemory(service, title, content, type = "project") {
40
+ return service.saveWithDedupe({ type, title, content, importance: 3 }).memory;
41
+ }
42
+
43
+ function snapshotOf(store, ...mems) {
44
+ return new Map(mems.map((m) => [m.id, store.getById(m.id)]));
45
+ }
46
+
47
+ function captureCtx(reply) {
48
+ const seen = [];
49
+ return {
50
+ seen,
51
+ logger: { warn: () => {}, info: () => {} },
52
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
53
+ llm: {
54
+ async *stream(options) {
55
+ seen.push(options.messages.find((m) => m.system ?? m.role === "system")?.content?.[0]?.text ?? "");
56
+ yield { type: "text-delta", index: 0, text: typeof reply === "function" ? reply() : reply };
57
+ yield { type: "finish", reason: { kind: "stop" } };
58
+ }
59
+ }
60
+ };
61
+ }
62
+
63
+ function baseConfig(overrides = {}) {
64
+ return {
65
+ sleepModeEnabled: true,
66
+ sleepIdleMinutes: 5,
67
+ sleepMinIntervalHours: 8,
68
+ sleepConflictStrictness: "normal",
69
+ sleepArchiveDays: 30,
70
+ sleepCompressDays: 90,
71
+ sleepPatternMinMemories: 100,
72
+ sleepMaxPatternPerRun: 3,
73
+ ...overrides
74
+ };
75
+ }
76
+
77
+ function seedPair(service, vectorIndex, t1, t2, sim = 1.0) {
78
+ const a = makeMemory(service, t1, `${t1} 的内容`);
79
+ const b = makeMemory(service, t2, `${t2} 的内容`);
80
+ const sin = Math.sqrt(Math.max(0, 1 - sim * sim));
81
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
82
+ vectorIndex.saveEmbedding(b.id, [sim, sin, 0]);
83
+ return { a, b };
84
+ }
85
+
86
+ // ---------------------------------------------------------------- 正文不变
87
+
88
+ test("issue#533: repeated differentiate with drifting wording never grows the bodies", () => {
89
+ const { service, store } = setup();
90
+ const a = makeMemory(service, "内网 SSH 端口", "内网走 22");
91
+ const b = makeMemory(service, "外网 SSH 端口", "外网映射 2222");
92
+ const before = { [a.id]: service.getById(a.id).content, [b.id]: service.getById(b.id).content };
93
+ // 模型每轮换一种说法——这正是字面 includes 幂等失效的场景,也是 issue 里单条堆到
94
+ // 19 段的真实成因。账本口径下措辞漂移不再产生任何正文副作用。
95
+ const wordings = [
96
+ ["内网直连", "外网映射"],
97
+ ["走内网", "走外网映射"],
98
+ ["内网那台直连", "外网那台做映射"],
99
+ ["内网的机器直连", "外网的机器走映射"],
100
+ ["内网直连(22)", "外网映射(2222)"]
101
+ ];
102
+ let appliedRounds = 0;
103
+ for (const distinctions of wordings) {
104
+ const snapshot = snapshotOf(store, service.getById(a.id), service.getById(b.id));
105
+ const { applied } = applyDecisions(
106
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions }],
107
+ service, null, snapshot, {}
108
+ );
109
+ appliedRounds += applied;
110
+ }
111
+ assert.equal(appliedRounds, 1, "only the first round is a real verdict; the rest are idempotent");
112
+ // 这一条是 #533 的核心断言:无论判多少轮,正文长度一个字节都不许变。
113
+ assert.equal(service.getById(a.id).content, before[a.id], "side A body is byte-identical");
114
+ assert.equal(service.getById(b.id).content, before[b.id], "side B body is byte-identical");
115
+ assert.equal(service.getById(a.id).content.length, "内网走 22".length, "no note accumulates");
116
+ store.close();
117
+ });
118
+
119
+ test("issue#533: the ledger keeps one row per pair and refreshes the distinctions", () => {
120
+ const { service, store } = setup();
121
+ const a = makeMemory(service, "甲", "内容甲");
122
+ const b = makeMemory(service, "乙", "内容乙");
123
+ for (const distinctions of [["第一版"], ["第二版", "补充版"]]) {
124
+ applyDecisions(
125
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions }],
126
+ service, null, snapshotOf(store, service.getById(a.id), service.getById(b.id)), {}
127
+ );
128
+ }
129
+ // 顺序归一:不管送审时谁在前,账本只有一行。
130
+ assert.equal(store.listDifferentiatedPairs().length, 1, "one pair, one row");
131
+ const record = service.findDifferentiatedPair(b.id, a.id);
132
+ assert.deepEqual(record.distinctions, ["第一版"], "an unchanged body keeps the original verdict");
133
+ store.close();
134
+ });
135
+
136
+ // -------------------------------------------------------------- 三层幂等
137
+
138
+ test("issue#533: a body edited after the verdict re-opens the pair", () => {
139
+ const { service, store } = setup();
140
+ const a = makeMemory(service, "甲", "内容甲");
141
+ const b = makeMemory(service, "乙", "内容乙");
142
+ applyDecisions(
143
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }],
144
+ service, null, snapshotOf(store, service.getById(a.id), service.getById(b.id)), {}
145
+ );
146
+ // 原样重放 → 幂等。
147
+ const replay = applyDecisions(
148
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }],
149
+ service, null, snapshotOf(store, service.getById(a.id), service.getById(b.id)), {}
150
+ );
151
+ assert.equal(replay.applied, 0, "an unchanged pair is idempotent");
152
+ // 一侧被改写 → 结论可能不再成立,必须能重新裁决(宁可再判一次,不静音真的演化)。
153
+ service.update(a.id, { content: "内容甲被改写" });
154
+ const reopened = applyDecisions(
155
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面", "改写后"] }],
156
+ service, null, snapshotOf(store, service.getById(a.id), service.getById(b.id)), {}
157
+ );
158
+ assert.equal(reopened.applied, 1, "an edited side re-opens the pair");
159
+ assert.deepEqual(
160
+ service.findDifferentiatedPair(a.id, b.id).distinctions, ["甲面", "乙面", "改写后"],
161
+ "the refreshed verdict replaces the stale one"
162
+ );
163
+ store.close();
164
+ });
165
+
166
+ test("issue#533: legacy rows that still carry the old note are treated as adjudicated", () => {
167
+ const { service, store } = setup();
168
+ // 旧版本写进正文的注记块原样造出来(升级不清账:这些行没有账本行)。
169
+ const a = makeMemory(service, "甲", "内容甲\n\n(差异注记:甲面;乙面)");
170
+ const b = makeMemory(service, "乙", "内容乙\n\n(差异注记:甲面;乙面)");
171
+ const { applied } = applyDecisions(
172
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }],
173
+ service, null, snapshotOf(store, service.getById(a.id), service.getById(b.id)), {}
174
+ );
175
+ assert.equal(applied, 0, "no ledger row is needed once both sides carry the note");
176
+ assert.equal(service.getById(a.id).content, "内容甲\n\n(差异注记:甲面;乙面)", "the legacy body is untouched");
177
+ store.close();
178
+ });
179
+
180
+ test("issue#533: a bare service without the ledger degrades to skipped, never throws", () => {
181
+ // 人工确认路径(service.js 的裸 applyService)没有账本透传——此时「正文不变」本身
182
+ // 就是全部副作用,没有账本就没有可写之处,只能是 skipped,不能算 applied。
183
+ const mems = new Map();
184
+ const bare = {
185
+ getById: (id) => mems.get(id) ?? null,
186
+ update: (id, patch) => { mems.set(id, { ...mems.get(id), ...patch }); },
187
+ setArchived: () => {},
188
+ transaction: (fn) => fn()
189
+ };
190
+ mems.set("a", { id: "a", title: "甲", content: "内容甲", archived: false });
191
+ mems.set("b", { id: "b", title: "乙", content: "内容乙", archived: false });
192
+ const { applied, failures } = applyDecisions(
193
+ [{ action: "differentiate", ids: ["a", "b"], distinctions: ["甲面", "乙面"] }],
194
+ bare, null, null, {}
195
+ );
196
+ assert.equal(applied, 0, "nothing to record, nothing applied");
197
+ assert.equal(failures.length, 0, "degrades cleanly");
198
+ assert.equal(mems.get("a").content, "内容甲", "still never touches the body");
199
+ });
200
+
201
+ // ------------------------------------------------------- 候选构造阶段抑制
202
+
203
+ test("issue#533: a second sleep cycle does not pay for an already-adjudicated pair", async () => {
204
+ const { service, store, vectorIndex } = setup();
205
+ const { a, b } = seedPair(service, vectorIndex, "甲端口", "乙端口");
206
+ const reply = () => JSON.stringify([
207
+ { action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }
208
+ ]);
209
+ const ctx1 = captureCtx(reply);
210
+ const first = await runSleep(ctx1, service, baseConfig({ sleepActionSet: "full" }), ctx1.logger, { embedder, vectorIndex }, null);
211
+ assert.equal(first.phases.conflicts.applied, 1, "the first cycle adjudicates the pair");
212
+ const callsAfterFirst = ctx1.seen.length;
213
+ assert.ok(service.findDifferentiatedPair(a.id, b.id), "the verdict landed in the ledger");
214
+
215
+ // 第二轮:同一对、同一相似度,账本命中且两侧正文未变 → 候选在送 LLM 之前就被压掉。
216
+ const ctx2 = captureCtx(reply);
217
+ const second = await runSleep(ctx2, service, baseConfig({ sleepActionSet: "full" }), ctx2.logger, { embedder, vectorIndex }, null);
218
+ assert.equal(second.phases.conflicts.status, "noop", "the phase has nothing left to adjudicate");
219
+ assert.equal(second.phases.conflicts.suppressed, 1, "the pair is accounted for, not silently dropped");
220
+ // 只数冲突阶段那次调用:patterns 阶段与本题无关,别把它的调用算进来。
221
+ assert.equal(ctx2.seen.filter((p) => p.includes("冲突仲裁")).length, 0,
222
+ "no LLM call is made for a settled pair");
223
+
224
+ // 但这不等于「永远不再判」:一侧被改写后必须重新回到候选里。
225
+ service.update(a.id, { content: "甲端口 的内容被改写" });
226
+ const ctx3 = captureCtx(reply);
227
+ const third = await runSleep(ctx3, service, baseConfig({ sleepActionSet: "full" }), ctx3.logger, { embedder, vectorIndex }, null);
228
+ assert.ok(ctx3.seen.some((p) => p.includes("冲突仲裁")), "an edited pair is re-sent to the model");
229
+ assert.ok(third.phases.conflicts, "the phase still runs");
230
+ store.close();
231
+ });
232
+
233
+ test("issue#533: the audit row reports suppressed separately from 'no conflicts found'", async () => {
234
+ const { service, store, vectorIndex } = setup();
235
+ const { a, b } = seedPair(service, vectorIndex, "甲端口", "乙端口");
236
+ // 先手工记一笔账(正文与相似度都没变),第二轮应报 noop + suppressed。
237
+ applyDecisions(
238
+ [{ action: "differentiate", ids: [a.id, b.id], distinctions: ["甲面", "乙面"] }],
239
+ service, null, snapshotOf(store, service.getById(a.id), service.getById(b.id)), {}
240
+ );
241
+ const ctx = captureCtx(JSON.stringify([]));
242
+ const result = await runSleep(ctx, service, baseConfig({ sleepActionSet: "full" }), ctx.logger, { embedder, vectorIndex }, null);
243
+ assert.equal(result.phases.conflicts.status, "noop");
244
+ assert.equal(result.phases.conflicts.reason, "all candidate pairs already differentiated");
245
+ assert.equal(result.phases.conflicts.suppressed, 1);
246
+ assert.equal(result.phases.conflicts.pairs, undefined, "no pair is reported as pending work");
247
+ store.close();
248
+ });
@@ -39,8 +39,8 @@ function seed(service, vectorIndex, title, vec) {
39
39
  }
40
40
 
41
41
  /** Capture the candidate titles the LLM was handed. */
42
- // 注意:不能用 helpers 的 mockCtx —— 它靠 userText.startsWith("id=") 区分阶段,而
43
- // semantic 可用时候选列表以「# 聚类 N」开头(语义预分组),会被误判成 summary。
42
+ // 注:helpers 的 mockCtx 过去靠 userText.startsWith("id=") 区分阶段,semantic 预分组把清单
43
+ // 头换成「# 聚类 N」就误判成 summary;#501/#493 把它的判据换成了清单形状(includes("| type=")),
44
44
  // 这里改用 system prompt 判定,与 listText 的形态解耦。
45
45
  function stubCtx(consolidationReply) {
46
46
  const seen = { titles: [] };