server-memory-enhanced 2.0.0 → 2.2.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/dist/index.js CHANGED
@@ -8,6 +8,9 @@ import { fileURLToPath } from 'url';
8
8
  import { KnowledgeGraphManager } from './lib/knowledge-graph-manager.js';
9
9
  import { EntitySchema, RelationSchema, SaveMemoryInputSchema, SaveMemoryOutputSchema, GetAnalyticsInputSchema, GetAnalyticsOutputSchema, GetObservationHistoryInputSchema, GetObservationHistoryOutputSchema } from './lib/schemas.js';
10
10
  import { handleSaveMemory } from './lib/save-memory-handler.js';
11
+ import { JsonlStorageAdapter } from './lib/jsonl-storage-adapter.js';
12
+ import { Neo4jStorageAdapter } from './lib/neo4j-storage-adapter.js';
13
+ import { NEO4J_ENV_VARS, STORAGE_LOG_MESSAGES, NEO4J_ERROR_MESSAGES } from './lib/storage-config.js';
11
14
  // Define memory directory path using environment variable with fallback
12
15
  export const defaultMemoryDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-data');
13
16
  export async function ensureMemoryDirectory() {
@@ -25,9 +28,76 @@ export async function ensureMemoryDirectory() {
25
28
  }
26
29
  return memoryDir;
27
30
  }
