code-graph-context 2.9.0 → 2.10.1
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 +101 -26
- package/dist/cli/cli.js +250 -10
- package/dist/core/embeddings/embedding-sidecar.js +244 -0
- package/dist/core/embeddings/embeddings.service.js +60 -132
- package/dist/core/embeddings/local-embeddings.service.js +43 -0
- package/dist/core/embeddings/openai-embeddings.service.js +114 -0
- package/dist/mcp/handlers/graph-generator.handler.js +6 -5
- package/dist/mcp/mcp.server.js +5 -0
- package/dist/mcp/service-init.js +24 -3
- package/dist/mcp/tools/search-codebase.tool.js +37 -13
- package/dist/mcp/tools/session-note.tool.js +5 -6
- package/dist/storage/neo4j/neo4j.service.js +4 -4
- package/package.json +3 -1
- package/sidecar/embedding_server.py +147 -0
- package/sidecar/requirements.txt +5 -0
package/dist/mcp/service-init.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
89
|
-
const
|
|
90
|
-
|
|
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',
|
|
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 (
|
|
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
|
|
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\`:
|
|
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\`:
|
|
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.
|
|
3
|
+
"version": "2.10.1",
|
|
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)
|