@levalicious/server-memory 0.0.9 → 0.0.10
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/server.js +85 -33
- package/dist/tests/memory-server.test.js +352 -59
- package/package.json +3 -3
package/dist/server.js
CHANGED
|
@@ -14,6 +14,44 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
|
|
|
14
14
|
? process.env.MEMORY_FILE_PATH
|
|
15
15
|
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
|
|
16
16
|
: defaultMemoryPath;
|
|
17
|
+
/**
|
|
18
|
+
* Sort entities by the specified field and direction.
|
|
19
|
+
* Returns a new array (does not mutate input).
|
|
20
|
+
* If sortBy is undefined, returns the original array (no sorting - preserves insertion order).
|
|
21
|
+
*/
|
|
22
|
+
function sortEntities(entities, sortBy, sortDir) {
|
|
23
|
+
if (!sortBy)
|
|
24
|
+
return entities; // No sorting - preserve current behavior
|
|
25
|
+
const dir = sortDir ?? (sortBy === "name" ? "asc" : "desc");
|
|
26
|
+
const mult = dir === "asc" ? 1 : -1;
|
|
27
|
+
return [...entities].sort((a, b) => {
|
|
28
|
+
if (sortBy === "name") {
|
|
29
|
+
return mult * a.name.localeCompare(b.name);
|
|
30
|
+
}
|
|
31
|
+
// For timestamps, treat undefined as 0 (oldest)
|
|
32
|
+
const aVal = a[sortBy] ?? 0;
|
|
33
|
+
const bVal = b[sortBy] ?? 0;
|
|
34
|
+
return mult * (aVal - bVal);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Sort neighbors by the specified field and direction.
|
|
39
|
+
* If sortBy is undefined, returns the original array (no sorting).
|
|
40
|
+
*/
|
|
41
|
+
function sortNeighbors(neighbors, sortBy, sortDir) {
|
|
42
|
+
if (!sortBy)
|
|
43
|
+
return neighbors;
|
|
44
|
+
const dir = sortDir ?? (sortBy === "name" ? "asc" : "desc");
|
|
45
|
+
const mult = dir === "asc" ? 1 : -1;
|
|
46
|
+
return [...neighbors].sort((a, b) => {
|
|
47
|
+
if (sortBy === "name") {
|
|
48
|
+
return mult * a.name.localeCompare(b.name);
|
|
49
|
+
}
|
|
50
|
+
const aVal = a[sortBy] ?? 0;
|
|
51
|
+
const bVal = b[sortBy] ?? 0;
|
|
52
|
+
return mult * (aVal - bVal);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
17
55
|
export const MAX_CHARS = 2048;
|
|
18
56
|
function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
|
|
19
57
|
const result = [];
|
|
@@ -254,7 +292,7 @@ export class KnowledgeGraphManager {
|
|
|
254
292
|
});
|
|
255
293
|
}
|
|
256
294
|
// Regex-based search function
|
|
257
|
-
async searchNodes(query) {
|
|
295
|
+
async searchNodes(query, sortBy, sortDir) {
|
|
258
296
|
const graph = await this.loadGraph();
|
|
259
297
|
let regex;
|
|
260
298
|
try {
|
|
@@ -272,7 +310,7 @@ export class KnowledgeGraphManager {
|
|
|
272
310
|
// Filter relations to only include those between filtered entities
|
|
273
311
|
const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
|
|
274
312
|
const filteredGraph = {
|
|
275
|
-
entities: filteredEntities,
|
|
313
|
+
entities: sortEntities(filteredEntities, sortBy, sortDir),
|
|
276
314
|
relations: filteredRelations,
|
|
277
315
|
};
|
|
278
316
|
return filteredGraph;
|
|
@@ -305,25 +343,21 @@ export class KnowledgeGraphManager {
|
|
|
305
343
|
};
|
|
306
344
|
return filteredGraph;
|
|
307
345
|
}
|
|
308
|
-
async getNeighbors(entityName, depth = 1,
|
|
346
|
+
async getNeighbors(entityName, depth = 1, sortBy, sortDir) {
|
|
309
347
|
const graph = await this.loadGraph();
|
|
310
348
|
const visited = new Set();
|
|
311
|
-
const
|
|
312
|
-
const resultRelations = new Map(); // Deduplicate relations
|
|
313
|
-
const relationKey = (r) => `${r.from}|${r.relationType}|${r.to}`;
|
|
349
|
+
const neighborNames = new Set();
|
|
314
350
|
const traverse = (currentName, currentDepth) => {
|
|
315
351
|
if (currentDepth > depth || visited.has(currentName))
|
|
316
352
|
return;
|
|
317
353
|
visited.add(currentName);
|
|
318
|
-
if (withEntities) {
|
|
319
|
-
const entity = graph.entities.find(e => e.name === currentName);
|
|
320
|
-
if (entity) {
|
|
321
|
-
resultEntities.set(currentName, entity);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
354
|
// Find all relations involving this entity
|
|
325
355
|
const connectedRelations = graph.relations.filter(r => r.from === currentName || r.to === currentName);
|
|
326
|
-
|
|
356
|
+
// Collect neighbor names
|
|
357
|
+
connectedRelations.forEach(r => {
|
|
358
|
+
const neighborName = r.from === currentName ? r.to : r.from;
|
|
359
|
+
neighborNames.add(neighborName);
|
|
360
|
+
});
|
|
327
361
|
if (currentDepth < depth) {
|
|
328
362
|
// Traverse to connected entities
|
|
329
363
|
connectedRelations.forEach(r => {
|
|
@@ -333,10 +367,19 @@ export class KnowledgeGraphManager {
|
|
|
333
367
|
}
|
|
334
368
|
};
|
|
335
369
|
traverse(entityName, 0);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
370
|
+
// Remove the starting entity from neighbors (it's not its own neighbor)
|
|
371
|
+
neighborNames.delete(entityName);
|
|
372
|
+
// Build neighbor objects with timestamps
|
|
373
|
+
const entityMap = new Map(graph.entities.map(e => [e.name, e]));
|
|
374
|
+
const neighbors = Array.from(neighborNames).map(name => {
|
|
375
|
+
const entity = entityMap.get(name);
|
|
376
|
+
return {
|
|
377
|
+
name,
|
|
378
|
+
mtime: entity?.mtime,
|
|
379
|
+
obsMtime: entity?.obsMtime,
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
return sortNeighbors(neighbors, sortBy, sortDir);
|
|
340
383
|
}
|
|
341
384
|
async findPath(fromEntity, toEntity, maxDepth = 5) {
|
|
342
385
|
const graph = await this.loadGraph();
|
|
@@ -358,9 +401,10 @@ export class KnowledgeGraphManager {
|
|
|
358
401
|
};
|
|
359
402
|
return dfs(fromEntity, toEntity, [], 0) || [];
|
|
360
403
|
}
|
|
361
|
-
async getEntitiesByType(entityType) {
|
|
404
|
+
async getEntitiesByType(entityType, sortBy, sortDir) {
|
|
362
405
|
const graph = await this.loadGraph();
|
|
363
|
-
|
|
406
|
+
const filtered = graph.entities.filter(e => e.entityType === entityType);
|
|
407
|
+
return sortEntities(filtered, sortBy, sortDir);
|
|
364
408
|
}
|
|
365
409
|
async getEntityTypes() {
|
|
366
410
|
const graph = await this.loadGraph();
|
|
@@ -383,7 +427,7 @@ export class KnowledgeGraphManager {
|
|
|
383
427
|
relationTypes: relationTypes.size
|
|
384
428
|
};
|
|
385
429
|
}
|
|
386
|
-
async getOrphanedEntities(strict = false) {
|
|
430
|
+
async getOrphanedEntities(strict = false, sortBy, sortDir) {
|
|
387
431
|
const graph = await this.loadGraph();
|
|
388
432
|
if (!strict) {
|
|
389
433
|
// Simple mode: entities with no relations at all
|
|
@@ -392,7 +436,8 @@ export class KnowledgeGraphManager {
|
|
|
392
436
|
connectedEntityNames.add(r.from);
|
|
393
437
|
connectedEntityNames.add(r.to);
|
|
394
438
|
});
|
|
395
|
-
|
|
439
|
+
const orphans = graph.entities.filter(e => !connectedEntityNames.has(e.name));
|
|
440
|
+
return sortEntities(orphans, sortBy, sortDir);
|
|
396
441
|
}
|
|
397
442
|
// Strict mode: entities not connected to "Self" (directly or indirectly)
|
|
398
443
|
// Build adjacency list (bidirectional)
|
|
@@ -420,7 +465,8 @@ export class KnowledgeGraphManager {
|
|
|
420
465
|
}
|
|
421
466
|
}
|
|
422
467
|
// Return entities not connected to Self (excluding Self itself if it exists)
|
|
423
|
-
|
|
468
|
+
const orphans = graph.entities.filter(e => !connectedToSelf.has(e.name));
|
|
469
|
+
return sortEntities(orphans, sortBy, sortDir);
|
|
424
470
|
}
|
|
425
471
|
async validateGraph() {
|
|
426
472
|
const graph = await this.loadGraph();
|
|
@@ -668,7 +714,7 @@ export function createServer(memoryFilePath) {
|
|
|
668
714
|
sizes: ["any"]
|
|
669
715
|
}
|
|
670
716
|
],
|
|
671
|
-
version: "0.0.
|
|
717
|
+
version: "0.0.10",
|
|
672
718
|
}, {
|
|
673
719
|
capabilities: {
|
|
674
720
|
tools: {},
|
|
@@ -821,6 +867,8 @@ export function createServer(memoryFilePath) {
|
|
|
821
867
|
type: "object",
|
|
822
868
|
properties: {
|
|
823
869
|
query: { type: "string", description: "Regex pattern to match against entity names, types, and observations." },
|
|
870
|
+
sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for entities. Omit for insertion order." },
|
|
871
|
+
sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
|
|
824
872
|
entityCursor: { type: "number", description: "Cursor for entity pagination (from previous response's nextCursor)" },
|
|
825
873
|
relationCursor: { type: "number", description: "Cursor for relation pagination" },
|
|
826
874
|
},
|
|
@@ -863,15 +911,15 @@ export function createServer(memoryFilePath) {
|
|
|
863
911
|
},
|
|
864
912
|
{
|
|
865
913
|
name: "get_neighbors",
|
|
866
|
-
description: "Get neighboring entities connected to a specific entity within a given depth. Results are paginated (max 512 chars).",
|
|
914
|
+
description: "Get names of neighboring entities connected to a specific entity within a given depth. Returns neighbor names with timestamps for sorting. Use open_nodes to get full entity data. Results are paginated (max 512 chars).",
|
|
867
915
|
inputSchema: {
|
|
868
916
|
type: "object",
|
|
869
917
|
properties: {
|
|
870
918
|
entityName: { type: "string", description: "The name of the entity to find neighbors for" },
|
|
871
|
-
depth: { type: "number", description: "Maximum depth to traverse (default:
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
919
|
+
depth: { type: "number", description: "Maximum depth to traverse (default: 1)", default: 1 },
|
|
920
|
+
sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for neighbors. Omit for arbitrary order." },
|
|
921
|
+
sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
|
|
922
|
+
cursor: { type: "number", description: "Cursor for pagination" },
|
|
875
923
|
},
|
|
876
924
|
required: ["entityName"],
|
|
877
925
|
},
|
|
@@ -897,6 +945,8 @@ export function createServer(memoryFilePath) {
|
|
|
897
945
|
type: "object",
|
|
898
946
|
properties: {
|
|
899
947
|
entityType: { type: "string", description: "The type of entities to retrieve" },
|
|
948
|
+
sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for entities. Omit for insertion order." },
|
|
949
|
+
sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
|
|
900
950
|
cursor: { type: "number", description: "Cursor for pagination" },
|
|
901
951
|
},
|
|
902
952
|
required: ["entityType"],
|
|
@@ -933,6 +983,8 @@ export function createServer(memoryFilePath) {
|
|
|
933
983
|
type: "object",
|
|
934
984
|
properties: {
|
|
935
985
|
strict: { type: "boolean", description: "If true, returns entities not connected to 'Self' (directly or indirectly). Default: false" },
|
|
986
|
+
sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for entities. Omit for insertion order." },
|
|
987
|
+
sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
|
|
936
988
|
cursor: { type: "number", description: "Cursor for pagination" },
|
|
937
989
|
},
|
|
938
990
|
},
|
|
@@ -1026,7 +1078,7 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
|
|
|
1026
1078
|
await knowledgeGraphManager.deleteRelations(args.relations);
|
|
1027
1079
|
return { content: [{ type: "text", text: "Relations deleted successfully" }] };
|
|
1028
1080
|
case "search_nodes": {
|
|
1029
|
-
const graph = await knowledgeGraphManager.searchNodes(args.query);
|
|
1081
|
+
const graph = await knowledgeGraphManager.searchNodes(args.query, args.sortBy, args.sortDir);
|
|
1030
1082
|
return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
|
|
1031
1083
|
}
|
|
1032
1084
|
case "open_nodes_filtered": {
|
|
@@ -1038,15 +1090,15 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
|
|
|
1038
1090
|
return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
|
|
1039
1091
|
}
|
|
1040
1092
|
case "get_neighbors": {
|
|
1041
|
-
const
|
|
1042
|
-
return { content: [{ type: "text", text: JSON.stringify(
|
|
1093
|
+
const neighbors = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth ?? 1, args.sortBy, args.sortDir);
|
|
1094
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateItems(neighbors, args.cursor ?? 0)) }] };
|
|
1043
1095
|
}
|
|
1044
1096
|
case "find_path": {
|
|
1045
1097
|
const path = await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth);
|
|
1046
1098
|
return { content: [{ type: "text", text: JSON.stringify(paginateItems(path, args.cursor ?? 0)) }] };
|
|
1047
1099
|
}
|
|
1048
1100
|
case "get_entities_by_type": {
|
|
1049
|
-
const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType);
|
|
1101
|
+
const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType, args.sortBy, args.sortDir);
|
|
1050
1102
|
return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
|
|
1051
1103
|
}
|
|
1052
1104
|
case "get_entity_types":
|
|
@@ -1056,7 +1108,7 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
|
|
|
1056
1108
|
case "get_stats":
|
|
1057
1109
|
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
|
|
1058
1110
|
case "get_orphaned_entities": {
|
|
1059
|
-
const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false);
|
|
1111
|
+
const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false, args.sortBy, args.sortDir);
|
|
1060
1112
|
return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
|
|
1061
1113
|
}
|
|
1062
1114
|
case "validate_graph":
|
|
@@ -283,64 +283,43 @@ describe('MCP Memory Server E2E Tests', () => {
|
|
|
283
283
|
]
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
|
-
it('should get neighbors at depth 0
|
|
286
|
+
it('should get immediate neighbors at depth 0', async () => {
|
|
287
287
|
const result = await callTool(client, 'get_neighbors', {
|
|
288
288
|
entityName: 'Root',
|
|
289
289
|
depth: 0
|
|
290
290
|
});
|
|
291
|
-
|
|
292
|
-
expect(result.
|
|
291
|
+
// depth 0 returns immediate neighbors only
|
|
292
|
+
expect(result.items).toHaveLength(2);
|
|
293
|
+
const names = result.items.map(n => n.name);
|
|
294
|
+
expect(names).toContain('Child1');
|
|
295
|
+
expect(names).toContain('Child2');
|
|
293
296
|
});
|
|
294
|
-
it('should get neighbors
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
allEntities.push(...result.entities.items);
|
|
306
|
-
entityCursor = result.entities.nextCursor;
|
|
307
|
-
}
|
|
308
|
-
expect(allEntities).toHaveLength(3);
|
|
309
|
-
expect(allEntities.map(e => e.name)).toContain('Root');
|
|
310
|
-
expect(allEntities.map(e => e.name)).toContain('Child1');
|
|
311
|
-
expect(allEntities.map(e => e.name)).toContain('Child2');
|
|
297
|
+
it('should get neighbors at depth 1 (includes neighbors of neighbors)', async () => {
|
|
298
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
299
|
+
entityName: 'Root',
|
|
300
|
+
depth: 1
|
|
301
|
+
});
|
|
302
|
+
// depth 1: Child1, Child2 (immediate) + Grandchild (neighbor of Child1)
|
|
303
|
+
expect(result.items).toHaveLength(3);
|
|
304
|
+
const names = result.items.map(n => n.name);
|
|
305
|
+
expect(names).toContain('Child1');
|
|
306
|
+
expect(names).toContain('Child2');
|
|
307
|
+
expect(names).toContain('Grandchild');
|
|
312
308
|
});
|
|
313
309
|
it('should traverse to specified depth', async () => {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// Pass large cursor to skip when done, 0 to fetch
|
|
326
|
-
entityCursor: entityCursor !== null ? entityCursor : 999999,
|
|
327
|
-
relationCursor: relationCursor !== null ? relationCursor : 999999
|
|
328
|
-
});
|
|
329
|
-
// Collect entities if we still need them
|
|
330
|
-
if (entityCursor !== null) {
|
|
331
|
-
allEntities.push(...result.entities.items);
|
|
332
|
-
entityCursor = result.entities.nextCursor;
|
|
333
|
-
}
|
|
334
|
-
// Collect relations if we still need them
|
|
335
|
-
if (relationCursor !== null) {
|
|
336
|
-
allRelations.push(...result.relations.items);
|
|
337
|
-
relationCursor = result.relations.nextCursor;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
expect(allEntities).toHaveLength(4); // All nodes
|
|
341
|
-
expect(allRelations).toHaveLength(3); // All relations
|
|
310
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
311
|
+
entityName: 'Root',
|
|
312
|
+
depth: 2
|
|
313
|
+
});
|
|
314
|
+
// At depth 2 from Root: same as depth 1 since graph is small
|
|
315
|
+
// Child1, Child2, Grandchild (Root is excluded as starting point)
|
|
316
|
+
expect(result.items).toHaveLength(3);
|
|
317
|
+
const names = result.items.map(n => n.name);
|
|
318
|
+
expect(names).toContain('Child1');
|
|
319
|
+
expect(names).toContain('Child2');
|
|
320
|
+
expect(names).toContain('Grandchild');
|
|
342
321
|
});
|
|
343
|
-
it('should deduplicate
|
|
322
|
+
it('should deduplicate neighbors in traversal', async () => {
|
|
344
323
|
// Add a bidirectional relation
|
|
345
324
|
await callTool(client, 'create_relations', {
|
|
346
325
|
relations: [{ from: 'Child2', to: 'Root', relationType: 'child_of' }]
|
|
@@ -349,10 +328,10 @@ describe('MCP Memory Server E2E Tests', () => {
|
|
|
349
328
|
entityName: 'Root',
|
|
350
329
|
depth: 1
|
|
351
330
|
});
|
|
352
|
-
// Each
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
expect(
|
|
331
|
+
// Each neighbor should appear only once
|
|
332
|
+
const names = result.items.map(n => n.name);
|
|
333
|
+
const uniqueNames = [...new Set(names)];
|
|
334
|
+
expect(names.length).toBe(uniqueNames.length);
|
|
356
335
|
});
|
|
357
336
|
it('should find path between entities', async () => {
|
|
358
337
|
const result = await callTool(client, 'find_path', {
|
|
@@ -524,13 +503,13 @@ describe('MCP Memory Server E2E Tests', () => {
|
|
|
524
503
|
previousCtxId: first.ctxId,
|
|
525
504
|
observations: ['Following up']
|
|
526
505
|
});
|
|
527
|
-
// Verify the chain via
|
|
506
|
+
// Verify the chain via neighbors
|
|
528
507
|
const neighbors = await callTool(client, 'get_neighbors', {
|
|
529
508
|
entityName: first.ctxId,
|
|
530
509
|
depth: 1
|
|
531
510
|
});
|
|
532
|
-
//
|
|
533
|
-
expect(neighbors.
|
|
511
|
+
// Second thought should be a neighbor of first
|
|
512
|
+
expect(neighbors.items.some(n => n.name === second.ctxId)).toBe(true);
|
|
534
513
|
});
|
|
535
514
|
it('should ignore invalid previousCtxId gracefully', async () => {
|
|
536
515
|
const result = await callTool(client, 'sequentialthinking', {
|
|
@@ -538,12 +517,12 @@ describe('MCP Memory Server E2E Tests', () => {
|
|
|
538
517
|
observations: ['Orphaned thought']
|
|
539
518
|
});
|
|
540
519
|
expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
|
|
541
|
-
// Verify no relations were created
|
|
520
|
+
// Verify no neighbors (no valid relations were created)
|
|
542
521
|
const neighbors = await callTool(client, 'get_neighbors', {
|
|
543
522
|
entityName: result.ctxId,
|
|
544
523
|
depth: 1
|
|
545
524
|
});
|
|
546
|
-
expect(neighbors.
|
|
525
|
+
expect(neighbors.items).toHaveLength(0);
|
|
547
526
|
});
|
|
548
527
|
it('should enforce observation limits on thoughts', async () => {
|
|
549
528
|
await expect(callTool(client, 'sequentialthinking', {
|
|
@@ -562,4 +541,318 @@ describe('MCP Memory Server E2E Tests', () => {
|
|
|
562
541
|
expect(thought.obsMtime).toBeDefined();
|
|
563
542
|
});
|
|
564
543
|
});
|
|
544
|
+
describe('Sorting', () => {
|
|
545
|
+
// Helper to create entities with controlled timestamps
|
|
546
|
+
async function createEntitiesWithDelay(entities) {
|
|
547
|
+
for (const entity of entities) {
|
|
548
|
+
await callTool(client, 'create_entities', { entities: [entity] });
|
|
549
|
+
// Small delay to ensure distinct mtime values
|
|
550
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
describe('search_nodes sorting', () => {
|
|
554
|
+
beforeEach(async () => {
|
|
555
|
+
// Create entities with distinct timestamps
|
|
556
|
+
await createEntitiesWithDelay([
|
|
557
|
+
{ name: 'Alpha', entityType: 'Letter', observations: ['First letter'] },
|
|
558
|
+
{ name: 'Beta', entityType: 'Letter', observations: ['Second letter'] },
|
|
559
|
+
{ name: 'Gamma', entityType: 'Letter', observations: ['Third letter'] }
|
|
560
|
+
]);
|
|
561
|
+
});
|
|
562
|
+
it('should preserve insertion order when sortBy is omitted', async () => {
|
|
563
|
+
const result = await callTool(client, 'search_nodes', {
|
|
564
|
+
query: 'Letter'
|
|
565
|
+
});
|
|
566
|
+
const names = result.entities.items.map(e => e.name);
|
|
567
|
+
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
|
|
568
|
+
});
|
|
569
|
+
it('should sort by name ascending', async () => {
|
|
570
|
+
const result = await callTool(client, 'search_nodes', {
|
|
571
|
+
query: 'Letter',
|
|
572
|
+
sortBy: 'name',
|
|
573
|
+
sortDir: 'asc'
|
|
574
|
+
});
|
|
575
|
+
const names = result.entities.items.map(e => e.name);
|
|
576
|
+
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
|
|
577
|
+
});
|
|
578
|
+
it('should sort by name descending', async () => {
|
|
579
|
+
const result = await callTool(client, 'search_nodes', {
|
|
580
|
+
query: 'Letter',
|
|
581
|
+
sortBy: 'name',
|
|
582
|
+
sortDir: 'desc'
|
|
583
|
+
});
|
|
584
|
+
const names = result.entities.items.map(e => e.name);
|
|
585
|
+
expect(names).toEqual(['Gamma', 'Beta', 'Alpha']);
|
|
586
|
+
});
|
|
587
|
+
it('should sort by mtime descending by default', async () => {
|
|
588
|
+
const result = await callTool(client, 'search_nodes', {
|
|
589
|
+
query: 'Letter',
|
|
590
|
+
sortBy: 'mtime'
|
|
591
|
+
});
|
|
592
|
+
const names = result.entities.items.map(e => e.name);
|
|
593
|
+
// Gamma was created last, so should be first when sorted desc
|
|
594
|
+
expect(names).toEqual(['Gamma', 'Beta', 'Alpha']);
|
|
595
|
+
});
|
|
596
|
+
it('should sort by mtime ascending when specified', async () => {
|
|
597
|
+
const result = await callTool(client, 'search_nodes', {
|
|
598
|
+
query: 'Letter',
|
|
599
|
+
sortBy: 'mtime',
|
|
600
|
+
sortDir: 'asc'
|
|
601
|
+
});
|
|
602
|
+
const names = result.entities.items.map(e => e.name);
|
|
603
|
+
// Alpha was created first, so should be first when sorted asc
|
|
604
|
+
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
|
|
605
|
+
});
|
|
606
|
+
it('should sort by obsMtime', async () => {
|
|
607
|
+
// Update observation on Alpha to make it have most recent obsMtime
|
|
608
|
+
await callTool(client, 'delete_observations', {
|
|
609
|
+
deletions: [{ entityName: 'Alpha', observations: ['First letter'] }]
|
|
610
|
+
});
|
|
611
|
+
await callTool(client, 'add_observations', {
|
|
612
|
+
observations: [{ entityName: 'Alpha', contents: ['Updated'] }]
|
|
613
|
+
});
|
|
614
|
+
const result = await callTool(client, 'search_nodes', {
|
|
615
|
+
query: 'Letter|Updated',
|
|
616
|
+
sortBy: 'obsMtime'
|
|
617
|
+
});
|
|
618
|
+
const names = result.entities.items.map(e => e.name);
|
|
619
|
+
// Alpha should be first because its obsMtime was just updated
|
|
620
|
+
expect(names[0]).toBe('Alpha');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
describe('get_entities_by_type sorting', () => {
|
|
624
|
+
beforeEach(async () => {
|
|
625
|
+
await createEntitiesWithDelay([
|
|
626
|
+
{ name: 'Zebra', entityType: 'Animal', observations: ['Striped'] },
|
|
627
|
+
{ name: 'Aardvark', entityType: 'Animal', observations: ['Nocturnal'] },
|
|
628
|
+
{ name: 'Monkey', entityType: 'Animal', observations: ['Clever'] }
|
|
629
|
+
]);
|
|
630
|
+
});
|
|
631
|
+
it('should preserve insertion order when sortBy is omitted', async () => {
|
|
632
|
+
const result = await callTool(client, 'get_entities_by_type', {
|
|
633
|
+
entityType: 'Animal'
|
|
634
|
+
});
|
|
635
|
+
const names = result.items.map(e => e.name);
|
|
636
|
+
expect(names).toEqual(['Zebra', 'Aardvark', 'Monkey']);
|
|
637
|
+
});
|
|
638
|
+
it('should sort by name ascending (default for name)', async () => {
|
|
639
|
+
const result = await callTool(client, 'get_entities_by_type', {
|
|
640
|
+
entityType: 'Animal',
|
|
641
|
+
sortBy: 'name'
|
|
642
|
+
});
|
|
643
|
+
const names = result.items.map(e => e.name);
|
|
644
|
+
expect(names).toEqual(['Aardvark', 'Monkey', 'Zebra']);
|
|
645
|
+
});
|
|
646
|
+
it('should sort by name descending', async () => {
|
|
647
|
+
const result = await callTool(client, 'get_entities_by_type', {
|
|
648
|
+
entityType: 'Animal',
|
|
649
|
+
sortBy: 'name',
|
|
650
|
+
sortDir: 'desc'
|
|
651
|
+
});
|
|
652
|
+
const names = result.items.map(e => e.name);
|
|
653
|
+
expect(names).toEqual(['Zebra', 'Monkey', 'Aardvark']);
|
|
654
|
+
});
|
|
655
|
+
it('should sort by mtime descending (default for mtime)', async () => {
|
|
656
|
+
const result = await callTool(client, 'get_entities_by_type', {
|
|
657
|
+
entityType: 'Animal',
|
|
658
|
+
sortBy: 'mtime'
|
|
659
|
+
});
|
|
660
|
+
const names = result.items.map(e => e.name);
|
|
661
|
+
// Monkey was created last
|
|
662
|
+
expect(names).toEqual(['Monkey', 'Aardvark', 'Zebra']);
|
|
663
|
+
});
|
|
664
|
+
it('should sort by mtime ascending', async () => {
|
|
665
|
+
const result = await callTool(client, 'get_entities_by_type', {
|
|
666
|
+
entityType: 'Animal',
|
|
667
|
+
sortBy: 'mtime',
|
|
668
|
+
sortDir: 'asc'
|
|
669
|
+
});
|
|
670
|
+
const names = result.items.map(e => e.name);
|
|
671
|
+
// Zebra was created first
|
|
672
|
+
expect(names).toEqual(['Zebra', 'Aardvark', 'Monkey']);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
describe('get_orphaned_entities sorting', () => {
|
|
676
|
+
beforeEach(async () => {
|
|
677
|
+
// Create orphaned entities (no relations)
|
|
678
|
+
await createEntitiesWithDelay([
|
|
679
|
+
{ name: 'Orphan_Z', entityType: 'Orphan', observations: ['Alone'] },
|
|
680
|
+
{ name: 'Orphan_A', entityType: 'Orphan', observations: ['Solo'] },
|
|
681
|
+
{ name: 'Orphan_M', entityType: 'Orphan', observations: ['Isolated'] }
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
it('should preserve insertion order when sortBy is omitted', async () => {
|
|
685
|
+
const result = await callTool(client, 'get_orphaned_entities', {});
|
|
686
|
+
const names = result.items.map(e => e.name);
|
|
687
|
+
expect(names).toEqual(['Orphan_Z', 'Orphan_A', 'Orphan_M']);
|
|
688
|
+
});
|
|
689
|
+
it('should sort by name ascending', async () => {
|
|
690
|
+
const result = await callTool(client, 'get_orphaned_entities', {
|
|
691
|
+
sortBy: 'name'
|
|
692
|
+
});
|
|
693
|
+
const names = result.items.map(e => e.name);
|
|
694
|
+
expect(names).toEqual(['Orphan_A', 'Orphan_M', 'Orphan_Z']);
|
|
695
|
+
});
|
|
696
|
+
it('should sort by name descending', async () => {
|
|
697
|
+
const result = await callTool(client, 'get_orphaned_entities', {
|
|
698
|
+
sortBy: 'name',
|
|
699
|
+
sortDir: 'desc'
|
|
700
|
+
});
|
|
701
|
+
const names = result.items.map(e => e.name);
|
|
702
|
+
expect(names).toEqual(['Orphan_Z', 'Orphan_M', 'Orphan_A']);
|
|
703
|
+
});
|
|
704
|
+
it('should sort by mtime descending (default)', async () => {
|
|
705
|
+
const result = await callTool(client, 'get_orphaned_entities', {
|
|
706
|
+
sortBy: 'mtime'
|
|
707
|
+
});
|
|
708
|
+
const names = result.items.map(e => e.name);
|
|
709
|
+
// Orphan_M was created last
|
|
710
|
+
expect(names).toEqual(['Orphan_M', 'Orphan_A', 'Orphan_Z']);
|
|
711
|
+
});
|
|
712
|
+
it('should work with strict mode and sorting', async () => {
|
|
713
|
+
// Create Self and connect one orphan to it
|
|
714
|
+
await callTool(client, 'create_entities', {
|
|
715
|
+
entities: [{ name: 'Self', entityType: 'Agent', observations: [] }]
|
|
716
|
+
});
|
|
717
|
+
await callTool(client, 'create_relations', {
|
|
718
|
+
relations: [{ from: 'Self', to: 'Orphan_A', relationType: 'knows' }]
|
|
719
|
+
});
|
|
720
|
+
const result = await callTool(client, 'get_orphaned_entities', {
|
|
721
|
+
strict: true,
|
|
722
|
+
sortBy: 'name'
|
|
723
|
+
});
|
|
724
|
+
const names = result.items.map(e => e.name);
|
|
725
|
+
// Orphan_A is now connected to Self, so only M and Z are orphaned
|
|
726
|
+
expect(names).toEqual(['Orphan_M', 'Orphan_Z']);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
describe('get_neighbors sorting', () => {
|
|
730
|
+
beforeEach(async () => {
|
|
731
|
+
// Create a hub with neighbors created at different times
|
|
732
|
+
await callTool(client, 'create_entities', {
|
|
733
|
+
entities: [{ name: 'Hub', entityType: 'Center', observations: [] }]
|
|
734
|
+
});
|
|
735
|
+
await createEntitiesWithDelay([
|
|
736
|
+
{ name: 'Neighbor_Z', entityType: 'Node', observations: ['First'] },
|
|
737
|
+
{ name: 'Neighbor_A', entityType: 'Node', observations: ['Second'] },
|
|
738
|
+
{ name: 'Neighbor_M', entityType: 'Node', observations: ['Third'] }
|
|
739
|
+
]);
|
|
740
|
+
// Connect all to Hub
|
|
741
|
+
await callTool(client, 'create_relations', {
|
|
742
|
+
relations: [
|
|
743
|
+
{ from: 'Hub', to: 'Neighbor_Z', relationType: 'connects' },
|
|
744
|
+
{ from: 'Hub', to: 'Neighbor_A', relationType: 'connects' },
|
|
745
|
+
{ from: 'Hub', to: 'Neighbor_M', relationType: 'connects' }
|
|
746
|
+
]
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
it('should return unsorted neighbors when sortBy is omitted', async () => {
|
|
750
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
751
|
+
entityName: 'Hub'
|
|
752
|
+
});
|
|
753
|
+
expect(result.items).toHaveLength(3);
|
|
754
|
+
// Just verify all neighbors are present
|
|
755
|
+
const names = result.items.map(n => n.name);
|
|
756
|
+
expect(names).toContain('Neighbor_Z');
|
|
757
|
+
expect(names).toContain('Neighbor_A');
|
|
758
|
+
expect(names).toContain('Neighbor_M');
|
|
759
|
+
});
|
|
760
|
+
it('should sort neighbors by name ascending', async () => {
|
|
761
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
762
|
+
entityName: 'Hub',
|
|
763
|
+
sortBy: 'name'
|
|
764
|
+
});
|
|
765
|
+
const names = result.items.map(n => n.name);
|
|
766
|
+
expect(names).toEqual(['Neighbor_A', 'Neighbor_M', 'Neighbor_Z']);
|
|
767
|
+
});
|
|
768
|
+
it('should sort neighbors by name descending', async () => {
|
|
769
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
770
|
+
entityName: 'Hub',
|
|
771
|
+
sortBy: 'name',
|
|
772
|
+
sortDir: 'desc'
|
|
773
|
+
});
|
|
774
|
+
const names = result.items.map(n => n.name);
|
|
775
|
+
expect(names).toEqual(['Neighbor_Z', 'Neighbor_M', 'Neighbor_A']);
|
|
776
|
+
});
|
|
777
|
+
it('should sort neighbors by mtime descending (default)', async () => {
|
|
778
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
779
|
+
entityName: 'Hub',
|
|
780
|
+
sortBy: 'mtime'
|
|
781
|
+
});
|
|
782
|
+
const names = result.items.map(n => n.name);
|
|
783
|
+
// Neighbor_M was created last
|
|
784
|
+
expect(names).toEqual(['Neighbor_M', 'Neighbor_A', 'Neighbor_Z']);
|
|
785
|
+
});
|
|
786
|
+
it('should sort neighbors by mtime ascending', async () => {
|
|
787
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
788
|
+
entityName: 'Hub',
|
|
789
|
+
sortBy: 'mtime',
|
|
790
|
+
sortDir: 'asc'
|
|
791
|
+
});
|
|
792
|
+
const names = result.items.map(n => n.name);
|
|
793
|
+
// Neighbor_Z was created first
|
|
794
|
+
expect(names).toEqual(['Neighbor_Z', 'Neighbor_A', 'Neighbor_M']);
|
|
795
|
+
});
|
|
796
|
+
it('should include mtime and obsMtime in neighbor objects', async () => {
|
|
797
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
798
|
+
entityName: 'Hub',
|
|
799
|
+
sortBy: 'name'
|
|
800
|
+
});
|
|
801
|
+
// Each neighbor should have timestamp fields
|
|
802
|
+
for (const neighbor of result.items) {
|
|
803
|
+
expect(neighbor.mtime).toBeDefined();
|
|
804
|
+
expect(neighbor.obsMtime).toBeDefined();
|
|
805
|
+
expect(typeof neighbor.mtime).toBe('number');
|
|
806
|
+
expect(typeof neighbor.obsMtime).toBe('number');
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
it('should sort by obsMtime after observation update', async () => {
|
|
810
|
+
// Update observation on Neighbor_Z to make it have most recent obsMtime
|
|
811
|
+
await callTool(client, 'delete_observations', {
|
|
812
|
+
deletions: [{ entityName: 'Neighbor_Z', observations: ['First'] }]
|
|
813
|
+
});
|
|
814
|
+
await callTool(client, 'add_observations', {
|
|
815
|
+
observations: [{ entityName: 'Neighbor_Z', contents: ['Updated recently'] }]
|
|
816
|
+
});
|
|
817
|
+
const result = await callTool(client, 'get_neighbors', {
|
|
818
|
+
entityName: 'Hub',
|
|
819
|
+
sortBy: 'obsMtime'
|
|
820
|
+
});
|
|
821
|
+
const names = result.items.map(n => n.name);
|
|
822
|
+
// Neighbor_Z should be first because its obsMtime was just updated
|
|
823
|
+
expect(names[0]).toBe('Neighbor_Z');
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
describe('sorting with pagination', () => {
|
|
827
|
+
it('should maintain sort order across paginated results', async () => {
|
|
828
|
+
// Create many entities to force pagination
|
|
829
|
+
const entities = [];
|
|
830
|
+
for (let i = 0; i < 20; i++) {
|
|
831
|
+
entities.push({
|
|
832
|
+
name: `Entity_${String(i).padStart(2, '0')}`,
|
|
833
|
+
entityType: 'Numbered',
|
|
834
|
+
observations: [`Number ${i}`]
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
await callTool(client, 'create_entities', { entities });
|
|
838
|
+
// Fetch all pages sorted by name descending
|
|
839
|
+
const allEntities = [];
|
|
840
|
+
let entityCursor = 0;
|
|
841
|
+
while (entityCursor !== null) {
|
|
842
|
+
const result = await callTool(client, 'search_nodes', {
|
|
843
|
+
query: 'Numbered',
|
|
844
|
+
sortBy: 'name',
|
|
845
|
+
sortDir: 'desc',
|
|
846
|
+
entityCursor
|
|
847
|
+
});
|
|
848
|
+
allEntities.push(...result.entities.items);
|
|
849
|
+
entityCursor = result.entities.nextCursor;
|
|
850
|
+
}
|
|
851
|
+
// Verify all entities are in descending order
|
|
852
|
+
const names = allEntities.map(e => e.name);
|
|
853
|
+
const sortedNames = [...names].sort().reverse();
|
|
854
|
+
expect(names).toEqual(sortedNames);
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
});
|
|
565
858
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levalicious/server-memory",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "MCP server for enabling memory for Claude through a knowledge graph",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Levalicious",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
"test": "NODE_OPTIONS='--experimental-vm-modules' jest"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@modelcontextprotocol/sdk": "1.
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.25.1",
|
|
24
24
|
"proper-lockfile": "^4.1.2"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/jest": "^30.0.0",
|
|
28
|
-
"@types/node": "^
|
|
28
|
+
"@types/node": "^25",
|
|
29
29
|
"@types/proper-lockfile": "^4.1.4",
|
|
30
30
|
"jest": "^30.2.0",
|
|
31
31
|
"shx": "^0.4.0",
|