code-graph-context 2.8.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.
@@ -16,6 +16,7 @@ const rootDir = join(__dirname, '..', '..');
16
16
  dotenv.config({ path: join(rootDir, '.env'), quiet: true });
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { stopEmbeddingSidecar } from '../core/embeddings/embedding-sidecar.js';
19
20
  import { MCP_SERVER_CONFIG, MESSAGES } from './constants.js';
20
21
  import { performIncrementalParse } from './handlers/incremental-parse.handler.js';
21
22
  import { initializeServices } from './service-init.js';
@@ -109,6 +110,7 @@ const shutdown = async (signal) => {
109
110
  await logServerStats(`shutdown-${signal}`);
110
111
  try {
111
112
  await watchManager.stopAllWatchers();
113
+ await stopEmbeddingSidecar();
112
114
  await debugLog('Shutdown complete', { signal });
113
115
  }
114
116
  catch (error) {
@@ -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
  /**
@@ -21,6 +21,7 @@ import { createSwarmClaimTaskTool } from './swarm-claim-task.tool.js';
21
21
  import { createSwarmCleanupTool } from './swarm-cleanup.tool.js';
22
22
  import { createSwarmCompleteTaskTool } from './swarm-complete-task.tool.js';
23
23
  import { createSwarmGetTasksTool } from './swarm-get-tasks.tool.js';
24
+ import { createSwarmMessageTool } from './swarm-message.tool.js';
24
25
  import { createSwarmPheromoneTool } from './swarm-pheromone.tool.js';
25
26
  import { createSwarmPostTaskTool } from './swarm-post-task.tool.js';
26
27
  import { createSwarmSenseTool } from './swarm-sense.tool.js';
@@ -73,6 +74,8 @@ export const registerAllTools = (server) => {
73
74
  createSwarmClaimTaskTool(server);
74
75
  createSwarmCompleteTaskTool(server);
75
76
  createSwarmGetTasksTool(server);
77
+ // Register swarm messaging tools (direct agent-to-agent communication)
78
+ createSwarmMessageTool(server);
76
79
  // Register session bookmark tools (cross-session context continuity)
77
80
  createSaveSessionBookmarkTool(server);
78
81
  createRestoreSessionBookmarkTool(server);
@@ -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);
@@ -12,6 +12,7 @@ import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
12
12
  import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
13
13
  import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
14
14
  import { TASK_TYPES, TASK_PRIORITIES } from './swarm-constants.js';
15
+ import { PENDING_MESSAGES_FOR_AGENT_QUERY, AUTO_ACKNOWLEDGE_QUERY } from './swarm-message.tool.js';
15
16
  /** Maximum retries when racing for a task */
16
17
  const MAX_CLAIM_RETRIES = 3;
17
18
  /** Delay between retries (ms) */
@@ -435,6 +436,39 @@ export const createSwarmClaimTaskTool = (server) => {
435
436
  name: t.name,
436
437
  filePath: t.filePath,
437
438
  }));
439
+ // Fetch pending messages for this agent (direct delivery on claim)
440
+ let pendingMessages = [];
441
+ try {
442
+ const msgResult = await neo4jService.run(PENDING_MESSAGES_FOR_AGENT_QUERY, {
443
+ projectId: resolvedProjectId,
444
+ swarmId,
445
+ agentId,
446
+ });
447
+ if (msgResult.length > 0) {
448
+ pendingMessages = msgResult.map((m) => {
449
+ const ts = typeof m.timestamp === 'object' && m.timestamp?.toNumber ? m.timestamp.toNumber() : m.timestamp;
450
+ return {
451
+ id: m.id,
452
+ from: m.fromAgentId,
453
+ category: m.category,
454
+ content: m.content,
455
+ taskId: m.taskId ?? undefined,
456
+ filePaths: m.filePaths?.length > 0 ? m.filePaths : undefined,
457
+ age: ts ? `${Math.round((Date.now() - ts) / 1000)}s ago` : null,
458
+ };
459
+ });
460
+ // Auto-acknowledge delivered messages
461
+ const deliveredIds = pendingMessages.map((m) => m.id);
462
+ await neo4jService.run(AUTO_ACKNOWLEDGE_QUERY, {
463
+ messageIds: deliveredIds,
464
+ agentId,
465
+ });
466
+ }
467
+ }
468
+ catch (msgError) {
469
+ // Non-fatal: message delivery failure shouldn't block task claim
470
+ await debugLog('Swarm claim task: message delivery failed (non-fatal)', { error: String(msgError) });
471
+ }
438
472
  // Slim response - only essential fields for agent to do work
