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,476 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { EventEmitter } from 'node:events'
4
+ import { spawn as spawnReal } from 'node:child_process'
5
+ import { createEmbeddingWorkerManager, childEnvironment, embeddingWorkerManager, closeEmbeddingWorkers } from '../lib/local-embedding-manager.js'
6
+
7
+ const MODEL = 'bge-small-zh-v1.5@r1+mean-l2-512'
8
+ const TOKEN_PATTERN = /^[0-9a-f]{64}$/
9
+
10
+ const PROFILE = Object.freeze({
11
+ catalogId: 'bge-small-zh-v1.5',
12
+ fingerprint: 'f'.repeat(64),
13
+ dimension: 512,
14
+ modelDir: 'D:/managed/model',
15
+ runtimeDir: 'D:/managed/runtime',
16
+ manifest: Object.freeze({ id: 'bge-small-zh-v1.5', revision: 'r1', protocol: 'mean-l2-512', dimension: 512, verified: true }),
17
+ })
18
+
19
+ const readyLine = (overrides = {}) => ({
20
+ type: 'ready',
21
+ baseUrl: 'http://127.0.0.1:45123/v1',
22
+ url: 'http://127.0.0.1:45123/v1/embeddings',
23
+ model: MODEL,
24
+ dimension: 512,
25
+ pid: 4242,
26
+ ...overrides,
27
+ })
28
+
29
+ const tick = () => new Promise(resolve => setImmediate(resolve))
30
+ const push = (child, value) => child.stdout.emit('data', Buffer.from(JSON.stringify(value) + String.fromCharCode(10)))
31
+ const rawPush = (child, text) => child.stdout.emit('data', Buffer.from(text))
32
+ const exitChild = (child, code = 1) => child.emit('close', code)
33
+
34
+ // A stand-in for a spawned worker: real EventEmitters with the same event names, so
35
+ // the manager's lifecycle can be exercised without a native runtime on any platform.
36
+ function fakeSpawn() {
37
+ const calls = []
38
+ const children = []
39
+ const spawnImpl = (command, args, options) => {
40
+ const child = new EventEmitter()
41
+ child.command = command
42
+ child.args = args
43
+ child.options = options
44
+ child.events = []
45
+ child.stdin = new EventEmitter()
46
+ child.stdin.ended = false
47
+ child.stdin.end = () => { child.events.push('stdin.end'); child.stdin.ended = true }
48
+ child.stdout = new EventEmitter()
49
+ child.stderr = new EventEmitter()
50
+ child.killed = false
51
+ child.kill = () => { child.events.push('kill'); child.killed = true }
52
+ calls.push({ command, args, options })
53
+ children.push(child)
54
+ return child
55
+ }
56
+ return { spawnImpl, calls, children }
57
+ }
58
+
59
+ function captureLogger() {
60
+ const lines = []
61
+ const record = level => message => lines.push(level + ':' + String(message))
62
+ return { lines, logger: { log: record('log'), info: record('info'), warn: record('warn'), error: record('error') } }
63
+ }
64
+
65
+ const makeManager = (overrides = {}) => createEmbeddingWorkerManager({
66
+ verifyProfile: () => PROFILE,
67
+ logger: { warn: () => {}, info: () => {}, error: () => {} },
68
+ timeoutMs: 5_000,
69
+ ...overrides,
70
+ })
71
+
72
+ test('concurrent acquires of one profile share a single worker and a bare loopback endpoint', async () => {
73
+ const { spawnImpl, calls, children } = fakeSpawn()
74
+ const manager = makeManager({ spawnImpl })
75
+ const first = manager.acquire(PROFILE)
76
+ const second = manager.acquire(PROFILE)
77
+ assert.equal(calls.length, 1, 'a second acquire must not spawn a second child')
78
+ assert.deepEqual(manager.status(), { running: 0, booting: 1, closed: false, tripped: 0 })
79
+ push(children[0], readyLine())
80
+ const [a, b] = await Promise.all([first, second])
81
+ assert.equal(calls.length, 1)
82
+ assert.equal(a, b, 'both callers observe the same endpoint object')
83
+ assert.ok(Object.isFrozen(a), 'the endpoint handed to MemU is immutable')
84
+ assert.equal(a.baseUrl, 'http://127.0.0.1:45123/v1')
85
+ assert.equal(a.model, MODEL, 'the ready model id is the manifest id, not the catalog id')
86
+ assert.equal(a.dimension, 512)
87
+ assert.match(a.token, TOKEN_PATTERN)
88
+ assert.deepEqual(manager.status(), { running: 1, booting: 0, closed: false, tripped: 0 })
89
+ const { args, options } = calls[0]
90
+ assert.equal(args.length, 1)
91
+ assert.match(args[0], /local-embedding-entry\.js$/)
92
+ assert.equal(options.env.DSH_MEMU_EMBED_TOKEN, a.token)
93
+ assert.equal(options.env.DSH_MEMU_EMBED_HOST, '127.0.0.1')
94
+ assert.equal(options.env.DSH_MEMU_EMBED_PORT, '0')
95
+ assert.equal(options.env.DSH_MEMU_EMBED_MODEL_DIR, PROFILE.modelDir)
96
+ assert.equal(options.env.DSH_MEMU_EMBED_RUNTIME_DIR, PROFILE.runtimeDir)
97
+ assert.equal(options.env.MEMU_TELEMETRY, '0')
98
+ assert.equal(options.env.DO_NOT_TRACK, '1')
99
+ assert.equal(options.env.ELECTRON_RUN_AS_NODE, '1')
100
+ assert.equal(JSON.parse(options.env.DSH_MEMU_EMBED_MANIFEST).verified, true)
101
+ assert.equal(manager.close(), 1)
102
+ assert.deepEqual(children[0].events, ['stdin.end', 'kill'], 'stdin EOF is asked for before the kill')
103
+ assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: true, tripped: 0 })
104
+ })
105
+
106
+ test('every worker gets a fresh random token that never reaches logs, status or errors', async () => {
107
+ const log = captureLogger()
108
+ const { spawnImpl, children } = fakeSpawn()
109
+ const manager = makeManager({ spawnImpl, logger: log.logger })
110
+ const firstBoot = manager.acquire(PROFILE)
111
+ push(children[0], readyLine())
112
+ const endpoint = await firstBoot
113
+ assert.match(endpoint.token, TOKEN_PATTERN)
114
+ const other = manager.acquire({ ...PROFILE, fingerprint: 'e'.repeat(64) })
115
+ assert.notEqual(children[1].options.env.DSH_MEMU_EMBED_TOKEN, endpoint.token, 'tokens are per worker')
116
+ // A worker that echoes its own secret back must not get it into any message.
117
+ push(children[1], readyLine({ baseUrl: 'http://user:' + endpoint.token + '@127.0.0.1:45124/v1' }))
118
+ await assert.rejects(other, error => {
119
+ assert.equal(error.code, 'LOCAL_VECTOR_BAD_READY')
120
+ assert.equal(error.fallback, 'lexical')
121
+ assert.ok(!String(error.message).includes(endpoint.token))
122
+ return true
123
+ })
124
+ await tick()
125
+ const surface = JSON.stringify(manager.status()) + log.lines.join(String.fromCharCode(10))
126
+ assert.ok(!surface.includes(endpoint.token), 'no token may appear in status or logs')
127
+ assert.ok(!surface.includes('45124'), 'no endpoint may appear in status or logs')
128
+ manager.close()
129
+ })
130
+
131
+ test('readiness is validated against the requested profile in every direction', async () => {
132
+ const cases = [
133
+ ['a non-object', 'oops'],
134
+ ['a null message', null],
135
+ ['a wrong type', readyLine({ type: 'hello' })],
136
+ ['another model', readyLine({ model: 'other-model@r1+mean-l2-512' })],
137
+ ['the catalog id instead of the manifest id', readyLine({ model: PROFILE.catalogId })],
138
+ ['another dimension', readyLine({ dimension: 384 })],
139
+ ['a wildcard host', readyLine({ baseUrl: 'http://0.0.0.0:45123/v1', url: null })],
140
+ ['a remote host', readyLine({ baseUrl: 'http://10.0.0.2:45123/v1', url: null })],
141
+ ['the localhost alias', readyLine({ baseUrl: 'http://localhost:45123/v1', url: null })],
142
+ ['https', readyLine({ baseUrl: 'https://127.0.0.1:45123/v1', url: null })],
143
+ ['a missing port', readyLine({ baseUrl: 'http://127.0.0.1/v1', url: null })],
144
+ ['port zero', readyLine({ baseUrl: 'http://127.0.0.1:0/v1', url: null })],
145
+ ['embedded credentials', readyLine({ baseUrl: 'http://user:pass@127.0.0.1:45123/v1', url: null })],
146
+ ['a query string', readyLine({ baseUrl: 'http://127.0.0.1:45123/v1?x=1', url: null })],
147
+ ['a fragment', readyLine({ baseUrl: 'http://127.0.0.1:45123/v1#x', url: null })],
148
+ ['another path', readyLine({ baseUrl: 'http://127.0.0.1:45123/', url: null })],
149
+ ['an embeddings url on another base', readyLine({ url: 'http://127.0.0.1:45123/v2/embeddings' })],
150
+ ]
151
+ for (const [label, value] of cases) {
152
+ const { spawnImpl, children } = fakeSpawn()
153
+ const manager = makeManager({ spawnImpl })
154
+ const pending = manager.acquire(PROFILE)
155
+ if (typeof value === 'string') rawPush(children[0], value + String.fromCharCode(10))
156
+ else push(children[0], value)
157
+ await assert.rejects(pending, error => {
158
+ assert.equal(error.code, 'LOCAL_VECTOR_BAD_READY', label)
159
+ assert.equal(error.fallback, 'lexical', label)
160
+ return true
161
+ })
162
+ assert.equal(children[0].stdin.ended, true, label)
163
+ assert.equal(children[0].killed, true, label)
164
+ assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 0 }, label)
165
+ manager.close()
166
+ }
167
+ })
168
+
169
+ test('unreadable and oversized readiness lines fail closed', async () => {
170
+ const unreadable = fakeSpawn()
171
+ const unreadableManager = makeManager({ spawnImpl: unreadable.spawnImpl })
172
+ const pending = unreadableManager.acquire(PROFILE)
173
+ rawPush(unreadable.children[0], 'not json at all' + String.fromCharCode(10))
174
+ await assert.rejects(pending, error => error.code === 'LOCAL_VECTOR_BAD_READY')
175
+ unreadableManager.close()
176
+
177
+ const oversized = fakeSpawn()
178
+ const oversizedManager = makeManager({ spawnImpl: oversized.spawnImpl })
179
+ const chunked = oversizedManager.acquire(PROFILE)
180
+ rawPush(oversized.children[0], 'x'.repeat(20_000))
181
+ await assert.rejects(chunked, error => error.code === 'LOCAL_VECTOR_BAD_READY')
182
+ assert.equal(oversized.children[0].killed, true)
183
+ oversizedManager.close()
184
+ })
185
+
186
+ test('a worker that never reports ready times out, is stopped and stays observable', async () => {
187
+ const log = captureLogger()
188
+ const { spawnImpl, children } = fakeSpawn()
189
+ const manager = makeManager({ spawnImpl, logger: log.logger, timeoutMs: 25 })
190
+ await assert.rejects(manager.acquire(PROFILE), error => {
191
+ assert.equal(error.code, 'LOCAL_VECTOR_START_TIMEOUT')
192
+ assert.equal(error.fallback, 'lexical')
193
+ return true
194
+ })
195
+ assert.deepEqual(children[0].events, ['stdin.end', 'kill'])
196
+ assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 0 })
197
+ manager.close()
198
+ })
199
+
200
+ test('healthy boots never count toward the fuse while an unexpected exit does', async () => {
201
+ const { spawnImpl, calls, children } = fakeSpawn()
202
+ const manager = makeManager({ spawnImpl, maxRestarts: 1, restartWindowMs: 60_000 })
203
+ const first = manager.acquire(PROFILE)
204
+ push(children[0], readyLine())
205
+ assert.equal((await first).model, MODEL)
206
+ const second = manager.acquire({ ...PROFILE, fingerprint: '1'.repeat(64) })
207
+ push(children[1], readyLine())
208
+ assert.equal((await second).model, MODEL)
209
+ assert.deepEqual(manager.status(), { running: 2, booting: 0, closed: false, tripped: 0 })
210
+ assert.equal(calls.length, 2)
211
+ // An exit the manager did not ask for is counted, so that profile is fused.
212
+ exitChild(children[0], 1)
213
+ assert.equal(manager.status().tripped, 1)
214
+ await assert.rejects(manager.acquire(PROFILE), error => error.code === 'LOCAL_VECTOR_RESTART_FUSED')
215
+ assert.equal(manager.status().running, 1, 'the other profile keeps its worker')
216
+ assert.equal(calls.length, 2)
217
+ manager.close()
218
+ })
219
+
220
+ test('failed boots trip the fuse, block respawns, and the cooldown releases it', async () => {
221
+ let clock = 7_000_000
222
+ const log = captureLogger()
223
+ const { spawnImpl, calls, children } = fakeSpawn()
224
+ const manager = makeManager({ spawnImpl, logger: log.logger, now: () => clock, maxRestarts: 2, restartWindowMs: 60_000 })
225
+ for (const index of [0, 1]) {
226
+ const pending = manager.acquire(PROFILE)
227
+ push(children[index], readyLine({ model: 'some-other-model@r1+mean' }))
228
+ await assert.rejects(pending, error => error.code === 'LOCAL_VECTOR_BAD_READY')
229
+ clock += 1_000
230
+ }
231
+ assert.equal(calls.length, 2)
232
+ await assert.rejects(manager.acquire(PROFILE), error => {
233
+ assert.equal(error.code, 'LOCAL_VECTOR_RESTART_FUSED')
234
+ assert.equal(error.fallback, 'lexical')
235
+ return true
236
+ })
237
+ assert.equal(calls.length, 2, 'a fused profile must not spawn again')
238
+ assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 1 })
239
+ // The fuse is per profile: another fingerprint still boots.
240
+ const otherProfile = manager.acquire({ ...PROFILE, fingerprint: 'a'.repeat(64) })
241
+ push(children[2], readyLine())
242
+ assert.equal((await otherProfile).model, MODEL)
243
+ assert.equal(manager.status().tripped, 1)
244
+ // After the window the same profile is allowed one more attempt.
245
+ clock += 61_000
246
+ assert.equal(manager.status().tripped, 0)
247
+ const retry = manager.acquire(PROFILE)
248
+ push(children[3], readyLine())
249
+ assert.equal((await retry).model, MODEL)
250
+ assert.equal(manager.status().running, 2)
251
+ manager.close()
252
+ })
253
+
254
+ test('an unexpected exit after ready is warned about and the next acquire starts a new child', async () => {
255
+ const log = captureLogger()
256
+ const { spawnImpl, children } = fakeSpawn()
257
+ const manager = makeManager({ spawnImpl, logger: log.logger })
258
+ const pending = manager.acquire(PROFILE)
259
+ push(children[0], readyLine())
260
+ const endpoint = await pending
261
+ exitChild(children[0], 1)
262
+ assert.equal(manager.status().running, 0)
263
+ assert.ok(log.lines.some(line => line.startsWith('warn:') && line.includes('exited')), 'an exit must stay observable')
264
+ const again = manager.acquire(PROFILE)
265
+ push(children[1], readyLine({ baseUrl: 'http://127.0.0.1:45124/v1', url: 'http://127.0.0.1:45124/v1/embeddings' }))
266
+ const next = await again
267
+ assert.notEqual(next.token, endpoint.token)
268
+ assert.notEqual(next.baseUrl, endpoint.baseUrl)
269
+ manager.close()
270
+ })
271
+
272
+ test('close rejects an in-flight acquire promptly instead of waiting for the child', async () => {
273
+ const log = captureLogger()
274
+ const { spawnImpl, calls, children } = fakeSpawn()
275
+ const manager = makeManager({ spawnImpl, logger: log.logger, timeoutMs: 120_000 })
276
+ const pending = manager.acquire(PROFILE)
277
+ assert.equal(manager.close(), 1)
278
+ await assert.rejects(pending, error => {
279
+ assert.equal(error.code, 'LOCAL_VECTOR_MANAGER_CLOSED')
280
+ assert.equal(error.fallback, 'lexical')
281
+ return true
282
+ })
283
+ assert.deepEqual(children[0].events, ['stdin.end', 'kill'])
284
+ // The late exit of the child we killed is not a crash and is not reported.
285
+ exitChild(children[0], 0)
286
+ await tick()
287
+ assert.deepEqual(log.lines, [])
288
+ assert.equal(manager.status().closed, true)
289
+ assert.equal(manager.close(), 0, 'close is idempotent')
290
+ await assert.rejects(manager.acquire(PROFILE), error => error.code === 'LOCAL_VECTOR_MANAGER_CLOSED')
291
+ assert.equal(calls.length, 1, 'a closed manager never spawns')
292
+ })
293
+
294
+ test('an EPIPE on the worker pipes degrades recall instead of crashing the host', async () => {
295
+ const { spawnImpl, children } = fakeSpawn()
296
+ const manager = makeManager({ spawnImpl })
297
+ const pending = manager.acquire(PROFILE)
298
+ const child = children[0]
299
+ const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE', errno: -4047 })
300
+ // The child died; its pipes now emit 'error'. Without a listener this is an
301
+ // uncaught exception in this very process.
302
+ assert.doesNotThrow(() => child.stdin.emit('error', epipe))
303
+ assert.doesNotThrow(() => child.stdout.emit('error', epipe))
304
+ assert.doesNotThrow(() => child.stderr.emit('error', epipe))
305
+ exitChild(child, 1)
306
+ await assert.rejects(pending, error => error.code === 'LOCAL_VECTOR_WORKER_EXITED')
307
+ assert.doesNotThrow(() => manager.close())
308
+ assert.doesNotThrow(() => manager.close())
309
+ })
310
+
311
+ test('ambient proxies and remote credentials never reach the worker', async () => {
312
+ const ambient = {
313
+ HTTP_PROXY: 'http://127.0.0.1:7890',
314
+ https_proxy: 'http://127.0.0.1:7890',
315
+ ALL_PROXY: 'socks5://127.0.0.1:7890',
316
+ NO_PROXY: 'localhost',
317
+ MEMU_HTTP_PROXY: 'http://127.0.0.1:1',
318
+ MEMU_API_KEY: 'ambient-memu-key',
319
+ MEMU_BASE_URL: 'https://api.example.invalid/v1',
320
+ MEMU_EMBED_PROVIDER: 'openai',
321
+ OPENAI_API_KEY: 'ambient-openai-key',
322
+ DSH_MEMU_EMBED_TOKEN: 'stale-token',
323
+ DSH_MEMU_EMBED_MODEL_DIR: 'D:/stale/model',
324
+ GOOGLE_APPLICATION_CREDENTIALS: 'C:/stale.json',
325
+ }
326
+ const saved = new Map()
327
+ for (const [name, value] of Object.entries(ambient)) {
328
+ saved.set(name, process.env[name])
329
+ process.env[name] = value
330
+ }
331
+ try {
332
+ const { spawnImpl, children } = fakeSpawn()
333
+ const manager = makeManager({ spawnImpl })
334
+ const pending = manager.acquire(PROFILE)
335
+ push(children[0], readyLine())
336
+ const endpoint = await pending
337
+ const env = children[0].options.env
338
+ for (const name of ['HTTP_PROXY', 'https_proxy', 'ALL_PROXY', 'NO_PROXY', 'MEMU_HTTP_PROXY', 'MEMU_API_KEY', 'MEMU_BASE_URL', 'MEMU_EMBED_PROVIDER', 'OPENAI_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS']) {
339
+ assert.equal(env[name], undefined, name + ' must not be inherited')
340
+ }
341
+ assert.equal(env.DSH_MEMU_EMBED_TOKEN, endpoint.token, 'the inherited token is replaced')
342
+ assert.equal(env.DSH_MEMU_EMBED_MODEL_DIR, PROFILE.modelDir, 'the inherited model directory is replaced')
343
+ for (const name of Object.keys(env)) {
344
+ assert.ok(!/proxy/i.test(name), name + ' must not look like a proxy setting')
345
+ assert.ok(!/_api_key$/i.test(name), name + ' must not carry a remote credential')
346
+ }
347
+ assert.equal(env.MEMU_TELEMETRY, '0')
348
+ assert.equal(env.DO_NOT_TRACK, '1')
349
+ assert.equal(env.MEMU_EVENTS_BASE_URL, '')
350
+ manager.close()
351
+ } finally {
352
+ for (const [name, value] of saved) {
353
+ if (value === undefined) delete process.env[name]
354
+ else process.env[name] = value
355
+ }
356
+ }
357
+ })
358
+
359
+ test('a profile that cannot work is refused before any child is spawned', async () => {
360
+ const { spawnImpl, calls } = fakeSpawn()
361
+ const { spawnImpl: noSpawn, calls: noCalls } = fakeSpawn()
362
+ const manager = makeManager({ spawnImpl })
363
+ await assert.rejects(manager.acquire({ ...PROFILE, modelDir: '' }), error => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE')
364
+ await assert.rejects(manager.acquire({ ...PROFILE, runtimeDir: null }), error => error.code === 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE')
365
+ await assert.rejects(manager.acquire({ ...PROFILE, manifest: {} }), error => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
366
+ await assert.rejects(manager.acquire({ ...PROFILE, dimension: 0 }), error => error.code === 'LOCAL_VECTOR_MANIFEST_INCOMPLETE')
367
+ assert.equal(calls.length, 0)
368
+ assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 0 })
369
+ const rejecting = makeManager({
370
+ spawnImpl: noSpawn,
371
+ verifyProfile: () => { throw Object.assign(new Error('profile needs repair'), { code: 'VECTOR_PROFILE_UNVERIFIED' }) },
372
+ })
373
+ await assert.rejects(rejecting.acquire(PROFILE), error => error.code === 'VECTOR_PROFILE_UNVERIFIED')
374
+ assert.equal(noCalls.length, 0, 'an unverified profile never starts a worker')
375
+ rejecting.close()
376
+ })
377
+
378
+ test('a spawn failure is a typed, observable failure rather than a hang', async () => {
379
+ const manager = makeManager({
380
+ spawnImpl: () => { throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES' }) },
381
+ maxRestarts: 1,
382
+ })
383
+ await assert.rejects(manager.acquire(PROFILE), error => {
384
+ assert.equal(error.code, 'LOCAL_VECTOR_SPAWN_FAILED')
385
+ assert.equal(error.fallback, 'lexical')
386
+ return true
387
+ })
388
+ assert.deepEqual(manager.status(), { running: 0, booting: 0, closed: false, tripped: 1 }, 'a failed boot counts toward the fuse')
389
+ await assert.rejects(manager.acquire(PROFILE), error => error.code === 'LOCAL_VECTOR_RESTART_FUSED')
390
+ manager.close()
391
+ })
392
+
393
+ test('the process-wide manager can be disposed and rebuilt from the plugin lifecycle', () => {
394
+ const first = embeddingWorkerManager()
395
+ assert.equal(embeddingWorkerManager(), first, 'the singleton is stable')
396
+ closeEmbeddingWorkers()
397
+ assert.equal(first.status().closed, true)
398
+ const second = embeddingWorkerManager()
399
+ assert.notEqual(second, first)
400
+ assert.deepEqual(second.status(), { running: 0, booting: 0, closed: false, tripped: 0 })
401
+ closeEmbeddingWorkers()
402
+ assert.doesNotThrow(() => closeEmbeddingWorkers(), 'disposal is idempotent')
403
+ })
404
+
405
+ test('a real child process serves the ready line and does not outlive the manager', async () => {
406
+ const spawned = []
407
+ const script = [
408
+ 'const line = JSON.stringify(' + JSON.stringify(readyLine()) + ')',
409
+ 'process.stdout.write(line + String.fromCharCode(10))',
410
+ 'const bye = () => process.exit(0)',
411
+ "process.stdin.on('end', bye)",
412
+ "process.stdin.on('close', bye)",
413
+ 'process.stdin.resume()',
414
+ 'setInterval(() => {}, 1000)',
415
+ ].join(String.fromCharCode(10))
416
+ const spawnImpl = (command, args, options) => {
417
+ const child = spawnReal(process.execPath, ['-e', script], options)
418
+ spawned.push(child)
419
+ return child
420
+ }
421
+ const manager = makeManager({ spawnImpl, timeoutMs: 15_000 })
422
+ try {
423
+ const endpoint = await manager.acquire(PROFILE)
424
+ assert.equal(endpoint.baseUrl, 'http://127.0.0.1:45123/v1')
425
+ assert.match(endpoint.token, TOKEN_PATTERN)
426
+ let guard = null
427
+ const exited = new Promise(resolve => spawned[0].once('exit', () => resolve(true)))
428
+ manager.close()
429
+ const deadline = new Promise(resolve => { guard = setTimeout(() => resolve(false), 10_000) })
430
+ const finished = await Promise.race([exited, deadline])
431
+ clearTimeout(guard)
432
+ assert.equal(finished, true, 'the worker child must not survive its manager')
433
+ } finally {
434
+ manager.close()
435
+ for (const child of spawned) {
436
+ try { child.kill() } catch {}
437
+ }
438
+ }
439
+ })
440
+
441
+ test('the ready timeout still fires when nothing but the timer holds the event loop', async () => {
442
+ // N2: an unref'd boot timer never fires on an event loop that has nothing else to do,
443
+ // so acquire() stays pending instead of degrading to lexical recall. A stand-in spawn
444
+ // (no pid, no libuv handle) is exactly that situation, and node:test keeps the loop
445
+ // alive for its own reasons, so the only honest check runs out of process: this probe
446
+ // exits on its own the moment nothing references it, and prints nothing if it does.
447
+ const managerUrl = new URL('../lib/local-embedding-manager.js', import.meta.url).href
448
+ const profileJson = JSON.stringify(PROFILE)
449
+ const script = [
450
+ 'const { createEmbeddingWorkerManager } = await import(' + JSON.stringify(managerUrl) + ')',
451
+ 'const { EventEmitter } = await import("node:events")',
452
+ 'const spawnImpl = () => {',
453
+ ' const child = new EventEmitter()',
454
+ ' child.stdin = new EventEmitter(); child.stdin.end = () => {}',
455
+ ' child.stdout = new EventEmitter(); child.stderr = new EventEmitter()',
456
+ ' child.kill = () => {}',
457
+ ' return child',
458
+ '}',
459
+ 'const profile = ' + profileJson,
460
+ 'const manager = createEmbeddingWorkerManager({ spawnImpl, verifyProfile: () => {}, logger: { warn() {} }, timeoutMs: 300 })',
461
+ 'const outcome = await manager.acquire(profile).then(() => "RESOLVED", error => "REJECTED:" + error.code)',
462
+ 'manager.close()',
463
+ 'process.stdout.write(outcome + String.fromCharCode(10))',
464
+ ].join(String.fromCharCode(10))
465
+ const child = spawnReal(process.execPath, ['--input-type=module', '-e', script], { stdio: ['ignore', 'pipe', 'ignore'], env: childEnvironment({}) })
466
+ let out = ''
467
+ child.stdout.setEncoding('utf8')
468
+ child.stdout.on('data', chunk => { out += chunk })
469
+ let guard = null
470
+ const closed = new Promise(resolve => child.once('close', code => resolve(code)))
471
+ const deadline = new Promise(resolve => { guard = setTimeout(() => resolve('hung'), 20_000) })
472
+ const result = await Promise.race([closed, deadline])
473
+ clearTimeout(guard)
474
+ assert.equal(result, 0, 'the probe must settle on the boot timeout, not drain with the acquire still pending')
475
+ assert.equal(out.trim(), 'REJECTED:LOCAL_VECTOR_START_TIMEOUT', 'a stand-in child that never reports ready must time out')
476
+ })
@@ -0,0 +1,95 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ DEFAULT_MAX_BATCH,
5
+ LocalEmbeddingError,
6
+ assertVector,
7
+ buildEmbeddingResponse,
8
+ decodeVectorBase64,
9
+ encodeVectorBase64,
10
+ errorBody,
11
+ errorStatus,
12
+ estimateUsage,
13
+ parseEmbeddingRequest,
14
+ } from '../lib/local-embedding-protocol.js'
15
+
16
+ const MODEL = 'bge-small-zh-v1.5@r1+mean-l2-512'
17
+ const rejects = (fn, pattern) => assert.throws(fn, (error) => error instanceof LocalEmbeddingError && pattern.test(error.code))
18
+
19
+ test('base64 payloads are raw little-endian float32, not a private array format', () => {
20
+ const vector = [0.5, -1.25, 0, 3]
21
+ const encoded = encodeVectorBase64(vector)
22
+ const expected = Buffer.alloc(vector.length * 4)
23
+ vector.forEach((value, index) => expected.writeFloatLE(value, index * 4))
24
+ assert.equal(encoded, expected.toString('base64'))
25
+ assert.deepEqual(decodeVectorBase64(encoded, 4), vector)
26
+ assert.deepEqual(Array.from(decodeVectorBase64(encoded)), Array.from(new Float32Array(vector)))
27
+ })
28
+
29
+ test('decoding a base64 payload of the wrong length reports a dimension mismatch', () => {
30
+ const encoded = encodeVectorBase64([1, 2, 3])
31
+ rejects(() => decodeVectorBase64(encoded, 4), /^LOCAL_VECTOR_DIMENSION_MISMATCH$/)
32
+ assert.throws(() => decodeVectorBase64('***'), (error) => error.code === 'LOCAL_VECTOR_INVALID_VECTOR')
33
+ })
34
+
35
+ test('vectors must be finite, non-zero and exactly the manifest dimension', () => {
36
+ assert.deepEqual(assertVector([1, 2, 3], 3), [1, 2, 3])
37
+ assert.deepEqual(assertVector(new Float32Array([1, 2, 3]), 3), [1, 2, 3])
38
+ rejects(() => assertVector([1, 2], 3), /^LOCAL_VECTOR_DIMENSION_MISMATCH$/)
39
+ rejects(() => assertVector([0, 0, 0], 3), /^LOCAL_VECTOR_ZERO_VECTOR$/)
40
+ rejects(() => assertVector([1, Number.NaN, 3], 3), /^LOCAL_VECTOR_INVALID_VECTOR$/)
41
+ rejects(() => assertVector([1, Number.POSITIVE_INFINITY, 3], 3), /^LOCAL_VECTOR_INVALID_VECTOR$/)
42
+ rejects(() => assertVector('1,2,3', 3), /^LOCAL_VECTOR_INVALID_VECTOR$/)
43
+ rejects(() => assertVector([1, 2, 3], 0), /^LOCAL_VECTOR_MANIFEST_INCOMPLETE$/)
44
+ })
45
+
46
+ test('the request parser accepts exactly the SDK shapes and refuses the rest', () => {
47
+ assert.deepEqual(parseEmbeddingRequest({ model: MODEL, input: 'hello' }, { model: MODEL }), { inputs: ['hello'], encoding: 'float' })
48
+ assert.deepEqual(parseEmbeddingRequest({ model: MODEL, input: ['a', 'b'], encoding_format: 'base64' }, { model: MODEL }), { inputs: ['a', 'b'], encoding: 'base64' })
49
+ rejects(() => parseEmbeddingRequest(null, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
50
+ rejects(() => parseEmbeddingRequest([], { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
51
+ rejects(() => parseEmbeddingRequest({ input: 'hello' }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
52
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: [] }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
53
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: ' ' }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
54
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: [1, 2] }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
55
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: [[1, 2, 3]] }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
56
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: { text: 'x' } }, { model: MODEL }), /^LOCAL_VECTOR_BAD_REQUEST$/)
57
+ rejects(() => parseEmbeddingRequest({ model: 'other-model', input: 'hello' }, { model: MODEL }), /^LOCAL_VECTOR_MODEL_MISMATCH$/)
58
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: 'hello', encoding_format: 'binary' }, { model: MODEL }), /^LOCAL_VECTOR_BAD_ENCODING$/)
59
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: 'x'.repeat(65_537) }, { model: MODEL }), /^LOCAL_VECTOR_INPUT_TOO_LONG$/)
60
+ })
61
+
62
+ test('the batch cap is enforced before any inference happens', () => {
63
+ const many = new Array(DEFAULT_MAX_BATCH + 1).fill('x')
64
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: many }, { model: MODEL }), /^LOCAL_VECTOR_BATCH_TOO_LARGE$/)
65
+ rejects(() => parseEmbeddingRequest({ model: MODEL, input: ['a', 'b', 'c'] }, { model: MODEL, maxBatch: 2 }), /^LOCAL_VECTOR_BATCH_TOO_LARGE$/)
66
+ assert.equal(parseEmbeddingRequest({ model: MODEL, input: ['a', 'b'] }, { model: MODEL, maxBatch: 2 }).inputs.length, 2)
67
+ })
68
+
69
+ test('responses keep input order, carry the model and honour the encoding', () => {
70
+ const vectors = [[1, 2, 3], [4, 5, 6]]
71
+ const floatBody = buildEmbeddingResponse({ model: MODEL, vectors, encoding: 'float' })
72
+ assert.equal(floatBody.object, 'list')
73
+ assert.equal(floatBody.model, MODEL)
74
+ assert.deepEqual(floatBody.data.map((entry) => entry.index), [0, 1])
75
+ assert.equal(floatBody.data[0].object, 'embedding')
76
+ assert.deepEqual(floatBody.data[1].embedding, [4, 5, 6])
77
+ assert.ok(Number.isFinite(floatBody.usage.prompt_tokens))
78
+
79
+ const base64Body = buildEmbeddingResponse({ model: MODEL, vectors, encoding: 'base64' })
80
+ assert.equal(typeof base64Body.data[0].embedding, 'string')
81
+ assert.deepEqual(decodeVectorBase64(base64Body.data[0].embedding, 3), [1, 2, 3])
82
+
83
+ const withUsage = buildEmbeddingResponse({ model: MODEL, vectors, encoding: 'float', usage: { prompt_tokens: 7, total_tokens: 7 } })
84
+ assert.deepEqual(withUsage.usage, { prompt_tokens: 7, total_tokens: 7 })
85
+ assert.deepEqual(estimateUsage(['abcd', 'abcdefgh']), { prompt_tokens: 3, total_tokens: 3 })
86
+ })
87
+
88
+ test('error envelopes expose a code without echoing the request body', () => {
89
+ const body = errorBody(new LocalEmbeddingError('unknown model; this worker serves m', 'LOCAL_VECTOR_MODEL_MISMATCH', 400))
90
+ assert.equal(body.error.type, 'invalid_request_error')
91
+ assert.equal(body.error.code, 'LOCAL_VECTOR_MODEL_MISMATCH')
92
+ assert.equal(errorStatus(new LocalEmbeddingError('x', 'X', 503)), 503)
93
+ assert.equal(errorStatus(new Error('plain')), 500)
94
+ assert.equal(errorBody(new LocalEmbeddingError('boom', 'LOCAL_VECTOR_INFERENCE_FAILED', 500)).error.type, 'server_error')
95
+ })