@reftrixmcp/ml 0.1.7

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 (97) hide show
  1. package/LICENSE +661 -0
  2. package/dist/dinov2/index.d.ts +7 -0
  3. package/dist/dinov2/index.d.ts.map +1 -0
  4. package/dist/dinov2/index.js +7 -0
  5. package/dist/dinov2/index.js.map +1 -0
  6. package/dist/dinov2/service.d.ts +106 -0
  7. package/dist/dinov2/service.d.ts.map +1 -0
  8. package/dist/dinov2/service.js +446 -0
  9. package/dist/dinov2/service.js.map +1 -0
  10. package/dist/dinov2/worker-thread-types.d.ts +77 -0
  11. package/dist/dinov2/worker-thread-types.d.ts.map +1 -0
  12. package/dist/dinov2/worker-thread-types.js +4 -0
  13. package/dist/dinov2/worker-thread-types.js.map +1 -0
  14. package/dist/dinov2/worker-thread.d.ts +2 -0
  15. package/dist/dinov2/worker-thread.d.ts.map +1 -0
  16. package/dist/dinov2/worker-thread.js +231 -0
  17. package/dist/dinov2/worker-thread.js.map +1 -0
  18. package/dist/embeddings/index.d.ts +12 -0
  19. package/dist/embeddings/index.d.ts.map +1 -0
  20. package/dist/embeddings/index.js +13 -0
  21. package/dist/embeddings/index.js.map +1 -0
  22. package/dist/embeddings/multimodal-embedding.service.d.ts +400 -0
  23. package/dist/embeddings/multimodal-embedding.service.d.ts.map +1 -0
  24. package/dist/embeddings/multimodal-embedding.service.js +650 -0
  25. package/dist/embeddings/multimodal-embedding.service.js.map +1 -0
  26. package/dist/embeddings/service.d.ts +261 -0
  27. package/dist/embeddings/service.d.ts.map +1 -0
  28. package/dist/embeddings/service.js +1093 -0
  29. package/dist/embeddings/service.js.map +1 -0
  30. package/dist/embeddings/style-embedding.service.d.ts +117 -0
  31. package/dist/embeddings/style-embedding.service.d.ts.map +1 -0
  32. package/dist/embeddings/style-embedding.service.js +157 -0
  33. package/dist/embeddings/style-embedding.service.js.map +1 -0
  34. package/dist/embeddings/types.d.ts +83 -0
  35. package/dist/embeddings/types.d.ts.map +1 -0
  36. package/dist/embeddings/types.js +4 -0
  37. package/dist/embeddings/types.js.map +1 -0
  38. package/dist/embeddings/vision-embedding.service.d.ts +70 -0
  39. package/dist/embeddings/vision-embedding.service.d.ts.map +1 -0
  40. package/dist/embeddings/vision-embedding.service.js +221 -0
  41. package/dist/embeddings/vision-embedding.service.js.map +1 -0
  42. package/dist/embeddings/vision-embedding.types.d.ts +102 -0
  43. package/dist/embeddings/vision-embedding.types.d.ts.map +1 -0
  44. package/dist/embeddings/vision-embedding.types.js +4 -0
  45. package/dist/embeddings/vision-embedding.types.js.map +1 -0
  46. package/dist/embeddings/worker-thread-types.d.ts +152 -0
  47. package/dist/embeddings/worker-thread-types.d.ts.map +1 -0
  48. package/dist/embeddings/worker-thread-types.js +4 -0
  49. package/dist/embeddings/worker-thread-types.js.map +1 -0
  50. package/dist/embeddings/worker-thread.d.ts +4 -0
  51. package/dist/embeddings/worker-thread.d.ts.map +1 -0
  52. package/dist/embeddings/worker-thread.js +441 -0
  53. package/dist/embeddings/worker-thread.js.map +1 -0
  54. package/dist/index.d.ts +18 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +19 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/search/fulltext-helpers.d.ts +30 -0
  59. package/dist/search/fulltext-helpers.d.ts.map +1 -0
  60. package/dist/search/fulltext-helpers.js +40 -0
  61. package/dist/search/fulltext-helpers.js.map +1 -0
  62. package/dist/search/hybrid.d.ts +40 -0
  63. package/dist/search/hybrid.d.ts.map +1 -0
  64. package/dist/search/hybrid.js +61 -0
  65. package/dist/search/hybrid.js.map +1 -0
  66. package/dist/search/index.d.ts +12 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/index.js +11 -0
  69. package/dist/search/index.js.map +1 -0
  70. package/dist/search/rrf.d.ts +69 -0
  71. package/dist/search/rrf.d.ts.map +1 -0
  72. package/dist/search/rrf.js +108 -0
  73. package/dist/search/rrf.js.map +1 -0
  74. package/dist/search/types.d.ts +130 -0
  75. package/dist/search/types.d.ts.map +1 -0
  76. package/dist/search/types.js +4 -0
  77. package/dist/search/types.js.map +1 -0
  78. package/package.json +65 -0
  79. package/src/dinov2/index.ts +21 -0
  80. package/src/dinov2/service.ts +539 -0
  81. package/src/dinov2/worker-thread-types.ts +103 -0
  82. package/src/dinov2/worker-thread.ts +306 -0
  83. package/src/embeddings/index.ts +74 -0
  84. package/src/embeddings/multimodal-embedding.service.ts +992 -0
  85. package/src/embeddings/service.ts +1287 -0
  86. package/src/embeddings/style-embedding.service.ts +201 -0
  87. package/src/embeddings/types.ts +95 -0
  88. package/src/embeddings/vision-embedding.service.ts +273 -0
  89. package/src/embeddings/vision-embedding.types.ts +117 -0
  90. package/src/embeddings/worker-thread-types.ts +194 -0
  91. package/src/embeddings/worker-thread.ts +529 -0
  92. package/src/index.ts +70 -0
  93. package/src/search/fulltext-helpers.ts +45 -0
  94. package/src/search/hybrid.ts +107 -0
  95. package/src/search/index.ts +25 -0
  96. package/src/search/rrf.ts +154 -0
  97. package/src/search/types.ts +141 -0
