@lotargo/memory_plugin 1.1.4 → 1.1.6

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.
@@ -1,38 +1,83 @@
1
+ import os from "node:os";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
1
4
  import { MODELS_DIR } from "../db/database.js";
2
5
 
3
6
  let extractorInstance = null;
4
7
  let loadedModelName = null;
8
+ let loadedDevice = null;
5
9
 
6
10
  let rerankerInstance = null;
7
11
  let loadedRerankerName = null;
8
12
 
9
13
  import { getConfig } from "../config/config_manager.js";
14
+ import { GpuMonitor, ExecutionTracer } from "./gpu_monitor.js";
15
+ export { GpuMonitor, ExecutionTracer };
16
+
17
+ function getOptimalThreadCount() {
18
+ const userSetting = getConfig().onnxThreads;
19
+ if (typeof userSetting === "number" && userSetting > 0) {
20
+ return userSetting;
21
+ }
22
+ const totalCores = os.availableParallelism ? os.availableParallelism() : os.cpus().length;
23
+ return Math.max(1, Math.min(totalCores, 8));
24
+ }
10
25
 
11
26
  export async function getExtractor(modelName = null, progressCallback = null) {
12
27
  const targetModel = modelName || getConfig().embeddingModel || "Xenova/multilingual-e5-small";
13
28
 
14
- if (extractorInstance && loadedModelName === targetModel) {
15
- return extractorInstance;
29
+ // Resolve target device BEFORE cache check so comparison works correctly
30
+ const rawDevice = (getConfig().executionDevice || "cpu").toLowerCase();
31
+ let targetDevice = "cpu";
32
+ if (rawDevice === "webgpu" || rawDevice === "gpu" || rawDevice === "dml" || rawDevice === "cuda") {
33
+ if (process.platform === "win32") {
34
+ targetDevice = "dml";
35
+ } else if (process.platform === "linux") {
36
+ targetDevice = "cuda";
37
+ } else {
38
+ targetDevice = "webgpu";
39
+ }
16
40
  }
17
41
 
18
- const { pipeline, env } = await import("@xenova/transformers");
42
+ if (extractorInstance && loadedModelName === targetModel && loadedDevice === targetDevice) {
43
+ return extractorInstance;
44
+ }
19
45
 
20
- // Fix ONNX Runtime Node.js WASM execution provider warning
21
- try {
22
- const { executionProviders } = await import("@xenova/transformers/src/backends/onnx.js");
23
- const wasmIdx = executionProviders.indexOf("wasm");
24
- if (wasmIdx !== -1) {
25
- executionProviders.splice(wasmIdx, 1);
26
- }
27
- } catch {}
46
+ const { pipeline, env } = await import("@huggingface/transformers");
28
47
 
29
48
  env.cacheDir = MODELS_DIR;
30
49
  env.allowLocalModels = true;
31
50
  env.allowRemoteModels = true;
32
51
  env.remoteHost = "https://huggingface.co";
33
52
  env.remotePathTemplate = "{model}/resolve/{revision}/";
53
+ env.sharp = false;
54
+
55
+ const numThreads = getOptimalThreadCount();
56
+ if (env.backends?.onnx?.wasm) {
57
+ env.backends.onnx.wasm.numThreads = numThreads;
58
+ }
34
59
 
35
- const pipelineOpts = { quantized: true };
60
+ const sessionOptions = {
61
+ graphOptimizationLevel: "all",
62
+ executionMode: targetDevice !== "cpu" ? "parallel" : "sequential",
63
+ };
64
+
65
+ if (targetDevice !== "cpu") {
66
+ sessionOptions.enableCpuMemArena = false;
67
+ sessionOptions.enableMemPattern = false;
68
+ if (targetDevice === "dml") {
69
+ sessionOptions.executionProviders = [{ name: "dml", device_id: 0 }];
70
+ } else if (targetDevice === "cuda") {
71
+ sessionOptions.executionProviders = [{ name: "cuda", device_id: 0 }];
72
+ }
73
+ }
74
+
75
+ const pipelineOpts = {
76
+ quantized: true,
77
+ dtype: "q8",
78
+ device: targetDevice,
79
+ session_options: sessionOptions,
80
+ };
36
81
  if (progressCallback) {
37
82
  pipelineOpts.progress_callback = progressCallback;
38
83
  }
@@ -40,11 +85,26 @@ export async function getExtractor(modelName = null, progressCallback = null) {
40
85
  try {
41
86
  extractorInstance = await pipeline("feature-extraction", targetModel, pipelineOpts);
42
87
  loadedModelName = targetModel;
88
+ loadedDevice = targetDevice;
89
+
90
+ if (targetDevice !== "cpu") {
91
+ try {
92
+ await extractorInstance("GPU VRAM Warmup Init", { pooling: "mean", normalize: true });
93
+ } catch {}
94
+ }
43
95
  } catch (err) {
44
- console.warn(`HuggingFace model load failed for ${targetModel}: ${err.message}. Falling back to default Xenova/multilingual-e5-small...`);
45
- if (targetModel !== "Xenova/multilingual-e5-small") {
96
+ if (targetDevice !== "cpu") {
97
+ console.warn(`[GPU Engine] GPU initialization (${targetDevice}) failed: ${err.message}. Falling back to CPU...`);
98
+ pipelineOpts.device = "cpu";
99
+ pipelineOpts.session_options.executionMode = "sequential";
100
+ extractorInstance = await pipeline("feature-extraction", targetModel, pipelineOpts);
101
+ loadedModelName = targetModel;
102
+ loadedDevice = "cpu";
103
+ } else if (targetModel !== "Xenova/multilingual-e5-small") {
104
+ console.warn(`HuggingFace model load failed for ${targetModel}: ${err.message}. Falling back to default Xenova/multilingual-e5-small...`);
46
105
  extractorInstance = await pipeline("feature-extraction", "Xenova/multilingual-e5-small", pipelineOpts);
47
106
  loadedModelName = "Xenova/multilingual-e5-small";
107
+ loadedDevice = targetDevice;
48
108
  } else {
49
109
  throw err;
50
110
  }
@@ -53,6 +113,12 @@ export async function getExtractor(modelName = null, progressCallback = null) {
53
113
  return extractorInstance;
54
114
  }
55
115
 
116
+ export function resetExtractor() {
117
+ extractorInstance = null;
118
+ loadedModelName = null;
119
+ loadedDevice = null;
120
+ }
121
+
56
122
  export function formatInputText(text, isQuery = false, modelName = null, instruction = null) {
57
123
  if (!text) return "";
58
124
  const targetModel = modelName || getConfig().embeddingModel || "Xenova/multilingual-e5-small";
@@ -65,6 +131,11 @@ export function formatInputText(text, isQuery = false, modelName = null, instruc
65
131
  cleanText = cleanText.substring(7).trim();
66
132
  }
67
133
 
134
+ // Safety trim long text to prevent ONNX tensor padding explosions (>4000 chars ~1000 tokens)
135
+ if (cleanText.length > 4000) {
136
+ cleanText = cleanText.substring(0, 4000);
137
+ }
138
+
68
139
  // 1. E5 Model Family (multilingual-e5-small, multilingual-e5-large, etc.)
69
140
  if (name.includes("e5")) {
70
141
  const isInstructModel = name.includes("-instruct");
@@ -97,12 +168,127 @@ export async function embedText(text, isQuery = false, modelName = null, progres
97
168
  const extractor = await getExtractor(targetModel, progressCallback);
98
169
  const formattedText = formatInputText(text, isQuery, targetModel, instruction);
99
170
 
171
+ const isBgeM3 = targetModel.toLowerCase().includes("bge-m3");
172
+ const maxLen = isBgeM3 ? 1024 : 512;
173
+
100
174
  const output = await extractor(formattedText, {
101
175
  pooling: "mean",
102
176
  normalize: true,
177
+ truncation: true,
178
+ max_length: maxLen,
103
179
  });
104
180
 
105
- return new Float32Array(output.data);
181
+ const result = output.data.slice();
182
+ if (typeof output.dispose === 'function') {
183
+ output.dispose();
184
+ }
185
+ return result;
186
+ }
187
+
188
+ export async function embedBatch(texts, isQuery = false, modelName = null, progressCallback = null, instruction = null, traceOptions = {}) {
189
+ if (!texts || texts.length === 0) return [];
190
+ const targetModel = modelName || getConfig().embeddingModel || "Xenova/multilingual-e5-small";
191
+
192
+ const rawDevice = (getConfig().executionDevice || "cpu").toLowerCase();
193
+ const isGpu = rawDevice === "webgpu" || rawDevice === "gpu" || rawDevice === "dml" || rawDevice === "cuda";
194
+
195
+ const tracer = traceOptions.enableTrace ? new ExecutionTracer(`Embed Batch (${texts.length} items)`) : null;
196
+ const monitor = (isGpu && traceOptions.enableMonitor) ? new GpuMonitor(50) : null;
197
+ if (monitor) monitor.start();
198
+
199
+ try {
200
+ if (tracer) tracer.startStage("Model Initialization & Session", "CPU");
201
+ const extractor = await getExtractor(targetModel, progressCallback);
202
+
203
+ if (tracer) tracer.startStage("Text Formatting & Tokenization Preprocessing", "CPU");
204
+ const formattedTexts = texts.map((text) => formatInputText(text, isQuery, targetModel, instruction));
205
+
206
+ const userBatch = getConfig().batchSize || 12;
207
+ const isBgeM3 = targetModel.toLowerCase().includes("bge-m3");
208
+ const isLarge = isBgeM3 || targetModel.toLowerCase().includes("large");
209
+
210
+ // DYNAMIC ATTENTION BUDGET SAMPLER (PyTorch-style Token Budgeting):
211
+ // Attention memory scales quadratically O(seq_len^2).
212
+ // Target max GPU attention budget per ONNX pass: ~2,000,000 token-squared units (~1.5 GB VRAM peak).
213
+ // Short texts (50 tokens) dynamically scale UP to full userBatch (32-64 items per pass) for max GPU compute.
214
+ // Long texts (1000 tokens) dynamically scale DOWN to 2-4 items per pass, keeping VRAM strictly <1.5 GB.
215
+ const configuredBudget = getConfig().gpuAttentionBudget || 2000000;
216
+ const maxAttentionBudget = isGpu
217
+ ? (isLarge ? Math.min(configuredBudget, 2000000) : configuredBudget)
218
+ : 32000000;
219
+
220
+ const CHAR_TO_TOKEN = 3.5;
221
+ const subBatches = [];
222
+ let currentSubBatch = [];
223
+ let currentSubBatchCost = 0;
224
+
225
+ for (const text of formattedTexts) {
226
+ const estimatedTokens = Math.max(1, Math.ceil(text.length / CHAR_TO_TOKEN));
227
+ const cost = estimatedTokens * estimatedTokens;
228
+
229
+ if (
230
+ currentSubBatch.length > 0 &&
231
+ (currentSubBatch.length >= userBatch || currentSubBatchCost + cost > maxAttentionBudget)
232
+ ) {
233
+ subBatches.push(currentSubBatch);
234
+ currentSubBatch = [];
235
+ currentSubBatchCost = 0;
236
+ }
237
+
238
+ currentSubBatch.push(text);
239
+ currentSubBatchCost += cost;
240
+ }
241
+
242
+ if (currentSubBatch.length > 0) {
243
+ subBatches.push(currentSubBatch);
244
+ }
245
+
246
+ if (tracer) tracer.startStage("ONNX Model Tensor Execution", isGpu ? "GPU" : "CPU");
247
+
248
+ const allResults = [];
249
+ const maxLen = isBgeM3 ? 1024 : 512;
250
+
251
+ for (const batchTexts of subBatches) {
252
+ const output = await extractor(batchTexts, {
253
+ pooling: "mean",
254
+ normalize: true,
255
+ truncation: true,
256
+ padding: isGpu ? "max_length" : true,
257
+ max_length: maxLen,
258
+ });
259
+
260
+ const dims = output.dims;
261
+ const batchSize = dims[0];
262
+ const vectorDim = dims[dims.length - 1];
263
+ const rawData = output.data;
264
+
265
+ for (let i = 0; i < batchSize; i++) {
266
+ const byteOffset = i * vectorDim;
267
+ allResults.push(rawData.slice(byteOffset, byteOffset + vectorDim));
268
+ }
269
+
270
+ if (typeof output.dispose === 'function') {
271
+ output.dispose();
272
+ }
273
+
274
+ if (isGpu && global.gc) {
275
+ global.gc({ type: 'minor' });
276
+ }
277
+ }
278
+
279
+ if (tracer) tracer.endStage();
280
+
281
+ const gpuStats = monitor ? monitor.stop() : null;
282
+
283
+ if (tracer && traceOptions.verboseTrace) {
284
+ tracer.printTraceReport(gpuStats);
285
+ }
286
+
287
+ return allResults;
288
+ } catch (err) {
289
+ if (monitor) monitor.stop();
290
+ throw err;
291
+ }
106
292
  }
107
293
 
108
294
  export async function getReranker(modelName = "Xenova/bge-reranker-base", progressCallback = null) {
@@ -110,10 +296,40 @@ export async function getReranker(modelName = "Xenova/bge-reranker-base", progre
110
296
  return rerankerInstance;
111
297
  }
112
298
 
113
- const { pipeline, env } = await import("@xenova/transformers");
299
+ const { pipeline, env } = await import("@huggingface/transformers");
114
300
  env.cacheDir = MODELS_DIR;
301
+ env.allowLocalModels = true;
302
+ env.allowRemoteModels = true;
303
+ env.remoteHost = "https://huggingface.co";
304
+ env.remotePathTemplate = "{model}/resolve/{revision}/";
305
+ env.sharp = false;
115
306
 
116
- const pipelineOpts = { quantized: true };
307
+ const rawDevice = (getConfig().executionDevice || "cpu").toLowerCase();
308
+ let targetDevice = "cpu";
309
+ if (rawDevice === "webgpu" || rawDevice === "gpu" || rawDevice === "dml" || rawDevice === "cuda") {
310
+ targetDevice = process.platform === "win32" ? "dml" : (process.platform === "linux" ? "cuda" : "webgpu");
311
+ }
312
+
313
+ const sessionOptions = {
314
+ graphOptimizationLevel: "all",
315
+ };
316
+
317
+ if (targetDevice !== "cpu") {
318
+ sessionOptions.enableCpuMemArena = true;
319
+ sessionOptions.enableMemPattern = true;
320
+ if (targetDevice === "dml") {
321
+ sessionOptions.executionProviders = [{ name: "dml", device_id: 0 }];
322
+ } else if (targetDevice === "cuda") {
323
+ sessionOptions.executionProviders = [{ name: "cuda", device_id: 0 }];
324
+ }
325
+ }
326
+
327
+ const pipelineOpts = {
328
+ quantized: true,
329
+ dtype: "q8",
330
+ device: targetDevice,
331
+ session_options: sessionOptions,
332
+ };
117
333
  if (progressCallback) {
118
334
  pipelineOpts.progress_callback = progressCallback;
119
335
  }
@@ -183,3 +399,97 @@ export function cosineSimilarity(vecA, vecB) {
183
399
  if (normA === 0 || normB === 0) return 0;
184
400
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
185
401
  }
402
+
403
+ export function getModelStorageInfo(modelName) {
404
+ if (!modelName || modelName === "none") return { status: "not_downloaded", sizeMB: "0.00", bytes: 0 };
405
+ const parts = modelName.split("/");
406
+ const modelDir = path.join(MODELS_DIR, ...parts);
407
+
408
+ if (!fs.existsSync(modelDir)) {
409
+ return { status: "not_downloaded", sizeMB: "0.00", bytes: 0, dir: modelDir };
410
+ }
411
+
412
+ let totalBytes = 0;
413
+ let hasConfig = false;
414
+ let hasTokenizer = false;
415
+ let hasOnnxWeights = false;
416
+
417
+ function scan(dir) {
418
+ try {
419
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
420
+ for (const entry of entries) {
421
+ const fullPath = path.join(dir, entry.name);
422
+ if (entry.isDirectory()) {
423
+ scan(fullPath);
424
+ } else if (entry.isFile()) {
425
+ const stat = fs.statSync(fullPath);
426
+ totalBytes += stat.size;
427
+ if (entry.name === "config.json") hasConfig = true;
428
+ if (entry.name.includes("tokenizer")) hasTokenizer = true;
429
+ // ONNX weights must be substantial (>5MB) or external data (.onnx_data)
430
+ if ((entry.name.endsWith(".onnx") || entry.name.endsWith(".onnx_data")) && stat.size > 5 * 1024 * 1024) {
431
+ hasOnnxWeights = true;
432
+ }
433
+ }
434
+ }
435
+ } catch {}
436
+ }
437
+
438
+ scan(modelDir);
439
+
440
+ const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2);
441
+
442
+ if (totalBytes === 0) {
443
+ return { status: "not_downloaded", sizeMB: "0.00", bytes: 0, dir: modelDir };
444
+ }
445
+
446
+ // Model is ready only if it has config, tokenizer, ONNX weights >5MB, and total folder size >10MB
447
+ if (hasConfig && hasTokenizer && hasOnnxWeights && totalBytes > 10 * 1024 * 1024) {
448
+ return { status: "downloaded", sizeMB, bytes: totalBytes, dir: modelDir };
449
+ }
450
+
451
+ return { status: "partial", sizeMB, bytes: totalBytes, dir: modelDir };
452
+ }
453
+
454
+ export function deleteModelCache(modelName) {
455
+ const info = getModelStorageInfo(modelName);
456
+ if (info.status === "not_downloaded" || !fs.existsSync(info.dir)) {
457
+ return { deleted: false, reason: "Model directory not found" };
458
+ }
459
+
460
+ try {
461
+ fs.rmSync(info.dir, { recursive: true, force: true });
462
+ const parentDir = path.dirname(info.dir);
463
+ if (fs.existsSync(parentDir) && fs.readdirSync(parentDir).length === 0) {
464
+ fs.rmdirSync(parentDir);
465
+ }
466
+ resetExtractor();
467
+ return { deleted: true, modelName, freedMB: info.sizeMB };
468
+ } catch (err) {
469
+ return { deleted: false, reason: err.message };
470
+ }
471
+ }
472
+
473
+ export function listAllCachedModels() {
474
+ const result = [];
475
+ if (!fs.existsSync(MODELS_DIR)) return result;
476
+
477
+ try {
478
+ const orgs = fs.readdirSync(MODELS_DIR, { withFileTypes: true });
479
+ for (const org of orgs) {
480
+ if (org.isDirectory()) {
481
+ const orgDir = path.join(MODELS_DIR, org.name);
482
+ const models = fs.readdirSync(orgDir, { withFileTypes: true });
483
+ for (const model of models) {
484
+ if (model.isDirectory()) {
485
+ const modelName = `${org.name}/${model.name}`;
486
+ const info = getModelStorageInfo(modelName);
487
+ result.push({ modelName, ...info });
488
+ }
489
+ }
490
+ }
491
+ }
492
+ } catch {}
493
+
494
+ return result;
495
+ }
@@ -1,22 +1,44 @@
1
- import { execSync } from "child_process";
2
-
3
- // Only run graceful process termination during explicit global npm updates
4
- // Skip if running interactively or inside active MCP sessions to avoid EOF drops
5
- if (process.env.npm_config_global === "true" || process.env.MEMORY_PREINSTALL_FORCE === "true") {
6
- try {
7
- const currentPid = process.pid;
8
- const ppid = process.ppid;
9
- if (process.platform === "win32") {
10
- // Safely attempt to terminate orphaned background memory node processes
11
- try {
12
- execSync(`wmic process where "name='node.exe' and commandline like '%memory_plugin%' and ProcessId!=${currentPid} and ProcessId!=${ppid}" call terminate`, { stdio: "ignore" });
13
- } catch {}
14
- } else {
15
- try {
16
- execSync(`pkill -f "memory-agent|memory_plugin" || true`, { stdio: "ignore" });
17
- } catch {}
18
- }
19
- } catch (e) {
20
- // Ignore errors
21
- }
22
- }
1
+ import { execSync } from "child_process";
2
+
3
+ // Only run graceful process termination during explicit global npm updates
4
+ // Skip if running interactively or inside active MCP sessions to avoid EOF drops
5
+ if (process.env.npm_config_global === "true" || process.env.MEMORY_PREINSTALL_FORCE === "true") {
6
+ try {
7
+ const currentPid = process.pid;
8
+ const ppid = process.ppid;
9
+
10
+ if (process.platform === "win32") {
11
+ try {
12
+ const psCmd = `Get-CimInstance Win32_Process | Where-Object { ($_.CommandLine -like '*mcp-server/index.js*' -or $_.CommandLine -like '*mcp-server\\\\index.js*') -and $_.CommandLine -notlike '*install*' -and $_.ProcessId -ne ${currentPid} -and $_.ProcessId -ne ${ppid} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
13
+ execSync(`powershell -NoProfile -NonInteractive -Command "${psCmd}"`, { stdio: "ignore" });
14
+ } catch {}
15
+ } else {
16
+ try {
17
+ const psOutput = execSync(`ps -eo pid,ppid,args 2>/dev/null || true`, { encoding: "utf-8" });
18
+ const lines = psOutput.split("\n");
19
+ for (const line of lines) {
20
+ const trimmed = line.trim();
21
+ if (!trimmed) continue;
22
+ const parts = trimmed.split(/\s+/);
23
+ const pid = parseInt(parts[0], 10);
24
+ const parentPid = parseInt(parts[1], 10);
25
+ const cmd = parts.slice(2).join(" ");
26
+
27
+ if (!pid || pid === currentPid || pid === ppid || parentPid === currentPid) continue;
28
+
29
+ const isServer = cmd.includes("mcp-server/index.js") || cmd.includes("mcp-server/index.js");
30
+ const isInstaller = /npm|npx|yarn|pnpm|preinstall|install/i.test(cmd);
31
+
32
+ if (isServer && !isInstaller) {
33
+ try {
34
+ process.kill(pid, "SIGTERM");
35
+ } catch {}
36
+ }
37
+ }
38
+ } catch {}
39
+ }
40
+ } catch (e) {
41
+ // Ignore errors
42
+ }
43
+ }
44
+
@@ -1,5 +1,5 @@
1
1
  import { getDatabase } from "../db/database.js";
2
- import { embedText, bufferToVector, cosineSimilarity, rerankHits } from "../ml/model_manager.js";
2
+ import { embedText, cosineSimilarity, rerankHits } from "../ml/model_manager.js";
3
3
  import { getRelatedSymbols } from "../graph/graph_extractor.js";
4
4
  import { getConfig } from "../config/config_manager.js";
5
5
 
@@ -40,17 +40,22 @@ export function bm25Search(db, query, limit = 30) {
40
40
  export function vectorSearch(db, queryVector, limit = 30, minSim = 0.25) {
41
41
  if (!queryVector || queryVector.length === 0) return [];
42
42
 
43
+ const vectorDim = queryVector.length;
44
+ const tempBuf = new ArrayBuffer(vectorDim * 4);
45
+ const tempView = new Uint8Array(tempBuf);
46
+ const tempVec = new Float32Array(tempBuf);
47
+
43
48
  const stmt = db.prepare(`
44
49
  SELECT m.id, m.section_id, m.doc_id, m.content, m.vector, s.breadcrumbs
45
50
  FROM micro_chunks m
46
51
  JOIN sections s ON m.section_id = s.id;
47
52
  `);
48
- const rows = stmt.all();
49
53
 
50
54
  const scored = [];
51
- for (const r of rows) {
52
- const vec = bufferToVector(r.vector);
53
- const sim = cosineSimilarity(queryVector, vec);
55
+ for (const r of stmt.iterate()) {
56
+ tempView.set(r.vector.subarray(0, vectorDim * 4));
57
+
58
+ const sim = cosineSimilarity(queryVector, tempVec);
54
59
  if (!isNaN(sim) && sim >= minSim) {
55
60
  scored.push({
56
61
  id: r.id,