dsh-memu-memory 1.1.1 → 1.1.3
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.
- package/AGENTS.md +8 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +61 -4
- package/lib/inject.js +195 -49
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +121 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -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,249 @@
|
|
|
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
|
+
|
|
7
|
+
const entry = fileURLToPath(new URL('./local-embedding-entry.js', import.meta.url))
|
|
8
|
+
const failure = (code, message) => Object.assign(new Error(message), { code, fallback: 'lexical' })
|
|
9
|
+
|
|
10
|
+
const LOOPBACK = '127.0.0.1'
|
|
11
|
+
const EMBEDDINGS_PATH = '/v1/embeddings'
|
|
12
|
+
const READY_LIMIT = 16_384
|
|
13
|
+
const TOKEN_BYTES = 32
|
|
14
|
+
const DEFAULT_WINDOW_MS = 60_000
|
|
15
|
+
|
|
16
|
+
// The worker needs the OS basics and nothing else. An inherited proxy variable can
|
|
17
|
+
// reroute the loopback call (§14.6: MEMU_HTTP_PROXY does exist), an inherited
|
|
18
|
+
// *_API_KEY would hand a cloud credential to a process whose whole point is that it
|
|
19
|
+
// never talks to anyone, and a stale DSH_MEMU_EMBED_* value would replace the fresh
|
|
20
|
+
// per-process token or point the child at another model directory.
|
|
21
|
+
const DENIED_ENV_NAMES = Object.freeze(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'node_options'])
|
|
22
|
+
const DENIED_ENV_PREFIXES = Object.freeze(['memu_', 'dsh_memu_embed_', 'openai_', 'azure_openai_', 'anthropic_', 'gemini_', 'google_', 'cohere_'])
|
|
23
|
+
|
|
24
|
+
function childEnvironment(extra) {
|
|
25
|
+
const env = {}
|
|
26
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
27
|
+
if (typeof value !== 'string') continue
|
|
28
|
+
const lower = name.toLowerCase()
|
|
29
|
+
if (DENIED_ENV_NAMES.includes(lower) || lower.includes('proxy')) continue
|
|
30
|
+
if (DENIED_ENV_PREFIXES.some(prefix => lower.startsWith(prefix))) continue
|
|
31
|
+
env[name] = value
|
|
32
|
+
}
|
|
33
|
+
return { ...env, ...extra }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One isolated native process per immutable profile. Tokens live only in memory and
|
|
37
|
+
// in the child environment: never in argv, never in a log line, never in status().
|
|
38
|
+
// A failed boot counts toward the fuse too; recall never triggers installation.
|
|
39
|
+
export function createEmbeddingWorkerManager({ spawnImpl = spawn, executable = process.execPath, logger = console, now = Date.now, timeoutMs = 60_000, maxRestarts = 3, restartWindowMs = 60_000, verifyProfile = assertProfileVerified } = {}) {
|
|
40
|
+
const records = new Map()
|
|
41
|
+
const crashes = new Map()
|
|
42
|
+
const windowMs = Number.isFinite(restartWindowMs) && restartWindowMs > 0 ? restartWindowMs : DEFAULT_WINDOW_MS
|
|
43
|
+
const fuseLimit = Number.isInteger(maxRestarts) && maxRestarts > 0 ? maxRestarts : 0
|
|
44
|
+
let disposed = false
|
|
45
|
+
|
|
46
|
+
const warn = message => { try { logger?.warn?.(message) } catch {} }
|
|
47
|
+
|
|
48
|
+
// The fuse counts failed boots and unexpected exits only: a worker that served
|
|
49
|
+
// requests is not a restart storm. Timestamps older than the window are dropped so
|
|
50
|
+
// a crash loop stays bounded while a single later retry is not blocked forever.
|
|
51
|
+
const recentCrashes = key => {
|
|
52
|
+
const list = (crashes.get(key) || []).filter(at => now() - at < windowMs)
|
|
53
|
+
if (list.length) crashes.set(key, list)
|
|
54
|
+
else crashes.delete(key)
|
|
55
|
+
return list
|
|
56
|
+
}
|
|
57
|
+
const noteCrash = key => {
|
|
58
|
+
const list = recentCrashes(key)
|
|
59
|
+
list.push(now())
|
|
60
|
+
crashes.set(key, list)
|
|
61
|
+
return list.length
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// stdin EOF is the worker's graceful "release the native session" signal; the kill
|
|
65
|
+
// only guarantees it for a child that is wedged. Both are best effort by design —
|
|
66
|
+
// stopping must never throw during disposal.
|
|
67
|
+
const stopChild = record => {
|
|
68
|
+
record.stopping = true
|
|
69
|
+
const child = record.child
|
|
70
|
+
if (!child) return
|
|
71
|
+
try { child.stdin?.end() } catch {}
|
|
72
|
+
try { child.kill() } catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readiness is the only thing the parent believes about the child, so it is checked
|
|
76
|
+
// against the requested profile instead of trusted: right model id, right dimension,
|
|
77
|
+
// and a bare http loopback base URL with a real port. Anything else degrades to
|
|
78
|
+
// lexical recall rather than pointing MemU at an unexpected endpoint.
|
|
79
|
+
function checkReady(ready, expected) {
|
|
80
|
+
if (!ready || typeof ready !== 'object' || Array.isArray(ready)) return { error: 'worker readiness is not an object' }
|
|
81
|
+
if (ready.type !== 'ready') return { error: 'worker readiness is not a ready message' }
|
|
82
|
+
if (!expected.model) return { error: 'the requested profile has no stable model id' }
|
|
83
|
+
if (ready.model !== expected.model) return { error: 'worker readiness names a model other than the requested profile' }
|
|
84
|
+
if (!Number.isInteger(expected.dimension) || expected.dimension <= 0) return { error: 'the requested profile has no embedding dimension' }
|
|
85
|
+
if (ready.dimension !== expected.dimension) return { error: 'worker readiness reports a dimension other than the requested profile' }
|
|
86
|
+
let base = null
|
|
87
|
+
try { base = new URL(String(ready.baseUrl || '')) } catch { base = null }
|
|
88
|
+
if (!base) return { error: 'worker readiness carries no usable endpoint' }
|
|
89
|
+
if (base.protocol !== 'http:') return { error: 'worker endpoint must speak http on loopback' }
|
|
90
|
+
if (base.hostname !== LOOPBACK) return { error: 'worker endpoint must be ' + LOOPBACK }
|
|
91
|
+
const port = Number(base.port)
|
|
92
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return { error: 'worker endpoint must carry a real port' }
|
|
93
|
+
if (base.username || base.password || base.search || base.hash) return { error: 'worker endpoint must be a bare loopback base URL' }
|
|
94
|
+
if (base.pathname !== '/v1') return { error: 'worker endpoint path must be /v1' }
|
|
95
|
+
if (ready.url !== undefined && ready.url !== null && String(ready.url) !== new URL(EMBEDDINGS_PATH, base).href) {
|
|
96
|
+
return { error: 'worker embeddings URL does not belong to its base URL' }
|
|
97
|
+
}
|
|
98
|
+
return { endpoint: Object.freeze({ baseUrl: base.href, model: expected.model, dimension: expected.dimension }) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function boot(profile, key, modelDir, runtimeDir) {
|
|
102
|
+
const token = crypto.randomBytes(TOKEN_BYTES).toString('hex')
|
|
103
|
+
const expected = { model: manifestModelId(profile.manifest), dimension: Number(profile.dimension) }
|
|
104
|
+
const record = { key, child: null, stopping: false, settled: false, endpoint: null, reject: null }
|
|
105
|
+
// Registered before the spawn so a concurrent acquire joins this boot instead of
|
|
106
|
+
// starting a second child for the same profile.
|
|
107
|
+
records.set(key, record)
|
|
108
|
+
record.ready = new Promise((resolve, reject) => {
|
|
109
|
+
let buffer = ''
|
|
110
|
+
let timer = null
|
|
111
|
+
const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null } }
|
|
112
|
+
const forget = () => { if (records.get(key) === record) records.delete(key) }
|
|
113
|
+
const abort = (code, message) => {
|
|
114
|
+
if (record.settled) return
|
|
115
|
+
record.settled = true
|
|
116
|
+
clearTimer()
|
|
117
|
+
forget()
|
|
118
|
+
reject(failure(code, message))
|
|
119
|
+
}
|
|
120
|
+
// close() rejects an in-flight acquire through this hook, so a caller waiting on
|
|
121
|
+
// a child that never reports ready fails immediately instead of hanging.
|
|
122
|
+
record.reject = abort
|
|
123
|
+
const fail = (code, message) => {
|
|
124
|
+
noteCrash(key)
|
|
125
|
+
abort(code, message)
|
|
126
|
+
stopChild(record)
|
|
127
|
+
}
|
|
128
|
+
timer = setTimeout(() => fail('LOCAL_VECTOR_START_TIMEOUT', 'the local embedding worker did not become ready in time'), timeoutMs)
|
|
129
|
+
timer.unref?.()
|
|
130
|
+
let child = null
|
|
131
|
+
try {
|
|
132
|
+
child = spawnImpl(executable, [entry], {
|
|
133
|
+
windowsHide: true,
|
|
134
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
135
|
+
env: childEnvironment({
|
|
136
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
137
|
+
DSH_MEMU_EMBED_MODEL_DIR: modelDir,
|
|
138
|
+
DSH_MEMU_EMBED_RUNTIME_DIR: runtimeDir,
|
|
139
|
+
DSH_MEMU_EMBED_MANIFEST: JSON.stringify({ ...profile.manifest, verified: true }),
|
|
140
|
+
DSH_MEMU_EMBED_TOKEN: token,
|
|
141
|
+
DSH_MEMU_EMBED_HOST: LOOPBACK,
|
|
142
|
+
DSH_MEMU_EMBED_PORT: '0',
|
|
143
|
+
MEMU_TELEMETRY: '0',
|
|
144
|
+
DO_NOT_TRACK: '1',
|
|
145
|
+
MEMU_EVENTS_BASE_URL: '',
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
} catch {
|
|
149
|
+
fail('LOCAL_VECTOR_SPAWN_FAILED', 'the local embedding worker could not be started')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
record.child = child
|
|
153
|
+
// A worker that died turns the next stdin write into EPIPE. An unhandled 'error'
|
|
154
|
+
// event on a child pipe is an uncaught exception in the host process, so every
|
|
155
|
+
// pipe gets a sink: semantic recall degrades, the host does not go down with it.
|
|
156
|
+
for (const pipe of [child?.stdin, child?.stdout, child?.stderr]) {
|
|
157
|
+
try { pipe?.on?.('error', () => {}) } catch {}
|
|
158
|
+
}
|
|
159
|
+
child?.on?.('error', () => {
|
|
160
|
+
if (record.settled) { warn('the local embedding worker reported an error; semantic recall will degrade to lexical'); return }
|
|
161
|
+
fail('LOCAL_VECTOR_SPAWN_FAILED', 'the local embedding worker could not be started')
|
|
162
|
+
})
|
|
163
|
+
child?.on?.('close', () => {
|
|
164
|
+
forget()
|
|
165
|
+
record.endpoint = null
|
|
166
|
+
// A shutdown this manager asked for is not a crash and must stay quiet.
|
|
167
|
+
if (record.stopping) return
|
|
168
|
+
noteCrash(key)
|
|
169
|
+
warn('the local embedding worker exited; semantic recall will degrade to lexical')
|
|
170
|
+
abort('LOCAL_VECTOR_WORKER_EXITED', 'the local embedding worker exited before it became ready')
|
|
171
|
+
})
|
|
172
|
+
// Native stderr may quote request text, so it is read and discarded.
|
|
173
|
+
child?.stderr?.on?.('data', () => {})
|
|
174
|
+
child?.stdout?.on?.('data', chunk => {
|
|
175
|
+
if (record.settled) return
|
|
176
|
+
buffer += String(chunk)
|
|
177
|
+
if (buffer.length > READY_LIMIT) { fail('LOCAL_VECTOR_BAD_READY', 'the worker readiness message exceeded the limit'); return }
|
|
178
|
+
const newline = buffer.indexOf(String.fromCharCode(10))
|
|
179
|
+
if (newline < 0) return
|
|
180
|
+
const line = buffer.slice(0, newline).replace(/\r+$/, '')
|
|
181
|
+
buffer = ''
|
|
182
|
+
let ready = null
|
|
183
|
+
try { ready = JSON.parse(line) } catch { fail('LOCAL_VECTOR_BAD_READY', 'the worker returned an unreadable readiness line'); return }
|
|
184
|
+
const checked = checkReady(ready, expected)
|
|
185
|
+
if (checked.error) { fail('LOCAL_VECTOR_BAD_READY', checked.error); return }
|
|
186
|
+
record.settled = true
|
|
187
|
+
clearTimer()
|
|
188
|
+
record.endpoint = Object.freeze({ ...checked.endpoint, token })
|
|
189
|
+
resolve(record.endpoint)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
return record
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function acquire(profile) {
|
|
196
|
+
if (disposed) throw failure('LOCAL_VECTOR_MANAGER_CLOSED', 'the embedding worker manager is closed')
|
|
197
|
+
verifyProfile(profile)
|
|
198
|
+
const modelDir = typeof profile?.modelDir === 'string' ? profile.modelDir.trim() : ''
|
|
199
|
+
const runtimeDir = typeof profile?.runtimeDir === 'string' ? profile.runtimeDir.trim() : ''
|
|
200
|
+
if (!modelDir || !runtimeDir) throw failure('LOCAL_VECTOR_RUNTIME_UNAVAILABLE', 'install the verified model and runtime before enabling local embeddings')
|
|
201
|
+
if (!manifestModelId(profile.manifest)) throw failure('LOCAL_VECTOR_MANIFEST_INCOMPLETE', 'the vector profile has no stable model id')
|
|
202
|
+
const dimension = Number(profile.dimension)
|
|
203
|
+
if (!Number.isInteger(dimension) || dimension <= 0) throw failure('LOCAL_VECTOR_MANIFEST_INCOMPLETE', 'the vector profile has no embedding dimension')
|
|
204
|
+
const key = JSON.stringify([String(profile.fingerprint || ''), modelDir, runtimeDir])
|
|
205
|
+
const existing = records.get(key)
|
|
206
|
+
if (existing) return existing.ready
|
|
207
|
+
if (fuseLimit && recentCrashes(key).length >= fuseLimit) throw failure('LOCAL_VECTOR_RESTART_FUSED', 'the local embedding worker keeps failing; retry after the cooldown')
|
|
208
|
+
return boot(profile, key, modelDir, runtimeDir).ready
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function close() {
|
|
212
|
+
disposed = true
|
|
213
|
+
const pending = [...records.values()]
|
|
214
|
+
records.clear()
|
|
215
|
+
crashes.clear()
|
|
216
|
+
for (const record of pending) {
|
|
217
|
+
// Reject first: a caller blocked in acquire() must fail on the shutdown it was
|
|
218
|
+
// told about, not on the "worker exited" symptom of our own kill.
|
|
219
|
+
record.reject?.('LOCAL_VECTOR_MANAGER_CLOSED', 'the embedding worker manager is closed')
|
|
220
|
+
stopChild(record)
|
|
221
|
+
}
|
|
222
|
+
return pending.length
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Deliberately shallow: no token, no endpoint, no model directory, no fingerprint.
|
|
226
|
+
function status() {
|
|
227
|
+
let running = 0
|
|
228
|
+
let booting = 0
|
|
229
|
+
for (const record of records.values()) {
|
|
230
|
+
if (record.settled) running += 1
|
|
231
|
+
else booting += 1
|
|
232
|
+
}
|
|
233
|
+
let tripped = 0
|
|
234
|
+
if (fuseLimit) for (const key of [...crashes.keys()]) if (recentCrashes(key).length >= fuseLimit) tripped += 1
|
|
235
|
+
return { running, booting, closed: disposed, tripped }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { acquire, close, status }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let singleton
|
|
242
|
+
export function embeddingWorkerManager() {
|
|
243
|
+
return singleton ||= createEmbeddingWorkerManager()
|
|
244
|
+
}
|
|
245
|
+
export function closeEmbeddingWorkers() {
|
|
246
|
+
const manager = singleton
|
|
247
|
+
singleton = null
|
|
248
|
+
try { manager?.close() } catch {}
|
|
249
|
+
}
|
|
@@ -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
|
+
}
|