code-graph-context 2.6.2 → 2.7.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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Session Note Tools
3
+ * Save and recall cross-session observations, decisions, and insights
4
+ */
5
+ import { z } from 'zod';
6
+ import { EmbeddingsService } from '../../core/embeddings/embeddings.service.js';
7
+ import { Neo4jService, QUERIES } from '../../storage/neo4j/neo4j.service.js';
8
+ import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
9
+ import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
10
+ const NOTE_CATEGORIES = ['architectural', 'bug', 'insight', 'decision', 'risk', 'todo'];
11
+ const NOTE_SEVERITIES = ['info', 'warning', 'critical'];
12
+ /**
13
+ * Cypher to create a SessionNote node, link it to code nodes via [:ABOUT],
14
+ * and link it to the latest SessionBookmark for the session via [:HAS_NOTE]
15
+ */
16
+ const CREATE_SESSION_NOTE_QUERY = `
17
+ // Create the SessionNote node
18
+ CREATE (n:SessionNote {
19
+ id: $noteId,
20
+ projectId: $projectId,
21
+ sessionId: $sessionId,
22
+ agentId: $agentId,
23
+ topic: $topic,
24
+ content: $content,
25
+ category: $category,
26
+ severity: $severity,
27
+ createdAt: timestamp(),
28
+ expiresAt: $expiresAt
29
+ })
30
+
31
+ // Link to referenced code nodes (filter out internal coordination nodes)
32
+ WITH n
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
+ OPTIONAL MATCH (target)
37
+ WHERE target.id = aboutNodeId
38
+ AND target.projectId = $projectId
39
+ AND NOT target:SessionNote
40
+ AND NOT target:SessionBookmark
41
+ AND NOT target:Pheromone
42
+ AND NOT target:SwarmTask
43
+ WITH n, collect(target) AS targets
44
+ FOREACH (t IN targets | MERGE (n)-[:ABOUT]->(t))
45
+
46
+ // Link to the latest SessionBookmark for this session (if one exists)
47
+ WITH n
48
+ OPTIONAL MATCH (bm:SessionBookmark {projectId: $projectId, sessionId: $sessionId})
49
+ WITH n, bm ORDER BY bm.createdAt DESC
50
+ LIMIT 1
51
+ FOREACH (_ IN CASE WHEN bm IS NOT NULL THEN [1] ELSE [] END |
52
+ MERGE (bm)-[:HAS_NOTE]->(n)
53
+ )
54
+
55
+ RETURN n.id AS noteId
56
+ `;
57
+ /**
58
+ * Cypher to store the embedding vector on an existing SessionNote node
59
+ */
60
+ const SET_NOTE_EMBEDDING_QUERY = `
61
+ MATCH (n:SessionNote {id: $noteId, projectId: $projectId})
62
+ SET n.embedding = $embedding
63
+ RETURN n.id AS noteId
64
+ `;
65
+ /**
66
+ * Semantic (vector) search for session notes
67
+ */
68
+ const VECTOR_SEARCH_NOTES_QUERY = `
69
+ CALL db.index.vector.queryNodes('session_notes_idx', toInteger($limit * 10), $queryEmbedding)
70
+ YIELD node AS n, score
71
+ WHERE n.projectId = $projectId
72
+ AND (n.expiresAt IS NULL OR n.expiresAt > timestamp())
73
+ AND ($category IS NULL OR n.category = $category)
74
+ AND ($severity IS NULL OR n.severity = $severity)
75
+ AND ($sessionId IS NULL OR n.sessionId = $sessionId)
76
+ AND ($agentId IS NULL OR n.agentId = $agentId)
77
+ AND score >= $minSimilarity
78
+
79
+ OPTIONAL MATCH (n)-[:ABOUT]->(codeNode)
80
+ WHERE NOT codeNode:SessionNote
81
+ AND NOT codeNode:SessionBookmark
82
+ AND NOT codeNode:Pheromone
83
+ AND NOT codeNode:SwarmTask
84
+
85
+ RETURN
86
+ n.id AS id,
87
+ n.topic AS topic,
88
+ n.content AS content,
89
+ n.category AS category,
90
+ n.severity AS severity,
91
+ n.agentId AS agentId,
92
+ n.sessionId AS sessionId,
93
+ n.createdAt AS createdAt,
94
+ n.expiresAt AS expiresAt,
95
+ score AS relevance,
96
+ collect(DISTINCT {id: codeNode.id, name: codeNode.name, filePath: codeNode.filePath}) AS aboutNodes
97
+
98
+ ORDER BY score DESC
99
+ LIMIT toInteger($limit)
100
+ `;
101
+ /**
102
+ * Filter-based (non-semantic) search for session notes
103
+ */
104
+ const FILTER_SEARCH_NOTES_QUERY = `
105
+ MATCH (n:SessionNote)
106
+ WHERE n.projectId = $projectId
107
+ AND (n.expiresAt IS NULL OR n.expiresAt > timestamp())
108
+ AND ($category IS NULL OR n.category = $category)
109
+ AND ($severity IS NULL OR n.severity = $severity)
110
+ AND ($sessionId IS NULL OR n.sessionId = $sessionId)
111
+ AND ($agentId IS NULL OR n.agentId = $agentId)
112
+
113
+ OPTIONAL MATCH (n)-[:ABOUT]->(codeNode)
114
+ WHERE NOT codeNode:SessionNote
115
+ AND NOT codeNode:SessionBookmark
116
+ AND NOT codeNode:Pheromone
117
+ AND NOT codeNode:SwarmTask
118
+
119
+ RETURN
120
+ n.id AS id,
121
+ n.topic AS topic,
122
+ n.content AS content,
123
+ n.category AS category,
124
+ n.severity AS severity,
125
+ n.agentId AS agentId,
126
+ n.sessionId AS sessionId,
127
+ n.createdAt AS createdAt,
128
+ n.expiresAt AS expiresAt,
129
+ null AS relevance,
130
+ collect(DISTINCT {id: codeNode.id, name: codeNode.name, filePath: codeNode.filePath}) AS aboutNodes
131
+
132
+ ORDER BY n.createdAt DESC
133
+ LIMIT toInteger($limit)
134
+ `;
135
+ /**
136
+ * Generate a unique note ID
137
+ */
138
+ const generateNoteId = () => {
139
+ return `note_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
140
+ };
141
+ export const createSaveSessionNoteTool = (server) => {
142
+ server.registerTool(TOOL_NAMES.saveSessionNote, {
143
+ title: TOOL_METADATA[TOOL_NAMES.saveSessionNote].title,
144
+ description: TOOL_METADATA[TOOL_NAMES.saveSessionNote].description,
145
+ inputSchema: {
146
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
147
+ sessionId: z.string().describe('Session identifier (e.g., conversation ID or session name)'),
148
+ agentId: z.string().describe('Agent identifier that is saving the note'),
149
+ topic: z.string().min(3).max(100).describe('Short topic label for the note (3-100 characters)'),
150
+ content: z.string().min(10).describe('Full observation text (minimum 10 characters)'),
151
+ category: z.enum(NOTE_CATEGORIES).describe('Category: architectural, bug, insight, decision, risk, or todo'),
152
+ severity: z
153
+ .enum(NOTE_SEVERITIES)
154
+ .optional()
155
+ .default('info')
156
+ .describe('Severity level: info (default), warning, or critical'),
157
+ aboutNodeIds: z
158
+ .array(z.string())
159
+ .optional()
160
+ .default([])
161
+ .describe('Code node IDs this note is about (links to graph nodes via [:ABOUT])'),
162
+ expiresInHours: z
163
+ .number()
164
+ .positive()
165
+ .optional()
166
+ .describe('Auto-expire after N hours. Omit for a permanent note.'),
167
+ },
168
+ }, async ({ projectId, sessionId, agentId, topic, content, category, severity = 'info', aboutNodeIds = [], expiresInHours, }) => {
169
+ const neo4jService = new Neo4jService();
170
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
171
+ if (!projectResult.success) {
172
+ await neo4jService.close();
173
+ return projectResult.error;
174
+ }
175
+ const resolvedProjectId = projectResult.projectId;
176
+ try {
177
+ const noteId = generateNoteId();
178
+ const expiresAt = expiresInHours != null ? Date.now() + expiresInHours * 3600 * 1000 : null;
179
+ const createResult = await neo4jService.run(CREATE_SESSION_NOTE_QUERY, {
180
+ noteId,
181
+ projectId: resolvedProjectId,
182
+ sessionId,
183
+ agentId,
184
+ topic,
185
+ content,
186
+ category,
187
+ severity,
188
+ aboutNodeIds,
189
+ expiresAt,
190
+ });
191
+ if (createResult.length === 0) {
192
+ return createErrorResponse('Failed to create session note.');
193
+ }
194
+ // Ensure vector index exists (idempotent — IF NOT EXISTS)
195
+ let hasEmbedding = false;
196
+ try {
197
+ await neo4jService.run(QUERIES.CREATE_SESSION_NOTES_VECTOR_INDEX);
198
+ const embeddingsService = new EmbeddingsService();
199
+ const embeddingText = `${topic}\n\n${content}`;
200
+ const embedding = await embeddingsService.embedText(embeddingText);
201
+ await neo4jService.run(SET_NOTE_EMBEDDING_QUERY, {
202
+ noteId,
203
+ projectId: resolvedProjectId,
204
+ embedding,
205
+ });
206
+ hasEmbedding = true;
207
+ }
208
+ catch (embErr) {
209
+ await debugLog('Session note embedding failed (non-fatal)', { error: String(embErr), noteId });
210
+ }
211
+ return createSuccessResponse(JSON.stringify({
212
+ success: true,
213
+ noteId,
214
+ topic,
215
+ category,
216
+ severity,
217
+ hasEmbedding,
218
+ expiresAt: expiresAt != null ? new Date(expiresAt).toISOString() : null,
219
+ projectId: resolvedProjectId,
220
+ sessionId,
221
+ agentId,
222
+ }, null, 2));
223
+ }
224
+ catch (error) {
225
+ await debugLog('Save session note error', { error: String(error) });
226
+ return createErrorResponse(error instanceof Error ? error : String(error));
227
+ }
228
+ finally {
229
+ await neo4jService.close();
230
+ }
231
+ });
232
+ };
233
+ export const createRecallSessionNotesTool = (server) => {
234
+ server.registerTool(TOOL_NAMES.recallSessionNotes, {
235
+ title: TOOL_METADATA[TOOL_NAMES.recallSessionNotes].title,
236
+ description: TOOL_METADATA[TOOL_NAMES.recallSessionNotes].description,
237
+ inputSchema: {
238
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
239
+ query: z
240
+ .string()
241
+ .optional()
242
+ .describe('Natural language search query. When provided, triggers semantic vector search.'),
243
+ category: z
244
+ .enum(NOTE_CATEGORIES)
245
+ .optional()
246
+ .describe('Filter by category: architectural, bug, insight, decision, risk, todo'),
247
+ severity: z.enum(NOTE_SEVERITIES).optional().describe('Filter by severity: info, warning, critical'),
248
+ sessionId: z.string().optional().describe('Filter by session ID'),
249
+ agentId: z.string().optional().describe('Filter by agent ID'),
250
+ limit: z
251
+ .number()
252
+ .int()
253
+ .min(1)
254
+ .max(50)
255
+ .optional()
256
+ .default(10)
257
+ .describe('Maximum number of notes to return (default: 10, max: 50)'),
258
+ minSimilarity: z
259
+ .number()
260
+ .min(0)
261
+ .max(1)
262
+ .optional()
263
+ .default(0.3)
264
+ .describe('Minimum similarity score for vector search (0.0-1.0, default: 0.3)'),
265
+ },
266
+ }, async ({ projectId, query, category, severity, sessionId, agentId, limit = 10, minSimilarity = 0.3 }) => {
267
+ const neo4jService = new Neo4jService();
268
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
269
+ if (!projectResult.success) {
270
+ await neo4jService.close();
271
+ return projectResult.error;
272
+ }
273
+ const resolvedProjectId = projectResult.projectId;
274
+ try {
275
+ let rawNotes;
276
+ if (query) {
277
+ // Semantic search mode
278
+ const embeddingsService = new EmbeddingsService();
279
+ const queryEmbedding = await embeddingsService.embedText(query);
280
+ rawNotes = await neo4jService.run(VECTOR_SEARCH_NOTES_QUERY, {
281
+ projectId: resolvedProjectId,
282
+ queryEmbedding,
283
+ limit: Math.floor(limit),
284
+ minSimilarity,
285
+ category: category ?? null,
286
+ severity: severity ?? null,
287
+ sessionId: sessionId ?? null,
288
+ agentId: agentId ?? null,
289
+ });
290
+ }
291
+ else {
292
+ // Filter-based search mode
293
+ rawNotes = await neo4jService.run(FILTER_SEARCH_NOTES_QUERY, {
294
+ projectId: resolvedProjectId,
295
+ limit: Math.floor(limit),
296
+ category: category ?? null,
297
+ severity: severity ?? null,
298
+ sessionId: sessionId ?? null,
299
+ agentId: agentId ?? null,
300
+ });
301
+ }
302
+ const notes = rawNotes.map((row) => {
303
+ const createdAt = typeof row.createdAt === 'object' && row.createdAt?.toNumber ? row.createdAt.toNumber() : row.createdAt;
304
+ const expiresAt = typeof row.expiresAt === 'object' && row.expiresAt?.toNumber ? row.expiresAt.toNumber() : row.expiresAt;
305
+ // Filter out null entries from optional ABOUT matches
306
+ const aboutNodes = (row.aboutNodes ?? []).filter((n) => n?.id != null);
307
+ return {
308
+ id: row.id,
309
+ topic: row.topic,
310
+ content: row.content,
311
+ category: row.category,
312
+ severity: row.severity,
313
+ relevance: row.relevance != null ? Math.round(row.relevance * 1000) / 1000 : null,
314
+ agentId: row.agentId,
315
+ sessionId: row.sessionId,
316
+ createdAt,
317
+ expiresAt,
318
+ aboutNodes,
319
+ };
320
+ });
321
+ return createSuccessResponse(JSON.stringify({
322
+ count: notes.length,
323
+ projectId: resolvedProjectId,
324
+ searchMode: query ? 'semantic' : 'filter',
325
+ filters: {
326
+ query: query ?? null,
327
+ category: category ?? null,
328
+ severity: severity ?? null,
329
+ sessionId: sessionId ?? null,
330
+ agentId: agentId ?? null,
331
+ },
332
+ notes,
333
+ }, null, 2));
334
+ }
335
+ catch (error) {
336
+ await debugLog('Recall session notes error', { error: String(error) });
337
+ return createErrorResponse(error instanceof Error ? error : String(error));
338
+ }
339
+ finally {
340
+ await neo4jService.close();
341
+ }
342
+ });
343
+ };
@@ -261,12 +261,9 @@ export const createSwarmClaimTaskTool = (server) => {
261
261
  .describe('Action: claim_and_start (RECOMMENDED: atomic claim+start), claim (reserve only), ' +
262
262
  'start (begin work on claimed task), release (give up task), ' +
263
263
  'abandon (release with tracking), force_start (recover from stuck claimed state)'),
264
- releaseReason: z
265
- .string()
266
- .optional()
267
- .describe('Reason for releasing/abandoning the task'),
264
+ releaseReason: z.string().optional().describe('Reason for releasing/abandoning the task'),
268
265
  },
269
- }, async ({ projectId, swarmId, agentId, taskId, types, minPriority, action = 'claim_and_start', releaseReason, }) => {
266
+ }, async ({ projectId, swarmId, agentId, taskId, types, minPriority, action = 'claim_and_start', releaseReason }) => {
270
267
  const neo4jService = new Neo4jService();
271
268
  // Resolve project ID
272
269
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
@@ -323,9 +320,7 @@ export const createSwarmClaimTaskTool = (server) => {
323
320
  ? `Current state: ${currentState.status}, claimedBy: ${currentState.claimedBy || 'none'}`
324
321
  : 'Task not found.'));
325
322
  }
326
- const abandonCount = typeof result[0].abandonCount === 'object'
327
- ? result[0].abandonCount.toNumber()
328
- : result[0].abandonCount;
323
+ const abandonCount = typeof result[0].abandonCount === 'object' ? result[0].abandonCount.toNumber() : result[0].abandonCount;
329
324
  return createSuccessResponse(JSON.stringify({ action: 'abandoned', taskId: result[0].id, abandonCount }));
330
325
  }
331
326
  // Handle force_start action (recovery from stuck claimed state)
@@ -406,9 +401,7 @@ export const createSwarmClaimTaskTool = (server) => {
406
401
  }
407
402
  else {
408
403
  // Auto-select highest priority available task with retry logic
409
- const minPriorityScore = minPriority
410
- ? TASK_PRIORITIES[minPriority]
411
- : null;
404
+ const minPriorityScore = minPriority ? TASK_PRIORITIES[minPriority] : null;
412
405
  // Retry loop to handle race conditions
413
406
  while (retryCount < MAX_CLAIM_RETRIES) {
414
407
  result = await neo4jService.run(CLAIM_NEXT_TASK_QUERY, {
@@ -96,7 +96,7 @@ export const createSwarmCleanupTool = (server) => {
96
96
  .describe('Pheromone types to preserve (default: ["warning"])'),
97
97
  dryRun: z.boolean().optional().default(false).describe('Preview what would be deleted without deleting'),
98
98
  },
99
- }, async ({ projectId, swarmId, agentId, all = false, includeTasks = true, keepTypes = ['warning'], dryRun = false }) => {
99
+ }, async ({ projectId, swarmId, agentId, all = false, includeTasks = true, keepTypes = ['warning'], dryRun = false, }) => {
100
100
  const neo4jService = new Neo4jService();
101
101
  // Resolve project ID
102
102
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
@@ -139,7 +139,8 @@ export const createSwarmCleanupTool = (server) => {
139
139
  if (swarmId && includeTasks) {
140
140
  const taskResult = await neo4jService.run(COUNT_TASKS_BY_SWARM_QUERY, params);
141
141
  taskCount = taskResult[0]?.count ?? 0;
142
- taskCount = typeof taskCount === 'object' && 'toNumber' in taskCount ? taskCount.toNumber() : taskCount;
142
+ taskCount =
143
+ typeof taskCount === 'object' && 'toNumber' in taskCount ? taskCount.toNumber() : taskCount;
143
144
  taskStatuses = taskResult[0]?.statuses ?? [];
144
145
  }
145
146
  return createSuccessResponse(JSON.stringify({
@@ -147,14 +148,18 @@ export const createSwarmCleanupTool = (server) => {
147
148
  dryRun: true,
148
149
  mode,
149
150
  pheromones: {
150
- wouldDelete: typeof pheromoneCount === 'object' && 'toNumber' in pheromoneCount ? pheromoneCount.toNumber() : pheromoneCount,
151
+ wouldDelete: typeof pheromoneCount === 'object' && 'toNumber' in pheromoneCount
152
+ ? pheromoneCount.toNumber()
153
+ : pheromoneCount,
151
154
  agents: pheromoneResult[0]?.agents ?? [],
152
155
  types: pheromoneResult[0]?.types ?? [],
153
156
  },
154
- tasks: swarmId && includeTasks ? {
155
- wouldDelete: taskCount,
156
- statuses: taskStatuses,
157
- } : null,
157
+ tasks: swarmId && includeTasks
158
+ ? {
159
+ wouldDelete: taskCount,
160
+ statuses: taskStatuses,
161
+ }
162
+ : null,
158
163
  keepTypes,
159
164
  projectId: resolvedProjectId,
160
165
  }));
@@ -168,21 +173,28 @@ export const createSwarmCleanupTool = (server) => {
168
173
  if (swarmId && includeTasks) {
169
174
  const taskResult = await neo4jService.run(CLEANUP_TASKS_BY_SWARM_QUERY, params);
170
175
  tasksDeleted = taskResult[0]?.deleted ?? 0;
171
- tasksDeleted = typeof tasksDeleted === 'object' && 'toNumber' in tasksDeleted ? tasksDeleted.toNumber() : tasksDeleted;
176
+ tasksDeleted =
177
+ typeof tasksDeleted === 'object' && 'toNumber' in tasksDeleted
178
+ ? tasksDeleted.toNumber()
179
+ : tasksDeleted;
172
180
  taskStatuses = taskResult[0]?.statuses ?? [];
173
181
  }
174
182
  return createSuccessResponse(JSON.stringify({
175
183
  success: true,
176
184
  mode,
177
185
  pheromones: {
178
- deleted: typeof pheromonesDeleted === 'object' && 'toNumber' in pheromonesDeleted ? pheromonesDeleted.toNumber() : pheromonesDeleted,
186
+ deleted: typeof pheromonesDeleted === 'object' && 'toNumber' in pheromonesDeleted
187
+ ? pheromonesDeleted.toNumber()
188
+ : pheromonesDeleted,
179
189
  agents: pheromoneResult[0]?.agents ?? [],
180
190
  types: pheromoneResult[0]?.types ?? [],
181
191
  },
182
- tasks: swarmId && includeTasks ? {
183
- deleted: tasksDeleted,
184
- statuses: taskStatuses,
185
- } : null,
192
+ tasks: swarmId && includeTasks
193
+ ? {
194
+ deleted: tasksDeleted,
195
+ statuses: taskStatuses,
196
+ }
197
+ : null,
186
198
  keepTypes,
187
199
  projectId: resolvedProjectId,
188
200
  message: swarmId && includeTasks
@@ -249,45 +249,20 @@ export const createSwarmCompleteTaskTool = (server) => {
249
249
  action: z
250
250
  .enum(['complete', 'fail', 'request_review', 'approve', 'reject', 'retry'])
251
251
  .describe('Action to take on the task'),
252
- summary: z
253
- .string()
254
- .optional()
255
- .describe('Summary of what was done (required for complete/request_review)'),
252
+ summary: z.string().optional().describe('Summary of what was done (required for complete/request_review)'),
256
253
  artifacts: z
257
254
  .record(z.unknown())
258
255
  .optional()
259
256
  .describe('Artifacts produced: { files: [], commits: [], pullRequests: [], notes: string }'),
260
- filesChanged: z
261
- .array(z.string())
262
- .optional()
263
- .describe('List of files that were modified'),
257
+ filesChanged: z.array(z.string()).optional().describe('List of files that were modified'),
264
258
  linesAdded: z.number().int().optional().describe('Number of lines added'),
265
259
  linesRemoved: z.number().int().optional().describe('Number of lines removed'),
266
- reason: z
267
- .string()
268
- .optional()
269
- .describe('Reason for failure (required if action=fail)'),
270
- errorDetails: z
271
- .string()
272
- .optional()
273
- .describe('Technical error details for debugging'),
274
- retryable: z
275
- .boolean()
276
- .optional()
277
- .default(true)
278
- .describe('Whether the task can be retried after failure'),
279
- reviewNotes: z
280
- .string()
281
- .optional()
282
- .describe('Notes for the reviewer (for request_review)'),
283
- reviewerId: z
284
- .string()
285
- .optional()
286
- .describe('ID of the reviewer (required for approve/reject)'),
287
- notes: z
288
- .string()
289
- .optional()
290
- .describe('Approval/rejection notes'),
260
+ reason: z.string().optional().describe('Reason for failure (required if action=fail)'),
261
+ errorDetails: z.string().optional().describe('Technical error details for debugging'),
262
+ retryable: z.boolean().optional().default(true).describe('Whether the task can be retried after failure'),
263
+ reviewNotes: z.string().optional().describe('Notes for the reviewer (for request_review)'),
264
+ reviewerId: z.string().optional().describe('ID of the reviewer (required for approve/reject)'),
265
+ notes: z.string().optional().describe('Approval/rejection notes'),
291
266
  markAsFailed: z
292
267
  .boolean()
293
268
  .optional()
@@ -14,6 +14,7 @@ export const PHEROMONE_CONFIG = {
14
14
  blocked: { halfLife: 5 * 60 * 1000, description: 'Stuck' },
15
15
  proposal: { halfLife: 60 * 60 * 1000, description: 'Awaiting approval' },
16
16
  needs_review: { halfLife: 30 * 60 * 1000, description: 'Review requested' },
17
+ session_context: { halfLife: 8 * 60 * 60 * 1000, description: 'Session working context marker' },
17
18
  };
18
19
  /**
19
20
  * Task status values for the blackboard task queue
@@ -76,7 +77,7 @@ export const WORKFLOW_STATES = ['exploring', 'claiming', 'modifying', 'completed
76
77
  /**
77
78
  * Flags can coexist with workflow states.
78
79
  */
79
- export const FLAG_TYPES = ['warning', 'proposal', 'needs_review'];
80
+ export const FLAG_TYPES = ['warning', 'proposal', 'needs_review', 'session_context'];
80
81
  // ============================================================================
81
82
  // ORCHESTRATOR CONSTANTS
82
83
  // ============================================================================
@@ -167,10 +167,7 @@ export const createSwarmGetTasksTool = (server) => {
167
167
  .array(z.enum(TASK_STATUSES))
168
168
  .optional()
169
169
  .describe('Filter by task statuses (e.g., ["available", "in_progress"])'),
170
- types: z
171
- .array(z.enum(TASK_TYPES))
172
- .optional()
173
- .describe('Filter by task types (e.g., ["implement", "fix"])'),
170
+ types: z.array(z.enum(TASK_TYPES)).optional().describe('Filter by task types (e.g., ["implement", "fix"])'),
174
171
  claimedBy: z.string().optional().describe('Filter tasks claimed by a specific agent'),
175
172
  createdBy: z.string().optional().describe('Filter tasks created by a specific agent'),
176
173
  minPriority: z
@@ -241,7 +238,7 @@ export const createSwarmGetTasksTool = (server) => {
241
238
  }
242
239
  }
243
240
  // Convert Neo4j integers
244
- const convertTimestamp = (ts) => typeof ts === 'object' && ts?.toNumber ? ts.toNumber() : ts;
241
+ const convertTimestamp = (ts) => (typeof ts === 'object' && ts?.toNumber ? ts.toNumber() : ts);
245
242
  task.createdAt = convertTimestamp(task.createdAt);
246
243
  task.updatedAt = convertTimestamp(task.updatedAt);
247
244
  task.claimedAt = convertTimestamp(task.claimedAt);
@@ -250,9 +247,7 @@ export const createSwarmGetTasksTool = (server) => {
250
247
  return createSuccessResponse(JSON.stringify({ success: true, task }));
251
248
  }
252
249
  // Get list of tasks
253
- const minPriorityScore = minPriority
254
- ? TASK_PRIORITIES[minPriority]
255
- : null;
250
+ const minPriorityScore = minPriority ? TASK_PRIORITIES[minPriority] : null;
256
251
  const tasksResult = await neo4jService.run(GET_TASKS_QUERY, {
257
252
  projectId: resolvedProjectId,
258
253
  swarmId: swarmId || null,
@@ -265,7 +260,7 @@ export const createSwarmGetTasksTool = (server) => {
265
260
  limit: Math.floor(limit),
266
261
  skip: Math.floor(skip),
267
262
  });
268
- const convertTimestamp = (ts) => typeof ts === 'object' && ts?.toNumber ? ts.toNumber() : ts;
263
+ const convertTimestamp = (ts) => (typeof ts === 'object' && ts?.toNumber ? ts.toNumber() : ts);
269
264
  const tasks = tasksResult.map((t) => {
270
265
  // Parse metadata if present
271
266
  let metadata = t.metadata;
@@ -379,9 +374,7 @@ export const createSwarmGetTasksTool = (server) => {
379
374
  status: w.type === 'modifying' ? 'working' : 'claiming',
380
375
  lastActivity: typeof w.lastActivity === 'object' ? w.lastActivity.toNumber() : w.lastActivity,
381
376
  nodesBeingWorked: typeof w.nodeCount === 'object' ? w.nodeCount.toNumber() : w.nodeCount,
382
- minutesSinceActivity: typeof w.minutesSinceActivity === 'object'
383
- ? w.minutesSinceActivity.toNumber()
384
- : w.minutesSinceActivity,
377
+ minutesSinceActivity: typeof w.minutesSinceActivity === 'object' ? w.minutesSinceActivity.toNumber() : w.minutesSinceActivity,
385
378
  }));
386
379
  }
387
380
  // Include dependency graph if requested
@@ -14,7 +14,7 @@ import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
14
14
  import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
15
15
  import { TaskDecompositionHandler, } from '../handlers/task-decomposition.handler.js';
16
16
  import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
17
- import { TASK_PRIORITIES, generateSwarmId, ORCHESTRATOR_CONFIG, getHalfLife, } from './swarm-constants.js';
17
+ import { TASK_PRIORITIES, generateSwarmId, ORCHESTRATOR_CONFIG, getHalfLife } from './swarm-constants.js';
18
18
  /**
19
19
  * Query to search for nodes matching the task description
20
20
  */
@@ -415,14 +415,16 @@ swarm_complete_task({
415
415
  ## IF STUCK
416
416
  swarm_complete_task({ ..., action: "fail", reason: "<WHY>", retryable: true })
417
417
  Then claim another task.`;
418
- const taskCalls = agentIds.map(agentId => {
418
+ const taskCalls = agentIds
419
+ .map((agentId) => {
419
420
  const prompt = workerPrompt.replace(/\{AGENT_ID\}/g, agentId);
420
421
  return `Task({
421
422
  subagent_type: "general-purpose",
422
423
  run_in_background: false,
423
424
  prompt: \`${prompt}\`
424
425
  })`;
425
- }).join('\n\n');
426
+ })
427
+ .join('\n\n');
426
428
  return `
427
429
  ## Worker Agent Instructions
428
430
 
@@ -41,11 +41,13 @@ const CREATE_PHEROMONE_QUERY = `
41
41
  p.intensity = $intensity,
42
42
  p.timestamp = timestamp(),
43
43
  p.data = $data,
44
- p.halfLife = $halfLife
44
+ p.halfLife = $halfLife,
45
+ p.sessionId = $sessionId
45
46
  ON MATCH SET
46
47
  p.intensity = $intensity,
47
48
  p.timestamp = timestamp(),
48
- p.data = $data
49
+ p.data = $data,
50
+ p.sessionId = COALESCE($sessionId, p.sessionId)
49
51
 
50
52
  // Create relationship to target node if it exists
51
53
  WITH p, target
@@ -73,7 +75,7 @@ export const createSwarmPheromoneTool = (server) => {
73
75
  nodeId: z.string().describe('The code node ID to mark with a pheromone'),
74
76
  type: z
75
77
  .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)'),
78
+ .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)'),
77
79
  intensity: z