@@ -0,0 +1,1093 @@
1
+ // SPDX-FileCopyrightText: 2026 TKMD and Reftrix Contributors
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ /**
4
+ * Embedding Service
5
+ *
6
+ * Generates embeddings using multilingual-e5-base model via Transformers.js.
7
+ * Supports both single and batch embedding generation with caching.
8
+ *
9
+ * v0.1.0: ONNX inference runs in a Worker Thread to prevent CPU-bound
10
+ * blocking of the main event loop. The main thread manages the LRU cache,
11
+ * E5 prefix logic, and normalization; the worker thread owns the ONNX
12
+ * pipeline lifecycle (init, inference, recycle, dispose).
13
+ *
14
+ * Set env EMBEDDING_WORKER_THREAD=false to disable worker thread and
15
+ * run inference in-process (legacy behavior, for testing or debugging).
16
+ */
17
+ import { Worker } from "node:worker_threads";
18
+ import { fileURLToPath } from "node:url";
19
+ import path from "node:path";
20
+ /**
21
+ * Default maximum cache size for LRU cache
22
+ * Prevents unbounded memory growth
23
+ */
24
+ export const DEFAULT_MAX_CACHE_SIZE = 5000;
25
+ /**
26
+ * Default number of inferences before recycling the ONNX pipeline.
27
+ *
28
+ * onnxruntime-node uses a native C++ arena allocator that grows monotonically
29
+ * during inference (~65-100MB per inference) but never returns memory to the OS.
30
+ * Pipeline recycling (dispose + recreate) tears down the InferenceSession via
31
+ * session.release(), which frees the arena's native memory back to the OS.
32
+ *
33
+ * With threshold=10, the arena is reset every 10 inferences, keeping peak
34
+ * additional native memory to ~650MB-1GB before each recycle. Model reload
35
+ * takes ~1.5-2s per recycle.
36
+ *
37
+ * v0.1.0: Reduced from 20 to 10 for universal embedding chunking.
38
+ * v0.1.0: Increased from 10 to 30 — chunking already limits items per chunk
39
+ * to 30, so threshold=30 means at most 1 recycle per chunk. With threshold=10,
40
+ * 3 recycles occurred per chunk, and model reload (~1.5-2s each) caused
41
+ * re-initialization overhead of ~45%. threshold=30 aligns with chunk size.
42
+ *
43
+ * Note: This project uses onnxruntime-node (native C++ bindings), NOT WASM.
44
+ * dispose() correctly frees native memory unlike WASM backing store pages.
45
+ */
46
+ export const DEFAULT_PIPELINE_RECYCLE_THRESHOLD = 30;
47
+ /**
48
+ * Maximum number of automatic worker thread restarts after crash.
49
+ * After this limit is reached, subsequent inference calls will throw.
50
+ */
51
+ const MAX_WORKER_RESTARTS = 5;
52
+ /**
53
+ * Timeout for worker thread responses (ms).
54
+ * Covers model loading (~15s) + inference (~5s). Set generously to avoid
55
+ * false timeouts on cold start or large batches.
56
+ */
57
+ const WORKER_RESPONSE_TIMEOUT_MS = 120_000;
58
+ // Default configuration
59
+ const DEFAULT_CONFIG = {
60
+ modelId: "Xenova/multilingual-e5-base",
61
+ cacheDir: process.env.MODEL_CACHE_DIR || "./.cache/models",
62
+ device: "cpu",
63
+ dtype: "fp32",
64
+ maxCacheSize: DEFAULT_MAX_CACHE_SIZE,
65
+ pipelineRecycleThreshold: process.env.PIPELINE_RECYCLE_THRESHOLD !== undefined
66
+ ? parseInt(process.env.PIPELINE_RECYCLE_THRESHOLD, 10)
67
+ : DEFAULT_PIPELINE_RECYCLE_THRESHOLD,
68
+ };
69
+ // E5 model prefixes for query and passage
70
+ const E5_PREFIX = {
71
+ query: "query: ",
72
+ passage: "passage: ",
73
+ };
74
+ // Embedding dimension for multilingual-e5-base
75
+ const EMBEDDING_DIMENSION = 768;
76
+ /**
77
+ * Whether to use worker thread for ONNX inference.
78
+ * Disabled by env var EMBEDDING_WORKER_THREAD=false or in test environments.
79
+ */
80
+ function isWorkerThreadEnabled() {
81
+ const envVal = process.env.EMBEDDING_WORKER_THREAD;
82
+ if (envVal === "false" || envVal === "0")
83
+ return false;
84
+ // Disable in Vitest to avoid worker thread complications in test harness
85
+ if (process.env.VITEST === "true" || process.env.VITEST_WORKER_ID !== undefined)
86
+ return false;
87
+ return true;
88
+ }
89
+ /**
90
+ * Generate a unique request ID for worker thread message correlation.
91
+ */
92
+ let requestIdCounter = 0;
93
+ function generateRequestId() {
94
+ return `req_${Date.now()}_${++requestIdCounter}`;
95
+ }
96
+ /**
97
+ * Resolve the path to the worker thread script.
98
+ * Uses import.meta.url for ESM compatibility.
99
+ */
100
+ function resolveWorkerScriptPath() {
101
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
102
+ return path.resolve(currentDir, "worker-thread.js");
103
+ }
104
+ /**
105
+ * Normalize a vector to unit length (L2 normalization)
106
+ */
107
+ function normalizeVector(vector) {
108
+ const norm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
109
+ if (norm === 0)
110
+ return vector;
111
+ return vector.map((val) => val / norm);
112
+ }
113
+ /**
114
+ * Calculate cosine similarity between two vectors
115
+ */
116
+ export function cosineSimilarity(a, b) {
117
+ if (a.length !== b.length) {
118
+ throw new Error("Vectors must have the same dimension");
119
+ }
120
+ let dotProduct = 0;
121
+ let normA = 0;
122
+ let normB = 0;
123
+ for (let i = 0; i < a.length; i++) {
124
+ const aVal = a[i];
125
+ const bVal = b[i];
126
+ if (aVal === undefined || bVal === undefined)
127
+ continue;
128
+ dotProduct += aVal * bVal;
129
+ normA += aVal * aVal;
130
+ normB += bVal * bVal;
131
+ }
132
+ normA = Math.sqrt(normA);
133
+ normB = Math.sqrt(normB);
134
+ if (normA === 0 || normB === 0)
135
+ return 0;
136
+ return dotProduct / (normA * normB);
137
+ }
138
+ // =====================================================
139
+ // EmbeddingService
140
+ // =====================================================
141
+ /**
142
+ * EmbeddingService - Generates embeddings using multilingual-e5-base
143
+ *
144
+ * v0.1.0: ONNX inference is offloaded to a Worker Thread by default.
145
+ * The main thread retains the LRU cache and E5 prefix logic. Only the
146
+ * actual ONNX pipeline.call() runs in the worker, keeping the main
147
+ * event loop responsive for BullMQ heartbeats and IPC messages.
148
+ */
149
+ export class EmbeddingService {
150
+ config;
151
+ cache = new Map();
152
+ cacheHits = 0;
153
+ cacheMisses = 0;
154
+ cacheEvictions = 0;
155
+ // --- Worker Thread state ---
156
+ useWorkerThread;
157
+ worker = null;
158
+ workerInitPromise = null;
159
+ workerReady = false;
160
+ workerRestartCount = 0;
161
+ pendingRequests = new Map();
162
+ /** Timestamp of last worker crash, used to enforce cooldown between restarts. */
163
+ lastCrashTime = 0;
164
+ // --- Provider tracking ---
165
+ currentProvider = "cpu";
166
+ // --- Idle timer for automatic VRAM release ---
167
+ idleTimer = null;
168
+ idleTimeoutMs = 0;
169
+ // --- In-process fallback state (legacy) ---
170
+ pipeline = null;
171
+ initPromise = null;
172
+ /**
173
+ * Number of inference calls since the last pipeline recycle.
174
+ * In worker-thread mode, the worker manages its own counter.
175
+ * In fallback mode, this counter is used directly.
176
+ */
177
+ inferencesSinceRecycle = 0;
178
+ /**
179
+ * Total number of pipeline recycles performed during this service's lifetime.
180
+ * In worker-thread mode, the count is approximate (tracked by dispose calls).
181
+ */
182
+ totalRecycles = 0;
183
+ constructor(config = {}) {
184
+ this.config = { ...DEFAULT_CONFIG, ...config };
185
+ // Apply ONNX_EXECUTION_PROVIDER env var if device not explicitly set
186
+ if (!config.device) {
187
+ const envProvider = process.env.ONNX_EXECUTION_PROVIDER;
188
+ if (envProvider === "cuda" || envProvider === "rocm") {
189
+ this.config.device = "cuda";
190
+ }
191
+ }
192
+ this.useWorkerThread = isWorkerThreadEnabled();
193
+ this.currentProvider = this.config.device === "cuda" ? "cuda" : "cpu";
194
+ if (process.env.NODE_ENV === "development") {
195
+ // eslint-disable-next-line no-console
196
+ console.log("[ML] EmbeddingService created with config:", {
197
+ modelId: this.config.modelId,
198
+ device: this.config.device,
199
+ dtype: this.config.dtype,
200
+ maxCacheSize: this.config.maxCacheSize,
201
+ pipelineRecycleThreshold: this.config.pipelineRecycleThreshold,
202
+ workerThread: this.useWorkerThread,
203
+ });
204
+ }
205
+ }
206
+ // =====================================================
207
+ // Worker Thread management
208
+ // =====================================================
209
+ /**
210
+ * Spawn the worker thread and send init message.
211
+ * Enforces a cooldown after crashes to prevent rapid spawn-crash-spawn cycles
212
+ * that consume memory (~10GB per CUDA init attempt).
213
+ */
214
+ async ensureWorkerReady() {
215
+ if (this.workerReady && this.worker)
216
+ return;
217
+ if (this.workerInitPromise)
218
+ return this.workerInitPromise;
219
+ // Cooldown: wait at least 3s after a crash before attempting restart.
220
+ // This prevents rapid spawn-crash cycles from consuming all system memory.
221
+ if (this.lastCrashTime > 0) {
222
+ const CRASH_COOLDOWN_MS = 3_000;
223
+ const elapsed = Date.now() - this.lastCrashTime;
224
+ if (elapsed < CRASH_COOLDOWN_MS) {
225
+ const waitMs = CRASH_COOLDOWN_MS - elapsed;
226
+ if (process.env.NODE_ENV === "development") {
227
+ // eslint-disable-next-line no-console
228
+ console.log("[ML] Waiting %dms before Worker Thread restart (crash cooldown)", waitMs);
229
+ }
230
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
231
+ }
232
+ }
233
+ this.workerInitPromise = this.spawnAndInitWorker();
234
+ return this.workerInitPromise;
235
+ }
236
+ async spawnAndInitWorker() {
237
+ try {
238
+ const scriptPath = resolveWorkerScriptPath();
239
+ if (process.env.NODE_ENV === "development") {
240
+ // eslint-disable-next-line no-console
241
+ console.log("[ML] Spawning ONNX Worker Thread:", scriptPath);
242
+ }
243
+ // Worker threads inherit the parent's execArgv by default.
244
+ // Node.js >=20.x rejects --max-old-space-size and --expose-gc in
245
+ // worker_threads Workers, so we must explicitly pass an empty array
246
+ // to prevent the parent's flags (set by WorkerSupervisor fork())
247
+ // from propagating.
248
+ this.worker = new Worker(scriptPath, { execArgv: [] });
249
+ // Handle worker messages
250
+ this.worker.on("message", (response) => {
251
+ this.handleWorkerResponse(response);
252
+ });
253
+ // Handle worker errors
254
+ this.worker.on("error", (error) => {
255
+ if (process.env.NODE_ENV === "development") {
256
+ console.warn("[ML] Worker Thread error:", error.message);
257
+ }
258
+ this.handleWorkerCrash(error);
259
+ });
260
+ // Handle worker exit
261
+ this.worker.on("exit", (code) => {
262
+ if (code !== 0) {
263
+ if (process.env.NODE_ENV === "development") {
264
+ console.warn("[ML] Worker Thread exited with code:", code);
265
+ }
266
+ this.handleWorkerCrash(new Error(`Worker thread exited with code ${code}`));
267
+ }
268
+ this.workerReady = false;
269
+ this.worker = null;
270
+ });
271
+ // Send init message and wait for response
272
+ const response = await this.sendWorkerMessage({
273
+ type: "init",
274
+ requestId: generateRequestId(),
275
+ config: {
276
+ modelId: this.config.modelId,
277
+ cacheDir: this.config.cacheDir,
278
+ device: this.config.device,
279
+ dtype: this.config.dtype,
280
+ pipelineRecycleThreshold: this.config.pipelineRecycleThreshold,
281
+ },
282
+ });
283
+ if (response.type === "error") {
284
+ throw new Error(`Worker init failed: ${response.error}`);
285
+ }
286
+ this.workerReady = true;
287
+ if (response.type === "init") {
288
+ this.currentProvider = response.executionProvider === "cuda" ? "cuda" : "cpu";
289
+ // eslint-disable-next-line no-console
290
+ console.log("[ML] Worker Thread initialized in %dms (provider: %s)", response.loadTimeMs, response.executionProvider);
291
+ }
292
+ }
293
+ catch (error) {
294
+ this.workerInitPromise = null;
295
+ this.workerReady = false;
296
+ const message = error instanceof Error ? error.message : "Unknown error";
297
+ throw new Error(`Failed to initialize ONNX Worker Thread: ${message}`);
298
+ }
299
+ }
300
+ /**
301
+ * Send a message to the worker and return a Promise for the response.
302
+ */
303
+ sendWorkerMessage(message) {
304
+ if (!this.worker) {
305
+ return Promise.reject(new Error("Worker thread not available"));
306
+ }
307
+ return new Promise((resolve, reject) => {
308
+ const timer = setTimeout(() => {
309
+ this.pendingRequests.delete(message.requestId);
310
+ reject(new Error(`Worker thread response timeout (${WORKER_RESPONSE_TIMEOUT_MS}ms) for ${message.type}`));
311
+ }, WORKER_RESPONSE_TIMEOUT_MS);
312
+ this.pendingRequests.set(message.requestId, { resolve, reject, timer });
313
+ this.worker.postMessage(message);
314
+ });
315
+ }
316
+ /**
317
+ * Route a response from the worker to the pending request's promise.
318
+ */
319
+ handleWorkerResponse(response) {
320
+ const pending = this.pendingRequests.get(response.requestId);
321
+ if (!pending)
322
+ return;
323
+ clearTimeout(pending.timer);
324
+ this.pendingRequests.delete(response.requestId);
325
+ pending.resolve(response);
326
+ }
327
+ /**
328
+ * Handle worker thread crash: reject all pending requests, attempt restart.
329
+ */
330
+ handleWorkerCrash(error) {
331
+ // Reject all pending requests
332
+ for (const [id, pending] of this.pendingRequests) {
333
+ clearTimeout(pending.timer);
334
+ pending.reject(new Error(`Worker thread crashed: ${error.message}`));
335
+ this.pendingRequests.delete(id);
336
+ }
337
+ this.workerReady = false;
338
+ this.worker = null;
339
+ this.workerInitPromise = null;
340
+ this.workerRestartCount++;
341
+ this.lastCrashTime = Date.now();
342
+ if (process.env.NODE_ENV === "development") {
343
+ // eslint-disable-next-line no-console
344
+ console.log("[ML] Worker Thread crash, restart count:", this.workerRestartCount);
345
+ }
346
+ }
347
+ /**
348
+ * Check if the worker can be restarted (under max restart limit).
349
+ */
350
+ canRestartWorker() {
351
+ return this.workerRestartCount < MAX_WORKER_RESTARTS;
352
+ }
353
+ // =====================================================
354
+ // In-process fallback (legacy mode)
355
+ // =====================================================
356
+ /**
357
+ * Initialize the in-process embedding pipeline (lazy loading).
358
+ * Used when worker thread is disabled.
359
+ *
360
+ * If the configured device is 'cuda' but initialization fails (e.g. because
361
+ * LD_LIBRARY_PATH was not set at the OS level), automatically falls back to CPU.
362
+ */
363
+ async initializeInProcess() {
364
+ if (this.pipeline)
365
+ return;
366
+ if (this.initPromise)
367
+ return this.initPromise;
368
+ this.initPromise = (async () => {
369
+ try {
370
+ if (process.env.NODE_ENV === "development") {
371
+ // eslint-disable-next-line no-console
372
+ console.log("[ML] Loading embedding model (in-process):", this.config.modelId, "(device:", this.config.device + ")");
373
+ }
374
+ const startTime = Date.now();
375
+ const { pipeline } = await import("@huggingface/transformers");
376
+ try {
377
+ this.pipeline = (await pipeline("feature-extraction", this.config.modelId, {
378
+ dtype: this.config.dtype,
379
+ device: this.config.device,
380
+ }));
381
+ }
382
+ catch (deviceError) {
383
+ if (this.config.device !== "cpu") {
384
+ const msg = deviceError instanceof Error ? deviceError.message : String(deviceError);
385
+ console.warn("[ML] %s pipeline creation failed, falling back to CPU: %s", this.config.device, msg);
386
+ this.config = { ...this.config, device: "cpu" };
387
+ this.currentProvider = "cpu";
388
+ this.pipeline = (await pipeline("feature-extraction", this.config.modelId, {
389
+ dtype: this.config.dtype,
390
+ device: "cpu",
391
+ }));
392
+ }
393
+ else {
394
+ throw deviceError;
395
+ }
396
+ }
397
+ const loadTime = Date.now() - startTime;
398
+ if (process.env.NODE_ENV === "development") {
399
+ // eslint-disable-next-line no-console
400
+ console.log("[ML] Model loaded in-process in", loadTime, "ms (provider:", this.currentProvider + ")");
401
+ }
402
+ }
403
+ catch (error) {
404
+ this.initPromise = null;
405
+ const message = error instanceof Error ? error.message : "Unknown error";
406
+ throw new Error(`Failed to load embedding model: ${message}`);
407
+ }
408
+ })();
409
+ return this.initPromise;
410
+ }
411
+ /**
412
+ * Recycle the in-process pipeline if threshold reached.
413
+ */
414
+ async recyclePipelineIfNeeded(inferenceCount) {
415
+ this.inferencesSinceRecycle += inferenceCount;
416
+ const threshold = this.config.pipelineRecycleThreshold;
417
+ if (threshold <= 0)
418
+ return;
419
+ if (this.inferencesSinceRecycle >= threshold) {
420
+ if (process.env.NODE_ENV === "development") {
421
+ // eslint-disable-next-line no-console
422
+ console.log("[ML] Pipeline recycle threshold reached", {
423
+ inferencesSinceRecycle: this.inferencesSinceRecycle,
424
+ threshold,
425
+ totalRecycles: this.totalRecycles,
426
+ });
427
+ }
428
+ await this.disposeInProcess();
429
+ this.totalRecycles++;
430
+ if (typeof global.gc === "function") {
431
+ global.gc();
432
+ }
433
+ if (process.env.NODE_ENV === "development") {
434
+ // eslint-disable-next-line no-console
435
+ console.log("[ML] Pipeline recycled successfully", {
436
+ totalRecycles: this.totalRecycles,
437
+ });
438
+ }
439
+ }
440
+ }
441
+ /**
442
+ * Dispose the in-process pipeline.
443
+ */
444
+ async disposeInProcess() {
445
+ if (this.pipeline && typeof this.pipeline.dispose === "function") {
446
+ try {
447
+ await this.pipeline.dispose();
448
+ }
449
+ catch {
450
+ // Disposal failure should not break the workflow
451
+ }
452
+ }
453
+ this.pipeline = null;
454
+ this.initPromise = null;
455
+ this.inferencesSinceRecycle = 0;
456
+ }
457
+ /**
458
+ * Generate a single embedding in-process (fallback).
459
+ */
460
+ async generateInProcess(prefixedText) {
461
+ await this.initializeInProcess();
462
+ if (!this.pipeline)
463
+ throw new Error("Embedding pipeline not initialized");
464
+ const output = await this.pipeline(prefixedText, { pooling: "mean", normalize: true });
465
+ let embedding;
466
+ if (output && typeof output.tolist === "function") {
467
+ const result = output.tolist();
468
+ embedding = Array.isArray(result[0]) ? result[0] : result;
469
+ if (typeof output.dispose === "function") {
470
+ output.dispose();
471
+ }
472
+ }
473
+ else if (Array.isArray(output)) {
474
+ embedding = output;
475
+ }
476
+ else {
477
+ throw new Error("Unexpected embedding output format");
478
+ }
479
+ if (embedding.length !== EMBEDDING_DIMENSION) {
480
+ console.warn(`[ML] Warning: Expected ${EMBEDDING_DIMENSION} dimensions, got ${embedding.length}`);
481
+ }
482
+ await this.recyclePipelineIfNeeded(1);
483
+ return normalizeVector(embedding);
484
+ }
485
+ /**
486
+ * Generate batch embeddings in-process (fallback).
487
+ */
488
+ async generateBatchInProcess(prefixedTexts) {
489
+ const BATCH_SIZE = 32;
490
+ const allEmbeddings = [];
491
+ for (let batchStart = 0; batchStart < prefixedTexts.length; batchStart += BATCH_SIZE) {
492
+ const batchEnd = Math.min(batchStart + BATCH_SIZE, prefixedTexts.length);
493
+ const batch = prefixedTexts.slice(batchStart, batchEnd);
494
+ if (!this.pipeline) {
495
+ await this.initializeInProcess();
496
+ if (!this.pipeline)
497
+ throw new Error("Embedding pipeline not initialized after recycle");
498
+ }
499
+ const output = await this.pipeline(batch, { pooling: "mean", normalize: true });
500
+ let batchEmbeddings;
501
+ if (output && typeof output.tolist === "function") {
502
+ batchEmbeddings = output.tolist();
503
+ if (typeof output.dispose === "function") {
504
+ output.dispose();
505
+ }
506
+ }
507
+ else if (Array.isArray(output)) {
508
+ batchEmbeddings = output;
509
+ }
510
+ else {
511
+ throw new Error("Unexpected batch embedding output format");
512
+ }
513
+ for (const emb of batchEmbeddings) {
514
+ allEmbeddings.push(normalizeVector(emb));
515
+ }
516
+ await this.recyclePipelineIfNeeded(batch.length);
517
+ // Yield between batches
518
+ if (batchEnd < prefixedTexts.length) {
519
+ await new Promise((resolve) => setImmediate(resolve));
520
+ }
521
+ }
522
+ return allEmbeddings;
523
+ }
524
+ // =====================================================
525
+ // Idle timer for automatic VRAM release
526
+ // =====================================================
527
+ /**
528
+ * Set the idle timeout for automatic VRAM release.
529
+ *
530
+ * When set to a positive value (ms), the service will automatically call
531
+ * dispose() after no embedding generation activity for the specified duration.
532
+ * This frees GPU VRAM (e.g. 1,406 MiB for multilingual-e5-base on CUDA)
533
+ * so that other GPU consumers (e.g. Ollama Vision at 11.7 GB) can use it.
534
+ *
535
+ * dispose() preserves the CUDA provider setting, so the next inference call
536
+ * will re-initialize with CUDA automatically (unlike releaseGpu() which
537
+ * forces CPU).
538
+ *
539
+ * @param ms - Timeout in milliseconds. 0 disables the idle timer.
540
+ */
541
+ setIdleTimeout(ms) {
542
+ this.idleTimeoutMs = ms;
543
+ // Clear any existing timer when reconfiguring
544
+ if (this.idleTimer !== null) {
545
+ clearTimeout(this.idleTimer);
546
+ this.idleTimer = null;
547
+ }
548
+ if (process.env.NODE_ENV === "development") {
549
+ // eslint-disable-next-line no-console
550
+ console.log("[ML] Idle timeout set to %dms (0 = disabled)", ms);
551
+ }
552
+ }
553
+ /**
554
+ * Reset the idle timer after an embedding generation completes.
555
+ * Called in finally blocks of generateEmbedding() and generateBatchEmbeddings().
556
+ */
557
+ resetIdleTimer() {
558
+ if (this.idleTimeoutMs <= 0)
559
+ return;
560
+ if (this.idleTimer !== null) {
561
+ clearTimeout(this.idleTimer);
562
+ }
563
+ this.idleTimer = setTimeout(() => {
564
+ this.idleTimer = null;
565
+ if (process.env.NODE_ENV === "development") {
566
+ // eslint-disable-next-line no-console
567
+ console.log("[ML] Idle timeout reached (%dms), disposing ONNX pipeline to free VRAM", this.idleTimeoutMs);
568
+ }
569
+ // Fire-and-forget: dispose() is safe to call and errors are caught internally
570
+ void this.dispose().catch((err) => {
571
+ console.warn("[ML] Idle timer dispose failed:", err instanceof Error ? err.message : String(err));
572
+ });
573
+ }, this.idleTimeoutMs);
574
+ // Ensure the timer does not prevent Node.js process from exiting
575
+ if (this.idleTimer && typeof this.idleTimer === "object" && "unref" in this.idleTimer) {
576
+ this.idleTimer.unref();
577
+ }
578
+ }
579
+ // =====================================================
580
+ // Public API
581
+ // =====================================================
582
+ /**
583
+ * Check if the model is initialized (worker thread is ready or pipeline loaded).
584
+ */
585
+ isInitialized() {
586
+ if (this.useWorkerThread) {
587
+ return this.workerReady;
588
+ }
589
+ return this.pipeline !== null;
590
+ }
591
+ /**
592
+ * Get the total number of pipeline recycles performed.
593
+ */
594
+ getRecycleCount() {
595
+ return this.totalRecycles;
596
+ }
597
+ /**
598
+ * Get the number of inferences since the last pipeline recycle.
599
+ * In worker-thread mode this is approximate (tracked via dispose calls).
600
+ */
601
+ getInferencesSinceRecycle() {
602
+ return this.inferencesSinceRecycle;
603
+ }
604
+ /**
605
+ * Generate cache key for embedding
606
+ */
607
+ getCacheKey(text, type) {
608
+ return `${type}:${text}`;
609
+ }
610
+ /**
611
+ * Set cache entry with LRU eviction
612
+ */
613
+ setCacheEntry(key, value) {
614
+ while (this.cache.size >= this.config.maxCacheSize) {
615
+ const oldestKey = this.cache.keys().next().value;
616
+ if (oldestKey !== undefined) {
617
+ this.cache.delete(oldestKey);
618
+ this.cacheEvictions++;
619
+ if (process.env.NODE_ENV === "development") {
620
+ // eslint-disable-next-line no-console
621
+ console.log("[ML] Cache evicted oldest entry, size:", this.cache.size);
622
+ }
623
+ }
624
+ else {
625
+ break;
626
+ }
627
+ }
628
+ this.cache.set(key, value);
629
+ }
630
+ /**
631
+ * Get cache entry with LRU update (move to end)
632
+ */
633
+ getCacheEntry(key) {
634
+ const value = this.cache.get(key);
635
+ if (value !== undefined) {
636
+ this.cache.delete(key);
637
+ this.cache.set(key, value);
638
+ }
639
+ return value;
640
+ }
641
+ /**
642
+ * Generate embedding for a single text
643
+ *
644
+ * @param text - Input text to embed
645
+ * @param type - Type of text ('query' or 'passage')
646
+ * @returns 768-dimensional normalized embedding vector
647
+ */
648
+ async generateEmbedding(text, type) {
649
+ // Check cache first (with LRU update)
650
+ const cacheKey = this.getCacheKey(text, type);
651
+ const cached = this.getCacheEntry(cacheKey);
652
+ if (cached) {
653
+ this.cacheHits++;
654
+ if (process.env.NODE_ENV === "development") {
655
+ // eslint-disable-next-line no-console
656
+ console.log("[ML] Cache hit for:", text.substring(0, 30));
657
+ }
658
+ // Cache hits also reset the idle timer (service is still "active")
659
+ this.resetIdleTimer();
660
+ return cached;
661
+ }
662
+ this.cacheMisses++;
663
+ const startTime = Date.now();
664
+ const prefixedText = E5_PREFIX[type] + text;
665
+ if (process.env.NODE_ENV === "development") {
666
+ // eslint-disable-next-line no-console
667
+ console.log("[ML] Generating embedding for:", prefixedText.substring(0, 50));
668
+ }
669
+ try {
670
+ let embedding;
671
+ if (this.useWorkerThread) {
672
+ embedding = await this.generateViaWorker(prefixedText);
673
+ }
674
+ else {
675
+ embedding = await this.generateInProcess(prefixedText);
676
+ }
677
+ // Ensure proper dimension
678
+ if (embedding.length !== EMBEDDING_DIMENSION) {
679
+ console.warn(`[ML] Warning: Expected ${EMBEDDING_DIMENSION} dimensions, got ${embedding.length}`);
680
+ }
681
+ // Cache the result
682
+ this.setCacheEntry(cacheKey, embedding);
683
+ const elapsedMs = Date.now() - startTime;
684
+ if (process.env.NODE_ENV === "development") {
685
+ // eslint-disable-next-line no-console
686
+ console.log("[ML] Embedding generated in", elapsedMs, "ms");
687
+ }
688
+ return embedding;
689
+ }
690
+ finally {
691
+ this.resetIdleTimer();
692
+ }
693
+ }
694
+ /**
695
+ * Generate embedding via Worker Thread, with crash recovery.
696
+ */
697
+ async generateViaWorker(prefixedText) {
698
+ // Ensure worker is running (spawn + init if needed, or restart after crash)
699
+ if (!this.workerReady || !this.worker) {
700
+ if (!this.canRestartWorker() && this.workerRestartCount > 0) {
701
+ throw new Error(`Worker thread exceeded max restarts (${MAX_WORKER_RESTARTS})`);
702
+ }
703
+ await this.ensureWorkerReady();
704
+ }
705
+ const response = await this.sendWorkerMessage({
706
+ type: "generate",
707
+ requestId: generateRequestId(),
708
+ text: prefixedText,
709
+ });
710
+ if (response.type === "error") {
711
+ throw new Error(`Worker inference failed: ${response.error}`);
712
+ }
713
+ if (response.type !== "generate") {
714
+ throw new Error(`Unexpected worker response type: ${response.type}`);
715
+ }
716
+ return response.embedding;
717
+ }
718
+ /**
719
+ * Generate batch embeddings via Worker Thread, with crash recovery.
720
+ */
721
+ async generateBatchViaWorker(prefixedTexts) {
722
+ if (!this.workerReady || !this.worker) {
723
+ if (!this.canRestartWorker() && this.workerRestartCount > 0) {
724
+ throw new Error(`Worker thread exceeded max restarts (${MAX_WORKER_RESTARTS})`);
725
+ }
726
+ await this.ensureWorkerReady();
727
+ }
728
+ const response = await this.sendWorkerMessage({
729
+ type: "generateBatch",
730
+ requestId: generateRequestId(),
731
+ texts: prefixedTexts,
732
+ });
733
+ if (response.type === "error") {
734
+ throw new Error(`Worker batch inference failed: ${response.error}`);
735
+ }
736
+ if (response.type !== "generateBatch") {
737
+ throw new Error(`Unexpected worker response type: ${response.type}`);
738
+ }
739
+ return response.embeddings;
740
+ }
741
+ /**
742
+ * Generate embeddings for multiple texts
743
+ *
744
+ * @param texts - Array of input texts
745
+ * @param type - Type of texts ('query' or 'passage')
746
+ * @returns Array of 768-dimensional normalized embedding vectors
747
+ */
748
+ async generateBatchEmbeddings(texts, type) {
749
+ if (texts.length === 0) {
750
+ return [];
751
+ }
752
+ const startTime = Date.now();
753
+ if (process.env.NODE_ENV === "development") {
754
+ // eslint-disable-next-line no-console
755
+ console.log("[ML] Generating batch embeddings for", texts.length, "texts");
756
+ }
757
+ try {
758
+ // Check cache for each text
759
+ const results = new Array(texts.length);
760
+ const uncachedIndices = [];
761
+ const uncachedTexts = [];
762
+ for (let i = 0; i < texts.length; i++) {
763
+ const text = texts[i];
764
+ if (text === undefined)
765
+ continue;
766
+ const cacheKey = this.getCacheKey(text, type);
767
+ const cached = this.getCacheEntry(cacheKey);
768
+ if (cached) {
769
+ this.cacheHits++;
770
+ results[i] = cached;
771
+ }
772
+ else {
773
+ this.cacheMisses++;
774
+ uncachedIndices.push(i);
775
+ uncachedTexts.push(text);
776
+ }
777
+ }
778
+ // Generate embeddings for uncached texts
779
+ if (uncachedTexts.length > 0) {
780
+ const prefixedTexts = uncachedTexts.map((text) => E5_PREFIX[type] + text);
781
+ let generatedEmbeddings;
782
+ if (this.useWorkerThread) {
783
+ generatedEmbeddings = await this.generateBatchViaWorker(prefixedTexts);
784
+ }
785
+ else {
786
+ generatedEmbeddings = await this.generateBatchInProcess(prefixedTexts);
787
+ }
788
+ // Store results and cache
789
+ for (let j = 0; j < generatedEmbeddings.length; j++) {
790
+ const originalIndex = uncachedIndices[j];
791
+ const embedding = generatedEmbeddings[j];
792
+ if (originalIndex === undefined || embedding === undefined)
793
+ continue;
794
+ results[originalIndex] = embedding;
795
+ // Cache the result
796
+ const originalText = texts[originalIndex];
797
+ if (originalText !== undefined) {
798
+ const cacheKey = this.getCacheKey(originalText, type);
799
+ this.setCacheEntry(cacheKey, embedding);
800
+ }
801
+ }
802
+ }
803
+ const elapsedMs = Date.now() - startTime;
804
+ if (process.env.NODE_ENV === "development") {
805
+ // eslint-disable-next-line no-console
806
+ console.log("[ML] Batch embeddings generated in", elapsedMs, "ms");
807
+ }
808
+ return results.filter((r) => r !== undefined);
809
+ }
810
+ finally {
811
+ this.resetIdleTimer();
812
+ }
813
+ }
814
+ /**
815
+ * Dispose the ONNX pipeline to free native memory.
816
+ *
817
+ * In worker-thread mode, sends a dispose message to the worker.
818
+ * In fallback mode, disposes the in-process pipeline.
819
+ *
820
+ * After disposal, the next inference call will re-initialize automatically.
821
+ */
822
+ async dispose() {
823
+ // Clear idle timer to prevent re-entrant dispose calls
824
+ if (this.idleTimer !== null) {
825
+ clearTimeout(this.idleTimer);
826
+ this.idleTimer = null;
827
+ }
828
+ if (process.env.NODE_ENV === "development") {
829
+ // eslint-disable-next-line no-console
830
+ console.log("[ML] Disposing ONNX pipeline", {
831
+ mode: this.useWorkerThread ? "worker-thread" : "in-process",
832
+ inferencesSinceRecycle: this.inferencesSinceRecycle,
833
+ totalRecycles: this.totalRecycles,
834
+ });
835
+ }
836
+ if (this.useWorkerThread) {
837
+ if (this.worker && this.workerReady) {
838
+ try {
839
+ await this.sendWorkerMessage({
840
+ type: "dispose",
841
+ requestId: generateRequestId(),
842
+ });
843
+ }
844
+ catch {
845
+ // Disposal failure should not break the workflow
846
+ }
847
+ }
848
+ }
849
+ else {
850
+ await this.disposeInProcess();
851
+ }
852
+ this.inferencesSinceRecycle = 0;
853
+ }
854
+ /**
855
+ * Terminate the worker thread completely and clean up.
856
+ * After this, the service cannot be used until a new instance is created.
857
+ */
858
+ async terminate() {
859
+ // Clear idle timer
860
+ if (this.idleTimer !== null) {
861
+ clearTimeout(this.idleTimer);
862
+ this.idleTimer = null;
863
+ }
864
+ if (this.worker) {
865
+ try {
866
+ await this.sendWorkerMessage({
867
+ type: "terminate",
868
+ requestId: generateRequestId(),
869
+ });
870
+ }
871
+ catch {
872
+ // May fail if worker already exited
873
+ }
874
+ // Force terminate after a grace period
875
+ try {
876
+ await this.worker.terminate();
877
+ }
878
+ catch {
879
+ // Already terminated
880
+ }
881
+ this.worker = null;
882
+ this.workerReady = false;
883
+ this.workerInitPromise = null;
884
+ }
885
+ // Also dispose in-process pipeline if present
886
+ await this.disposeInProcess();
887
+ // Reject any remaining pending requests
888
+ for (const [id, pending] of this.pendingRequests) {
889
+ clearTimeout(pending.timer);
890
+ pending.reject(new Error("Service terminated"));
891
+ this.pendingRequests.delete(id);
892
+ }
893
+ }
894
+ /**
895
+ * Get cache statistics
896
+ */
897
+ getCacheStats() {
898
+ return {
899
+ hits: this.cacheHits,
900
+ misses: this.cacheMisses,
901
+ size: this.cache.size,
902
+ evictions: this.cacheEvictions,
903
+ };
904
+ }
905
+ /**
906
+ * Clear the embedding cache
907
+ */
908
+ clearCache() {
909
+ this.cache.clear();
910
+ this.cacheHits = 0;
911
+ this.cacheMisses = 0;
912
+ this.cacheEvictions = 0;
913
+ if (process.env.NODE_ENV === "development") {
914
+ // eslint-disable-next-line no-console
915
+ console.log("[ML] Cache cleared");
916
+ }
917
+ }
918
+ /**
919
+ * Whether this service is using a Worker Thread for inference.
920
+ */
921
+ isUsingWorkerThread() {
922
+ return this.useWorkerThread;
923
+ }
924
+ /**
925
+ * Get the current ONNX execution provider ('cpu' or 'cuda').
926
+ */
927
+ getCurrentProvider() {
928
+ return this.currentProvider;
929
+ }
930
+ /**
931
+ * Switch ONNX execution provider at runtime.
932
+ *
933
+ * In worker-thread mode: sends a switch-provider message to the worker,
934
+ * which disposes the current pipeline and updates the device config.
935
+ * In in-process mode: disposes the pipeline and updates the config
936
+ * so the next inference call re-initializes with the new provider.
937
+ *
938
+ * @param provider - The target execution provider ('cpu' or 'cuda').
939
+ * @returns true if the switch succeeded and the requested provider is now
940
+ * active; false if the switch was not possible (e.g. CUDA not
941
+ * available) — in that case the service remains on CPU.
942
+ */
943
+ async switchProvider(provider) {
944
+ if (provider === this.currentProvider) {
945
+ return true;
946
+ }
947
+ if (process.env.NODE_ENV === "development") {
948
+ // eslint-disable-next-line no-console
949
+ console.log("[ML] Switching provider: %s → %s", this.currentProvider, provider);
950
+ }
951
+ if (this.useWorkerThread) {
952
+ return this.switchProviderViaWorker(provider);
953
+ }
954
+ return this.switchProviderInProcess(provider);
955
+ }
956
+ /**
957
+ * Switch provider via Worker Thread message.
958
+ */
959
+ async switchProviderViaWorker(provider) {
960
+ if (!this.workerReady || !this.worker) {
961
+ // Worker not running — just update local config. When the worker
962
+ // starts next, it will pick up the new device setting.
963
+ this.config = { ...this.config, device: provider };
964
+ this.currentProvider = provider;
965
+ // For CUDA, we can't verify availability without the worker, so
966
+ // we optimistically set it. The worker's init will fall back to
967
+ // CPU if onnxruntime-gpu is not installed.
968
+ return true;
969
+ }
970
+ const response = await this.sendWorkerMessage({
971
+ type: "switch-provider",
972
+ requestId: generateRequestId(),
973
+ provider,
974
+ });
975
+ if (response.type === "error") {
976
+ if (process.env.NODE_ENV === "development") {
977
+ console.warn("[ML] Provider switch failed:", response.error);
978
+ }
979
+ return false;
980
+ }
981
+ if (response.type === "switch-provider") {
982
+ this.currentProvider = response.provider;
983
+ this.config = { ...this.config, device: response.provider };
984
+ return response.provider === provider;
985
+ }
986
+ return false;
987
+ }
988
+ /**
989
+ * Switch provider in-process (fallback mode).
990
+ */
991
+ async switchProviderInProcess(provider) {
992
+ // For CUDA, verify the CUDA provider shared library is available
993
+ if (provider === "cuda") {
994
+ try {
995
+ const fs = await import("node:fs");
996
+ const pathMod = await import("node:path");
997
+ const { createRequire } = await import("node:module");
998
+ const require_ = createRequire(import.meta.url);
999
+ const ortNodePath = require_.resolve("onnxruntime-node");
1000
+ // Walk up to package root (resolve returns .../dist/index.js)
1001
+ let packageDir = pathMod.dirname(ortNodePath);
1002
+ for (let i = 0; i < 5; i++) {
1003
+ if (fs.existsSync(pathMod.join(packageDir, "package.json")))
1004
+ break;
1005
+ packageDir = pathMod.dirname(packageDir);
1006
+ }
1007
+ // Search across napi versions (v3, v6, etc.) for CUDA provider
1008
+ let cudaFound = false;
1009
+ const binDir = pathMod.join(packageDir, "bin");
1010
+ if (fs.existsSync(binDir)) {
1011
+ const napiDirs = fs.readdirSync(binDir).filter((d) => d.startsWith("napi-v"));
1012
+ for (const napiDir of napiDirs) {
1013
+ const p = pathMod.join(binDir, napiDir, "linux", "x64", "libonnxruntime_providers_cuda.so");
1014
+ if (fs.existsSync(p)) {
1015
+ cudaFound = true;
1016
+ break;
1017
+ }
1018
+ }
1019
+ }
1020
+ if (!cudaFound) {
1021
+ if (process.env.NODE_ENV === "development") {
1022
+ console.warn("[ML] Cannot switch to CUDA in-process: CUDA provider not found");
1023
+ }
1024
+ return false;
1025
+ }
1026
+ }
1027
+ catch {
1028
+ if (process.env.NODE_ENV === "development") {
1029
+ console.warn("[ML] Cannot switch to CUDA in-process: onnxruntime-node not found");
1030
+ }
1031
+ return false;
1032
+ }
1033
+ }
1034
+ // Dispose current pipeline so next inference re-inits with new device
1035
+ await this.disposeInProcess();
1036
+ this.config = { ...this.config, device: provider };
1037
+ this.currentProvider = provider;
1038
+ return true;
1039
+ }
1040
+ /**
1041
+ * Release GPU resources by disposing the pipeline and switching to CPU.
1042
+ *
1043
+ * Safe to call even if already on CPU — acts as a dispose + ensures
1044
+ * the provider is set to CPU.
1045
+ */
1046
+ async releaseGpu() {
1047
+ if (process.env.NODE_ENV === "development") {
1048
+ // eslint-disable-next-line no-console
1049
+ console.log("[ML] Releasing GPU resources (current provider: %s)", this.currentProvider);
1050
+ }
1051
+ if (this.useWorkerThread) {
1052
+ await this.releaseGpuViaWorker();
1053
+ }
1054
+ else {
1055
+ await this.releaseGpuInProcess();
1056
+ }
1057
+ }
1058
+ /**
1059
+ * Release GPU via Worker Thread message.
1060
+ */
1061
+ async releaseGpuViaWorker() {
1062
+ if (this.worker && this.workerReady) {
1063
+ try {
1064
+ await this.sendWorkerMessage({
1065
+ type: "release-gpu",
1066
+ requestId: generateRequestId(),
1067
+ });
1068
+ }
1069
+ catch {
1070
+ // Release failure should not break the workflow
1071
+ }
1072
+ }
1073
+ this.currentProvider = "cpu";
1074
+ this.config = { ...this.config, device: "cpu" };
1075
+ }
1076
+ /**
1077
+ * Release GPU in-process (fallback mode).
1078
+ */
1079
+ async releaseGpuInProcess() {
1080
+ await this.disposeInProcess();
1081
+ this.currentProvider = "cpu";
1082
+ this.config = { ...this.config, device: "cpu" };
1083
+ }
1084
+ /**
1085
+ * Get the number of worker thread restarts that have occurred.
1086
+ */
1087
+ getWorkerRestartCount() {
1088
+ return this.workerRestartCount;
1089
+ }
1090
+ }
1091
+ // Singleton instance for default usage
1092
+ export const embeddingService = new EmbeddingService();
1093
+ //# sourceMappingURL=service.js.map