code-graph-context 2.12.7 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -190,7 +190,7 @@ If you prefer to edit the config files directly:
190
190
  | `NEO4J_USER` | No | `neo4j` | Neo4j username |
191
191
  | `NEO4J_PASSWORD` | No | `PASSWORD` | Neo4j password |
192
192
  | `EMBEDDING_MODEL` | No | `codesage/codesage-base-v2` | Local embedding model (see [Embedding Configuration](#embedding-configuration)) |
193
- | `EMBEDDING_BATCH_SIZE` | No | `16` | Texts per embedding batch (lower = less memory, higher = faster) |
193
+ | `EMBEDDING_BATCH_SIZE` | No | `8` | Texts per embedding batch (lower = less memory, higher = faster) |
194
194
  | `EMBEDDING_SIDECAR_PORT` | No | `8787` | Port for local embedding server |
195
195
  | `EMBEDDING_DEVICE` | No | auto (`mps`/`cpu`) | Device for embeddings. Auto-detects MPS on Apple Silicon |
196
196
  | `EMBEDDING_HALF_PRECISION` | No | `false` | Set `true` for float16 (uses ~0.5x memory) |
@@ -175,22 +175,28 @@ export class EmbeddingSidecar {
175
175
  }
176
176
  return false;
177
177
  }
178
- catch {
178
+ catch (err) {
179
+ const msg = err instanceof Error ? err.message : String(err);
180
+ console.error(`[embedding-sidecar] Health check failed: ${msg}`);
179
181
  return false;
180
182
  }
181
183
  }
182
184
  /**
183
185
  * Embed an array of texts. Lazily starts the sidecar if not running.
184
186
  */
185
- async embed(texts) {
187
+ async embed(texts, gpuBatchSize) {
186
188
  await this.start();
187
189
  const controller = new AbortController();
188
190
  const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
191
+ const startTime = Date.now();
189
192
  try {
193
+ const body = { texts };
194
+ if (gpuBatchSize)
195
+ body.batch_size = gpuBatchSize;
190
196
  const res = await fetch(`${this.baseUrl}/embed`, {
191
197
  method: 'POST',
192
198
  headers: { 'Content-Type': 'application/json' },
193
- body: JSON.stringify({ texts }),
199
+ body: JSON.stringify(body),
194
200
  signal: controller.signal,
195
201
  });
196
202
  if (!res.ok) {
@@ -202,21 +208,24 @@ export class EmbeddingSidecar {
202
208
  console.error('[embedding-sidecar] OOM detected, restarting sidecar to reclaim GPU memory');
203
209
  await this.stop();
204
210
  }
211
+ console.error(`[embedding-sidecar] Embed failed after ${Date.now() - startTime}ms: status=${res.status}, texts=${texts.length}, oom=${isOOM}, detail=${detail}`);
205
212
  throw new Error(`Sidecar embed failed (${res.status}): ${detail}`);
206
213
  }
207
214
  const data = (await res.json());
208
215
  if (data.dimensions)
209
216
  this._dimensions = data.dimensions;
217
+ console.error(`[embedding-sidecar] Embedded ${texts.length} texts in ${Date.now() - startTime}ms (dims=${data.dimensions})`);
210
218
  this.resetIdleTimer();
211
219
  return data.embeddings;
212
220
  }
213
221
  catch (err) {
214
222
  if (err instanceof Error && err.name === 'AbortError') {
215
- // Timeout likely means the sidecar is stuck kill it
216
- console.error('[embedding-sidecar] Request timed out, restarting sidecar');
223
+ console.error(`[embedding-sidecar] Request timed out after ${Date.now() - startTime}ms (limit=${this.config.requestTimeoutMs}ms, texts=${texts.length}), restarting sidecar`);
217
224
  await this.stop();
218
225
  throw new Error(`Embedding request timed out after ${this.config.requestTimeoutMs}ms`);
219
226
  }
227
+ const msg = err instanceof Error ? err.message : String(err);
228
+ console.error(`[embedding-sidecar] Embed error after ${Date.now() - startTime}ms: ${msg} (url=${this.baseUrl}, running=${this.isRunning}, texts=${texts.length})`);
220
229
  throw err;
221
230
  }
222
231
  finally {
@@ -6,7 +6,7 @@
6
6
  import { debugLog } from '../../mcp/utils.js';
7
7
  import { getEmbeddingSidecar } from './embedding-sidecar.js';
8
8
  const BATCH_CONFIG = {
9
- maxBatchSize: parseInt(process.env.EMBEDDING_BATCH_SIZE ?? '', 10) || 16,
9
+ maxBatchSize: parseInt(process.env.EMBEDDING_BATCH_SIZE ?? '', 10) || 8,
10
10
  };
11
11
  export class LocalEmbeddingsService {
12
12
  async embedText(text) {
@@ -19,33 +19,25 @@ export class LocalEmbeddingsService {
19
19
  const sidecar = getEmbeddingSidecar();
20
20
  return sidecar.embed(texts);
21
21
  }
22
- async embedTextsInBatches(texts, batchSize = BATCH_CONFIG.maxBatchSize) {
23
- // Cap batch size — callers (e.g. graph-generator) may pass 100 which OOMs the local model
24
- const safeBatchSize = Math.min(batchSize, BATCH_CONFIG.maxBatchSize);
25
- await debugLog('Batch embedding started', { provider: 'local', textCount: texts.length });
22
+ async embedTextsInBatches(texts, _batchSize) {
23
+ if (texts.length === 0)
24
+ return [];
25
+ // GPU batch size controls how many texts the model processes at once (memory-bound).
26
+ // We send ALL texts in a single HTTP request and let the sidecar handle GPU batching
27
+ // internally via model.encode(batch_size=N). This eliminates HTTP round-trip overhead.
28
+ const gpuBatchSize = BATCH_CONFIG.maxBatchSize;
29
+ const gpuBatches = Math.ceil(texts.length / gpuBatchSize);
30
+ console.error(`[embedding] Sending ${texts.length} texts in 1 request (gpu_batch_size=${gpuBatchSize}, ~${gpuBatches} GPU batches)`);
31
+ await debugLog('Batch embedding started', { provider: 'local', textCount: texts.length, gpuBatchSize });
26
32
  const sidecar = getEmbeddingSidecar();
27
- const results = [];
28
- const totalBatches = Math.ceil(texts.length / safeBatchSize);
29
- for (let i = 0; i < texts.length; i += safeBatchSize) {
30
- const batch = texts.slice(i, i + safeBatchSize);
31
- const batchIndex = Math.floor(i / safeBatchSize) + 1;
32
- console.error(`[embedding] Batch ${batchIndex}/${totalBatches} (${batch.length} texts)`);
33
- await debugLog('Embedding batch progress', {
34
- provider: 'local',
35
- batchIndex,
36
- totalBatches,
37
- batchSize: batch.length,
38
- });
39
- try {
40
- const batchResults = await sidecar.embed(batch);
41
- results.push(...batchResults);
42
- }
43
- catch (error) {
44
- const msg = error instanceof Error ? error.message : String(error);
45
- console.error(`[embedding] Batch ${batchIndex}/${totalBatches} FAILED: ${msg}`);
46
- throw error;
47
- }
33
+ try {
34
+ const results = await sidecar.embed(texts, gpuBatchSize);
35
+ return results;
36
+ }
37
+ catch (error) {
38
+ const msg = error instanceof Error ? error.message : String(error);
39
+ console.error(`[embedding] FAILED (${texts.length} texts, gpuBatchSize=${gpuBatchSize}): ${msg}`);
40
+ throw error;
48
41
  }
49
- return results;
50
42
  }
51
43
  }
@@ -380,11 +380,11 @@ export const DEFAULTS = {
380
380
  // Parsing Configuration
381
381
  export const PARSING = {
382
382
  /** File count threshold to trigger parallel parsing with worker pool */
383
- parallelThreshold: 500,
383
+ parallelThreshold: 250,
384
384
  /** File count threshold to trigger streaming import */
385
385
  streamingThreshold: 100,
386
386
  /** Default number of files per chunk */
387
- defaultChunkSize: 100,
387
+ defaultChunkSize: 75,
388
388
  /** Worker timeout in milliseconds (30 minutes) */
389
389
  workerTimeoutMs: 30 * 60 * 1000,
390
390
  };
@@ -24,19 +24,31 @@ export class GraphGeneratorHandler {
24
24
  }
25
25
  async generateGraph(graphJsonPath, batchSize = DEFAULTS.batchSize, clearExisting = true) {
26
26
  console.error(`Generating graph from JSON file: ${graphJsonPath}`);
27
- await debugLog('Starting graph generation', { graphJsonPath, batchSize, clearExisting, projectId: this.projectId });
27
+ const graphData = await this.loadGraphData(graphJsonPath);
28
+ return this.generateGraphFromData(graphData.nodes, graphData.edges, batchSize, clearExisting, graphData.metadata);
29
+ }
30
+ /**
31
+ * Import nodes and edges directly from in-memory data.
32
+ * Skips the file read/write round-trip used by generateGraph.
33
+ *
34
+ * @param skipIndexes - When true, skips index creation (caller manages indexes).
35
+ * Use this for chunked imports where indexes are created once before/after all chunks.
36
+ */
37
+ async generateGraphFromData(nodes, edges, batchSize = DEFAULTS.batchSize, clearExisting = true, metadata = {}, skipIndexes = false) {
38
+ await debugLog('Starting graph generation', { nodeCount: nodes.length, edgeCount: edges.length, batchSize, clearExisting, skipIndexes, projectId: this.projectId });
28
39
  try {
29
- const graphData = await this.loadGraphData(graphJsonPath);
30
- const { nodes, edges, metadata } = graphData;
31
40
  console.error(`Generating graph with ${nodes.length} nodes and ${edges.length} edges`);
32
- await debugLog('Graph data loaded', { nodeCount: nodes.length, edgeCount: edges.length });
33
41
  if (clearExisting) {
34
42
  await this.clearExistingData();
35
43
  }
36
- await this.createProjectIndexes();
44
+ if (!skipIndexes) {
45
+ await this.createProjectIndexes();
46
+ }
37
47
  await this.importNodes(nodes, batchSize);
38
48
  await this.importEdges(edges, batchSize);
39
- await this.createVectorIndexes();
49
+ if (!skipIndexes) {
50
+ await this.createVectorIndexes();
51
+ }
40
52
  const result = {
41
53
  nodesImported: nodes.length,
42
54
  edgesImported: edges.length,
@@ -51,6 +63,13 @@ export class GraphGeneratorHandler {
51
63
  throw error;
52
64
  }
53
65
  }
66
+ /**
67
+ * Create all indexes. Call once before chunked imports start.
68
+ */
69
+ async ensureIndexes() {
70
+ await this.createProjectIndexes();
71
+ await this.createVectorIndexes();
72
+ }
54
73
  async loadGraphData(graphJsonPath) {
55
74
  const fileContent = await fs.readFile(graphJsonPath, 'utf-8');
56
75
  return JSON.parse(fileContent);
@@ -81,17 +100,26 @@ export class GraphGeneratorHandler {
81
100
  }
82
101
  async importNodes(nodes, batchSize) {
83
102
  console.error(`Importing ${nodes.length} nodes with embeddings...`);
103
+ // Pipelined: write batch N to Neo4j while embedding batch N+1.
104
+ // This overlaps GPU work with Neo4j I/O.
105
+ let pendingWrite = null;
84
106
  for (let i = 0; i < nodes.length; i += batchSize) {
107
+ // Embed this batch (GPU-bound, the slow part)
85
108
  const batch = await this.processNodeBatch(nodes.slice(i, i + batchSize));
86
- const result = await this.neo4jService.run(QUERIES.CREATE_NODE, { nodes: batch });
109
+ // Wait for previous Neo4j write before starting next
110
+ if (pendingWrite)
111
+ await pendingWrite;
112
+ const batchStart = i + 1;
87
113
  const batchEnd = Math.min(i + batchSize, nodes.length);
88
- console.error(`Created ${result[0].created} nodes in batch ${i + 1}-${batchEnd}`);
89
- await debugLog('Node batch imported', {
90
- batchStart: i + 1,
91
- batchEnd,
92
- created: result[0].created,
114
+ // Start Neo4j write don't await, overlap with next batch's embedding
115
+ pendingWrite = this.neo4jService.run(QUERIES.CREATE_NODE, { nodes: batch }).then(async (result) => {
116
+ console.error(`Created ${result[0].created} nodes in batch ${batchStart}-${batchEnd}`);
117
+ await debugLog('Node batch imported', { batchStart, batchEnd, created: result[0].created });
93
118
  });
94
119
  }
120
+ // Wait for the final write to complete
121
+ if (pendingWrite)
122
+ await pendingWrite;
95
123
  }
96
124
  /**
97
125
  * Process a batch of nodes with batched embedding calls.
@@ -132,8 +160,9 @@ export class GraphGeneratorHandler {
132
160
  // Batch embed all texts that need it
133
161
  if (nodesNeedingEmbedding.length > 0) {
134
162
  const texts = nodesNeedingEmbedding.map((n) => n.text);
135
- const totalBatches = Math.ceil(texts.length / EMBEDDING_BATCH_CONFIG.maxBatchSize);
136
- console.error(`[embedding] Starting ${texts.length} texts in ${totalBatches} batches (batch_size=${EMBEDDING_BATCH_CONFIG.maxBatchSize})`);
163
+ const effectiveBatchSize = parseInt(process.env.EMBEDDING_BATCH_SIZE ?? '', 10) || EMBEDDING_BATCH_CONFIG.maxBatchSize;
164
+ const totalBatches = Math.ceil(texts.length / effectiveBatchSize);
165
+ console.error(`[embedding] Starting ${texts.length} texts in ~${totalBatches} batches (effective_batch_size=${effectiveBatchSize}, config_max=${EMBEDDING_BATCH_CONFIG.maxBatchSize})`);
137
166
  try {
138
167
  const embeddings = await this.embeddingsService.embedTextsInBatches(texts, EMBEDDING_BATCH_CONFIG.maxBatchSize);
139
168
  // Map embeddings back to their nodes
@@ -3,7 +3,6 @@
3
3
  * Orchestrates parallel chunk parsing using a worker pool with pipelined import.
4
4
  * Used for large codebases (>= PARSING.parallelThreshold files).
5
5
  */
6
- import { join } from 'path';
7
6
  import { ProgressReporter } from '../../core/utils/progress-reporter.js';
8
7
  import { debugLog } from '../utils.js';
9
8
  import { ChunkWorkerPool } from '../workers/chunk-worker-pool.js';
@@ -41,6 +40,8 @@ export class ParallelImportHandler {
41
40
  projectId: config.projectId,
42
41
  projectType: config.projectType,
43
42
  });
43
+ // Create indexes once before chunked imports start
44
+ await this.graphGeneratorHandler.ensureIndexes();
44
45
  // Pipelined: import starts as soon as each chunk completes parsing
45
46
  const poolResult = await pool.processChunks(chunks, async (result, stats) => {
46
47
  await this.importToNeo4j(result.nodes, result.edges);
@@ -116,21 +117,6 @@ export class ParallelImportHandler {
116
117
  async importToNeo4j(nodes, edges) {
117
118
  if (nodes.length === 0 && edges.length === 0)
118
119
  return;
119
- const fs = await import('fs/promises');
120
- const { randomBytes } = await import('crypto');
121
- const { tmpdir } = await import('os');
122
- const tempPath = join(tmpdir(), `chunk-${Date.now()}-${randomBytes(8).toString('hex')}.json`);
123
- try {
124
- await fs.writeFile(tempPath, JSON.stringify({ nodes, edges, metadata: { parallel: true } }));
125
- await this.graphGeneratorHandler.generateGraph(tempPath, 100, false);
126
- }
127
- finally {
128
- try {
129
- await fs.unlink(tempPath);
130
- }
131
- catch {
132
- // Ignore cleanup errors
133
- }
134
- }
120
+ await this.graphGeneratorHandler.generateGraphFromData(nodes, edges, 100, false, {}, true);
135
121
  }
136
122
  }
@@ -2,20 +2,9 @@
2
2
  * Streaming Import Handler
3
3
  * Orchestrates chunked parsing and import for large codebases
4
4
  */
5
- import { randomBytes } from 'crypto';
6
- import { tmpdir } from 'os';
7
- import { join } from 'path';
8
5
  import { ProgressReporter } from '../../core/utils/progress-reporter.js';
9
6
  import { DEFAULTS } from '../constants.js';
10
7
  import { debugLog } from '../utils.js';
11
- /**
12
- * Generate a secure temporary file path using crypto random bytes
13
- * to avoid race conditions and predictable filenames
14
- */
15
- const generateTempPath = (prefix) => {
16
- const randomSuffix = randomBytes(16).toString('hex');
17
- return join(tmpdir(), `${prefix}-${Date.now()}-${randomSuffix}.json`);
18
- };
19
8
  export class StreamingImportHandler {
20
9
  graphGeneratorHandler;
21
10
  progressReporter;
@@ -50,6 +39,8 @@ export class StreamingImportHandler {
50
39
  }
51
40
  let totalNodesImported = 0;
52
41
  let totalEdgesImported = 0;
42
+ // Create indexes once before chunked imports start
43
+ await this.graphGeneratorHandler.ensureIndexes();
53
44
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
54
45
  const chunk = chunks[chunkIndex];
55
46
  const filesProcessed = chunkIndex * config.chunkSize + chunk.length;
@@ -129,37 +120,11 @@ export class StreamingImportHandler {
129
120
  return result;
130
121
  }
131
122
  async importChunkToNeo4j(nodes, edges) {
132
- const tempPath = generateTempPath('chunk');
133
- const fs = await import('fs/promises');
134
- try {
135
- await fs.writeFile(tempPath, JSON.stringify({ nodes, edges, metadata: { chunked: true } }));
136
- await this.graphGeneratorHandler.generateGraph(tempPath, DEFAULTS.batchSize, false);
137
- }
138
- finally {
139
- try {
140
- await fs.unlink(tempPath);
141
- }
142
- catch {
143
- // Ignore cleanup errors
144
- }
145
- }
123
+ await this.graphGeneratorHandler.generateGraphFromData(nodes, edges, DEFAULTS.batchSize, false, {}, true);
146
124
  }
147
125
  async importEdgesToNeo4j(edges) {
148
126
  if (edges.length === 0)
149
127
  return;
150
- const tempPath = generateTempPath('edges');
151
- const fs = await import('fs/promises');
152
- try {
153
- await fs.writeFile(tempPath, JSON.stringify({ nodes: [], edges, metadata: { edgesOnly: true } }));
154
- await this.graphGeneratorHandler.generateGraph(tempPath, DEFAULTS.batchSize, false);
155
- }
156
- finally {
157
- try {
158
- await fs.unlink(tempPath);
159
- }
160
- catch {
161
- // Ignore cleanup errors
162
- }
163
- }
128
+ await this.graphGeneratorHandler.generateGraphFromData([], edges, DEFAULTS.batchSize, false, {}, true);
164
129
  }
165
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-graph-context",
3
- "version": "2.12.7",
3
+ "version": "2.13.0",
4
4
  "description": "MCP server that builds code graphs to provide rich context to LLMs",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/drewdrewH/code-graph-context#readme",