@lojban/semantic-search-mcp 1.0.7 → 1.0.9

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 (3) hide show
  1. package/README.md +10 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +82 -27
package/README.md CHANGED
@@ -101,6 +101,16 @@ To replace the entire index with new content from several places:
101
101
 
102
102
  Paths can be anywhere on disk (e.g. different drives or projects); the server reads and indexes all supported text/TSV/CSV files under each directory recursively.
103
103
 
104
+ ### Memory and batch size
105
+
106
+ Indexing uses **adaptive batch size** based on free system RAM so the OS doesn’t freeze on low-memory machines. The server reads `os.freemem()`, keeps a reserve (default 400MB), and caps batch size between 32 and 512 lines. You can tune this with env vars:
107
+
108
+ - **`SEMANTIC_SEARCH_RESERVE_MB`** — MB of RAM to keep free (default `400`).
109
+ - **`SEMANTIC_SEARCH_MIN_BATCH`** — minimum lines per batch (default `32`).
110
+ - **`SEMANTIC_SEARCH_MAX_BATCH`** — maximum lines per batch (default `512`).
111
+
112
+ Example: `SEMANTIC_SEARCH_RESERVE_MB=800 SEMANTIC_SEARCH_MAX_BATCH=256` to leave more headroom and use smaller batches.
113
+
104
114
  ## Example: Lojban dictionary gaps
105
115
 
106
116
  1. Put your dictionary TSV (e.g. `jbo-eng.tsv`) in a folder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lojban/semantic-search-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Local-first MCP server for semantic search using transformers.js and SQLite",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  CallToolRequestSchema,
6
6
  ListToolsRequestSchema,
7
7
  } from '@modelcontextprotocol/sdk/types.js';
8
+ import os from 'node:os';
8
9
  import path from 'path';
9
10
  import { getEmbedding, getBatchEmbeddings } from './embeddings.js';
10
11
  import { createVectorStorage, type SearchResult, type VectorStorage } from './storage.js';
@@ -36,17 +37,51 @@ const indexStatus: IndexStatus = {
36
37
  directories: [],
37
38
  };
38
39
 
