code-graph-context 2.6.1 → 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.
@@ -3,7 +3,7 @@
3
3
  * Shared utilities for creating/converting graph nodes and edges
4
4
  */
5
5
  import crypto from 'crypto';
6
- import { CoreEdgeType, CORE_TYPESCRIPT_SCHEMA } from '../config/schema.js';
6
+ import { CoreEdgeType, CORE_TYPESCRIPT_SCHEMA, } from '../config/schema.js';
7
7
  // ============================================
8
8
  // Node ID Generation
9
9
  // ============================================
@@ -39,6 +39,10 @@ export const TOOL_NAMES = {
39
39
  swarmCompleteTask: 'swarm_complete_task',
40
40
  swarmGetTasks: 'swarm_get_tasks',
41
41
  swarmOrchestrate: 'swarm_orchestrate',
42
+ saveSessionBookmark: 'save_session_bookmark',
43
+ restoreSessionBookmark: 'restore_session_bookmark',
44
+ saveSessionNote: 'save_session_note',
45
+ recallSessionNotes: 'recall_session_notes',
42
46
  };
43
47
  // Tool Metadata
44
48
  export const TOOL_METADATA = {
@@ -205,9 +209,9 @@ Parameters:
205
209
  },
206
210
  [TOOL_NAMES.swarmPheromone]: {
207
211
  title: 'Swarm Pheromone',
208
- description: `Mark a code node with a pheromone for coordination. Types: exploring (2min), modifying (10min), claiming (1hr), completed (24hr), warning (permanent), blocked (5min), proposal (1hr), needs_review (30min).
212
+ description: `Mark a code node with a pheromone for coordination. Types: exploring (2min), modifying (10min), claiming (1hr), completed (24hr), warning (permanent), blocked (5min), proposal (1hr), needs_review (30min), session_context (8hr).
209
213
 
210
- Workflow states (exploring/claiming/modifying/completed/blocked) are mutually exclusive per agent+node. Use remove:true to delete. Pheromones decay automatically.`,
214
+ Workflow states (exploring/claiming/modifying/completed/blocked) are mutually exclusive per agent+node. Flag types (warning/proposal/needs_review/session_context) can coexist. Use remove:true to delete. Pheromones decay automatically.`,
211
215
  },
