code-graph-context 2.0.1 → 2.3.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.
Files changed (38) hide show
  1. package/README.md +221 -2
  2. package/dist/constants.js +167 -0
  3. package/dist/core/config/fairsquare-framework-schema.js +9 -7
  4. package/dist/core/config/schema.js +41 -2
  5. package/dist/core/embeddings/natural-language-to-cypher.service.js +166 -110
  6. package/dist/core/parsers/typescript-parser.js +1039 -742
  7. package/dist/core/parsers/workspace-parser.js +175 -193
  8. package/dist/core/utils/code-normalizer.js +299 -0
  9. package/dist/core/utils/file-change-detection.js +17 -2
  10. package/dist/core/utils/file-utils.js +40 -5
  11. package/dist/core/utils/graph-factory.js +161 -0
  12. package/dist/core/utils/shared-utils.js +79 -0
  13. package/dist/core/workspace/workspace-detector.js +59 -5
  14. package/dist/mcp/constants.js +261 -8
  15. package/dist/mcp/handlers/graph-generator.handler.js +1 -0
  16. package/dist/mcp/handlers/incremental-parse.handler.js +22 -6
  17. package/dist/mcp/handlers/parallel-import.handler.js +136 -0
  18. package/dist/mcp/handlers/streaming-import.handler.js +14 -59
  19. package/dist/mcp/mcp.server.js +77 -2
  20. package/dist/mcp/services/job-manager.js +5 -8
  21. package/dist/mcp/services/watch-manager.js +64 -25
  22. package/dist/mcp/tools/detect-dead-code.tool.js +413 -0
  23. package/dist/mcp/tools/detect-duplicate-code.tool.js +450 -0
  24. package/dist/mcp/tools/hello.tool.js +16 -2
  25. package/dist/mcp/tools/impact-analysis.tool.js +20 -4
  26. package/dist/mcp/tools/index.js +37 -0
  27. package/dist/mcp/tools/parse-typescript-project.tool.js +15 -14
  28. package/dist/mcp/tools/swarm-cleanup.tool.js +157 -0
  29. package/dist/mcp/tools/swarm-constants.js +35 -0
  30. package/dist/mcp/tools/swarm-pheromone.tool.js +196 -0
  31. package/dist/mcp/tools/swarm-sense.tool.js +212 -0
  32. package/dist/mcp/workers/chunk-worker-pool.js +196 -0
  33. package/dist/mcp/workers/chunk-worker.types.js +4 -0
  34. package/dist/mcp/workers/chunk.worker.js +89 -0
  35. package/dist/mcp/workers/parse-coordinator.js +183 -0
  36. package/dist/mcp/workers/worker.pool.js +54 -0
  37. package/dist/storage/neo4j/neo4j.service.js +198 -14
  38. package/package.json +1 -1
@@ -12,20 +12,16 @@ import { z } from 'zod';
12
12
  import { CORE_TYPESCRIPT_SCHEMA } from '../../core/config/schema.js';
13
13
  import { EmbeddingsService } from '../../core/embeddings/embeddings.service.js';
14
14
  import { ParserFactory } from '../../core/parsers/parser-factory.js';
15
+ import { detectChangedFiles } from '../../core/utils/file-change-detection.js';
15
16
  import { resolveProjectId, getProjectName, UPSERT_PROJECT_QUERY, UPDATE_PROJECT_STATUS_QUERY, } from '../../core/utils/project-id.js';
16
17
  import { Neo4jService, QUERIES } from '../../storage/neo4j/neo4j.service.js';
17
- import { TOOL_NAMES, TOOL_METADATA, DEFAULTS, FILE_PATHS, LOG_CONFIG } from '../constants.js';
18
+ import { TOOL_NAMES, TOOL_METADATA, DEFAULTS, FILE_PATHS, LOG_CONFIG, PARSING } from '../constants.js';
18
19
  import { deleteSourceFileSubgraphs, loadExistingNodesForEdgeDetection, getCrossFileEdges, } from '../handlers/cross-file-edge.helpers.js';