39
- async function startIndexing(storage: VectorStorage, directories: string[]): Promise<void> {
40
+ // Single "mutex": only one indexing job is allowed to run. Starting a new job aborts the previous one.
41
+ let currentIndexingAbortController: AbortController | null = null;
42
+ let currentJobId = 0;
43
+
44
+ // Adaptive batch size: reserve RAM so we don't freeze the OS (env overrides in bytes or MB)
45
+ const RESERVE_MB = Number(process.env.SEMANTIC_SEARCH_RESERVE_MB) || 400;
46
+ const RESERVE_BYTES = RESERVE_MB * 1024 * 1024;
47
+ const MIN_BATCH = Number(process.env.SEMANTIC_SEARCH_MIN_BATCH) || 32;
48
+ const MAX_BATCH = Number(process.env.SEMANTIC_SEARCH_MAX_BATCH) || 512;
49
+
50
+ /** Rough bytes per indexed line in memory: line text + path + embedding (384 floats) + overhead */
51
+ const BYTES_PER_LINE_ESTIMATE = 4000;
52
+
53
+ /**
54
+ * Compute batch size from current free system RAM. Keeps reserve free to avoid freezing the OS.
55
+ */
56
+ function getAdaptiveBatchSize(): number {
57
+ const free = os.freemem();
58
+ const available = free > RESERVE_BYTES ? free - RESERVE_BYTES : Math.floor(free / 2);
59
+ const batch = Math.floor(available / BYTES_PER_LINE_ESTIMATE);
60
+ const clamped = Math.max(MIN_BATCH, Math.min(MAX_BATCH, batch));
61
+ return clamped;
62
+ }
63
+
64
+ /**
65
+ * Request indexing of directories. If another indexing job is running, it is aborted first.
66
+ * Then a new job is started (clears index and rebuilds).
67
+ */
68
+ function requestIndexing(storage: VectorStorage, directories: string[]): void {
40
69
  if (!directories.length) {
41
70
  console.error('No directories to index. Set SEMANTIC_SEARCH_INDEX_DIRS (comma-separated paths).');
42
71
  return;
43
72
  }
44
73
 
45
- if (indexStatus.isIndexing) {
46
- console.error('Indexing already in progress, not starting a new job.');
47
- return;
74
+ // Abort any in-progress indexing so it doesn't conflict or flush this job's work.
75
+ if (currentIndexingAbortController) {
76
+ currentIndexingAbortController.abort();
77
+ currentIndexingAbortController = null;
48
78
  }
49
79
 
80
+ currentJobId += 1;
81
+ const jobId = currentJobId;
82
+ currentIndexingAbortController = new AbortController();
83
+ const signal = currentIndexingAbortController.signal;
84
+
50
85
  indexStatus.isIndexing = true;
51
86
  indexStatus.startedAt = Date.now();
52
87
  indexStatus.finishedAt = null;
@@ -55,13 +90,24 @@ async function startIndexing(storage: VectorStorage, directories: string[]): Pro
55
90
  indexStatus.indexedLines = 0;
56
91
  indexStatus.indexedFiles = 0;
57
92
 
93
+ void startIndexing(storage, directories, signal, jobId);
94
+ }
95
+
96
+ async function startIndexing(
97
+ storage: VectorStorage,
98
+ directories: string[],
99
+ signal: AbortSignal,
100
+ jobId: number
101
+ ): Promise<void> {
102
+ const isCurrentJob = (): boolean => currentJobId === jobId;
103
+
58
104
  try {
59
- storage.clear();
105
+ if (signal.aborted) return;
60
106
 
107
+ storage.clear();
61
108
  console.error(`Scanning ${directories.length} directories (background indexing)...`);
62
109
 
63
110
  let indexedCount = 0;
64
- const BATCH_SIZE = 512;
65
111
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
112
  let currentBatch: any[] = [];
67
113
 
@@ -79,43 +125,48 @@ async function startIndexing(storage: VectorStorage, directories: string[]): Pro
79
125
 
80
126
  await storage.upsertLinesBatch(batchData);
81
127
  indexedCount += batchToProcess.length;
82
- indexStatus.indexedLines = indexedCount;
128
+ if (isCurrentJob()) indexStatus.indexedLines = indexedCount;
83
129
  console.error(`Indexed ${indexedCount} lines...`);
84
130
  };
85
131
 
86
- // Pipelining: Read next batch while processing current batch
87
- // We allow ONE batch to be processed in parallel with reading the next one.
88
132
  let processingPromise: Promise<void> | null = null;
133
+ let batchSize = getAdaptiveBatchSize();
134
+ console.error(`Adaptive batch size: ${batchSize} (free RAM: ${Math.round(os.freemem() / 1024 / 1024)}MB, reserve: ${RESERVE_MB}MB)`);
89
135
 
90
136
  for await (const line of scanDirectories(directories)) {
137
+ if (signal.aborted) break;
138
+
91
139
  currentBatch.push(line);
92
- if (currentBatch.length >= BATCH_SIZE) {
93
- // If there's a previous batch still processing, wait for it
140
+ if (currentBatch.length >= batchSize) {
94
141
  if (processingPromise) {
95
142
  await processingPromise;
96
143
  }
144
+ if (signal.aborted) break;
97
145
 
98
146
  const batchToProcess = currentBatch;
99
147
  currentBatch = [];
148
+ batchSize = getAdaptiveBatchSize();
100
149
 
101
- // Start processing this batch, but don't await it yet!
102
- // This allows the loop to continue and read the next batch from disk.
103
150
  processingPromise = processBatch(batchToProcess).catch((err) => {
104
151
  console.error('Error in background batch processing:', err);
105
152
  });
106
153
  }
107
154
  }
108
155
 
109
- // Wait for the last async batch
156
+ if (signal.aborted) {
157
+ console.error('Indexing aborted (new job started or cancelled).');
158
+ return;
159
+ }
160
+
110
161
  if (processingPromise) {
111
162
  await processingPromise;
112
163
  }
113
-
114
- // Process any remaining lines
115
164
  if (currentBatch.length > 0) {
116
165
  await processBatch(currentBatch);
117
166
  }
118
167
 
168
+ if (!isCurrentJob()) return;
169
+
119
170
  const stats = await storage.getStats();
120
171
  indexStatus.indexedFiles = stats.totalFiles;
121
172
  indexStatus.indexedLines = stats.totalLines;
@@ -126,15 +177,22 @@ async function startIndexing(storage: VectorStorage, directories: string[]): Pro
126
177
  );
127
178
  } catch (err) {
128
179
  const message = err instanceof Error ? err.message : String(err);
129
- indexStatus.lastError = message;
130
- indexStatus.finishedAt = Date.now();
180
+ if (isCurrentJob()) {
181
+ indexStatus.lastError = message;
182
+ indexStatus.finishedAt = Date.now();
183
+ }
131
184
  console.error('Error during indexing job:', err);
132
185
  } finally {
133
- indexStatus.isIndexing = false;
186
+ if (isCurrentJob()) {
187
+ indexStatus.isIndexing = false;
188
+ }
189
+ if (currentIndexingAbortController && currentJobId === jobId) {
190
+ currentIndexingAbortController = null;
191
+ }
134
192
  }
135
193
  }
136
194
 
137
- async function ensureInitialIndexing(storage: VectorStorage): Promise<void> {
195
+ function ensureInitialIndexing(storage: VectorStorage): void {
138
196
  const envDirs = process.env.SEMANTIC_SEARCH_INDEX_DIRS;
139
197
  const directories = envDirs ? envDirs.split(',').map((d) => d.trim()).filter(Boolean) : [];
140
198
 
@@ -145,8 +203,7 @@ async function ensureInitialIndexing(storage: VectorStorage): Promise<void> {
145
203
  return;
146
204
  }
147
205
 
148
- // Fire-and-forget; indexing runs in background.
149
- void startIndexing(storage, directories);
206
+ requestIndexing(storage, directories);
150
207
  }
151
208
 
152
209
  async function main() {
@@ -222,10 +279,8 @@ async function main() {
222
279
  );
223
280
  }
224
281
 
225
- // Trigger (or reuse) background indexing job.
226
- if (!indexStatus.isIndexing) {
227
- void startIndexing(storage, directories);
228
- }
282
+ // Abort any in-progress indexing and start a new job (clears and rebuilds).
283
+ requestIndexing(storage, directories);
229
284
 
230
285
  const stats = await storage.getStats();
231
286
  return {
@@ -313,7 +368,7 @@ async function main() {
313
368
  console.error('Semantic Search MCP Server running on stdio');
314
369
 
315
370
  // Kick off initial background indexing when the MCP server is enabled.
316
- await ensureInitialIndexing(storage);
371
+ ensureInitialIndexing(storage);
317
372
  }
318
373
 
319
374
  main().catch(console.error);