@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 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, withEntities = false) {
346
+ async getNeighbors(entityName, depth = 1, sortBy, sortDir) {
309
347
  const graph = await this.loadGraph();
310
348
  const visited = new Set();
311
- const resultEntities = new Map();
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
- connectedRelations.forEach(r => resultRelations.set(relationKey(r), r));
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
- return {
337
- entities: Array.from(resultEntities.values()),
338
- relations: Array.from(resultRelations.values())
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
- return graph.entities.filter(e => e.entityType === entityType);
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
- return graph.entities.filter(e => !connectedEntityNames.has(e.name));
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
- return graph.entities.filter(e => !connectedToSelf.has(e.name));
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.4",
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: 0)", default: 0 },
872
- withEntities: { type: "boolean", description: "If true, include full entity data. Default returns only relations for lightweight structure exploration.", default: false },
873
- entityCursor: { type: "number", description: "Cursor for entity pagination" },
874
- relationCursor: { type: "number", description: "Cursor for relation pagination" },
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 graph = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities);
1042
- return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
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 (relations only by default)', async () => {
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
- expect(result.entities.items).toHaveLength(0); // withEntities defaults to false
292
- expect(result.relations.items).toHaveLength(2); // Root's direct relations
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 with entities when requested', async () => {
295
- // Accumulate all entities across pagination
296
- const allEntities = [];
297
- let entityCursor = 0;
298
- while (entityCursor !== null) {
299
- const result = await callTool(client, 'get_neighbors', {
300
- entityName: 'Root',
301
- depth: 1,
302
- withEntities: true,
303
- entityCursor
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
- // Collect all entities and relations using pagination
315
- const allEntities = [];
316
- const allRelations = [];
317
- let entityCursor = 0;
318
- let relationCursor = 0;
319
- // Paginate through all results
320
- while (entityCursor !== null || relationCursor !== null) {
321
- const result = await callTool(client, 'get_neighbors', {
322
- entityName: 'Root',
323
- depth: 2,
324
- withEntities: true,
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 relations in traversal', async () => {
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 unique relation should appear only once
353
- const relationKeys = result.relations.items.map(r => `${r.from}|${r.relationType}|${r.to}`);
354
- const uniqueKeys = [...new Set(relationKeys)];
355
- expect(relationKeys.length).toBe(uniqueKeys.length);
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 relations
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
- // Should have 'follows' relation from first to second
533
- expect(neighbors.relations.items.some(r => r.from === first.ctxId && r.to === second.ctxId && r.relationType === 'follows')).toBe(true);
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.relations.totalCount).toBe(0);
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.9",
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.24.0",
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": "^24",
28
+ "@types/node": "^25",
29
29
  "@types/proper-lockfile": "^4.1.4",
30
30
  "jest": "^30.2.0",
31
31
  "shx": "^0.4.0",