@levalicious/server-memory 0.0.8 → 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/README.md CHANGED
@@ -8,33 +8,37 @@ A basic implementation of persistent memory using a local knowledge graph. This
8
8
  Entities are the primary nodes in the knowledge graph. Each entity has:
9
9
  - A unique name (identifier)
10
10
  - An entity type (e.g., "person", "organization", "event")
11
- - A list of observations
11
+ - A list of observations (max 2, each max 140 characters)
12
+ - Modification timestamps (`mtime` for any change, `obsMtime` for observation changes)
12
13
 
13
14
  Example:
14
15
  ```json
15
16
  {
16
17
  "name": "John_Smith",
17
18
  "entityType": "person",
18
- "observations": ["Speaks fluent Spanish"]
19
+ "observations": ["Speaks fluent Spanish"],
20
+ "mtime": 1733423456789,
21
+ "obsMtime": 1733423456789
19
22
  }
20
23
  ```
21
24
 
22
25
  ### Relations
23
- Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other.
26
+ Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other. Each relation has a modification timestamp (`mtime`).
24
27
 
25
28
  Example:
26
29
  ```json
27
30
  {
28
31
  "from": "John_Smith",
29
32
  "to": "Anthropic",
30
- "relationType": "works_at"
33
+ "relationType": "works_at",
34
+ "mtime": 1733423456789
31
35
  }
32
36
  ```
33
37
  ### Observations
34
38
  Observations are discrete pieces of information about an entity. They are:
35
39
 
36
- - Stored as strings
37
- - Attached to specific entities
40
+ - Stored as strings (max 140 characters each)
41
+ - Attached to specific entities (max 2 per entity)
38
42
  - Can be added or removed independently
39
43
  - Should be atomic (one fact per observation)
40
44
 
@@ -44,8 +48,7 @@ Example:
44
48
  "entityName": "John_Smith",
45
49
  "observations": [
46
50
  "Speaks fluent Spanish",
47
- "Graduated in 2019",
48
- "Prefers morning meetings"
51
+ "Graduated in 2019"
49
52
  ]
50
53
  }
