dsh-memu-memory 1.1.2 → 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.
Files changed (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +32 -5
  8. package/docs/troubleshooting.md +36 -7
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +697 -49
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +36 -0
  20. package/lib/inject.js +131 -30
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. package/scripts/repetition-eval.mjs +702 -0
@@ -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
+ }