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,425 @@
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-vector-profile-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ process.env.DSH_MEMU_MEMORY_HOME = home
10
+
11
+ const catalog = await import('../lib/embedding-catalog.js')
12
+ const vp = await import('../lib/vector-profile.js')
13
+
14
+ const BGE = 'bge-small-zh-v1.5-int8'
15
+ const entry = catalog.getEmbeddingModel(BGE)
16
+ const profile = vp.profileFromCatalog(BGE)
17
+ const librariesRoot = path.join(home, 'libraries')
18
+ const activeFile = path.join(home, 'index', 'vector-active.json')
19
+
20
+ test.after(() => {
21
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
22
+ })
23
+
24
+ function clone(value) {
25
+ return JSON.parse(JSON.stringify(value))
26
+ }
27
+
28
+ function resetHome() {
29
+ fs.rmSync(path.join(home, 'index'), { recursive: true, force: true })
30
+ fs.rmSync(librariesRoot, { recursive: true, force: true })
31
+ }
32
+
33
+ function makeDatabases(fp, generation, libraryIds = []) {
34
+ const databases = {
35
+ global: { path: vp.vectorDbRelPath({ libraryId: null, fingerprint: fp, generation }), bytes: 6, sha256: null },
36
+ libraries: {},
37
+ }
38
+ for (const id of libraryIds) {
39
+ databases.libraries[id] = { path: vp.vectorDbRelPath({ libraryId: id, fingerprint: fp, generation }), bytes: 6, sha256: null }
40
+ }
41
+ return databases
42
+ }
43
+
44
+ function touchDatabases(databases) {
45
+ fs.mkdirSync(librariesRoot, { recursive: true })
46
+ for (const item of [databases.global, ...Object.values(databases.libraries)]) {
47
+ fs.writeFileSync(path.join(home, ...item.path.split('/')), 'sqlite')
48
+ }
49
+ }
50
+
51
+ function activate({ fp = profile.fingerprint, generation = 1, libraryIds = ['lib_a'], previous = null, pending = null, profileValue = profile } = {}) {
52
+ const databases = makeDatabases(fp, generation, libraryIds)
53
+ touchDatabases(databases)
54
+ vp.updateVectorStateSync(current => ({
55
+ ...current,
56
+ generation,
57
+ active: {
58
+ status: 'active',
59
+ generation,
60
+ fingerprint: fp,
61
+ profile: profileValue,
62
+ databases,
63
+ snapshot: { digest: 'snapshot-' + generation, at: new Date(0).toISOString() },
64
+ previous,
65
+ updatedAt: new Date(0).toISOString(),
66
+ },
67
+ pending,
68
+ }))
69
+ return databases
70
+ }
71
+
72
+ function code(fn) {
73
+ try {
74
+ fn()
75
+ } catch (error) {
76
+ return error.code
77
+ }
78
+ return null
79
+ }
80
+
81
+ function assertCode(fn, expected) {
82
+ assert.throws(fn, error => {
83
+ assert.equal(error.code, expected, 'expected ' + expected + ' but got ' + error.code + ' (' + error.message + ')')
84
+ return true
85
+ })
86
+ }
87
+
88
+ // ---------------------------------------------------------------- 身份
89
+
90
+ test('profile fingerprint is the catalog protocol, not a local reimplementation', () => {
91
+ const built = vp.buildProfile(entry)
92
+ assert.equal(built.fingerprint, entry.profileFingerprint)
93
+ assert.equal(built.fingerprint, catalog.profileFingerprint(entry))
94
+ assert.match(built.fingerprint, /^[0-9a-f]{64}$/)
95
+ assert.equal(built.schema, 1)
96
+ assert.equal(built.dimension, 512)
97
+ assert.equal(built.maxTokens, 512)
98
+ assert.equal(built.pooling, 'cls')
99
+ assert.equal(built.normalization, 'l2')
100
+ assert.equal(built.provider, 'modelscope')
101
+ assert.equal(built.revision, '313386ad7ffad360a4870fd1910f8ecaf1a05151')
102
+ assert.equal(built.graphSha256, entry.files.find(file => file.role === 'graph').sha256)
103
+ assert.ok(Object.isFrozen(built))
104
+ assert.ok(Object.isFrozen(built.files))
105
+ // 任何改变向量空间的字段都必须改变指纹(catalog 协议)
106
+ assert.notEqual(vp.buildProfile({ ...entry, pooling: 'mean' }).fingerprint, built.fingerprint)
107
+ assert.notEqual(vp.buildProfile({ ...entry, prefixes: { query: 'q: ', document: 'd: ' } }).fingerprint, built.fingerprint)
108
+ assert.notEqual(vp.buildProfile({ ...entry, dimension: 384 }).fingerprint, built.fingerprint)
109
+ })
110
+
111
+ test('manifestDigest covers artifacts, not prose', () => {
112
+ const digest = vp.manifestDigest(entry)
113
+ assert.match(digest, /^[0-9a-f]{64}$/)
114
+ assert.equal(digest, vp.manifestDigest({ ...clone(entry), profileFingerprint: 'f'.repeat(64) }))
115
+ // 改说明文字/显示名不能判废用户已激活的 profile(2026-10-30 现场:改了 catalog 里一句
116
+ // 文档路径,manifest digest 变化,整个向量空间被拒不使用)。
117
+ assert.equal(digest, vp.manifestDigest({ ...clone(entry), displayName: 'renamed', summary: '新说明', parameterSource: 'x' }))
118
+ // 制品字节变了才算变。
119
+ const tampered = clone(entry)
120
+ tampered.files[0].sha256 = 'f'.repeat(64)
121
+ assert.notEqual(digest, vp.manifestDigest(tampered))
122
+ assert.equal(vp.manifestDigest(null), null)
123
+ })
124
+
125
+ test('a legacy full-manifest digest written by an older build is still accepted', () => {
126
+ const legacy = vp.legacyManifestDigest(entry)
127
+ assert.equal(vp.manifestDigestAccepted(legacy, entry), true)
128
+ assert.equal(vp.manifestDigestAccepted(vp.manifestDigest(entry), entry), true)
129
+ assert.equal(vp.manifestDigestAccepted('0'.repeat(64), entry), false)
130
+ assert.equal(vp.manifestDigestAccepted(undefined, entry), true, 'profiles from before the digest existed')
131
+ })
132
+
133
+ test('profile depends on the catalog only: it never touches state', () => {
134
+ resetHome()
135
+ const built = vp.buildProfile(entry)
136
+ assert.equal(fs.existsSync(path.join(home, 'index')), false, 'building a profile must not write the active manifest')
137
+ assert.equal(built.fingerprint, vp.buildProfile(entry).fingerprint)
138
+ })
139
+
140
+ // ---------------------------------------------------------------- 拒绝
141
+
142
+ test('a pending model is refused instead of guessed', () => {
143
+ assertCode(() => vp.profileFromCatalog('multilingual-e5-small'), 'VECTOR_PROFILE_PENDING')
144
+ assertCode(() => vp.profileFromCatalog('all-minilm-l6-v2-int8'), 'VECTOR_PROFILE_PENDING')
145
+ assertCode(() => vp.profileFromCatalog('nope-not-in-catalog'), 'VECTOR_PROFILE_UNKNOWN')
146
+ assertCode(() => vp.profileFromCatalog(null), 'VECTOR_PROFILE_UNKNOWN')
147
+ })
148
+
149
+ test('an unverified manifest is refused', () => {
150
+ const unverified = clone(entry)
151
+ unverified.verification = { ...unverified.verification, status: 'unverified' }
152
+ assertCode(() => vp.assertManifestUsable(unverified), 'VECTOR_PROFILE_UNVERIFIED')
153
+ const noFiles = clone(entry)
154
+ noFiles.files = []
155
+ noFiles.graph = null
156
+ assertCode(() => vp.assertManifestUsable(noFiles), 'VECTOR_PROFILE_UNVERIFIED')
157
+ const tampered = clone(entry)
158
+ tampered.profileFingerprint = '0'.repeat(64)
159
+ assertCode(() => vp.assertManifestUsable(tampered), 'VECTOR_PROFILE_UNVERIFIED')
160
+ })
161
+
162
+ test('a profile whose catalog entry changed or vanished is refused', () => {
163
+ assertCode(() => vp.assertProfileVerified({ ...profile, fingerprint: 'a'.repeat(64) }), 'VECTOR_PROFILE_FINGERPRINT_MISMATCH')
164
+ assertCode(() => vp.assertProfileVerified({ ...profile, manifestDigest: 'b'.repeat(64) }), 'VECTOR_PROFILE_MANIFEST_CHANGED')
165
+ assertCode(() => vp.assertProfileVerified({ ...profile, catalogId: 'gone-model' }), 'VECTOR_PROFILE_UNKNOWN')
166
+ assertCode(() => vp.assertProfileVerified(null), 'VECTOR_PROFILE_MISSING')
167
+ assert.equal(vp.assertProfileVerified(profile), profile)
168
+ })
169
+
170
+ // ---------------------------------------------------------------- 路径
171
+
172
+ test('database paths are generation-scoped and live under librariesDir', () => {
173
+ const fp = profile.fingerprint
174
+ const short = fp.slice(0, 8)
175
+ assert.equal(vp.vectorDbRelPath({ fingerprint: fp, generation: 3 }), 'libraries/global.' + short + '.g3.sqlite3')
176
+ assert.equal(vp.vectorDbRelPath({ libraryId: 'lib_a', fingerprint: fp, generation: 3 }), 'libraries/lib_a.' + short + '.g3.sqlite3')
177
+ const glob = vp.vectorDbAbsPath({ fingerprint: fp, generation: 3 })
178
+ assert.equal(path.dirname(glob), librariesRoot, 'the global shadow must be scanned by backups')
179
+ assert.equal(vp.vectorDbAbsPath({ libraryId: null, fingerprint: fp, generation: 1 }), vp.vectorDbAbsPath({ fingerprint: fp, generation: 1 }))
180
+ assert.notEqual(vp.vectorDbAbsPath({ fingerprint: fp, generation: 1 }), vp.vectorDbAbsPath({ fingerprint: fp, generation: 2 }))
181
+ assertCode(() => vp.vectorDbRelPath({ libraryId: '../escape', fingerprint: fp, generation: 1 }), 'VECTOR_PATH_ESCAPE')
182
+ assertCode(() => vp.vectorDbRelPath({ fingerprint: fp, generation: 0 }), 'VECTOR_BAD_UPDATE')
183
+ })
184
+
185
+ test('stored paths cannot escape the data root', () => {
186
+ assertCode(() => vp.resolveVectorPath('../outside.sqlite3'), 'VECTOR_PATH_ESCAPE')
187
+ assertCode(() => vp.resolveVectorPath('libraries/../../outside.sqlite3'), 'VECTOR_PATH_ESCAPE')
188
+ assertCode(() => vp.resolveVectorPath(path.join(home, 'absolute.sqlite3')), 'VECTOR_PATH_ESCAPE')
189
+ assertCode(() => vp.resolveVectorPath(''), 'VECTOR_PATH_ESCAPE')
190
+ assert.equal(vp.resolveVectorPath('libraries/x.sqlite3'), path.join(librariesRoot, 'x.sqlite3'))
191
+ })
192
+
193
+ test('without an active generation the plugin keeps the legacy library paths', () => {
194
+ resetHome()
195
+ assert.equal(vp.dbPathFor('lib_a'), path.join(librariesRoot, 'lib_a.sqlite3'))
196
+ assert.equal(vp.dbPathFor(null), path.join(home, 'global.sqlite3'))
197
+ assert.equal(vp.activeProfile(), null)
198
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_NOT_ACTIVE')
199
+ assert.equal(vp.tryCaptureGeneration(), null)
200
+ })
201
+
202
+ // ---------------------------------------------------------------- 代际选择
203
+
204
+ test('one atomic manifest selects profile and database set together', () => {
205
+ resetHome()
206
+ const databases = activate({ generation: 2, libraryIds: ['lib_a', 'lib_b'] })
207
+ const active = vp.activeDatabases()
208
+ assert.equal(active.generation, 2)
209
+ assert.equal(active.fingerprint, profile.fingerprint)
210
+ assert.equal(active.global, path.join(home, ...databases.global.path.split('/')))
211
+ assert.equal(active.libraries.lib_a, path.join(home, ...databases.libraries.lib_a.path.split('/')))
212
+ assert.equal(active.libraries.lib_b, path.join(home, ...databases.libraries.lib_b.path.split('/')))
213
+ // 每个库都在同一个代际里(不会出现新旧混空间的中间态)
214
+ assert.match(active.libraries.lib_a, /\.g2\.sqlite3$/)
215
+ assert.match(active.libraries.lib_b, /\.g2\.sqlite3$/)
216
+ assert.equal(vp.dbPathFor('lib_a'), active.libraries.lib_a)
217
+ assert.equal(vp.dbPathFor('lib_b'), active.libraries.lib_b)
218
+ // active 代际里没有的库不猜路径:绝不再回落到 legacy 库或远端(那样写入会落在未受管的库里)
219
+ assertCode(() => vp.dbPathFor('lib_unknown'), 'VECTOR_TORN_STATE')
220
+ assert.equal(vp.activeGeneration(), 2)
221
+ assert.equal(vp.activeProfile().fingerprint, profile.fingerprint)
222
+ })
223
+
224
+ test('requested stays separate from active until a switch happens', () => {
225
+ resetHome()
226
+ assert.equal(vp.requestedProfile(), null)
227
+ vp.setRequestedProfile(BGE)
228
+ assert.equal(vp.requestedProfile().catalogId, BGE)
229
+ assert.equal(vp.requestedProfile().fingerprint, profile.fingerprint)
230
+ assert.equal(vp.activeProfile(), null, 'requesting a model must not activate it')
231
+ activate({ generation: 1 })
232
+ assert.equal(vp.requestedProfile().catalogId, BGE, 'activation keeps the requested record')
233
+ assert.equal(vp.activeGeneration(), 1)
234
+ vp.clearRequestedProfile()
235
+ assert.equal(vp.requestedProfile(), null)
236
+ assert.equal(vp.activeGeneration(), 1, 'clearing requested must not disturb the active generation')
237
+ assert.equal(vp.nextGeneration(), 2)
238
+ })
239
+
240
+ test('a torn active manifest is refused instead of guessed', () => {
241
+ resetHome()
242
+ const databases = activate({ generation: 4 })
243
+ // 缺库
244
+ fs.rmSync(path.join(home, ...databases.libraries.lib_a.path.split('/')))
245
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_TORN_STATE')
246
+ assert.equal(vp.vectorStatus().status, 'torn')
247
+ // 补回来又好了
248
+ touchDatabases(databases)
249
+ assert.equal(vp.assertVectorWriteAllowed().generation, 4)
250
+ // journal 说别的代际已提交
251
+ vp.updateVectorStateSync(state => ({ ...state, pending: { status: 'committed', generation: 9, fingerprint: profile.fingerprint, token: 't', previousGeneration: 4, profile, databases: state.active.databases } }))
252
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_TORN_STATE')
253
+ })
254
+
255
+ test('invalid or corrupt manifests are typed errors, never a silent fallback', () => {
256
+ resetHome()
257
+ fs.mkdirSync(path.join(home, 'index'), { recursive: true })
258
+ fs.writeFileSync(activeFile, '{ not json')
259
+ assertCode(() => vp.vectorState(), 'VECTOR_ACTIVE_CORRUPT')
260
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_ACTIVE_CORRUPT')
261
+ assertCode(() => vp.updateVectorStateSync(() => ({ version: 1 })), 'VECTOR_ACTIVE_CORRUPT')
262
+ fs.writeFileSync(activeFile, JSON.stringify({ version: 1, active: { status: 'active', generation: 1 } }))
263
+ assertCode(() => vp.vectorState(), 'VECTOR_ACTIVE_CORRUPT')
264
+ fs.writeFileSync(activeFile, JSON.stringify({ version: 1, active: null, pending: { status: 'weird' } }))
265
+ assertCode(() => vp.vectorState(), 'VECTOR_TORN_STATE')
266
+ })
267
+
268
+ test('paths recorded in the manifest cannot escape the data root', () => {
269
+ resetHome()
270
+ const good = activate({ generation: 3 })
271
+ vp.updateVectorStateSync(state => ({
272
+ ...state,
273
+ active: { ...state.active, databases: { global: { path: '../global.sqlite3' }, libraries: good.libraries } },
274
+ }))
275
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_TORN_STATE')
276
+ })
277
+
278
+ test('the manifest lock never spans an await', () => {
279
+ resetHome()
280
+ activate({ generation: 1 })
281
+ assertCode(() => vp.updateVectorStateSync(async state => state), 'VECTOR_BAD_UPDATE')
282
+ })
283
+
284
+ // ---------------------------------------------------------------- 护栏
285
+
286
+ test('guard captures one generation per turn and rejects stale ones', () => {
287
+ resetHome()
288
+ activate({ generation: 5, libraryIds: ['lib_a'] })
289
+ const captured = vp.captureGeneration()
290
+ assert.equal(captured.generation, 5)
291
+ assert.equal(captured.token, 'gen:5:' + profile.fingerprint.slice(0, 8))
292
+ assert.deepEqual(vp.assertGeneration(captured).generation, 5)
293
+ // 回合进行中切换了代际:in-flight 结果不得计入新代
294
+ activate({ generation: 6, libraryIds: ['lib_a'] })
295
+ assertCode(() => vp.assertGeneration(captured), 'VECTOR_STALE_GENERATION')
296
+ assertCode(() => vp.assertGeneration(null), 'VECTOR_NO_GENERATION')
297
+ // 库集合变了也算过期
298
+ const latest = vp.captureGeneration()
299
+ activate({ generation: 7, libraryIds: ['lib_a', 'lib_b'] })
300
+ assertCode(() => vp.assertGeneration(latest), 'VECTOR_STALE_GENERATION')
301
+ })
302
+
303
+ test('guard refuses vectors when the profile itself is no longer verified', () => {
304
+ resetHome()
305
+ activate({ generation: 2 })
306
+ vp.updateVectorStateSync(state => ({ ...state, active: { ...state.active, profile: { ...state.active.profile, manifestDigest: 'c'.repeat(64) } } }))
307
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_PROFILE_MANIFEST_CHANGED')
308
+ vp.updateVectorStateSync(state => ({ ...state, active: { ...state.active, profile: { ...state.active.profile, manifestDigest: profile.manifestDigest, catalogId: 'ghost' } } }))
309
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_PROFILE_UNKNOWN')
310
+ })
311
+
312
+ test('deep guard catches a database that shrank below its committed size', () => {
313
+ resetHome()
314
+ const databases = activate({ generation: 2 })
315
+ const target = path.join(home, ...databases.libraries.lib_a.path.split('/'))
316
+ fs.writeFileSync(target, 'sq')
317
+ assert.equal(vp.assertVectorWriteAllowed().generation, 2, 'runtime checks do not compare sizes')
318
+ assertCode(() => vp.assertVectorWriteAllowed({ deep: true }), 'VECTOR_TORN_STATE')
319
+ })
320
+
321
+ test('every vector error is a typed lexical-fallback error', () => {
322
+ const error = vp.vectorError('VECTOR_TEST', 'boom')
323
+ assert.equal(error.code, 'VECTOR_TEST')
324
+ assert.equal(error.fallback, 'lexical')
325
+ assert.equal(error.forbidLegacyRemote, true)
326
+ assert.equal(vp.isVectorStateError(error), true)
327
+ assert.equal(vp.isVectorStateError(new Error('plain')), false)
328
+ assert.equal(vp.isVectorStateError({ code: 'VECTOR_ACTIVE_CORRUPT' }), true)
329
+ assert.equal(code(() => vp.assertProfileVerified(null)), vp.VECTOR_ERROR_CODES.PROFILE_MISSING)
330
+ })
331
+
332
+ test('profileEnv and profileDatabaseMap describe the active generation without secrets', () => {
333
+ resetHome()
334
+ activate({ generation: 1, libraryIds: ['lib_a'] })
335
+ const env = vp.profileEnv(profile, { baseUrl: 'http://127.0.0.1:8123/', token: 'local-token' })
336
+ assert.equal(env.MEMU_EMBED_MODEL, BGE)
337
+ assert.equal(env.MEMU_BASE_URL, 'http://127.0.0.1:8123')
338
+ assert.equal(env.MEMU_API_KEY, 'local-token')
339
+ assert.equal(env.MEMU_TELEMETRY, '0')
340
+ const map = vp.profileDatabaseMap(['lib_a', 'lib_b'])
341
+ assert.equal(map.lib_a, vp.dbPathFor('lib_a'))
342
+ assert.match(map.lib_b, /lib_b\.[0-9a-f]{8}\.g\d+\.sqlite3$/)
343
+ assert.deepEqual(vp.profileEnv(null), {})
344
+ })
345
+
346
+ test('persisted manifest and derived protocol tampering are rejected', () => {
347
+ const changed = structuredClone(profile)
348
+ changed.manifest.pooling = 'mean'
349
+ assertCode(() => vp.assertProfileVerified(changed), 'VECTOR_PROFILE_MANIFEST_CHANGED')
350
+ assertCode(() => vp.assertProfileVerified({ ...profile, dimension: 12 }), 'VECTOR_PROFILE_MANIFEST_CHANGED')
351
+ })
352
+
353
+ test('async write leases and freezes exclude each other without holding filesystem locks', async () => {
354
+ resetHome()
355
+ activate()
356
+ let release
357
+ const gate = new Promise(resolve => { release = resolve })
358
+ const write = vp.withVectorWrite(async captured => {
359
+ assert.equal(captured.generation, 1)
360
+ await gate
361
+ })
362
+ assert.equal(fs.existsSync(activeFile + '.lock'), false)
363
+ await assert.rejects(vp.withVectorWriteFreeze(() => {}), { code: 'VECTOR_WRITE_BUSY' })
364
+ release()
365
+ await write
366
+ await vp.withVectorWriteFreeze(async () => {
367
+ assert.equal(fs.existsSync(activeFile + '.lock'), false)
368
+ await assert.rejects(vp.withVectorWrite(() => {}), { code: 'VECTOR_WRITE_FROZEN' })
369
+ assertCode(() => vp.assertVectorWriteAllowed(), 'VECTOR_WRITE_FROZEN')
370
+ assert.equal(vp.captureVectorGeneration().generation, 1)
371
+ })
372
+ assert.equal(vp.activeVectorState().writeGuard.frozen, null)
373
+ assert.deepEqual(vp.activeVectorState().writeGuard.leases, {})
374
+ })
375
+
376
+ test('exceptional write and freeze release all guards', async () => {
377
+ resetHome()
378
+ await assert.rejects(vp.withVectorWrite(async () => { throw new Error('write failed') }), /write failed/)
379
+ await assert.rejects(vp.withVectorWriteFreeze(async () => { throw new Error('freeze failed') }), /freeze failed/)
380
+ assert.equal(vp.activeVectorState().writeGuard.frozen, null)
381
+ assert.deepEqual(vp.activeVectorState().writeGuard.leases, {})
382
+ })
383
+
384
+ test('empty new library enrollment stays in current generation and is idempotent', async () => {
385
+ resetHome()
386
+ activate()
387
+ assertCode(() => vp.memuDbFor('new_library'), 'VECTOR_TORN_STATE')
388
+ assertCode(() => vp.enrollActiveLibrary('new_library'), 'VECTOR_WRITE_LEASE_REQUIRED')
389
+ const before = vp.captureVectorGeneration()
390
+ await vp.withVectorWrite(async () => {
391
+ const db = vp.enrollActiveLibrary('new_library')
392
+ assert.equal(vp.enrollActiveLibrary('new_library'), db)
393
+ assert.equal(fs.statSync(db).size, 0)
394
+ assert.equal(vp.memuDbFor('new_library'), db)
395
+ assert.equal(vp.activeGeneration(), before.generation)
396
+ assert.equal(vp.memuGlobalDbFor(), before.global)
397
+ assert.equal(vp.activeProfile().fingerprint, before.fingerprint)
398
+ })
399
+ })
400
+
401
+ test('nonempty corrupt and quarantined library indexes cannot enroll empty databases', async () => {
402
+ resetHome()
403
+ activate()
404
+ await vp.withVectorWrite(async () => {
405
+ for (const entries of [{ held: { text: 'held', status: 'pending_review' } }, { live: { text: 'live' } }]) {
406
+ assertCode(() => vp.enrollActiveLibrary('existing', { loadIndex: () => ({ entries }) }), 'VECTOR_LIBRARY_REBUILD_REQUIRED')
407
+ }
408
+ assertCode(() => vp.enrollActiveLibrary('async_index', { loadIndex: async () => ({ entries: {} }) }), 'VECTOR_BAD_UPDATE')
409
+ fs.writeFileSync(path.join(home, 'index', 'corrupt.json'), '{bad')
410
+ fs.writeFileSync(path.join(home, 'index', 'quarantined.json.corrupt-example'), '{bad')
411
+ for (const id of ['corrupt', 'quarantined']) assertCode(() => vp.enrollActiveLibrary(id), 'VECTOR_LIBRARY_REBUILD_REQUIRED')
412
+ })
413
+ assert.deepEqual(Object.keys(vp.activeVectorState().active.databases.libraries), ['lib_a'])
414
+ })
415
+
416
+ test('library enrollment never reuses preexisting generation database bytes', async () => {
417
+ resetHome()
418
+ activate()
419
+ const db = vp.vectorDbAbsPath({ libraryId: 'orphan', fingerprint: profile.fingerprint, generation: 1 })
420
+ fs.writeFileSync(db, 'do not overwrite')
421
+ await vp.withVectorWrite(async () => {
422
+ assertCode(() => vp.enrollActiveLibrary('orphan'), 'VECTOR_SHADOW_PATH_TAKEN')
423
+ })
424
+ assert.equal(fs.readFileSync(db, 'utf8'), 'do not overwrite')
425
+ })
@@ -0,0 +1,152 @@
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-visibility-'))
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 migrate = await import('../lib/migrate.js')
13
+ const visibility = await import('../lib/visibility.js')
14
+
15
+ test.after(() => {
16
+ try { state.flushState() } catch {}
17
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
18
+ })
19
+
20
+ const cwd = path.join(home, 'coding')
21
+ const library = state.createLibrary({ name: 'coding', scopeKey: cwd })
22
+ const execA = { agent: { session: { header: { id: 'conv-a', cwd } } } }
23
+ const execB = { agent: { session: { header: { id: 'conv-b', cwd } } } }
24
+ state.ensureSession('conv-a', { cwd })
25
+ state.ensureSession('conv-b', { cwd })
26
+
27
+ function indexFile(libraryId) {
28
+ return path.join(home, 'index', `${libraryId}.json`)
29
+ }
30
+
31
+ function writeLegacy(entries) {
32
+ fs.mkdirSync(path.join(home, 'index'), { recursive: true })
33
+ fs.writeFileSync(indexFile(library.id), JSON.stringify({ version: 1, entries }, null, 2))
34
+ }
35
+
36
+ function legacyEntry(overrides = {}) {
37
+ return {
38
+ id: 'mem-legacy',
39
+ title: '旧版本的会话记忆',
40
+ content: '这条记忆属于一个已经不存在的会话。',
41
+ visibility: 'session',
42
+ sessionId: 'session-gone',
43
+ context: {},
44
+ status: 'active',
45
+ layer: 'long_term',
46
+ owner: 'me',
47
+ source: 'inferred',
48
+ createdAt: '2026-10-01T00:00:00.000Z',
49
+ updatedAt: '2026-10-01T00:00:00.000Z',
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ test('the migration rule keeps context and drops the conversation', () => {
55
+ const entry = legacyEntry({ context: { characterId: 'hero', branchId: 'main' } })
56
+ assert.equal(visibility.isLegacySession(entry), true)
57
+ assert.equal(visibility.migrateEntryVisibility(entry, '2026-10-29T00:00:00.000Z'), true)
58
+ assert.equal(entry.visibility, 'shared')
59
+ assert.equal('sessionId' in entry, false)
60
+ assert.deepEqual(entry.context, { characterId: 'hero', branchId: 'main' }, 'context is the scope that survives')
61
+ assert.equal(entry.migratedFromVisibility, 'session')
62
+ assert.equal(entry.visibilityMigratedAt, '2026-10-29T00:00:00.000Z')
63
+ assert.equal(visibility.migrateEntryVisibility(entry), false, 'already migrated entries are left alone')
64
+
65
+ // Older writes also stamped `sessionId: null` onto ordinary entries. The field is dead
66
+ // now, and leaving it would make the file look like it still had the level.
67
+ const leftover = { id: 'mem-plain', visibility: 'shared', sessionId: null }
68
+ assert.equal(visibility.migrateEntryVisibility(leftover), true)
69
+ assert.equal('sessionId' in leftover, false)
70
+ assert.equal(leftover.migratedFromVisibility, undefined, 'a plain entry is not marked as migrated')
71
+ })
72
+
73
+ test('reads normalize the removed level without waiting for a write', () => {
74
+ writeLegacy({ 'mem-legacy': legacyEntry() })
75
+ const index = state.loadPrivateIndex(library.id)
76
+ assert.equal(index.entries['mem-legacy'].visibility, 'shared')
77
+ assert.equal('sessionId' in index.entries['mem-legacy'], false)
78
+ })
79
+
80
+ test('the repair script path rewrites the file and can prove it', () => {
81
+ const normal = legacyEntry({ id: 'mem-normal', visibility: 'secret' })
82
+ delete normal.sessionId
83
+ writeLegacy({ 'mem-legacy': legacyEntry(), 'mem-normal': normal })
84
+ const scan = migrate.scanLegacyLibraries()
85
+ assert.deepEqual(scan.map((s) => s.libraryId), [library.id])
86
+ assert.equal(scan[0].legacy, 1, 'a secret entry is not counted')
87
+
88
+ const report = migrate.migrateLibraries({ now: '2026-10-29T00:00:00.000Z' })
89
+ assert.equal(report.length, 1)
90
+ assert.equal(report[0].before, 1)
91
+ assert.equal(report[0].after, 0)
92
+ assert.equal(report[0].migrated, 1)
93
+
94
+ const onDisk = JSON.parse(fs.readFileSync(indexFile(library.id), 'utf8'))
95
+ assert.equal(onDisk.entries['mem-legacy'].visibility, 'shared')
96
+ assert.equal('sessionId' in onDisk.entries['mem-legacy'], false)
97
+ assert.equal(onDisk.entries['mem-normal'].visibility, 'secret', 'untouched entries keep their exact shape')
98
+
99
+ assert.deepEqual(migrate.scanLegacyLibraries(), [], 'nothing left to do')
100
+ assert.deepEqual(migrate.migrateLibraries(), [], 'a second run is a no-op')
101
+ })
102
+
103
+ test('the tool surface refuses the removed level with an explanation', async () => {
104
+ await assert.rejects(
105
+ () => memory.privateRemember({ exec: execA, title: 'x', content: 'y', visibility: 'session' }),
106
+ /visibility 'session' was removed/,
107
+ )
108
+ const written = await memory.privateRemember({ exec: execA, title: '正常', content: '一条正常的共享记忆。' })
109
+ assert.equal(written.entry.visibility, 'shared')
110
+ assert.equal('sessionId' in written.entry, false)
111
+ await assert.rejects(
112
+ () => memory.privateUpdate({ exec: execA, id: written.id, visibility: 'session' }),
113
+ /visibility 'session' was removed/,
114
+ )
115
+ })
116
+
117
+ test('a blank context is one shared scope: another conversation reads even a secret', async () => {
118
+ const secret = await memory.privateRemember({
119
+ exec: execA,
120
+ title: '空白作用域里的秘密',
121
+ content: '这件事不要主动提,但同文件夹问起来要说。',
122
+ visibility: 'secret',
123
+ })
124
+ const recalled = await memory.privateRecall({ exec: execB, query: '空白作用域里的秘密' })
125
+ assert.ok(recalled.files.some((f) => f.name === secret.id), 'an explicit recall crosses conversations when the scope is blank')
126
+ const listed = await memory.privateList({ exec: execB })
127
+ assert.ok(listed.files.some((f) => f.name === secret.id), 'the sidebar lists it too')
128
+ })
129
+
130
+ test('a secret is still kept out of automatic surfaces', async () => {
131
+ const secret = await memory.privateRemember({
132
+ exec: execA,
133
+ title: '只手动查的事',
134
+ content: '不要自己冒出来。',
135
+ visibility: 'secret',
136
+ })
137
+ const entry = state.loadPrivateIndex(library.id).entries[secret.id]
138
+ assert.equal(memory.isMemoryEligible(entry, { automatic: true }), false)
139
+ const automatic = await memory.privateRecall({ exec: execA, query: '只手动查的事', purpose: 'automatic' })
140
+ assert.equal(automatic.files.some((f) => f.name === secret.id), false)
141
+ })
142
+
143
+ test('a filled context is still a boundary', async () => {
144
+ state.setSessionBinding('conv-a', { memoryContext: { characterId: 'hero' } })
145
+ state.setSessionBinding('conv-b', { memoryContext: { characterId: 'villain' } })
146
+ const scoped = await memory.privateRemember({ exec: execA, title: '英雄知道的事', content: '只有 hero 这条线知道。' })
147
+ assert.equal(scoped.entry.context.characterId, 'hero')
148
+ const fromVillain = await memory.privateRecall({ exec: execB, query: '英雄知道的事' })
149
+ assert.equal(fromVillain.files.some((f) => f.name === scoped.id), false, 'another character does not read it')
150
+ const fromHero = await memory.privateRecall({ exec: execA, query: '英雄知道的事' })
151
+ assert.ok(fromHero.files.some((f) => f.name === scoped.id))
152
+ })