dsh-memu-memory 1.1.2 → 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 (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,232 @@
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-human-'))
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
+
15
+ test.after(() => {
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ function execFor(sessionId, cwd) {
20
+ return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
21
+ }
22
+
23
+ function ageEntry(libraryId, id, days) {
24
+ const index = state.loadPrivateIndex(libraryId)
25
+ const at = new Date(Date.now() - days * 86_400_000).toISOString()
26
+ index.entries[id].updatedAt = at
27
+ index.entries[id].createdAt = at
28
+ state.savePrivateIndex(libraryId, index)
29
+ }
30
+
31
+ test('relative time reads like a person, not like a timestamp', () => {
32
+ const now = Date.parse('2026-10-27T12:00:00.000Z')
33
+ const ago = (ms) => new Date(now - ms).toISOString()
34
+ assert.equal(memory.relativeTime(ago(30_000), now), '刚刚')
35
+ assert.equal(memory.relativeTime(ago(5 * 60_000), now), '5 分钟前')
36
+ assert.equal(memory.relativeTime(ago(3 * 3_600_000), now), '3 小时前')
37
+ assert.equal(memory.relativeTime(ago(2 * 86_400_000), now), '2 天前')
38
+ assert.equal(memory.relativeTime(ago(40 * 86_400_000), now), '1 个月前')
39
+ // An unparseable stamp must not paint "NaN 天前" into the prompt.
40
+ assert.equal(memory.relativeTime('not a date', now), '')
41
+ })
42
+
43
+ test('the injected line carries how old the memory is', async () => {
44
+ const cwd = path.join(home, 'proj-age')
45
+ const sessionId = 'age-1'
46
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
47
+ const exec = execFor(sessionId, cwd)
48
+ const { id } = await memory.privateRemember({
49
+ exec, content: 'The rollback drill runs every Friday afternoon.', title: 'rollback drill',
50
+ })
51
+ ageEntry(binding.libraryId, id, 4)
52
+ const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
53
+ assert.ok(anchor.text.includes('4 天前'), anchor.text)
54
+ })
55
+
56
+ test('near-duplicate memories stop eating the recall budget', async () => {
57
+ const cwd = path.join(home, 'proj-mmr')
58
+ const sessionId = 'mmr-1'
59
+ const exec = execFor(sessionId, cwd)
60
+ const written = []
61
+ for (const [title, content] of [
62
+ ['hero plan', 'pricing page rewrite plan for the landing hero'],
63
+ ['hero plan b', 'pricing page rewrite plan for the landing hero section'],
64
+ ['hero plan c', 'pricing page rewrite plan for the landing hero area'],
65
+ ['pricing doc', 'pricing page rewrite plan is written down in docs/pricing.md'],
66
+ ]) {
67
+ const { id } = await memory.privateRemember({ exec, content, title })
68
+ written.push(id)
69
+ }
70
+ const result = await memory.privateRecall({
71
+ exec, query: 'pricing page rewrite plan', topK: 2, relevanceThreshold: 0,
72
+ })
73
+ const names = result.files.map((file) => file.name)
74
+ assert.equal(names.length, 2)
75
+ assert.ok(names.includes(written[3]), `the distinct memory must survive the near-duplicates, got ${names.join(',')}`)
76
+ })
77
+
78
+ test('a memory that keeps being pushed fades, and editing it brings it back', async () => {
79
+ const cwd = path.join(home, 'proj-habit')
80
+ const sessionId = 'habit-1'
81
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
82
+ const exec = execFor(sessionId, cwd)
83
+ const { id } = await memory.privateRemember({
84
+ exec, content: 'Deploy window notes: staging turns over at noon.', title: 'deploy window',
85
+ })
86
+ const scoreOf = async () => {
87
+ const res = await memory.privateRecall({ exec, query: 'deploy window notes', topK: 3, relevanceThreshold: 0 })
88
+ const hit = res.files.find((file) => file.name === id)
89
+ return hit ? hit.score : 0
90
+ }
91
+ const fresh = await scoreOf()
92
+ memory.noteInjected({ libraryId: binding.libraryId, ids: [id] })
93
+ memory.noteInjected({ libraryId: binding.libraryId, ids: [id] })
94
+ const dulled = await scoreOf()
95
+ assert.ok(dulled < fresh, `repeated injection should lower the score (${fresh} -> ${dulled})`)
96
+
97
+ // Rewriting the body is the signal that this memory is worth keeping current.
98
+ await memory.privateUpdate({ exec, id, content: 'Deploy window notes: staging turns over at noon, prod at six.' })
99
+ const refreshed = await scoreOf()
100
+ assert.ok(refreshed > dulled, `an edit must clear the habituation (${dulled} -> ${refreshed})`)
101
+ })
102
+
103
+ test('a context-scoped memory stays inside its character until the user widens it', async () => {
104
+ const cwd = path.join(home, 'proj-scope')
105
+ const mine = 'scope-a'
106
+ const other = 'scope-b'
107
+ const binding = state.ensureSession(mine, { cwd, agentPreset: 'coding' })
108
+ const execMine = execFor(mine, cwd)
109
+ const execOther = execFor(other, cwd)
110
+ state.ensureSession(other, { cwd, agentPreset: 'coding' })
111
+ // Both conversations live in one folder. The only thing that may separate them is the
112
+ // scope: `session` used to, and it did so by making the memory disappear for everyone.
113
+ state.setSessionBinding(mine, { memoryContext: { characterId: 'hero' } })
114
+ state.setSessionBinding(other, { memoryContext: { characterId: 'villain' } })
115
+
116
+ const { id } = await memory.privateRemember({
117
+ exec: execMine,
118
+ content: 'The seal number for this thread is 4471.',
119
+ title: 'seal number',
120
+ })
121
+ const seenByMine = await memory.privateRecall({ exec: execMine, query: 'seal number', topK: 3, relevanceThreshold: 0 })
122
+ assert.ok(seenByMine.files.some((file) => file.name === id), 'the writing conversation must still see it')
123
+
124
+ const seenByOther = await memory.privateRecall({ exec: execOther, query: 'seal number', topK: 3, relevanceThreshold: 0 })
125
+ assert.equal(seenByOther.files.some((file) => file.name === id), false, 'another character must not see it')
126
+ const anchorForOther = inject.buildAnchor(state.getSessionBinding(other), { ...state.getSettings(), anchorTokens: 700 }, other)
127
+ assert.equal(anchorForOther.text.includes(id), false)
128
+
129
+ // Only the user can widen a stored boundary; the memory wall has no conversation and
130
+ // therefore no scope to defend. Widening drops the pointer, it does not copy the entry.
131
+ await rpc.dispatch({}, 'updateMemory', { libraryId: binding.libraryId, id, patch: { context: {} } })
132
+ assert.deepEqual(state.loadPrivateIndex(binding.libraryId).entries[id].context, {})
133
+ const seenAgain = await memory.privateRecall({ exec: execOther, query: 'seal number', topK: 3, relevanceThreshold: 0 })
134
+ assert.ok(seenAgain.files.some((file) => file.name === id), 'once the user widens it, the folder can read it')
135
+ })
136
+
137
+ test('an old open loop is raised once, then left alone', async () => {
138
+ const cwd = path.join(home, 'proj-loop')
139
+ const sessionId = 'loop-1'
140
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
141
+ const exec = execFor(sessionId, cwd)
142
+ const { id } = await memory.privateRemember({
143
+ exec,
144
+ content: 'He asked whether the salary review letter ever went out; I never answered.',
145
+ title: 'salary review letter',
146
+ openLoop: true,
147
+ })
148
+ ageEntry(binding.libraryId, id, 6)
149
+
150
+ const first = inject.buildOpenLoopNudge(state.getSessionBinding(sessionId))
151
+ assert.ok(first.text.includes('[open loop]'), first.text)
152
+ assert.ok(first.text.includes(id))
153
+ assert.ok(first.text.includes('没下文了'))
154
+
155
+ state.markInjected(sessionId, [id], 3)
156
+ const second = inject.buildOpenLoopNudge(state.getSessionBinding(sessionId))
157
+ assert.equal(second.text, '', 'once it has been said, it stops being a nudge')
158
+ })
159
+
160
+ test('compaction replay replays gists, never whole bodies, and skips secrets', async () => {
161
+ const cwd = path.join(home, 'proj-replay')
162
+ const sessionId = 'replay-1'
163
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
164
+ const exec = execFor(sessionId, cwd)
165
+ const long = 'X'.repeat(400)
166
+ const { id: plain } = await memory.privateRemember({
167
+ exec, content: `BODY-START ${long}`, title: 'long one',
168
+ })
169
+ const { id: secret } = await memory.privateRemember({
170
+ exec, content: 'SECRET-BODY that must stay out of the prompt', title: 'secret one', visibility: 'secret',
171
+ })
172
+ state.setSessionBinding(sessionId, { replayIds: [plain, secret] })
173
+ const replay = inject.buildReplay(state.getSessionBinding(sessionId), state.getSettings(), sessionId)
174
+ assert.ok(replay.text.includes('[replay'), replay.text)
175
+ assert.ok(replay.text.includes(plain))
176
+ assert.equal(replay.text.includes('X'.repeat(120)), false, 'a replay is a gist, not the body')
177
+ assert.equal(replay.text.includes(secret), false)
178
+ assert.equal(replay.text.includes('SECRET-BODY'), false)
179
+ assert.ok(binding.libraryId)
180
+ })
181
+
182
+ test('import restores what can travel and reports what cannot', async () => {
183
+ const cwd = path.join(home, 'proj-import')
184
+ const sessionId = 'import-1'
185
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
186
+ const exec = execFor(sessionId, cwd)
187
+ // Already here: the duplicate check has to look at the target library, not just the file.
188
+ await memory.privateRemember({ exec, content: 'pricing thread body about the hero', title: 'pricing thread' })
189
+
190
+ const result = await memory.importMemories({
191
+ libraryId: binding.libraryId,
192
+ memories: [
193
+ { id: 'old-1', title: 'pricing thread', content: 'pricing thread body about the hero', layer: 'long_term' },
194
+ { id: 'old-2', title: 'pricing thread copy', content: 'pricing thread body about the hero' },
195
+ { id: 'old-3', title: 'archived one', content: 'dead content', status: 'archived' },
196
+ { id: 'old-4', title: 'session note', content: 'session bound body', visibility: 'session' },
197
+ { id: 'old-5', content: ' ' },
198
+ { id: 'old-6', title: 'draft', content: 'draft body that expires', layer: 'short_term', expiresAt: new Date(Date.now() + 3_600_000).toISOString() },
199
+ { id: 'old-7', title: 'unique', content: 'a body nobody here has ever written' },
200
+ ],
201
+ })
202
+
203
+ assert.equal(result.imported, 3, JSON.stringify(result.counts))
204
+ assert.equal(result.counts.duplicate, 2)
205
+ assert.equal(result.counts.dead, 1)
206
+ assert.equal(result.counts.invalid, 1)
207
+ // A `session` entry from an older export lands as `shared` with the same marker a stored
208
+ // entry gets: dropping it silently would be the very data loss this upgrade prevents,
209
+ // and the report still has to say that its reader went from one conversation to a folder.
210
+ assert.equal(result.counts.downgraded, 1)
211
+
212
+ const index = state.loadPrivateIndex(binding.libraryId)
213
+ const entries = Object.entries(index.entries)
214
+ const restored = entries.filter(([, entry]) => entry.importedFrom)
215
+ assert.equal(restored.length, 3)
216
+ const kept = state.loadPrivateIndex(binding.libraryId).entries
217
+ const fromSession = Object.values(kept).find((entry) => entry.importedFrom === 'old-4')
218
+ assert.equal(fromSession.visibility, 'shared', 'the removed level has to come back as shared')
219
+ assert.equal(fromSession.migratedFromVisibility, 'session')
220
+ assert.equal('sessionId' in fromSession, false)
221
+ assert.ok(result.examples.downgraded.some((line) => line.includes('session note')), 'the report has to name what changed level')
222
+ assert.ok(result.examples.downgraded.some((line) => line.includes('session -> shared')))
223
+ const draft = restored.find(([, entry]) => entry.importedFrom === 'old-6')
224
+ assert.equal(draft[1].layer, 'short_term')
225
+ const hours = (Date.parse(draft[1].expiresAt) - Date.now()) / 3_600_000
226
+ assert.ok(hours > 0 && hours <= 1, `the earlier deadline wins, got ${hours}h`)
227
+ const unique = restored.find(([, entry]) => entry.importedFrom === 'old-7')
228
+ assert.ok(unique[1].content.includes('a body nobody here has ever written'), 'the body has to survive the restore')
229
+
230
+ const recall = await memory.privateRecall({ exec, query: 'body nobody ever written', topK: 3, relevanceThreshold: 0 })
231
+ assert.ok(recall.files.some((file) => file.name === unique[0]), 'a restored memory must be retrievable')
232
+ })
@@ -0,0 +1,92 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ const memory = await import('../lib/memory.js')
5
+
6
+ function entry(overrides = {}) {
7
+ return { status: 'active', title: 'memory', content: 'memory body', createdAt: '2026-10-01T00:00:00.000Z', updatedAt: '2026-10-01T00:00:00.000Z', ...overrides }
8
+ }
9
+
10
+ function indexOf(entries) {
11
+ return { entries }
12
+ }
13
+
14
+ test('RRF keeps one canonical id when both routes find it and preserves the best order', () => {
15
+ const index = indexOf({
16
+ 'mem-a': entry({ title: 'alpha' }),
17
+ 'mem-b': entry({ title: 'beta' }),
18
+ 'mem-c': entry({ title: 'gamma' }),
19
+ })
20
+ const fused = memory.fuseRecallCandidates({
21
+ local: [{ name: 'mem-a' }, { name: 'mem-b' }],
22
+ semantic: [{ name: 'mem-b.md' }, { name: 'mem-c' }],
23
+ index,
24
+ limit: 12,
25
+ })
26
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-b', 'mem-a', 'mem-c'])
27
+ assert.equal(fused.files[0].retrievalScore, 1)
28
+ assert.deepEqual(fused.files[0].fusion.routes, ['local', 'semantic'])
29
+ assert.equal(fused.debug.shared, 1)
30
+ assert.equal(fused.debug.localOnly, 1)
31
+ assert.equal(fused.debug.semanticOnly, 1)
32
+ assert.equal(fused.debug.fused, 3)
33
+ })
34
+
35
+ test('duplicate names within one route cannot double-score or consume a candidate slot', () => {
36
+ const index = indexOf({ 'mem-a': entry({ title: 'alpha' }), 'mem-b': entry({ title: 'beta' }) })
37
+ const fused = memory.fuseRecallCandidates({
38
+ local: [{ name: 'mem-a' }, { name: 'mem-a.md' }, { name: 'mem-b' }],
39
+ semantic: [],
40
+ index,
41
+ limit: 2,
42
+ })
43
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-a', 'mem-b'])
44
+ assert.equal(fused.debug.localCandidates, 2)
45
+ const first = fused.files.find((file) => file.name === 'mem-a')
46
+ assert.equal(first.fusion.rrf, 1 / 61)
47
+ assert.equal(first.fusion.bestRank, 1)
48
+ assert.equal(first.fusion.localRank, 1)
49
+ })
50
+
51
+ test('eligibility and context are applied before the candidate cap', () => {
52
+ const index = indexOf({
53
+ 'mem-pending': entry({ status: 'pending_review' }),
54
+ 'mem-secret': entry({ visibility: 'secret' }),
55
+ 'mem-ok': entry({ title: 'ok' }),
56
+ })
57
+ const fused = memory.fuseRecallCandidates({
58
+ local: [{ name: 'mem-pending' }, { name: 'mem-secret' }, { name: 'mem-ok' }],
59
+ semantic: [{ name: 'mem-secret' }],
60
+ index,
61
+ limit: 12,
62
+ automatic: true,
63
+ })
64
+ assert.deepEqual(fused.files.map((file) => file.name), ['mem-ok'])
65
+ assert.equal(fused.debug.localCandidates, 1)
66
+ assert.equal(fused.debug.semanticCandidates, 0)
67
+ })
68
+
69
+ test('the per-route candidate cap is bounded and deterministic', () => {
70
+ const entries = {}
71
+ const local = []
72
+ for (let i = 0; i < 60; i += 1) {
73
+ const id = 'mem-' + String(i).padStart(2, '0')
74
+ entries[id] = entry({ title: 'memory ' + i })
75
+ local.push({ name: id })
76
+ }
77
+ assert.equal(memory.hybridCandidateLimit(1), 12)
78
+ assert.equal(memory.hybridCandidateLimit(3), 12)
79
+ assert.equal(memory.hybridCandidateLimit(8), 32)
80
+ assert.equal(memory.hybridCandidateLimit(50), 40)
81
+ const fused = memory.fuseRecallCandidates({ local, semantic: [], index: indexOf(entries), limit: memory.hybridCandidateLimit(3) })
82
+ assert.equal(fused.files.length, 12)
83
+ assert.deepEqual(fused.files.map((file) => file.name), local.slice(0, 12).map((file) => file.name).sort((a, b) => a.localeCompare(b)))
84
+ })
85
+
86
+ test('equal fused scores fall back to the best rank and then the canonical id', () => {
87
+ const index = indexOf({ 'mem-a': entry(), 'mem-b': entry() })
88
+ const first = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
89
+ const second = memory.fuseRecallCandidates({ local: [{ name: 'mem-a' }], semantic: [{ name: 'mem-b' }], index })
90
+ assert.deepEqual(first.files.map((file) => file.name), ['mem-a', 'mem-b'])
91
+ assert.deepEqual(first.files.map((file) => file.name), second.files.map((file) => file.name))
92
+ })