19
- import { detectChangedFiles } from '../../core/utils/file-change-detection.js';
20
20
  import { GraphGeneratorHandler } from '../handlers/graph-generator.handler.js';
21
21
  import { StreamingImportHandler } from '../handlers/streaming-import.handler.js';
22
22
  import { jobManager } from '../services/job-manager.js';
23
23
  import { watchManager } from '../services/watch-manager.js';
24
24
  import { createErrorResponse, createSuccessResponse, formatParseSuccess, formatParsePartialSuccess, debugLog, } from '../utils.js';
25
- // Threshold for using streaming import (files)
26
- const STREAMING_THRESHOLD = 100;
27
- // Worker thread timeout (30 minutes)
28
- const WORKER_TIMEOUT_MS = 30 * 60 * 1000;
29
25
  /**
30
26
  * Validates that a path exists and is accessible
31
27
  * @throws Error if path doesn't exist or isn't accessible
@@ -87,7 +83,7 @@ export const createParseTypescriptProjectTool = (server) => {
87
83
  chunkSize: z
88
84
  .number()
89
85
  .optional()
90
- .default(50)
86
+ .default(100)
91
87
  .describe('Files per chunk for streaming import (default: 50). Set to 0 to disable streaming.'),
92
88
  useStreaming: z
93
89
  .enum(['auto', 'always', 'never'])
@@ -139,7 +135,7 @@ export const createParseTypescriptProjectTool = (server) => {
139
135
  // Get path to worker script
140
136
  const __filename = fileURLToPath(import.meta.url);
141
137
  const __dirname = dirname(__filename);
142
- const workerPath = join(__dirname, '..', 'workers', 'parse-worker.js');
138
+ const workerPath = join(__dirname, '..', 'workers', 'parse-coordinator.js');
143
139
  // Create Worker thread to run parsing without blocking MCP server
144
140
  const worker = new Worker(workerPath, {
145
141
  workerData: {
@@ -168,10 +164,10 @@ export const createParseTypescriptProjectTool = (server) => {
168
164
  const timeoutId = setTimeout(async () => {
169
165
  const job = jobManager.getJob(jobId);
170
166
  if (job && job.status === 'running') {
171
- jobManager.failJob(jobId, `Worker timed out after ${WORKER_TIMEOUT_MS / 60000} minutes`);
167
+ jobManager.failJob(jobId, `Worker timed out after ${PARSING.workerTimeoutMs / 60000} minutes`);
172
168
  await terminateWorker('timeout');
173
169
  }
174
- }, WORKER_TIMEOUT_MS);
170
+ }, PARSING.workerTimeoutMs);
175
171
  // Handle progress messages from worker
176
172
  worker.on('message', (msg) => {
177
173
  if (msg.type === 'progress') {
@@ -216,16 +212,19 @@ export const createParseTypescriptProjectTool = (server) => {
216
212
  const embeddingsService = new EmbeddingsService();
217
213
  const graphGeneratorHandler = new GraphGeneratorHandler(neo4jService, embeddingsService);
218
214
  // Determine if we should use streaming import
215
+ // Use lazyLoad = true for consistent glob-based file discovery (matches incremental parse)
219
216
  const parser = projectType === 'auto'
220
- ? await ParserFactory.createParserWithAutoDetection(projectPath, tsconfigPath, resolvedProjectId)
217
+ ? await ParserFactory.createParserWithAutoDetection(projectPath, tsconfigPath, resolvedProjectId, true)
221
218
  : ParserFactory.createParser({
222
219
  workspacePath: projectPath,
223
220
  tsConfigPath: tsconfigPath,
224
221
  projectType: projectType,
225
222
  projectId: resolvedProjectId,
223
+ lazyLoad: true,
226
224
  });
227
- const totalFiles = parser.getSourceFilePaths().length;
228
- const shouldUseStreaming = useStreaming === 'always' || (useStreaming === 'auto' && totalFiles > STREAMING_THRESHOLD && chunkSize > 0);
225
+ const discoveredFiles = await parser.discoverSourceFiles();
226
+ const totalFiles = discoveredFiles.length;
227
+ const shouldUseStreaming = useStreaming === 'always' || (useStreaming === 'auto' && totalFiles > PARSING.streamingThreshold && chunkSize > 0);
229
228
  console.log(`📊 Project has ${totalFiles} files. Streaming: ${shouldUseStreaming ? 'enabled' : 'disabled'}`);
230
229
  if (shouldUseStreaming && clearExisting !== false) {
231
230
  // Use streaming import for large projects
@@ -385,13 +384,15 @@ const parseProject = async (options) => {
385
384
  const { neo4jService, tsconfigPath, projectPath, projectId, clearExisting = true, projectType = 'auto' } = options;
386
385
  // Resolve projectId early - needed for incremental queries before parser is created
387
386
  const resolvedId = resolveProjectId(projectPath, projectId);
387
+ // Use lazyLoad = true for consistent glob-based file discovery (matches incremental parse)
388
388
  const parser = projectType === 'auto'
389
- ? await ParserFactory.createParserWithAutoDetection(projectPath, tsconfigPath, resolvedId)
389
+ ? await ParserFactory.createParserWithAutoDetection(projectPath, tsconfigPath, resolvedId, true)
390
390
  : ParserFactory.createParser({
391
391
  workspacePath: projectPath,
392
392
  tsConfigPath: tsconfigPath,
393
393
  projectType: projectType,
394
394
  projectId: resolvedId,
395
+ lazyLoad: true,
395
396
  });
396
397
  let incrementalStats;
397
398
  let savedCrossFileEdges = [];
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Swarm Cleanup Tool
3
+ * Bulk delete pheromones after a swarm completes
4
+ */
5
+ import { z } from 'zod';
6
+ import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
7
+ import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
8
+ import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
9
+ /**
10
+ * Neo4j query to delete pheromones by swarm ID
11
+ */
12
+ const CLEANUP_BY_SWARM_QUERY = `
13
+ MATCH (p:Pheromone)
14
+ WHERE p.projectId = $projectId
15
+ AND p.swarmId = $swarmId
16
+ AND NOT p.type IN $keepTypes
17
+ WITH p, p.agentId as agentId, p.type as type
18
+ DETACH DELETE p
19
+ RETURN count(p) as deleted, collect(DISTINCT agentId) as agents, collect(DISTINCT type) as types
20
+ `;
21
+ /**
22
+ * Neo4j query to delete pheromones by agent ID
23
+ */
24
+ const CLEANUP_BY_AGENT_QUERY = `
25
+ MATCH (p:Pheromone)
26
+ WHERE p.projectId = $projectId
27
+ AND p.agentId = $agentId
28
+ AND NOT p.type IN $keepTypes
29
+ WITH p, p.swarmId as swarmId, p.type as type
30
+ DETACH DELETE p
31
+ RETURN count(p) as deleted, collect(DISTINCT swarmId) as swarms, collect(DISTINCT type) as types
32
+ `;
33
+ /**
34
+ * Neo4j query to delete all pheromones in a project
35
+ */
36
+ const CLEANUP_ALL_QUERY = `
37
+ MATCH (p:Pheromone)
38
+ WHERE p.projectId = $projectId
39
+ AND NOT p.type IN $keepTypes
40
+ WITH p, p.agentId as agentId, p.swarmId as swarmId, p.type as type
41
+ DETACH DELETE p
42
+ RETURN count(p) as deleted, collect(DISTINCT agentId) as agents, collect(DISTINCT swarmId) as swarms, collect(DISTINCT type) as types
43
+ `;
44
+ /**
45
+ * Count queries for dry run
46
+ */
47
+ const COUNT_BY_SWARM_QUERY = `
48
+ MATCH (p:Pheromone)
49
+ WHERE p.projectId = $projectId AND p.swarmId = $swarmId AND NOT p.type IN $keepTypes
50
+ RETURN count(p) as count, collect(DISTINCT p.agentId) as agents, collect(DISTINCT p.type) as types
51
+ `;
52
+ const COUNT_BY_AGENT_QUERY = `
53
+ MATCH (p:Pheromone)
54
+ WHERE p.projectId = $projectId AND p.agentId = $agentId AND NOT p.type IN $keepTypes
55
+ RETURN count(p) as count, collect(DISTINCT p.swarmId) as swarms, collect(DISTINCT p.type) as types
56
+ `;
57
+ const COUNT_ALL_QUERY = `
58
+ MATCH (p:Pheromone)
59
+ WHERE p.projectId = $projectId AND NOT p.type IN $keepTypes
60
+ RETURN count(p) as count, collect(DISTINCT p.agentId) as agents, collect(DISTINCT p.swarmId) as swarms, collect(DISTINCT p.type) as types
61
+ `;
62
+ export const createSwarmCleanupTool = (server) => {
63
+ server.registerTool(TOOL_NAMES.swarmCleanup, {
64
+ title: TOOL_METADATA[TOOL_NAMES.swarmCleanup].title,
65
+ description: TOOL_METADATA[TOOL_NAMES.swarmCleanup].description,
66
+ inputSchema: {
67
+ projectId: z.string().describe('Project ID, name, or path'),
68
+ swarmId: z.string().optional().describe('Delete all pheromones from this swarm'),
69
+ agentId: z.string().optional().describe('Delete all pheromones from this agent'),
70
+ all: z.boolean().optional().default(false).describe('Delete ALL pheromones in project (use with caution)'),
71
+ keepTypes: z
72
+ .array(z.string())
73
+ .optional()
74
+ .default(['warning'])
75
+ .describe('Pheromone types to preserve (default: ["warning"])'),
76
+ dryRun: z.boolean().optional().default(false).describe('Preview what would be deleted without deleting'),
77
+ },
78
+ }, async ({ projectId, swarmId, agentId, all = false, keepTypes = ['warning'], dryRun = false }) => {
79
+ const neo4jService = new Neo4jService();
80
+ // Resolve project ID
81
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
82
+ if (!projectResult.success) {
83
+ await neo4jService.close();
84
+ return projectResult.error;
85
+ }
86
+ const resolvedProjectId = projectResult.projectId;
87
+ try {
88
+ // Validate: must specify swarmId, agentId, or all
89
+ if (!swarmId && !agentId && !all) {
90
+ return createErrorResponse('Must specify one of: swarmId, agentId, or all=true. Use dryRun=true to preview.');
91
+ }
92
+ await debugLog('Swarm cleanup operation', {
93
+ projectId: resolvedProjectId,
94
+ swarmId,
95
+ agentId,
96
+ all,
97
+ keepTypes,
98
+ dryRun,
99
+ });
100
+ const params = { projectId: resolvedProjectId, keepTypes };
101
+ let deleteQuery;
102
+ let countQuery;
103
+ let mode;
104
+ if (swarmId) {
105
+ params.swarmId = swarmId;
106
+ deleteQuery = CLEANUP_BY_SWARM_QUERY;
107
+ countQuery = COUNT_BY_SWARM_QUERY;
108
+ mode = 'swarm';
109
+ }
110
+ else if (agentId) {
111
+ params.agentId = agentId;
112
+ deleteQuery = CLEANUP_BY_AGENT_QUERY;
113
+ countQuery = COUNT_BY_AGENT_QUERY;
114
+ mode = 'agent';
115
+ }
116
+ else {
117
+ deleteQuery = CLEANUP_ALL_QUERY;
118
+ countQuery = COUNT_ALL_QUERY;
119
+ mode = 'all';
120
+ }
121
+ if (dryRun) {
122
+ const result = await neo4jService.run(countQuery, params);
123
+ const count = result[0]?.count ?? 0;
124
+ return createSuccessResponse(JSON.stringify({
125
+ success: true,
126
+ dryRun: true,
127
+ mode,
128
+ wouldDelete: typeof count === 'object' && 'toNumber' in count ? count.toNumber() : count,
129
+ agents: result[0]?.agents ?? [],
130
+ swarms: result[0]?.swarms ?? [],
131
+ types: result[0]?.types ?? [],
132
+ keepTypes,
133
+ projectId: resolvedProjectId,
134
+ }));
135
+ }
136
+ const result = await neo4jService.run(deleteQuery, params);
137
+ const deleted = result[0]?.deleted ?? 0;
138
+ return createSuccessResponse(JSON.stringify({
139
+ success: true,
140
+ mode,
141
+ deleted: typeof deleted === 'object' && 'toNumber' in deleted ? deleted.toNumber() : deleted,
142
+ agents: result[0]?.agents ?? [],
143
+ swarms: result[0]?.swarms ?? [],
144
+ types: result[0]?.types ?? [],
145
+ keepTypes,
146
+ projectId: resolvedProjectId,
147
+ }));
148
+ }
149
+ catch (error) {
150
+ await debugLog('Swarm cleanup error', { error: String(error) });
151
+ return createErrorResponse(error instanceof Error ? error : String(error));
152
+ }
153
+ finally {
154
+ await neo4jService.close();
155
+ }
156
+ });
157
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared constants for swarm coordination tools
3
+ */
4
+ /**
5
+ * Pheromone types and their half-lives in milliseconds.
6
+ * Half-life determines decay rate - after one half-life, intensity drops to 50%.
7
+ */
8
+ export const PHEROMONE_CONFIG = {
9
+ exploring: { halfLife: 2 * 60 * 1000, description: 'Browsing/reading' },
10
+ modifying: { halfLife: 10 * 60 * 1000, description: 'Active work' },
11
+ claiming: { halfLife: 60 * 60 * 1000, description: 'Ownership' },
12
+ completed: { halfLife: 24 * 60 * 60 * 1000, description: 'Done' },
13
+ warning: { halfLife: -1, description: 'Never decays' },
14
+ blocked: { halfLife: 5 * 60 * 1000, description: 'Stuck' },
15
+ proposal: { halfLife: 60 * 60 * 1000, description: 'Awaiting approval' },
16
+ needs_review: { halfLife: 30 * 60 * 1000, description: 'Review requested' },
17
+ };
18
+ export const PHEROMONE_TYPES = Object.keys(PHEROMONE_CONFIG);
19
+ /**
20
+ * Get half-life for a pheromone type.
21
+ * Returns -1 for types that never decay (e.g., warning).
22
+ */
23
+ export const getHalfLife = (type) => {
24
+ return PHEROMONE_CONFIG[type]?.halfLife ?? PHEROMONE_CONFIG.exploring.halfLife;
25
+ };
26
+ /**
27
+ * Workflow states are mutually exclusive per agent+node.
28
+ * Setting one removes others in this group.
29
+ * Flags (warning, proposal, needs_review) can coexist with workflow states.
30
+ */
31
+ export const WORKFLOW_STATES = ['exploring', 'claiming', 'modifying', 'completed', 'blocked'];
32
+ /**
33
+ * Flags can coexist with workflow states.
34
+ */
35
+ export const FLAG_TYPES = ['warning', 'proposal', 'needs_review'];
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Swarm Pheromone Tool
3
+ * Leave a pheromone marker on a code node for stigmergic coordination
4
+ */
5
+ import { z } from 'zod';
6
+ import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
7
+ import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
8
+ import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
9
+ import { PHEROMONE_TYPES, WORKFLOW_STATES, getHalfLife } from './swarm-constants.js';
10
+ /**
11
+ * Neo4j query to clean up other workflow states before setting a new one.
12
+ * Only runs for workflow state pheromones, not flags.
13
+ */
14
+ const CLEANUP_WORKFLOW_STATES_QUERY = `
15
+ MATCH (p:Pheromone)
16
+ WHERE p.projectId = $projectId
17
+ AND p.nodeId = $nodeId
18
+ AND p.agentId = $agentId
19
+ AND p.swarmId = $swarmId
20
+ AND p.type IN $workflowStates
21
+ AND p.type <> $newType
22
+ DETACH DELETE p
23
+ RETURN count(p) as cleaned
24
+ `;
25
+ /**
26
+ * Neo4j query to create or update a pheromone
27
+ */
28
+ const CREATE_PHEROMONE_QUERY = `
29
+ // Find the target code node (exclude other pheromones)
30
+ MATCH (target)
31
+ WHERE target.id = $nodeId
32
+ AND target.projectId = $projectId
33
+ AND NOT target:Pheromone
34
+ WITH target
35
+ LIMIT 1
36
+
37
+ // Create or update pheromone (scoped to project)
38
+ MERGE (p:Pheromone {projectId: $projectId, nodeId: $nodeId, agentId: $agentId, swarmId: $swarmId, type: $type})
39
+ ON CREATE SET
40
+ p.id = randomUUID(),
41
+ p.intensity = $intensity,
42
+ p.timestamp = timestamp(),
43
+ p.data = $data,
44
+ p.halfLife = $halfLife
45
+ ON MATCH SET
46
+ p.intensity = $intensity,
47
+ p.timestamp = timestamp(),
48
+ p.data = $data
49
+
50
+ // Create relationship to target node if it exists
51
+ WITH p, target
52
+ WHERE target IS NOT NULL
53
+ MERGE (p)-[:MARKS]->(target)
54
+
55
+ RETURN p.id as id, p.nodeId as nodeId, p.projectId as projectId, p.type as type, p.intensity as intensity,
56
+ p.timestamp as timestamp, p.agentId as agentId, p.swarmId as swarmId,
57
+ CASE WHEN target IS NOT NULL THEN true ELSE false END as linkedToNode
58
+ `;
59
+ /**
60
+ * Neo4j query to delete a pheromone
61
+ */
62
+ const DELETE_PHEROMONE_QUERY = `
63
+ MATCH (p:Pheromone {projectId: $projectId, nodeId: $nodeId, agentId: $agentId, swarmId: $swarmId, type: $type})
64
+ DETACH DELETE p
65
+ RETURN count(p) as deleted
66
+ `;
67
+ export const createSwarmPheromoneTool = (server) => {
68
+ server.registerTool(TOOL_NAMES.swarmPheromone, {
69
+ title: TOOL_METADATA[TOOL_NAMES.swarmPheromone].title,
70
+ description: TOOL_METADATA[TOOL_NAMES.swarmPheromone].description,
71
+ inputSchema: {
72
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
73
+ nodeId: z.string().describe('The code node ID to mark with a pheromone'),
74
+ type: z
75
+ .enum(PHEROMONE_TYPES)
76
+ .describe('Type of pheromone: exploring (browsing), modifying (active work), claiming (ownership), completed (done), warning (danger), blocked (stuck), proposal (awaiting approval), needs_review (review request)'),
77
+ intensity: z
78
+ .number()
79
+ .min(0)
80
+ .max(1)
81
+ .optional()
82
+ .default(1.0)
83
+ .describe('Pheromone intensity from 0.0 to 1.0 (default: 1.0)'),
84
+ agentId: z.string().describe('Unique identifier for the agent leaving the pheromone'),
85
+ swarmId: z.string().describe('Swarm ID for grouping related agents (e.g., "swarm_xyz")'),
86
+ data: z
87
+ .record(z.unknown())
88
+ .optional()
89
+ .describe('Optional metadata to attach to the pheromone (e.g., summary, reason)'),
90
+ remove: z
91
+ .boolean()
92
+ .optional()
93
+ .default(false)
94
+ .describe('If true, removes the pheromone instead of creating/updating it'),
95
+ },
96
+ }, async ({ projectId, nodeId, type, intensity = 1.0, agentId, swarmId, data, remove = false }) => {
97
+ const neo4jService = new Neo4jService();
98
+ // Resolve project ID
99
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
100
+ if (!projectResult.success) {
101
+ await neo4jService.close();
102
+ return projectResult.error;
103
+ }
104
+ const resolvedProjectId = projectResult.projectId;
105
+ try {
106
+ if (remove) {
107
+ const result = await neo4jService.run(DELETE_PHEROMONE_QUERY, {
108
+ projectId: resolvedProjectId,
109
+ nodeId,
110
+ agentId,
111
+ swarmId,
112
+ type,
113
+ });
114
+ const deleted = result[0]?.deleted ?? 0;
115
+ if (deleted > 0) {
116
+ return createSuccessResponse(JSON.stringify({
117
+ success: true,
118
+ action: 'removed',
119
+ projectId: resolvedProjectId,
120
+ nodeId,
121
+ type,
122
+ agentId,
123
+ swarmId,
124
+ }));
125
+ }
126
+ else {
127
+ return createSuccessResponse(JSON.stringify({
128
+ success: true,
129
+ action: 'not_found',
130
+ message: 'No matching pheromone found to remove',
131
+ projectId: resolvedProjectId,
132
+ nodeId,
133
+ type,
134
+ agentId,
135
+ swarmId,
136
+ }));
137
+ }
138
+ }
139
+ // Create or update pheromone
140
+ const halfLife = getHalfLife(type);
141
+ const dataJson = data ? JSON.stringify(data) : null;
142
+ // If setting a workflow state, clean up other workflow states first
143
+ let cleanedStates = 0;
144
+ if (WORKFLOW_STATES.includes(type)) {
145
+ const cleanupResult = await neo4jService.run(CLEANUP_WORKFLOW_STATES_QUERY, {
146
+ projectId: resolvedProjectId,
147
+ nodeId,
148
+ agentId,
149
+ swarmId,
150
+ workflowStates: WORKFLOW_STATES,
151
+ newType: type,
152
+ });
153
+ cleanedStates = cleanupResult[0]?.cleaned ?? 0;
154
+ }
155
+ const result = await neo4jService.run(CREATE_PHEROMONE_QUERY, {
156
+ projectId: resolvedProjectId,
157
+ nodeId,
158
+ type,
159
+ intensity,
160
+ agentId,
161
+ swarmId,
162
+ data: dataJson,
163
+ halfLife,
164
+ });
165
+ if (result.length === 0) {
166
+ return createErrorResponse(`Failed to create pheromone. Node ${nodeId} may not exist in the graph.`);
167
+ }
168
+ const pheromone = result[0];
169
+ return createSuccessResponse(JSON.stringify({
170
+ success: true,
171
+ action: cleanedStates > 0 ? 'transitioned' : 'created',
172
+ previousStatesRemoved: cleanedStates,
173
+ pheromone: {
174
+ id: pheromone.id,
175
+ projectId: pheromone.projectId,
176
+ nodeId: pheromone.nodeId,
177
+ type: pheromone.type,
178
+ intensity: pheromone.intensity,
179
+ agentId: pheromone.agentId,
180
+ swarmId: pheromone.swarmId,
181
+ timestamp: pheromone.timestamp,
182
+ linkedToNode: pheromone.linkedToNode,
183
+ halfLifeMs: halfLife,
184
+ expiresIn: halfLife < 0 ? 'never' : `${Math.round(halfLife / 60000)} minutes`,
185
+ },
186
+ }));
187
+ }
188
+ catch (error) {
189
+ await debugLog('Swarm pheromone error', { error: String(error) });
190
+ return createErrorResponse(error instanceof Error ? error : String(error));
191
+ }
192
+ finally {
193
+ await neo4jService.close();
194
+ }
195
+ });
196
+ };