dsh-memu-memory 1.1.3 → 1.2.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 (184) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +39 -6
  3. package/README.zh.md +39 -7
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +3 -3
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +8 -0
  9. package/docs/host-api.md +2 -2
  10. package/docs/memory-semantics.md +12 -10
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +396 -395
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +23 -6
  15. package/lib/binding-diagnostics.js +8 -3
  16. package/lib/client.js +335 -35
  17. package/lib/consolidation-ledger.js +347 -0
  18. package/lib/consolidation-receipt.js +284 -0
  19. package/lib/consolidation-snapshot.js +274 -0
  20. package/lib/consolidation-worker.js +144 -0
  21. package/lib/curator.js +86 -31
  22. package/lib/cycle.js +40 -6
  23. package/lib/graph.js +139 -5
  24. package/lib/index.js +33 -1
  25. package/lib/inject.js +104 -76
  26. package/lib/local-embedding-manager.js +24 -6
  27. package/lib/memory.js +534 -88
  28. package/lib/mood.js +268 -39
  29. package/lib/preset-scope.js +1 -1
  30. package/lib/private.js +430 -43
  31. package/lib/prompts/private-consolidation.coding.md +1 -0
  32. package/lib/prompts/private-consolidation.md +1 -0
  33. package/lib/prompts/private-consolidation.mixed.md +1 -0
  34. package/lib/prompts/private-consolidation.rp.md +1 -0
  35. package/lib/prompts.js +4 -4
  36. package/lib/remote-summary.js +51 -25
  37. package/lib/rpc.js +54 -5
  38. package/lib/state.js +29 -12
  39. package/lib/text-similarity.js +106 -0
  40. package/lib/tools.js +113 -10
  41. package/lib/vector-migration.js +28 -3
  42. package/lib/vector-profile.js +51 -0
  43. package/package.json +4 -2
  44. package/scripts/acceptance-local.mjs +2 -1
  45. package/scripts/active-memory-ab-live.mjs +655 -0
  46. package/scripts/active-memory-ab.mjs +1302 -0
  47. package/scripts/chain-matrix.mjs +11 -1
  48. package/scripts/check-all.mjs +10 -1
  49. package/scripts/replay.mjs +573 -0
  50. package/tests/activation.test.mjs +89 -0
  51. package/tests/active-memory-ab.test.mjs +745 -0
  52. package/tests/active-memory.test.mjs +129 -0
  53. package/tests/activity-log.test.mjs +126 -0
  54. package/tests/activity-wiring.test.mjs +103 -0
  55. package/tests/appendix-b.test.mjs +216 -0
  56. package/tests/appendix-b2.test.mjs +166 -0
  57. package/tests/audit-regression.test.mjs +272 -0
  58. package/tests/audit-safety.test.mjs +227 -0
  59. package/tests/backend-compensation.test.mjs +272 -0
  60. package/tests/backup.test.mjs +793 -0
  61. package/tests/binding-diagnostics.test.mjs +720 -0
  62. package/tests/browser.test.mjs +366 -0
  63. package/tests/client-render.test.mjs +892 -0
  64. package/tests/coalesce.test.mjs +113 -0
  65. package/tests/config.test.mjs +159 -0
  66. package/tests/consolidation-credential.test.mjs +331 -0
  67. package/tests/consolidation-feed.test.mjs +96 -0
  68. package/tests/consolidation-fork.test.mjs +489 -0
  69. package/tests/consolidation-ledger.test.mjs +298 -0
  70. package/tests/consolidation-snapshot.test.mjs +360 -0
  71. package/tests/consolidation-value.test.mjs +89 -0
  72. package/tests/context-scope.test.mjs +40 -0
  73. package/tests/cue.test.mjs +44 -0
  74. package/tests/curator-commit.test.mjs +277 -0
  75. package/tests/curator-probe.test.mjs +433 -0
  76. package/tests/curator.test.mjs +150 -0
  77. package/tests/cycle.test.mjs +411 -0
  78. package/tests/disclosure.test.mjs +231 -0
  79. package/tests/doc-drift.test.mjs +222 -0
  80. package/tests/embedding-catalog.test.mjs +266 -0
  81. package/tests/embedding-download.test.mjs +391 -0
  82. package/tests/embedding-runtime.test.mjs +389 -0
  83. package/tests/emotion-chain.test.mjs +84 -0
  84. package/tests/emotion-contract.test.mjs +445 -0
  85. package/tests/engine.test.mjs +35 -0
  86. package/tests/fixtures/active-memory-ab/README.md +56 -0
  87. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  88. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  89. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  90. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  92. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  93. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  94. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  95. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  96. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  97. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  98. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  99. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  100. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  101. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  102. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  103. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  104. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  105. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  106. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  107. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  108. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  109. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  110. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  111. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  112. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  113. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  114. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  115. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  116. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  117. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  118. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  119. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  120. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  121. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  122. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  123. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  124. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  125. package/tests/graph-cascade.test.mjs +190 -0
  126. package/tests/graph-view.test.mjs +187 -0
  127. package/tests/graph.test.mjs +94 -0
  128. package/tests/hook-order.test.mjs +72 -0
  129. package/tests/humanlike.test.mjs +232 -0
  130. package/tests/hybrid-recall.test.mjs +122 -0
  131. package/tests/inbound.test.mjs +393 -0
  132. package/tests/inject-guide.test.mjs +364 -0
  133. package/tests/inject.test.mjs +222 -0
  134. package/tests/injection-budget-settings.test.mjs +35 -0
  135. package/tests/injection-budget.test.mjs +64 -0
  136. package/tests/injection-chain.test.mjs +191 -0
  137. package/tests/loader-register.mjs +3 -0
  138. package/tests/loader.mjs +12 -0
  139. package/tests/local-embedding-entry.test.mjs +95 -0
  140. package/tests/local-embedding-manager.test.mjs +476 -0
  141. package/tests/local-embedding-protocol.test.mjs +95 -0
  142. package/tests/local-embedding-worker.test.mjs +346 -0
  143. package/tests/managed-memory.test.mjs +591 -0
  144. package/tests/managed-runtime.test.mjs +40 -0
  145. package/tests/media.test.mjs +132 -0
  146. package/tests/migration-payload.test.mjs +42 -0
  147. package/tests/mood.test.mjs +109 -0
  148. package/tests/multiprocess.test.mjs +29 -0
  149. package/tests/new-session-gate.test.mjs +361 -0
  150. package/tests/package-shipping.test.mjs +84 -0
  151. package/tests/preset-scope.test.mjs +212 -0
  152. package/tests/prompts.test.mjs +250 -0
  153. package/tests/react-snapshot.test.mjs +23 -0
  154. package/tests/recall-bench.test.mjs +306 -0
  155. package/tests/recall-dedup.test.mjs +417 -0
  156. package/tests/remote.test.mjs +140 -0
  157. package/tests/rename.test.mjs +30 -0
  158. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  159. package/tests/repetition-eval-live.test.mjs +177 -0
  160. package/tests/repetition-eval.test.mjs +373 -0
  161. package/tests/salient-date.test.mjs +142 -0
  162. package/tests/settings-beat.test.mjs +221 -0
  163. package/tests/short-term.test.mjs +166 -0
  164. package/tests/sidebar-beat.test.mjs +212 -0
  165. package/tests/sidebar-refresh.test.mjs +123 -0
  166. package/tests/sidebar.test.mjs +70 -0
  167. package/tests/snippet.test.mjs +47 -0
  168. package/tests/source-kind.test.mjs +52 -0
  169. package/tests/state.test.mjs +150 -0
  170. package/tests/stats.test.mjs +112 -0
  171. package/tests/storage.test.mjs +125 -0
  172. package/tests/stubs/dsh-llm.js +5 -0
  173. package/tests/stubs/dsh-session.js +7 -0
  174. package/tests/stubs/dsh-tools.js +101 -0
  175. package/tests/tool-schema.test.mjs +73 -0
  176. package/tests/tools.test.mjs +184 -0
  177. package/tests/ui-defaults.test.mjs +57 -0
  178. package/tests/update-fields.test.mjs +138 -0
  179. package/tests/vector-migration.test.mjs +790 -0
  180. package/tests/vector-profile.test.mjs +425 -0
  181. package/tests/visibility-migration.test.mjs +152 -0
  182. package/tests/webui-auth.test.mjs +454 -0
  183. package/tests/webui.test.mjs +165 -0
  184. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,445 @@
