dsh-memu-memory 1.1.3 → 1.2.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 (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,166 @@
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-appb2-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const memory = await import('../lib/memory.js')
12
+ const inject = await import('../lib/inject.js')
13
+
14
+ test.after(() => {
15
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
16
+ })
17
+
18
+ function execFor(sessionId, cwd) {
19
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
20
+ }
21
+
22
+ function setup(name) {
23
+ const cwd = path.join(home, 'proj-' + name)
24
+ const sessionId = 'appb2-' + name
25
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
26
+ return { cwd, sessionId, binding, exec: execFor(sessionId, cwd) }
27
+ }
28
+
29
+ function daysAgo(n) {
30
+ return new Date(Date.now() - n * 86_400_000).toISOString()
31
+ }
32
+
33
+ // ---- 10: negativity bias and self-reference stretch the half-life ----
34
+
35
+ test('an unpleasant memory fades slower than a neutral one of the same age', () => {
36
+ const plain = { layer: 'long_term', strength: 1, createdAt: daysAgo(60), moodValence: 0 }
37
+ const sore = { layer: 'long_term', strength: 1, createdAt: daysAgo(60), moodValence: -0.9 }
38
+ // Both change how long it lives, never the score today.
39
+ assert.ok(memory.decayStrength(sore) > memory.decayStrength(plain))
40
+ assert.ok(memory.negativityBias(plain) === 1, 'a neutral memory gets no bonus')
41
+ assert.ok(memory.negativityBias(sore) > 1 && memory.negativityBias(sore) <= 1.3)
42
+ assert.ok(memory.negativityBias({ moodValence: -0.05 }) === 1, 'a trace of displeasure is not a bias')
43
+ })
44
+
45
+ test('memories about me or us outlive memories about the world', () => {
46
+ // Kept well inside the 14-day half-life, or the 0.05 floor flattens the difference.
47
+ const theirs = { layer: 'long_term', strength: 1, createdAt: daysAgo(10), owner: 'them' }
48
+ const mine = { layer: 'long_term', strength: 1, createdAt: daysAgo(10), owner: 'me' }
49
+ const ours = { layer: 'long_term', strength: 1, createdAt: daysAgo(10), owner: 'us' }
50
+ assert.ok(memory.decayStrength(mine) > memory.decayStrength(theirs))
51
+ assert.ok(memory.decayStrength(ours) > memory.decayStrength(theirs))
52
+ assert.equal(memory.selfReferenceBias({ owner: 'them' }), 1)
53
+ })
54
+
55
+ // ---- 11: a pinned memory still fades, and the tip-of-the-tongue effect ----
56
+
57
+ test('must_remember decays on a one-year half-life but never below the floor', () => {
58
+ const fresh = { layer: 'must_remember', strength: 1, createdAt: daysAgo(0) }
59
+ const twoYears = { layer: 'must_remember', strength: 1, createdAt: daysAgo(730) }
60
+ const tenYears = { layer: 'must_remember', strength: 1, createdAt: daysAgo(3650) }
61
+ assert.ok(Math.abs(memory.decayStrength(fresh) - 1) < 1e-7)
62
+ assert.ok(memory.decayStrength(twoYears) < 1, 'permanent stopped meaning verbatim forever')
63
+ assert.ok(memory.decayStrength(tenYears) >= 0.35, 'the floor keeps a pin from evaporating')
64
+ })
65
+
66
+ test('tip-of-tongue is about reachability, not importance', () => {
67
+ assert.equal(memory.isTipOfTongue({ layer: 'must_remember', confidence: 0.1 }), false, 'a pin is never half-remembered')
68
+ assert.equal(memory.isTipOfTongue({ layer: 'long_term', marginalized: true, confidence: 0.9 }), true)
69
+ assert.equal(memory.isTipOfTongue({ layer: 'long_term', confidence: 0.3 }), true)
70
+ assert.equal(memory.isTipOfTongue({ layer: 'long_term', confidence: 0.9, strength: 1, createdAt: daysAgo(1) }), false)
71
+ })
72
+
73
+ test('injection gives the gist, not the wording, when a memory is half-remembered', async () => {
74
+ const { sessionId, binding, exec } = setup('tongue')
75
+ const content = '周四晚上在厨房,他一边擦杯子一边说那句关于不要再改需求的话,说完就把杯子放回架子上,转身去阳台抽了一支烟,很久都没有再开口,客厅的电视一直在放同一档节目。'
76
+ const saved = await memory.privateRemember({ exec, content, title: '厨房里那句话', confidence: 0.3 })
77
+ const index = state.loadPrivateIndex(binding.libraryId)
78
+ index.entries[saved.id].confidence = 0.3
79
+ state.savePrivateIndex(binding.libraryId, index)
80
+
81
+ const out = await inject.buildRecall(binding, '厨房 那句话', 1, { ...state.getSettings(), privateTopK: 3, relevanceThreshold: 0 }, exec, sessionId)
82
+ assert.ok(out.text.includes('只记得大概'), 'the model is told it only remembers the gist')
83
+ assert.ok(!out.text.includes('客厅的电视一直在放同一档节目'), 'the exact wording must not be handed over as if quoted')
84
+ assert.ok(out.text.includes(saved.id), 'the id and the tool are how the original is still reachable')
85
+ })
86
+
87
+ // ---- 12: fading affect bias ----
88
+
89
+ test('a bad feeling goes neutral faster than a good one', () => {
90
+ const grudge = { moodValence: -0.8, updatedAt: daysAgo(60) }
91
+ const fondness = { moodValence: 0.8, updatedAt: daysAgo(60) }
92
+ const fadedGrudge = Math.abs(memory.fadedValence(grudge))
93
+ const fadedFondness = Math.abs(memory.fadedValence(fondness))
94
+ assert.ok(fadedGrudge < fadedFondness, 'the event stays, the sting fades')
95
+ assert.ok(fadedGrudge < 0.2)
96
+ // Fresh memories keep their colour.
97
+ assert.ok(Math.abs(memory.fadedValence({ moodValence: -0.8, updatedAt: daysAgo(0) }) - -0.8) < 0.05)
98
+ })
99
+
100
+ // ---- 13: vividness is not accuracy ----
101
+
102
+ test('high arousal discounts accuracy while leaving confidence alone', () => {
103
+ const calm = memory.accuracyOf({ peak: false, arousal: 0.2, confidence: 0.9 })
104
+ assert.equal(calm, 0.9, 'an ordinary memory is as accurate as it is confident')
105
+ const flash = memory.accuracyOf({ peak: true, arousal: 0.95, confidence: 0.9 })
106
+ assert.ok(flash < calm, 'a flashbulb memory is vivid, not precise')
107
+ assert.ok(flash >= 0.2)
108
+ assert.equal(memory.accuracyOf({ peak: true, arousal: 0.95, confidence: 0.1 }), 0.2, 'the floor is 0.2')
109
+ })
110
+
111
+ test('a remembered memory stores accuracy next to confidence', async () => {
112
+ const { exec, binding } = setup('accuracy')
113
+ const saved = await memory.privateRemember({
114
+ exec, content: 'The siren woke the whole street at 3am.', title: 'the siren', peak: true, arousal: 0.95, confidence: 0.9,
115
+ })
116
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[saved.id]
117
+ assert.equal(entry.confidence, 0.9)
118
+ assert.ok(entry.accuracy < entry.confidence)
119
+ })
120
+
121
+ // ---- 14: contradiction detection inside one library ----
122
+
123
+ test('a same-title rewrite is queued for review instead of being appended', async () => {
124
+ const { exec, binding } = setup('conflict-same')
125
+ const first = await memory.privateRemember({ exec, content: '部署密钥放在 vault 里的 dsh-memu-memory-prod 下面。', title: '部署密钥位置' })
126
+ const second = await memory.privateRemember({ exec, content: '部署密钥改成放在 1Password 的 ops 保险库里了。', title: '部署密钥位置' })
127
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[second.id]
128
+ assert.equal(entry.status, 'pending_review', 'two answers to one question cannot both be active')
129
+ assert.equal(entry.conflictingWith, first.id)
130
+ assert.match(entry.reviewReason, /同题/)
131
+ assert.equal(second.conflict, first.id)
132
+ })
133
+
134
+ test('a flipped claim is caught even under a different title', async () => {
135
+ const { exec, binding } = setup('conflict-negation')
136
+ const first = await memory.privateRemember({ exec, content: 'We always deploy on Friday afternoons.', title: 'deploy window' })
137
+ const second = await memory.privateRemember({ exec, content: 'We never deploy on Friday afternoons.', title: 'release rule' })
138
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[second.id]
139
+ assert.equal(entry.status, 'pending_review')
140
+ assert.equal(entry.conflictingWith, first.id)
141
+ assert.match(entry.reviewReason, /相反/)
142
+ })
143
+
144
+ test('unrelated memories are not flagged', async () => {
145
+ const { exec, binding } = setup('conflict-clean')
146
+ await memory.privateRemember({ exec, content: '他喜欢周五下午整理下周计划。', title: '周五习惯' })
147
+ const other = await memory.privateRemember({ exec, content: 'The kettle in the office kitchen boils slowly.', title: 'the kettle' })
148
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[other.id]
149
+ assert.equal(entry.status, 'active', 'a heuristic this loose would bury the queue in noise')
150
+ assert.equal(other.conflict, null)
151
+ })
152
+
153
+ test('a claim under review never reaches the prompt or a recall', async () => {
154
+ const { exec, binding } = setup('pending-hidden')
155
+ await memory.privateRemember({ exec, content: 'We always deploy on Friday afternoons.', title: 'deploy window' })
156
+ const second = await memory.privateRemember({ exec, content: 'We never deploy on Friday afternoons.', title: 'deploy window' })
157
+ assert.equal(second.entry.status, 'pending_review')
158
+ const anchor = inject.buildAnchor(binding, state.getSettings(), null)
159
+ // Both halves of a contradiction in one prompt is worse than neither: the model
160
+ // would pick a side and sound certain about it.
161
+ assert.ok(!anchor.text.includes('never deploy'), 'a pending entry stays out of the anchor')
162
+ assert.ok(!anchor.ids.includes(second.id))
163
+ const recall = await inject.buildRecall(binding, 'deploy on Friday', 3, state.getSettings(), exec, null)
164
+ assert.ok(!recall.ids.includes(second.id), 'and out of the recall list')
165
+ assert.ok(!String(recall.text).includes('never deploy'))
166
+ })
@@ -0,0 +1,272 @@
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-audit-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const memory = await import('../lib/memory.js')
12
+ const inject = await import('../lib/inject.js')
13
+ const rpc = await import('../lib/rpc.js')
14
+ const curator = await import('../lib/curator.js')
15
+ const memu = await import('../lib/memu.js')
16
+
17
+ test.after(() => {
18
+ try { state.flushState() } catch {}
19
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
20
+ })
21
+
22
+ function execFor(sessionId, cwd, agentPreset = 'coding') {
23
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset } } } }
24
+ }
25
+
26
+ function setup(name) {
27
+ const cwd = path.join(home, 'proj-' + name)
28
+ const sessionId = 'audit-' + name
29
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
30
+ return { cwd, sessionId, binding, exec: execFor(sessionId, cwd) }
31
+ }
32
+
33
+ // ---- §2.2 pending_review must not reach recall / anchor / anniversary / global
34
+
35
+ test('a held (pending_review) memory never reaches recall, anchor, anniversary, or global recall', async () => {
36
+ const { exec, binding, sessionId } = setup('pending')
37
+ await memory.privateRemember({ exec, content: '部署密钥放在 vault。', title: '部署密钥位置' })
38
+ const held = await memory.privateRemember({ exec, content: '部署密钥改放在 1Password。', title: '部署密钥位置' })
39
+ assert.equal(held.entry.status, 'pending_review')
40
+
41
+ const recalled = await memory.privateRecall({ exec, query: '部署密钥位置', topK: 10, relevanceThreshold: 0 })
42
+ assert.ok(!recalled.files.some((f) => f.name === held.id), 'held memory must not be recalled')
43
+
44
+ const anchor = inject.buildAnchor(binding, state.getSettings(), sessionId)
45
+ assert.ok(!anchor.text.includes(held.id), 'held memory must not sit in the anchor')
46
+
47
+ const md = '01-01'
48
+ state.updatePrivateIndex(binding.libraryId, (idx) => { idx.entries[held.id].salientDate = md })
49
+ const anniversary = inject.buildAnniversary(sessionId, binding, new Date(2026, 0, 1))
50
+ assert.ok(!anniversary.ids.includes(held.id), 'held memory must not surface in anniversaries')
51
+
52
+ state.saveGlobalIndex({ entries: { g1: { title: 't', content: '部署密钥改放在 1Password。', domain: 'coding', status: 'pending_review' } } })
53
+ const global = await memory.globalRecall({ exec, query: '部署密钥', topK: 10, relevanceThreshold: 0 })
54
+ assert.ok(!global.files.some((f) => f.name === 'g1'), 'held global memory must not be recalled')
55
+ state.saveGlobalIndex({ entries: {} })
56
+ })
57
+
58
+ // ---- §2.4 agent backend: accepting a global review must not touch the engine
59
+
60
+ test('agent backend accepts a global review without the engine', async () => {
61
+ state.saveGlobalIndex({ entries: { gpend: { title: 'x', content: 'y', domain: 'coding', status: 'pending_review' } } })
62
+ const out = await rpc.dispatch({}, 'resolveReview', { id: 'gpend', action: 'accept' })
63
+ assert.equal(out.status, 'active')
64
+ const index = state.loadGlobalIndex()
65
+ assert.equal(index.entries.gpend.status, 'active')
66
+ state.saveGlobalIndex({ entries: {} })
67
+ })
68
+
69
+ // ---- §2.1 MemU nonzero exit must reject, not crash the host
70
+
71
+ test('a failing MemU command rejects with MEMU_COMMAND_FAILED (and the process survives)', async () => {
72
+ fs.mkdirSync(path.join(home, 'engine'), { recursive: true })
73
+ fs.writeFileSync(path.join(home, 'engine', 'current.json'), JSON.stringify({ python: process.execPath, venv: home }))
74
+ let error = null
75
+ try {
76
+ await memu.retrieve({ query: 'x', db: path.join(home, 'x.sqlite3'), timeoutMs: 15_000 })
77
+ } catch (e) {
78
+ error = e
79
+ }
80
+ assert.ok(error, 'must reject, not resolve')
81
+ assert.equal(error.code, 'MEMU_COMMAND_FAILED')
82
+ fs.rmSync(path.join(home, 'engine'), { recursive: true, force: true })
83
+ })
84
+
85
+ // ---- §2.7 globalList domain is a real filter
86
+
87
+ test('globalList({domain}) returns only that domain', async () => {
88
+ state.saveGlobalIndex({
89
+ entries: {
90
+ gd1: { title: 'a', content: 'a', domain: 'coding', status: 'active' },
91
+ gd2: { title: 'b', content: 'b', domain: 'rp', status: 'active' },
92
+ },
93
+ })
94
+ const { exec, sessionId } = setup('glist')
95
+ state.setSessionBinding(sessionId, { domainOverride: 'coding' })
96
+ const out = await memory.globalList({ exec, domain: 'coding' })
97
+ assert.deepEqual(out.files.map((f) => f.name), ['gd1'])
98
+ state.saveGlobalIndex({ entries: {} })
99
+ })
100
+
101
+ // ---- §2.8 backups carry the prompt manifest
102
+
103
+ test('a backup includes prompts/manifest.json', () => {
104
+ fs.mkdirSync(path.join(home, 'prompts'), { recursive: true })
105
+ fs.writeFileSync(path.join(home, 'prompts', 'manifest.json'), '{"version":1}')
106
+ const dest = state.backupDatabases()
107
+ assert.ok(fs.existsSync(path.join(dest, 'prompts', 'manifest.json')), 'prompt manifest must be in the backup')
108
+ })
109
+
110
+ // ---- §2.9 confidence 0 survives
111
+
112
+ test('confidence: 0 is stored as 0, not coerced to the default', async () => {
113
+ const { exec, binding } = setup('confzero')
114
+ const saved = await memory.privateRemember({ exec, content: 'low confidence', title: 'zero', confidence: 0 })
115
+ const entry = state.loadPrivateIndex(binding.libraryId).entries[saved.id]
116
+ assert.equal(entry.confidence, 0)
117
+ const updated = await memory.privateUpdate({ exec, id: saved.id, confidence: 0.35 })
118
+ assert.equal(updated.entry.confidence, 0.35)
119
+ const zeroed = await memory.privateUpdate({ exec, id: saved.id, confidence: 0 })
120
+ assert.equal(zeroed.entry.confidence, 0)
121
+ })
122
+
123
+ // ---- §2.6 library browser cannot orphan a session-scoped memory
124
+
125
+ test('the panel refuses the removed session level with an explanation', async () => {
126
+ const lib = state.createLibrary({ name: 'audit-orphan', cwd: path.join(home, 'ws-orphan') })
127
+ // Refused by the write path itself now, not by a panel-only guard: a client that kept
128
+ // the old dropdown must be told what replaced the level, not silently given 'shared'.
129
+ await assert.rejects(
130
+ rpc.dispatch({}, 'addMemory', { libraryId: lib.id, content: 'x', visibility: 'session' }),
131
+ /visibility 'session' was removed/,
132
+ )
133
+ const saved = await rpc.dispatch({}, 'addMemory', { libraryId: lib.id, content: 'y', title: 'y' })
134
+ await assert.rejects(
135
+ rpc.dispatch({}, 'updateMemory', { libraryId: lib.id, id: saved.id, patch: { visibility: 'session' } }),
136
+ /visibility 'session' was removed/,
137
+ )
138
+ assert.equal(state.loadPrivateIndex(lib.id).entries[saved.id].visibility, 'shared', 'the failed edit changed nothing')
139
+ })
140
+
141
+ test('addMemory passes pin fields through for must_remember', async () => {
142
+ const lib = state.createLibrary({ name: 'audit-pin', cwd: path.join(home, 'ws-pin') })
143
+ const out = await rpc.dispatch({}, 'addMemory', {
144
+ libraryId: lib.id, content: 'pinned from browser', title: 'pinned',
145
+ layer: 'must_remember', pinReason: 'user asked to remember', pinSource: 'user_explicit',
146
+ })
147
+ assert.equal(out.entry.layer, 'must_remember')
148
+ assert.equal(out.entry.pinSource, 'user_explicit')
149
+ })
150
+
151
+ // ---- §2.13 switches constrain tool-level operations
152
+
153
+ test('a conversation with useLibrary=false cannot read or write private memory', async () => {
154
+ const { exec, sessionId } = setup('switched-off')
155
+ await memory.privateRemember({ exec, content: 'before the switch', title: 't' })
156
+ state.setSessionBinding(sessionId, { useLibrary: false })
157
+ await assert.rejects(() => memory.privateRecall({ exec, query: 'before the switch' }), /turned off for this conversation/)
158
+ await assert.rejects(() => memory.privateRemember({ exec, content: 'sneaky', title: 's' }), /turned off for this conversation/)
159
+ state.setSessionBinding(sessionId, { useLibrary: true })
160
+ })
161
+
162
+ test('a conversation with useGlobal=false cannot recall global memory', async () => {
163
+ const { exec, sessionId } = setup('global-off')
164
+ state.setSessionBinding(sessionId, { useGlobal: false })
165
+ await assert.rejects(() => memory.globalRecall({ exec, query: 'anything' }), /turned off for this conversation/)
166
+ state.setSessionBinding(sessionId, { useGlobal: true })
167
+ })
168
+
169
+ // ---- §2.14 unbound writes are refused, not written to null.json
170
+
171
+ test('writing with no library binding refuses instead of creating index/null.json', async () => {
172
+ const cwd = path.join(home, 'proj-unbound')
173
+ const sessionId = 'audit-unbound'
174
+ // Bind with both layers off and no library — the "no memory" new-session choice.
175
+ state.ensureSession(sessionId, { cwd, createLibrary: false })
176
+ state.setSessionBinding(sessionId, { useLibrary: false, useGlobal: false })
177
+ const exec = execFor(sessionId, cwd)
178
+ await assert.rejects(() => memory.privateRemember({ exec, content: 'orphan', title: 'o' }), /no memory library/)
179
+ assert.ok(!fs.existsSync(path.join(home, 'index', 'null.json')), 'no null.json may appear')
180
+ })
181
+
182
+ // ---- §2.21 engine downgrade gate
183
+
184
+ test('engine version comparison treats betas and finals correctly', async () => {
185
+ const { isVersionDowngrade } = await import('../lib/engine.js')
186
+ assert.equal(isVersionDowngrade('0.11.0b2', '0.11.0b3'), true)
187
+ assert.equal(isVersionDowngrade('0.11.0b3', '0.11.0b3'), false)
188
+ assert.equal(isVersionDowngrade('0.11.0', '0.11.0b3'), false, 'final release is newer than its betas')
189
+ assert.equal(isVersionDowngrade('0.10.0', '0.11.0'), true)
190
+ assert.equal(isVersionDowngrade('0.12.0', '0.11.0b3'), false)
191
+ })
192
+
193
+ // ---- §2.16 a failed state.json read must never write the fallback back
194
+
195
+ test('a transient state.json read failure does not overwrite real data on recovery', () => {
196
+ const stateFile = path.join(home, 'state.json')
197
+ state.flushState()
198
+ const saved = fs.readFileSync(stateFile, 'utf8')
199
+ const parsed = JSON.parse(saved)
200
+ parsed.settings.marker = 'real-data'
201
+ fs.writeFileSync(stateFile, JSON.stringify(parsed))
202
+
203
+ // Make the file unreadable (a directory at its path), then touch bookkeeping.
204
+ fs.rmSync(stateFile)
205
+ fs.mkdirSync(stateFile)
206
+ state.loadState()
207
+ state.setWorkspacePolicy(path.join(home, 'during-failure'), { useLibrary: false })
208
+ assert.throws(() => state.flushState(), (error) => error.code === 'dsh-memu-memory/read-failed')
209
+
210
+ // Recover: the real file comes back. The next flush must adopt the disk truth,
211
+ // not the defaults that were cached while reads failed.
212
+ fs.rmdirSync(stateFile)
213
+ fs.writeFileSync(stateFile, JSON.stringify(parsed))
214
+ state.loadState()
215
+ state.flushState()
216
+ const after = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
217
+ assert.equal(after.settings.marker, 'real-data', 'real settings survive the incident')
218
+ assert.ok(after.workspacePolicies != null, 'workspace policies map still exists')
219
+ })
220
+
221
+ // ---- §2.15 recall ids match the text that actually got injected
222
+
223
+ test('buildRecall ids only contain entries present in the (possibly truncated) text', async () => {
224
+ const { exec, binding, sessionId } = setup('truncate')
225
+ const long = '内容'.repeat(400)
226
+ await memory.privateRemember({ exec, content: long, title: '第一条长记忆' })
227
+ await memory.privateRemember({ exec, content: long, title: '第二条长记忆' })
228
+ const settings = { ...state.getSettings(), turnTokens: 120, relevanceThreshold: 0, privateTopK: 4, dedupeTurns: 0 }
229
+ const out = await inject.buildRecall(binding, '长记忆', 1, settings, exec, sessionId)
230
+ if (out.text) {
231
+ assert.ok(out.text.includes('</memory-context>'), 'the block must be closed after truncation')
232
+ for (const id of out.ids) assert.ok(out.text.includes(`[${id}]`), `id ${id} must appear in the text`)
233
+ }
234
+ })
235
+
236
+ // ---- §2.17 merge + needs_review must not overwrite the target
237
+
238
+ test('curator merge with needs_review holds a copy and keeps the original active', async () => {
239
+ state.saveGlobalIndex({
240
+ entries: { gtarget: { title: 'original', content: 'original body', domain: 'coding', status: 'active', createdAt: new Date().toISOString() } },
241
+ })
242
+ const job = { step: '' }
243
+ const out = await curator.commitCuratorOutput({
244
+ memories: [{ op: 'merge', target_id: 'gtarget', title: 'original', content: 'rewritten body', domain: 'coding', needs_review: true }],
245
+ }, { job })
246
+ assert.equal(out.pending, 1)
247
+ const index = state.loadGlobalIndex()
248
+ assert.equal(index.entries.gtarget.status, 'active')
249
+ assert.equal(index.entries.gtarget.content, 'original body')
250
+ const held = Object.values(index.entries).find((e) => e.status === 'pending_review')
251
+ assert.ok(held, 'the rewrite waits in its own pending row')
252
+ assert.notEqual(Object.keys(index.entries).find((id) => index.entries[id] === held), 'gtarget')
253
+ state.saveGlobalIndex({ entries: {} })
254
+ })
255
+
256
+ // ---- §2.20 the remember tool says when a memory is held for review
257
+
258
+ test('memu_private_remember reports a held conflict instead of plain success', async () => {
259
+ const { registerTools } = await import('../lib/tools.js')
260
+ const registered = []
261
+ const dispose = registerTools({ tools: { register(t) { registered.push(t); return () => {} } }, logger: { warn() {} } })
262
+ try {
263
+ const remember = registered.find((t) => t.name === 'memu_private_remember')
264
+ const { exec } = setup('conflict-reply')
265
+ await remember.execute({ content: '周五发布。', title: '发布日' }, exec)
266
+ const reply = await remember.execute({ content: '改到周四发布。', title: '发布日' }, exec)
267
+ assert.match(reply, /待复核|pending_review/, 'the reply must say the memory is held for review')
268
+ assert.doesNotMatch(reply, /^已写入私有记忆/)
269
+ } finally {
270
+ dispose()
271
+ }
272
+ })
@@ -0,0 +1,227 @@
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-safety-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ const state = await import('../lib/state.js')
10
+ const memory = await import('../lib/memory.js')
11
+ const inject = await import('../lib/inject.js')
12
+ const rpc = await import('../lib/rpc.js')
13
+ const paths = await import('../lib/paths.js')
14
+ const { readMood } = await import('../lib/mood.js')
15
+ const { estTokens } = await import('../lib/budget.js')
16
+
17
+ test.after(() => {
18
+ state.flushState()
19
+ fs.rmSync(home, { recursive: true, force: true })
20
+ })
21
+
22
+ function session(name, cwd = path.join(home, name)) {
23
+ const binding = state.ensureSession(name, { cwd, agentPreset: 'rp' })
24
+ return { binding, exec: { agent: { session: { header: { id: name, cwd, agentPreset: 'rp' } } } } }
25
+ }
26
+
27
+ test('a failed atomic replacement never removes the previous file', () => {
28
+ const file = path.join(home, 'atomic.json')
29
+ paths.writeJson(file, { old: true })
30
+ const rename = fs.renameSync
31
+ fs.renameSync = () => { throw Object.assign(new Error('busy'), { code: 'EBUSY' }) }
32
+ try { assert.throws(() => paths.writeJson(file, { new: true }), /busy/) } finally { fs.renameSync = rename }
33
+ assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { old: true })
34
+ assert.ok(!fs.readdirSync(home).some(name => name.includes('.tmp-')))
35
+ let attempts = 0
36
+ fs.renameSync = (...args) => {
37
+ if (++attempts <= 2) throw Object.assign(new Error('temporary Windows handle'), { code: 'EPERM' })
38
+ return rename(...args)
39
+ }
40
+ try { paths.writeJson(file, { recovered: true }) } finally { fs.renameSync = rename }
41
+ assert.equal(attempts, 3)
42
+ assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { recovered: true })
43
+ })
44
+
45
+ test('a throwing transaction cannot mutate either the cached or persisted memory', () => {
46
+ const { binding } = session('mutator')
47
+ state.updatePrivateIndex(binding.libraryId, index => { index.entries.a = { title: 'original' } })
48
+ assert.throws(() => state.updatePrivateIndex(binding.libraryId, index => {
49
+ index.entries.a.title = 'dirty'
50
+ throw new Error('refused')
51
+ }), /refused/)
52
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries.a.title, 'original')
53
+ state.clearCaches()
54
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries.a.title, 'original')
55
+ })
56
+
57
+ test('a pending state write merges external fields even when a synchronous save follows', () => {
58
+ state.flushState()
59
+ state.updateSettings({ dedupeTurns: 17 })
60
+ const file = path.join(home, 'state.json')
61
+ const external = JSON.parse(fs.readFileSync(file))
62
+ external.settings.globalTopK = 9
63
+ external.sessions['external-session'] = { libraryId: null, useLibrary: false }
64
+ fs.writeFileSync(file, JSON.stringify(external, null, 1))
65
+ state.setSessionBinding('local-session', { useGlobal: false })
66
+ state.saveState(state.loadState())
67
+ const saved = JSON.parse(fs.readFileSync(file))
68
+ assert.equal(saved.settings.dedupeTurns, 17)
69
+ assert.equal(saved.settings.globalTopK, 9)
70
+ assert.ok(saved.sessions['external-session'])
71
+ assert.ok(saved.sessions['local-session'])
72
+ })
73
+
74
+ test('state flush preserves disk-only bindings even if the cached fingerprint matches', () => {
75
+ state.flushState()
76
+ const file = path.join(home, 'state.json')
77
+ const external = JSON.parse(fs.readFileSync(file))
78
+ external.sessions['fingerprint-collision'] = { useGlobal: false }
79
+ const read = fs.readFileSync
80
+ let raced = false
81
+ state.clearCaches()
82
+ fs.readFileSync = (...args) => {
83
+ const result = read(...args)
84
+ if (String(args[0]) === file && !raced) {
85
+ raced = true
86
+ fs.writeFileSync(file, JSON.stringify(external))
87
+ }
88
+ return result
89
+ }
90
+ try { state.loadState() } finally { fs.readFileSync = read }
91
+ state.updateSettings({ dedupeTurns: 18 })
92
+ state.flushState()
93
+ assert.ok(raced)
94
+ assert.ok(JSON.parse(fs.readFileSync(file)).sessions['fingerprint-collision'])
95
+ })
96
+
97
+ test('a compact follow-up query includes only recent real user context', () => {
98
+ const previous = { role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '周五去车站找小周。' }] }
99
+ const injection = { role: 'user', source: { kind: 'plugin:dsh-memu-memory' }, content: [{ type: 'text', text: 'do not use plugin text' }] }
100
+ const current = { role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '她呢?' }] }
101
+ const query = inject.contextualQuery([previous, injection, current], current)
102
+ assert.ok(query.includes('小周'))
103
+ assert.ok(!query.includes('plugin'))
104
+ })
105
+
106
+ test('disjoint stale snapshots merge, but an older edit to the same entry is refused', () => {
107
+ const { binding } = session('snapshots')
108
+ state.savePrivateIndex(binding.libraryId, { entries: { a: { title: 'a' }, b: { title: 'b' } } })
109
+ const first = state.loadPrivateIndex(binding.libraryId)
110
+ const second = state.loadPrivateIndex(binding.libraryId)
111
+ first.entries.a.title = 'new a'
112
+ state.savePrivateIndex(binding.libraryId, first)
113
+ second.entries.b.title = 'new b'
114
+ state.savePrivateIndex(binding.libraryId, second)
115
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries.a.title, 'new a')
116
+ second.entries.a.title = 'stale a'
117
+ assert.throws(() => state.savePrivateIndex(binding.libraryId, second), /changed concurrently/)
118
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries.a.title, 'new a')
119
+ })
120
+
121
+ test('a Chinese body larger than the turn budget still supplies a closed context and its actual id', async () => {
122
+ const { exec, binding } = session('budget')
123
+ const saved = await memory.privateRemember({ exec, title: '月光', content: '月光映着杯子。'.repeat(120), source: 'imagined' })
124
+ const result = await inject.buildRecall(binding, '月光杯子', 2, { ...state.getSettings(), turnTokens: 640 }, exec)
125
+ assert.deepEqual(result.ids, [saved.id])
126
+ assert.ok(result.text.includes('source=imagined'))
127
+ assert.ok(result.text.endsWith('</memory-context>'))
128
+ assert.ok(estTokens(result.text) <= 640)
129
+ assert.equal(state.loadPrivateIndex(binding.libraryId).entries[saved.id].accessCount, 0, 'automatic candidate lookup does not reinforce unseen text')
130
+ })
131
+
132
+ test('secrets require an explicit recall and never appear in automatic recall', async () => {
133
+ const { exec, binding } = session('secret')
134
+ const saved = await memory.privateRemember({ exec, title: '藏钥匙', content: '钥匙在绿色花盆下。', visibility: 'secret' })
135
+ assert.deepEqual((await memory.privateRecall({ exec, query: '钥匙', purpose: 'automatic' })).files, [])
136
+ assert.ok(!(await inject.buildRecall(binding, '钥匙', 2, state.getSettings(), exec)).text.includes(saved.id))
137
+ assert.equal((await memory.privateRecall({ exec, query: saved.id })).files[0].name, saved.id)
138
+ })
139
+
140
+ test('lists and edits enforce the context boundary even with an explicit library id', async () => {
141
+ const a = session('owner-a')
142
+ const b = session('owner-b', path.join(home, 'owner-a'))
143
+ // Both conversations live in the same folder (one library); what separates them is the
144
+ // scope, which is the only thing left that can separate them.
145
+ state.setSessionBinding('owner-a', { memoryContext: { characterId: 'hero' } })
146
+ state.setSessionBinding('owner-b', { memoryContext: { characterId: 'villain' } })
147
+ const saved = await memory.privateRemember({ exec: a.exec, title: '独处', content: '只属于英雄线的片段。' })
148
+ assert.equal((await memory.privateList({ exec: b.exec })).files.length, 0)
149
+ await assert.rejects(memory.privateUpdate({ exec: b.exec, libraryId: a.binding.libraryId, id: saved.id, visibility: 'secret' }), /not visible/)
150
+ assert.equal(state.loadPrivateIndex(a.binding.libraryId).entries[saved.id].visibility, 'shared', 'an out-of-scope edit changes nothing')
151
+ state.setSessionBinding('owner-b', { useLibrary: false })
152
+ await assert.rejects(memory.privateList({ exec: b.exec }), /turned off/)
153
+ })
154
+
155
+ test('a read on an unbound conversation does not silently create a library', async () => {
156
+ const before = state.listLibrariesAll().length
157
+ const exec = { agent: { session: { header: { id: 'never-bound', cwd: path.join(home, 'new') } } } }
158
+ await assert.rejects(memory.privateList({ exec }), /no bound session/)
159
+ assert.equal(state.listLibrariesAll().length, before)
160
+ })
161
+
162
+ test('review acceptance replaces the old private fact in the same write and keeps its history', async () => {
163
+ const { exec, binding } = session('review')
164
+ const old = await memory.privateRemember({ exec, title: '会面地点', content: '明天在学校门口见面。' })
165
+ const next = await memory.privateRemember({ exec, title: '会面地点', content: '明天改在市中心图书馆见面。' })
166
+ await assert.rejects(memory.privateUpdate({ exec, id: next.id, status: 'active' }), /require review/)
167
+ await rpc.dispatch({}, 'resolveReview', { libraryId: binding.libraryId, id: next.id, action: 'accept' })
168
+ const index = state.loadPrivateIndex(binding.libraryId)
169
+ assert.equal(index.entries[old.id].status, 'superseded')
170
+ assert.equal(index.entries[old.id].content, old.entry.content)
171
+ assert.equal(index.entries[old.id].validTo, index.entries[next.id].validFrom)
172
+ const recalled = await memory.privateRecall({ exec, query: '会面地点', topK: 10, relevanceThreshold: 0 })
173
+ assert.deepEqual(recalled.files.map(file => file.name), [next.id])
174
+ })
175
+
176
+ test('review coexistence needs conditions, and global acceptance retires the conflicting fact', async () => {
177
+ state.saveGlobalIndex({ entries: {
178
+ old: { title: 'old', content: 'old', status: 'active', domain: 'coding' },
179
+ next: { title: 'new', content: 'new', status: 'pending_review', domain: 'coding', conflictingWith: 'old' },
180
+ } })
181
+ await assert.rejects(rpc.dispatch({}, 'resolveReview', { id: 'next', action: 'accept', resolution: 'coexist' }), /needs a reason/)
182
+ await rpc.dispatch({}, 'resolveReview', { id: 'next', action: 'accept' })
183
+ assert.equal(state.loadGlobalIndex().entries.old.status, 'superseded')
184
+ state.saveGlobalIndex({ entries: {} })
185
+ })
186
+
187
+ test('global list obeys switches, status and conversation domain', async () => {
188
+ const { exec } = session('global-gate')
189
+ state.setSessionBinding('global-gate', { domain: 'rp' })
190
+ state.saveGlobalIndex({ entries: {
191
+ coding: { title: 'coding', domain: 'coding', status: 'active' },
192
+ rp: { title: 'rp', domain: 'rp', status: 'active' },
193
+ held: { title: 'held', domain: 'rp', status: 'pending_review' },
194
+ } })
195
+ assert.deepEqual((await memory.globalList({ exec })).files.map(file => file.name), ['rp'])
196
+ state.setSessionBinding('global-gate', { useGlobal: false })
197
+ await assert.rejects(memory.globalList({ exec }), /turned off/)
198
+ state.saveGlobalIndex({ entries: {} })
199
+ })
200
+
201
+ test('anniversaries do not revive superseded or overdue drafts', () => {
202
+ const { binding } = session('anniversary')
203
+ state.savePrivateIndex(binding.libraryId, { entries: {
204
+ old: { title: 'old', salientDate: '10-28', status: 'superseded' },
205
+ due: { title: 'due', salientDate: '10-28', status: 'active', expiresAt: '2026-10-27T00:00:00Z' },
206
+ } })
207
+ assert.deepEqual(inject.buildAnniversary('anniversary', binding, new Date(2026, 9, 28)).ids, [])
208
+ })
209
+
210
+ test('Chinese negation does not turn sadness into a happy retrieval lens', () => {
211
+ assert.ok(readMood('我今天不开心').valence < 0)
212
+ assert.ok(readMood('我并不高兴').valence <= 0)
213
+ assert.ok(readMood('他说“开心极了”,但我没有这种感觉。').valence <= 0)
214
+ })
215
+
216
+ test('domestic texture is once per day and band, and disabled for coding', () => {
217
+ session('texture')
218
+ const random = Math.random
219
+ Math.random = () => 1
220
+ try {
221
+ assert.ok(inject.maybeTexture('texture', { habitsInjection: true }, new Date(2026, 9, 27, 10)))
222
+ assert.equal(inject.maybeTexture('texture', { habitsInjection: true }, new Date(2026, 9, 27, 10)), null)
223
+ assert.ok(inject.maybeTexture('texture', { habitsInjection: true }, new Date(2026, 9, 28, 10)))
224
+ state.setSessionBinding('texture', { domainOverride: 'coding' })
225
+ assert.equal(inject.maybeTexture('texture', { habitsInjection: true }, new Date(2026, 9, 29, 10)), null)
226
+ } finally { Math.random = random }
227
+ })