code-graph-context 2.9.0 → 2.10.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.
@@ -5,6 +5,7 @@
5
5
  import fs from 'fs/promises';
6
6
  import { join } from 'path';
7
7
  import { ensureNeo4jRunning, isDockerInstalled, isDockerRunning } from '../cli/neo4j-docker.js';
8
+ import { isOpenAIEnabled, getEmbeddingDimensions } from '../core/embeddings/embeddings.service.js';
8
9
  import { Neo4jService, QUERIES } from '../storage/neo4j/neo4j.service.js';
9
10
  import { FILE_PATHS, LOG_CONFIG } from './constants.js';
10
11
  import { initializeNaturalLanguageService } from './tools/natural-language-to-cypher.tool.js';
@@ -13,12 +14,32 @@ import { debugLog } from './utils.js';
13
14
  * Log startup warnings for missing configuration
14
15
  */
15
16
  const checkConfiguration = async () => {
16
- if (!process.env.OPENAI_API_KEY) {
17
+ const openai = isOpenAIEnabled();
18
+ const dims = getEmbeddingDimensions();
19
+ const provider = openai ? 'openai' : 'local';
20
+ console.error(JSON.stringify({
21
+ level: 'info',
22
+ message: `[code-graph-context] Embedding provider: ${provider} (${dims} dimensions)`,
23
+ }));
24
+ await debugLog('Embedding configuration', { provider, dimensions: dims });
25
+ if (openai && !process.env.OPENAI_API_KEY) {
17
26
  console.error(JSON.stringify({
18
27
  level: 'warn',
19
- message: '[code-graph-context] OPENAI_API_KEY not set. Semantic search and NL queries unavailable.',
28
+ message: '[code-graph-context] OPENAI_ENABLED=true but OPENAI_API_KEY not set. Embedding calls will fail.',
20
29
  }));
21
- await debugLog('Configuration warning', { warning: 'OPENAI_API_KEY not set' });
30
+ await debugLog('Configuration warning', { warning: 'OPENAI_ENABLED=true but OPENAI_API_KEY not set' });
31
+ }
32
+ if (!openai) {
33
+ console.error(JSON.stringify({
34
+ level: 'info',
35
+ message: '[code-graph-context] Using local embeddings (Python sidecar). Starts on first embedding request.',
36
+ }));
37
+ if (!process.env.OPENAI_API_KEY) {
38
+ console.error(JSON.stringify({
39
+ level: 'info',
40
+ message: '[code-graph-context] natural_language_to_cypher requires OPENAI_API_KEY and is unavailable.',
41
+ }));
42
+ }
22
43
  }
23
44
  };
24
45
  /**
@@ -39,6 +39,12 @@ export const createSearchCodebaseTool = (server) => {
39
39
  .optional()
40
40
  .describe(`Length of code snippets to include (default: ${DEFAULTS.codeSnippetLength})`)
41
41
  .default(DEFAULTS.codeSnippetLength),
42
+ topK: z
43
+ .number()
44
+ .int()
45
+ .optional()
46
+ .describe('Number of top vector matches to return (default: 3, max: 10). The best match is traversed; others shown as alternatives.')
47
+ .default(3),
42
48
  minSimilarity: z
43
49
  .number()
44
50
  .optional()
@@ -50,7 +56,7 @@ export const createSearchCodebaseTool = (server) => {
50
56
  .describe('Use weighted traversal strategy that scores each node for relevance (default: false)')
51
57
  .default(true),
52
58
  },
53
- }, async ({ projectId, query, maxDepth = DEFAULTS.traversalDepth, maxNodesPerChain = 5, skip = 0, includeCode = true, snippetLength = DEFAULTS.codeSnippetLength, minSimilarity = 0.65, useWeightedTraversal = true, }) => {
59
+ }, async ({ projectId, query, maxDepth = DEFAULTS.traversalDepth, maxNodesPerChain = 5, skip = 0, includeCode = true, snippetLength = DEFAULTS.codeSnippetLength, topK = 3, minSimilarity = 0.65, useWeightedTraversal = true, }) => {
54
60
  const neo4jService = new Neo4jService();
55
61
  try {
56
62
  // Resolve project ID from name, path, or ID
@@ -63,11 +69,12 @@ export const createSearchCodebaseTool = (server) => {
63
69
  const sanitizedMaxNodesPerChain = sanitizeNumericInput(maxNodesPerChain, 5);
64
70
  const sanitizedSkip = sanitizeNumericInput(skip, 0);
65
71
  const sanitizedSnippetLength = sanitizeNumericInput(snippetLength, DEFAULTS.codeSnippetLength);
72
+ const sanitizedTopK = sanitizeNumericInput(topK, 3, 10);
66
73
  const embeddingsService = new EmbeddingsService();
67
74
  const traversalHandler = new TraversalHandler(neo4jService);
68
75
  const embedding = await embeddingsService.embedText(query);
69
76
  const vectorResults = await neo4jService.run(QUERIES.VECTOR_SEARCH, {
70
- limit: 1,
77
+ limit: sanitizedTopK,
71
78
  embedding,
72
79
  projectId: resolvedProjectId,
73
80
  fetchMultiplier: 10,
@@ -77,28 +84,45 @@ export const createSearchCodebaseTool = (server) => {
77
84
  return createSuccessResponse(`No code found with similarity >= ${minSimilarity}. ` +
78
85
  `Try rephrasing your query or lowering the minSimilarity threshold. Query: "${query}"`);
79
86
  }
80
- const startNode = vectorResults[0].node;
81
- const nodeId = startNode.properties.id;
82
- const similarityScore = vectorResults[0].score;
83
- // Check if best match meets threshold - prevents traversing low-relevance results
84
- if (similarityScore < minSimilarity) {
85
- return createSuccessResponse(`No sufficiently relevant code found. Best match score: ${similarityScore.toFixed(3)} ` +
87
+ // Filter results that meet the similarity threshold
88
+ const qualifiedResults = vectorResults.filter((r) => r.score >= minSimilarity);
89
+ if (qualifiedResults.length === 0) {
90
+ const bestScore = vectorResults[0].score;
91
+ return createSuccessResponse(`No sufficiently relevant code found. Best match score: ${bestScore.toFixed(3)} ` +
86
92
  `(threshold: ${minSimilarity}). Try rephrasing your query.`);
87
93
  }
88
- // Include similarity score in the title so users can see relevance
89
- const scoreDisplay = typeof similarityScore === 'number' ? similarityScore.toFixed(3) : 'N/A';
90
- return await traversalHandler.traverseFromNode(nodeId, embedding, {
94
+ // Best match traverse from this node
95
+ const bestMatch = qualifiedResults[0];
96
+ const nodeId = bestMatch.node.properties.id;
97
+ const bestScore = bestMatch.score.toFixed(3);
98
+ // Build alternative matches summary for the response
99
+ const alternatives = qualifiedResults.slice(1);
100
+ const altLines = alternatives.map((r) => {
101
+ const props = r.node.properties;
102
+ const name = props.name ?? props.id;
103
+ const file = props.filePath ? ` (${props.filePath})` : '';
104
+ return ` - ${name}${file} [score: ${r.score.toFixed(3)}, id: ${props.id}]`;
105
+ });
106
+ const altSection = altLines.length > 0
107
+ ? `\n\nAlternative matches (use traverse_from_node to explore):\n${altLines.join('\n')}`
108
+ : '';
109
+ const traversalResult = await traversalHandler.traverseFromNode(nodeId, embedding, {
91
110
  projectId: resolvedProjectId,
92
111
  maxDepth: sanitizedMaxDepth,
93
- direction: 'BOTH', // Show both incoming (who calls this) and outgoing (what this calls)
112
+ direction: 'BOTH',
94
113
  includeCode,
95
114
  maxNodesPerChain: sanitizedMaxNodesPerChain,
96
115
  skip: sanitizedSkip,
97
116
  summaryOnly: false,
98
117
  snippetLength: sanitizedSnippetLength,
99
- title: `Search Results (similarity: ${scoreDisplay}) - Starting from: ${nodeId}`,
118
+ title: `Search Results (${qualifiedResults.length} matches, best: ${bestScore}) - Traversing from: ${nodeId}`,
100
119
  useWeightedTraversal,
101
120
  });
121
+ // Append alternatives to the traversal response
122
+ if (altSection && traversalResult.content?.[0]?.type === 'text') {
123
+ traversalResult.content[0].text += altSection;
124
+ }
125
+ return traversalResult;
102
126
  }
103
127
  catch (error) {
104
128
  console.error('Search codebase error:', error);
@@ -3,7 +3,7 @@
3
3
  * Save and recall cross-session observations, decisions, and insights
4
4
  */
5
5
  import { z } from 'zod';
6
- import { EmbeddingsService } from '../../core/embeddings/embeddings.service.js';
6
+ import { EmbeddingsService, getEmbeddingDimensions } from '../../core/embeddings/embeddings.service.js';
7
7
  import { Neo4jService, QUERIES } from '../../storage/neo4j/neo4j.service.js';
8
8
  import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
9
9
  import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
@@ -31,17 +31,16 @@ const CREATE_SESSION_NOTE_QUERY = `
31
31
  // Link to referenced code nodes (filter out internal coordination nodes)
32
32
  WITH n
33
33
  UNWIND CASE WHEN size($aboutNodeIds) = 0 THEN [null] ELSE $aboutNodeIds END AS aboutNodeId
34
- WITH n, aboutNodeId
35
- WHERE aboutNodeId IS NOT NULL
36
34
  OPTIONAL MATCH (target)
37
- WHERE target.id = aboutNodeId
35
+ WHERE aboutNodeId IS NOT NULL
36
+ AND target.id = aboutNodeId
38
37
  AND target.projectId = $projectId
39
38
  AND NOT target:SessionNote
40
39
  AND NOT target:SessionBookmark
41
40
  AND NOT target:Pheromone
42
41
  AND NOT target:SwarmTask
43
42
  WITH n, collect(target) AS targets
44
- FOREACH (t IN targets | MERGE (n)-[:ABOUT]->(t))
43
+ FOREACH (t IN [x IN targets WHERE x IS NOT NULL] | MERGE (n)-[:ABOUT]->(t))
45
44
 
46
45
  // Link to the latest SessionBookmark for this session (if one exists)
47
46
  WITH n
@@ -194,7 +193,7 @@ export const createSaveSessionNoteTool = (server) => {
194
193
  // Ensure vector index exists (idempotent — IF NOT EXISTS)
195
194
  let hasEmbedding = false;
196
195
  try {
197
- await neo4jService.run(QUERIES.CREATE_SESSION_NOTES_VECTOR_INDEX);
196
+ await neo4jService.run(QUERIES.CREATE_SESSION_NOTES_VECTOR_INDEX(getEmbeddingDimensions()));
198
197
  const embeddingsService = new EmbeddingsService();
199
198
  const embeddingText = `${topic}\n\n${content}`;
200
199
  const embedding = await embeddingsService.embedText(embeddingText);
@@ -136,19 +136,19 @@ export const QUERIES = {
136
136
  RETURN labels(n)[0] as nodeType, count(*) as count
137
137
  ORDER BY count DESC
138
138
  `,
139
- CREATE_EMBEDDED_VECTOR_INDEX: `
139
+ CREATE_EMBEDDED_VECTOR_INDEX: (dimensions) => `
140
140
  CREATE VECTOR INDEX embedded_nodes_idx IF NOT EXISTS
141
141
  FOR (n:Embedded) ON (n.embedding)
142
142
  OPTIONS {indexConfig: {
143
- \`vector.dimensions\`: 3072,
143
+ \`vector.dimensions\`: ${dimensions},
144
144
  \`vector.similarity_function\`: 'cosine'
145
145
  }}
146
146
  `,
147
- CREATE_SESSION_NOTES_VECTOR_INDEX: `
147
+ CREATE_SESSION_NOTES_VECTOR_INDEX: (dimensions) => `
148
148
  CREATE VECTOR INDEX session_notes_idx IF NOT EXISTS
149
149
  FOR (n:SessionNote) ON (n.embedding)
150
150
  OPTIONS {indexConfig: {
151
- \`vector.dimensions\`: 3072,
151
+ \`vector.dimensions\`: ${dimensions},
152
152
  \`vector.similarity_function\`: 'cosine'
153
153
  }}
154
154
  `,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-graph-context",
3
- "version": "2.9.0",
3
+ "version": "2.10.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",
@@ -34,6 +34,8 @@
34
34
  },
35
35
  "files": [
36
36
  "dist/**/*",
37
+ "sidecar/embedding_server.py",
38
+ "sidecar/requirements.txt",
37
39
  "README.md",
38
40
  "LICENSE",
39
41
  ".env.example"
@@ -0,0 +1,147 @@
1
+ """
2
+ Local embedding server for code-graph-context.
3
+ Uses Qodo-Embed-1-1.5B for high-quality code embeddings without OpenAI dependency.
4
+ Runs as a sidecar process managed by the Node.js MCP server.
5
+ """
6
+
7
+ import gc
8
+ import os
9
+ import sys
10
+ import signal
11
+ import logging
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from pydantic import BaseModel
15
+
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s [%(levelname)s] %(message)s",
19
+ stream=sys.stderr,
20
+ )
21
+ logger = logging.getLogger("embedding-sidecar")
22
+
23
+ app = FastAPI(title="code-graph-context embedding sidecar")
24
+
25
+ model = None
26
+ model_name = os.environ.get("EMBEDDING_MODEL", "Qodo/Qodo-Embed-1-1.5B")
27
+
28
+
29
+ class EmbedRequest(BaseModel):
30
+ texts: list[str]
31
+ batch_size: int = 8
32
+
33
+
34
+ class EmbedResponse(BaseModel):
35
+ embeddings: list[list[float]]
36
+ dimensions: int
37
+ model: str
38
+
39
+
40
+ @app.on_event("startup")
41
+ def load_model():
42
+ global model
43
+ try:
44
+ import torch
45
+ from sentence_transformers import SentenceTransformer
46
+
47
+ device = "mps" if torch.backends.mps.is_available() else "cpu"
48
+ logger.info(f"Loading {model_name} on {device}...")
49
+ model = SentenceTransformer(model_name, device=device)
50
+
51
+ # Warm up with a test embedding
52
+ test = model.encode(["warmup"], show_progress_bar=False)
53
+ dims = len(test[0])
54
+ logger.info(f"Model loaded: {dims} dimensions, device={device}")
55
+ except Exception as e:
56
+ logger.error(f"Failed to load model: {e}")
57
+ raise
58
+
59
+
60
+ @app.get("/health")
61
+ def health():
62
+ if model is None:
63
+ raise HTTPException(status_code=503, detail="Model not loaded")
64
+ sample = model.encode(["dim_check"], show_progress_bar=False)
65
+ return {
66
+ "status": "ok",
67
+ "model": model_name,
68
+ "dimensions": len(sample[0]),
69
+ "device": str(model.device),
70
+ }
71
+
72
+
73
+ @app.post("/embed", response_model=EmbedResponse)
74
+ async def embed(req: EmbedRequest):
75
+ if model is None:
76
+ raise HTTPException(status_code=503, detail="Model not loaded")
77
+
78
+ if not req.texts:
79
+ return EmbedResponse(embeddings=[], dimensions=0, model=model_name)
80
+
81
+ try:
82
+ embeddings = _encode_with_oom_fallback(req.texts, req.batch_size)
83
+ dims = len(embeddings[0])
84
+ return EmbedResponse(
85
+ embeddings=embeddings,
86
+ dimensions=dims,
87
+ model=model_name,
88
+ )
89
+ except Exception as e:
90
+ logger.error(f"Embedding error: {e}")
91
+ raise HTTPException(status_code=500, detail=str(e))
92
+
93
+
94
+ def _encode_with_oom_fallback(texts: list[str], batch_size: int) -> list[list[float]]:
95
+ """
96
+ Encode texts, falling back to CPU if MPS runs out of memory.
97
+ Also retries with smaller batch sizes before giving up.
98
+ """
99
+ import torch
100
+
101
+ try:
102
+ result = model.encode(
103
+ texts,
104
+ batch_size=batch_size,
105
+ show_progress_bar=False,
106
+ normalize_embeddings=True,
107
+ )
108
+ return result.tolist()
109
+ except (torch.mps.OutOfMemoryError, RuntimeError) as e:
110
+ if "out of memory" not in str(e).lower():
111
+ raise
112
+
113
+ logger.warning(f"MPS OOM with batch_size={batch_size}, len={len(texts)}. Falling back to CPU.")
114
+
115
+ # Free MPS memory
116
+ if hasattr(torch.mps, "empty_cache"):
117
+ torch.mps.empty_cache()
118
+ gc.collect()
119
+
120
+ # Fall back to CPU for this request
121
+ original_device = model.device
122
+ model.to("cpu")
123
+
124
+ try:
125
+ # Use smaller batches on CPU
126
+ cpu_batch = min(batch_size, 4)
127
+ result = model.encode(
128
+ texts,
129
+ batch_size=cpu_batch,
130
+ show_progress_bar=False,
131
+ normalize_embeddings=True,
132
+ )
133
+ return result.tolist()
134
+ finally:
135
+ # Move back to MPS for future requests
136
+ try:
137
+ model.to(original_device)
138
+ except Exception:
139
+ logger.warning("Could not move model back to MPS, staying on CPU")
140
+
141
+
142
+ def handle_signal(sig, _frame):
143
+ logger.info(f"Received signal {sig}, shutting down")
144
+ sys.exit(0)
145
+
146
+
147
+ signal.signal(signal.SIGTERM, handle_signal)
@@ -0,0 +1,5 @@
1
+ fastapi>=0.104.0
2
+ uvicorn>=0.24.0
3
+ sentence-transformers>=3.0.0
4
+ torch>=2.0.0
5
+ pydantic>=2.0.0