code-graph-context 2.13.1 → 2.13.3

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/dist/cli/cli.js CHANGED
@@ -174,7 +174,10 @@ const installSidecarDeps = (sidecarDir) => {
174
174
  const verifySidecar = (sidecarDir) => {
175
175
  return new Promise((resolve) => {
176
176
  const python = getSidecarPython(sidecarDir);
177
- const test = spawnProcess(python, ['-c', `import transformers.modeling_utils as _mu; hasattr(_mu,"Conv1D") or setattr(_mu,"Conv1D",__import__("transformers.pytorch_utils",fromlist=["Conv1D"]).Conv1D); from sentence_transformers import SentenceTransformer; print("ok")`], {
177
+ const test = spawnProcess(python, [
178
+ '-c',
179
+ `import transformers.modeling_utils as _mu; hasattr(_mu,"Conv1D") or setattr(_mu,"Conv1D",__import__("transformers.pytorch_utils",fromlist=["Conv1D"]).Conv1D); from sentence_transformers import SentenceTransformer; print("ok")`,
180
+ ], {
178
181
  cwd: sidecarDir,
179
182
  stdio: ['pipe', 'pipe', 'pipe'],
180
183
  });
@@ -25,9 +25,40 @@ export class EmbeddingSidecar {
25
25
  stopping = false;
26
26
  _exitHandler = null;
27
27
  _idleTimer = null;
28
+ // Concurrency semaphore — model.encode() is GPU-bound and processes
29
+ // requests serially inside uvicorn. If 15 workers send requests at once,
30
+ // the first takes ~3s, the last waits ~45s and times out. By queuing
31
+ // excess requests here (no timeout pressure) we let only N through at a
32
+ // time, keeping each request well within the 60s timeout.
33
+ maxConcurrent = parseInt(process.env.EMBEDDING_MAX_CONCURRENT ?? '', 10) || 2;
34
+ inflight = 0;
35
+ waitQueue = [];
28
36
  constructor(config = {}) {
29
37
  this.config = { ...DEFAULT_CONFIG, ...config };
30
38
  }
39
+ /**
40
+ * Wait for a concurrency slot. If under the limit, returns immediately.
41
+ * Otherwise parks the caller in a FIFO queue until a slot opens.
42
+ */
43
+ acquireSlot() {
44
+ if (this.inflight < this.maxConcurrent) {
45
+ this.inflight++;
46
+ return; // fast path — no allocation, no Promise
47
+ }
48
+ return new Promise((resolve) => this.waitQueue.push(() => {
49
+ this.inflight++;
50
+ resolve();
51
+ }));
52
+ }
53
+ /**
54
+ * Release a slot, unblocking the next queued caller if any.
55
+ */
56
+ releaseSlot() {
57
+ this.inflight--;
58
+ const next = this.waitQueue.shift();
59
+ if (next)
60
+ next(); // wake one waiter — it will increment inflight
61
+ }
31
62
  get baseUrl() {
32
63
  return `http://${this.config.host}:${this.config.port}`;
33
64
  }
@@ -183,9 +214,19 @@ export class EmbeddingSidecar {
183
214
  }
184
215
  /**
185
216
  * Embed an array of texts. Lazily starts the sidecar if not running.
217
+ * Concurrency-limited: at most `maxConcurrent` requests hit the sidecar
218
+ * at once. Excess callers wait in a FIFO queue (no timeout pressure).
186
219
  */
187
220
  async embed(texts, gpuBatchSize) {
188
221
  await this.start();
222
+ // Wait for a concurrency slot — the timeout only starts AFTER we
223
+ // acquire the slot, so queued requests don't eat into their timeout.
224
+ const queuedAt = Date.now();
225
+ await this.acquireSlot();
226
+ const queueMs = Date.now() - queuedAt;
227
+ if (queueMs > 100) {
228
+ console.error(`[embedding-sidecar] Waited ${queueMs}ms for concurrency slot (inflight=${this.inflight}, queued=${this.waitQueue.length})`);
229
+ }
189
230
  const controller = new AbortController();
190
231
  const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
191
232
  const startTime = Date.now();
@@ -203,8 +244,6 @@ export class EmbeddingSidecar {
203
244
  const detail = await res.text();
204
245
  const isOOM = detail.toLowerCase().includes('out of memory');
205
246
  if (res.status === 500 && isOOM) {
206
- // OOM leaves GPU memory in a corrupted state — kill the sidecar
207
- // so the next request spawns a fresh process with clean memory
208
247
  console.error('[embedding-sidecar] OOM detected, restarting sidecar to reclaim GPU memory');
209
248
  await this.stop();
210
249
  }
@@ -230,6 +269,7 @@ export class EmbeddingSidecar {
230
269
  }
231
270
  finally {
232
271
  clearTimeout(timeout);
272
+ this.releaseSlot(); // always release, even on error
233
273
  }
234
274
  }
235
275
  /**
@@ -31,7 +31,13 @@ export class LocalEmbeddingsService {
31
31
  const httpBatches = Math.ceil(texts.length / httpLimit);
32
32
  const gpuBatchesPerRequest = Math.ceil(httpLimit / gpuBatchSize);
33
33
  console.error(`[embedding] ${texts.length} texts → ${httpBatches} HTTP requests (http_limit=${httpLimit}, gpu_batch_size=${gpuBatchSize}, ~${gpuBatchesPerRequest} GPU batches/req)`);
34
- await debugLog('Batch embedding started', { provider: 'local', textCount: texts.length, gpuBatchSize, httpLimit, httpBatches });
34
+ await debugLog('Batch embedding started', {
35
+ provider: 'local',
36
+ textCount: texts.length,
37
+ gpuBatchSize,
38
+ httpLimit,
39
+ httpBatches,
40
+ });
35
41
  const sidecar = getEmbeddingSidecar();
36
42
  const allResults = [];
37
43
  for (let i = 0; i < texts.length; i += httpLimit) {
@@ -449,3 +449,30 @@ export const MESSAGES = {
449
449
  startingServer: 'Starting MCP server...',
450
450
  },
451
451
  };
452
+ export const CONFIG_FILE_PATTERNS = {
453
+ defaultGlobs: [
454
+ 'docker-compose*.{yml,yaml}',
455
+ 'Dockerfile*',
456
+ '**/*.json', // All JSON files (package.json, tsconfig.json, .mcp.json, etc.)
457
+ '**/*.{yml,yaml}', // All YAML files (CI configs, k8s manifests, etc.)
458
+ '**/*.toml', // Cargo.toml, pyproject.toml, etc.
459
+ '**/*.cfg', // Python setup.cfg, etc.
460
+ '**/*.ini', // INI config files
461
+ '**/Makefile', // Makefiles
462
+ '.env*',
463
+ '**/*.sh', // Shell scripts
464
+ '**/*.py', // Python files (sidecar, scripts)
465
+ ],
466
+ excludeGlobs: [
467
+ '**/node_modules/**',
468
+ '**/dist/**',
469
+ '**/build/**',
470
+ '**/.git/**',
471
+ '**/coverage/**',
472
+ '**/.next/**',
473
+ '**/package-lock.json', // Too large, not useful for search
474
+ '**/yarn.lock',
475
+ '**/pnpm-lock.yaml',
476
+ ],
477
+ maxFileSizeBytes: 512 * 1024, // 512 KB — skip large generated files
478
+ };
@@ -3,7 +3,14 @@
3
3
  * Handles importing parsed graph data into Neo4j with embeddings
4
4
  */
5
5
  import fs from 'fs/promises';
6
- import { EMBEDDING_BATCH_CONFIG, getEmbeddingDimensions } from '../../core/embeddings/embeddings.service.js';
6
+ import path from 'path';
7
+ import { glob } from 'glob';
8
+ import { EXCLUDE_PATTERNS_GLOB } from '../../constants.js';
9
+ import { CoreNodeType } from '../../core/config/schema.js';
10
+ import { EMBEDDING_BATCH_CONFIG, getEmbeddingDimensions, } from '../../core/embeddings/embeddings.service.js';
11
+ import { normalizeCode } from '../../core/utils/code-normalizer.js';
12
+ import { hashFile } from '../../core/utils/file-utils.js';
13
+ import { generateDeterministicId } from '../../core/utils/graph-factory.js';
7
14
  import { QUERIES } from '../../storage/neo4j/neo4j.service.js';
8
15
  import { DEFAULTS } from '../constants.js';
9
16
  import { debugLog } from '../utils.js';
@@ -35,7 +42,14 @@ export class GraphGeneratorHandler {
35
42
  * Use this for chunked imports where indexes are created once before/after all chunks.
36
43
  */
37
44
  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 });
45
+ await debugLog('Starting graph generation', {
46
+ nodeCount: nodes.length,
47
+ edgeCount: edges.length,
48
+ batchSize,
49
+ clearExisting,
50
+ skipIndexes,
51
+ projectId: this.projectId,
52
+ });
39
53
  try {
40
54
  console.error(`Generating graph with ${nodes.length} nodes and ${edges.length} edges`);
41
55
  if (clearExisting) {
@@ -63,6 +77,61 @@ export class GraphGeneratorHandler {
63
77
  throw error;
64
78
  }
65
79
  }
80
+ async ingestConfigFiles(projectPath, projectId, globs, excludeGlobs, maxFileSizeBytes, batchSize = DEFAULTS.batchSize) {
81
+ const files = await glob(globs, {
82
+ cwd: projectPath,
83
+ ignore: [...EXCLUDE_PATTERNS_GLOB, ...excludeGlobs],
84
+ absolute: true,
85
+ });
86
+ console.error(`[config-ingest] Found ${files.length} config files to ingest`);
87
+ const nodes = [];
88
+ for (const file of files) {
89
+ const stats = await fs.stat(file);
90
+ if (stats.size > maxFileSizeBytes) {
91
+ console.error(`[config-ingest] Skipping ${file} (${stats.size} bytes > ${maxFileSizeBytes})`);
92
+ continue;
93
+ }
94
+ const content = await fs.readFile(file, 'utf-8');
95
+ const name = path.basename(file);
96
+ const relativePath = path.relative(projectPath, file);
97
+ const lineCount = content.split('\n').length;
98
+ const contentHash = await hashFile(file);
99
+ const nodeId = generateDeterministicId(projectId, CoreNodeType.SOURCE_FILE, relativePath, name);
100
+ let normalizedHash;
101
+ try {
102
+ const result = normalizeCode(content);
103
+ if (result.normalizedHash)
104
+ normalizedHash = result.normalizedHash;
105
+ }
106
+ catch {
107
+ // Config files may not normalize cleanly — skip
108
+ }
109
+ const properties = {
110
+ id: nodeId,
111
+ projectId,
112
+ name,
113
+ coreType: CoreNodeType.SOURCE_FILE,
114
+ semanticType: 'ConfigFile',
115
+ filePath: relativePath,
116
+ sourceCode: content,
117
+ startLine: 1,
118
+ endLine: lineCount,
119
+ createdAt: new Date().toISOString(),
120
+ size: Number(stats.size),
121
+ mtime: Number(stats.mtimeMs),
122
+ contentHash,
123
+ ...(normalizedHash && { normalizedHash }),
124
+ };
125
+ nodes.push({
126
+ id: nodeId,
127
+ labels: ['SourceFile'],
128
+ properties,
129
+ });
130
+ }
131
+ console.error(`[config-ingest] Importing ${nodes.length} config file nodes`);
132
+ await this.importNodes(nodes, batchSize);
133
+ return { nodesCreated: nodes.length };
134
+ }
66
135
  /**
67
136
  * Create all indexes. Call once before chunked imports start.
68
137
  */
@@ -16,7 +16,7 @@ import { ParserFactory } from '../../core/parsers/parser-factory.js';
16
16
  import { detectChangedFiles } from '../../core/utils/file-change-detection.js';
17
17
  import { resolveProjectId, getProjectName, UPSERT_PROJECT_QUERY, UPDATE_PROJECT_STATUS_QUERY, } from '../../core/utils/project-id.js';
18
18
  import { Neo4jService, QUERIES } from '../../storage/neo4j/neo4j.service.js';
19
- import { TOOL_NAMES, TOOL_METADATA, DEFAULTS, FILE_PATHS, LOG_CONFIG, PARSING } from '../constants.js';
19
+ import { TOOL_NAMES, TOOL_METADATA, DEFAULTS, FILE_PATHS, LOG_CONFIG, PARSING, CONFIG_FILE_PATTERNS, } from '../constants.js';
20
20
  import { deleteSourceFileSubgraphs, loadExistingNodesForEdgeDetection, getCrossFileEdges, } from '../handlers/cross-file-edge.helpers.js';
21
21
  import { GraphGeneratorHandler } from '../handlers/graph-generator.handler.js';
22
22
  import { StreamingImportHandler } from '../handlers/streaming-import.handler.js';
@@ -67,6 +67,11 @@ export const createParseTypescriptProjectTool = (server) => {
67
67
  inputSchema: {
68
68
  projectPath: z.string().describe('Path to the TypeScript project root directory'),
69
69
  tsconfigPath: z.string().describe('Path to TypeScript project tsconfig.json file'),
70
+ configFileGlobs: z
71
+ .array(z.string())
72
+ .optional()
73
+ .describe('Custom glob patterns for config files (default: all JSON, YAML, .env, Dockerfile, .sh, .py)')
74
+ .default(CONFIG_FILE_PATTERNS.defaultGlobs),
70
75
  projectId: z
71
76
  .string()
72
77
  .optional()
@@ -107,7 +112,7 @@ export const createParseTypescriptProjectTool = (server) => {
107
112
  .default(1000)
108
113
  .describe('Debounce delay for watch mode in milliseconds (default: 1000)'),
109
114
  },
110
- }, async ({ tsconfigPath, projectPath, projectId, clearExisting, projectType = 'auto', chunkSize = 100, useStreaming = 'auto', async: asyncMode = false, watch = false, watchDebounceMs = 1000, }) => {
115
+ }, async ({ tsconfigPath, projectPath, projectId, configFileGlobs, clearExisting, projectType = 'auto', chunkSize = 100, useStreaming = 'auto', async: asyncMode = false, watch = false, watchDebounceMs = 1000, }) => {
111
116
  try {
112
117
  // SECURITY: Validate input paths before processing
113
118
  await validatePathExists(projectPath, 'directory');
@@ -145,6 +150,7 @@ export const createParseTypescriptProjectTool = (server) => {
145
150
  projectId: resolvedProjectId,
146
151
  projectType,
147
152
  chunkSize: chunkSize > 0 ? chunkSize : 50,
153
+ configFileGlobs,
148
154
  },
149
155
  resourceLimits: {
150
156
  maxOldGenerationSizeMb: 8192, // 8GB heap for large monorepos
@@ -254,6 +260,10 @@ export const createParseTypescriptProjectTool = (server) => {
254
260
  projectId: resolvedProjectId,
255
261
  });
256
262
  await debugLog('Streaming import completed', result);
263
+ // Ingest config files
264
+ const configResult = await graphGeneratorHandler.ingestConfigFiles(projectPath, resolvedProjectId, configFileGlobs, CONFIG_FILE_PATTERNS.excludeGlobs, CONFIG_FILE_PATTERNS.maxFileSizeBytes);
265
+ result.nodesImported += configResult.nodesCreated;
266
+ await debugLog('Config file ingestion completed', { nodesCreated: configResult.nodesCreated });
257
267
  // Update Project node status to complete
258
268
  await neo4jService.run(UPDATE_PROJECT_STATUS_QUERY, {
259
269
  projectId: resolvedProjectId,
@@ -328,6 +338,10 @@ export const createParseTypescriptProjectTool = (server) => {
328
338
  }
329
339
  console.error('Graph generation completed:', result);
330
340
  await debugLog('Neo4j import completed', result);
341
+ // Ingest config files
342
+ const configResult = await graphGeneratorHandler.ingestConfigFiles(projectPath, finalProjectId, configFileGlobs, CONFIG_FILE_PATTERNS.excludeGlobs, CONFIG_FILE_PATTERNS.maxFileSizeBytes);
343
+ result.nodesImported += configResult.nodesCreated;
344
+ await debugLog('Config file ingestion completed', { nodesCreated: configResult.nodesCreated });
331
345
  // Update Project node status to complete
332
346
  await neo4jService.run(UPDATE_PROJECT_STATUS_QUERY, {
333
347
  projectId: finalProjectId,
@@ -165,7 +165,9 @@ export const createSwarmCleanupTool = (server) => {
165
165
  const msgResult = await neo4jService.run(COUNT_MESSAGES_BY_SWARM_QUERY, params);
166
166
  messageCount = msgResult[0]?.count ?? 0;
167
167
  messageCount =
168
- typeof messageCount === 'object' && 'toNumber' in messageCount ? messageCount.toNumber() : messageCount;
168
+ typeof messageCount === 'object' && 'toNumber' in messageCount
169
+ ? messageCount.toNumber()
170
+ : messageCount;
169
171
  messageCategories = msgResult[0]?.categories ?? [];
170
172
  }
171
173
  return createSuccessResponse(JSON.stringify({
@@ -164,46 +164,24 @@ export const createSwarmMessageTool = (server) => {
164
164
  .enum(['send', 'read', 'acknowledge'])
165
165
  .describe('Action: send (post message), read (get messages), acknowledge (mark as read)'),
166
166
  // Send parameters
167
- toAgentId: z
168
- .string()
169
- .optional()
170
- .describe('Target agent ID. Omit for broadcast to all swarm agents.'),
167
+ toAgentId: z.string().optional().describe('Target agent ID. Omit for broadcast to all swarm agents.'),
171
168
  category: z
172
169
  .enum(MESSAGE_CATEGORY_KEYS)
173
170
  .optional()
174
171
  .describe('Message category: blocked (need help), conflict (resource clash), finding (important discovery), ' +
175
172
  'request (direct ask), alert (urgent notification), handoff (context transfer)'),
176
- content: z
177
- .string()
178
- .optional()
179
- .describe('Message content (required for send action)'),
180
- taskId: z
181
- .string()
182
- .optional()
183
- .describe('Related task ID for context'),
184
- filePaths: z
185
- .array(z.string())
186
- .optional()
187
- .describe('File paths relevant to this message'),
173
+ content: z.string().optional().describe('Message content (required for send action)'),
174
+ taskId: z.string().optional().describe('Related task ID for context'),
175
+ filePaths: z.array(z.string()).optional().describe('File paths relevant to this message'),
188
176
  ttlMs: z
189
177
  .number()
190
178
  .int()
191
179
  .optional()
192
180
  .describe(`Time-to-live in ms (default: ${MESSAGE_DEFAULT_TTL_MS / 3600000}h). Set 0 for swarm lifetime.`),
193
181
  // Read parameters
194
- unreadOnly: z
195
- .boolean()
196
- .optional()
197
- .default(true)
198
- .describe('Only return unread messages (default: true)'),
199
- categories: z
200
- .array(z.enum(MESSAGE_CATEGORY_KEYS))
201
- .optional()
202
- .describe('Filter by message categories'),
203
- fromAgentId: z
204
- .string()
205
- .optional()
206
- .describe('Filter messages from a specific agent'),
182
+ unreadOnly: z.boolean().optional().default(true).describe('Only return unread messages (default: true)'),
183
+ categories: z.array(z.enum(MESSAGE_CATEGORY_KEYS)).optional().describe('Filter by message categories'),
184
+ fromAgentId: z.string().optional().describe('Filter messages from a specific agent'),
207
185
  limit: z
208
186
  .number()
209
187
  .int()
@@ -218,11 +196,7 @@ export const createSwarmMessageTool = (server) => {
218
196
  .optional()
219
197
  .describe('Specific message IDs to acknowledge. Omit to acknowledge all unread.'),
220
198
  // Maintenance
221
- cleanup: z
222
- .boolean()
223
- .optional()
224
- .default(false)
225
- .describe('Also clean up expired messages'),
199
+ cleanup: z.boolean().optional().default(false).describe('Also clean up expired messages'),
226
200
  },
227
201
  }, async ({ projectId, swarmId, agentId, action, toAgentId, category, content, taskId, filePaths, ttlMs, unreadOnly = true, categories, fromAgentId, limit = 20, messageIds, cleanup = false, }) => {
228
202
  const neo4jService = new Neo4jService();
@@ -341,7 +315,7 @@ export const createSwarmMessageTool = (server) => {
341
315
  });
342
316
  const count = typeof result[0]?.acknowledged === 'object'
343
317
  ? result[0].acknowledged.toNumber()
344
- : result[0]?.acknowledged ?? 0;
318
+ : (result[0]?.acknowledged ?? 0);
345
319
  return createSuccessResponse(JSON.stringify({
346
320
  action: 'acknowledged_all',
347
321
  count,
@@ -66,13 +66,20 @@ const DELETE_PHEROMONE_QUERY = `
66
66
  DETACH DELETE p
67
67
  RETURN count(p) as deleted
68
68
  `;
69
+ const BY_FILE_PATH = `MATCH (n:SourceFile)
70
+ WHERE n.projectId = $projectId AND n.filePath ENDS WITH $filePath
71
+ RETURN n.id as nodeId LIMIT 1`;
69
72
  export const createSwarmPheromoneTool = (server) => {
70
73
  server.registerTool(TOOL_NAMES.swarmPheromone, {
71
74
  title: TOOL_METADATA[TOOL_NAMES.swarmPheromone].title,
72
75
  description: TOOL_METADATA[TOOL_NAMES.swarmPheromone].description,
73
76
  inputSchema: {
74
77
  projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
75
- nodeId: z.string().describe('The code node ID to mark with a pheromone'),
78
+ nodeId: z.string().optional().describe('The code node ID to mark with a pheromone'),
79
+ filePath: z
80
+ .string()
81
+ .optional()
82
+ .describe('File path to mark (alternative to nodeId — auto-resolves to SourceFile node)'),
76
83
  type: z
77
84
  .enum(PHEROMONE_TYPES)
78
85
  .describe('Type of pheromone: exploring (browsing), modifying (active work), claiming (ownership), completed (done), warning (danger), blocked (stuck), proposal (awaiting approval), needs_review (review request), session_context (session working set)'),
@@ -99,8 +106,11 @@ export const createSwarmPheromoneTool = (server) => {
99
106
  .default(false)
100
107
  .describe('If true, removes the pheromone instead of creating/updating it'),
101
108
  },
102
- }, async ({ projectId, nodeId, type, intensity = 1.0, agentId, swarmId, sessionId, data, remove = false }) => {
109
+ }, async ({ projectId, nodeId, filePath, type, intensity = 1.0, agentId, swarmId, sessionId, data, remove = false, }) => {
103
110
  const neo4jService = new Neo4jService();
111
+ if (!nodeId && !filePath) {
112
+ return createErrorResponse(new Error('nodeId or filePath required'));
113
+ }
104
114
  // Resolve project ID
105
115
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
106
116
  if (!projectResult.success) {
@@ -109,6 +119,21 @@ export const createSwarmPheromoneTool = (server) => {
109
119
  }
110
120
  const resolvedProjectId = projectResult.projectId;
111
121
  try {
122
+ // Resolve filePath to nodeId before any queries that need it
123
+ let resolvedFromFilePath = false;
124
+ let linkedToNode = true;
125
+ if (!nodeId && filePath) {
126
+ const records = await neo4jService.run(BY_FILE_PATH, { projectId: resolvedProjectId, filePath });
127
+ if (records[0]?.nodeId) {
128
+ nodeId = records[0].nodeId;
129
+ }
130
+ else {
131
+ // No graph node — use filePath as identifier so pheromone is still created
132
+ nodeId = filePath;
133
+ linkedToNode = false;
134
+ }
135
+ resolvedFromFilePath = true;
136
+ }
112
137
  if (remove) {
113
138
  const result = await neo4jService.run(DELETE_PHEROMONE_QUERY, {
114
139
  projectId: resolvedProjectId,
@@ -101,6 +101,10 @@ export const createSwarmSenseTool = (server) => {
101
101
  .optional()
102
102
  .describe('Filter by pheromone types. If empty, returns all types. Options: exploring, modifying, claiming, completed, warning, blocked, proposal, needs_review'),
103
103
  nodeIds: z.array(z.string()).optional().describe('Filter by specific node IDs. If empty, searches all nodes.'),
104
+ filePaths: z
105
+ .array(z.string())
106
+ .optional()
107
+ .describe('Filter by file paths (resolves to SourceFile nodeIds). Also matches pheromones placed via filePath.'),
104
108
  agentIds: z
105
109
  .array(z.string())
106
110
  .optional()
@@ -136,7 +140,7 @@ export const createSwarmSenseTool = (server) => {
136
140
  .default(false)
137
141
  .describe('Run cleanup of fully decayed pheromones (intensity < 0.01)'),
138
142
  },
139
- }, async ({ projectId, types, nodeIds, agentIds, swarmId, excludeAgentId, sessionId, minIntensity = 0.3, limit = 50, includeStats = false, cleanup = false, }) => {
143
+ }, async ({ projectId, types, nodeIds, filePaths, agentIds, swarmId, excludeAgentId, sessionId, minIntensity = 0.3, limit = 50, includeStats = false, cleanup = false, }) => {
140
144
  const neo4jService = new Neo4jService();
141
145
  // Resolve project ID
142
146
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
@@ -146,6 +150,19 @@ export const createSwarmSenseTool = (server) => {
146
150
  }
147
151
  const resolvedProjectId = projectResult.projectId;
148
152
  try {
153
+ // Resolve filePaths to nodeIds and merge with any explicit nodeIds
154
+ if (filePaths?.length) {
155
+ const resolvedIds = [...(nodeIds ?? [])];
156
+ const records = await neo4jService.run(`MATCH (n:SourceFile)
157
+ WHERE n.projectId = $projectId AND any(fp IN $filePaths WHERE n.filePath ENDS WITH fp)
158
+ RETURN collect(n.id) as nodeIds`, { projectId: resolvedProjectId, filePaths });
159
+ if (records[0]?.nodeIds) {
160
+ resolvedIds.push(...records[0].nodeIds);
161
+ }
162
+ // Also include raw filePaths — catches pheromones placed on new files via filePath
163
+ resolvedIds.push(...filePaths);
164
+ nodeIds = resolvedIds;
165
+ }
149
166
  const result = {
150
167
  pheromones: [],
151
168
  projectId: resolvedProjectId,
@@ -18,7 +18,7 @@ import { debugLog } from '../../core/utils/file-utils.js';
18
18
  import { getProjectName, UPSERT_PROJECT_QUERY, UPDATE_PROJECT_STATUS_QUERY } from '../../core/utils/project-id.js';
19
19
  import { WorkspaceDetector } from '../../core/workspace/index.js';
20
20
  import { Neo4jService, QUERIES } from '../../storage/neo4j/neo4j.service.js';
21
- import { PARSING } from '../constants.js';
21
+ import { PARSING, CONFIG_FILE_PATTERNS } from '../constants.js';
22
22
  import { GraphGeneratorHandler } from '../handlers/graph-generator.handler.js';
23
23
  import { ParallelImportHandler } from '../handlers/parallel-import.handler.js';
24
24
  import { StreamingImportHandler } from '../handlers/streaming-import.handler.js';
@@ -122,6 +122,11 @@ const runParser = async () => {
122
122
  totalNodesImported = result.nodesImported;
123
123
  totalEdgesImported = result.edgesImported;
124
124
  }
125
+ // Ingest config files (JSON, YAML, Dockerfiles, etc.)
126
+ const configGlobs = config.configFileGlobs ?? CONFIG_FILE_PATTERNS.defaultGlobs;
127
+ const configResult = await graphGeneratorHandler.ingestConfigFiles(config.projectPath, resolvedProjectId, configGlobs, CONFIG_FILE_PATTERNS.excludeGlobs, CONFIG_FILE_PATTERNS.maxFileSizeBytes);
128
+ totalNodesImported += configResult.nodesCreated;
129
+ await debugLog('Config file ingestion completed', { nodesCreated: configResult.nodesCreated });
125
130
  await neo4jService.run(UPDATE_PROJECT_STATUS_QUERY, {
126
131
  projectId: resolvedProjectId,
127
132
  status: 'complete',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-graph-context",
3
- "version": "2.13.1",
3
+ "version": "2.13.3",
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",