dsh-memu-memory 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { startEmbeddingWorker } from './local-embedding-worker.js'
5
+ import { LocalEmbeddingError } from './local-embedding-protocol.js'
6
+
7
+ // §14.5: the worker runs in its own child process so a native crash cannot take
8
+ // the host down. The port and the random token travel over the process
9
+ // environment (never argv, never a shared config file, never a log line), and the
10
+ // parent learns the real port from a single ready line on stdout.
11
+ export const ENTRY_ENV = Object.freeze({
12
+ modelDir: 'DSH_MEMU_EMBED_MODEL_DIR',
13
+ manifest: 'DSH_MEMU_EMBED_MANIFEST',
14
+ manifestFile: 'DSH_MEMU_EMBED_MANIFEST_FILE',
15
+ runtimeDir: 'DSH_MEMU_EMBED_RUNTIME_DIR',
16
+ token: 'DSH_MEMU_EMBED_TOKEN',
17
+ host: 'DSH_MEMU_EMBED_HOST',
18
+ port: 'DSH_MEMU_EMBED_PORT',
19
+ maxBodyBytes: 'DSH_MEMU_EMBED_MAX_BODY_BYTES',
20
+ maxBatch: 'DSH_MEMU_EMBED_MAX_BATCH',
21
+ })
22
+
23
+ function readManifest(env) {
24
+ const inline = String(env[ENTRY_ENV.manifest] || '').trim()
25
+ if (inline.startsWith('{')) {
26
+ try {
27
+ return JSON.parse(inline)
28
+ } catch (error) {
29
+ throw new LocalEmbeddingError('manifest env is not valid JSON', 'LOCAL_VECTOR_MANIFEST_INVALID', 503, String((error && error.message) || error))
30
+ }
31
+ }
32
+ const file = inline || String(env[ENTRY_ENV.manifestFile] || '').trim()
33
+ if (!file) throw new LocalEmbeddingError('a manifest path or inline manifest is required', 'LOCAL_VECTOR_MANIFEST_INVALID', 503)
34
+ let raw = ''
35
+ try {
36
+ raw = fs.readFileSync(path.resolve(file), 'utf8')
37
+ } catch (error) {
38
+ throw new LocalEmbeddingError('manifest file ' + path.basename(file) + ' could not be read', 'LOCAL_VECTOR_MANIFEST_INVALID', 503, String((error && error.code) || error))
39
+ }
40
+ try {
41
+ return JSON.parse(raw)
42
+ } catch (error) {
43
+ throw new LocalEmbeddingError('manifest file ' + path.basename(file) + ' is not valid JSON', 'LOCAL_VECTOR_MANIFEST_INVALID', 503, String((error && error.message) || error))
44
+ }
45
+ }
46
+
47
+ function readInteger(value, label, fallback) {
48
+ const text = String(value || '').trim()
49
+ if (!text) return fallback
50
+ const number = Number(text)
51
+ if (!Number.isInteger(number) || number < 0) {
52
+ throw new LocalEmbeddingError(label + ' must be a non-negative integer', 'LOCAL_VECTOR_BAD_CONFIG', 400)
53
+ }
54
+ return number
55
+ }
56
+
57
+ export function parseEmbeddingEntryConfig(env = process.env) {
58
+ const token = String(env[ENTRY_ENV.token] || '')
59
+ if (!token) throw new LocalEmbeddingError('a worker token is required', 'LOCAL_VECTOR_TOKEN_INVALID', 400)
60
+ const modelDir = String(env[ENTRY_ENV.modelDir] || '').trim()
61
+ if (!modelDir) throw new LocalEmbeddingError('modelDir is required', 'LOCAL_VECTOR_MODEL_DIR_MISSING', 503)
62
+ const runtimeDir = String(env[ENTRY_ENV.runtimeDir] || '').trim()
63
+ return {
64
+ modelDir,
65
+ manifest: readManifest(env),
66
+ runtimeDir: runtimeDir || undefined,
67
+ token,
68
+ host: String(env[ENTRY_ENV.host] || '127.0.0.1').trim() || '127.0.0.1',
69
+ port: readInteger(env[ENTRY_ENV.port], 'port', 0),
70
+ maxBodyBytes: readInteger(env[ENTRY_ENV.maxBodyBytes], 'maxBodyBytes', undefined),
71
+ maxBatch: readInteger(env[ENTRY_ENV.maxBatch], 'maxBatch', undefined),
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Starts the worker and emits exactly one ready line. Kept separate from main()
77
+ * so a test can drive it with a stub worker and never listen on a socket.
78
+ */
79
+ export async function runEmbeddingEntry({ env = process.env, stdout = process.stdout, logger = console, startWorker = startEmbeddingWorker } = {}) {
80
+ const config = parseEmbeddingEntryConfig(env)
81
+ const worker = await startWorker({ ...config, logger })
82
+ const ready = {
83
+ type: 'ready',
84
+ baseUrl: worker.baseUrl,
85
+ url: worker.url,
86
+ model: worker.model,
87
+ dimension: worker.dimension,
88
+ pid: process.pid,
89
+ }
90
+ stdout.write(JSON.stringify(ready) + '\n')
91
+ return { worker, ready }
92
+ }
93
+
94
+ function stderrLogger(stderr) {
95
+ const write = (level) => (message) => {
96
+ try { stderr.write('[local-embedding ' + level + '] ' + String(message) + '\n') } catch {}
97
+ }
98
+ return { info: write('info'), warn: write('warn'), error: write('error'), log: write('info') }
99
+ }
100
+
101
+ function isEntryPoint() {
102
+ const invoked = process.argv[1]
103
+ if (!invoked) return false
104
+ try {
105
+ return pathToFileURL(fs.realpathSync(invoked)).href === pathToFileURL(fs.realpathSync(new URL(import.meta.url))).href
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ async function main() {
112
+ const stdout = process.stdout
113
+ const stderr = process.stderr
114
+ let worker = null
115
+ let stopping = false
116
+ const stop = async (code) => {
117
+ if (stopping) return
118
+ stopping = true
119
+ try { await worker?.close() } catch {}
120
+ process.exit(code)
121
+ }
122
+ const failAndExit = (error) => {
123
+ const code = String((error && error.code) || 'LOCAL_VECTOR_ERROR')
124
+ const message = String((error && error.message) || error || 'embedding worker failed')
125
+ try { stderr.write(JSON.stringify({ type: 'error', code, message }) + '\n') } catch {}
126
+ void stop(1)
127
+ }
128
+
129
+ // The parent owns the lifetime: stdin EOF, SIGTERM/SIGINT or an IPC disconnect
130
+ // all mean "release the native session now" (§14.5: no leaked processes).
131
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { void stop(0) })
132
+ process.on('disconnect', () => { void stop(0) })
133
+ process.on('uncaughtException', failAndExit)
134
+ process.on('unhandledRejection', failAndExit)
135
+ try { process.stdin.on('end', () => { void stop(0) }); process.stdin.on('close', () => { void stop(0) }); process.stdin.resume() } catch {}
136
+
137
+ try {
138
+ const result = await runEmbeddingEntry({ stdout, logger: stderrLogger(stderr) })
139
+ worker = result.worker
140
+ } catch (error) {
141
+ failAndExit(error)
142
+ }
143
+ }
144
+
145
+ if (isEntryPoint()) main()
@@ -0,0 +1,267 @@
1
+ import crypto from 'node:crypto'
2
+ import { spawn } from 'node:child_process'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { assertProfileVerified } from './vector-profile.js'
5
+ import { manifestModelId } from './local-embedding-worker.js'
6
+ import { windowsSystemEnv } from './paths.js'
7
+
8
+ const entry = fileURLToPath(new URL('./local-embedding-entry.js', import.meta.url))
9
+ const failure = (code, message) => Object.assign(new Error(message), { code, fallback: 'lexical' })
10
+
11
+ const LOOPBACK = '127.0.0.1'
12
+ const EMBEDDINGS_PATH = '/v1/embeddings'
13
+ const READY_LIMIT = 16_384
14
+ const TOKEN_BYTES = 32
15
+ const DEFAULT_WINDOW_MS = 60_000
16
+
17
+ // The worker needs the OS basics and nothing else. An inherited proxy variable can
18
+ // reroute the loopback call (§14.6: MEMU_HTTP_PROXY does exist), an inherited
19
+ // *_API_KEY would hand a cloud credential to a process whose whole point is that it
20
+ // never talks to anyone, and a stale DSH_MEMU_EMBED_* value would replace the fresh
21
+ // per-process token or point the child at another model directory.
22
+ const DENIED_ENV_NAMES = Object.freeze(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'node_options'])
23
+ const DENIED_ENV_PREFIXES = Object.freeze(['memu_', 'dsh_memu_embed_', 'openai_', 'azure_openai_', 'anthropic_', 'gemini_', 'google_', 'cohere_'])
24
+
25
+ // The system-variable overlay goes in *first* so a real host value always wins, and
26
+ // only after it does the filtered copy of the host environment. The host can hand a
27
+ // tool-spawned process an empty environment (docs/env-and-commands.md §9.1), and a
28
+ // child that then resolves `%SystemDrive%\ProgramData\...` literally writes that
29
+ // junk into its own cwd — which is how this repo once grew a "%SystemDrive%" folder.
30
+ // The worker loads ONNX/DirectML and tokenizer native libraries from exactly that
31
+ // kind of path, so it is the site that can least afford to miss the overlay.
32
+ export function childEnvironment(extra, { platform = process.platform, env = process.env } = {}) {
33
+ const inherited = {}
34
+ for (const [name, value] of Object.entries(env || {})) {
35
+ if (typeof value !== 'string') continue
36
+ const lower = name.toLowerCase()
37
+ if (DENIED_ENV_NAMES.includes(lower) || lower.includes('proxy')) continue
38
+ if (DENIED_ENV_PREFIXES.some(prefix => lower.startsWith(prefix))) continue
39
+ inherited[name] = value
40
+ }
41
+ return { ...windowsSystemEnv(platform, env || {}), ...inherited, ...extra }
42
+ }
43
+
44
+ // One isolated native process per immutable profile. Tokens live only in memory and
45
+ // in the child environment: never in argv, never in a log line, never in status().
46
+ // A failed boot counts toward the fuse too; recall never triggers installation.
47
+ export function createEmbeddingWorkerManager({ spawnImpl = spawn, executable = process.execPath, logger = console, now = Date.now, timeoutMs = 60_000, maxRestarts = 3, restartWindowMs = 60_000, verifyProfile = assertProfileVerified } = {}) {
48
+ const records = new Map()
49
+ const crashes = new Map()
50
+ const windowMs = Number.isFinite(restartWindowMs) && restartWindowMs > 0 ? restartWindowMs : DEFAULT_WINDOW_MS
51
+ const fuseLimit = Number.isInteger(maxRestarts) && maxRestarts > 0 ? maxRestarts : 0
52
+ let disposed = false
53
+
54
+ const warn = message => { try { logger?.warn?.(message) } catch {} }
55
+
56
+ // The fuse counts failed boots and unexpected exits only: a worker that served
57
+ // requests is not a restart storm. Timestamps older than the window are dropped so
58
+ // a crash loop stays bounded while a single later retry is not blocked forever.
59
+ const recentCrashes = key => {
60
+ const list = (crashes.get(key) || []).filter(at => now() - at < windowMs)
61
+ if (list.length) crashes.set(key, list)
62
+ else crashes.delete(key)
63
+ return list
64
+ }
65
+ const noteCrash = key => {
66
+ const list = recentCrashes(key)
67
+ list.push(now())
68
+ crashes.set(key, list)
69
+ return list.length
70
+ }
71
+
72
+ // stdin EOF is the worker's graceful "release the native session" signal; the kill
73
+ // only guarantees it for a child that is wedged. Both are best effort by design —
74
+ // stopping must never throw during disposal.
75
+ const stopChild = record => {
76
+ record.stopping = true
77
+ const child = record.child
78
+ if (!child) return
79
+ try { child.stdin?.end() } catch {}
80
+ try { child.kill() } catch {}
81
+ }
82
+
83
+ // Readiness is the only thing the parent believes about the child, so it is checked
84
+ // against the requested profile instead of trusted: right model id, right dimension,
85
+ // and a bare http loopback base URL with a real port. Anything else degrades to
86
+ // lexical recall rather than pointing MemU at an unexpected endpoint.
87
+ function checkReady(ready, expected) {
88
+ if (!ready || typeof ready !== 'object' || Array.isArray(ready)) return { error: 'worker readiness is not an object' }
89
+ if (ready.type !== 'ready') return { error: 'worker readiness is not a ready message' }
90
+ if (!expected.model) return { error: 'the requested profile has no stable model id' }
91
+ if (ready.model !== expected.model) return { error: 'worker readiness names a model other than the requested profile' }
92
+ if (!Number.isInteger(expected.dimension) || expected.dimension <= 0) return { error: 'the requested profile has no embedding dimension' }
93
+ if (ready.dimension !== expected.dimension) return { error: 'worker readiness reports a dimension other than the requested profile' }
94
+ let base = null
95
+ try { base = new URL(String(ready.baseUrl || '')) } catch { base = null }
96
+ if (!base) return { error: 'worker readiness carries no usable endpoint' }
97
+ if (base.protocol !== 'http:') return { error: 'worker endpoint must speak http on loopback' }
98
+ if (base.hostname !== LOOPBACK) return { error: 'worker endpoint must be ' + LOOPBACK }
99
+ const port = Number(base.port)
100
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { error: 'worker endpoint must carry a real port' }
101
+ if (base.username || base.password || base.search || base.hash) return { error: 'worker endpoint must be a bare loopback base URL' }
102
+ if (base.pathname !== '/v1') return { error: 'worker endpoint path must be /v1' }
103
+ if (ready.url !== undefined && ready.url !== null && String(ready.url) !== new URL(EMBEDDINGS_PATH, base).href) {
104
+ return { error: 'worker embeddings URL does not belong to its base URL' }
105
+ }
106
+ return { endpoint: Object.freeze({ baseUrl: base.href, model: expected.model, dimension: expected.dimension }) }
107
+ }
108
+
109
+ function boot(profile, key, modelDir, runtimeDir) {
110
+ const token = crypto.randomBytes(TOKEN_BYTES).toString('hex')
111
+ const expected = { model: manifestModelId(profile.manifest), dimension: Number(profile.dimension) }
112
+ const record = { key, child: null, stopping: false, settled: false, endpoint: null, reject: null }
113
+ // Registered before the spawn so a concurrent acquire joins this boot instead of
114
+ // starting a second child for the same profile.
115
+ records.set(key, record)
116
+ record.ready = new Promise((resolve, reject) => {
117
+ let buffer = ''
118
+ let timer = null
119
+ const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null } }
120
+ const forget = () => { if (records.get(key) === record) records.delete(key) }
121
+ const abort = (code, message) => {
122
+ if (record.settled) return
123
+ record.settled = true
124
+ clearTimer()
125
+ forget()
126
+ reject(failure(code, message))
127
+ }
128
+ // close() rejects an in-flight acquire through this hook, so a caller waiting on
129
+ // a child that never reports ready fails immediately instead of hanging.
130
+ record.reject = abort
131
+ const fail = (code, message) => {
132
+ noteCrash(key)
133
+ abort(code, message)
134
+ stopChild(record)
135
+ }
136
+ // This timer is the only thing that turns "never reported ready" into the typed
137
+ // lexical fallback, so nothing may depend on the event loop being held open for
138
+ // another reason: an unconditional unref let acquire() stay pending forever whenever
139
+ // the child contributed no libuv handle of its own (a spawn stand-in, N2). It is
140
+ // dereferenced below only once the spawn proves a real child holds the process open,
141
+ // which is the sole case where this timer would merely delay shutdown.
142
+ timer = setTimeout(() => fail('LOCAL_VECTOR_START_TIMEOUT', 'the local embedding worker did not become ready in time'), timeoutMs)
143
+ let child = null
144
+ try {
145
+ child = spawnImpl(executable, [entry], {
146
+ windowsHide: true,
147
+ stdio: ['pipe', 'pipe', 'pipe'],
148
+ env: childEnvironment({
149
+ ELECTRON_RUN_AS_NODE: '1',
150
+ DSH_MEMU_EMBED_MODEL_DIR: modelDir,
151
+ DSH_MEMU_EMBED_RUNTIME_DIR: runtimeDir,
152
+ DSH_MEMU_EMBED_MANIFEST: JSON.stringify({ ...profile.manifest, verified: true }),
153
+ DSH_MEMU_EMBED_TOKEN: token,
154
+ DSH_MEMU_EMBED_HOST: LOOPBACK,
155
+ DSH_MEMU_EMBED_PORT: '0',
156
+ MEMU_TELEMETRY: '0',
157
+ DO_NOT_TRACK: '1',
158
+ MEMU_EVENTS_BASE_URL: '',
159
+ }),
160
+ })
161
+ } catch {
162
+ fail('LOCAL_VECTOR_SPAWN_FAILED', 'the local embedding worker could not be started')
163
+ return
164
+ }
165
+ record.child = child
166
+ // A real child holds the process open by itself (stdio pipes and its own handle), so
167
+ // its boot timer need not; a stand-in without a pid holds nothing, and dereferencing
168
+ // the timer there would leave the timeout unable to fire. A spawn that fails reaches
169
+ // fail() through 'error'/'close' and clears the timer either way.
170
+ if (Number.isInteger(child?.pid) && child.pid > 0) timer.unref?.()
171
+ // A worker that died turns the next stdin write into EPIPE. An unhandled 'error'
172
+ // event on a child pipe is an uncaught exception in the host process, so every
173
+ // pipe gets a sink: semantic recall degrades, the host does not go down with it.
174
+ for (const pipe of [child?.stdin, child?.stdout, child?.stderr]) {
175
+ try { pipe?.on?.('error', () => {}) } catch {}
176
+ }
177
+ child?.on?.('error', () => {
178
+ if (record.settled) { warn('the local embedding worker reported an error; semantic recall will degrade to lexical'); return }
179
+ fail('LOCAL_VECTOR_SPAWN_FAILED', 'the local embedding worker could not be started')
180
+ })
181
+ child?.on?.('close', () => {
182
+ forget()
183
+ record.endpoint = null
184
+ // A shutdown this manager asked for is not a crash and must stay quiet.
185
+ if (record.stopping) return
186
+ noteCrash(key)
187
+ warn('the local embedding worker exited; semantic recall will degrade to lexical')
188
+ abort('LOCAL_VECTOR_WORKER_EXITED', 'the local embedding worker exited before it became ready')
189
+ })
190
+ // Native stderr may quote request text, so it is read and discarded.
191
+ child?.stderr?.on?.('data', () => {})
192
+ child?.stdout?.on?.('data', chunk => {
193
+ if (record.settled) return
194
+ buffer += String(chunk)
195
+ if (buffer.length > READY_LIMIT) { fail('LOCAL_VECTOR_BAD_READY', 'the worker readiness message exceeded the limit'); return }
196
+ const newline = buffer.indexOf(String.fromCharCode(10))
197
+ if (newline < 0) return
198
+ const line = buffer.slice(0, newline).replace(/\r+$/, '')
199
+ buffer = ''
200
+ let ready = null
201
+ try { ready = JSON.parse(line) } catch { fail('LOCAL_VECTOR_BAD_READY', 'the worker returned an unreadable readiness line'); return }
202
+ const checked = checkReady(ready, expected)
203
+ if (checked.error) { fail('LOCAL_VECTOR_BAD_READY', checked.error); return }
204
+ record.settled = true
205
+ clearTimer()
206
+ record.endpoint = Object.freeze({ ...checked.endpoint, token })
207
+ resolve(record.endpoint)
208
+ })
209
+ })
210
+ return record
211
+ }
212
+
213
+ async function acquire(profile) {
214
+ if (disposed) throw failure('LOCAL_VECTOR_MANAGER_CLOSED', 'the embedding worker manager is closed')
215
+ verifyProfile(profile)
216
+ const modelDir = typeof profile?.modelDir === 'string' ? profile.modelDir.trim() : ''
217
+ const runtimeDir = typeof profile?.runtimeDir === 'string' ? profile.runtimeDir.trim() : ''
218
+ if (!modelDir || !runtimeDir) throw failure('LOCAL_VECTOR_RUNTIME_UNAVAILABLE', 'install the verified model and runtime before enabling local embeddings')
219
+ if (!manifestModelId(profile.manifest)) throw failure('LOCAL_VECTOR_MANIFEST_INCOMPLETE', 'the vector profile has no stable model id')
220
+ const dimension = Number(profile.dimension)
221
+ if (!Number.isInteger(dimension) || dimension <= 0) throw failure('LOCAL_VECTOR_MANIFEST_INCOMPLETE', 'the vector profile has no embedding dimension')
222
+ const key = JSON.stringify([String(profile.fingerprint || ''), modelDir, runtimeDir])
223
+ const existing = records.get(key)
224
+ if (existing) return existing.ready
225
+ if (fuseLimit && recentCrashes(key).length >= fuseLimit) throw failure('LOCAL_VECTOR_RESTART_FUSED', 'the local embedding worker keeps failing; retry after the cooldown')
226
+ return boot(profile, key, modelDir, runtimeDir).ready
227
+ }
228
+
229
+ function close() {
230
+ disposed = true
231
+ const pending = [...records.values()]
232
+ records.clear()
233
+ crashes.clear()
234
+ for (const record of pending) {
235
+ // Reject first: a caller blocked in acquire() must fail on the shutdown it was
236
+ // told about, not on the "worker exited" symptom of our own kill.
237
+ record.reject?.('LOCAL_VECTOR_MANAGER_CLOSED', 'the embedding worker manager is closed')
238
+ stopChild(record)
239
+ }
240
+ return pending.length
241
+ }
242
+
243
+ // Deliberately shallow: no token, no endpoint, no model directory, no fingerprint.
244
+ function status() {
245
+ let running = 0
246
+ let booting = 0
247
+ for (const record of records.values()) {
248
+ if (record.settled) running += 1
249
+ else booting += 1
250
+ }
251
+ let tripped = 0
252
+ if (fuseLimit) for (const key of [...crashes.keys()]) if (recentCrashes(key).length >= fuseLimit) tripped += 1
253
+ return { running, booting, closed: disposed, tripped }
254
+ }
255
+
256
+ return { acquire, close, status }
257
+ }
258
+
259
+ let singleton
260
+ export function embeddingWorkerManager() {
261
+ return singleton ||= createEmbeddingWorkerManager()
262
+ }
263
+ export function closeEmbeddingWorkers() {
264
+ const manager = singleton
265
+ singleton = null
266
+ try { manager?.close() } catch {}
267
+ }
@@ -0,0 +1,173 @@
1
+ // §14 local vector: the wire contract shared by the managed embedding worker,
2
+ // its child-process entry and their tests. MemU's OpenAI-compatible SDK posts
3
+ // {model, input, encoding_format} and expects object=list with data ordered like
4
+ // the input plus a usage block; float arrays and base64 float32 little-endian
5
+ // payloads are both real client shapes (probe in §14.3 saw base64), so both are
6
+ // implemented here and nothing else invents a private array protocol.
7
+ //
8
+ // All request/response validation lives in this file on purpose: the worker only
9
+ // moves bytes, so a test can pin the contract without opening a socket.
10
+
11
+ export const EMBEDDINGS_PATH = '/v1/embeddings'
12
+ export const HEALTH_PATH = '/health'
13
+
14
+ // MemU commits in batches of 64 by default; the body cap still leaves room for
15
+ // 64 long strings while refusing a whole document in one shot.
16
+ export const DEFAULT_MAX_BODY_BYTES = 1_048_576
17
+ export const DEFAULT_MAX_BATCH = 64
18
+ export const MAX_VECTOR_DIMENSION = 8192
19
+ export const MAX_INPUT_CHARS = 65_536
20
+ export const ENCODINGS = Object.freeze(['float', 'base64'])
21
+
22
+ export class LocalEmbeddingError extends Error {
23
+ constructor(message, code = 'LOCAL_VECTOR_ERROR', status = 500, detail = null) {
24
+ super(message)
25
+ this.name = 'LocalEmbeddingError'
26
+ this.code = code
27
+ this.status = status
28
+ if (detail !== null && detail !== undefined) this.detail = detail
29
+ }
30
+ }
31
+
32
+ export function errorStatus(error) {
33
+ const status = Number(error && error.status)
34
+ return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500
35
+ }
36
+
37
+ // Mirrors the OpenAI error envelope the SDKs already understand, without echoing
38
+ // the request body (an error response must never replay a secret or raw text).
39
+ export function errorBody(error) {
40
+ const status = errorStatus(error)
41
+ return {
42
+ error: {
43
+ message: String((error && error.message) || 'local embedding request failed'),
44
+ type: status >= 500 ? 'server_error' : 'invalid_request_error',
45
+ code: String((error && error.code) || 'LOCAL_VECTOR_ERROR'),
46
+ },
47
+ }
48
+ }
49
+
50
+ function badRequest(message, code) {
51
+ return new LocalEmbeddingError(message, code, 400)
52
+ }
53
+
54
+ function plainNumbers(value) {
55
+ if (Array.isArray(value)) return value
56
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) return Array.from(value)
57
+ return null
58
+ }
59
+
60
+ export function encodeVectorBase64(vector) {
61
+ const numbers = plainNumbers(vector)
62
+ if (!numbers) throw new LocalEmbeddingError('cannot encode a non-numeric vector', 'LOCAL_VECTOR_INVALID_VECTOR', 500)
63
+ const buffer = Buffer.allocUnsafe(numbers.length * 4)
64
+ for (let index = 0; index < numbers.length; index++) buffer.writeFloatLE(Number(numbers[index]), index * 4)
65
+ return buffer.toString('base64')
66
+ }
67
+
68
+ export function decodeVectorBase64(text, dimension = 0) {
69
+ const buffer = Buffer.from(String(text || ''), 'base64')
70
+ if (buffer.length === 0 || buffer.length % 4 !== 0) {
71
+ throw new LocalEmbeddingError('base64 payload is not a whole float32 array', 'LOCAL_VECTOR_INVALID_VECTOR', 500)
72
+ }
73
+ const out = new Array(buffer.length / 4)
74
+ for (let index = 0; index < out.length; index++) out[index] = buffer.readFloatLE(index * 4)
75
+ if (Number.isInteger(dimension) && dimension > 0 && out.length !== dimension) {
76
+ throw new LocalEmbeddingError('base64 payload decoded to ' + out.length + ' values, expected ' + dimension, 'LOCAL_VECTOR_DIMENSION_MISMATCH', 500)
77
+ }
78
+ return out
79
+ }
80
+
81
+ // One vector from the model, checked the way §14.6 demands: finite values,
82
+ // exactly the manifest dimension, and not the all-zero vector a broken session
83
+ // returns when its inputs never reach the graph.
84
+ export function assertVector(vector, dimension, index = 0) {
85
+ const label = index >= 0 ? 'embedding ' + index : 'embedding'
86
+ const numbers = plainNumbers(vector)
87
+ if (!numbers) throw new LocalEmbeddingError(label + ' is not a numeric array', 'LOCAL_VECTOR_INVALID_VECTOR', 500)
88
+ if (!Number.isInteger(dimension) || dimension <= 0) {
89
+ throw new LocalEmbeddingError('the manifest declares no usable embedding dimension', 'LOCAL_VECTOR_MANIFEST_INCOMPLETE', 503)
90
+ }
91
+ if (numbers.length !== dimension) {
92
+ throw new LocalEmbeddingError(label + ' has ' + numbers.length + ' values, expected ' + dimension, 'LOCAL_VECTOR_DIMENSION_MISMATCH', 500)
93
+ }
94
+ let squared = 0
95
+ for (let position = 0; position < numbers.length; position++) {
96
+ const value = numbers[position]
97
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
98
+ throw new LocalEmbeddingError(label + ' contains a non-finite value at ' + position, 'LOCAL_VECTOR_INVALID_VECTOR', 500)
99
+ }
100
+ squared += value * value
101
+ }
102
+ if (!(squared > 0)) throw new LocalEmbeddingError(label + ' is all zeros', 'LOCAL_VECTOR_ZERO_VECTOR', 500)
103
+ return numbers
104
+ }
105
+
106
+ function normalizeInputText(value, index, maxChars) {
107
+ if (typeof value !== 'string') {
108
+ throw badRequest('input[' + index + '] must be a string; token-id arrays are not accepted', 'LOCAL_VECTOR_BAD_REQUEST')
109
+ }
110
+ if (!value.trim()) throw badRequest('input[' + index + '] is empty', 'LOCAL_VECTOR_BAD_REQUEST')
111
+ if (value.length > maxChars) {
112
+ throw badRequest('input[' + index + '] is longer than ' + maxChars + ' characters', 'LOCAL_VECTOR_INPUT_TOO_LONG')
113
+ }
114
+ return value
115
+ }
116
+
117
+ // Accepts exactly what the SDK sends: one string or an array of strings. Anything
118
+ // else (token ids, nested arrays, non-string items) is a client bug, not something
119
+ // to coerce, because a silent coercion would embed the wrong text.
120
+ export function parseEmbeddingRequest(body, { model, maxBatch = DEFAULT_MAX_BATCH, maxChars = MAX_INPUT_CHARS } = {}) {
121
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
122
+ throw badRequest('request body must be a JSON object', 'LOCAL_VECTOR_BAD_REQUEST')
123
+ }
124
+ const expected = String(model || '')
125
+ const requested = body.model
126
+ if (typeof requested !== 'string' || !requested.trim()) {
127
+ throw badRequest('model is required', 'LOCAL_VECTOR_BAD_REQUEST')
128
+ }
129
+ if (!expected || requested !== expected) {
130
+ throw badRequest('unknown model; this worker serves ' + (expected || '(none)'), 'LOCAL_VECTOR_MODEL_MISMATCH')
131
+ }
132
+ const encoding = body.encoding_format === undefined || body.encoding_format === null ? 'float' : body.encoding_format
133
+ if (typeof encoding !== 'string' || !ENCODINGS.includes(encoding)) {
134
+ throw badRequest('encoding_format must be one of ' + ENCODINGS.join(', '), 'LOCAL_VECTOR_BAD_ENCODING')
135
+ }
136
+ const raw = body.input
137
+ const list = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : null
138
+ if (!list) throw badRequest('input must be a string or an array of strings', 'LOCAL_VECTOR_BAD_REQUEST')
139
+ if (list.length === 0) throw badRequest('input must not be empty', 'LOCAL_VECTOR_BAD_REQUEST')
140
+ const batchLimit = Number.isInteger(maxBatch) && maxBatch > 0 ? maxBatch : DEFAULT_MAX_BATCH
141
+ if (list.length > batchLimit) {
142
+ throw badRequest('batch of ' + list.length + ' exceeds the limit of ' + batchLimit, 'LOCAL_VECTOR_BATCH_TOO_LARGE')
143
+ }
144
+ const inputs = list.map((value, index) => normalizeInputText(value, index, maxChars))
145
+ return { inputs, encoding }
146
+ }
147
+
148
+ // Token counts are only used for the usage block; the worker never claims a real
149
+ // tokenizer count when the adapter cannot supply one.
150
+ export function estimateUsage(inputs) {
151
+ let count = 0
152
+ for (const input of inputs) count += Math.max(1, Math.ceil(String(input).length / 4))
153
+ return { prompt_tokens: count, total_tokens: count }
154
+ }
155
+
156
+ export function buildEmbeddingResponse({ model, vectors, encoding = 'float', usage = null }) {
157
+ const list = Array.isArray(vectors) ? vectors : []
158
+ const data = list.map((vector, index) => ({
159
+ object: 'embedding',
160
+ index,
161
+ embedding: encoding === 'base64' ? encodeVectorBase64(vector) : Array.from(vector, Number),
162
+ }))
163
+ const tokens = usage && typeof usage === 'object' ? usage : estimateUsage(new Array(list.length).fill(''))
164
+ return {
165
+ object: 'list',
166
+ data,
167
+ model,
168
+ usage: {
169
+ prompt_tokens: Number.isFinite(tokens.prompt_tokens) ? tokens.prompt_tokens : 0,
170
+ total_tokens: Number.isFinite(tokens.total_tokens) ? tokens.total_tokens : 0,
171
+ },
172
+ }
173
+ }