31
+ /**
32
+ * Get Neo4j configuration from environment variables.
33
+ * Extracted for testability and Single Responsibility Principle.
34
+ */
35
+ function getNeo4jConfig() {
36
+ const uri = process.env[NEO4J_ENV_VARS.URI];
37
+ const username = process.env[NEO4J_ENV_VARS.USERNAME];
38
+ const password = process.env[NEO4J_ENV_VARS.PASSWORD];
39
+ const database = process.env[NEO4J_ENV_VARS.DATABASE];
40
+ if (!uri || !username || !password) {
41
+ return null;
42
+ }
43
+ return { uri, username, password, database };
44
+ }
45
+ /**
46
+ * Create Neo4j storage adapter if configured.
47
+ * Extracted for Single Responsibility Principle and testability.
48
+ */
49
+ async function createNeo4jAdapter(config) {
50
+ try {
51
+ console.error(STORAGE_LOG_MESSAGES.ATTEMPTING_NEO4J, config.uri);
52
+ const neo4jAdapter = new Neo4jStorageAdapter(config);
53
+ await neo4jAdapter.initialize();
54
+ console.error(STORAGE_LOG_MESSAGES.NEO4J_SUCCESS);
55
+ return neo4jAdapter;
56
+ }
57
+ catch (error) {
58
+ // Sanitize error message to avoid exposing credentials
59
+ const safeErrorMessage = error instanceof Error ? error.message.replace(/password[=:][\S]+/gi, 'password:***') : 'Connection failed';
60
+ console.error(STORAGE_LOG_MESSAGES.NEO4J_FALLBACK, safeErrorMessage);
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Create JSONL storage adapter.
66
+ * Extracted for DRY and testability.
67
+ */
68
+ async function createJsonlAdapter(memoryDirPath) {
69
+ const jsonlAdapter = new JsonlStorageAdapter(memoryDirPath);
70
+ await jsonlAdapter.initialize();
71
+ console.error(STORAGE_LOG_MESSAGES.USING_JSONL, memoryDirPath);
72
+ return jsonlAdapter;
73
+ }
74
+ /**
75
+ * Create storage adapter based on environment variables.
76
+ * Falls back to JSONL storage if Neo4j is not configured or connection fails.
77
+ *
78
+ * Follows Open/Closed Principle: Open for extension (add new storage types)
79
+ * without modifying existing code.
80
+ */
81
+ async function createStorageAdapter(memoryDirPath) {
82
+ // Try Neo4j if configured
83
+ const neo4jConfig = getNeo4jConfig();
84
+ if (neo4jConfig) {
85
+ const neo4jAdapter = await createNeo4jAdapter(neo4jConfig);
86
+ if (neo4jAdapter) {
87
+ return neo4jAdapter;
88
+ }
89
+ }
90
+ else {
91
+ console.error(NEO4J_ERROR_MESSAGES.NOT_CONFIGURED);
92
+ }
93
+ // Fall back to JSONL storage
94
+ return createJsonlAdapter(memoryDirPath);
95
+ }
28
96
  // Initialize memory directory path (will be set during startup)
29
97
  let MEMORY_DIR_PATH;
30
98
  export { KnowledgeGraphManager } from './lib/knowledge-graph-manager.js';
99
+ export { JsonlStorageAdapter } from './lib/jsonl-storage-adapter.js';
100
+ export { Neo4jStorageAdapter } from './lib/neo4j-storage-adapter.js';
31
101
  let knowledgeGraphManager;
32
102
  // Zod schemas for enhanced entities and relations
33
103
  const EntitySchemaCompat = EntitySchema;
@@ -491,8 +561,38 @@ server.registerTool("get_observation_history", {
491
561
  async function main() {
492
562
  // Initialize memory directory path
493
563
  MEMORY_DIR_PATH = await ensureMemoryDirectory();
494
- // Initialize knowledge graph manager with the memory directory path
495
- knowledgeGraphManager = new KnowledgeGraphManager(MEMORY_DIR_PATH);
564
+ // Create storage adapter based on environment variables
565
+ // Falls back to JSONL if Neo4j is not configured or connection fails
566
+ const storageAdapter = await createStorageAdapter(MEMORY_DIR_PATH);
567
+ // Initialize knowledge graph manager with the storage adapter
568
+ knowledgeGraphManager = new KnowledgeGraphManager(MEMORY_DIR_PATH, storageAdapter);
569
+ // Register graceful shutdown handlers to ensure storage adapter is closed
570
+ let isShuttingDown = false;
571
+ const shutdown = async (signal) => {
572
+ if (isShuttingDown) {
573
+ return;
574
+ }
575
+ isShuttingDown = true;
576
+ console.error(`Received ${signal}, shutting down gracefully...`);
577
+ try {
578
+ // Close storage adapter (including Neo4j connections) before exiting
579
+ if (storageAdapter && 'close' in storageAdapter && typeof storageAdapter.close === 'function') {
580
+ await storageAdapter.close();
581
+ }
582
+ }
583
+ catch (err) {
584
+ console.error("Error during storage adapter shutdown:", err);
585
+ }
586
+ finally {
587
+ process.exit(0);
588
+ }
589
+ };
590
+ process.on("SIGINT", () => {
591
+ void shutdown("SIGINT");
592
+ });
593
+ process.on("SIGTERM", () => {
594
+ void shutdown("SIGTERM");
595
+ });
496
596
  const transport = new StdioServerTransport();
497
597
  await server.connect(transport);
498
598
  console.error("Enhanced Knowledge Graph MCP Server running on stdio");
@@ -0,0 +1,315 @@
1
+ /**
2
+ * JSONL Storage Adapter - implements file-based storage using JSON Lines format
3
+ */
4
+ import { promises as fs } from 'fs';
5
+ import path from 'path';
6
+ // Constants for file naming and types
7
+ const THREAD_FILE_PREFIX = 'thread-';
8
+ const THREAD_FILE_EXTENSION = '.jsonl';
9
+ const ENTITY_TYPE = 'entity';
10
+ const RELATION_TYPE = 'relation';
11
+ const FILE_NOT_FOUND_ERROR = 'ENOENT';
12
+ /**
13
+ * Type guard to check if an error is a FileSystemError
14
+ */
15
+ function isFileSystemError(error) {
16
+ return error instanceof Error && 'code' in error;
17
+ }
18
+ /**
19
+ * Check if a string field is valid (non-empty after trimming)
20
+ */
21
+ function isValidString(value) {
22
+ return typeof value === 'string' && value.trim().length > 0;
23
+ }
24
+ /**
25
+ * JSONL-based storage adapter for the knowledge graph
26
+ * Stores data in thread-specific JSONL files
27
+ *
28
+ * Responsibilities:
29
+ * - File I/O operations for JSONL format
30
+ * - Thread-based data organization
31
+ * - Data serialization/deserialization
32
+ */
33
+ export class JsonlStorageAdapter {
34
+ memoryDirPath;
35
+ constructor(memoryDirPath) {
36
+ this.memoryDirPath = memoryDirPath;
37
+ }
38
+ /**
39
+ * Get the file path for a specific thread
40
+ */
41
+ getThreadFilePath(agentThreadId) {
42
+ return path.join(this.memoryDirPath, `${THREAD_FILE_PREFIX}${agentThreadId}${THREAD_FILE_EXTENSION}`);
43
+ }
44
+ /**
45
+ * Check if an item is a valid entity
46
+ */
47
+ isValidEntity(item) {
48
+ return item.type === ENTITY_TYPE &&
49
+ isValidString(item.name) &&
50
+ isValidString(item.entityType) &&
51
+ Array.isArray(item.observations) &&
52
+ isValidString(item.agentThreadId) &&
53
+ isValidString(item.timestamp) &&
54
+ typeof item.confidence === 'number' &&
55
+ typeof item.importance === 'number';
56
+ }
57
+ /**
58
+ * Check if an item is a valid relation
59
+ */
60
+ isValidRelation(item) {
61
+ return item.type === RELATION_TYPE &&
62
+ isValidString(item.from) &&
63
+ isValidString(item.to) &&
64
+ isValidString(item.relationType) &&
65
+ isValidString(item.agentThreadId) &&
66
+ isValidString(item.timestamp) &&
67
+ typeof item.confidence === 'number' &&
68
+ typeof item.importance === 'number';
69
+ }
70
+ /**
71
+ * Parse and validate a single JSONL line
72
+ */
73
+ parseLine(line, filePath) {
74
+ try {
75
+ return JSON.parse(line);
76
+ }
77
+ catch (parseError) {
78
+ console.warn(`Skipping malformed JSON line in ${filePath} (line length: ${line.length} chars)`);
79
+ return null;
80
+ }
81
+ }
82
+ /**
83
+ * Convert JSONL item to Entity
84
+ */
85
+ toEntity(item) {
86
+ return {
87
+ name: item.name,
88
+ entityType: item.entityType,
89
+ observations: item.observations,
90
+ agentThreadId: item.agentThreadId,
91
+ timestamp: item.timestamp,
92
+ confidence: item.confidence,
93
+ importance: item.importance
94
+ };
95
+ }
96
+ /**
97
+ * Convert JSONL item to Relation
98
+ */
99
+ toRelation(item) {
100
+ return {
101
+ from: item.from,
102
+ to: item.to,
103
+ relationType: item.relationType,
104
+ agentThreadId: item.agentThreadId,
105
+ timestamp: item.timestamp,
106
+ confidence: item.confidence,
107
+ importance: item.importance
108
+ };
109
+ }
110
+ /**
111
+ * Process a single JSONL item and add to graph
112
+ */
113
+ processItem(item, graph, filePath) {
114
+ if (!item) {
115
+ return;
116
+ }
117
+ if (this.isValidEntity(item)) {
118
+ graph.entities.push(this.toEntity(item));
119
+ }
120
+ else if (this.isValidRelation(item)) {
121
+ graph.relations.push(this.toRelation(item));
122
+ }
123
+ else if (item.type === ENTITY_TYPE || item.type === RELATION_TYPE) {
124
+ console.warn(`Skipping ${item.type} with missing required fields in ${filePath}`);
125
+ }
126
+ }
127
+ /**
128
+ * Serialize an entity to JSONL format
129
+ */
130
+ serializeEntity(entity) {
131
+ return JSON.stringify({
132
+ type: ENTITY_TYPE,
133
+ name: entity.name,
134
+ entityType: entity.entityType,
135
+ observations: entity.observations,
136
+ agentThreadId: entity.agentThreadId,
137
+ timestamp: entity.timestamp,
138
+ confidence: entity.confidence,
139
+ importance: entity.importance
140
+ });
141
+ }
142
+ /**
143
+ * Serialize a relation to JSONL format
144
+ */
145
+ serializeRelation(relation) {
146
+ return JSON.stringify({
147
+ type: RELATION_TYPE,
148
+ from: relation.from,
149
+ to: relation.to,
150
+ relationType: relation.relationType,
151
+ agentThreadId: relation.agentThreadId,
152
+ timestamp: relation.timestamp,
153
+ confidence: relation.confidence,
154
+ importance: relation.importance
155
+ });
156
+ }
157
+ /**
158
+ * Get or create thread data in the map
159
+ */
160
+ getOrCreateThreadData(threadMap, threadId) {
161
+ let threadData = threadMap.get(threadId);
162
+ if (!threadData) {
163
+ threadData = { entities: [], relations: [] };
164
+ threadMap.set(threadId, threadData);
165
+ }
166
+ return threadData;
167
+ }
168
+ /**
169
+ * Check if error is a file not found error
170
+ */
171
+ isFileNotFoundError(error) {
172
+ return isFileSystemError(error) && error.code === FILE_NOT_FOUND_ERROR;
173
+ }
174
+ /**
175
+ * Extract thread ID from filename
176
+ */
177
+ extractThreadId(fileName) {
178
+ const match = fileName.match(new RegExp(`^${THREAD_FILE_PREFIX}(.+)${THREAD_FILE_EXTENSION}$`));
179
+ return match ? match[1] : null;
180
+ }
181
+ /**
182
+ * Load graph data from a single JSONL file
183
+ */
184
+ async loadGraphFromFile(filePath) {
185
+ try {
186
+ const data = await fs.readFile(filePath, "utf-8");
187
+ const lines = data.split("\n").filter(line => line.trim() !== "");
188
+ const graph = { entities: [], relations: [] };
189
+ for (const line of lines) {
190
+ const item = this.parseLine(line, filePath);
191
+ this.processItem(item, graph, filePath);
192
+ }
193
+ return graph;
194
+ }
195
+ catch (error) {
196
+ if (this.isFileNotFoundError(error)) {
197
+ return { entities: [], relations: [] };
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ /**
203
+ * Get all thread file names in the memory directory
204
+ */
205
+ async getThreadFileNames() {
206
+ const files = await fs.readdir(this.memoryDirPath).catch(() => []);
207
+ return files.filter(f => f.startsWith(THREAD_FILE_PREFIX) && f.endsWith(THREAD_FILE_EXTENSION));
208
+ }
209
+ /**
210
+ * Merge multiple graphs into one
211
+ */
212
+ mergeGraphs(graphs) {
213
+ return graphs.reduce((acc, graph) => ({
214
+ entities: [...acc.entities, ...graph.entities],
215
+ relations: [...acc.relations, ...graph.relations]
216
+ }), { entities: [], relations: [] });
217
+ }
218
+ /**
219
+ * Load the complete knowledge graph from all thread files
220
+ */
221
+ async loadGraph() {
222
+ const threadFiles = await this.getThreadFileNames();
223
+ const graphs = await Promise.all(threadFiles.map(f => this.loadGraphFromFile(path.join(this.memoryDirPath, f))));
224
+ return this.mergeGraphs(graphs);
225
+ }
226
+ /**
227
+ * Delete empty thread file if it exists
228
+ */
229
+ async deleteThreadFileIfExists(threadFilePath) {
230
+ try {
231
+ await fs.unlink(threadFilePath);
232
+ }
233
+ catch (error) {
234
+ if (!this.isFileNotFoundError(error)) {
235
+ console.warn(`Failed to delete empty thread file ${threadFilePath}:`, error);
236
+ }
237
+ }
238
+ }
239
+ /**
240
+ * Serialize thread data to JSONL lines
241
+ */
242
+ serializeThreadData(threadData) {
243
+ return [
244
+ ...threadData.entities.map(e => this.serializeEntity(e)),
245
+ ...threadData.relations.map(r => this.serializeRelation(r))
246
+ ];
247
+ }
248
+ /**
249
+ * Save data for a specific thread
250
+ */
251
+ async saveGraphForThread(agentThreadId, threadData) {
252
+ const threadFilePath = this.getThreadFilePath(agentThreadId);
253
+ const lines = this.serializeThreadData(threadData);
254
+ if (lines.length === 0) {
255
+ await this.deleteThreadFileIfExists(threadFilePath);
256
+ return;
257
+ }
258
+ await fs.writeFile(threadFilePath, lines.join("\n"));
259
+ }
260
+ /**
261
+ * Group graph data by thread ID
262
+ */
263
+ groupByThread(graph) {
264
+ const threadMap = new Map();
265
+ for (const entity of graph.entities) {
266
+ const threadData = this.getOrCreateThreadData(threadMap, entity.agentThreadId);
267
+ threadData.entities.push(entity);
268
+ }
269
+ for (const relation of graph.relations) {
270
+ const threadData = this.getOrCreateThreadData(threadMap, relation.agentThreadId);
271
+ threadData.relations.push(relation);
272
+ }
273
+ return threadMap;
274
+ }
275
+ /**
276
+ * Save all thread data to their respective files
277
+ */
278
+ async saveAllThreads(threadMap) {
279
+ const savePromises = Array.from(threadMap.entries()).map(([threadId, data]) => this.saveGraphForThread(threadId, data));
280
+ await Promise.all(savePromises);
281
+ }
282
+ /**
283
+ * Clean up stale thread files that are no longer in the graph
284
+ */
285
+ async cleanupStaleThreadFiles(activeThreadIds) {
286
+ try {
287
+ const threadFiles = await this.getThreadFileNames();
288
+ const deletePromises = threadFiles.map(async (fileName) => {
289
+ const threadId = this.extractThreadId(fileName);
290
+ if (threadId && !activeThreadIds.has(threadId)) {
291
+ const filePath = path.join(this.memoryDirPath, fileName);
292
+ await this.deleteThreadFileIfExists(filePath);
293
+ }
294
+ });
295
+ await Promise.all(deletePromises);
296
+ }
297
+ catch (error) {
298
+ console.warn('Failed to clean up stale thread files:', error);
299
+ }
300
+ }
301
+ /**
302
+ * Save the complete knowledge graph to thread-specific files
303
+ */
304
+ async saveGraph(graph) {
305
+ const threadMap = this.groupByThread(graph);
306
+ await this.saveAllThreads(threadMap);
307
+ await this.cleanupStaleThreadFiles(new Set(threadMap.keys()));
308
+ }
309
+ /**
310
+ * Initialize the storage adapter (create memory directory if needed)
311
+ */
312
+ async initialize() {
313
+ await fs.mkdir(this.memoryDirPath, { recursive: true });
314
+ }
315
+ }
@@ -1,182 +1,38 @@
1
1
  /**
2
2
  * KnowledgeGraphManager - Main class for managing the knowledge graph
3
3
  */
4
- import { promises as fs } from 'fs';
5
- import path from 'path';
6
4
  import { randomUUID } from 'crypto';
5
+ import { JsonlStorageAdapter } from './jsonl-storage-adapter.js';
7
6
  export class KnowledgeGraphManager {
8
- memoryDirPath;
9
7
  static NEGATION_WORDS = new Set(['not', 'no', 'never', 'neither', 'none', 'doesn\'t', 'don\'t', 'isn\'t', 'aren\'t']);
10
- constructor(memoryDirPath) {
11
- this.memoryDirPath = memoryDirPath;
8
+ storage;
9
+ initializePromise = null;
10
+ constructor(memoryDirPath, storageAdapter) {
11
+ this.storage = storageAdapter || new JsonlStorageAdapter(memoryDirPath);
12
+ // Lazy initialization - will be called on first operation
12
13
  }
13
- getThreadFilePath(agentThreadId) {
14
- return path.join(this.memoryDirPath, `thread-${agentThreadId}.jsonl`);
15
- }
16
- async loadGraphFromFile(filePath) {
17
- try {
18
- const data = await fs.readFile(filePath, "utf-8");
19
- const lines = data.split("\n").filter(line => line.trim() !== "");
20
- return lines.reduce((graph, line) => {
21
- let item;
22
- try {
23
- item = JSON.parse(line);
24
- }
25
- catch (parseError) {
26
- console.warn(`Skipping malformed JSON line in ${filePath} (line length: ${line.length} chars)`);
27
- return graph;
28
- }
29
- if (item.type === "entity") {
30
- // Validate required fields
31
- if (!item.name || !item.entityType || !Array.isArray(item.observations) ||
32
- !item.agentThreadId || !item.timestamp ||
33
- typeof item.confidence !== 'number' || typeof item.importance !== 'number') {
34
- console.warn(`Skipping entity with missing required fields in ${filePath}`);
35
- return graph;
36
- }
37
- graph.entities.push({
38
- name: item.name,
39
- entityType: item.entityType,
40
- observations: item.observations,
41
- agentThreadId: item.agentThreadId,
42
- timestamp: item.timestamp,
43
- confidence: item.confidence,
44
- importance: item.importance
45
- });
46
- }
47
- if (item.type === "relation") {
48
- // Validate required fields
49
- if (!item.from || !item.to || !item.relationType ||
50
- !item.agentThreadId || !item.timestamp ||
51
- typeof item.confidence !== 'number' || typeof item.importance !== 'number') {
52
- console.warn(`Skipping relation with missing required fields in ${filePath}`);
53
- return graph;
54
- }
55
- graph.relations.push({
56
- from: item.from,
57
- to: item.to,
58
- relationType: item.relationType,
59
- agentThreadId: item.agentThreadId,
60
- timestamp: item.timestamp,
61
- confidence: item.confidence,
62
- importance: item.importance
63
- });
64
- }
65
- return graph;
66
- }, { entities: [], relations: [] });
67
- }
68
- catch (error) {
69
- if (error instanceof Error && 'code' in error && error.code === "ENOENT") {
70
- return { entities: [], relations: [] };
71
- }
72
- throw error;
73
- }
74
- }
75
- async loadGraph() {
76
- const files = await fs.readdir(this.memoryDirPath).catch(() => []);
77
- const threadFiles = files.filter(f => f.startsWith('thread-') && f.endsWith('.jsonl'));
78
- const graphs = await Promise.all(threadFiles.map(f => this.loadGraphFromFile(path.join(this.memoryDirPath, f))));
79
- return graphs.reduce((acc, graph) => ({
80
- entities: [...acc.entities, ...graph.entities],
81
- relations: [...acc.relations, ...graph.relations]
82
- }), { entities: [], relations: [] });
83
- }
84
- async saveGraphForThread(agentThreadId, entities, relations) {
85
- const threadFilePath = this.getThreadFilePath(agentThreadId);
86
- const lines = [
87
- ...entities.map(e => JSON.stringify({
88
- type: "entity",
89
- name: e.name,
90
- entityType: e.entityType,
91
- observations: e.observations,
92
- agentThreadId: e.agentThreadId,
93
- timestamp: e.timestamp,
94
- confidence: e.confidence,
95
- importance: e.importance
96
- })),
97
- ...relations.map(r => JSON.stringify({
98
- type: "relation",
99
- from: r.from,
100
- to: r.to,
101
- relationType: r.relationType,
102
- agentThreadId: r.agentThreadId,
103
- timestamp: r.timestamp,
104
- confidence: r.confidence,
105
- importance: r.importance
106
- })),
107
- ];
108
- // Avoid creating or keeping empty files when there is no data for this thread
109
- if (lines.length === 0) {
110
- try {
111
- await fs.unlink(threadFilePath);
112
- }
113
- catch (error) {
114
- // Only ignore ENOENT errors (file doesn't exist)
115
- if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
116
- console.warn(`Failed to delete empty thread file ${threadFilePath}:`, error);
117
- }
118
- }
119
- return;
120
- }
121
- await fs.writeFile(threadFilePath, lines.join("\n"));
122
- }
123
- async saveGraph(graph) {
124
- // Group entities and relations by agentThreadId
125
- const threadMap = new Map();
126
- for (const entity of graph.entities) {
127
- if (!threadMap.has(entity.agentThreadId)) {
128
- threadMap.set(entity.agentThreadId, { entities: [], relations: [] });
129
- }
130
- threadMap.get(entity.agentThreadId).entities.push(entity);
131
- }
132
- for (const relation of graph.relations) {
133
- if (!threadMap.has(relation.agentThreadId)) {
134
- threadMap.set(relation.agentThreadId, { entities: [], relations: [] });
135
- }
136
- threadMap.get(relation.agentThreadId).relations.push(relation);
137
- }
138
- // Save each thread's data to its own file
139
- await Promise.all(Array.from(threadMap.entries()).map(([threadId, data]) => this.saveGraphForThread(threadId, data.entities, data.relations)));
140
- // Clean up stale thread files that no longer have data
141
- try {
142
- const files = await fs.readdir(this.memoryDirPath).catch(() => []);
143
- const threadFiles = files.filter(f => f.startsWith('thread-') && f.endsWith('.jsonl'));
144
- await Promise.all(threadFiles.map(async (fileName) => {
145
- // Extract threadId from filename: thread-{agentThreadId}.jsonl
146
- const match = fileName.match(/^thread-(.+)\.jsonl$/);
147
- if (match) {
148
- const threadId = match[1];
149
- if (!threadMap.has(threadId)) {
150
- const filePath = path.join(this.memoryDirPath, fileName);
151
- try {
152
- await fs.unlink(filePath);
153
- }
154
- catch (error) {
155
- // Only log non-ENOENT errors
156
- if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
157
- console.warn(`Failed to delete stale thread file ${filePath}:`, error);
158
- }
159
- }
160
- }
161
- }
162
- }));
163
- }
164
- catch (error) {
165
- // Best-effort cleanup: log but don't fail the save operation
166
- console.warn('Failed to clean up stale thread files:', error);
14
+ /**
15
+ * Ensure storage is initialized before any operation
16
+ * This is called automatically by all public methods
17
+ */
18
+ async ensureInitialized() {
19
+ if (!this.initializePromise) {
20
+ this.initializePromise = this.storage.initialize();
167
21
  }
22
+ await this.initializePromise;
168
23
  }
169
24
  async createEntities(entities) {
170
- const graph = await this.loadGraph();
25
+ await this.ensureInitialized();
26
+ const graph = await this.storage.loadGraph();
171
27
  // Entity names are globally unique across all threads in the collaborative knowledge graph
172
28
  // This prevents duplicate entities while allowing multiple threads to contribute to the same entity
173
29
  const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
174
30
  graph.entities.push(...newEntities);
175
- await this.saveGraph(graph);
31
+ await this.storage.saveGraph(graph);
176
32
  return newEntities;
177
33
  }
178
34
  async createRelations(relations) {
179
- const graph = await this.loadGraph();
35
+ const graph = await this.storage.loadGraph();
180
36
  // Validate that referenced entities exist
181
37
  const entityNames = new Set(graph.entities.map(e => e.name));
182
38
  const validRelations = relations.filter(r => {
@@ -192,11 +48,11 @@ export class KnowledgeGraphManager {
192
48
  existingRelation.to === r.to &&
193
49
  existingRelation.relationType === r.relationType));
194
50
  graph.relations.push(...newRelations);
195
- await this.saveGraph(graph);
51
+ await this.storage.saveGraph(graph);
196
52
  return newRelations;
197
53
  }
198
54
  async addObservations(observations) {
199
- const graph = await this.loadGraph();
55
+ const graph = await this.storage.loadGraph();
200
56
  const results = observations.map(o => {
201
57
  const entity = graph.entities.find(e => e.name === o.entityName);
202
58
  if (!entity) {
@@ -231,17 +87,17 @@ export class KnowledgeGraphManager {
231
87
  entity.importance = Math.max(entity.importance, o.importance);
232
88
  return { entityName: o.entityName, addedObservations: newObservations };
233
89
  });
234
- await this.saveGraph(graph);
90
+ await this.storage.saveGraph(graph);
235
91
  return results;
236
92
  }
237
93
  async deleteEntities(entityNames) {
238
- const graph = await this.loadGraph();
94
+ const graph = await this.storage.loadGraph();
239
95
  graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
240
96
  graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
241
- await this.saveGraph(graph);
97
+ await this.storage.saveGraph(graph);
242
98
  }
243
99
  async deleteObservations(deletions) {
244
- const graph = await this.loadGraph();
100
+ const graph = await this.storage.loadGraph();
245
101
  deletions.forEach(d => {
246
102
  const entity = graph.entities.find(e => e.name === d.entityName);
247
103
  if (entity) {
@@ -249,22 +105,22 @@ export class KnowledgeGraphManager {
249
105
  entity.observations = entity.observations.filter(o => !d.observations.includes(o.content) && !d.observations.includes(o.id));
250
106
  }
251
107
  });
252
- await this.saveGraph(graph);
108
+ await this.storage.saveGraph(graph);
253
109
  }
254
110
  async deleteRelations(relations) {
255
- const graph = await this.loadGraph();
111
+ const graph = await this.storage.loadGraph();
256
112
  // Delete relations globally across all threads by matching (from, to, relationType)
257
113
  // In a collaborative knowledge graph, deletions affect all threads
258
114
  graph.relations = graph.relations.filter(r => !relations.some(delRelation => r.from === delRelation.from &&
259
115
  r.to === delRelation.to &&
260
116
  r.relationType === delRelation.relationType));
261
- await this.saveGraph(graph);
117
+ await this.storage.saveGraph(graph);
262
118
  }
263
119
  async readGraph() {
264
- return this.loadGraph();
120
+ return this.storage.loadGraph();
265
121
  }
266
122
  async searchNodes(query) {
267
- const graph = await this.loadGraph();
123
+ const graph = await this.storage.loadGraph();
268
124
  // Filter entities
269
125
  const filteredEntities = graph.entities.filter(e => e.name.toLowerCase().includes(query.toLowerCase()) ||
270
126
  e.entityType.toLowerCase().includes(query.toLowerCase()) ||
@@ -280,7 +136,7 @@ export class KnowledgeGraphManager {
280
136
  return filteredGraph;
281
137
  }
282
138
  async openNodes(names) {
283
- const graph = await this.loadGraph();
139
+ const graph = await this.storage.loadGraph();
284
140
  // Filter entities
285
141
  const filteredEntities = graph.entities.filter(e => names.includes(e.name));
286
142
  // Create a Set of filtered entity names for quick lookup
@@ -294,7 +150,7 @@ export class KnowledgeGraphManager {
294
150
  return filteredGraph;
295
151
  }
296
152
  async queryNodes(filters) {
297
- const graph = await this.loadGraph();
153
+ const graph = await this.storage.loadGraph();
298
154
  // If no filters provided, return entire graph
299
155
  if (!filters) {
300
156
  return graph;
@@ -349,7 +205,7 @@ export class KnowledgeGraphManager {
349
205
  }
350
206
  // Enhancement 1: Memory Statistics & Insights
351
207
  async getMemoryStats() {
352
- const graph = await this.loadGraph();
208
+ const graph = await this.storage.loadGraph();
353
209
  // Count entity types
354
210
  const entityTypes = {};
355
211
  graph.entities.forEach(e => {
@@ -392,7 +248,7 @@ export class KnowledgeGraphManager {
392
248
  }
393
249
  // Enhancement 2: Get recent changes
394
250
  async getRecentChanges(since) {
395
- const graph = await this.loadGraph();
251
+ const graph = await this.storage.loadGraph();
396
252
  const sinceDate = new Date(since);
397
253
  // Only return entities and relations that were actually modified since the specified time
398
254
  const recentEntities = graph.entities.filter(e => new Date(e.timestamp) >= sinceDate);
@@ -405,7 +261,7 @@ export class KnowledgeGraphManager {
405
261
  }
406
262
  // Enhancement 3: Relationship path finding
407
263
  async findRelationPath(from, to, maxDepth = 5) {
408
- const graph = await this.loadGraph();
264
+ const graph = await this.storage.loadGraph();
409
265
  if (from === to) {
410
266
  return { found: true, path: [from], relations: [] };
411
267
  }
@@ -463,7 +319,7 @@ export class KnowledgeGraphManager {
463
319
  }
464
320
  // Enhancement 4: Detect conflicting observations
465
321
  async detectConflicts() {
466
- const graph = await this.loadGraph();
322
+ const graph = await this.storage.loadGraph();
467
323
  const conflicts = [];
468
324
  for (const entity of graph.entities) {
469
325
  const entityConflicts = [];
@@ -504,7 +360,7 @@ export class KnowledgeGraphManager {
504
360
  }
505
361
  // Enhancement 5: Memory pruning
506
362
  async pruneMemory(options) {
507
- const graph = await this.loadGraph();
363
+ const graph = await this.storage.loadGraph();
508
364
  const initialEntityCount = graph.entities.length;
509
365
  const initialRelationCount = graph.relations.length;
510
366
  // Filter entities to remove
@@ -534,7 +390,7 @@ export class KnowledgeGraphManager {
534
390
  const relationsToKeep = graph.relations.filter(r => keptEntityNames.has(r.from) && keptEntityNames.has(r.to));
535
391
  graph.entities = entitiesToKeep;
536
392
  graph.relations = relationsToKeep;
537
- await this.saveGraph(graph);
393
+ await this.storage.saveGraph(graph);
538
394
  return {
539
395
  removedEntities: initialEntityCount - entitiesToKeep.length,
540
396
  removedRelations: initialRelationCount - relationsToKeep.length
@@ -542,7 +398,7 @@ export class KnowledgeGraphManager {
542
398
  }
543
399
  // Enhancement 6: Batch operations
544
400
  async bulkUpdate(updates) {
545
- const graph = await this.loadGraph();
401
+ const graph = await this.storage.loadGraph();
546
402
  let updated = 0;
547
403
  const notFound = [];
548
404
  for (const update of updates) {
@@ -575,12 +431,12 @@ export class KnowledgeGraphManager {
575
431
  entity.timestamp = new Date().toISOString();
576
432
  updated++;
577
433
  }
578
- await this.saveGraph(graph);
434
+ await this.storage.saveGraph(graph);
579
435
  return { updated, notFound };
580
436
  }
581
437
  // Enhancement 7: Flag for review (Human-in-the-Loop)
582
438
  async flagForReview(entityName, reason, reviewer) {
583
- const graph = await this.loadGraph();
439
+ const graph = await this.storage.loadGraph();
584
440
  const entity = graph.entities.find(e => e.name === entityName);
585
441
  if (!entity) {
586
442
  throw new Error(`Entity with name ${entityName} not found`);
@@ -600,17 +456,17 @@ export class KnowledgeGraphManager {
600
456
  };
601
457
  entity.observations.push(flagObservation);
602
458
  entity.timestamp = new Date().toISOString();
603
- await this.saveGraph(graph);
459
+ await this.storage.saveGraph(graph);
604
460
  }
605
461
  }
606
462
  // Enhancement 8: Get entities flagged for review
607
463
  async getFlaggedEntities() {
608
- const graph = await this.loadGraph();
464
+ const graph = await this.storage.loadGraph();
609
465
  return graph.entities.filter(e => e.observations.some(obs => obs.content.includes('[FLAGGED FOR REVIEW:')));
610
466
  }
611
467
  // Enhancement 9: Get context (entities related to a topic/entity)
612
468
  async getContext(entityNames, depth = 1) {
613
- const graph = await this.loadGraph();
469
+ const graph = await this.storage.loadGraph();
614
470
  const contextEntityNames = new Set(entityNames);
615
471
  // Expand to include related entities up to specified depth
616
472
  for (let d = 0; d < depth; d++) {
@@ -635,7 +491,7 @@ export class KnowledgeGraphManager {
635
491
  }
636
492
  // Enhancement 10: List conversations (agent threads)
637
493
  async listConversations() {
638
- const graph = await this.loadGraph();
494
+ const graph = await this.storage.loadGraph();
639
495
  // Group data by agent thread
640
496
  const threadMap = new Map();
641
497
  // Collect entities by thread
@@ -673,7 +529,7 @@ export class KnowledgeGraphManager {
673
529
  }
674
530
  // Analytics: Get analytics for a specific thread (limited to 4 core metrics)
675
531
  async getAnalytics(threadId) {
676
- const graph = await this.loadGraph();
532
+ const graph = await this.storage.loadGraph();
677
533
  // Filter to thread-specific data
678
534
  const threadEntities = graph.entities.filter(e => e.agentThreadId === threadId);
679
535
  const threadRelations = graph.relations.filter(r => r.agentThreadId === threadId);
@@ -756,7 +612,7 @@ export class KnowledgeGraphManager {
756
612
  }
757
613
  // Observation Versioning: Get full history chain for an observation
758
614
  async getObservationHistory(entityName, observationId) {
759
- const graph = await this.loadGraph();
615
+ const graph = await this.storage.loadGraph();
760
616
  // Find the entity
761
617
  const entity = graph.entities.find(e => e.name === entityName);
762
618
  if (!entity) {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Neo4j Cypher Queries
3
+ *
4
+ * Centralizes all Cypher queries for the Neo4j storage adapter.
5
+ * Following Single Responsibility Principle - this module is responsible only for query definitions.
6
+ */
7
+ /**
8
+ * Constraint and index queries for schema initialization
9
+ */
10
+ export const SCHEMA_QUERIES = {
11
+ createUniqueConstraint: 'CREATE CONSTRAINT entity_name_unique IF NOT EXISTS FOR (e:Entity) REQUIRE e.name IS UNIQUE',
12
+ createEntityTypeIndex: 'CREATE INDEX entity_type_idx IF NOT EXISTS FOR (e:Entity) ON (e.entityType)',
13
+ createThreadIndex: 'CREATE INDEX entity_thread_idx IF NOT EXISTS FOR (e:Entity) ON (e.agentThreadId)',
14
+ createTimestampIndex: 'CREATE INDEX entity_timestamp_idx IF NOT EXISTS FOR (e:Entity) ON (e.timestamp)',
15
+ };
16
+ /**
17
+ * Entity queries
18
+ */
19
+ export const ENTITY_QUERIES = {
20
+ loadAll: `
21
+ MATCH (e:Entity)
22
+ RETURN e.name as name,
23
+ e.entityType as entityType,
24
+ e.observations as observations,
25
+ e.agentThreadId as agentThreadId,
26
+ e.timestamp as timestamp,
27
+ e.confidence as confidence,
28
+ e.importance as importance
29
+ `,
30
+ // Note: Using CREATE instead of MERGE is intentional here.
31
+ // The saveGraph() method first deletes all existing data, then creates fresh entities.
32
+ // This is a complete graph replacement operation, not an upsert.
33
+ // The unique constraint on entity names prevents duplicates within a single transaction.
34
+ create: `
35
+ CREATE (e:Entity {
36
+ name: $name,
37
+ entityType: $entityType,
38
+ observations: $observations,
39
+ agentThreadId: $agentThreadId,
40
+ timestamp: $timestamp,
41
+ confidence: $confidence,
42
+ importance: $importance
43
+ })
44
+ `,
45
+ };
46
+ /**
47
+ * Relation queries
48
+ */
49
+ export const RELATION_QUERIES = {
50
+ loadAll: `
51
+ MATCH (from:Entity)-[r:RELATES_TO]->(to:Entity)
52
+ RETURN from.name as from,
53
+ to.name as to,
54
+ r.relationType as relationType,
55
+ r.agentThreadId as agentThreadId,
56
+ r.timestamp as timestamp,
57
+ r.confidence as confidence,
58
+ r.importance as importance
59
+ `,
60
+ // Note: Using MATCH for both entities is intentional.
61
+ // The saveGraph() method ensures entities are created first, then relations.
62
+ // Since this runs in a transaction after entity creation, both entities must exist.
63
+ // If either entity doesn't exist, the relation creation will fail the transaction,
64
+ // maintaining data integrity (no orphaned relations).
65
+ create: `
66
+ MATCH (from:Entity {name: $from})
67
+ MATCH (to:Entity {name: $to})
68
+ CREATE (from)-[r:RELATES_TO {
69
+ relationType: $relationType,
70
+ agentThreadId: $agentThreadId,
71
+ timestamp: $timestamp,
72
+ confidence: $confidence,
73
+ importance: $importance
74
+ }]->(to)
75
+ `,
76
+ };
77
+ /**
78
+ * Maintenance queries
79
+ */
80
+ export const MAINTENANCE_QUERIES = {
81
+ deleteAll: 'MATCH (n:Entity) DETACH DELETE n',
82
+ };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Neo4j Storage Adapter
3
+ *
4
+ * Production-ready implementation of the Neo4j storage adapter.
5
+ * Provides full CRUD operations for the knowledge graph using Neo4j.
6
+ *
7
+ * SOLID Principles Applied:
8
+ * - Single Responsibility: Adapter only handles Neo4j storage operations
9
+ * - Open/Closed: Can be extended without modification through IStorageAdapter
10
+ * - Liskov Substitution: Can replace any IStorageAdapter implementation
11
+ * - Interface Segregation: Implements minimal IStorageAdapter interface
12
+ * - Dependency Inversion: Depends on IStorageAdapter abstraction
13
+ *
14
+ * Example usage:
15
+ * ```typescript
16
+ * import { Neo4jStorageAdapter } from './neo4j-storage-adapter.js';
17
+ * import { KnowledgeGraphManager } from './knowledge-graph-manager.js';
18
+ *
19
+ * const neo4jAdapter = new Neo4jStorageAdapter({
20
+ * uri: 'neo4j://localhost:7687',
21
+ * username: 'neo4j',
22
+ * password: 'password'
23
+ * });
24
+ *
25
+ * await neo4jAdapter.initialize();
26
+ * const manager = new KnowledgeGraphManager('', neo4jAdapter);
27
+ * ```
28
+ */
29
+ import neo4j from 'neo4j-driver';
30
+ import { SCHEMA_QUERIES, ENTITY_QUERIES, RELATION_QUERIES, MAINTENANCE_QUERIES } from './neo4j-queries.js';
31
+ import { NEO4J_ERROR_MESSAGES } from './storage-config.js';
32
+ /**
33
+ * Neo4j-based storage adapter for the knowledge graph.
34
+ * Follows Single Responsibility Principle - only handles Neo4j storage operations.
35
+ */
36
+ export class Neo4jStorageAdapter {
37
+ config;
38
+ driver = null;
39
+ constructor(config) {
40
+ this.config = config;
41
+ }
42
+ /**
43
+ * Initialize Neo4j connection and schema.
44
+ * Creates constraints and indexes for optimal performance.
45
+ */
46
+ async initialize() {
47
+ try {
48
+ await this.initializeDriver();
49
+ await this.verifyConnection();
50
+ await this.initializeSchema();
51
+ }
52
+ catch (error) {
53
+ throw new Error(`${NEO4J_ERROR_MESSAGES.CONNECTION_FAILED}: ${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ }
56
+ /**
57
+ * Initialize the Neo4j driver.
58
+ * Extracted for better testability and separation of concerns.
59
+ */
60
+ async initializeDriver() {
61
+ this.driver = neo4j.driver(this.config.uri, neo4j.auth.basic(this.config.username, this.config.password));
62
+ }
63
+ /**
64
+ * Verify connection to Neo4j.
65
+ * Extracted for better error handling and testability.
66
+ */
67
+ async verifyConnection() {
68
+ if (!this.driver) {
69
+ throw new Error(NEO4J_ERROR_MESSAGES.NOT_INITIALIZED);
70
+ }
71
+ await this.driver.verifyConnectivity();
72
+ }
73
+ /**
74
+ * Initialize database schema (constraints and indexes).
75
+ * Extracted for Single Responsibility Principle.
76
+ */
77
+ async initializeSchema() {
78
+ const session = await this.createSession();
79
+ try {
80
+ await session.run(SCHEMA_QUERIES.createUniqueConstraint);
81
+ await session.run(SCHEMA_QUERIES.createEntityTypeIndex);
82
+ await session.run(SCHEMA_QUERIES.createThreadIndex);
83
+ await session.run(SCHEMA_QUERIES.createTimestampIndex);
84
+ }
85
+ finally {
86
+ await session.close();
87
+ }
88
+ }
89
+ /**
90
+ * Create a Neo4j session.
91
+ * Centralized session creation for DRY principle.
92
+ */
93
+ async createSession() {
94
+ this.ensureDriverInitialized();
95
+ return this.driver.session({ database: this.config.database });
96
+ }
97
+ /**
98
+ * Ensure driver is initialized.
99
+ * Guard clause for better error handling.
100
+ */
101
+ ensureDriverInitialized() {
102
+ if (!this.driver) {
103
+ throw new Error(NEO4J_ERROR_MESSAGES.NOT_INITIALIZED);
104
+ }
105
+ }
106
+ /**
107
+ * Serialize observations for Neo4j storage.
108
+ * Extracted for testability and reusability (DRY).
109
+ */
110
+ serializeObservations(observations) {
111
+ return JSON.stringify(observations);
112
+ }
113
+ /**
114
+ * Deserialize observations from Neo4j storage.
115
+ * Extracted for testability and reusability (DRY).
116
+ * Returns empty array on parse error for robustness.
117
+ */
118
+ deserializeObservations(observationsJson) {
119
+ try {
120
+ return JSON.parse(observationsJson);
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ /**
127
+ * Map Neo4j record to Entity object.
128
+ * Extracted for Single Responsibility Principle and DRY.
129
+ */
130
+ mapRecordToEntity(record) {
131
+ return {
132
+ name: record.get('name'),
133
+ entityType: record.get('entityType'),
134
+ observations: this.deserializeObservations(record.get('observations')),
135
+ agentThreadId: record.get('agentThreadId'),
136
+ timestamp: record.get('timestamp'),
137
+ confidence: record.get('confidence'),
138
+ importance: record.get('importance')
139
+ };
140
+ }
141
+ /**
142
+ * Map Neo4j record to Relation object.
143
+ * Extracted for Single Responsibility Principle and DRY.
144
+ */
145
+ mapRecordToRelation(record) {
146
+ return {
147
+ from: record.get('from'),
148
+ to: record.get('to'),
149
+ relationType: record.get('relationType'),
150
+ agentThreadId: record.get('agentThreadId'),
151
+ timestamp: record.get('timestamp'),
152
+ confidence: record.get('confidence'),
153
+ importance: record.get('importance')
154
+ };
155
+ }
156
+ /**
157
+ * Load the complete knowledge graph from Neo4j.
158
+ * Delegates to specialized methods for clarity.
159
+ */
160
+ async loadGraph() {
161
+ this.ensureDriverInitialized();
162
+ const session = await this.createSession();
163
+ try {
164
+ const entities = await this.loadEntities(session);
165
+ const relations = await this.loadRelations(session);
166
+ return { entities, relations };
167
+ }
168
+ finally {
169
+ await session.close();
170
+ }
171
+ }
172
+ /**
173
+ * Load all entities from Neo4j.
174
+ * Extracted for Single Responsibility Principle.
175
+ */
176
+ async loadEntities(session) {
177
+ const result = await session.run(ENTITY_QUERIES.loadAll);
178
+ return result.records.map(record => this.mapRecordToEntity(record));
179
+ }
180
+ /**
181
+ * Load all relations from Neo4j.
182
+ * Extracted for Single Responsibility Principle.
183
+ */
184
+ async loadRelations(session) {
185
+ const result = await session.run(RELATION_QUERIES.loadAll);
186
+ return result.records.map(record => this.mapRecordToRelation(record));
187
+ }
188
+ /**
189
+ * Save the complete knowledge graph to Neo4j.
190
+ * Uses transactions for atomicity.
191
+ */
192
+ async saveGraph(graph) {
193
+ this.ensureDriverInitialized();
194
+ const session = await this.createSession();
195
+ try {
196
+ await session.executeWrite(async (tx) => {
197
+ await this.clearDatabase(tx);
198
+ await this.saveEntities(tx, graph.entities);
199
+ await this.saveRelations(tx, graph.relations);
200
+ });
201
+ }
202
+ finally {
203
+ await session.close();
204
+ }
205
+ }
206
+ /**
207
+ * Clear all data from the database.
208
+ * Extracted for Single Responsibility Principle.
209
+ */
210
+ async clearDatabase(tx) {
211
+ await tx.run(MAINTENANCE_QUERIES.deleteAll);
212
+ }
213
+ /**
214
+ * Save all entities to Neo4j.
215
+ * Extracted for Single Responsibility Principle and testability.
216
+ */
217
+ async saveEntities(tx, entities) {
218
+ for (const entity of entities) {
219
+ await this.saveEntity(tx, entity);
220
+ }
221
+ }
222
+ /**
223
+ * Save a single entity to Neo4j.
224
+ * Extracted for DRY and testability.
225
+ */
226
+ async saveEntity(tx, entity) {
227
+ await tx.run(ENTITY_QUERIES.create, {
228
+ name: entity.name,
229
+ entityType: entity.entityType,
230
+ observations: this.serializeObservations(entity.observations),
231
+ agentThreadId: entity.agentThreadId,
232
+ timestamp: entity.timestamp,
233
+ confidence: entity.confidence,
234
+ importance: entity.importance
235
+ });
236
+ }
237
+ /**
238
+ * Save all relations to Neo4j.
239
+ * Extracted for Single Responsibility Principle and testability.
240
+ */
241
+ async saveRelations(tx, relations) {
242
+ for (const relation of relations) {
243
+ await this.saveRelation(tx, relation);
244
+ }
245
+ }
246
+ /**
247
+ * Save a single relation to Neo4j.
248
+ * Extracted for DRY and testability.
249
+ */
250
+ async saveRelation(tx, relation) {
251
+ await tx.run(RELATION_QUERIES.create, {
252
+ from: relation.from,
253
+ to: relation.to,
254
+ relationType: relation.relationType,
255
+ agentThreadId: relation.agentThreadId,
256
+ timestamp: relation.timestamp,
257
+ confidence: relation.confidence,
258
+ importance: relation.importance
259
+ });
260
+ }
261
+ /**
262
+ * Close Neo4j connection.
263
+ * Properly cleans up resources.
264
+ */
265
+ async close() {
266
+ if (this.driver) {
267
+ await this.driver.close();
268
+ this.driver = null;
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Storage Configuration Constants
3
+ *
4
+ * Centralizes all configuration constants for storage adapters.
5
+ * Following DRY principle - define once, use everywhere.
6
+ */
7
+ /**
8
+ * Environment variable names for Neo4j configuration
9
+ */
10
+ export const NEO4J_ENV_VARS = {
11
+ URI: 'NEO4J_URI',
12
+ USERNAME: 'NEO4J_USERNAME',
13
+ PASSWORD: 'NEO4J_PASSWORD',
14
+ DATABASE: 'NEO4J_DATABASE',
15
+ };
16
+ /**
17
+ * Default Neo4j configuration values
18
+ */
19
+ export const NEO4J_DEFAULTS = {
20
+ URI: 'neo4j://localhost:7687',
21
+ USERNAME: 'neo4j',
22
+ PASSWORD: 'testpassword',
23
+ };
24
+ /**
25
+ * Error messages for Neo4j storage
26
+ */
27
+ export const NEO4J_ERROR_MESSAGES = {
28
+ NOT_INITIALIZED: 'Neo4j driver not initialized. Call initialize() first.',
29
+ CONNECTION_FAILED: 'Failed to initialize Neo4j connection',
30
+ NOT_CONFIGURED: 'Neo4j not configured (NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD), using JSONL storage',
31
+ };
32
+ /**
33
+ * Log messages for storage selection
34
+ */
35
+ export const STORAGE_LOG_MESSAGES = {
36
+ ATTEMPTING_NEO4J: 'Attempting to connect to Neo4j at',
37
+ NEO4J_SUCCESS: 'Successfully connected to Neo4j storage',
38
+ NEO4J_FALLBACK: 'Failed to connect to Neo4j, falling back to JSONL storage:',
39
+ USING_JSONL: 'Using JSONL storage at',
40
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Storage interface abstraction for the Knowledge Graph
3
+ * This allows for different storage backends (JSONL, Neo4j, etc.)
4
+ */
5
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "server-memory-enhanced",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Enhanced MCP server for memory with agent threading, timestamps, and confidence scoring",
5
5
  "license": "MIT",
6
6
  "mcpName": "io.github.modelcontextprotocol/server-memory-enhanced",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@modelcontextprotocol/sdk": "^1.25.2",
27
+ "neo4j-driver": "^6.0.1",
27
28
  "zod": "^3.25.0"
28
29
  },
29
30
  "devDependencies": {