@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 +46 -33
- package/dist/server.js +122 -38
- package/dist/tests/memory-server.test.js +380 -61
- package/package.json +3 -3
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
|
|
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
|
|
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:
|
|
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
|
-
-
|
|
161
|
-
-
|
|
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
|
|
171
|
+
- Validate the knowledge graph
|
|
165
172
|
- No input required
|
|
166
|
-
- Returns
|
|
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,
|
|
346
|
+
async getNeighbors(entityName, depth = 1, sortBy, sortDir) {
|
|
308
347
|
const graph = await this.loadGraph();
|
|
309
348
|
const visited = new Set();
|
|
310
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
447
|
+
neighbors.get(r.from)?.add(r.to);
|
|
448
|
+
neighbors.get(r.to)?.add(r.from);
|
|
391
449
|
});
|
|
392
|
-
|
|
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 =
|
|
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.
|
|
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:
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
1010
|
-
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)) }] };
|
|
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
|
|
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', {
|
|
@@ -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(/^
|
|
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
|
|
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
|
-
//
|
|
507
|
-
expect(neighbors.
|
|
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(/^
|
|
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.
|
|
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.
|
|
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",
|