dsh-memu-memory 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,346 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import http from 'node:http'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import {
8
+ createOnnxAdapter,
9
+ manifestDimension,
10
+ manifestModelId,
11
+ resolveInsideModelDir,
12
+ startEmbeddingWorker,
13
+ } from '../lib/local-embedding-worker.js'
14
+ import { decodeVectorBase64 } from '../lib/local-embedding-protocol.js'
15
+
16
+ const TOKEN = 'test-worker-token-0123456789abcdef'
17
+ const DIMENSION = 8
18
+ const MODEL = 'bge-small-zh-v1.5@r1+mean-l2-512'
19
+
20
+ const manifest = (overrides = {}) => ({
21
+ id: 'bge-small-zh-v1.5',
22
+ revision: 'r1',
23
+ protocol: 'mean-l2-512',
24
+ verified: true,
25
+ dimension: DIMENSION,
26
+ graph: 'model.onnx',
27
+ tokenizer: { file: 'tokenizer.json' },
28
+ ...overrides,
29
+ })
30
+
31
+ const modelDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-embed-'))
32
+ test.after(() => fs.rmSync(modelDir, { recursive: true, force: true }))
33
+
34
+ const vectorFor = (text, dimension = DIMENSION) => {
35
+ const seed = [...String(text)].reduce((total, character) => total + character.codePointAt(0), 0)
36
+ return Array.from({ length: dimension }, (_unused, index) => Math.sin(seed * 0.37 + index) + 0.25)
37
+ }
38
+
39
+ function fakeAdapter({ vectors = null, usage = null } = {}) {
40
+ const calls = []
41
+ return {
42
+ calls,
43
+ encode: async (inputs) => {
44
+ calls.push([...inputs])
45
+ const produced = vectors ? vectors(inputs) : inputs.map((text) => vectorFor(text))
46
+ return usage ? { vectors: produced, usage } : produced
47
+ },
48
+ }
49
+ }
50
+
51
+ const silence = () => {
52
+ const lines = []
53
+ const record = (level) => (message) => { lines.push(level + ' ' + String(message)) }
54
+ return { lines, logger: { info: record('info'), warn: record('warn'), error: record('error'), log: record('info') } }
55
+ }
56
+
57
+ async function fixture({ adapter = fakeAdapter(), logger = silence().logger, ...overrides } = {}) {
58
+ const worker = await startEmbeddingWorker({
59
+ modelDir,
60
+ manifest: manifest(),
61
+ runtimeDir: modelDir,
62
+ token: TOKEN,
63
+ adapter,
64
+ logger,
65
+ ...overrides,
66
+ })
67
+ return { worker, adapter }
68
+ }
69
+
70
+ const auth = { authorization: 'Bearer ' + TOKEN, 'content-type': 'application/json' }
71
+
72
+ const post = (worker, body, headers = auth) => fetch(worker.url, {
73
+ method: 'POST',
74
+ headers,
75
+ body: JSON.stringify(body),
76
+ }).then(async (response) => ({ status: response.status, headers: response.headers, body: await response.json() }))
77
+
78
+ const rawCall = (baseUrl, { path: route = '/v1/embeddings', method = 'POST', headers = {}, body = null } = {}) => new Promise((resolve, reject) => {
79
+ const url = new URL(route, baseUrl)
80
+ const request = http.request({ hostname: url.hostname, port: url.port, path: url.pathname, method, headers }, (response) => {
81
+ let text = ''
82
+ response.setEncoding('utf8')
83
+ response.on('data', (chunk) => { text += chunk })
84
+ response.on('end', () => {
85
+ let json = null
86
+ try { json = JSON.parse(text) } catch {}
87
+ resolve({ status: response.statusCode, headers: response.headers, body: text, json })
88
+ })
89
+ })
90
+ request.on('error', reject)
91
+ if (body !== null) request.write(body)
92
+ request.end()
93
+ })
94
+
95
+ test('a verified manifest is required; pending, unverified and missing directories are refused', async () => {
96
+ const base = { modelDir, manifest: manifest(), runtimeDir: modelDir, token: TOKEN, adapter: fakeAdapter(), logger: silence().logger }
97
+ await assert.rejects(startEmbeddingWorker({ ...base, manifest: manifest({ status: 'pending', verified: false }) }), (error) => error.code === 'LOCAL_VECTOR_MANIFEST_UNVERIFIED')
98
+ await assert.rejects(startEmbeddingWorker({ ...base, manifest: manifest({ verified: false, status: 'available' }) }), (error) => error.code === 'LOCAL_VECTOR_MANIFEST_UNVERIFIED')
99
+ await assert.rejects(startEmbeddingWorker({ ...base, manifest: manifest({ verified: false }) }), (error) => error.code === 'LOCAL_VECTOR_MANIFEST_UNVERIFIED')
100
+ await assert.rejects(startEmbeddingWorker({ ...base, modelDir: '' }), (error) => error.code === 'LOCAL_VECTOR_MODEL_DIR_MISSING')
101
+ await assert.rejects(startEmbeddingWorker({ ...base, modelDir: path.join(modelDir, 'nope') }), (error) => error.code === 'LOCAL_VECTOR_MODEL_DIR_MISSING')
102
+ await assert.rejects(startEmbeddingWorker({ ...base, manifest: manifest({ dimension: undefined }) }), (error) => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
103
+ await assert.rejects(startEmbeddingWorker({ ...base, token: 'short' }), (error) => error.code === 'LOCAL_VECTOR_TOKEN_INVALID')
104
+ await assert.rejects(startEmbeddingWorker({ ...base, host: '0.0.0.0' }), (error) => error.code === 'LOCAL_VECTOR_BAD_HOST')
105
+ const statusVerified = await startEmbeddingWorker({ ...base, manifest: manifest({ verified: false, status: 'verified' }) })
106
+ assert.equal(statusVerified.dimension, DIMENSION)
107
+ await statusVerified.close()
108
+ })
109
+
110
+ test('the manifest fixes the model id and dimension exposed to MemU', async () => {
111
+ assert.equal(manifestModelId(manifest()), MODEL)
112
+ assert.equal(manifestModelId({ id: 'm', revision: 'r' }), 'm@r')
113
+ assert.equal(manifestModelId({ model: 'explicit-model' }), 'explicit-model')
114
+ assert.equal(manifestModelId({}), '')
115
+ assert.equal(manifestDimension({ expectedDimension: 512 }), 512)
116
+ assert.equal(manifestDimension({ output: { dimension: 384 } }), 384)
117
+ assert.equal(manifestDimension({}), 0)
118
+ })
119
+
120
+ test('the worker binds loopback only and reports the base URL MemU needs', async () => {
121
+ const { worker, adapter } = await fixture()
122
+ try {
123
+ assert.match(worker.baseUrl, /^http:\/\/127\.0\.0\.1:\d+\/v1$/)
124
+ assert.equal(worker.url, worker.baseUrl + '/embeddings')
125
+ assert.equal(worker.model, MODEL)
126
+ assert.equal(worker.dimension, DIMENSION)
127
+ assert.equal(worker.lastError, null)
128
+ // ready means one smoke encode already produced a finite vector
129
+ assert.deepEqual(adapter.calls.length, 1)
130
+ assert.equal(adapter.calls[0].length, 1)
131
+ } finally {
132
+ await worker.close()
133
+ }
134
+ })
135
+
136
+ test('POST /v1/embeddings serves float and base64 vectors in input order', async () => {
137
+ const { worker } = await fixture()
138
+ try {
139
+ const single = await post(worker, { model: MODEL, input: '一个人在海边散步' })
140
+ assert.equal(single.status, 200)
141
+ assert.equal(single.body.object, 'list')
142
+ assert.equal(single.body.model, MODEL)
143
+ assert.equal(single.body.data.length, 1)
144
+ assert.equal(single.body.data[0].index, 0)
145
+ assert.deepEqual(single.body.data[0].embedding.map((value) => Math.round(value * 1e6) / 1e6), vectorFor('一个人在海边散步').map((value) => Math.round(value * 1e6) / 1e6))
146
+ assert.ok(Number.isFinite(single.body.usage.prompt_tokens))
147
+
148
+ const texts = ['alpha', 'beta', 'gamma']
149
+ const batch = await post(worker, { model: MODEL, input: texts })
150
+ assert.deepEqual(batch.body.data.map((entry) => entry.index), [0, 1, 2])
151
+ assert.deepEqual(batch.body.data[1].embedding.map((value) => Math.round(value * 1e6) / 1e6), vectorFor('beta').map((value) => Math.round(value * 1e6) / 1e6))
152
+
153
+ const encoded = await post(worker, { model: MODEL, input: texts, encoding_format: 'base64' })
154
+ assert.equal(encoded.status, 200)
155
+ assert.equal(typeof encoded.body.data[0].embedding, 'string')
156
+ const decoded = decodeVectorBase64(encoded.body.data[2].embedding, DIMENSION)
157
+ assert.deepEqual(decoded.map((value) => Math.round(value * 1e6) / 1e6), vectorFor('gamma').map((value) => Math.round(value * 1e6) / 1e6))
158
+ } finally {
159
+ await worker.close()
160
+ }
161
+ })
162
+
163
+ test('adapter usage is passed through when the runtime knows token counts', async () => {
164
+ const adapter = fakeAdapter({ usage: { prompt_tokens: 11, total_tokens: 11 } })
165
+ const { worker } = await fixture({ adapter })
166
+ try {
167
+ const response = await post(worker, { model: MODEL, input: 'hello' })
168
+ assert.deepEqual(response.body.usage, { prompt_tokens: 11, total_tokens: 11 })
169
+ } finally {
170
+ await worker.close()
171
+ }
172
+ })
173
+
174
+ test('auth, origin, host, method, content type and JSON failures are all refused', async () => {
175
+ const { worker, adapter } = await fixture()
176
+ try {
177
+ const noToken = await post(worker, { model: MODEL, input: 'x' }, { 'content-type': 'application/json' })
178
+ assert.equal(noToken.status, 401)
179
+ assert.equal(noToken.body.error.code, 'LOCAL_VECTOR_UNAUTHORIZED')
180
+ assert.equal(noToken.headers.get('www-authenticate'), 'Bearer')
181
+
182
+ const wrongToken = await post(worker, { model: MODEL, input: 'x' }, { authorization: 'Bearer nope-nope-nope-nope', 'content-type': 'application/json' })
183
+ assert.equal(wrongToken.status, 401)
184
+
185
+ const origin = await rawCall(worker.baseUrl, { headers: { ...auth, origin: 'https://evil.example' }, body: JSON.stringify({ model: MODEL, input: 'x' }) })
186
+ assert.equal(origin.status, 403)
187
+ assert.equal(origin.json.error.code, 'LOCAL_VECTOR_CORS_REJECTED')
188
+ assert.equal(origin.headers['access-control-allow-origin'], undefined)
189
+
190
+ const host = await rawCall(worker.baseUrl, { headers: { ...auth, host: 'evil.example' }, body: JSON.stringify({ model: MODEL, input: 'x' }) })
191
+ assert.equal(host.status, 403)
192
+ assert.equal(host.json.error.code, 'LOCAL_VECTOR_BAD_HOST')
193
+
194
+ const wrongMethod = await rawCall(worker.baseUrl, { method: 'GET', headers: auth })
195
+ assert.equal(wrongMethod.status, 405)
196
+ assert.equal(wrongMethod.headers.allow, 'POST')
197
+
198
+ const wrongType = await rawCall(worker.baseUrl, { headers: { ...auth, 'content-type': 'text/plain' }, body: 'hello' })
199
+ assert.equal(wrongType.status, 415)
200
+ assert.equal(wrongType.json.error.code, 'LOCAL_VECTOR_UNSUPPORTED_MEDIA_TYPE')
201
+
202
+ const brokenJson = await rawCall(worker.baseUrl, { headers: { ...auth, 'content-length': '7' }, body: 'notjson' })
203
+ assert.equal(brokenJson.status, 400)
204
+ assert.equal(brokenJson.json.error.code, 'LOCAL_VECTOR_BAD_REQUEST')
205
+
206
+ const unknownPath = await rawCall(worker.baseUrl, { path: '/v1/models', headers: auth })
207
+ assert.equal(unknownPath.status, 404)
208
+ assert.equal(unknownPath.json.error.code, 'LOCAL_VECTOR_NOT_FOUND')
209
+ // none of the refused requests reached the model
210
+ assert.equal(adapter.calls.length, 1)
211
+ } finally {
212
+ await worker.close()
213
+ }
214
+ })
215
+
216
+ test('an unknown model is refused instead of being served by the loaded weights', async () => {
217
+ const { worker, adapter } = await fixture()
218
+ try {
219
+ const response = await post(worker, { model: 'text-embedding-3-small', input: 'x' })
220
+ assert.equal(response.status, 400)
221
+ assert.equal(response.body.error.code, 'LOCAL_VECTOR_MODEL_MISMATCH')
222
+ assert.equal(adapter.calls.length, 1)
223
+ } finally {
224
+ await worker.close()
225
+ }
226
+ })
227
+
228
+ test('body and batch limits are enforced before inference', async () => {
229
+ const { worker, adapter } = await fixture({ maxBatch: 2, maxBodyBytes: 256 })
230
+ try {
231
+ const tooMany = await post(worker, { model: MODEL, input: ['a', 'b', 'c'] })
232
+ assert.equal(tooMany.status, 400)
233
+ assert.equal(tooMany.body.error.code, 'LOCAL_VECTOR_BATCH_TOO_LARGE')
234
+
235
+ const bigBody = await rawCall(worker.baseUrl, { headers: { ...auth, 'content-length': '4096' }, body: '{}' })
236
+ assert.equal(bigBody.status, 413)
237
+ assert.equal(bigBody.json.error.code, 'LOCAL_VECTOR_BODY_TOO_LARGE')
238
+ assert.equal(adapter.calls.length, 1)
239
+ } finally {
240
+ await worker.close()
241
+ }
242
+ })
243
+
244
+ test('broken vectors fail the request and leave a typed lastError behind', async () => {
245
+ const cases = [
246
+ { adapter: fakeAdapter({ vectors: (inputs) => inputs.map(() => new Array(DIMENSION - 1).fill(0.5)) }), code: 'LOCAL_VECTOR_DIMENSION_MISMATCH' },
247
+ { adapter: fakeAdapter({ vectors: (inputs) => inputs.map(() => new Array(DIMENSION).fill(0)) }), code: 'LOCAL_VECTOR_ZERO_VECTOR' },
248
+ { adapter: fakeAdapter({ vectors: (inputs) => inputs.map(() => [Number.NaN, ...new Array(DIMENSION - 1).fill(0.5)]) }), code: 'LOCAL_VECTOR_INVALID_VECTOR' },
249
+ { adapter: fakeAdapter({ vectors: () => [] }), code: 'LOCAL_VECTOR_VECTOR_COUNT_MISMATCH' },
250
+ ]
251
+ for (const item of cases) {
252
+ // the smoke encode uses the same adapter, so a broken adapter is refused at start
253
+ await assert.rejects(fixture({ adapter: item.adapter }), (error) => typeof error.code === 'string' && error.code.startsWith('LOCAL_VECTOR_'))
254
+ }
255
+
256
+ const good = fakeAdapter()
257
+ let broken = true
258
+ const flaky = {
259
+ encode: async (inputs) => {
260
+ if (!broken) return good.encode(inputs)
261
+ // first call (smoke) is healthy, every later call returns a wrong dimension
262
+ if (good.calls.length === 0) return good.encode(inputs)
263
+ return inputs.map(() => new Array(DIMENSION - 1).fill(0.5))
264
+ },
265
+ }
266
+ const { worker } = await fixture({ adapter: flaky })
267
+ try {
268
+ const failed = await post(worker, { model: MODEL, input: 'x' })
269
+ assert.equal(failed.status, 500)
270
+ assert.equal(failed.body.error.code, 'LOCAL_VECTOR_DIMENSION_MISMATCH')
271
+ assert.equal(worker.lastError.code, 'LOCAL_VECTOR_DIMENSION_MISMATCH')
272
+ assert.ok(worker.lastError.at)
273
+ broken = false
274
+ const healthy = await post(worker, { model: MODEL, input: 'y' })
275
+ assert.equal(healthy.status, 200)
276
+ assert.equal(worker.lastError, null)
277
+ } finally {
278
+ await worker.close()
279
+ }
280
+ })
281
+
282
+ test('GET /health answers behind the token and never leaks the token or input text into logs', async () => {
283
+ const sink = silence()
284
+ const { worker } = await fixture({ logger: sink.logger })
285
+ try {
286
+ const health = await fetch(worker.healthUrl, { headers: { authorization: 'Bearer ' + TOKEN } })
287
+ assert.equal(health.status, 200)
288
+ const body = await health.json()
289
+ assert.equal(body.status, 'ok')
290
+ assert.equal(body.model, MODEL)
291
+ assert.equal(body.dimension, DIMENSION)
292
+ assert.deepEqual(body.encodings, ['float', 'base64'])
293
+ assert.equal(await (await fetch(worker.healthUrl)).status, 401)
294
+
295
+ await post(worker, { model: MODEL, input: '机密用户原文 secret-input-text' })
296
+ await post(worker, { model: 'wrong-model', input: '机密用户原文 secret-input-text' })
297
+ await fetch(worker.url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"input":"保密"}' })
298
+
299
+ const logged = sink.lines.join('\n')
300
+ assert.ok(!logged.includes(TOKEN))
301
+ assert.ok(!logged.includes('secret-input-text'))
302
+ assert.ok(!logged.includes('机密用户原文'))
303
+ assert.ok(!logged.includes('保密'))
304
+ } finally {
305
+ await worker.close()
306
+ }
307
+ })
308
+
309
+ test('close is idempotent, stops the listener and honours an abort signal', async () => {
310
+ const { worker } = await fixture()
311
+ await worker.close()
312
+ await worker.close()
313
+ await assert.rejects(fetch(worker.url, { method: 'POST', headers: auth, body: '{}' }))
314
+
315
+ const controller = new AbortController()
316
+ const aborted = await fixture({ signal: controller.signal })
317
+ const before = await fetch(aborted.worker.healthUrl, { headers: { authorization: 'Bearer ' + TOKEN } })
318
+ assert.equal(before.status, 200)
319
+ controller.abort()
320
+ await new Promise((resolve) => setTimeout(resolve, 100))
321
+ await assert.rejects(fetch(aborted.worker.healthUrl, { headers: { authorization: 'Bearer ' + TOKEN } }))
322
+ })
323
+
324
+ test('model files are resolved inside the model directory and cannot escape it', () => {
325
+ fs.writeFileSync(path.join(modelDir, 'tokenizer.json'), '{}')
326
+ assert.equal(path.basename(resolveInsideModelDir(modelDir, 'tokenizer.json')), 'tokenizer.json')
327
+ assert.throws(() => resolveInsideModelDir(modelDir, '../escape.json'), (error) => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
328
+ assert.throws(() => resolveInsideModelDir(modelDir, 'C:/Windows/win.ini'), (error) => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
329
+ assert.throws(() => resolveInsideModelDir(modelDir, 'missing.onnx'), (error) => error.code === 'LOCAL_VECTOR_MODEL_FILE_MISSING')
330
+ })
331
+
332
+ test('without an injected adapter the pinned runtime is required and never downloaded', async () => {
333
+ await assert.rejects(
334
+ createOnnxAdapter({ modelDir, manifest: manifest(), runtimeDir: modelDir }),
335
+ (error) => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE' || error.code === 'LOCAL_VECTOR_MODEL_FILE_MISSING',
336
+ )
337
+ fs.writeFileSync(path.join(modelDir, 'model.onnx'), 'not a graph')
338
+ await assert.rejects(
339
+ createOnnxAdapter({ modelDir, manifest: manifest({ runtime: { module: 'onnxruntime-node-not-installed' } }), runtimeDir: modelDir }),
340
+ (error) => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE' && /not installed/.test(error.message),
341
+ )
342
+ await assert.rejects(
343
+ startEmbeddingWorker({ modelDir, manifest: manifest(), runtimeDir: modelDir, token: TOKEN, logger: silence().logger, adapter: null }),
344
+ (error) => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE' || error.code === 'LOCAL_VECTOR_MODEL_FILE_MISSING',
345
+ )
346
+ })