212
216
  [TOOL_NAMES.swarmSense]: {
213
217
  title: 'Swarm Sense',
@@ -261,6 +265,75 @@ Sort by: priority (default), created, updated. Add includeStats:true for aggrega
261
265
 
262
266
  Use dryRun:true to preview plan. maxAgents controls parallelism (default: 3). Failed tasks auto-retry via pheromone decay.`,
263
267
  },
268
+ [TOOL_NAMES.saveSessionBookmark]: {
269
+ title: 'Save Session Bookmark',
270
+ description: `Save current session context as a bookmark for cross-session continuity.
271
+
272
+ Records your working set (code node IDs), task context, findings, and next steps so a future session can resume exactly where you left off.
273
+
274
+ Parameters:
275
+ - projectId (required): Project ID, name, or path
276
+ - sessionId (required): Unique session/conversation ID for recovery
277
+ - agentId (required): Your agent identifier
278
+ - summary (required, min 10 chars): Brief description of current work state
279
+ - workingSetNodeIds (required): Code node IDs you are focused on
280
+ - taskContext (required): High-level task being worked on
281
+ - findings: Key discoveries or decisions made so far
282
+ - nextSteps: What to do next when resuming
283
+ - metadata: Additional structured data
284
+
285
+ Returns bookmarkId for use with restore_session_bookmark.`,
286
+ },
287
+ [TOOL_NAMES.restoreSessionBookmark]: {
288
+ title: 'Restore Session Bookmark',
289
+ description: `Restore a previously saved session bookmark to resume work.
290
+
291
+ Retrieves the bookmark, fetches working set code nodes (with source), and returns any session notes. Use after conversation compaction or when resuming a task in a new session.
292
+
293
+ Parameters:
294
+ - projectId (required): Project ID, name, or path
295
+ - sessionId: Specific session to restore (latest bookmark if omitted)
296
+ - agentId: Filter by agent ID (any agent if omitted)
297
+ - includeCode (default: true): Include source code for working set nodes
298
+ - snippetLength (default: 500): Max characters per code snippet
299
+
300
+ Returns: bookmark data, working set nodes, session notes, and staleNodeIds (nodes no longer in graph after re-parse).`,
301
+ },
302
+ [TOOL_NAMES.saveSessionNote]: {
303
+ title: 'Save Session Note',
304
+ description: `Save an observation, decision, insight, or risk as a durable session note linked to code nodes.
305
+
306
+ Notes survive session compaction and are recalled by restore_session_bookmark or recall_session_notes.
307
+
308
+ Parameters:
309
+ - projectId (required): Project ID, name, or path
310
+ - sessionId (required): Session/conversation identifier
311
+ - agentId (required): Your agent identifier
312
+ - topic (required, 3-100 chars): Short topic label
313
+ - content (required, min 10 chars): Full observation text
314
+ - category (required): architectural, bug, insight, decision, risk, or todo
315
+ - severity (default: info): info, warning, or critical
316
+ - aboutNodeIds: Code node IDs this note is about (creates [:ABOUT] links)
317
+ - expiresInHours: Auto-expire after N hours (omit for permanent)
318
+
319
+ Returns noteId, hasEmbedding (enables semantic recall), and expiresAt.`,
320
+ },
321
+ [TOOL_NAMES.recallSessionNotes]: {
322
+ title: 'Recall Session Notes',
323
+ description: `Search and retrieve saved session notes. Supports semantic vector search (when query provided) or filter-based search.
324
+
325
+ Parameters:
326
+ - projectId (required): Project ID, name, or path
327
+ - query: Natural language search — triggers semantic vector search when provided
328
+ - category: Filter by architectural, bug, insight, decision, risk, todo
329
+ - severity: Filter by info, warning, or critical
330
+ - sessionId: Filter by session ID
331
+ - agentId: Filter by agent ID
332
+ - limit (default: 10, max: 50): Maximum notes to return
333
+ - minSimilarity (default: 0.3): Minimum similarity for vector search
334
+
335
+ Returns notes with topic, content, category, severity, relevance score (vector mode), and linked aboutNodes.`,
336
+ },
264
337
  };
265
338
  // Default Values
266
339
  export const DEFAULTS = {
@@ -180,6 +180,7 @@ export class GraphGeneratorHandler {
180
180
  async createVectorIndexes() {
181
181
  console.error('Creating vector indexes...');
182
182
  await this.neo4jService.run(QUERIES.CREATE_EMBEDDED_VECTOR_INDEX);
183
+ await this.neo4jService.run(QUERIES.CREATE_SESSION_NOTES_VECTOR_INDEX);
183
184
  await debugLog('Vector indexes created');
184
185
  }
185
186
  flattenProperties(properties) {
@@ -24,8 +24,8 @@ import { registerAllTools } from './tools/index.js';
24
24
  import { debugLog } from './utils.js';
25
25
  // Track server state for debugging
26
26
  let serverStartTime;
27
- let toolCallCount = 0;
28
- let lastToolCall = null;
27
+ const toolCallCount = 0;
28
+ const lastToolCall = null;
29
29
  /**
30
30
  * Log memory usage and server stats
31
31
  */
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import fs from 'fs/promises';
6
6
  import { join } from 'path';
7
- import { ensureNeo4jRunning, isDockerInstalled, isDockerRunning, } from '../cli/neo4j-docker.js';
7
+ import { ensureNeo4jRunning, isDockerInstalled, isDockerRunning } from '../cli/neo4j-docker.js';
8
8
  import { Neo4jService, QUERIES } from '../storage/neo4j/neo4j.service.js';
9
9
  import { FILE_PATHS, LOG_CONFIG } from './constants.js';
10
10
  import { initializeNaturalLanguageService } from './tools/natural-language-to-cypher.tool.js';
@@ -47,7 +47,7 @@ class WatchManager {
47
47
  // This is expected if the client doesn't support logging capability
48
48
  debugLog('sendNotification: MCP message failed (expected if client lacks logging)', {
49
49
  type: notification.type,
50
- error: String(error)
50
+ error: String(error),
51
51
  });
52
52
  });
53
53
  }
@@ -62,7 +62,8 @@ class WatchManager {
62
62
  }
63
63
  // Enforce maximum watcher limit
64
64
  if (this.watchers.size >= WATCH.maxWatchers) {
65
- throw new Error(`Maximum watcher limit (${WATCH.maxWatchers}) reached. ` + `Stop an existing watcher before starting a new one.`);
65
+ throw new Error(`Maximum watcher limit (${WATCH.maxWatchers}) reached. ` +
66
+ `Stop an existing watcher before starting a new one.`);
66
67
  }
67
68
  const fullConfig = {
68
69
  projectPath: config.projectPath,
@@ -138,7 +139,13 @@ class WatchManager {
138
139
  * Handle a file system event
139
140
  */
140
141
  handleFileEvent(state, type, filePath) {
141
- debugLog('handleFileEvent START', { type, filePath, projectId: state.projectId, status: state.status, isStopping: state.isStopping });
142
+ debugLog('handleFileEvent START', {
143
+ type,
144
+ filePath,
145
+ projectId: state.projectId,
146
+ status: state.status,
147
+ isStopping: state.isStopping,
148
+ });
142
149
  // Ignore events if watcher is stopping or not active
143
150
  if (state.isStopping || state.status !== 'active') {
144
151
  debugLog('Ignoring event - watcher not active or stopping', {
@@ -194,12 +201,12 @@ class WatchManager {
194
201
  projectId: state.projectId,
195
202
  isProcessing: state.isProcessing,
196
203
  pendingCount: state.pendingEvents.length,
197
- isStopping: state.isStopping
204
+ isStopping: state.isStopping,
198
205
  });
199
206
  // Don't process if already processing, no events, or watcher is stopping
200
207
  if (state.isProcessing || state.pendingEvents.length === 0 || state.isStopping) {
201
208
  await debugLog('processEvents: early return', {
202
- reason: state.isProcessing ? 'already processing' : state.pendingEvents.length === 0 ? 'no events' : 'stopping'
209
+ reason: state.isProcessing ? 'already processing' : state.pendingEvents.length === 0 ? 'no events' : 'stopping',
203
210
  });
204
211
  return;
205
212
  }
@@ -226,12 +233,12 @@ class WatchManager {
226
233
  }
227
234
  await debugLog('processEvents: calling incrementalParseHandler', {
228
235
  projectPath: state.projectPath,
229
- projectId: state.projectId
236
+ projectId: state.projectId,
230
237
  });
231
238
  const result = await this.incrementalParseHandler(state.projectPath, state.projectId, state.tsconfigPath);
232
239
  await debugLog('processEvents: incrementalParseHandler returned', {
233
240
  nodesUpdated: result.nodesUpdated,
234
- edgesUpdated: result.edgesUpdated
241
+ edgesUpdated: result.edgesUpdated,
235
242
  });
236
243
  state.lastUpdateTime = new Date();
237
244
  const elapsedMs = Date.now() - startTime;
@@ -285,7 +292,10 @@ class WatchManager {
285
292
  debugLog('handleWatcherError: cleanup succeeded', { projectId: state.projectId });
286
293
  })
287
294
  .catch((cleanupError) => {
288
- debugLog('handleWatcherError: cleanup failed', { projectId: state.projectId, cleanupError: String(cleanupError) });
295
+ debugLog('handleWatcherError: cleanup failed', {
296
+ projectId: state.projectId,
297
+ cleanupError: String(cleanupError),
298
+ });
289
299
  console.error(`[WatchManager] Failed to cleanup errored watcher ${state.projectId}:`, cleanupError);
290
300
  });
291
301
  }
@@ -306,7 +316,7 @@ class WatchManager {
306
316
  debugLog('syncMissedChanges: completed', {
307
317
  projectId: state.projectId,
308
318
  nodesUpdated: result.nodesUpdated,
309
- edgesUpdated: result.edgesUpdated
319
+ edgesUpdated: result.edgesUpdated,
310
320
  });
311
321
  if (result.nodesUpdated > 0 || result.edgesUpdated > 0) {
312
322
  console.error(`[WatchManager] Synced missed changes for ${state.projectId}: ` +
@@ -314,7 +324,11 @@ class WatchManager {
314
324
  }
315
325
  })
316
326
  .catch((error) => {
317
- debugLog('syncMissedChanges: error', { projectId: state.projectId, error: String(error), isStopping: state.isStopping });
327
+ debugLog('syncMissedChanges: error', {
328
+ projectId: state.projectId,
329
+ error: String(error),
330
+ isStopping: state.isStopping,
331
+ });
318
332
  // Only log if watcher hasn't been stopped
319
333
  if (!state.isStopping) {
320
334
  console.error(`[WatchManager] Failed to sync missed changes for ${state.projectId}:`, error);
@@ -12,6 +12,8 @@ import { createListWatchersTool } from './list-watchers.tool.js';
12
12
  import { createNaturalLanguageToCypherTool } from './natural-language-to-cypher.tool.js';
13
13
  import { createParseTypescriptProjectTool } from './parse-typescript-project.tool.js';
14
14
  import { createSearchCodebaseTool } from './search-codebase.tool.js';
15
+ import { createRestoreSessionBookmarkTool, createSaveSessionBookmarkTool } from './session-bookmark.tool.js';
16
+ import { createRecallSessionNotesTool, createSaveSessionNoteTool } from './session-note.tool.js';
15
17
  import { createStartWatchProjectTool } from './start-watch-project.tool.js';
16
18
  import { createStopWatchProjectTool } from './stop-watch-project.tool.js';
17
19
  import { createSwarmClaimTaskTool } from './swarm-claim-task.tool.js';
@@ -73,4 +75,10 @@ export const registerAllTools = (server) => {
73
75
  createSwarmGetTasksTool(server);
74
76
  // Register swarm orchestration tool (meta-tool for coordinating multi-agent work)
75
77
  createSwarmOrchestrateTool(server);
78
+ // Register session bookmark tools (cross-session context continuity)
79
+ createSaveSessionBookmarkTool(server);
80
+ createRestoreSessionBookmarkTool(server);
81
+ // Register session note tools (durable observations and decisions)
82
+ createSaveSessionNoteTool(server);
83
+ createRecallSessionNotesTool(server);
76
84
  };
@@ -90,10 +90,10 @@ export const createParseTypescriptProjectTool = (server) => {
90
90
  .optional()
91
91
  .default('auto')
92
92
  .describe('When to use streaming import: auto (>100 files), always, or never'),
93
- async: z
93
+ async: z.coerce
94
94
  .boolean()
95
95
  .optional()
96
- .default(false)
96
+ .default(true)
97
97
  .describe('Run parsing in background and return job ID immediately. Use check_parse_status to monitor.'),
98
98
  watch: z
99
99
  .boolean()
@@ -224,7 +224,8 @@ export const createParseTypescriptProjectTool = (server) => {
224
224
  });
225
225
  const discoveredFiles = await parser.discoverSourceFiles();
226
226
  const totalFiles = discoveredFiles.length;
227
- const shouldUseStreaming = useStreaming === 'always' || (useStreaming === 'auto' && totalFiles > PARSING.streamingThreshold && chunkSize > 0);
227
+ const shouldUseStreaming = useStreaming === 'always' ||
228
+ (useStreaming === 'auto' && totalFiles > PARSING.streamingThreshold && chunkSize > 0);
228
229
  console.error(`📊 Project has ${totalFiles} files. Streaming: ${shouldUseStreaming ? 'enabled' : 'disabled'}`);
229
230
  if (shouldUseStreaming && clearExisting !== false) {
230
231
  // Use streaming import for large projects
@@ -0,0 +1,314 @@
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
+ RETURN n.id AS id,
100
+ n.sessionId AS sessionId,
101
+ n.agentId AS agentId,
102
+ n.content AS content,
103
+ n.createdAt AS createdAt
104
+ ORDER BY n.createdAt ASC
105
+ LIMIT 50
106
+ `;
107
+ export const createSaveSessionBookmarkTool = (server) => {
108
+ server.registerTool(TOOL_NAMES.saveSessionBookmark, {
109
+ title: TOOL_METADATA[TOOL_NAMES.saveSessionBookmark].title,
110
+ description: TOOL_METADATA[TOOL_NAMES.saveSessionBookmark].description,
111
+ inputSchema: {
112
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
113
+ sessionId: z.string().describe('Unique session identifier (e.g., conversation ID) for cross-session recovery'),
114
+ agentId: z.string().describe('Agent identifier for this bookmark'),
115
+ summary: z.string().min(10).describe('Brief summary of current work state (min 10 characters)'),
116
+ workingSetNodeIds: z
117
+ .array(z.string())
118
+ .describe('Code node IDs currently being focused on (from search_codebase or traverse_from_node)'),
119
+ taskContext: z.string().describe('High-level task currently being worked on'),
120
+ findings: z.string().optional().default('').describe('Key discoveries or decisions made so far'),
121
+ nextSteps: z.string().optional().default('').describe('What to do next when resuming this session'),
122
+ metadata: z.record(z.unknown()).optional().describe('Additional structured data to store with the bookmark'),
123
+ },
124
+ }, async ({ projectId, sessionId, agentId, summary, workingSetNodeIds, taskContext, findings = '', nextSteps = '', metadata, }) => {
125
+ const neo4jService = new Neo4jService();
126
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
127
+ if (!projectResult.success) {
128
+ await neo4jService.close();
129
+ return projectResult.error;
130
+ }
131
+ const resolvedProjectId = projectResult.projectId;
132
+ try {
133
+ const bookmarkId = `bookmark_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
134
+ const metadataJson = metadata ? JSON.stringify(metadata) : null;
135
+ const result = await neo4jService.run(CREATE_BOOKMARK_QUERY, {
136
+ bookmarkId,
137
+ projectId: resolvedProjectId,
138
+ sessionId,
139
+ agentId,
140
+ summary,
141
+ workingSetNodeIds,
142
+ taskContext,
143
+ findings,
144
+ nextSteps,
145
+ metadata: metadataJson,
146
+ });
147
+ if (result.length === 0) {
148
+ return createErrorResponse('Failed to create session bookmark');
149
+ }
150
+ const bookmark = result[0];
151
+ const linkedNodes = typeof bookmark.linkedNodes === 'object' && bookmark.linkedNodes?.toNumber
152
+ ? bookmark.linkedNodes.toNumber()
153
+ : (bookmark.linkedNodes ?? 0);
154
+ return createSuccessResponse(JSON.stringify({
155
+ success: true,
156
+ bookmarkId: bookmark.id,
157
+ sessionId: bookmark.sessionId,
158
+ agentId: bookmark.agentId,
159
+ projectId: resolvedProjectId,
160
+ summary: bookmark.summary,
161
+ taskContext: bookmark.taskContext,
162
+ workingSetSize: workingSetNodeIds.length,
163
+ linkedNodes,
164
+ createdAt: typeof bookmark.createdAt === 'object' && bookmark.createdAt?.toNumber
165
+ ? bookmark.createdAt.toNumber()
166
+ : bookmark.createdAt,
167
+ message: `Session bookmark saved. ${linkedNodes} of ${workingSetNodeIds.length} working set nodes linked in graph.`,
168
+ }));
169
+ }
170
+ catch (error) {
171
+ await debugLog('Save session bookmark error', { error: String(error) });
172
+ return createErrorResponse(error instanceof Error ? error : String(error));
173
+ }
174
+ finally {
175
+ await neo4jService.close();
176
+ }
177
+ });
178
+ };
179
+ export const createRestoreSessionBookmarkTool = (server) => {
180
+ server.registerTool(TOOL_NAMES.restoreSessionBookmark, {
181
+ title: TOOL_METADATA[TOOL_NAMES.restoreSessionBookmark].title,
182
+ description: TOOL_METADATA[TOOL_NAMES.restoreSessionBookmark].description,
183
+ inputSchema: {
184
+ projectId: z.string().describe('Project ID, name, or path (e.g., "backend" or "proj_a1b2c3d4e5f6")'),
185
+ sessionId: z
186
+ .string()
187
+ .optional()
188
+ .describe('Specific session ID to restore. If omitted, restores the most recent bookmark.'),
189
+ agentId: z
190
+ .string()
191
+ .optional()
192
+ .describe('Filter bookmarks by agent ID. If omitted, returns bookmarks from any agent.'),
193
+ includeCode: z
194
+ .boolean()
195
+ .optional()
196
+ .default(true)
197
+ .describe('Include source code snippets for working set nodes (default: true)'),
198
+ snippetLength: z
199
+ .number()
200
+ .int()
201
+ .min(50)
202
+ .max(5000)
203
+ .optional()
204
+ .default(500)
205
+ .describe('Maximum characters per code snippet (default: 500)'),
206
+ },
207
+ }, async ({ projectId, sessionId, agentId, includeCode = true, snippetLength = 500 }) => {
208
+ const neo4jService = new Neo4jService();
209
+ const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
210
+ if (!projectResult.success) {
211
+ await neo4jService.close();
212
+ return projectResult.error;
213
+ }
214
+ const resolvedProjectId = projectResult.projectId;
215
+ try {
216
+ // Find the most recent matching bookmark
217
+ const bookmarkRows = await neo4jService.run(FIND_BOOKMARK_QUERY, {
218
+ projectId: resolvedProjectId,
219
+ sessionId: sessionId ?? null,
220
+ agentId: agentId ?? null,
221
+ });
222
+ if (bookmarkRows.length === 0) {
223
+ return createSuccessResponse(JSON.stringify({
224
+ success: false,
225
+ message: sessionId
226
+ ? `No bookmark found for session "${sessionId}"${agentId ? ` and agent "${agentId}"` : ''}`
227
+ : `No bookmarks found for this project${agentId ? ` and agent "${agentId}"` : ''}`,
228
+ projectId: resolvedProjectId,
229
+ }));
230
+ }
231
+ const bm = bookmarkRows[0];
232
+ // Fetch working set nodes linked in the graph
233
+ const workingSetRows = await neo4jService.run(GET_BOOKMARK_WORKING_SET_QUERY, {
234
+ bookmarkId: bm.id,
235
+ projectId: resolvedProjectId,
236
+ includeCode,
237
+ });
238
+ // Fetch any session notes for this session
239
+ const noteRows = await neo4jService.run(GET_SESSION_NOTES_QUERY, {
240
+ projectId: resolvedProjectId,
241
+ sessionId: bm.sessionId,
242
+ });
243
+ // Build working set with optional code truncation
244
+ const workingSet = workingSetRows.map((row) => {
245
+ const node = {
246
+ id: row.id,
247
+ type: row.type,
248
+ name: row.name,
249
+ filePath: row.filePath,
250
+ coreType: row.coreType,
251
+ semanticType: row.semanticType,
252
+ startLine: typeof row.startLine === 'object' && row.startLine?.toNumber ? row.startLine.toNumber() : row.startLine,
253
+ endLine: typeof row.endLine === 'object' && row.endLine?.toNumber ? row.endLine.toNumber() : row.endLine,
254
+ };
255
+ if (includeCode && row.sourceCode) {
256
+ const code = row.sourceCode;
257
+ if (code.length <= snippetLength) {
258
+ node.sourceCode = code;
259
+ }
260
+ else {
261
+ const half = Math.floor(snippetLength / 2);
262
+ node.sourceCode =
263
+ code.substring(0, half) + '\n\n... [truncated] ...\n\n' + code.substring(code.length - half);
264
+ node.truncated = true;
265
+ }
266
+ }
267
+ return node;
268
+ });
269
+ const notes = noteRows.map((n) => ({
270
+ id: n.id,
271
+ sessionId: n.sessionId,
272
+ agentId: n.agentId,
273
+ content: n.content,
274
+ createdAt: typeof n.createdAt === 'object' && n.createdAt?.toNumber ? n.createdAt.toNumber() : n.createdAt,
275
+ }));
276
+ // Identify working set nodes not found in the graph (stale IDs after re-parse)
277
+ const foundIds = new Set(workingSetRows.map((r) => r.id));
278
+ const storedIds = Array.isArray(bm.workingSetNodeIds) ? bm.workingSetNodeIds : [];
279
+ const staleNodeIds = storedIds.filter((id) => !foundIds.has(id));
280
+ return createSuccessResponse(JSON.stringify({
281
+ success: true,
282
+ bookmark: {
283
+ id: bm.id,
284
+ projectId: resolvedProjectId,
285
+ sessionId: bm.sessionId,
286
+ agentId: bm.agentId,
287
+ summary: bm.summary,
288
+ taskContext: bm.taskContext,
289
+ findings: bm.findings,
290
+ nextSteps: bm.nextSteps,
291
+ metadata: bm.metadata ? JSON.parse(bm.metadata) : null,
292
+ createdAt: typeof bm.createdAt === 'object' && bm.createdAt?.toNumber ? bm.createdAt.toNumber() : bm.createdAt,
293
+ updatedAt: typeof bm.updatedAt === 'object' && bm.updatedAt?.toNumber ? bm.updatedAt.toNumber() : bm.updatedAt,
294
+ },
295
+ workingSet,
296
+ notes,
297
+ staleNodeIds,
298
+ stats: {
299
+ workingSetTotal: storedIds.length,
300
+ workingSetFound: workingSet.length,
301
+ workingSetStale: staleNodeIds.length,
302
+ notesCount: notes.length,
303
+ },
304
+ }));
305
+ }
306
+ catch (error) {
307
+ await debugLog('Restore session bookmark error', { error: String(error) });
308
+ return createErrorResponse(error instanceof Error ? error : String(error));
309
+ }
310
+ finally {
311
+ await neo4jService.close();
312
+ }
313
+ });
314
+ };