code-graph-context 2.2.0 → 2.4.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.
- package/README.md +119 -64
- package/dist/cli/cli.js +266 -0
- package/dist/cli/neo4j-docker.js +159 -0
- package/dist/mcp/constants.js +120 -0
- package/dist/mcp/handlers/incremental-parse.handler.js +19 -0
- package/dist/mcp/mcp.server.js +76 -1
- package/dist/mcp/service-init.js +53 -0
- package/dist/mcp/services/watch-manager.js +57 -7
- package/dist/mcp/tools/hello.tool.js +16 -2
- package/dist/mcp/tools/index.js +33 -0
- package/dist/mcp/tools/swarm-cleanup.tool.js +157 -0
- package/dist/mcp/tools/swarm-constants.js +35 -0
- package/dist/mcp/tools/swarm-pheromone.tool.js +196 -0
- package/dist/mcp/tools/swarm-sense.tool.js +212 -0
- package/dist/storage/neo4j/neo4j.service.js +8 -4
- package/package.json +2 -2
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Sense Tool
|
|
3
|
+
* Query pheromones in the code graph 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 } from './swarm-constants.js';
|
|
10
|
+
/**
|
|
11
|
+
* Neo4j query to sense pheromones with decay calculation
|
|
12
|
+
* Uses nodeId-based matching (self-healing) instead of [:MARKS] relationship
|
|
13
|
+
* This survives graph rebuilds since nodeIds are deterministic
|
|
14
|
+
*/
|
|
15
|
+
const SENSE_PHEROMONES_QUERY = `
|
|
16
|
+
// Match pheromones scoped to project, optionally filtering by type
|
|
17
|
+
MATCH (p:Pheromone)
|
|
18
|
+
WHERE p.projectId = $projectId
|
|
19
|
+
AND ($types IS NULL OR size($types) = 0 OR p.type IN $types)
|
|
20
|
+
AND ($nodeIds IS NULL OR size($nodeIds) = 0 OR p.nodeId IN $nodeIds)
|
|
21
|
+
AND ($agentIds IS NULL OR size($agentIds) = 0 OR p.agentId IN $agentIds)
|
|
22
|
+
AND ($swarmId IS NULL OR p.swarmId = $swarmId)
|
|
23
|
+
AND ($excludeAgentId IS NULL OR p.agentId <> $excludeAgentId)
|
|
24
|
+
|
|
25
|
+
// Calculate current intensity with exponential decay
|
|
26
|
+
WITH p,
|
|
27
|
+
CASE
|
|
28
|
+
WHEN p.halfLife IS NULL OR p.halfLife <= 0 THEN p.intensity
|
|
29
|
+
ELSE p.intensity * exp(-0.693147 * (timestamp() - p.timestamp) / p.halfLife)
|
|
30
|
+
END AS currentIntensity
|
|
31
|
+
|
|
32
|
+
// Filter by minimum intensity
|
|
33
|
+
WHERE currentIntensity >= $minIntensity
|
|
34
|
+
|
|
35
|
+
// Find target by nodeId (self-healing - survives graph rebuilds)
|
|
36
|
+
OPTIONAL MATCH (target)
|
|
37
|
+
WHERE target.id = p.nodeId AND target.projectId = p.projectId
|
|
38
|
+
|
|
39
|
+
// Return pheromone data
|
|
40
|
+
RETURN
|
|
41
|
+
p.id AS id,
|
|
42
|
+
p.projectId AS projectId,
|
|
43
|
+
p.nodeId AS nodeId,
|
|
44
|
+
p.type AS type,
|
|
45
|
+
p.intensity AS originalIntensity,
|
|
46
|
+
currentIntensity,
|
|
47
|
+
p.agentId AS agentId,
|
|
48
|
+
p.swarmId AS swarmId,
|
|
49
|
+
p.timestamp AS timestamp,
|
|
50
|
+
p.data AS data,
|
|
51
|
+
p.halfLife AS halfLifeMs,
|
|
52
|
+
CASE WHEN target IS NOT NULL THEN labels(target)[0] ELSE null END AS targetType,
|
|
53
|
+
CASE WHEN target IS NOT NULL THEN target.name ELSE null END AS targetName,
|
|
54
|
+
CASE WHEN target IS NOT NULL THEN target.filePath ELSE null END AS targetFilePath
|
|
55
|
+
|
|
56
|
+
ORDER BY currentIntensity DESC, p.timestamp DESC
|
|
57
|
+
LIMIT toInteger($limit)
|
|
58
|
+
`;
|
|
59
|
+
/**
|
|
60
|
+
* Neo4j query to get pheromone summary statistics
|
|
61
|
+
*/
|
|
62
|
+
const PHEROMONE_STATS_QUERY = `
|
|
63
|
+
MATCH (p:Pheromone)
|
|
64
|
+
WHERE p.projectId = $projectId
|
|
65
|
+
WITH p,
|
|
66
|
+
CASE
|
|
67
|
+
WHEN p.halfLife IS NULL OR p.halfLife <= 0 THEN p.intensity
|
|
68
|
+
ELSE p.intensity * exp(-0.693147 * (timestamp() - p.timestamp) / p.halfLife)
|
|
69
|
+
END AS currentIntensity
|
|
70
|
+
WHERE currentIntensity >= $minIntensity
|
|
71
|
+
|
|
72
|
+
RETURN
|
|
73
|
+
p.type AS type,
|
|
74
|
+
count(p) AS count,
|
|
75
|
+
avg(currentIntensity) AS avgIntensity,
|
|
76
|
+
collect(DISTINCT p.agentId) AS agents
|
|
77
|
+
ORDER BY count DESC
|
|
78
|
+
`;
|
|
79
|
+
/**
|
|
80
|
+
* Neo4j query to clean up fully decayed pheromones for a project
|
|
81
|
+
*/
|
|
82
|
+
const CLEANUP_DECAYED_QUERY = `
|
|
83
|
+
MATCH (p:Pheromone)
|
|
84
|
+
WHERE p.projectId = $projectId
|
|
85
|
+
AND p.halfLife IS NOT NULL
|
|
86
|
+
AND p.halfLife > 0
|
|
87
|
+
AND p.intensity * exp(-0.693147 * (timestamp() - p.timestamp) / p.halfLife) < 0.01
|
|
88
|
+
DETACH DELETE p
|
|
89
|
+
RETURN count(p) AS cleaned
|
|
90
|
+
`;
|
|
91
|
+
export const createSwarmSenseTool = (server) => {
|
|
92
|
+
server.registerTool(TOOL_NAMES.swarmSense, {
|
|
93
|
+
title: TOOL_METADATA[TOOL_NAMES.swarmSense].title,
|
|
94
|
+
description: TOOL_METADATA[TOOL_NAMES.swarmSense].description,
|
|
95
|
+
inputSchema: {
|
|
96
|
+
projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
|
|
97
|
+
types: z
|
|
98
|
+
.array(z.enum(PHEROMONE_TYPES))
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('Filter by pheromone types. If empty, returns all types. Options: exploring, modifying, claiming, completed, warning, blocked, proposal, needs_review'),
|
|
101
|
+
nodeIds: z.array(z.string()).optional().describe('Filter by specific node IDs. If empty, searches all nodes.'),
|
|
102
|
+
agentIds: z
|
|
103
|
+
.array(z.string())
|
|
104
|
+
.optional()
|
|
105
|
+
.describe('Filter by specific agent IDs. If empty, returns pheromones from all agents.'),
|
|
106
|
+
swarmId: z.string().optional().describe('Filter by swarm ID. If empty, returns pheromones from all swarms.'),
|
|
107
|
+
excludeAgentId: z
|
|
108
|
+
.string()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('Exclude pheromones from this agent ID (useful for seeing what OTHER agents are doing)'),
|
|
111
|
+
minIntensity: z
|
|
112
|
+
.number()
|
|
113
|
+
.min(0)
|
|
114
|
+
.max(1)
|
|
115
|
+
.optional()
|
|
116
|
+
.default(0.3)
|
|
117
|
+
.describe('Minimum effective intensity after decay (0.0-1.0, default: 0.3)'),
|
|
118
|
+
limit: z
|
|
119
|
+
.number()
|
|
120
|
+
.int()
|
|
121
|
+
.min(1)
|
|
122
|
+
.max(500)
|
|
123
|
+
.optional()
|
|
124
|
+
.default(50)
|
|
125
|
+
.describe('Maximum number of pheromones to return (default: 50, max: 500)'),
|
|
126
|
+
includeStats: z.boolean().optional().default(false).describe('Include summary statistics by pheromone type'),
|
|
127
|
+
cleanup: z
|
|
128
|
+
.boolean()
|
|
129
|
+
.optional()
|
|
130
|
+
.default(false)
|
|
131
|
+
.describe('Run cleanup of fully decayed pheromones (intensity < 0.01)'),
|
|
132
|
+
},
|
|
133
|
+
}, async ({ projectId, types, nodeIds, agentIds, swarmId, excludeAgentId, minIntensity = 0.3, limit = 50, includeStats = false, cleanup = false, }) => {
|
|
134
|
+
const neo4jService = new Neo4jService();
|
|
135
|
+
// Resolve project ID
|
|
136
|
+
const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
|
|
137
|
+
if (!projectResult.success) {
|
|
138
|
+
await neo4jService.close();
|
|
139
|
+
return projectResult.error;
|
|
140
|
+
}
|
|
141
|
+
const resolvedProjectId = projectResult.projectId;
|
|
142
|
+
try {
|
|
143
|
+
const result = {
|
|
144
|
+
pheromones: [],
|
|
145
|
+
projectId: resolvedProjectId,
|
|
146
|
+
query: {
|
|
147
|
+
types: types ?? null,
|
|
148
|
+
minIntensity,
|
|
149
|
+
limit,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
// Run cleanup if requested
|
|
153
|
+
if (cleanup) {
|
|
154
|
+
const cleanupResult = await neo4jService.run(CLEANUP_DECAYED_QUERY, { projectId: resolvedProjectId });
|
|
155
|
+
result.cleaned = cleanupResult[0]?.cleaned ?? 0;
|
|
156
|
+
}
|
|
157
|
+
// Query pheromones (ensure limit is integer for Neo4j LIMIT clause)
|
|
158
|
+
const pheromones = await neo4jService.run(SENSE_PHEROMONES_QUERY, {
|
|
159
|
+
projectId: resolvedProjectId,
|
|
160
|
+
types: types ?? null,
|
|
161
|
+
nodeIds: nodeIds ?? null,
|
|
162
|
+
agentIds: agentIds ?? null,
|
|
163
|
+
swarmId: swarmId ?? null,
|
|
164
|
+
excludeAgentId: excludeAgentId ?? null,
|
|
165
|
+
minIntensity,
|
|
166
|
+
limit: Math.floor(limit),
|
|
167
|
+
});
|
|
168
|
+
result.pheromones = pheromones.map((p) => {
|
|
169
|
+
// Convert Neo4j Integer to JS number
|
|
170
|
+
const ts = typeof p.timestamp === 'object' && p.timestamp?.toNumber ? p.timestamp.toNumber() : p.timestamp;
|
|
171
|
+
return {
|
|
172
|
+
id: p.id,
|
|
173
|
+
projectId: p.projectId,
|
|
174
|
+
nodeId: p.nodeId,
|
|
175
|
+
type: p.type,
|
|
176
|
+
intensity: Math.round(p.currentIntensity * 1000) / 1000, // Round to 3 decimals
|
|
177
|
+
originalIntensity: p.originalIntensity,
|
|
178
|
+
agentId: p.agentId,
|
|
179
|
+
swarmId: p.swarmId,
|
|
180
|
+
timestamp: ts,
|
|
181
|
+
age: ts ? `${Math.round((Date.now() - ts) / 1000)}s ago` : null,
|
|
182
|
+
data: p.data ? JSON.parse(p.data) : null,
|
|
183
|
+
target: p.targetType
|
|
184
|
+
? {
|
|
185
|
+
type: p.targetType,
|
|
186
|
+
name: p.targetName,
|
|
187
|
+
filePath: p.targetFilePath,
|
|
188
|
+
}
|
|
189
|
+
: null,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
// Include stats if requested
|
|
193
|
+
if (includeStats) {
|
|
194
|
+
const stats = await neo4jService.run(PHEROMONE_STATS_QUERY, { projectId: resolvedProjectId, minIntensity });
|
|
195
|
+
result.stats = stats.map((s) => ({
|
|
196
|
+
type: s.type,
|
|
197
|
+
count: typeof s.count === 'object' ? s.count.toNumber() : s.count,
|
|
198
|
+
avgIntensity: Math.round(s.avgIntensity * 1000) / 1000,
|
|
199
|
+
activeAgents: s.agents,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
return createSuccessResponse(JSON.stringify(result, null, 2));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
await debugLog('Swarm sense error', { error: String(error) });
|
|
206
|
+
return createErrorResponse(error instanceof Error ? error : String(error));
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await neo4jService.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
};
|
|
@@ -215,15 +215,19 @@ export const QUERIES = {
|
|
|
215
215
|
// The previous query (WHERE startNode(r) IS NULL OR endNode(r) IS NULL) could never match anything
|
|
216
216
|
// Get existing nodes (excluding files being reparsed) for edge target matching
|
|
217
217
|
// Returns minimal info needed for edge detection: id, name, coreType, semanticType
|
|
218
|
+
// NOTE: Using property-based query instead of path traversal to avoid Cartesian explosion
|
|
219
|
+
// The old query `MATCH (sf:SourceFile)-[*]->(n)` caused OOM with large graphs
|
|
218
220
|
GET_EXISTING_NODES_FOR_EDGE_DETECTION: `
|
|
219
|
-
MATCH (
|
|
220
|
-
WHERE
|
|
221
|
-
|
|
221
|
+
MATCH (n)
|
|
222
|
+
WHERE n.projectId = $projectId
|
|
223
|
+
AND n.filePath IS NOT NULL
|
|
224
|
+
AND NOT n.filePath IN $excludeFilePaths
|
|
225
|
+
RETURN DISTINCT n.id AS id,
|
|
222
226
|
n.name AS name,
|
|
223
227
|
n.coreType AS coreType,
|
|
224
228
|
n.semanticType AS semanticType,
|
|
225
229
|
labels(n) AS labels,
|
|
226
|
-
|
|
230
|
+
n.filePath AS filePath
|
|
227
231
|
`,
|
|
228
232
|
EXPLORE_ALL_CONNECTIONS: (maxDepth = MAX_TRAVERSAL_DEPTH, direction = 'BOTH', relationshipTypes) => {
|
|
229
233
|
const safeMaxDepth = Math.min(Math.max(maxDepth, 1), MAX_TRAVERSAL_DEPTH);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-graph-context",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"main": "dist/mcp/mcp.server.js",
|
|
32
32
|
"bin": {
|
|
33
|
-
"code-graph-context": "dist/
|
|
33
|
+
"code-graph-context": "dist/cli/cli.js"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"dist/**/*",
|