51
54
  ```
@@ -59,7 +62,7 @@ Example:
59
62
  - Each object contains:
60
63
  - `name` (string): Entity identifier
61
64
  - `entityType` (string): Type classification
62
- - `observations` (string[]): Associated observations
65
+ - `observations` (string[]): Associated observations (max 2, each max 140 chars)
63
66
  - Ignores entities with existing names
64
67
 
65
68
  - **create_relations**
@@ -76,9 +79,9 @@ Example:
76
79
  - Input: `observations` (array of objects)
77
80
  - Each object contains:
78
81
  - `entityName` (string): Target entity
79
- - `contents` (string[]): New observations to add
82
+ - `contents` (string[]): New observations to add (each max 140 chars)
80
83
  - Returns added observations per entity
81
- - Fails if entity doesn't exist
84
+ - Fails if entity doesn't exist or would exceed 2 observations
82
85
 
83
86
  - **delete_entities**
84
87
  - Remove entities and their relations
@@ -103,42 +106,45 @@ Example:
103
106
  - `relationType` (string): Relationship type
104
107
  - Silent operation if relation doesn't exist
105
108
 
106
- - **read_graph**
107
- - Read the entire knowledge graph
108
- - No input required
109
- - Returns complete graph structure with all entities and relations
110
-
111
109
  - **search_nodes**
112
- - Search for nodes based on query
113
- - Input: `query` (string)
110
+ - Search for nodes using a regex pattern
111
+ - Input: `query` (string), `entityCursor` (number, optional), `relationCursor` (number, optional)
114
112
  - Searches across:
115
113
  - Entity names
116
114
  - Entity types
117
115
  - Observation content
118
- - Returns matching entities and their relations
116
+ - Returns matching entities and their relations (paginated)
117
+
118
+ - **open_nodes_filtered**
119
+ - Retrieve specific nodes by name with filtered relations
120
+ - Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
121
+ - Returns:
122
+ - Requested entities
123
+ - Only relations where both endpoints are in the requested set
124
+ - Silently skips non-existent nodes (paginated)
119
125
 
120
126
  - **open_nodes**
121
127
  - Retrieve specific nodes by name
122
- - Input: `names` (string[])
128
+ - Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
123
129
  - Returns:
124
130
  - Requested entities
125
- - Relations between requested entities
126
- - Silently skips non-existent nodes
131
+ - Relations originating from requested entities
132
+ - Silently skips non-existent nodes (paginated)
127
133
 
128
134
  - **get_neighbors**
129
135
  - Get neighboring entities connected to a specific entity within a given depth
130
- - Input: `entityName` (string), `depth` (number, default: 1)
131
- - Returns entities connected within specified depth
136
+ - Input: `entityName` (string), `depth` (number, default: 0), `withEntities` (boolean, default: false), `entityCursor` (number, optional), `relationCursor` (number, optional)
137
+ - Returns relations (and optionally entities) connected within specified depth (paginated)
132
138
 
133
139
  - **find_path**
134
140
  - Find a path between two entities in the knowledge graph
135
- - Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5)
136
- - Returns path between entities if one exists
141
+ - Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5), `cursor` (number, optional)
142
+ - Returns path between entities if one exists (paginated)
137
143
 
138
144
  - **get_entities_by_type**
139
145
  - Get all entities of a specific type
140
- - Input: `entityType` (string)
141
- - Returns all entities matching the specified type
146
+ - Input: `entityType` (string), `cursor` (number, optional)
147
+ - Returns all entities matching the specified type (paginated)
142
148
 
143
149
  - **get_entity_types**
144
150
  - Get all unique entity types in the knowledge graph
@@ -156,14 +162,15 @@ Example:
156
162
  - Returns entity count, relation count, entity types count, relation types count
157
163
 
158
164
  - **get_orphaned_entities**
159
- - Get entities that have no relations
160
- - No input required
161
- - Returns entities with no connections
165
+ - Get entities that have no relations (orphaned entities)
166
+ - Input: `strict` (boolean, default: false), `cursor` (number, optional)
167
+ - In strict mode, returns entities not connected to 'Self' entity (directly or indirectly)
168
+ - Returns entities with no connections (paginated)
162
169
 
163
170
  - **validate_graph**
164
- - Validate the knowledge graph and return missing entities referenced in relations
171
+ - Validate the knowledge graph
165
172
  - No input required
166
- - Returns list of missing entities
173
+ - Returns missing entities referenced in relations and observation limit violations
167
174
 
168
175
  - **evaluate_bcl**
169
176
  - Evaluate a Binary Combinatory Logic (BCL) program
@@ -182,6 +189,12 @@ Example:
182
189
  - No input required
183
190
  - Resets BCL constructor
184
191
 
192
+ - **sequentialthinking**
193
+ - Record a thought in the knowledge graph
194
+ - Input: `observations` (string[], max 2, each max 140 chars), `previousCtxId` (string, optional)
195
+ - Creates a Thought entity and links it to the previous thought if provided
196
+ - Returns the new thought's context ID for chaining
197
+
185
198
  # Usage with Claude Desktop
186
199
 
187
200
  ### Setup
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { promises as fs } from 'fs';
5
+ import { randomBytes } from 'crypto';
5
6
  import path from 'path';
6
7
  import { fileURLToPath } from 'url';
7
8
  import lockfile from 'proper-lockfile';
@@ -13,6 +14,44 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
13
14
  ? process.env.MEMORY_FILE_PATH
14
15
  : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
15
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
+ }
16
55
  export const MAX_CHARS = 2048;
17
56
  function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
18
57
  const result = [];
@@ -253,7 +292,7 @@ export class KnowledgeGraphManager {
253
292
  });
254
293
  }
255
294
  // Regex-based search function
256
- async searchNodes(query) {
295
+ async searchNodes(query, sortBy, sortDir) {
257
296
  const graph = await this.loadGraph();
258
297
  let regex;
259
298
  try {
@@ -271,7 +310,7 @@ export class KnowledgeGraphManager {
271
310
  // Filter relations to only include those between filtered entities
272
311
  const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
273
312
  const filteredGraph = {
274
- entities: filteredEntities,
313
+ entities: sortEntities(filteredEntities, sortBy, sortDir),
275
314
  relations: filteredRelations,
276
315
  };
277
316
  return filteredGraph;
@@ -304,25 +343,21 @@ export class KnowledgeGraphManager {
304
343
  };
305
344
  return filteredGraph;
306
345
  }
307
- async getNeighbors(entityName, depth = 1, withEntities = false) {
346
+ async getNeighbors(entityName, depth = 1, sortBy, sortDir) {
308
347
  const graph = await this.loadGraph();
309
348
  const visited = new Set();
310
- const resultEntities = new Map();
311
- const resultRelations = new Map(); // Deduplicate relations
312
- const relationKey = (r) => `${r.from}|${r.relationType}|${r.to}`;
349
+ const neighborNames = new Set();
313
350
  const traverse = (currentName, currentDepth) => {
314
351
  if (currentDepth > depth || visited.has(currentName))
315
352
  return;
316
353
  visited.add(currentName);
317
- if (withEntities) {
318
- const entity = graph.entities.find(e => e.name === currentName);
319
- if (entity) {
320
- resultEntities.set(currentName, entity);
321
- }
322
- }
323
354
  // Find all relations involving this entity
324
355
  const connectedRelations = graph.relations.filter(r => r.from === currentName || r.to === currentName);
325
- 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
+ });
326
361
  if (currentDepth < depth) {
327
362
  // Traverse to connected entities
328
363
  connectedRelations.forEach(r => {
@@ -332,10 +367,19 @@ export class KnowledgeGraphManager {
332
367
  }
333
368
  };
334
369
  traverse(entityName, 0);
335
- return {
336
- entities: Array.from(resultEntities.values()),
337
- relations: Array.from(resultRelations.values())
338
- };
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);
339
383
  }
340
384
  async findPath(fromEntity, toEntity, maxDepth = 5) {
341
385
  const graph = await this.loadGraph();
@@ -357,9 +401,10 @@ export class KnowledgeGraphManager {
357
401
  };
358
402
  return dfs(fromEntity, toEntity, [], 0) || [];
359
403
  }
360
- async getEntitiesByType(entityType) {
404
+ async getEntitiesByType(entityType, sortBy, sortDir) {
361
405
  const graph = await this.loadGraph();
362
- return graph.entities.filter(e => e.entityType === entityType);
406
+ const filtered = graph.entities.filter(e => e.entityType === entityType);
407
+ return sortEntities(filtered, sortBy, sortDir);
363
408
  }
364
409
  async getEntityTypes() {
365
410
  const graph = await this.loadGraph();
@@ -382,14 +427,46 @@ export class KnowledgeGraphManager {
382
427
  relationTypes: relationTypes.size
383
428
  };
384
429
  }
385
- async getOrphanedEntities() {
430
+ async getOrphanedEntities(strict = false, sortBy, sortDir) {
386
431
  const graph = await this.loadGraph();
387
- const connectedEntityNames = new Set();
432
+ if (!strict) {
433
+ // Simple mode: entities with no relations at all
434
+ const connectedEntityNames = new Set();
435
+ graph.relations.forEach(r => {
436
+ connectedEntityNames.add(r.from);
437
+ connectedEntityNames.add(r.to);
438
+ });
439
+ const orphans = graph.entities.filter(e => !connectedEntityNames.has(e.name));
440
+ return sortEntities(orphans, sortBy, sortDir);
441
+ }
442
+ // Strict mode: entities not connected to "Self" (directly or indirectly)
443
+ // Build adjacency list (bidirectional)
444
+ const neighbors = new Map();
445
+ graph.entities.forEach(e => neighbors.set(e.name, new Set()));
388
446
  graph.relations.forEach(r => {
389
- connectedEntityNames.add(r.from);
390
- connectedEntityNames.add(r.to);
447
+ neighbors.get(r.from)?.add(r.to);
448
+ neighbors.get(r.to)?.add(r.from);
391
449
  });
392
- return graph.entities.filter(e => !connectedEntityNames.has(e.name));
450
+ // BFS from Self to find all connected entities
451
+ const connectedToSelf = new Set();
452
+ const queue = ['Self'];
453
+ while (queue.length > 0) {
454
+ const current = queue.shift();
455
+ if (connectedToSelf.has(current))
456
+ continue;
457
+ connectedToSelf.add(current);
458
+ const currentNeighbors = neighbors.get(current);
459
+ if (currentNeighbors) {
460
+ for (const neighbor of currentNeighbors) {
461
+ if (!connectedToSelf.has(neighbor)) {
462
+ queue.push(neighbor);
463
+ }
464
+ }
465
+ }
466
+ }
467
+ // Return entities not connected to Self (excluding Self itself if it exists)
468
+ const orphans = graph.entities.filter(e => !connectedToSelf.has(e.name));
469
+ return sortEntities(orphans, sortBy, sortDir);
393
470
  }
394
471
  async validateGraph() {
395
472
  const graph = await this.loadGraph();
@@ -596,9 +673,9 @@ export class KnowledgeGraphManager {
596
673
  throw new Error(`Observation exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
