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 +102 -2
- package/dist/lib/jsonl-storage-adapter.js +315 -0
- package/dist/lib/knowledge-graph-manager.js +46 -190
- package/dist/lib/neo4j-queries.js +82 -0
- package/dist/lib/neo4j-storage-adapter.js +271 -0
- package/dist/lib/storage-config.js +40 -0
- package/dist/lib/storage-interface.js +5 -0
- package/package.json +2 -1
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
|
-
//
|
|
495
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "server-memory-enhanced",
|
|
3
|
-
"version": "2.
|
|
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": {
|