439
473
  return createSuccessResponse(JSON.stringify({
440
474
  action: actionLabel,
@@ -450,6 +484,7 @@ export const createSwarmClaimTaskTool = (server) => {
450
484
  targetFilePaths: task.targetFilePaths,
451
485
  ...(task.dependencies?.length > 0 && { dependencies: task.dependencies }),
452
486
  },
487
+ ...(pendingMessages.length > 0 && { messages: pendingMessages }),
453
488
  ...(retryCount > 0 && { retryAttempts: retryCount }),
454
489
  }));
455
490
  }
@@ -75,6 +75,22 @@ const COUNT_ALL_QUERY = `
75
75
  WHERE p.projectId = $projectId AND NOT p.type IN $keepTypes
76
76
  RETURN count(p) as count, collect(DISTINCT p.agentId) as agents, collect(DISTINCT p.swarmId) as swarms, collect(DISTINCT p.type) as types
77
77
  `;
78
+ /**
79
+ * Neo4j query to delete SwarmMessage nodes by swarm ID
80
+ */
81
+ const CLEANUP_MESSAGES_BY_SWARM_QUERY = `
82
+ MATCH (m:SwarmMessage)
83
+ WHERE m.projectId = $projectId
84
+ AND m.swarmId = $swarmId
85
+ WITH m, m.category as category
86
+ DELETE m
87
+ RETURN count(m) as deleted, collect(DISTINCT category) as categories
88
+ `;
89
+ const COUNT_MESSAGES_BY_SWARM_QUERY = `
90
+ MATCH (m:SwarmMessage)
91
+ WHERE m.projectId = $projectId AND m.swarmId = $swarmId
92
+ RETURN count(m) as count, collect(DISTINCT m.category) as categories
93
+ `;
78
94
  export const createSwarmCleanupTool = (server) => {
79
95
  server.registerTool(TOOL_NAMES.swarmCleanup, {
80
96
  title: TOOL_METADATA[TOOL_NAMES.swarmCleanup].title,
@@ -143,6 +159,15 @@ export const createSwarmCleanupTool = (server) => {
143
159
  typeof taskCount === 'object' && 'toNumber' in taskCount ? taskCount.toNumber() : taskCount;
144
160
  taskStatuses = taskResult[0]?.statuses ?? [];
145
161
  }
162
+ let messageCount = 0;
163
+ let messageCategories = [];
164
+ if (swarmId) {
165
+ const msgResult = await neo4jService.run(COUNT_MESSAGES_BY_SWARM_QUERY, params);
166
+ messageCount = msgResult[0]?.count ?? 0;
167
+ messageCount =
168
+ typeof messageCount === 'object' && 'toNumber' in messageCount ? messageCount.toNumber() : messageCount;
169
+ messageCategories = msgResult[0]?.categories ?? [];
170
+ }
146
171
  return createSuccessResponse(JSON.stringify({
147
172
  success: true,
148
173
  dryRun: true,
@@ -160,6 +185,12 @@ export const createSwarmCleanupTool = (server) => {
160
185
  statuses: taskStatuses,
161
186
  }
162
187
  : null,
188
+ messages: swarmId
189
+ ? {
190
+ wouldDelete: messageCount,
191
+ categories: messageCategories,
192
+ }
193
+ : null,
163
194
  keepTypes,
164
195
  projectId: resolvedProjectId,
165
196
  }));
@@ -179,6 +210,23 @@ export const createSwarmCleanupTool = (server) => {
179
210
  : tasksDeleted;
180
211
  taskStatuses = taskResult[0]?.statuses ?? [];
181
212
  }
213
+ // Delete messages if swarmId provided
214
+ let messagesDeleted = 0;
215
+ let messageCategories = [];
216
+ if (swarmId) {
217
+ const msgResult = await neo4jService.run(CLEANUP_MESSAGES_BY_SWARM_QUERY, params);
218
+ messagesDeleted = msgResult[0]?.deleted ?? 0;
219
+ messagesDeleted =
220
+ typeof messagesDeleted === 'object' && 'toNumber' in messagesDeleted
221
+ ? messagesDeleted.toNumber()
222
+ : messagesDeleted;
223
+ messageCategories = msgResult[0]?.categories ?? [];
224
+ }
225
+ const parts = [`${pheromonesDeleted} pheromones`];
226
+ if (swarmId && includeTasks)
227
+ parts.push(`${tasksDeleted} tasks`);
228
+ if (swarmId)
229
+ parts.push(`${messagesDeleted} messages`);
182
230
  return createSuccessResponse(JSON.stringify({
183
231
  success: true,
184
232
  mode,
@@ -195,11 +243,15 @@ export const createSwarmCleanupTool = (server) => {
195
243
  statuses: taskStatuses,
196
244
  }
197
245
  : null,
246
+ messages: swarmId
247
+ ? {
248
+ deleted: messagesDeleted,
249
+ categories: messageCategories,
250
+ }
251
+ : null,
198
252
  keepTypes,
199
253
  projectId: resolvedProjectId,
200
- message: swarmId && includeTasks
201
- ? `Cleaned up ${pheromonesDeleted} pheromones and ${tasksDeleted} tasks`
202
- : `Cleaned up ${pheromonesDeleted} pheromones`,
254
+ message: `Cleaned up ${parts.join(', ')}`,
203
255
  }));
204
256
  }
205
257
  catch (error) {
@@ -79,6 +79,34 @@ export const WORKFLOW_STATES = ['exploring', 'claiming', 'modifying', 'completed
79
79
  */
80
80
  export const FLAG_TYPES = ['warning', 'proposal', 'needs_review', 'session_context'];
81
81
  // ============================================================================
82
+ // SWARM MESSAGING CONSTANTS
83
+ // ============================================================================
84
+ /**
85
+ * Message categories for direct agent-to-agent communication.
86
+ * Unlike pheromones (passive, decay-based), messages are explicit and persistent until read.
87
+ */
88
+ export const MESSAGE_CATEGORIES = {
89
+ blocked: 'Agent is blocked and needs help',
90
+ conflict: 'File or resource conflict detected',
91
+ finding: 'Important discovery another agent should know',
92
+ request: 'Direct request to another agent',
93
+ alert: 'Urgent notification (e.g., breaking change, test failure)',
94
+ handoff: 'Context handoff between agents (e.g., partial work)',
95
+ };
96
+ export const MESSAGE_CATEGORY_KEYS = Object.keys(MESSAGE_CATEGORIES);
97
+ /**
98
+ * Default TTL for messages (4 hours). Messages older than this are auto-cleaned.
99
+ */
100
+ export const MESSAGE_DEFAULT_TTL_MS = 4 * 60 * 60 * 1000;
101
+ /**
102
+ * Generate a unique message ID
103
+ */
104
+ export const generateMessageId = () => {
105
+ const timestamp = Date.now().toString(36);
106
+ const random = Math.random().toString(36).substring(2, 8);
107
+ return `msg_${timestamp}_${random}`;
108
+ };
109
+ // ============================================================================
82
110
  // ORCHESTRATOR CONSTANTS
83
111
  // ============================================================================
84
112
  /**