597
674
  }
598
675
  }
599
- // Generate new context ID
676
+ // Generate new context ID (24-char hex)
600
677
  const now = Date.now();
601
- const ctxId = `thought_${now}_${Math.random().toString(36).substring(2, 8)}`;
678
+ const ctxId = randomBytes(12).toString('hex');
602
679
  // Create thought entity
603
680
  const thoughtEntity = {
604
681
  name: ctxId,
@@ -637,7 +714,7 @@ export function createServer(memoryFilePath) {
637
714
  sizes: ["any"]
638
715
  }
639
716
  ],
640
- version: "0.0.4",
717
+ version: "0.0.10",
641
718
  }, {
642
719
  capabilities: {
643
720
  tools: {},
@@ -790,6 +867,8 @@ export function createServer(memoryFilePath) {
790
867
  type: "object",
791
868
  properties: {
792
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." },
793
872
  entityCursor: { type: "number", description: "Cursor for entity pagination (from previous response's nextCursor)" },
794
873
  relationCursor: { type: "number", description: "Cursor for relation pagination" },
795
874
  },
@@ -832,15 +911,15 @@ export function createServer(memoryFilePath) {
832
911
  },
833
912
  {
834
913
  name: "get_neighbors",
835
- 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).",
836
915
  inputSchema: {
837
916
  type: "object",
838
917
  properties: {
839
918
  entityName: { type: "string", description: "The name of the entity to find neighbors for" },
840
- depth: { type: "number", description: "Maximum depth to traverse (default: 0)", default: 0 },
841
- withEntities: { type: "boolean", description: "If true, include full entity data. Default returns only relations for lightweight structure exploration.", default: false },
842
- entityCursor: { type: "number", description: "Cursor for entity pagination" },
843
- 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" },
844
923
  },
845
924
  required: ["entityName"],
846
925
  },
@@ -866,6 +945,8 @@ export function createServer(memoryFilePath) {
866
945
  type: "object",
867
946
  properties: {
868
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." },
869
950
  cursor: { type: "number", description: "Cursor for pagination" },
870
951
  },
871
952
  required: ["entityType"],
@@ -897,10 +978,13 @@ export function createServer(memoryFilePath) {
897
978
  },
898
979
  {
899
980
  name: "get_orphaned_entities",
900
- description: "Get entities that have no relations (orphaned entities). Results are paginated (max 512 chars).",
981
+ description: "Get entities that have no relations (orphaned entities). In strict mode, returns entities not connected to 'Self' entity. Results are paginated (max 512 chars).",
901
982
  inputSchema: {
902
983
  type: "object",
903
984
  properties: {
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." },
904
988
  cursor: { type: "number", description: "Cursor for pagination" },
905
989
  },
906
990
  },
@@ -994,7 +1078,7 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
994
1078
  await knowledgeGraphManager.deleteRelations(args.relations);
995
1079
  return { content: [{ type: "text", text: "Relations deleted successfully" }] };
996
1080
  case "search_nodes": {
997
- const graph = await knowledgeGraphManager.searchNodes(args.query);
1081
+ const graph = await knowledgeGraphManager.searchNodes(args.query, args.sortBy, args.sortDir);
998
1082
  return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
999
1083
  }
1000
1084
  case "open_nodes_filtered": {
@@ -1006,15 +1090,15 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
1006
1090
  return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1007
1091
  }
1008
1092
  case "get_neighbors": {
1009
- const graph = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities);
1010
- 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)) }] };
1011
1095
  }