78
80
  .number()
79
81
  .min(0)
@@ -83,6 +85,10 @@ export const createSwarmPheromoneTool = (server) => {
83
85
  .describe('Pheromone intensity from 0.0 to 1.0 (default: 1.0)'),
84
86
  agentId: z.string().describe('Unique identifier for the agent leaving the pheromone'),
85
87
  swarmId: z.string().describe('Swarm ID for grouping related agents (e.g., "swarm_xyz")'),
88
+ sessionId: z
89
+ .string()
90
+ .optional()
91
+ .describe('Session identifier for cross-session recovery (e.g., conversation ID)'),
86
92
  data: z
87
93
  .record(z.unknown())
88
94
  .optional()
@@ -93,7 +99,7 @@ export const createSwarmPheromoneTool = (server) => {
93
99
  .default(false)
94
100
  .describe('If true, removes the pheromone instead of creating/updating it'),
95
101
  },
96
- }, async ({ projectId, nodeId, type, intensity = 1.0, agentId, swarmId, data, remove = false }) => {
102
+ }, async ({ projectId, nodeId, type, intensity = 1.0, agentId, swarmId, sessionId, data, remove = false }) => {
97
103
  const neo4jService = new Neo4jService();
98
104
  // Resolve project ID
99
105
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
@@ -159,6 +165,7 @@ export const createSwarmPheromoneTool = (server) => {
159
165
  intensity,
160
166
  agentId,
161
167
  swarmId,
168
+ sessionId: sessionId ?? null,
162
169
  data: dataJson,
163
170
  halfLife,
164
171
  });