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,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
+ import { registerHooks } from 'node:module'
7
+
8
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-backend-compensation-'))
9
+ process.env.DSH_MEMU_HOME = home
10
+ process.env.DSH_MEMU_MEMORY_HOME = home
11
+
12
+ const calls = []
13
+ let commitHook = null
14
+ let commitResult = null
15
+ let forgetHook = null
16
+ let vp
17
+
18
+ const backend = {
19
+ async commit(args) {
20
+ calls.push({ op: 'commit', ...args })
21
+ if (commitHook) await commitHook(args)
22
+ return commitResult || { ok: true }
23
+ },
24
+ async retrieve(args) {
25
+ calls.push({ op: 'retrieve', ...args })
26
+ return { files: [], segments: [], resources: [], raw: null }
27
+ },
28
+ async forget(args) {
29
+ calls.push({ op: 'forget', ...args })
30
+ if (forgetHook) return forgetHook(args)
31
+ return { deleted: 1 }
32
+ },
33
+ async listFiles() {
34
+ throw new Error('unexpected list engine call')
35
+ },
36
+ }
37
+
38
+ globalThis.__backendCompensationTest = backend
39
+ const engineSource = 'const bridge = globalThis.__backendCompensationTest; export const commit = bridge.commit, retrieve = bridge.retrieve, forget = bridge.forget, listFiles = bridge.listFiles;'
40
+ const hooks = registerHooks({
41
+ resolve(specifier, context, nextResolve) {
42
+ if (specifier === './memu.js') {
43
+ return { url: 'data:text/javascript,' + encodeURIComponent(engineSource), shortCircuit: true }
44
+ }
45
+ return nextResolve(specifier, context)
46
+ },
47
+ })
48
+
49
+ const state = await import('../lib/state.js')
50
+ vp = await import('../lib/vector-profile.js')
51
+ const paths = await import('../lib/paths.js')
52
+ const memory = await import('../lib/memory.js')
53
+ const profile = vp.profileFromCatalog('bge-small-zh-v1.5-int8')
54
+ const activeFile = vp.vectorActiveFile()
55
+ let serial = 0
56
+
57
+ function entry(content, extra = {}) {
58
+ return {
59
+ status: 'active',
60
+ title: content,
61
+ content,
62
+ layer: 'long_term',
63
+ strength: 1,
64
+ createdAt: new Date().toISOString(),
65
+ updatedAt: new Date().toISOString(),
66
+ ...extra,
67
+ }
68
+ }
69
+
70
+ function fixture(entries = {}) {
71
+ serial += 1
72
+ const sessionId = 'backend-compensation-' + serial
73
+ const cwd = path.join(home, 'project-' + serial)
74
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
75
+ state.setSessionBinding(sessionId, { useLibrary: true, useGlobal: true, domainOverride: 'coding' })
76
+ state.savePrivateIndex(binding.libraryId, { entries })
77
+ return {
78
+ sessionId,
79
+ libraryId: binding.libraryId,
80
+ exec: { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } },
81
+ }
82
+ }
83
+
84
+ function activate(libraryIds = []) {
85
+ const databases = vp.buildDatabaseSet({ profile, generation: 1, libraryIds })
86
+ fs.mkdirSync(path.join(home, 'libraries'), { recursive: true })
87
+ for (const row of [databases.global, ...Object.values(databases.libraries)]) {
88
+ fs.writeFileSync(vp.resolveVectorPath(row.path), '')
89
+ }
90
+ vp.updateVectorStateSync(current => ({
91
+ ...current,
92
+ generation: 1,
93
+ requested: null,
94
+ pending: null,
95
+ active: { status: 'active', generation: 1, fingerprint: profile.fingerprint, profile, databases },
96
+ }))
97
+ return databases
98
+ }
99
+
100
+ test.beforeEach(() => {
101
+ fs.rmSync(activeFile, { force: true })
102
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local', relevanceThreshold: 0 })
103
+ state.saveGlobalIndex({ entries: {} })
104
+ calls.length = 0
105
+ commitHook = null
106
+ commitResult = null
107
+ forgetHook = null
108
+ paths.setWarnSink(() => {})
109
+ })
110
+
111
+ test.after(() => {
112
+ hooks.deregister()
113
+ delete globalThis.__backendCompensationTest
114
+ paths.setWarnSink(null)
115
+ fs.rmSync(home, { recursive: true, force: true })
116
+ })
117
+
118
+ test('remember commits a real backend row, then forgets it after a failed index CAS', async () => {
119
+ const f = fixture()
120
+ activate([f.libraryId])
121
+ commitHook = async () => state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
122
+
123
+ await assert.rejects(
124
+ memory.privateRemember({ exec: f.exec, content: 'new backend fact', title: 'new backend title' }),
125
+ error => error?.message === 'memory scope changed during write; retry',
126
+ )
127
+
128
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget'])
129
+ assert.equal(calls[0].route, 'managed-local')
130
+ assert.equal(calls[0].db, vp.dbPathFor(f.libraryId))
131
+ assert.equal(calls[0].payload.recall_files[0].name.startsWith('mem-'), true)
132
+ assert.equal(calls[0].payload.recall_files[0].content, 'new backend fact')
133
+ assert.deepEqual(calls[1], { op: 'forget', route: 'managed-local', db: vp.dbPathFor(f.libraryId), name: calls[0].payload.recall_files[0].name })
134
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries, {})
135
+ })
136
+
137
+ test('update commits a new row, then forgets and restores the authoritative old payload', async () => {
138
+ const old = entry('old authoritative fact', { title: 'old authoritative title', track: 'habit' })
139
+ const f = fixture({ 'mem-live': old })
140
+ activate([f.libraryId])
141
+ commitHook = async () => state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
142
+
143
+ await assert.rejects(
144
+ memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'new backend fact', title: 'new backend title' }),
145
+ error => error?.message === 'memory scope changed during update; retry',
146
+ )
147
+
148
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget', 'commit'])
149
+ assert.equal(calls[0].payload.recall_files[0].content, 'new backend fact')
150
+ assert.equal(calls[0].payload.recall_files[0].description, 'new backend title')
151
+ assert.deepEqual(calls[1], { op: 'forget', route: 'managed-local', db: vp.dbPathFor(f.libraryId), name: 'mem-live' })
152
+ assert.equal(calls[2].route, 'managed-local')
153
+ assert.equal(calls[2].db, vp.dbPathFor(f.libraryId))
154
+ assert.deepEqual(calls[2].payload.recall_files[0], {
155
+ name: 'mem-live',
156
+ track: 'habit',
157
+ description: 'old authoritative title',
158
+ content: 'old authoritative fact',
159
+ })
160
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries['mem-live'], old)
161
+ })
162
+
163
+ test('uncertain delete acknowledgement exposes compensation failure and preserves the original CAS cause', async () => {
164
+ const f = fixture()
165
+ activate([f.libraryId])
166
+ commitHook = async () => state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
167
+ forgetHook = () => ({ ok: false })
168
+
169
+ await assert.rejects(
170
+ memory.privateRemember({ exec: f.exec, content: 'delete acknowledgement is uncertain' }),
171
+ error => error?.code === 'PRIVATE_BACKEND_COMPENSATION_FAILED'
172
+ && /backend compensation delete was not acknowledged/.test(error.message)
173
+ && error.cause?.message === 'memory scope changed during write; retry',
174
+ )
175
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget'])
176
+ })
177
+
178
+ test('uncertain restore acknowledgement exposes compensation failure and preserves the original CAS cause', async () => {
179
+ const old = entry('restore me', { title: 'restore title' })
180
+ const f = fixture({ 'mem-live': old })
181
+ activate([f.libraryId])
182
+ let commits = 0
183
+ commitHook = async () => {
184
+ commits += 1
185
+ if (commits === 1) state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
186
+ else commitResult = { parseWarning: 'restore response was not parseable' }
187
+ }
188
+ forgetHook = () => ({ deleted: 1 })
189
+
190
+ try {
191
+ await assert.rejects(
192
+ memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'replacement' }),
193
+ error => error?.code === 'PRIVATE_BACKEND_COMPENSATION_FAILED'
194
+ && /backend compensation restore was not acknowledged/.test(error.message)
195
+ && error.cause?.message === 'memory scope changed during update; retry',
196
+ )
197
+ } finally {
198
+ commitResult = null
199
+ }
200
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget', 'commit'])
201
+ })
202
+
203
+ test('metadata changed before compensation refuses repair instead of weakening the expectedEntry guard', async () => {
204
+ const old = entry('authoritative body', { title: 'authoritative title' })
205
+ const f = fixture({ 'mem-live': old })
206
+ activate([f.libraryId])
207
+ commitHook = async () => {
208
+ state.updatePrivateIndex(f.libraryId, fresh => {
209
+ fresh.entries['mem-live'].title = 'concurrent metadata winner'
210
+ })
211
+ state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
212
+ }
213
+
214
+ await assert.rejects(
215
+ memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'replacement' }),
216
+ error => error?.code === 'PRIVATE_BACKEND_COMPENSATION_FAILED'
217
+ && /memory changed before backend compensation/.test(error.message)
218
+ && error.cause?.message === 'memory changed concurrently; reload and retry',
219
+ )
220
+ assert.deepEqual(calls.map(call => call.op), ['commit'])
221
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries['mem-live'].title, 'concurrent metadata winner')
222
+ })
223
+
224
+ test('same-library managed writes are serialized through the backend queue', async () => {
225
+ const f = fixture()
226
+ activate([f.libraryId])
227
+ let active = 0
228
+ let maximum = 0
229
+ let commits = 0
230
+ let entered
231
+ let release
232
+ const firstEntered = new Promise(resolve => { entered = resolve })
233
+ const gate = new Promise(resolve => { release = resolve })
234
+ commitHook = async () => {
235
+ commits += 1
236
+ active += 1
237
+ maximum = Math.max(maximum, active)
238
+ if (commits === 1) {
239
+ entered()
240
+ await gate
241
+ }
242
+ active -= 1
243
+ }
244
+
245
+ const first = memory.privateRemember({ exec: f.exec, content: 'first queued fact' })
246
+ await firstEntered
247
+ const second = memory.privateRemember({ exec: f.exec, content: 'second queued fact' })
248
+ await Promise.resolve()
249
+ assert.equal(calls.filter(call => call.op === 'commit').length, 1)
250
+ release()
251
+ await Promise.all([first, second])
252
+ assert.equal(maximum, 1)
253
+ assert.equal(calls.filter(call => call.op === 'commit').length, 2)
254
+ })
255
+
256
+ test('a rejected write does not poison the same-library queue', async () => {
257
+ const f = fixture()
258
+ activate([f.libraryId])
259
+ let commits = 0
260
+ commitHook = async () => {
261
+ commits += 1
262
+ if (commits === 1) state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
263
+ }
264
+
265
+ await assert.rejects(memory.privateRemember({ exec: f.exec, content: 'first rejected fact' }), /memory scope changed during write; retry/)
266
+ state.setSessionBinding(f.sessionId, { libraryId: f.libraryId, useLibrary: true, useGlobal: true })
267
+ const second = await memory.privateRemember({ exec: f.exec, content: 'second healthy fact' })
268
+
269
+ assert.ok(second.id)
270
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget', 'commit'])
271
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries[second.id].content, 'second healthy fact')
272
+ })