1012
1096
  case "find_path": {
1013
1097
  const path = await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth);
1014
1098
  return { content: [{ type: "text", text: JSON.stringify(paginateItems(path, args.cursor ?? 0)) }] };
1015
1099
  }
1016
1100
  case "get_entities_by_type": {
1017
- const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType);
1101
+ const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType, args.sortBy, args.sortDir);
1018
1102
  return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1019
1103
  }
1020
1104
  case "get_entity_types":
@@ -1024,7 +1108,7 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
1024
1108
  case "get_stats":
1025
1109
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
1026
1110
  case "get_orphaned_entities": {
1027
- const entities = await knowledgeGraphManager.getOrphanedEntities();
1111
+ const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false, args.sortBy, args.sortDir);
1028
1112
  return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1029
1113
  }
1030
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', {
@@ -441,6 +420,32 @@ describe('MCP Memory Server E2E Tests', () => {
441
420
  expect(result.items).toHaveLength(1);
442
421
  expect(result.items[0].name).toBe('Orphan');
443
422
  });
423
+ it('should find entities not connected to Self in strict mode', async () => {
424
+ await callTool(client, 'create_entities', {
425
+ entities: [
426
+ { name: 'Self', entityType: 'Agent', observations: [] },
427
+ { name: 'ConnectedToSelf', entityType: 'Node', observations: [] },
428
+ { name: 'IndirectlyConnected', entityType: 'Node', observations: [] },
429
+ { name: 'Island1', entityType: 'Node', observations: [] },
430
+ { name: 'Island2', entityType: 'Node', observations: [] }
431
+ ]
432
+ });
433
+ await callTool(client, 'create_relations', {
434
+ relations: [
435
+ { from: 'Self', to: 'ConnectedToSelf', relationType: 'knows' },
436
+ { from: 'ConnectedToSelf', to: 'IndirectlyConnected', relationType: 'links' },
437
+ { from: 'Island1', to: 'Island2', relationType: 'links' } // Connected to each other but not to Self
438
+ ]
439
+ });
440
+ // Non-strict: Island1 and Island2 are connected, so not orphaned
441
+ const nonStrict = await callTool(client, 'get_orphaned_entities', {});
442
+ expect(nonStrict.items).toHaveLength(0);
443
+ // Strict: Island1 and Island2 are not connected to Self
444
+ const strict = await callTool(client, 'get_orphaned_entities', { strict: true });
445
+ expect(strict.items).toHaveLength(2);
446
+ const names = strict.items.map(e => e.name).sort();
447
+ expect(names).toEqual(['Island1', 'Island2']);
448
+ });
444
449
  it('should validate graph and report violations', async () => {
445
450
  // Directly write invalid data to test validation
446
451
  const invalidData = [
@@ -486,7 +491,7 @@ describe('MCP Memory Server E2E Tests', () => {
486
491
  const result = await callTool(client, 'sequentialthinking', {
487
492
  observations: ['First thought observation']
488
493
  });
489
- expect(result.ctxId).toMatch(/^thought_\d+_[a-z0-9]+$/);
494
+ expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
490
495
  });
491
496
  it('should chain thoughts with relations', async () => {
492
497
  // Create first thought
@@ -498,26 +503,26 @@ describe('MCP Memory Server E2E Tests', () => {
498
503
  previousCtxId: first.ctxId,
499
504
  observations: ['Following up']
500
505
  });
501
- // Verify the chain via relations
506
+ // Verify the chain via neighbors
502
507
  const neighbors = await callTool(client, 'get_neighbors', {
503
508
  entityName: first.ctxId,
504
509
  depth: 1
505
510
  });
506
- // Should have 'follows' relation from first to second
507
- 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);
508
513
  });
509
514
  it('should ignore invalid previousCtxId gracefully', async () => {
510
515
  const result = await callTool(client, 'sequentialthinking', {
511
516
  previousCtxId: 'nonexistent_thought',
512
517
  observations: ['Orphaned thought']
513
518
  });
514
- expect(result.ctxId).toMatch(/^thought_\d+_[a-z0-9]+$/);
515
- // Verify no relations were created
519
+ expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
520
+ // Verify no neighbors (no valid relations were created)
516
521
  const neighbors = await callTool(client, 'get_neighbors', {
517
522
  entityName: result.ctxId,
518
523
  depth: 1
519
524
  });
520
- expect(neighbors.relations.totalCount).toBe(0);
525
+ expect(neighbors.items).toHaveLength(0);
521
526
  });
522
527
  it('should enforce observation limits on thoughts', async () => {
523
528
  await expect(callTool(client, 'sequentialthinking', {
@@ -536,4 +541,318 @@ describe('MCP Memory Server E2E Tests', () => {
536
541
  expect(thought.obsMtime).toBeDefined();
537
542
  });
538
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
+ });
539
858
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.8",
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",