dsh-memu-memory 1.1.3 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +39 -6
  3. package/README.zh.md +39 -7
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +3 -3
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +8 -0
  9. package/docs/host-api.md +2 -2
  10. package/docs/memory-semantics.md +12 -10
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +396 -395
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +23 -6
  15. package/lib/binding-diagnostics.js +8 -3
  16. package/lib/client.js +335 -35
  17. package/lib/consolidation-ledger.js +347 -0
  18. package/lib/consolidation-receipt.js +284 -0
  19. package/lib/consolidation-snapshot.js +274 -0
  20. package/lib/consolidation-worker.js +144 -0
  21. package/lib/curator.js +86 -31
  22. package/lib/cycle.js +40 -6
  23. package/lib/graph.js +139 -5
  24. package/lib/index.js +33 -1
  25. package/lib/inject.js +104 -76
  26. package/lib/local-embedding-manager.js +24 -6
  27. package/lib/memory.js +534 -88
  28. package/lib/mood.js +268 -39
  29. package/lib/preset-scope.js +1 -1
  30. package/lib/private.js +430 -43
  31. package/lib/prompts/private-consolidation.coding.md +1 -0
  32. package/lib/prompts/private-consolidation.md +1 -0
  33. package/lib/prompts/private-consolidation.mixed.md +1 -0
  34. package/lib/prompts/private-consolidation.rp.md +1 -0
  35. package/lib/prompts.js +4 -4
  36. package/lib/remote-summary.js +51 -25
  37. package/lib/rpc.js +54 -5
  38. package/lib/state.js +29 -12
  39. package/lib/text-similarity.js +106 -0
  40. package/lib/tools.js +113 -10
  41. package/lib/vector-migration.js +28 -3
  42. package/lib/vector-profile.js +51 -0
  43. package/package.json +4 -2
  44. package/scripts/acceptance-local.mjs +2 -1
  45. package/scripts/active-memory-ab-live.mjs +655 -0
  46. package/scripts/active-memory-ab.mjs +1302 -0
  47. package/scripts/chain-matrix.mjs +11 -1
  48. package/scripts/check-all.mjs +10 -1
  49. package/scripts/replay.mjs +573 -0
  50. package/tests/activation.test.mjs +89 -0
  51. package/tests/active-memory-ab.test.mjs +745 -0
  52. package/tests/active-memory.test.mjs +129 -0
  53. package/tests/activity-log.test.mjs +126 -0
  54. package/tests/activity-wiring.test.mjs +103 -0
  55. package/tests/appendix-b.test.mjs +216 -0
  56. package/tests/appendix-b2.test.mjs +166 -0
  57. package/tests/audit-regression.test.mjs +272 -0
  58. package/tests/audit-safety.test.mjs +227 -0
  59. package/tests/backend-compensation.test.mjs +272 -0
  60. package/tests/backup.test.mjs +793 -0
  61. package/tests/binding-diagnostics.test.mjs +720 -0
  62. package/tests/browser.test.mjs +366 -0
  63. package/tests/client-render.test.mjs +892 -0
  64. package/tests/coalesce.test.mjs +113 -0
  65. package/tests/config.test.mjs +159 -0
  66. package/tests/consolidation-credential.test.mjs +331 -0
  67. package/tests/consolidation-feed.test.mjs +96 -0
  68. package/tests/consolidation-fork.test.mjs +489 -0
  69. package/tests/consolidation-ledger.test.mjs +298 -0
  70. package/tests/consolidation-snapshot.test.mjs +360 -0
  71. package/tests/consolidation-value.test.mjs +89 -0
  72. package/tests/context-scope.test.mjs +40 -0
  73. package/tests/cue.test.mjs +44 -0
  74. package/tests/curator-commit.test.mjs +277 -0
  75. package/tests/curator-probe.test.mjs +433 -0
  76. package/tests/curator.test.mjs +150 -0
  77. package/tests/cycle.test.mjs +411 -0
  78. package/tests/disclosure.test.mjs +231 -0
  79. package/tests/doc-drift.test.mjs +222 -0
  80. package/tests/embedding-catalog.test.mjs +266 -0
  81. package/tests/embedding-download.test.mjs +391 -0
  82. package/tests/embedding-runtime.test.mjs +389 -0
  83. package/tests/emotion-chain.test.mjs +84 -0
  84. package/tests/emotion-contract.test.mjs +445 -0
  85. package/tests/engine.test.mjs +35 -0
  86. package/tests/fixtures/active-memory-ab/README.md +56 -0
  87. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  88. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  89. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  90. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  92. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  93. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  94. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  95. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  96. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  97. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  98. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  99. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  100. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  101. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  102. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  103. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  104. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  105. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  106. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  107. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  108. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  109. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  110. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  111. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  112. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  113. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  114. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  115. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  116. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  117. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  118. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  119. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  120. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  121. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  122. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  123. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  124. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  125. package/tests/graph-cascade.test.mjs +190 -0
  126. package/tests/graph-view.test.mjs +187 -0
  127. package/tests/graph.test.mjs +94 -0
  128. package/tests/hook-order.test.mjs +72 -0
  129. package/tests/humanlike.test.mjs +232 -0
  130. package/tests/hybrid-recall.test.mjs +122 -0
  131. package/tests/inbound.test.mjs +393 -0
  132. package/tests/inject-guide.test.mjs +364 -0
  133. package/tests/inject.test.mjs +222 -0
  134. package/tests/injection-budget-settings.test.mjs +35 -0
  135. package/tests/injection-budget.test.mjs +64 -0
  136. package/tests/injection-chain.test.mjs +191 -0
  137. package/tests/loader-register.mjs +3 -0
  138. package/tests/loader.mjs +12 -0
  139. package/tests/local-embedding-entry.test.mjs +95 -0
  140. package/tests/local-embedding-manager.test.mjs +476 -0
  141. package/tests/local-embedding-protocol.test.mjs +95 -0
  142. package/tests/local-embedding-worker.test.mjs +346 -0
  143. package/tests/managed-memory.test.mjs +591 -0
  144. package/tests/managed-runtime.test.mjs +40 -0
  145. package/tests/media.test.mjs +132 -0
  146. package/tests/migration-payload.test.mjs +42 -0
  147. package/tests/mood.test.mjs +109 -0
  148. package/tests/multiprocess.test.mjs +29 -0
  149. package/tests/new-session-gate.test.mjs +361 -0
  150. package/tests/package-shipping.test.mjs +84 -0
  151. package/tests/preset-scope.test.mjs +212 -0
  152. package/tests/prompts.test.mjs +250 -0
  153. package/tests/react-snapshot.test.mjs +23 -0
  154. package/tests/recall-bench.test.mjs +306 -0
  155. package/tests/recall-dedup.test.mjs +417 -0
  156. package/tests/remote.test.mjs +140 -0
  157. package/tests/rename.test.mjs +30 -0
  158. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  159. package/tests/repetition-eval-live.test.mjs +177 -0
  160. package/tests/repetition-eval.test.mjs +373 -0
  161. package/tests/salient-date.test.mjs +142 -0
  162. package/tests/settings-beat.test.mjs +221 -0
  163. package/tests/short-term.test.mjs +166 -0
  164. package/tests/sidebar-beat.test.mjs +212 -0
  165. package/tests/sidebar-refresh.test.mjs +123 -0
  166. package/tests/sidebar.test.mjs +70 -0
  167. package/tests/snippet.test.mjs +47 -0
  168. package/tests/source-kind.test.mjs +52 -0
  169. package/tests/state.test.mjs +150 -0
  170. package/tests/stats.test.mjs +112 -0
  171. package/tests/storage.test.mjs +125 -0
  172. package/tests/stubs/dsh-llm.js +5 -0
  173. package/tests/stubs/dsh-session.js +7 -0
  174. package/tests/stubs/dsh-tools.js +101 -0
  175. package/tests/tool-schema.test.mjs +73 -0
  176. package/tests/tools.test.mjs +184 -0
  177. package/tests/ui-defaults.test.mjs +57 -0
  178. package/tests/update-fields.test.mjs +138 -0
  179. package/tests/vector-migration.test.mjs +790 -0
  180. package/tests/vector-profile.test.mjs +425 -0
  181. package/tests/visibility-migration.test.mjs +152 -0
  182. package/tests/webui-auth.test.mjs +454 -0
  183. package/tests/webui.test.mjs +165 -0
  184. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,591 @@
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
+ // No engine, worker, download or real library is used: all stores live here and
9
+ // the engine boundary is replaced before either production module is imported.
10
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-managed-memory-'))
11
+ process.env.DSH_MEMU_HOME = home
12
+ process.env.DSH_MEMU_MEMORY_HOME = home
13
+ const calls = []
14
+ const warnings = []
15
+ const backendRows = new Map()
16
+ const backendKey = (db, name) => JSON.stringify([db, name])
17
+ let commitHook = null
18
+ let retrieveHook = null
19
+ let failCommit = false
20
+ let failForget = false
21
+ let forgetHook = null
22
+ let vp
23
+
24
+ globalThis.__managedMemoryTest = {
25
+ async commit(args) {
26
+ calls.push({ op: 'commit', ...args })
27
+ // The lease invariant only exists while there is a generation to protect. A
28
+ // requested-but-never-migrated profile writes without vectors on purpose.
29
+ if (args.route === 'managed-local' && vp.hasActiveVectorGeneration()) {
30
+ assert.ok(Object.keys(vp.activeVectorState().writeGuard.leases).length, 'lease spans MemU await')
31
+ }
32
+ if (commitHook) await commitHook(args)
33
+ if (failCommit) throw new Error('mock managed commit failed')
34
+ for (const row of args.payload.recall_files) backendRows.set(backendKey(args.db, row.name), structuredClone(row))
35
+ return { ok: true }
36
+ },
37
+ async retrieve(args) {
38
+ calls.push({ op: 'retrieve', ...args })
39
+ return retrieveHook ? retrieveHook(args) : { files: [], segments: [], resources: [], raw: null }
40
+ },
41
+ async forget(args) {
42
+ calls.push({ op: 'forget', ...args })
43
+ if (args.route === 'managed-local' && vp.hasActiveVectorGeneration()) assert.ok(Object.keys(vp.activeVectorState().writeGuard.leases).length)
44
+ if (forgetHook) return forgetHook(args)
45
+ if (failForget) throw new Error('mock managed forget failed')
46
+ backendRows.delete(backendKey(args.db, args.name))
47
+ return { deleted: 1, segments: 1 }
48
+ },
49
+ async listFiles() { throw new Error('unexpected list engine call') },
50
+ }
51
+ const engineSource = 'const bridge = globalThis.__managedMemoryTest; export const commit = bridge.commit, retrieve = bridge.retrieve, forget = bridge.forget, listFiles = bridge.listFiles;'
52
+ const hooks = registerHooks({
53
+ resolve(specifier, context, nextResolve) {
54
+ if (specifier === './memu.js') {
55
+ return { url: 'data:text/javascript,' + encodeURIComponent(engineSource), shortCircuit: true }
56
+ }
57
+ return nextResolve(specifier, context)
58
+ },
59
+ })
60
+ const state = await import('../lib/state.js')
61
+ vp = await import('../lib/vector-profile.js')
62
+ const paths = await import('../lib/paths.js')
63
+ const memory = await import('../lib/memory.js')
64
+ const curator = await import('../lib/curator.js')
65
+ const activity = await import('../lib/activity-log.js')
66
+ const profile = vp.profileFromCatalog('bge-small-zh-v1.5-int8')
67
+ const activeFile = vp.vectorActiveFile()
68
+ let serial = 0
69
+
70
+ function entry(content, extra = {}) {
71
+ return { status: 'active', title: content, content, layer: 'long_term', strength: 1,
72
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...extra }
73
+ }
74
+ function fixture(entries = {}) {
75
+ serial += 1
76
+ const sessionId = 'managed-' + serial
77
+ const cwd = path.join(home, 'project-' + serial)
78
+ const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
79
+ state.setSessionBinding(sessionId, { useLibrary: true, useGlobal: true, domainOverride: 'coding' })
80
+ state.savePrivateIndex(binding.libraryId, { entries })
81
+ return { sessionId, libraryId: binding.libraryId, exec: { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } } }
82
+ }
83
+ function activate(libraryIds = []) {
84
+ const databases = vp.buildDatabaseSet({ profile, generation: 1, libraryIds })
85
+ fs.mkdirSync(path.join(home, 'libraries'), { recursive: true })
86
+ for (const row of [databases.global, ...Object.values(databases.libraries)]) fs.writeFileSync(vp.resolveVectorPath(row.path), '')
87
+ vp.updateVectorStateSync(current => ({ ...current, generation: 1, requested: null, pending: null,
88
+ active: { status: 'active', generation: 1, fingerprint: profile.fingerprint, profile, databases } }))
89
+ return databases
90
+ }
91
+ function job() { return { step: '', abort: new AbortController() } }
92
+ function curated(extra = {}) {
93
+ return { op: 'create', domain: 'coding', title: 'global decision', content: 'global implementation decision',
94
+ evidence: [{ session_id: 'source-session', quote: 'explicit decision' }], ...extra }
95
+ }
96
+
97
+ test.beforeEach(() => {
98
+ fs.rmSync(activeFile, { force: true })
99
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local', relevanceThreshold: 0 })
100
+ state.saveGlobalIndex({ entries: {} })
101
+ calls.length = 0
102
+ warnings.length = 0
103
+ commitHook = null
104
+ retrieveHook = null
105
+ failCommit = false
106
+ failForget = false
107
+ forgetHook = null
108
+ backendRows.clear()
109
+ paths.setWarnSink(message => warnings.push(String(message)))
110
+ })
111
+ test.after(() => {
112
+ hooks.deregister()
113
+ delete globalThis.__managedMemoryTest
114
+ paths.setWarnSink(null)
115
+ fs.rmSync(home, { recursive: true, force: true })
116
+ })
117
+
118
+ test('fresh defaults disable all vector routes while retaining local as the selected source', () => {
119
+ assert.equal(state.DEFAULT_SETTINGS.memoryBackend, 'agent')
120
+ assert.equal(state.DEFAULT_SETTINGS.vectorSource, 'local')
121
+ // 2026-10-30:用户要求召回默认 5/5(原来是 3/2)。
122
+ assert.equal(state.DEFAULT_SETTINGS.privateTopK, 5)
123
+ assert.equal(state.DEFAULT_SETTINGS.globalTopK, 5)
124
+ assert.equal(state.effectiveVectorRoute(state.DEFAULT_SETTINGS), 'lexical')
125
+ assert.equal(state.vectorBackendEnabled(state.DEFAULT_SETTINGS), false)
126
+ })
127
+
128
+ test('managed local private recall fuses real lexical and vector ids without LLM', async () => {
129
+ const f = fixture({ 'mem-needle': entry('needle local match'), 'mem-orbit': entry('orbital mechanics') })
130
+ activate([f.libraryId])
131
+ retrieveHook = () => ({ files: [{ name: 'mem-orbit' }], segments: [], resources: [] })
132
+ const result = await memory.privateRecall({ exec: f.exec, query: 'needle', topK: 8 })
133
+ assert.deepEqual(new Set(result.files.map(file => file.name)), new Set(['mem-needle', 'mem-orbit']))
134
+ assert.equal(result.recallRoute, 'managed-vector-rrf')
135
+ assert.equal(calls[0].db, vp.dbPathFor(f.libraryId))
136
+ })
137
+
138
+ test('corrupt managed intent cannot call LLM or legacy API and preserves lexical ids', async () => {
139
+ const f = fixture({ 'mem-needle': entry('needle local match') })
140
+ state.updateSettings({ memoryBackend: 'api' })
141
+ fs.writeFileSync(activeFile, '{bad-json')
142
+ const result = await memory.privateRecall({ exec: f.exec, query: 'needle', topK: 8 })
143
+ assert.deepEqual(result.files.map(file => file.name), ['mem-needle'])
144
+ assert.equal(result.recallRoute, 'local-fallback')
145
+ assert.match(state.getSessionBinding(f.sessionId).lastRecallError, /not valid JSON/)
146
+ assert.ok(warnings.length)
147
+ assert.equal(calls.length, 0)
148
+ })
149
+
150
+ test('agent backend stays lexical even when a local profile is active', async () => {
151
+ const f = fixture({ 'mem-needle': entry('needle local match') })
152
+ activate([f.libraryId])
153
+ state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
154
+ retrieveHook = () => { throw new Error('agent route must not call MemU') }
155
+ const result = await memory.privateRecall({ exec: f.exec, query: 'needle', topK: 8 })
156
+ assert.deepEqual(result.files.map(file => file.name), ['mem-needle'])
157
+ assert.equal(result.recallRoute, 'local')
158
+ assert.equal(calls.length, 0)
159
+ assert.equal(state.effectiveVectorRoute(), 'lexical')
160
+ assert.equal(state.vectorBackendEnabled(), false)
161
+ })
162
+
163
+ test('remote-api ignores an active local profile and passes explicit remote route', async () => {
164
+ const f = fixture({ 'mem-needle': entry('needle local match') })
165
+ activate([f.libraryId])
166
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'api' })
167
+ retrieveHook = () => ({ files: [{ name: 'mem-needle' }], segments: [], resources: [] })
168
+ const result = await memory.privateRecall({ exec: f.exec, query: 'needle', topK: 8 })
169
+ assert.equal(result.recallRoute, 'managed-vector-rrf')
170
+ assert.equal(calls[0].route, 'remote-api')
171
+ assert.notEqual(calls[0].db, vp.dbPathFor(f.libraryId))
172
+ })
173
+
174
+ test('invalid profile is a managed lexical fallback, never a remote fallback', async () => {
175
+ const f = fixture({ 'mem-needle': entry('needle local match') })
176
+ activate([f.libraryId])
177
+ vp.updateVectorStateSync(current => ({ ...current, active: { ...current.active,
178
+ profile: { ...current.active.profile, catalogId: 'missing-catalog-entry' } } }))
179
+ const result = await memory.privateRecall({ exec: f.exec, query: 'needle', topK: 8 })
180
+ assert.deepEqual(result.files.map(file => file.name), ['mem-needle'])
181
+ assert.match(state.getSessionBinding(f.sessionId).lastRecallError, /catalog/)
182
+ assert.equal(calls.length, 0)
183
+ })
184
+
185
+ test('global managed recall fuses by domain and records semantic errors despite lexical hits', async () => {
186
+ const f = fixture()
187
+ activate([f.libraryId])
188
+ state.saveGlobalIndex({ entries: { 'gmem-needle': entry('needle global match', { domain: 'coding' }),
189
+ 'gmem-orbit': entry('orbital mechanics', { domain: 'coding' }), 'gmem-other': entry('needle other world', { domain: 'rp' }) } })
190
+ retrieveHook = () => { throw new Error('worker unavailable') }
191
+ const failed = await memory.globalRecall({ exec: f.exec, query: 'needle', topK: 8 })
192
+ assert.deepEqual(failed.files.map(file => file.name), ['gmem-needle'])
193
+ assert.equal(failed.recallRoute, 'local-fallback')
194
+ assert.match(state.getSessionBinding(f.sessionId).lastRecallError, /worker unavailable/)
195
+ retrieveHook = () => ({ files: [{ name: 'gmem-orbit' }, { name: 'gmem-other' }] })
196
+ const healthy = await memory.globalRecall({ exec: f.exec, query: 'needle', topK: 8 })
197
+ assert.deepEqual(new Set(healthy.files.map(file => file.name)), new Set(['gmem-needle', 'gmem-orbit']))
198
+ assert.equal(healthy.recallRoute, 'managed-vector-rrf')
199
+ assert.equal(state.getSessionBinding(f.sessionId).lastRecallError, undefined)
200
+ })
201
+
202
+ test('new library enrolls inside lease before first managed commit and lease spans index write', async () => {
203
+ const f = fixture()
204
+ activate()
205
+ let entered
206
+ let release
207
+ const started = new Promise(resolve => { entered = resolve })
208
+ const gate = new Promise(resolve => { release = resolve })
209
+ commitHook = async args => {
210
+ assert.equal(args.db, vp.dbPathFor(f.libraryId))
211
+ assert.equal(Object.keys(state.loadPrivateIndex(f.libraryId).entries).length, 0)
212
+ entered()
213
+ await gate
214
+ }
215
+ const writing = memory.privateRemember({ exec: f.exec, content: 'new managed fact', title: 'new managed fact' })
216
+ await started
217
+ await assert.rejects(vp.withVectorWriteFreeze(() => {}), { code: 'VECTOR_WRITE_BUSY' })
218
+ release()
219
+ const result = await writing
220
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries[result.id].content, 'new managed fact')
221
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 0)
222
+ assert.ok(vp.activeVectorState().active.databases.libraries[f.libraryId])
223
+ })
224
+
225
+ test('nonempty old library must rebuild rather than silently enroll an empty vector DB', async () => {
226
+ const f = fixture({ 'mem-existing': entry('old stored fact') })
227
+ activate()
228
+ await assert.rejects(memory.privateRemember({ exec: f.exec, content: 'new different fact' }), { code: 'VECTOR_LIBRARY_REBUILD_REQUIRED' })
229
+ assert.equal(calls.length, 0)
230
+ assert.deepEqual(Object.keys(state.loadPrivateIndex(f.libraryId).entries), ['mem-existing'])
231
+ })
232
+
233
+ test('freeze rejects every async write before engine or index mutation', async () => {
234
+ const f = fixture({ 'mem-live': entry('live fact'), 'mem-held': entry('held claim', { status: 'pending_review' }),
235
+ 'mem-archived': entry('old archived', { status: 'archived' }) })
236
+ activate([f.libraryId])
237
+ const before = JSON.stringify(state.loadPrivateIndex(f.libraryId))
238
+ await vp.withVectorWriteFreeze(async () => {
239
+ const writes = [
240
+ () => memory.privateRemember({ exec: f.exec, content: 'new fact' }),
241
+ () => memory.importMemories({ libraryId: f.libraryId, memories: [{ content: 'restored fact' }] }),
242
+ () => memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'updated fact' }),
243
+ () => memory.privateDelete({ exec: f.exec, id: 'mem-archived' }),
244
+ () => memory.resolveMemoryReview({ libraryId: f.libraryId, id: 'mem-held', action: 'accept' }),
245
+ () => curator.commitCuratorOutput({ memories: [curated()] }, { job: job() }),
246
+ ]
247
+ for (const write of writes) await assert.rejects(write(), { code: 'VECTOR_WRITE_FROZEN' })
248
+ })
249
+ assert.equal(JSON.stringify(state.loadPrivateIndex(f.libraryId)), before)
250
+ assert.equal(calls.length, 0)
251
+ assert.deepEqual(state.loadGlobalIndex().entries, {})
252
+ })
253
+
254
+ test('managed agent update import delete and review call engine under lease; held edits stay index-only', async () => {
255
+ const f = fixture({ 'mem-live': entry('live fact'), 'mem-held': entry('held claim', { status: 'pending_review' }),
256
+ 'mem-archived': entry('old archived', { status: 'archived' }) })
257
+ activate([f.libraryId])
258
+ await memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'new implementation fact' })
259
+ await memory.privateUpdate({ exec: f.exec, id: 'mem-held', content: 'edited held claim' })
260
+ assert.equal(calls.length, 1, 'pending_review edit never commits')
261
+ await memory.resolveMemoryReview({ libraryId: f.libraryId, id: 'mem-held', action: 'accept' })
262
+ await memory.importMemories({ libraryId: f.libraryId, memories: [{ content: 'restored unique observation' }] })
263
+ await memory.privateDelete({ exec: f.exec, id: 'mem-archived' })
264
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'commit', 'commit', 'forget'])
265
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries['mem-archived'], undefined)
266
+ })
267
+
268
+ test('post-CAS remember failure compensates the newly committed backend row', async () => {
269
+ const f = fixture()
270
+ activate([f.libraryId])
271
+ commitHook = async () => state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
272
+ await assert.rejects(
273
+ memory.privateRemember({ exec: f.exec, content: 'must compensate', title: 'must compensate' }),
274
+ /memory scope changed during write; retry/,
275
+ )
276
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget'])
277
+ assert.equal(calls[1].db, calls[0].db, 'cleanup uses the captured database after rebind')
278
+ assert.equal(calls[1].name, calls[0].payload.recall_files[0].name)
279
+ assert.equal(backendRows.size, 0, 'no orphan survives in the fake backend')
280
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries, {})
281
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 0)
282
+ })
283
+
284
+ test('post-CAS update failure deletes the new row and restores the old backend payload', async () => {
285
+ const old = entry('old backend fact', { title: 'old title' })
286
+ const f = fixture({ 'mem-live': old })
287
+ activate([f.libraryId])
288
+ commitHook = async () => state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
289
+ await assert.rejects(
290
+ memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'new backend fact', title: 'new title' }),
291
+ /memory scope changed during update; retry/,
292
+ )
293
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget', 'commit'])
294
+ assert.equal(calls[2].payload.recall_files[0].content, old.content)
295
+ assert.equal(calls[2].payload.recall_files[0].description, old.title)
296
+ assert.equal(calls[1].db, calls[0].db)
297
+ assert.equal(calls[2].db, calls[0].db)
298
+ assert.equal(backendRows.get(backendKey(calls[0].db, 'mem-live')).content, old.content)
299
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries['mem-live'], old)
300
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 0)
301
+ })
302
+
303
+ test('uncertain backend compensation is visible instead of reported as a successful write', async () => {
304
+ const f = fixture()
305
+ activate([f.libraryId])
306
+ commitHook = async () => state.setSessionBinding(f.sessionId, { libraryId: 'rebound-library' })
307
+ failForget = true
308
+ await assert.rejects(
309
+ memory.privateRemember({ exec: f.exec, content: 'uncertain backend state' }),
310
+ error => error?.code === 'PRIVATE_BACKEND_COMPENSATION_FAILED' && /compensation uncertain/.test(error.message)
311
+ && /memory scope changed/.test(error.cause?.message) && /mock managed forget failed/.test(error.compensationError?.message),
312
+ )
313
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget'])
314
+ })
315
+
316
+ test('a failed restore is reported as uncertain and never erases the authoritative index', async () => {
317
+ const f = fixture({ 'mem-live': entry('original backend payload') })
318
+ activate([f.libraryId])
319
+ const original = state.loadPrivateIndex(f.libraryId).entries['mem-live']
320
+ let count = 0
321
+ commitHook = async () => {
322
+ if (++count > 1) throw new Error('restore unavailable')
323
+ state.setSessionBinding(f.sessionId, { useLibrary: false })
324
+ }
325
+ await assert.rejects(memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'rejected edit' }), error => {
326
+ assert.equal(error.code, 'PRIVATE_BACKEND_COMPENSATION_FAILED')
327
+ assert.match(error.compensationError.message, /restore unavailable/)
328
+ return true
329
+ })
330
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget', 'commit'])
331
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries['mem-live'], original)
332
+ assert.ok(warnings.some(warning => /compensation uncertain/.test(warning)))
333
+ })
334
+
335
+ test('unparseable compensation acknowledgement is visibly uncertain', async () => {
336
+ const f = fixture()
337
+ activate([f.libraryId])
338
+ commitHook = async () => state.setSessionBinding(f.sessionId, { useLibrary: false })
339
+ forgetHook = async () => ({ deleted: 0, parseWarning: 'mock non-JSON acknowledgement' })
340
+ await assert.rejects(memory.privateRemember({ exec: f.exec, content: 'rejected new payload' }), {
341
+ code: 'PRIVATE_BACKEND_COMPENSATION_FAILED',
342
+ })
343
+ assert.equal(backendRows.size, 1, 'uncertain cleanup is not claimed to remove the orphan')
344
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries, {})
345
+ forgetHook = async () => ({ ok: true })
346
+ state.setSessionBinding(f.sessionId, { useLibrary: true })
347
+ await assert.rejects(memory.privateRemember({ exec: f.exec, content: 'malformed delete acknowledgement' }), {
348
+ code: 'PRIVATE_BACKEND_COMPENSATION_FAILED',
349
+ })
350
+ })
351
+
352
+ test('a concurrently replaced index refuses destructive compensation', async () => {
353
+ const f = fixture({ 'mem-live': entry('original backend payload') })
354
+ activate([f.libraryId])
355
+ commitHook = async () => state.updatePrivateIndex(f.libraryId, index => {
356
+ index.entries['mem-live'].content = 'externally replaced authoritative value'
357
+ })
358
+ await assert.rejects(memory.privateUpdate({ exec: f.exec, id: 'mem-live', content: 'rejected edit' }), error => {
359
+ assert.equal(error.code, 'PRIVATE_BACKEND_COMPENSATION_FAILED')
360
+ assert.match(error.cause.message, /memory changed concurrently/)
361
+ assert.match(error.compensationError.message, /changed before backend compensation/)
362
+ return true
363
+ })
364
+ assert.deepEqual(calls.map(call => call.op), ['commit'], 'do not delete a newer same-name backend row')
365
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries['mem-live'].content, 'externally replaced authoritative value')
366
+ })
367
+
368
+ test('library queue spans compensation across routes and releases after failure', async () => {
369
+ const f = fixture()
370
+ activate([f.libraryId])
371
+ let entered
372
+ let release
373
+ const started = new Promise(resolve => { entered = resolve })
374
+ const gate = new Promise(resolve => { release = resolve })
375
+ commitHook = async () => {
376
+ commitHook = null
377
+ state.setSessionBinding(f.sessionId, { useLibrary: false })
378
+ }
379
+ forgetHook = async args => {
380
+ entered()
381
+ await gate
382
+ backendRows.delete(backendKey(args.db, args.name))
383
+ return { deleted: 1 }
384
+ }
385
+ const first = memory.privateRemember({ exec: f.exec, content: 'first write must roll back' })
386
+ const rejected = assert.rejects(first, /private memory is turned off/)
387
+ await started
388
+ // A remote writer to the same library must wait for managed compensation.
389
+ state.updateSettings({ vectorSource: 'api' })
390
+ const second = memory.importMemories({ libraryId: f.libraryId, memories: [{ content: 'later remote write' }] })
391
+ await new Promise(resolve => setImmediate(resolve))
392
+ assert.deepEqual(calls.map(call => call.op), ['commit', 'forget'])
393
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 1)
394
+ release()
395
+ await rejected
396
+ const imported = await second
397
+ assert.equal(imported.imported, 1)
398
+ assert.equal(calls[2].route, 'remote-api')
399
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 0)
400
+ assert.equal(backendRows.size, 1)
401
+ })
402
+
403
+ test('managed import failure rejects without index-first false success', async () => {
404
+ const f = fixture()
405
+ activate([f.libraryId])
406
+ failCommit = true
407
+ await assert.rejects(memory.importMemories({ libraryId: f.libraryId, memories: [{ content: 'restored unique fact' }] }), /mock managed commit failed/)
408
+ assert.deepEqual(state.loadPrivateIndex(f.libraryId).entries, {})
409
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 0)
410
+ })
411
+
412
+ test('a write under an active generation records which vector space it entered', async () => {
413
+ const f = fixture()
414
+ activate([f.libraryId])
415
+ const written = await memory.privateRemember({ exec: f.exec, content: '这条应该记下向量归属', title: '向量归属' })
416
+ const entry = state.loadPrivateIndex(f.libraryId).entries[written.id]
417
+ assert.equal(entry.vector.model, 'bge-small-zh-v1.5-int8')
418
+ assert.equal(entry.vector.generation, 1)
419
+ assert.equal(entry.vector.route, 'managed-local')
420
+ assert.match(entry.vector.fingerprint, /^[0-9a-f]{8}$/)
421
+
422
+ // 没有代际(降级写)时不允许写字面上的"已入向量库"。
423
+ fs.rmSync(activeFile, { force: true })
424
+ const plain = await memory.privateRemember({ exec: f.exec, content: '降级写不应带向量归属', title: '降级' })
425
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries[plain.id].vector, undefined)
426
+ })
427
+
428
+ test('the vector count follows reality: writes stamp, review/import/curator stamp, deletes shrink', async () => {
429
+ // 现场事故(2026-10-31):设置页"当前已入向量库 N 条"只数迁移账本 —— 账本在切换
430
+ // 那一刻写完就再也不更新,聊天里新写的记忆永远不进计数。membership = 账本腿(迁移
431
+ // 写入,扣除已删除)+ 戳腿(迁移后写入)。每条迁移后写入路径都必须落戳,否则漏数。
432
+ const vm = await import('../lib/vector-migration.js')
433
+ const f = fixture({
434
+ 'mem-migrated': entry('迁移时就有的记忆'),
435
+ 'mem-held': entry('待审的主张', { status: 'pending_review' }),
436
+ })
437
+ activate([f.libraryId])
438
+ // 预置"切换时写入 1/1"的账本快照:mem-migrated 在迁移时进了向量库(索引故意无戳)。
439
+ const { ledger } = vm.loadLedger(profile.fingerprint, 1)
440
+ ledger.libraries[f.libraryId] = { items: { 'mem-migrated': { hash: 'seed', at: new Date().toISOString() } }, tombstones: {}, snapshot: null }
441
+ vm.saveLedger(ledger)
442
+ const migrator = vm.createVectorMigrator({
443
+ listLibraries: () => state.listLibraries().map(item => item.id),
444
+ loadIndex: libraryId => libraryId == null ? state.loadGlobalIndex() : state.loadPrivateIndex(libraryId),
445
+ commit: async () => {}, forget: async () => {}, listFiles: async () => [],
446
+ })
447
+ const idsOf = scope => (migrator.membership()[scope] || []).sort()
448
+ assert.deepEqual(idsOf(f.libraryId), ['mem-migrated'], 'starts at the migration count — the reported bug state')
449
+
450
+ // 聊天写入:戳腿接管,计数跟着涨。
451
+ const written = await memory.privateRemember({ exec: f.exec, content: '切换后新写的记忆', title: '新记忆' })
452
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries[written.id].vector.generation, 1)
453
+ assert.deepEqual(idsOf(f.libraryId), ['mem-migrated', written.id])
454
+
455
+ // 编辑重提交:行被重写进当前空间,戳刷新到当前代际。
456
+ await memory.privateUpdate({ exec: f.exec, id: written.id, content: '更新后的记忆内容' })
457
+ const updated = state.loadPrivateIndex(f.libraryId).entries[written.id]
458
+ assert.equal(updated.vector.generation, 1)
459
+ assert.equal(updated.vector.route, 'managed-local')
460
+ assert.deepEqual(idsOf(f.libraryId), ['mem-migrated', written.id])
461
+
462
+ // 批量恢复与评审接受:此前完全不落戳,是计数漏数的另外两条路。
463
+ await memory.importMemories({ libraryId: f.libraryId, memories: [{ content: '恢复回来的独特观察' }] })
464
+ const imported = Object.entries(state.loadPrivateIndex(f.libraryId).entries).find(([, item]) => item.content === '恢复回来的独特观察')[0]
465
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries[imported].vector.generation, 1)
466
+ await memory.resolveMemoryReview({ libraryId: f.libraryId, id: 'mem-held', action: 'accept' })
467
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries['mem-held'].vector.generation, 1)
468
+ assert.ok(idsOf(f.libraryId).includes(imported) && idsOf(f.libraryId).includes('mem-held'))
469
+
470
+ // 永久删除:引擎行先删、索引行后删,账本不知道 —— membership 必须把它扣掉。
471
+ await memory.privateArchive({ exec: f.exec, id: 'mem-migrated' })
472
+ await memory.privateDelete({ exec: f.exec, id: 'mem-migrated' })
473
+ assert.equal(idsOf(f.libraryId).includes('mem-migrated'), false, 'a permanently deleted migration item leaves the count')
474
+
475
+ // 全局整理:同样的口径,提交成功即落戳。
476
+ const clean = await curator.commitCuratorOutput({ memories: [curated()] }, { job: job() })
477
+ assert.equal(clean.created, 1)
478
+ const curatedId = Object.keys(state.loadGlobalIndex().entries)[0]
479
+ assert.equal(state.loadGlobalIndex().entries[curatedId].vector.route, 'managed-local')
480
+ assert.deepEqual((migrator.membership().global || []), [curatedId])
481
+ })
482
+
483
+ test('managed curator clean output commits under lease and held output is index-only', async () => {
484
+ activate()
485
+ const clean = await curator.commitCuratorOutput({ memories: [curated()] }, { job: job() })
486
+ assert.equal(clean.created, 1)
487
+ assert.equal(calls.length, 1)
488
+ assert.equal(calls[0].db, vp.dbPathFor(null))
489
+ const held = await curator.commitCuratorOutput({ memories: [curated({ title: 'held conflict', op: 'conflict' })] }, { job: job() })
490
+ assert.equal(held.pending, 1)
491
+ assert.equal(calls.length, 1)
492
+ assert.ok(Object.values(state.loadGlobalIndex().entries).some(item => item.status === 'pending_review'))
493
+ assert.equal(Object.keys(vp.activeVectorState().writeGuard.leases).length, 0)
494
+ })
495
+
496
+ // 现场事故(2026-10-29):下载完模型、点了选择,但迁移没跑完 —— vector-active 只有
497
+ // requested,没有 active。写入路径当时直接抛 VECTOR_NOT_ACTIVE,整理 fork 跑完却一条
498
+ // 记忆都没落下,而最后睡眠标记照样前进。写入必须和召回一样降级,不能整体失败。
499
+ test('a requested-but-never-migrated profile degrades writes instead of failing them', async () => {
500
+ const f = fixture()
501
+ assert.equal(vp.hasActiveVectorGeneration(), false)
502
+ fs.writeFileSync(activeFile, JSON.stringify({
503
+ version: 1,
504
+ generation: 0,
505
+ requested: { status: 'requested', catalogId: 'bge-small-zh-v1.5-int8', fingerprint: profile.fingerprint },
506
+ }))
507
+ const private1 = await memory.privateRemember({ exec: f.exec, content: 'degraded private write must land', title: 'degraded private write' })
508
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries[private1.id].content, 'degraded private write must land')
509
+ // No generation means no embedding environment, so MemU cannot take a commit at
510
+ // all (envFor refuses managed-local). Index-only is the honest degradation; the
511
+ // record is re-embedded by the next migration.
512
+ assert.deepEqual(calls.map(call => call.op), [], 'a vectorless profile must not call the engine')
513
+ assert.ok(warnings.some(message => /no active vector generation/.test(message)), 'the degradation is logged: ' + warnings.join(' | '))
514
+ assert.ok(activity.listEvents({ kind: 'vector' }).length >= 1, 'the degradation is visible in the activity log')
515
+
516
+ const curatedResult = await curator.commitCuratorOutput({ memories: [curated()] }, { job: job() })
517
+ assert.equal(curatedResult.created, 1, 'global curation must not be thrown away either')
518
+ assert.match(Object.keys(state.loadGlobalIndex().entries).join(','), /gmem-/, 'the global index still receives it')
519
+ assert.deepEqual(calls.map(call => call.op), [], 'global curation degrades the same way')
520
+ })
521
+
522
+ test('archive stays index-only; deleting is what removes the vector row', async () => {
523
+ const f = fixture({ 'mem-arch': entry('to archive') })
524
+ activate([f.libraryId])
525
+ await memory.privateArchive({ exec: f.exec, id: 'mem-arch' })
526
+ assert.equal(state.loadPrivateIndex(f.libraryId).entries['mem-arch'].status, 'archived')
527
+ assert.deepEqual(calls.map(call => call.op), [], 'archiving must not start the engine; it only flips the index status')
528
+ await memory.privateDelete({ exec: f.exec, id: 'mem-arch' })
529
+ assert.deepEqual(calls.map(call => call.op), ['forget'], 'permanent delete is what removes the MemU row')
530
+ })
531
+
532
+ test('UI listings read the index even when vectors are active', async () => {
533
+ const f = fixture()
534
+ activate([f.libraryId])
535
+ // 列表走引擎意味着起 Python + embedding worker(秒级)。索引是权威,UI 刷新不该等它。
536
+ const privateView = await memory.privateList({ libraryId: f.libraryId, preferIndex: true })
537
+ assert.equal(privateView.source, 'index')
538
+ assert.ok(privateView.files.length >= 0)
539
+ const globalView = await memory.globalList({ exec: { agent: null }, preferIndex: true })
540
+ assert.equal(globalView.source, 'index')
541
+ assert.deepEqual(calls.map(call => call.op).filter(op => op === 'listFiles'), [], 'no engine list call')
542
+ })
543
+
544
+ test('global list degrades to the index when no vector generation is active', async () => {
545
+ fs.writeFileSync(activeFile, JSON.stringify({
546
+ version: 1,
547
+ generation: 0,
548
+ requested: { status: 'requested', catalogId: 'bge-small-zh-v1.5-int8', fingerprint: profile.fingerprint },
549
+ }))
550
+ state.saveGlobalIndex({ entries: { 'gmem-1': entry('global fact', { domain: 'coding' }) } })
551
+ // 全局记忆页原先会闪一下 "managed local vector profile is not active" 再消失:
552
+ // globalList 走了引擎路由,没有代际就必抛。列表本身能答,就按索引答。
553
+ const result = await memory.globalList({ exec: { agent: null } })
554
+ assert.equal(result.source, 'index')
555
+ assert.deepEqual(result.files.map(file => file.name), ['gmem-1'])
556
+ assert.match(result.warning, /本地向量未激活/)
557
+ })
558
+
559
+ test('an existing but torn generation still refuses writes loudly', async () => {
560
+ const f = fixture()
561
+ activate([f.libraryId])
562
+ assert.equal(vp.hasActiveVectorGeneration(), true)
563
+ const dbPath = vp.resolveVectorPath(vp.activeVectorState().active.databases.libraries[f.libraryId].path)
564
+ fs.rmSync(dbPath, { force: true })
565
+ await assert.rejects(
566
+ memory.privateRemember({ exec: f.exec, content: 'must not be silently split', title: 'torn generation' }),
567
+ { code: 'VECTOR_TORN_STATE' },
568
+ )
569
+ assert.deepEqual(Object.keys(state.loadPrivateIndex(f.libraryId).entries), [], 'a torn generation must not leave an index-only entry behind')
570
+ })
571
+
572
+ test('deleting an active archived library removes its route without tearing later writes', async () => {
573
+ const f = fixture()
574
+ const databases = activate([f.libraryId])
575
+ const activeDb = vp.resolveVectorPath(databases.libraries[f.libraryId].path)
576
+ fs.writeFileSync(path.join(home, 'libraries', f.libraryId + '.old.g1.sqlite3'), '')
577
+ state.archiveLibrary(f.libraryId)
578
+ await vp.withVectorWrite(async () => {
579
+ assert.throws(() => state.deleteLibrary(f.libraryId), { code: 'VECTOR_WRITE_BUSY' })
580
+ })
581
+ const result = state.deleteLibrary(f.libraryId)
582
+ assert.equal(result.ok, true)
583
+ assert.equal(vp.activeVectorState().active.databases.libraries[f.libraryId], undefined)
584
+ assert.equal(fs.existsSync(activeDb), false)
585
+ assert.equal(fs.existsSync(path.join(home, 'libraries', f.libraryId + '.old.g1.sqlite3')), false)
586
+
587
+ const next = fixture()
588
+ const remembered = await memory.privateRemember({ exec: next.exec, content: 'write after deleting another library', title: 'route remains healthy' })
589
+ assert.equal(state.loadPrivateIndex(next.libraryId).entries[remembered.id].content, 'write after deleting another library')
590
+ assert.equal(vp.activeVectorState().active.databases.libraries[f.libraryId], undefined)
591
+ })
@@ -0,0 +1,40 @@
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 crypto from 'node:crypto'
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-runtime-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ const { ensureManagedPython, verifyRuntimeArchive, UV_VERSION, MANAGED_PYTHON } = await import('../lib/managed-runtime.js')
10
+ test.after(() => fs.rmSync(home, { recursive: true, force: true }))
11
+ test('runtime downloads cannot execute unless their pinned SHA256 matches', () => {
12
+ const data = Buffer.from('not a trusted runtime')
13
+ assert.throws(() => verifyRuntimeArchive(data, '0'.repeat(64)), /checksum mismatch/)
14
+ assert.doesNotThrow(() => verifyRuntimeArchive(data, crypto.createHash('sha256').update(data).digest('hex')))
15
+ })
16
+ test('managed Python discovery is pinned and all write directories stay inside engine', async () => {
17
+ const binary = path.join(home, 'engine', 'runtime', `uv-${UV_VERSION}`, process.platform === 'win32' ? 'uv.exe' : 'uv')
18
+ fs.mkdirSync(path.dirname(binary), { recursive: true })
19
+ fs.writeFileSync(binary, 'fixture; never executed')
20
+ const python = path.join(home, 'engine', 'runtime', 'python', 'python.exe')
21
+ fs.mkdirSync(path.dirname(python), { recursive: true })
22
+ fs.writeFileSync(python, 'fixture')
23
+ const calls = []
24
+ const result = await ensureManagedPython({
25
+ run: async (cmd, args, options) => {
26
+ calls.push({ cmd, args, options })
27
+ return { code: 0, stdout: args[0] === '--version' ? `uv ${UV_VERSION}\n` : args[1] === 'find' ? python + '\n' : '' }
28
+ },
29
+ fetcher: async () => { throw new Error('must reuse the managed runtime') },
30
+ })
31
+ assert.equal(result.version.raw, MANAGED_PYTHON)
32
+ assert.equal(result.hasPip, false)
33
+ for (const call of calls) for (const key of ['UV_PYTHON_INSTALL_DIR', 'UV_CACHE_DIR', 'UV_PYTHON_BIN_DIR']) assert.ok(call.options.env[key].startsWith(path.join(home, 'engine')))
34
+ assert.ok(calls.some(call => call.options.env.UV_PYTHON_PREFERENCE === 'only-managed' && call.args.includes('--no-python-downloads')))
35
+ })
36
+ test('a discovered interpreter outside the plugin directory is refused', async () => {
37
+ await assert.rejects(ensureManagedPython({
38
+ run: async (_cmd, args) => ({ code: 0, stdout: args[0] === '--version' ? `uv ${UV_VERSION}` : 'C:/outside/python.exe' }),
39
+ }), /escaped/)
40
+ })