code-graph-context 2.6.2 → 2.8.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,335 @@
1
+ /**
2
+ * Session Bookmark Tools
3
+ * Save and restore session context for cross-session continuity
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 create a SessionBookmark node and link to code nodes
11
+ */
12
+ const CREATE_BOOKMARK_QUERY = `
13
+ CREATE (b:SessionBookmark {
14
+ id: $bookmarkId,
15
+ projectId: $projectId,
16
+ sessionId: $sessionId,
17
+ agentId: $agentId,
18
+ summary: $summary,
19
+ workingSetNodeIds: $workingSetNodeIds,
20
+ taskContext: $taskContext,
21
+ findings: $findings,
22
+ nextSteps: $nextSteps,
23
+ metadata: $metadata,
24
+ createdAt: timestamp(),
25
+ updatedAt: timestamp()
26
+ })
27
+
28
+ // Link to referenced code nodes (exclude coordination nodes)
29
+ WITH b
30
+ OPTIONAL MATCH (target)
31
+ WHERE target.id IN $workingSetNodeIds
32
+ AND target.projectId = $projectId
33
+ AND NOT target:Pheromone
34
+ AND NOT target:SwarmTask
35
+ AND NOT target:SessionBookmark
36
+ AND NOT target:SessionNote
37
+ WITH b, collect(DISTINCT target) AS targets
38
+ FOREACH (t IN targets | MERGE (b)-[:REFERENCES]->(t))
39
+
40
+ RETURN b.id AS id,
41
+ b.sessionId AS sessionId,
42
+ b.agentId AS agentId,
43
+ b.summary AS summary,
44
+ b.taskContext AS taskContext,
45
+ b.createdAt AS createdAt,
46
+ size(targets) AS linkedNodes
47
+ `;
48
+ /**
49
+ * Neo4j query to find the most recent SessionBookmark matching filters
50
+ */
51
+ const FIND_BOOKMARK_QUERY = `
52
+ MATCH (b:SessionBookmark)
53
+ WHERE b.projectId = $projectId
54
+ AND ($sessionId IS NULL OR b.sessionId = $sessionId)
55
+ AND ($agentId IS NULL OR b.agentId = $agentId)
56
+ RETURN b.id AS id,
57
+ b.projectId AS projectId,
58
+ b.sessionId AS sessionId,
59
+ b.agentId AS agentId,
60
+ b.summary AS summary,
61
+ b.workingSetNodeIds AS workingSetNodeIds,
62
+ b.taskContext AS taskContext,
63
+ b.findings AS findings,
64
+ b.nextSteps AS nextSteps,
65
+ b.metadata AS metadata,
66
+ b.createdAt AS createdAt,
67
+ b.updatedAt AS updatedAt
68
+ ORDER BY b.createdAt DESC
69
+ LIMIT 1
70
+ `;
71
+ /**
72
+ * Neo4j query to get code nodes referenced by a bookmark
73
+ */
74
+ const GET_BOOKMARK_WORKING_SET_QUERY = `
75
+ MATCH (b:SessionBookmark {id: $bookmarkId, projectId: $projectId})-[:REFERENCES]->(target)
76
+ WHERE NOT target:Pheromone
77
+ AND NOT target:SwarmTask
78
+ AND NOT target:SessionBookmark
79
+ AND NOT target:SessionNote
80
+ RETURN target.id AS id,
81
+ target.projectId AS projectId,
82
+ labels(target)[0] AS type,
83
+ target.name AS name,
84
+ target.filePath AS filePath,
85
+ CASE WHEN $includeCode THEN target.sourceCode ELSE null END AS sourceCode,
86
+ target.coreType AS coreType,
87
+ target.semanticType AS semanticType,
88
+ target.startLine AS startLine,
89
+ target.endLine AS endLine
90
+ ORDER BY target.filePath, target.startLine
91
+ `;
92
+ /**
93
+ * Neo4j query to get SessionNote nodes linked to a bookmark's session
94
+ */
95
+ const GET_SESSION_NOTES_QUERY = `
96
+ MATCH (n:SessionNote)
97
+ WHERE n.projectId = $projectId
98
+ AND n.sessionId = $sessionId
99
+ AND (n.expiresAt IS NULL OR n.expiresAt > timestamp())
100
+
101
+ OPTIONAL MATCH (n)-[:ABOUT]->(codeNode)
102
+ WHERE NOT codeNode:SessionNote
103
+ AND NOT codeNode:SessionBookmark
104
+ AND NOT codeNode:Pheromone
105
+ AND NOT codeNode:SwarmTask
106
+
107
+ RETURN n.id AS id,
108
+ n.topic AS topic,
109
+ n.content AS content,
110
+ n.category AS category,
111
+ n.severity AS severity,
112
+ n.agentId AS agentId,
113
+ n.sessionId AS sessionId,
114
+ n.createdAt AS createdAt,
115
+ n.expiresAt AS expiresAt,
116
+ collect(DISTINCT {id: codeNode.id, name: codeNode.name, filePath: codeNode.filePath}) AS aboutNodes
117
+ ORDER BY n.createdAt ASC
118
+ LIMIT 50
119
+ `;
120
+ export const createSaveSessionBookmarkTool = (server) => {
121
+ server.registerTool(TOOL_NAMES.saveSessionBookmark, {
122
+ title: TOOL_METADATA[TOOL_NAMES.saveSessionBookmark].title,
123
+ description: TOOL_METADATA[TOOL_NAMES.saveSessionBookmark].description,
124
+ inputSchema: {
125
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
126
+ sessionId: z.string().describe('Unique session identifier (e.g., conversation ID) for cross-session recovery'),
127
+ agentId: z.string().describe('Agent identifier for this bookmark'),
128
+ summary: z.string().min(10).describe('Brief summary of current work state (min 10 characters)'),
129
+ workingSetNodeIds: z
130
+ .array(z.string())
131
+ .describe('Code node IDs currently being focused on (from search_codebase or traverse_from_node)'),
132
+ taskContext: z.string().describe('High-level task currently being worked on'),
133
+ findings: z.string().optional().default('').describe('Key discoveries or decisions made so far'),
134
+ nextSteps: z.string().optional().default('').describe('What to do next when resuming this session'),
135
+ metadata: z.record(z.unknown()).optional().describe('Additional structured data to store with the bookmark'),
136
+ },
137
+ }, async ({ projectId, sessionId, agentId, summary, workingSetNodeIds, taskContext, findings = '', nextSteps = '', metadata, }) => {
138
+ const neo4jService = new Neo4jService();
139
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
140
+ if (!projectResult.success) {
141
+ await neo4jService.close();
142
+ return projectResult.error;
143
+ }
144
+ const resolvedProjectId = projectResult.projectId;
145
+ try {
146
+ const bookmarkId = `bookmark_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
147
+ const metadataJson = metadata ? JSON.stringify(metadata) : null;
148
+ const result = await neo4jService.run(CREATE_BOOKMARK_QUERY, {
149
+ bookmarkId,
150
+ projectId: resolvedProjectId,
151
+ sessionId,
152
+ agentId,
153
+ summary,
154
+ workingSetNodeIds,
155
+ taskContext,
156
+ findings,
157
+ nextSteps,
158
+ metadata: metadataJson,
159
+ });
160
+ if (result.length === 0) {
161
+ return createErrorResponse('Failed to create session bookmark');
162
+ }
163
+ const bookmark = result[0];
164
+ const linkedNodes = typeof bookmark.linkedNodes === 'object' && bookmark.linkedNodes?.toNumber
165
+ ? bookmark.linkedNodes.toNumber()
166
+ : (bookmark.linkedNodes ?? 0);
167
+ return createSuccessResponse(JSON.stringify({
168
+ success: true,
169
+ bookmarkId: bookmark.id,
170
+ sessionId: bookmark.sessionId,
171
+ agentId: bookmark.agentId,
172
+ projectId: resolvedProjectId,
173
+ summary: bookmark.summary,
174
+ taskContext: bookmark.taskContext,
175
+ workingSetSize: workingSetNodeIds.length,
176
+ linkedNodes,
177
+ createdAt: typeof bookmark.createdAt === 'object' && bookmark.createdAt?.toNumber
178
+ ? bookmark.createdAt.toNumber()
179
+ : bookmark.createdAt,
180
+ message: `Session bookmark saved. ${linkedNodes} of ${workingSetNodeIds.length} working set nodes linked in graph.`,
181
+ }));
182
+ }
183
+ catch (error) {
184
+ await debugLog('Save session bookmark error', { error: String(error) });
185
+ return createErrorResponse(error instanceof Error ? error : String(error));
186
+ }
187
+ finally {
188
+ await neo4jService.close();
189
+ }
190
+ });
191
+ };
192
+ export const createRestoreSessionBookmarkTool = (server) => {
193
+ server.registerTool(TOOL_NAMES.restoreSessionBookmark, {
194
+ title: TOOL_METADATA[TOOL_NAMES.restoreSessionBookmark].title,
195
+ description: TOOL_METADATA[TOOL_NAMES.restoreSessionBookmark].description,
196
+ inputSchema: {
197
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
198
+ sessionId: z
199
+ .string()
200
+ .optional()
201
+ .describe('Specific session ID to restore. If omitted, restores the most recent bookmark.'),
202
+ agentId: z
203
+ .string()
204
+ .optional()
205
+ .describe('Filter bookmarks by agent ID. If omitted, returns bookmarks from any agent.'),
206
+ includeCode: z
207
+ .boolean()
208
+ .optional()
209
+ .default(true)
210
+ .describe('Include source code snippets for working set nodes (default: true)'),
211
+ snippetLength: z
212
+ .number()
213
+ .int()
214
+ .min(50)
215
+ .max(5000)
216
+ .optional()
217
+ .default(500)
218
+ .describe('Maximum characters per code snippet (default: 500)'),
219
+ },
220
+ }, async ({ projectId, sessionId, agentId, includeCode = true, snippetLength = 500 }) => {
221
+ const neo4jService = new Neo4jService();
222
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
223
+ if (!projectResult.success) {
224
+ await neo4jService.close();
225
+ return projectResult.error;
226
+ }
227
+ const resolvedProjectId = projectResult.projectId;
228
+ try {
229
+ // Find the most recent matching bookmark
230
+ const bookmarkRows = await neo4jService.run(FIND_BOOKMARK_QUERY, {
231
+ projectId: resolvedProjectId,
232
+ sessionId: sessionId ?? null,
233
+ agentId: agentId ?? null,
234
+ });
235
+ if (bookmarkRows.length === 0) {
236
+ return createSuccessResponse(JSON.stringify({
237
+ success: false,
238
+ message: sessionId
239
+ ? `No bookmark found for session "${sessionId}"${agentId ? ` and agent "${agentId}"` : ''}`
240
+ : `No bookmarks found for this project${agentId ? ` and agent "${agentId}"` : ''}`,
241
+ projectId: resolvedProjectId,
242
+ }));
243
+ }
244
+ const bm = bookmarkRows[0];
245
+ // Fetch working set nodes linked in the graph
246
+ const workingSetRows = await neo4jService.run(GET_BOOKMARK_WORKING_SET_QUERY, {
247
+ bookmarkId: bm.id,
248
+ projectId: resolvedProjectId,
249
+ includeCode,
250
+ });
251
+ // Fetch any session notes for this session
252
+ const noteRows = await neo4jService.run(GET_SESSION_NOTES_QUERY, {
253
+ projectId: resolvedProjectId,
254
+ sessionId: bm.sessionId,
255
+ });
256
+ // Build working set with optional code truncation
257
+ const workingSet = workingSetRows.map((row) => {
258
+ const node = {
259
+ id: row.id,
260
+ type: row.type,
261
+ name: row.name,
262
+ filePath: row.filePath,
263
+ coreType: row.coreType,
264
+ semanticType: row.semanticType,
265
+ startLine: typeof row.startLine === 'object' && row.startLine?.toNumber ? row.startLine.toNumber() : row.startLine,
266
+ endLine: typeof row.endLine === 'object' && row.endLine?.toNumber ? row.endLine.toNumber() : row.endLine,
267
+ };
268
+ if (includeCode && row.sourceCode) {
269
+ const code = row.sourceCode;
270
+ if (code.length <= snippetLength) {
271
+ node.sourceCode = code;
272
+ }
273
+ else {
274
+ const half = Math.floor(snippetLength / 2);
275
+ node.sourceCode =
276
+ code.substring(0, half) + '\n\n... [truncated] ...\n\n' + code.substring(code.length - half);
277
+ node.truncated = true;
278
+ }
279
+ }
280
+ return node;
281
+ });
282
+ const notes = noteRows.map((n) => {
283
+ const aboutNodes = (n.aboutNodes ?? []).filter((node) => node?.id != null);
284
+ return {
285
+ id: n.id,
286
+ topic: n.topic,
287
+ content: n.content,
288
+ category: n.category,
289
+ severity: n.severity,
290
+ agentId: n.agentId,
291
+ sessionId: n.sessionId,
292
+ createdAt: typeof n.createdAt === 'object' && n.createdAt?.toNumber ? n.createdAt.toNumber() : n.createdAt,
293
+ expiresAt: typeof n.expiresAt === 'object' && n.expiresAt?.toNumber ? n.expiresAt.toNumber() : n.expiresAt,
294
+ aboutNodes,
295
+ };
296
+ });
297
+ // Identify working set nodes not found in the graph (stale IDs after re-parse)
298
+ const foundIds = new Set(workingSetRows.map((r) => r.id));
299
+ const storedIds = Array.isArray(bm.workingSetNodeIds) ? bm.workingSetNodeIds : [];
300
+ const staleNodeIds = storedIds.filter((id) => !foundIds.has(id));
301
+ return createSuccessResponse(JSON.stringify({
302
+ success: true,
303
+ bookmark: {
304
+ id: bm.id,
305
+ projectId: resolvedProjectId,
306
+ sessionId: bm.sessionId,
307
+ agentId: bm.agentId,
308
+ summary: bm.summary,
309
+ taskContext: bm.taskContext,
310
+ findings: bm.findings,
311
+ nextSteps: bm.nextSteps,
312
+ metadata: bm.metadata ? JSON.parse(bm.metadata) : null,
313
+ createdAt: typeof bm.createdAt === 'object' && bm.createdAt?.toNumber ? bm.createdAt.toNumber() : bm.createdAt,
314
+ updatedAt: typeof bm.updatedAt === 'object' && bm.updatedAt?.toNumber ? bm.updatedAt.toNumber() : bm.updatedAt,
315
+ },
316
+ workingSet,
317
+ notes,
318
+ staleNodeIds,
319
+ stats: {
320
+ workingSetTotal: storedIds.length,
321
+ workingSetFound: workingSet.length,
322
+ workingSetStale: staleNodeIds.length,
323
+ notesCount: notes.length,
324
+ },
325
+ }));
326
+ }
327
+ catch (error) {
328
+ await debugLog('Restore session bookmark error', { error: String(error) });
329
+ return createErrorResponse(error instanceof Error ? error : String(error));
330
+ }
331
+ finally {
332
+ await neo4jService.close();
333
+ }
334
+ });
335
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Session Cleanup Tool
3
+ * Remove expired notes and prune old bookmarks
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
+ * Count expired notes (for dry run)
11
+ */
12
+ const COUNT_EXPIRED_NOTES_QUERY = `
13
+ MATCH (n:SessionNote)
14
+ WHERE n.projectId = $projectId
15
+ AND n.expiresAt IS NOT NULL
16
+ AND n.expiresAt <= timestamp()
17
+ RETURN count(n) AS count
18
+ `;
19
+ /**
20
+ * Delete expired notes and their edges
21
+ */
22
+ const DELETE_EXPIRED_NOTES_QUERY = `
23
+ MATCH (n:SessionNote)
24
+ WHERE n.projectId = $projectId
25
+ AND n.expiresAt IS NOT NULL
26
+ AND n.expiresAt <= timestamp()
27
+ WITH collect(n) AS toDelete
28
+ WITH size(toDelete) AS cnt, toDelete
29
+ FOREACH (n IN toDelete | DETACH DELETE n)
30
+ RETURN cnt AS deleted
31
+ `;
32
+ /**
33
+ * Find old bookmarks to prune (keeping N most recent per session)
34
+ */
35
+ const COUNT_OLD_BOOKMARKS_QUERY = `
36
+ MATCH (b:SessionBookmark)
37
+ WHERE b.projectId = $projectId
38
+ WITH b.sessionId AS sessionId, b
39
+ ORDER BY b.createdAt DESC
40
+ WITH sessionId, collect(b) AS bookmarks
41
+ WHERE size(bookmarks) > $keepBookmarks
42
+ UNWIND bookmarks[$keepBookmarks..] AS old
43
+ RETURN count(old) AS count
44
+ `;
45
+ /**
46
+ * Delete old bookmarks (keeping N most recent per session)
47
+ */
48
+ const DELETE_OLD_BOOKMARKS_QUERY = `
49
+ MATCH (b:SessionBookmark)
50
+ WHERE b.projectId = $projectId
51
+ WITH b.sessionId AS sessionId, b
52
+ ORDER BY b.createdAt DESC
53
+ WITH sessionId, collect(b) AS bookmarks
54
+ WHERE size(bookmarks) > $keepBookmarks
55
+ WITH reduce(all = [], bs IN collect(bookmarks[$keepBookmarks..]) | all + bs) AS toDelete
56
+ WITH size(toDelete) AS cnt, toDelete
57
+ FOREACH (b IN toDelete | DETACH DELETE b)
58
+ RETURN cnt AS deleted
59
+ `;
60
+ export const createCleanupSessionTool = (server) => {
61
+ server.registerTool(TOOL_NAMES.cleanupSession, {
62
+ title: TOOL_METADATA[TOOL_NAMES.cleanupSession].title,
63
+ description: TOOL_METADATA[TOOL_NAMES.cleanupSession].description,
64
+ inputSchema: {
65
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
66
+ keepBookmarks: z
67
+ .number()
68
+ .int()
69
+ .min(1)
70
+ .max(50)
71
+ .optional()
72
+ .default(3)
73
+ .describe('Number of most recent bookmarks to keep per session (default: 3)'),
74
+ dryRun: z
75
+ .boolean()
76
+ .optional()
77
+ .default(false)
78
+ .describe('Preview what would be deleted without deleting (default: false)'),
79
+ },
80
+ }, async ({ projectId, keepBookmarks = 3, dryRun = false }) => {
81
+ const neo4jService = new Neo4jService();
82
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
83
+ if (!projectResult.success) {
84
+ await neo4jService.close();
85
+ return projectResult.error;
86
+ }
87
+ const resolvedProjectId = projectResult.projectId;
88
+ try {
89
+ const params = { projectId: resolvedProjectId, keepBookmarks };
90
+ if (dryRun) {
91
+ const [noteCount, bookmarkCount] = await Promise.all([
92
+ neo4jService.run(COUNT_EXPIRED_NOTES_QUERY, params),
93
+ neo4jService.run(COUNT_OLD_BOOKMARKS_QUERY, params),
94
+ ]);
95
+ const expiredNotes = noteCount[0]?.count ?? 0;
96
+ const oldBookmarks = bookmarkCount[0]?.count ?? 0;
97
+ const toNumber = (v) => (typeof v === 'object' && v?.toNumber ? v.toNumber() : v);
98
+ return createSuccessResponse(JSON.stringify({
99
+ dryRun: true,
100
+ projectId: resolvedProjectId,
101
+ wouldDelete: {
102
+ expiredNotes: toNumber(expiredNotes),
103
+ oldBookmarks: toNumber(oldBookmarks),
104
+ },
105
+ keepBookmarks,
106
+ message: toNumber(expiredNotes) === 0 && toNumber(oldBookmarks) === 0
107
+ ? 'Nothing to clean up.'
108
+ : `Would delete ${toNumber(expiredNotes)} expired notes and ${toNumber(oldBookmarks)} old bookmarks.`,
109
+ }));
110
+ }
111
+ const [noteResult, bookmarkResult] = await Promise.all([
112
+ neo4jService.run(DELETE_EXPIRED_NOTES_QUERY, params),
113
+ neo4jService.run(DELETE_OLD_BOOKMARKS_QUERY, params),
114
+ ]);
115
+ const toNumber = (v) => (typeof v === 'object' && v?.toNumber ? v.toNumber() : v);
116
+ const deletedNotes = toNumber(noteResult[0]?.deleted ?? 0);
117
+ const deletedBookmarks = toNumber(bookmarkResult[0]?.deleted ?? 0);
118
+ return createSuccessResponse(JSON.stringify({
119
+ success: true,
120
+ projectId: resolvedProjectId,
121
+ deleted: {
122
+ expiredNotes: deletedNotes,
123
+ oldBookmarks: deletedBookmarks,
124
+ },
125
+ keepBookmarks,
126
+ message: deletedNotes === 0 && deletedBookmarks === 0
127
+ ? 'Nothing to clean up.'
128
+ : `Deleted ${deletedNotes} expired notes and ${deletedBookmarks} old bookmarks.`,
129
+ }));
130
+ }
131
+ catch (error) {
132
+ await debugLog('Cleanup session error', { error: String(error) });
133
+ return createErrorResponse(error instanceof Error ? error : String(error));
134
+ }
135
+ finally {
136
+ await neo4jService.close();
137
+ }
138
+ });
139
+ };