1
+ // 独立验收:按 docs/emotion-model-spec.md v3 §8 写的一组黑盒契约测试。
2
+ //
3
+ // 这些断言只走公开入口(lib/mood.js 的纯函数、lib/state.js 的会话 mood、
4
+ // lib/memory.js 的写入/更新/召回),不读实现私有字段,也不为了迁就现有行为
5
+ // 而放宽期望:实现与 spec 不一致时,这里就应该失败。
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import fs from 'node:fs'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+
12
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-emotion-contract-'))
13
+ process.env.DSH_MEMU_HOME = home
14
+
15
+ const state = await import('../lib/state.js')
16
+ const memory = await import('../lib/memory.js')
17
+ const mood = await import('../lib/mood.js')
18
+
19
+ test.after(() => {
20
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
21
+ })
22
+
23
+ const AXES = ['joy', 'anger', 'sorrow', 'fear']
24
+ const shareSum = (emotion) => AXES.reduce((total, axis) => total + Number(emotion[axis]), 0)
25
+ const close = (a, b, epsilon = 1e-9) => Math.abs(a - b) < epsilon
26
+ const emptyEmotion = () => ({ joy: 0, anger: 0, sorrow: 0, fear: 0 })
27
+
28
+ function execFor(sessionId, cwd) {
29
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
30
+ }
31
+
32
+ function setup(name) {
33
+ const cwd = path.join(home, 'proj-' + name)
34
+ const sessionId = 'emotion-' + name
35
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
36
+ return { cwd, sessionId, binding, exec: execFor(sessionId, cwd) }
37
+ }
38
+
39
+ function entryOf(binding, id) {
40
+ return state.loadPrivateIndex(binding.libraryId).entries[id]
41
+ }
42
+
43
+ /**
44
+ * 反解一个让 fade 正好等于 target 的时刻。
45
+ *
46
+ * fade = 0.5 ** (minutes / 90),所以先求出实数解,再在相邻 double 上搜一个
47
+ * 让 Math.pow 的结果精确落在 target 上的取值——「fade === 0.35 仍保留」这条
48
+ * 边界只有拿到精确相等才验证得了。
49
+ */
50
+ function timeAtFade(target) {
51
+ const base = (Math.log(target) / Math.log(0.5)) * 90 * 60_000
52
+ for (let i = -20_000; i <= 20_000; i += 1) {
53
+ const now = base + i * Number.EPSILON * base
54
+ if (Math.pow(0.5, (now / 60_000) / 90) === target) return now
55
+ }
56
+ return null
57
+ }
58
+
59
+ // ---- §8 第一条:归一化 ----
60
+
61
+ test('normalizeEmotion 归一后和为 1,所有分量非负', () => {
62
+ const samples = [
63
+ { joy: 3, anger: 1, sorrow: 0, fear: 0 },
64
+ { joy: 0.1, anger: 0.2, sorrow: 0.3, fear: 0.4 },
65
+ { joy: 0.0001, anger: 0.0002, sorrow: 0.0003, fear: 0.0004 },
66
+ { joy: 1_000_000, anger: 1, sorrow: 1, fear: 1 },
67
+ ]
68
+ for (const raw of samples) {
69
+ const normalized = mood.normalizeEmotion(raw)
70
+ assert.ok(normalized && typeof normalized === 'object', `合法输入 ${JSON.stringify(raw)} 必须归一化成一个对象`)
71
+ for (const axis of AXES) {
72
+ assert.ok(Number.isFinite(normalized[axis]), `归一化后的 ${axis} 必须是有限数,否则下游 valence/overlap 会传播 NaN`)
73
+ assert.ok(normalized[axis] >= 0, `归一化后的 ${axis} 不得为负:负数只能来自调用方 bug,不能在这里被 clamp 成合法值`)
74
+ }
75
+ assert.ok(
76
+ Math.abs(shareSum(normalized) - 1) < 1e-9,
77
+ `构成必须落在单纯形上(误差 < 1e-9),实际 sum=${shareSum(normalized)};份额不归一,overlap 就不再有 [0,1] 的语义`,
78
+ )
79
+ }
80
+ assert.ok(close(mood.normalizeEmotion({ joy: 3, anger: 1, sorrow: 0, fear: 0 }).joy, 0.75, 1e-12))
81
+ })
82
+
83
+ test('normalizeEmotion 拒绝缺键、负数、非有限数、数组和字符串,错误信息含 emotion', () => {
84
+ const cases = [
85
+ ['缺少 joy 键', { anger: 1, sorrow: 0, fear: 0 }],
86
+ ['joy 为负数', { joy: -1, anger: 2, sorrow: 0, fear: 0 }],
87
+ ['joy 为 NaN', { joy: NaN, anger: 1, sorrow: 0, fear: 0 }],
88
+ ['fear 为 Infinity', { joy: 1, anger: 0, sorrow: 0, fear: Infinity }],
89
+ ['传入数组', [1, 0, 0, 0]],
90
+ ['传入字符串', 'joy:1'],
91
+ ['轴是字符串数字', { joy: '1', anger: 0, sorrow: 0, fear: 0 }],
92
+ ['传入数字', 1],
93
+ ]
94
+ for (const [label, input] of cases) {
95
+ assert.throws(
96
+ () => mood.normalizeEmotion(input),
97
+ /emotion/,
98
+ `${label} 必须被拒绝,且错误里要出现 emotion:静默修复会把调用方 bug 永久藏起来`,
99
+ )
100
+ }
101
+ // null / undefined 是规范的中性表示,不是非法输入。
102
+ assert.strictEqual(mood.normalizeEmotion(null), null)
103
+ assert.strictEqual(mood.normalizeEmotion(undefined), null)
104
+ })
105
+
106
+ test('全零输入规范化为 null,不会被当成均匀分布', async () => {
107
+ const zero = mood.normalizeEmotion(emptyEmotion())
108
+ assert.strictEqual(zero, null, '全零代表「明确中性」;返回 {0.25,0.25,0.25,0.25} 会让召回把中性当成一种情绪')
109
+ assert.notDeepEqual(zero, { joy: 0.25, anger: 0.25, sorrow: 0.25, fear: 0.25 })
110
+
111
+ // 会话读数和条目写入都必须走同一个规范化,而不是各写一份。
112
+ const { sessionId, exec, binding } = setup('zeros')
113
+ state.setSessionMood(sessionId, { emotion: emptyEmotion(), arousal: 0.35, dominance: 0 })
114
+ assert.strictEqual(state.getSessionMood(sessionId).emotion, null, 'setSessionMood 也要把全零折成 null')
115
+
116
+ const { id } = await memory.privateRemember({ exec, content: 'The invoice is due on the fourteenth.', title: 'invoice', emotion: emptyEmotion() })
117
+ assert.strictEqual(entryOf(binding, id).emotion, null, '写入路径同样不得把全零存成四轴均匀分布')
118
+ })
119
+
120
+ // ---- §8 第二条:派生 valence ----
121
+
122
+ test('四个纯轴的派生 valence 方向正确且量级一致(统一系数 0.8)', () => {
123
+ const pureAxis = (axis) => ({ ...emptyEmotion(), [axis]: 1 })
124
+
125
+ const joyValence = mood.derivedValence(pureAxis('joy'))
126
+ assert.ok(joyValence > 0, '纯喜悦必须投影为正,否则旧路径的快乐记忆会被推下去')
127
+ assert.ok(close(joyValence, 0.8, 1e-12), `纯轴的系数是 0.8,实际 ${joyValence}`)
128
+
129
+ for (const axis of ['anger', 'sorrow', 'fear']) {
130
+ const valence = mood.derivedValence(pureAxis(axis))
131
+ assert.ok(valence < 0, `纯 ${axis} 必须投影为负;愤怒不是「轻微正面」,它和悲伤一样往下拉`)
132
+ assert.ok(close(Math.abs(valence), Math.abs(joyValence), 1e-12), `纯 ${axis} 的量级必须与纯 joy 一致`)
133
+ }
134
+
135
+ // 复合结果按 spec 的公式可复算:0.8*(joy - anger - sorrow - fear)
136
+ assert.ok(close(mood.derivedValence({ joy: 0.5, anger: 0, sorrow: 0.5, fear: 0 }), 0, 1e-12), 'joy 0.5 + sorrow 0.5 必须互相抵消为 0')
137
+ assert.ok(close(mood.derivedValence({ joy: 0.75, anger: 0.25, sorrow: 0, fear: 0 }), 0.4, 1e-12), '0.8*(0.75-0.25)=0.4,复合轴要能逐项复算')
138
+ assert.ok(close(mood.derivedValence(mood.normalizeEmotion({ joy: 3, anger: 1, sorrow: 0, fear: 0 })), 0.4, 1e-12), '归一化后的构成同样要能复算')
139
+ })
140
+
141
+ // ---- §8 第三条:overlap ----
142
+
143
+ test('overlap 自反、对称、落在 [0,1],不相交与 null 输入为 0', () => {
144
+ const p = { joy: 0.5, anger: 0.5, sorrow: 0, fear: 0 }
145
+ const q = { joy: 0.2, anger: 0.3, sorrow: 0.5, fear: 0 }
146
+ const disjoint = { joy: 0, anger: 0, sorrow: 0, fear: 1 }
147
+
148
+ assert.ok(close(mood.overlap(p, p), 1, 1e-12), '自反:同一份心情必须 100% 重叠,否则「心情一致」永远拿不到满权重')
149
+ assert.ok(close(mood.overlap(p, q), mood.overlap(q, p), 1e-12), '对称:p 对 q 与 q 对 p 必须相等')
150
+ assert.ok(close(mood.overlap(p, q), 0.5, 1e-12), 'sum(min(p,q)) = min(.5,.2)+min(.5,.3) = 0.5')
151
+ assert.strictEqual(mood.overlap(p, disjoint), 0, '没有任何共同轴时不能凭空产生重叠')
152
+ assert.strictEqual(mood.overlap(null, p), 0, '没有情绪的一侧应被视为「无信号」,而不是 0.5')
153
+ assert.strictEqual(mood.overlap(p, null), 0)
154
+ assert.strictEqual(mood.overlap(null, null), 0)
155
+
156
+ for (const [left, right] of [[p, q], [p, disjoint], [q, disjoint], [p, p]]) {
157
+ const value = mood.overlap(left, right)
158
+ assert.ok(value >= 0 && value <= 1, `overlap 必须落在 [0,1],实际 ${value};它直接乘进 moodBias,越界就会放大成排序事故`)
159
+ }
160
+ })
161
+
162
+ // ---- §8 第四条:bias 界 ----
163
+
164
+ test('dominanceBias 始终落在 [0.9,1.1],缺失或非法值是 1 而不是 NaN', () => {
165
+ for (const entryDominance of [-1, -0.7, -0.2, 0, 0.4, 1]) {
166
+ for (const contextDominance of [-1, -0.3, 0, 0.6, 1]) {
167
+ const bias = mood.dominanceBias(entryDominance, contextDominance)
168
+ assert.ok(Number.isFinite(bias), `dominanceBias(${entryDominance}, ${contextDominance}) 不能是 NaN`)
169
+ assert.ok(bias >= 0.9 && bias <= 1.1, `dominanceBias 必须被限制在 [0.9,1.1],实际 ${bias}`)
170
+ }
171
+ }
172
+ assert.ok(close(mood.dominanceBias(0.3, 0.3), 1.1, 1e-12), '完全同向时取上界 1.1')
173
+ assert.ok(close(mood.dominanceBias(-1, 1), 0.9, 1e-12), '完全反向时取下界 0.9')
174
+
175
+ assert.strictEqual(mood.dominanceBias(undefined, 0.5), 1, '旧条目没有 dominance:这是「无信号」,不能参与打分')
176
+ assert.strictEqual(mood.dominanceBias(null, 0.5), 1, 'spec §5.1:缺失的 dominance 必须是无信号;Number(null) === 0 会把它伪装成一个真实的「零掌控」读数')
177
+ assert.strictEqual(mood.dominanceBias(0.5, NaN), 1, '非法 dominance 必须退回 1,绝不能把 NaN 乘进分数')
178
+ assert.strictEqual(mood.dominanceBias(Infinity, 0), 1)
179
+ })
180
+
181
+ test('召回里的 moodBias 实测被限制在 [0.7,1.3]', async () => {
182
+ const { sessionId, exec } = setup('mood-bias')
183
+ const { id } = await memory.privateRemember({
184
+ exec,
185
+ content: 'The kestrel ledger was found under the ninth stair.',
186
+ title: 'kestrel ledger',
187
+ emotion: { joy: 0, anger: 0, sorrow: 1, fear: 0 },
188
+ arousal: 0.35,
189
+ // dominance 取 1、会话取 0:dominanceBias 恰好是 1(|1-0|/2 = 0.5),
190
+ // 这样三次召回的分差里只剩 moodBias,不会混进 ±10% 的掌控感项。
191
+ dominance: 1,
192
+ })
193
+
194
+ // 三次召回只改会话情绪,条目本身完全不变;因此分差只能来自 moodBias。
195
+ // purpose=automatic 会跳过 touchAccess,accessCount 不会在三次之间变动。
196
+ async function scoreWith(moodShape) {
197
+ state.setSessionMood(sessionId, moodShape)
198
+ const out = await memory.privateRecall({ exec, query: 'kestrel ledger ninth stair', topK: 1, relevanceThreshold: 0, purpose: 'automatic' })
199
+ const hit = (out.files || []).find((file) => file.name === id)
200
+ assert.ok(hit, '同一查询必须能召回这条记忆,否则比值无从谈起')
201
+ return hit.score
202
+ }
203
+
204
+ const neutral = await scoreWith({ emotion: null, arousal: 0.35, dominance: 0 })
205
+ const congruent = await scoreWith({ emotion: { joy: 0, anger: 0, sorrow: 1, fear: 0 }, arousal: 0.35, dominance: 0 })
206
+ const incongruent = await scoreWith({ emotion: { joy: 1, anger: 0, sorrow: 0, fear: 0 }, arousal: 0.35, dominance: 0 })
207
+
208
+ // 条目刚写入,fade 因子 ≈ 1,所以正向重叠应拿到 1+0.3,反向重叠应拿到 1-0.3。
209
+ const up = congruent / neutral
210
+ const down = incongruent / neutral
211
+ assert.ok(up >= 0.7 && up <= 1.3, `moodBias 上界必须是 1.3,实测 ${up}`)
212
+ assert.ok(down >= 0.7 && down <= 1.3, `moodBias 下界必须是 0.7,实测 ${down}`)
213
+ assert.ok(close(up, 1.3, 0.005), `完全重叠时应接近 1.3,实测 ${up};偏差过大说明公式或衰减因子被改动`)
214
+ assert.ok(close(down, 0.7, 0.005), `完全相反时应接近 0.7,实测 ${down}`)
215
+ })
216
+
217
+ // ---- §8 第五条:门控 ----
218
+
219
+ test('emotionActive 真值表:null 只关闭第一腿,高 arousal / 高 dominance 仍开启', () => {
220
+ const baseline = { emotion: null, arousal: 0.35, dominance: 0 }
221
+ assert.strictEqual(mood.emotionActive(baseline), false, '全基线必须是关闭状态,否则每条记忆都带着无意义的情绪乘数')
222
+ assert.strictEqual(mood.emotionActive(), false, '默认参数同样是全基线')
223
+
224
+ assert.strictEqual(mood.emotionActive({ ...baseline, arousal: 0.9 }), true, 'null + 高 arousal 必须开启:紧张但没有明确好坏也是一种心情')
225
+ assert.strictEqual(mood.emotionActive({ ...baseline, arousal: 0.05 }), true, '低于基线的 arousal 同样偏离休息位')
226
+ assert.strictEqual(mood.emotionActive({ ...baseline, dominance: 0.3 }), true, 'null + 高 dominance 必须开启')
227
+ assert.strictEqual(mood.emotionActive({ ...baseline, dominance: -0.4 }), true, '失控方向同样算高掌控感')
228
+
229
+ assert.strictEqual(mood.emotionActive({ ...baseline, arousal: 0.44 }), false, 'arousal 门槛是 0.1:0.44 距基线只有 0.09')
230
+ assert.strictEqual(mood.emotionActive({ ...baseline, dominance: 0.24 }), false, 'dominance 门槛是 0.25')
231
+
232
+ const lowShare = { joy: 0.2, anger: 0.2, sorrow: 0.2, fear: 0.2 }
233
+ assert.strictEqual(mood.emotionActive({ ...baseline, emotion: lowShare }), false, '非空但主导 share 只有 0.2,不能算明确情绪')
234
+ const uniform = { joy: 0.25, anger: 0.25, sorrow: 0.25, fear: 0.25 }
235
+ assert.strictEqual(mood.emotionActive({ ...baseline, emotion: uniform }), false, '归一化后的均匀构成最大 share 是 0.25,同样低于 0.35 门槛')
236
+ const dominant = { joy: 0.5, anger: 0.2, sorrow: 0.2, fear: 0.1 }
237
+ assert.strictEqual(mood.emotionActive({ ...baseline, emotion: dominant }), true, '主导 share 0.5 必须开启')
238
+ })
239
+
240
+ // ---- §8 第六条:decay 边界 ----
241
+
242
+ test('moodDecay 在 fade 0.35 两侧行为稳定,清空后 arousal/dominance 仍继续衰减', () => {
243
+ const boundary = timeAtFade(0.35)
244
+ assert.ok(boundary !== null, '需要先构造出 Math.pow 精确等于 0.35 的时刻,才能验证「严格小于」的边界')
245
+ const reading = { emotion: { joy: 0, anger: 0, sorrow: 1, fear: 0 }, arousal: 0.9, dominance: 0.8, at: 0 }
246
+
247
+ const exact = mood.moodDecay(reading, boundary)
248
+ assert.deepEqual(exact.emotion, reading.emotion, 'spec 写成严格小于:fade === 0.35 仍保留构成,<= 会在这里提前清空')
249
+ assert.ok(close(exact.arousal, 0.35 + 0.55 * 0.35, 1e-12))
250
+ assert.ok(close(exact.dominance, 0.8 * 0.35, 1e-12))
251
+
252
+ const above = mood.moodDecay(reading, boundary - 60_000)
253
+ assert.deepEqual(above.emotion, reading.emotion, 'fade 略大于 0.35 时必须保留构成')
254
+
255
+ const below = mood.moodDecay(reading, boundary + 60_000)
256
+ assert.strictEqual(below.emotion, null, 'fade 略小于 0.35 时必须清空构成')
257
+ assert.ok(below.arousal > 0.35, '构成清空不等于情绪轴清空:arousal 仍要返回并高于基线')
258
+ assert.ok(below.arousal < above.arousal, '清空后 arousal 仍在向基线衰减')
259
+ assert.ok(below.dominance > 0 && below.dominance < above.dominance, '清空后 dominance 仍在向 0 衰减')
260
+
261
+ const later = mood.moodDecay(reading, boundary + 60 * 60_000)
262
+ assert.strictEqual(later.emotion, null)
263
+ assert.ok(later.arousal < below.arousal, '更晚的时刻 arousal 必须更接近 0.35')
264
+ assert.ok(later.dominance < below.dominance, '更晚的时刻 dominance 必须更接近 0')
265
+ })
266
+
267
+ // ---- §8 第七条:兼容 ----
268
+
269
+ test('readMood(...).valence 继续存在,旧形状的会话 mood 可读回', () => {
270
+ const read = mood.readMood('今天太开心了,测试全部通过')
271
+ assert.strictEqual(typeof read.valence, 'number', 'valence 是旧召回路径直接读取的字段,不能因为新增 emotion 就消失')
272
+ assert.ok(read.valence > 0, '旧路径的语义不能变:这句话仍然读成正面')
273
+ assert.ok('emotion' in read && 'arousal' in read && 'dominance' in read, 'spec §4.1 的四个字段必须同时返回')
274
+
275
+ const { sessionId } = setup('compat')
276
+ state.setSessionMood(sessionId, { valence: -0.6, arousal: 0.8 })
277
+ const stored = state.getSessionMood(sessionId)
278
+ assert.strictEqual(stored.valence, -0.6, '旧形状 { valence, arousal } 必须原样可读')
279
+ assert.strictEqual(stored.arousal, 0.8)
280
+ assert.strictEqual(stored.emotion, null, '只有 valence 的旧读数不得反推四轴')
281
+ assert.strictEqual(typeof stored.at, 'number')
282
+ })
283
+
284
+ // ---- §8 第八条:旧条目不反推 ----
285
+
286
+ test('旧条目没有 emotion 时不反推四轴,也不产生 NaN', async () => {
287
+ const { binding, exec, sessionId } = setup('legacy')
288
+ const stamp = new Date().toISOString()
289
+ const index = state.loadPrivateIndex(binding.libraryId)
290
+ index.entries['mem-legacy-harbour'] = {
291
+ track: 'memory',
292
+ layer: 'long_term',
293
+ title: 'Harbour bell at dusk',
294
+ content: 'The harbour bell rang twice at dusk.',
295
+ status: 'active',
296
+ owner: 'me',
297
+ visibility: 'shared',
298
+ source: 'user_said',
299
+ moodValence: -0.8,
300
+ arousal: 0.6,
301
+ peak: false,
302
+ confidence: 0.8,
303
+ createdAt: stamp,
304
+ updatedAt: stamp,
305
+ }
306
+ state.savePrivateIndex(binding.libraryId, index)
307
+
308
+ // 新路径在场(会话有四轴情绪),旧条目仍必须走 legacy valence 分流。
309
+ state.setSessionMood(sessionId, { emotion: { joy: 0, anger: 0, sorrow: 1, fear: 0 }, arousal: 0.35, dominance: 0 })
310
+ const out = await memory.privateRecall({ exec, query: 'harbour bell dusk', topK: 1, relevanceThreshold: 0, purpose: 'automatic' })
311
+ const hit = (out.files || []).find((file) => file.name === 'mem-legacy-harbour')
312
+ assert.ok(hit, '没有 emotion 的旧条目仍然必须能被召回')
313
+ assert.ok(Number.isFinite(hit.score) && hit.score > 0, `旧条目缺 emotion/dominance 时打分不能变成 NaN,实测 ${hit.score}`)
314
+ assert.strictEqual(hit.entry.emotion, undefined, 'spec §3.3:旧条目没有 emotion 就保持原样,不能从 moodValence 反推四轴')
315
+ assert.strictEqual(
316
+ state.loadPrivateIndex(binding.libraryId).entries['mem-legacy-harbour'].emotion,
317
+ undefined,
318
+ '召回本身也不能把推断出的构成写回索引',
319
+ )
320
+ })
321
+
322
+ // ---- §8 第九条:显式 0 ----
323
+
324
+ test('显式 arousal: 0 与 dominance: 0 写入后读回仍是 0', async () => {
325
+ const { exec, binding, sessionId } = setup('explicit-zero')
326
+ const { id } = await memory.privateRemember({
327
+ exec,
328
+ content: 'The gauge read zero at noon.',
329
+ title: 'gauge zero',
330
+ arousal: 0,
331
+ dominance: 0,
332
+ })
333
+ const entry = entryOf(binding, id)
334
+ assert.strictEqual(entry.arousal, 0, 'spec §3.2:显式 0 是读数的一部分,不能被 || 0.35 覆盖')
335
+ assert.strictEqual(entry.dominance, 0, 'dominance 的显式 0 同样不能被默认值吞掉')
336
+
337
+ state.setSessionMood(sessionId, { valence: 0, arousal: 0, dominance: 0 })
338
+ const stored = state.getSessionMood(sessionId)
339
+ assert.strictEqual(stored.arousal, 0, '会话 mood 的显式 0 也必须保留')
340
+ assert.strictEqual(stored.dominance, 0)
341
+ })
342
+
343
+ // ---- §8 最后一条:remember -> list -> update -> recall 往返 ----
344
+
345
+ test('remember 带 emotion:归一化、来源 model、moodValence 等于派生值', async () => {
346
+ const { exec, binding } = setup('remember')
347
+ const { id } = await memory.privateRemember({
348
+ exec,
349
+ content: 'We argued about the broken deploy until midnight.',
350
+ title: 'midnight deploy',
351
+ emotion: { joy: 0.5, anger: 0.5, sorrow: 0, fear: 0 },
352
+ })
353
+ const entry = entryOf(binding, id)
354
+ assert.ok(close(shareSum(entry.emotion), 1, 1e-9), '存下来的构成必须已经归一化,读回时不能再做一次隐式缩放')
355
+ assert.ok(close(entry.emotion.joy, 0.5, 1e-12) && close(entry.emotion.anger, 0.5, 1e-12), '0.5/0.5 归一化后仍是 0.5/0.5')
356
+ assert.strictEqual(entry.emotionSource, 'model', '工具参数显式给了 emotion,来源必须是 model,否则审计分不清事实与推断')
357
+ assert.ok(close(entry.moodValence, mood.derivedValence(entry.emotion), 1e-12), 'moodValence 是构成的兼容投影,必须与 derivedValence 一致')
358
+
359
+ // 0 == 0 的断言是空洞的,再写一条非零投影的。
360
+ const second = await memory.privateRemember({
361
+ exec,
362
+ content: 'The release finally shipped clean.',
363
+ title: 'clean release',
364
+ emotion: { joy: 0.75, anger: 0.25, sorrow: 0, fear: 0 },
365
+ })
366
+ assert.ok(close(entryOf(binding, second.id).moodValence, 0.4, 1e-12), '0.8*(0.75-0.25)=0.4')
367
+ })
368
+
369
+ test('update 三态:省略保留、null 清空并标 unknown、新对象整份替换', async () => {
370
+ const { exec, binding } = setup('update')
371
+ const { id } = await memory.privateRemember({
372
+ exec,
373
+ content: 'The roof leaked through the night.',
374
+ title: 'roof leak',
375
+ emotion: { joy: 0, anger: 0, sorrow: 0.75, fear: 0.25 },
376
+ })
377
+ const before = entryOf(binding, id).emotion
378
+
379
+ await memory.privateUpdate({ exec, id, title: 'roof leak (edited)' })
380
+ assert.deepEqual(entryOf(binding, id).emotion, before, '省略 emotion 表示保留:只改标题不能把情绪弄丢')
381
+
382
+ await memory.privateUpdate({ exec, id, emotion: null })
383
+ const cleared = entryOf(binding, id)
384
+ assert.strictEqual(cleared.emotion, null, '显式 null 表示清除构成')
385
+ assert.strictEqual(cleared.emotionSource, 'unknown', '清除后来源必须是 unknown,不能再声称是 model 读到的')
386
+
387
+ await memory.privateUpdate({ exec, id, emotion: { joy: 1, anger: 0, sorrow: 0, fear: 0 } })
388
+ const replaced = entryOf(binding, id)
389
+ assert.deepEqual(replaced.emotion, { joy: 1, anger: 0, sorrow: 0, fear: 0 }, '新对象必须整份替换旧构成,不能按轴合并')
390
+ assert.strictEqual(replaced.emotionSource, 'model')
391
+ assert.ok(close(replaced.moodValence, 0.8, 1e-12), '替换后兼容投影要跟着重算')
392
+ })
393
+
394
+ test('update 的非法 emotion 必须 reject,错误含 emotion,且不留下半截修改', async () => {
395
+ const { exec, binding } = setup('update-bad')
396
+ const { id } = await memory.privateRemember({
397
+ exec,
398
+ content: 'The invoice arrived twice.',
399
+ title: 'invoice twice',
400
+ emotion: { joy: 0, anger: 0, sorrow: 1, fear: 0 },
401
+ })
402
+ const before = entryOf(binding, id).emotion
403
+
404
+ await assert.rejects(
405
+ () => memory.privateUpdate({ exec, id, emotion: { joy: 1, anger: -1, sorrow: 0, fear: 0 } }),
406
+ /emotion/,
407
+ 'spec §3.1:负数份额只能拒绝,不能 clamp 成合法值;错误信息必须点明 emotion',
408
+ )
409
+ assert.deepEqual(entryOf(binding, id).emotion, before, '被拒绝的更新不能改动已存构成')
410
+ })
411
+
412
+ test('about_project 的条目在没有显式 emotion 时保持中性', async () => {
413
+ const { exec, binding, sessionId } = setup('project')
414
+ // 会话此刻很兴奋:工程事实仍然不能被染色。
415
+ state.setSessionMood(sessionId, { emotion: { joy: 1, anger: 0, sorrow: 0, fear: 0 }, arousal: 0.9, dominance: 0.5 })
416
+
417
+ const { id } = await memory.privateRemember({
418
+ exec,
419
+ content: 'Node 22 的构建终于通过了,我特别开心。',
420
+ title: 'build green on Node 22',
421
+ owner: 'about_project',
422
+ })
423
+ const entry = entryOf(binding, id)
424
+ assert.strictEqual(entry.emotion, null, 'spec §6.1/§6.3:工程事实固定中性;bug 难修不是项目在难过')
425
+ assert.strictEqual(entry.emotionSource, 'unknown', '没有证据就必须标 unknown,不能借会话心情冒充推断')
426
+ assert.strictEqual(entry.dominance, 0, '工程事实不继承会话的掌控感')
427
+ assert.ok(Number.isFinite(entry.moodValence), '中性也要给出可用的兼容投影,而不是 NaN')
428
+ })
429
+
430
+ test('remember 的显式 emotion: null 表示明确中性(spec §6.1),不继承会话心情', async () => {
431
+ const { exec, binding, sessionId } = setup('remember-null')
432
+ state.setSessionMood(sessionId, { emotion: { joy: 1, anger: 0, sorrow: 0, fear: 0 }, arousal: 0.9, dominance: 0.4 })
433
+
434
+ const { id } = await memory.privateRemember({
435
+ exec,
436
+ content: 'The build passed.',
437
+ title: 'build passed',
438
+ emotion: null,
439
+ })
440
+ assert.strictEqual(
441
+ entryOf(binding, id).emotion,
442
+ null,
443
+ 'spec §6.1:省略=继承、null=明确中性。把 null 当成省略会把整理时的心情写进事件本身',
444
+ )
445
+ })
@@ -0,0 +1,35 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-engine-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const engine = await import('../lib/engine.js')
11
+
12
+ test.after(() => {
13
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
14
+ })
15
+
16
+ test('version history reads the memu-cli distribution, not the memu game package', () => {
17
+ const html = [
18
+ '<a href="../../packages/memu_cli-0.11.0b3-py3-none-any.whl#sha256=x">memu_cli-0.11.0b3-py3-none-any.whl</a>',
19
+ '<a href="../../packages/memu_cli-0.11.0b2.tar.gz#sha256=y">memu_cli-0.11.0b2.tar.gz</a>',
20
+ '<a href="../../packages/memu-2.1.4.tar.gz#sha256=z">memu-2.1.4.tar.gz</a>',
21
+ ].join('\n')
22
+ assert.deepEqual(engine.parseSimpleVersions(html).sort(), ['0.11.0b2', '0.11.0b3'])
23
+ })
24
+
25
+ test('sdist suffixes never leak into a version number', () => {
26
+ assert.equal(engine.normalizeVersion('0.11.0b3.tar'), '0.11.0b3')
27
+ assert.equal(engine.normalizeVersion('0.10.0'), '0.10.0')
28
+ assert.equal(engine.normalizeVersion('not-a-version'), null)
29
+ })
30
+
31
+ test('a release sorts above its pre-releases', () => {
32
+ assert.ok(engine.compareVersions('0.11.0', '0.11.0b3') > 0)
33
+ assert.ok(engine.compareVersions('0.11.0b3', '0.11.0b2') > 0)
34
+ assert.ok(engine.compareVersions('0.11.0b1', '0.10.0') > 0)
35
+ })
@@ -0,0 +1,56 @@
1
+ # tests/fixtures/active-memory-ab
2
+
3
+ 主动记忆金标 A/B 的**候选集**。这里全部是**合成代理标注(synthetic proxy)**,不是人工金标。
4
+
5
+ ## 状态
6
+
7
+ | 项 | 值 | 含义 |
8
+ |---|---|---|
9
+ | status | `synthetic-proxy-not-gold` | 数据集不是 gold set |
10
+ | draft / notGold | `true` | 草稿,未确认 |
11
+ | gold | `false` | 只有人工逐条确认后、并且 review 改成 `human-reviewed`,才可能进入金标 |
12
+ | review | `pending` | 全部 case 仍在待审 |
13
+
14
+ **人审候选与 gold gating 是两件事**:把一条 case 标成候选(存在这里、有人读过)不等于它已成为金标。`scripts/active-memory-ab.mjs` 的结构校验会**拒绝** `annotationSource: "human"` 但 `review` 不是 `human-reviewed` 的 case,也拒绝任何 `gold: true` 的合成代理 case。
15
+
16
+ ## 目录
17
+
18
+ ```text
19
+ manifest.json 数据集清单 + 每个 case 的 throughSeq 与快照 sha256(pin)
20
+ cases/*.json 一个 case = 一份固定快照 + 标签 + 期望
21
+ ```
22
+
23
+ ## 固定快照与 pin
24
+
25
+ - `snapshot.events` 是**手写事件**,`seq` 由 harness 按数组位置分配(不要手写 `seq`)。
26
+ - `snapshot.throughSeq` 指向快照的**最后一个事件**;它之后的事件属于“尚未到达”,**不进入** worker 输入,也**不进入**摘要。
27
+ - 快照摘要 = `sha256(canonicalJson({schemaVersion, fromSeq, throughSeq, events[fromSeq..throughSeq]}))`。manifest 里的 `snapshotSha256` 是 pin;改了 case 就必须重新钉,否则加载直接失败。
28
+ - 重新钉 pin 的方法(**不自动改写**,要人工看 diff):
29
+
30
+ ```bash
31
+ node scripts/active-memory-ab.mjs --digests
32
+ ```
33
+
34
+ - `snapshot.padding`/`{ "type": "padding", ... }` 占位符由 `ab-polling-padding-v1` 确定性展开成工具轮询事件(文本完全相同,因此去重后只算 1 个工作单元)。生成规则固定在脚本里,展开结果参与摘要,所以同样可复现。
35
+
36
+ ## 标签
37
+
38
+ - `labels.expected[]`:`{ id: "mem-ab-…", class, sourceSeq, note }`。`class` 取 `decision | preference | root-cause | promise | fiction-fact`。`sourceSeq` 指向“若由人来标也应记”的那个事件。
39
+ - `labels.mainProactive[]`:主模型**自己**写过的条目 id(快照里能看到 `memu_remember`/`memu_update` 的成功 `tool/call`)。后台检查再遇到它只能算重复,不能算新增覆盖。
40
+ - `labels.userReminders[]`:用户主动提醒的 seq。
41
+ - `snapshot.globalEntries[]` + `snapshot.conflicts[]`:全局干扰维度的输入(合成数据,只用于量预算与泄漏)。
42
+ - `expect`:这个 case 的可断言期望(`tests/active-memory-ab.test.mjs` 会逐条核对)。
43
+
44
+ ## 加一条 case
45
+
46
+ 1. 新建 `cases/NN-<id>.json`,`id` 用 `^[a-z0-9][a-z0-9-]{2,80}$`,`annotationSource` 保持 `synthetic-proxy`、`review` 保持 `pending`、`gold` 保持 `false`。
47
+ 2. 跑 `node scripts/active-memory-ab.mjs --digests`,把打印出来的 `throughSeq` 与 `sha256` 手工写进 `manifest.json` 的对应条目(`file` 写相对路径 `cases/NN-<id>.json`)。
48
+ 3. 跑 `node --test tests/active-memory-ab.test.mjs`。
49
+
50
+ **不要**为了让测试变绿去改既有 case 的标签或期望;先问“是我的改动错了,还是标签本来就该这样”。
51
+
52
+ ## 明确不做
53
+
54
+ - 不放真实会话正文、真实路径、密钥。
55
+ - 不把合成代理标注说成人工金标,不报“真实模型主功率提升”。
56
+ - 不在本目录引入任何需要联网或调用模型的资产。