@levalicious/server-memory 0.0.9 → 0.0.11

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
@@ -108,11 +108,12 @@ Example:
108
108
 
109
109
  - **search_nodes**
110
110
  - Search for nodes using a regex pattern
111
- - Input: `query` (string), `entityCursor` (number, optional), `relationCursor` (number, optional)
112
- - Searches across:
113
- - Entity names
114
- - Entity types
115
- - Observation content
111
+ - Input:
112
+ - `query` (string): Regex pattern to search
113
+ - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
114
+ - `sortDir` (string, optional): Sort direction ("asc" or "desc")
115
+ - `entityCursor` (number, optional), `relationCursor` (number, optional)
116
+ - Searches across entity names, types, and observation content
116
117
  - Returns matching entities and their relations (paginated)
117
118
 
118
119
  - **open_nodes_filtered**
@@ -132,9 +133,15 @@ Example:
132
133
  - Silently skips non-existent nodes (paginated)
133
134
 
134
135
  - **get_neighbors**
135
- - Get neighboring entities connected to a specific entity within a given 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)
136
+ - Get names of neighboring entities connected to a specific entity within a given depth
137
+ - Input:
138
+ - `entityName` (string): The entity to find neighbors for
139
+ - `depth` (number, default: 1): Maximum traversal depth
140
+ - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
141
+ - `sortDir` (string, optional): Sort direction ("asc" or "desc")
142
+ - `cursor` (number, optional): Pagination cursor
143
+ - Returns neighbor names with timestamps (paginated)
144
+ - Use `open_nodes` to get full entity data for neighbors
138
145
 
139
146
  - **find_path**
140
147
  - Find a path between two entities in the knowledge graph
@@ -143,7 +150,11 @@ Example:
143
150
 
144
151
  - **get_entities_by_type**
145
152
  - Get all entities of a specific type
146
- - Input: `entityType` (string), `cursor` (number, optional)
153
+ - Input:
154
+ - `entityType` (string): Type to filter by
155
+ - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
156
+ - `sortDir` (string, optional): Sort direction ("asc" or "desc")
157
+ - `cursor` (number, optional)
147
158
  - Returns all entities matching the specified type (paginated)
148
159
 
149
160
  - **get_entity_types**
@@ -163,8 +174,11 @@ Example:
163
174
 
164
175
  - **get_orphaned_entities**
165
176
  - 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)
177
+ - Input:
178
+ - `strict` (boolean, default: false): If true, returns entities not connected to 'Self' entity
179
+ - `sortBy` (string, optional): Sort field ("mtime", "obsMtime", or "name")
180
+ - `sortDir` (string, optional): Sort direction ("asc" or "desc")
181
+ - `cursor` (number, optional)
168
182
  - Returns entities with no connections (paginated)
169
183
 
170
184
  - **validate_graph**
@@ -172,22 +186,22 @@ Example:
172
186
  - No input required
173
187
  - Returns missing entities referenced in relations and observation limit violations
174
188
 
175
- - **evaluate_bcl**
176
- - Evaluate a Binary Combinatory Logic (BCL) program
177
- - Input: `program` (string), `maxSteps` (number, default: 1000000)
178
- - BCL syntax: T:=00|01|1TT where 00=K, 01=S, 1=application
179
- - Returns evaluation result with halt status
180
-
181
- - **add_bcl_term**
182
- - Add a BCL term to the constructor, maintaining valid syntax
183
- - Input: `term` (string)
184
- - Valid values: '1' or 'App' (application), '00' or 'K' (K combinator), '01' or 'S' (S combinator)
185
- - Returns completion status
186
-
187
- - **clear_bcl_term**
188
- - Clear the current BCL term being constructed and reset the constructor state
189
- - No input required
190
- - Resets BCL constructor
189
+ - **decode_timestamp**
190
+ - Decode a millisecond timestamp to human-readable UTC format
191
+ - Input:
192
+ - `timestamp` (number, optional): Millisecond timestamp to decode. If omitted, returns current time
193
+ - `relative` (boolean, optional): If true, include relative time (e.g., "3 days ago")
194
+ - Returns timestamp, ISO 8601 string, formatted UTC string, and optional relative time
195
+ - Useful for interpreting `mtime`/`obsMtime` values from entities
196
+
197
+ - **random_walk**
198
+ - Perform a random walk from a starting entity, following random relations
199
+ - Input:
200
+ - `start` (string): Name of the entity to start the walk from
201
+ - `depth` (number, default: 3): Number of hops to take
202
+ - `seed` (string, optional): Seed for reproducible walks
203
+ - Returns the terminal entity name and the path taken
204
+ - Useful for serendipitous exploration of the knowledge graph
191
205
 
192
206
  - **sequentialthinking**
193
207
  - Record a thought in the knowledge graph
package/dist/server.js CHANGED
@@ -14,6 +14,44 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
14
14
  ? process.env.MEMORY_FILE_PATH
15
15
  : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
16
16
  : defaultMemoryPath;
17
+ /**
18
+ * Sort entities by the specified field and direction.
19
+ * Returns a new array (does not mutate input).
20
+ * If sortBy is undefined, returns the original array (no sorting - preserves insertion order).
21
+ */
22
+ function sortEntities(entities, sortBy, sortDir) {
23
+ if (!sortBy)
24
+ return entities; // No sorting - preserve current behavior
25
+ const dir = sortDir ?? (sortBy === "name" ? "asc" : "desc");
26
+ const mult = dir === "asc" ? 1 : -1;
27
+ return [...entities].sort((a, b) => {
28
+ if (sortBy === "name") {
29
+ return mult * a.name.localeCompare(b.name);
30
+ }
31
+ // For timestamps, treat undefined as 0 (oldest)
32
+ const aVal = a[sortBy] ?? 0;
33
+ const bVal = b[sortBy] ?? 0;
34
+ return mult * (aVal - bVal);
35
+ });
36
+ }
37
+ /**
38
+ * Sort neighbors by the specified field and direction.
39
+ * If sortBy is undefined, returns the original array (no sorting).
40
+ */
41
+ function sortNeighbors(neighbors, sortBy, sortDir) {
42
+ if (!sortBy)
43
+ return neighbors;
44
+ const dir = sortDir ?? (sortBy === "name" ? "asc" : "desc");
45
+ const mult = dir === "asc" ? 1 : -1;
46
+ return [...neighbors].sort((a, b) => {
47
+ if (sortBy === "name") {
48
+ return mult * a.name.localeCompare(b.name);
49
+ }
50
+ const aVal = a[sortBy] ?? 0;
51
+ const bVal = b[sortBy] ?? 0;
52
+ return mult * (aVal - bVal);
53
+ });
54
+ }
17
55
  export const MAX_CHARS = 2048;
18
56
  function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
19
57
  const result = [];
@@ -93,8 +131,6 @@ function paginateGraph(graph, entityCursor = 0, relationCursor = 0) {
93
131
  }
94
132
  // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
95
133
  export class KnowledgeGraphManager {
96
- bclCtr = 0;
97
- bclTerm = "";
98
134
  memoryFilePath;
99
135
  constructor(memoryFilePath = DEFAULT_MEMORY_FILE_PATH) {
100
136
  this.memoryFilePath = memoryFilePath;
@@ -254,7 +290,7 @@ export class KnowledgeGraphManager {
254
290
  });
255
291
  }
256
292
  // Regex-based search function
257
- async searchNodes(query) {
293
+ async searchNodes(query, sortBy, sortDir) {
258
294
  const graph = await this.loadGraph();
259
295
  let regex;
260
296
  try {
@@ -272,7 +308,7 @@ export class KnowledgeGraphManager {
272
308
  // Filter relations to only include those between filtered entities
273
309
  const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to));
274
310
  const filteredGraph = {
275
- entities: filteredEntities,
311
+ entities: sortEntities(filteredEntities, sortBy, sortDir),
276
312
  relations: filteredRelations,
277
313
  };
278
314
  return filteredGraph;
@@ -305,25 +341,21 @@ export class KnowledgeGraphManager {
305
341
  };
306
342
  return filteredGraph;
307
343
  }
308
- async getNeighbors(entityName, depth = 1, withEntities = false) {
344
+ async getNeighbors(entityName, depth = 1, sortBy, sortDir) {
309
345
  const graph = await this.loadGraph();
310
346
  const visited = new Set();
311
- const resultEntities = new Map();
312
- const resultRelations = new Map(); // Deduplicate relations
313
- const relationKey = (r) => `${r.from}|${r.relationType}|${r.to}`;
347
+ const neighborNames = new Set();
314
348
  const traverse = (currentName, currentDepth) => {
315
349
  if (currentDepth > depth || visited.has(currentName))
316
350
  return;
317
351
  visited.add(currentName);
318
- if (withEntities) {
319
- const entity = graph.entities.find(e => e.name === currentName);
320
- if (entity) {
321
- resultEntities.set(currentName, entity);
322
- }
323
- }
324
352
  // Find all relations involving this entity
325
353
  const connectedRelations = graph.relations.filter(r => r.from === currentName || r.to === currentName);
326
- connectedRelations.forEach(r => resultRelations.set(relationKey(r), r));
354
+ // Collect neighbor names
355
+ connectedRelations.forEach(r => {
356
+ const neighborName = r.from === currentName ? r.to : r.from;
357
+ neighborNames.add(neighborName);
358
+ });
327
359
  if (currentDepth < depth) {
328
360
  // Traverse to connected entities
329
361
  connectedRelations.forEach(r => {
@@ -333,10 +365,19 @@ export class KnowledgeGraphManager {
333
365
  }
334
366
  };
335
367
  traverse(entityName, 0);
336
- return {
337
- entities: Array.from(resultEntities.values()),
338
- relations: Array.from(resultRelations.values())
339
- };
368
+ // Remove the starting entity from neighbors (it's not its own neighbor)
369
+ neighborNames.delete(entityName);
370
+ // Build neighbor objects with timestamps
371
+ const entityMap = new Map(graph.entities.map(e => [e.name, e]));
372
+ const neighbors = Array.from(neighborNames).map(name => {
373
+ const entity = entityMap.get(name);
374
+ return {
375
+ name,
376
+ mtime: entity?.mtime,
377
+ obsMtime: entity?.obsMtime,
378
+ };
379
+ });
380
+ return sortNeighbors(neighbors, sortBy, sortDir);
340
381
  }
341
382
  async findPath(fromEntity, toEntity, maxDepth = 5) {
342
383
  const graph = await this.loadGraph();
@@ -358,9 +399,10 @@ export class KnowledgeGraphManager {
358
399
  };
359
400
  return dfs(fromEntity, toEntity, [], 0) || [];
360
401
  }
361
- async getEntitiesByType(entityType) {
402
+ async getEntitiesByType(entityType, sortBy, sortDir) {
362
403
  const graph = await this.loadGraph();
363
- return graph.entities.filter(e => e.entityType === entityType);
404
+ const filtered = graph.entities.filter(e => e.entityType === entityType);
405
+ return sortEntities(filtered, sortBy, sortDir);
364
406
  }
365
407
  async getEntityTypes() {
366
408
  const graph = await this.loadGraph();
@@ -383,7 +425,7 @@ export class KnowledgeGraphManager {
383
425
  relationTypes: relationTypes.size
384
426
  };
385
427
  }
386
- async getOrphanedEntities(strict = false) {
428
+ async getOrphanedEntities(strict = false, sortBy, sortDir) {
387
429
  const graph = await this.loadGraph();
388
430
  if (!strict) {
389
431
  // Simple mode: entities with no relations at all
@@ -392,7 +434,8 @@ export class KnowledgeGraphManager {
392
434
  connectedEntityNames.add(r.from);
393
435
  connectedEntityNames.add(r.to);
394
436
  });
395
- return graph.entities.filter(e => !connectedEntityNames.has(e.name));
437
+ const orphans = graph.entities.filter(e => !connectedEntityNames.has(e.name));
438
+ return sortEntities(orphans, sortBy, sortDir);
396
439
  }
397
440
  // Strict mode: entities not connected to "Self" (directly or indirectly)
398
441
  // Build adjacency list (bidirectional)
@@ -420,7 +463,8 @@ export class KnowledgeGraphManager {
420
463
  }
421
464
  }
422
465
  // Return entities not connected to Self (excluding Self itself if it exists)
423
- return graph.entities.filter(e => !connectedToSelf.has(e.name));
466
+ const orphans = graph.entities.filter(e => !connectedToSelf.has(e.name));
467
+ return sortEntities(orphans, sortBy, sortDir);
424
468
  }
425
469
  async validateGraph() {
426
470
  const graph = await this.loadGraph();
@@ -457,163 +501,96 @@ export class KnowledgeGraphManager {
457
501
  observationViolations
458
502
  };
459
503
  }
460
- // BCL (Binary Combinatory Logic) evaluator
461
- async evaluateBCL(program, maxSteps) {
462
- let stepCount = 0;
463
- let max_size = program.length;
464
- let mode = 0;
465
- let ctr = 1;
466
- let t0 = program;
467
- let t1 = '';
468
- let t2 = '';
469
- let t3 = '';
470
- let t4 = '';
471
- while (stepCount < maxSteps) {
472
- if (t0.length == 0)
473
- break;
474
- let b = t0[0];
475
- t0 = t0.slice(1);
476
- if (mode === 0) {
477
- t1 += b;
478
- let size = t1.length + t0.length;
479
- if (size > max_size)
480
- max_size = size;
481
- if (t1.slice(-4) === '1100') {
482
- mode = 1;
483
- t1 = t1.slice(0, -4);
484
- }
485
- else if (t1.slice(-5) === '11101') {
486
- mode = 3;
487
- t1 = t1.slice(0, -5);
488
- }
489
- }
490
- else if (mode === 1) {
491
- t2 += b;
492
- if (b == '1') {
493
- ctr += 1;
494
- }
495
- else if (b == '0') {
496
- ctr -= 1;
497
- t2 += t0[0];
498
- t0 = t0.slice(1);
499
- }
500
- if (ctr === 0) {
501
- mode = 2;
502
- ctr = 1;
503
- }
504
- }
505
- else if (mode === 2) {
506
- if (b == '1') {
507
- ctr += 1;
508
- }
509
- else if (b == '0') {
510
- ctr -= 1;
511
- t0 = t0.slice(1);
512
- }
513
- if (ctr === 0) {
514
- t0 = t2 + t0;
515
- t2 = '';
516
- mode = 0;
517
- ctr = 1;
518
- stepCount += 1;
519
- }
520
- }
521
- else if (mode === 3) {
522
- t2 += b;
523
- if (b == '1') {
524
- ctr += 1;
525
- }
526
- else if (b == '0') {
527
- ctr -= 1;
528
- t2 += t0[0];
529
- t0 = t0.slice(1);
530
- }
531
- if (ctr === 0) {
532
- mode = 4;
533
- ctr = 1;
534
- }
504
+ async randomWalk(start, depth = 3, seed) {
505
+ const graph = await this.loadGraph();
506
+ // Verify start entity exists
507
+ const startEntity = graph.entities.find(e => e.name === start);
508
+ if (!startEntity) {
509
+ throw new Error(`Start entity not found: ${start}`);
510
+ }
511
+ // Create seeded RNG if seed provided, otherwise use crypto.randomBytes
512
+ let rngState = seed ? this.hashSeed(seed) : null;
513
+ const random = () => {
514
+ if (rngState !== null) {
515
+ // Simple seeded PRNG (xorshift32)
516
+ rngState ^= rngState << 13;
517
+ rngState ^= rngState >>> 17;
518
+ rngState ^= rngState << 5;
519
+ return (rngState >>> 0) / 0xFFFFFFFF;
535
520
  }
536
- else if (mode === 4) {
537
- t3 += b;
538
- if (b == '1') {
539
- ctr += 1;
540
- }
541
- else if (b == '0') {
542
- ctr -= 1;
543
- t3 += t0[0];
544
- t0 = t0.slice(1);
545
- }
546
- if (ctr === 0) {
547
- mode = 5;
548
- ctr = 1;
549
- }
521
+ else {
522
+ return randomBytes(4).readUInt32BE() / 0xFFFFFFFF;
550
523
  }
551
- else if (mode === 5) {
552
- t4 += b;
553
- if (b == '1') {
554
- ctr += 1;
555
- }
556
- else if (b == '0') {
557
- ctr -= 1;
558
- t4 += t0[0];
559
- t0 = t0.slice(1);
560
- }
561
- if (ctr === 0) {
562
- t0 = '11' + t2 + t4 + '1' + t3 + t4 + t0;
563
- t2 = '';
564
- t3 = '';
565
- t4 = '';
566
- mode = 0;
567
- ctr = 1;
568
- stepCount += 1;
569
- }
524
+ };
525
+ const path = [start];
526
+ let current = start;
527
+ for (let i = 0; i < depth; i++) {
528
+ // Get unique neighbors (both directions)
529
+ const neighbors = new Set();
530
+ for (const rel of graph.relations) {
531
+ if (rel.from === current && rel.to !== current)
532
+ neighbors.add(rel.to);
533
+ if (rel.to === current && rel.from !== current)
534
+ neighbors.add(rel.from);
570
535
  }
536
+ // Filter to only existing entities
537
+ const validNeighbors = Array.from(neighbors).filter(n => graph.entities.some(e => e.name === n));
538
+ if (validNeighbors.length === 0)
539
+ break; // Dead end
540
+ // Pick random neighbor (uniform over entities)
541
+ const idx = Math.floor(random() * validNeighbors.length);
542
+ current = validNeighbors[idx];
543
+ path.push(current);
571
544
  }
572
- const halted = stepCount < maxSteps;
573
- return {
574
- result: t1,
575
- info: `${stepCount} steps, max size ${max_size}`,
576
- halted,
577
- errored: halted && mode != 0,
578
- };
545
+ return { entity: current, path };
579
546
  }
580
- async addBCLTerm(term) {
581
- const termset = ["1", "00", "01"];
582
- if (!term || term.trim() === "") {
583
- throw new Error("BCL term cannot be empty");
584
- }
585
- // Term can be 1, 00, 01, or K, S, App (application)
586
- const validTerms = ["1", "App", "00", "K", "01", "S"];
587
- if (!validTerms.includes(term)) {
588
- throw new Error(`Invalid BCL term: ${term}\nExpected one of: ${validTerms.join(", ")}`);
589
- }
590
- let processedTerm = 0;
591
- if (term === "00" || term === "K")
592
- processedTerm = 1;
593
- else if (term === "01" || term === "S")
594
- processedTerm = 2;
595
- this.bclTerm += termset[processedTerm];
596
- if (processedTerm === 0) {
597
- if (this.bclCtr === 0)
598
- this.bclCtr += 1;
599
- this.bclCtr += 1;
600
- }
601
- else {
602
- this.bclCtr -= 1;
603
- }
604
- if (this.bclCtr <= 0) {
605
- const constructedProgram = this.bclTerm;
606
- this.bclCtr = 0;
607
- this.bclTerm = "";
608
- return `Constructed Program: ${constructedProgram}`;
609
- }
610
- else {
611
- return `Need ${this.bclCtr} more term(s) to complete the program.`;
547
+ hashSeed(seed) {
548
+ // Simple string hash to 32-bit integer
549
+ let hash = 0;
550
+ for (let i = 0; i < seed.length; i++) {
551
+ const char = seed.charCodeAt(i);
552
+ hash = ((hash << 5) - hash) + char;
553
+ hash = hash & hash; // Convert to 32-bit integer
612
554
  }
555
+ return hash || 1; // Ensure non-zero for xorshift
613
556
  }
614
- async clearBCLTerm() {
615
- this.bclCtr = 0;
616
- this.bclTerm = "";
557
+ decodeTimestamp(timestamp, relative = false) {
558
+ const ts = timestamp ?? Date.now();
559
+ const date = new Date(ts);
560
+ const result = {
561
+ timestamp: ts,
562
+ iso8601: date.toISOString(),
563
+ formatted: date.toUTCString(),
564
+ };
565
+ if (relative) {
566
+ const now = Date.now();
567
+ const diffMs = now - ts;
568
+ const diffSec = Math.abs(diffMs) / 1000;
569
+ const diffMin = diffSec / 60;
570
+ const diffHour = diffMin / 60;
571
+ const diffDay = diffHour / 24;
572
+ let relStr;
573
+ if (diffSec < 60) {
574
+ relStr = `${Math.floor(diffSec)} seconds`;
575
+ }
576
+ else if (diffMin < 60) {
577
+ relStr = `${Math.floor(diffMin)} minutes`;
578
+ }
579
+ else if (diffHour < 24) {
580
+ relStr = `${Math.floor(diffHour)} hours`;
581
+ }
582
+ else if (diffDay < 30) {
583
+ relStr = `${Math.floor(diffDay)} days`;
584
+ }
585
+ else if (diffDay < 365) {
586
+ relStr = `${Math.floor(diffDay / 30)} months`;
587
+ }
588
+ else {
589
+ relStr = `${Math.floor(diffDay / 365)} years`;
590
+ }
591
+ result.relative = diffMs >= 0 ? `${relStr} ago` : `in ${relStr}`;
592
+ }
593
+ return result;
617
594
  }
618
595
  async addThought(observations, previousCtxId) {
619
596
  return this.withLock(async () => {
@@ -668,7 +645,7 @@ export function createServer(memoryFilePath) {
668
645
  sizes: ["any"]
669
646
  }
670
647
  ],
671
- version: "0.0.4",
648
+ version: "0.0.11",
672
649
  }, {
673
650
  capabilities: {
674
651
  tools: {},
@@ -821,6 +798,8 @@ export function createServer(memoryFilePath) {
821
798
  type: "object",
822
799
  properties: {
823
800
  query: { type: "string", description: "Regex pattern to match against entity names, types, and observations." },
801
+ sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for entities. Omit for insertion order." },
802
+ sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
824
803
  entityCursor: { type: "number", description: "Cursor for entity pagination (from previous response's nextCursor)" },
825
804
  relationCursor: { type: "number", description: "Cursor for relation pagination" },
826
805
  },
@@ -863,15 +842,15 @@ export function createServer(memoryFilePath) {
863
842
  },
864
843
  {
865
844
  name: "get_neighbors",
866
- description: "Get neighboring entities connected to a specific entity within a given depth. Results are paginated (max 512 chars).",
845
+ description: "Get names of neighboring entities connected to a specific entity within a given depth. Returns neighbor names with timestamps for sorting. Use open_nodes to get full entity data. Results are paginated (max 512 chars).",
867
846
  inputSchema: {
868
847
  type: "object",
869
848
  properties: {
870
849
  entityName: { type: "string", description: "The name of the entity to find neighbors for" },
871
- depth: { type: "number", description: "Maximum depth to traverse (default: 0)", default: 0 },
872
- withEntities: { type: "boolean", description: "If true, include full entity data. Default returns only relations for lightweight structure exploration.", default: false },
873
- entityCursor: { type: "number", description: "Cursor for entity pagination" },
874
- relationCursor: { type: "number", description: "Cursor for relation pagination" },
850
+ depth: { type: "number", description: "Maximum depth to traverse (default: 1)", default: 1 },
851
+ sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for neighbors. Omit for arbitrary order." },
852
+ sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
853
+ cursor: { type: "number", description: "Cursor for pagination" },
875
854
  },
876
855
  required: ["entityName"],
877
856
  },
@@ -897,6 +876,8 @@ export function createServer(memoryFilePath) {
897
876
  type: "object",
898
877
  properties: {
899
878
  entityType: { type: "string", description: "The type of entities to retrieve" },
879
+ sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for entities. Omit for insertion order." },
880
+ sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
900
881
  cursor: { type: "number", description: "Cursor for pagination" },
901
882
  },
902
883
  required: ["entityType"],
@@ -933,6 +914,8 @@ export function createServer(memoryFilePath) {
933
914
  type: "object",
934
915
  properties: {
935
916
  strict: { type: "boolean", description: "If true, returns entities not connected to 'Self' (directly or indirectly). Default: false" },
917
+ sortBy: { type: "string", enum: ["mtime", "obsMtime", "name"], description: "Sort field for entities. Omit for insertion order." },
918
+ sortDir: { type: "string", enum: ["asc", "desc"], description: "Sort direction. Default: desc for timestamps, asc for name." },
936
919
  cursor: { type: "number", description: "Cursor for pagination" },
937
920
  },
938
921
  },
@@ -946,37 +929,27 @@ export function createServer(memoryFilePath) {
946
929
  },
947
930
  },
948
931
  {
949
- name: "evaluate_bcl",
950
- description: "Evaluate a Binary Combinatory Logic (BCL) program",
932
+ name: "decode_timestamp",
933
+ description: "Decode a millisecond timestamp to human-readable UTC format. If no timestamp provided, returns the current time. Use this to interpret mtime/obsMtime values from entities.",
951
934
  inputSchema: {
952
935
  type: "object",
953
936
  properties: {
954
- program: { type: "string", description: "The BCL program as a binary string (syntax: T:=00|01|1TT) 00=K, 01=S, 1=application." },
955
- maxSteps: { type: "number", description: "Maximum number of reduction steps to perform (default: 1000000)", default: 1000000 },
937
+ timestamp: { type: "number", description: "Millisecond timestamp to decode. If omitted, returns current time." },
938
+ relative: { type: "boolean", description: "If true, include relative time (e.g., '3 days ago'). Default: false" },
956
939
  },
957
- required: ["program"],
958
940
  },
959
941
  },
960
942
  {
961
- name: "add_bcl_term",
962
- description: "Add a BCL term to the constructor, maintaining valid syntax. Returns completion status.",
943
+ name: "random_walk",
944
+ description: "Perform a random walk from a starting entity, following random relations. Returns the terminal entity name and the path taken. Useful for serendipitous exploration of the knowledge graph.",
963
945
  inputSchema: {
964
946
  type: "object",
965
947
  properties: {
966
- term: {
967
- type: "string",
968
- description: "BCL term to add. Valid values: '1' or 'App' (application), '00' or 'K' (K combinator), '01' or 'S' (S combinator)"
969
- },
948
+ start: { type: "string", description: "Name of the entity to start the walk from." },
949
+ depth: { type: "number", description: "Number of steps to take. Default: 3" },
950
+ seed: { type: "string", description: "Optional seed for reproducible walks." },
970
951
  },
971
- required: ["term"],
972
- },
973
- },
974
- {
975
- name: "clear_bcl_term",
976
- description: "Clear the current BCL term being constructed and reset the constructor state",
977
- inputSchema: {
978
- type: "object",
979
- properties: {},
952
+ required: ["start"],
980
953
  },
981
954
  },
982
955
  {
@@ -1026,7 +999,7 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
1026
999
  await knowledgeGraphManager.deleteRelations(args.relations);
1027
1000
  return { content: [{ type: "text", text: "Relations deleted successfully" }] };
1028
1001
  case "search_nodes": {
1029
- const graph = await knowledgeGraphManager.searchNodes(args.query);
1002
+ const graph = await knowledgeGraphManager.searchNodes(args.query, args.sortBy, args.sortDir);
1030
1003
  return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1031
1004
  }
1032
1005
  case "open_nodes_filtered": {
@@ -1038,15 +1011,15 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
1038
1011
  return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1039
1012
  }
1040
1013
  case "get_neighbors": {
1041
- const graph = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities);
1042
- return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1014
+ const neighbors = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth ?? 1, args.sortBy, args.sortDir);
1015
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(neighbors, args.cursor ?? 0)) }] };
1043
1016
  }
1044
1017
  case "find_path": {
1045
1018
  const path = await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth);
1046
1019
  return { content: [{ type: "text", text: JSON.stringify(paginateItems(path, args.cursor ?? 0)) }] };
1047
1020
  }
1048
1021
  case "get_entities_by_type": {
1049
- const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType);
1022
+ const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType, args.sortBy, args.sortDir);
1050
1023
  return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1051
1024
  }
1052
1025
  case "get_entity_types":
@@ -1056,18 +1029,17 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
1056
1029
  case "get_stats":
1057
1030
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
1058
1031
  case "get_orphaned_entities": {
1059
- const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false);
1032
+ const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false, args.sortBy, args.sortDir);
1060
1033
  return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1061
1034
  }
1062
1035
  case "validate_graph":
1063
1036
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.validateGraph(), null, 2) }] };
1064
- case "evaluate_bcl":
1065
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.evaluateBCL(args.program, args.maxSteps), null, 2) }] };
1066
- case "add_bcl_term":
1067
- return { content: [{ type: "text", text: await knowledgeGraphManager.addBCLTerm(args.term) }] };
1068
- case "clear_bcl_term":
1069
- await knowledgeGraphManager.clearBCLTerm();
1070
- return { content: [{ type: "text", text: "BCL term constructor cleared successfully" }] };
1037
+ case "decode_timestamp":
1038
+ return { content: [{ type: "text", text: JSON.stringify(knowledgeGraphManager.decodeTimestamp(args.timestamp, args.relative ?? false)) }] };
1039
+ case "random_walk": {
1040
+ const result = await knowledgeGraphManager.randomWalk(args.start, args.depth ?? 3, args.seed);
1041
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1042
+ }
1071
1043
  case "sequentialthinking": {
1072
1044
  const result = await knowledgeGraphManager.addThought(args.observations, args.previousCtxId);
1073
1045
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
@@ -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', {
@@ -478,35 +457,6 @@ describe('MCP Memory Server E2E Tests', () => {
478
457
  expect(result.missingEntities).toContain('Missing');
479
458
  });
480
459
  });
481
- describe('BCL Evaluator', () => {
482
- it('should evaluate K combinator (identity for first arg)', async () => {
483
- // K = 00, evaluating K applied to two args should return first
484
- // This is a simplified test - BCL semantics are complex
485
- const result = await callTool(client, 'evaluate_bcl', {
486
- program: '00',
487
- maxSteps: 100
488
- });
489
- expect(result.halted).toBe(true);
490
- });
491
- it('should construct BCL terms incrementally', async () => {
492
- let result = await callTool(client, 'add_bcl_term', { term: 'App' });
493
- expect(result).toContain('more term');
494
- result = await callTool(client, 'add_bcl_term', { term: 'K' });
495
- expect(result).toContain('more term');
496
- result = await callTool(client, 'add_bcl_term', { term: 'S' });
497
- expect(result).toContain('Constructed Program');
498
- });
499
- it('should clear BCL constructor state', async () => {
500
- await callTool(client, 'add_bcl_term', { term: 'App' });
501
- await callTool(client, 'clear_bcl_term', {});
502
- // After clearing, we should need to start fresh
503
- const result = await callTool(client, 'add_bcl_term', { term: 'K' });
504
- expect(result).toContain('Constructed Program');
505
- });
506
- it('should reject invalid BCL terms', async () => {
507
- await expect(callTool(client, 'add_bcl_term', { term: 'invalid' })).rejects.toThrow(/Invalid BCL term/);
508
- });
509
- });
510
460
  describe('Sequential Thinking', () => {
511
461
  it('should create a thought and return ctxId', async () => {
512
462
  const result = await callTool(client, 'sequentialthinking', {
@@ -524,13 +474,13 @@ describe('MCP Memory Server E2E Tests', () => {
524
474
  previousCtxId: first.ctxId,
525
475
  observations: ['Following up']
526
476
  });
527
- // Verify the chain via relations
477
+ // Verify the chain via neighbors
528
478
  const neighbors = await callTool(client, 'get_neighbors', {
529
479
  entityName: first.ctxId,
530
480
  depth: 1
531
481
  });
532
- // Should have 'follows' relation from first to second
533
- expect(neighbors.relations.items.some(r => r.from === first.ctxId && r.to === second.ctxId && r.relationType === 'follows')).toBe(true);
482
+ // Second thought should be a neighbor of first
483
+ expect(neighbors.items.some(n => n.name === second.ctxId)).toBe(true);
534
484
  });
535
485
  it('should ignore invalid previousCtxId gracefully', async () => {
536
486
  const result = await callTool(client, 'sequentialthinking', {
@@ -538,12 +488,12 @@ describe('MCP Memory Server E2E Tests', () => {
538
488
  observations: ['Orphaned thought']
539
489
  });
540
490
  expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
541
- // Verify no relations were created
491
+ // Verify no neighbors (no valid relations were created)
542
492
  const neighbors = await callTool(client, 'get_neighbors', {
543
493
  entityName: result.ctxId,
544
494
  depth: 1
545
495
  });
546
- expect(neighbors.relations.totalCount).toBe(0);
496
+ expect(neighbors.items).toHaveLength(0);
547
497
  });
548
498
  it('should enforce observation limits on thoughts', async () => {
549
499
  await expect(callTool(client, 'sequentialthinking', {
@@ -562,4 +512,408 @@ describe('MCP Memory Server E2E Tests', () => {
562
512
  expect(thought.obsMtime).toBeDefined();
563
513
  });
564
514
  });
515
+ describe('Timestamp Decoding', () => {
516
+ it('should decode a specific timestamp', async () => {
517
+ const result = await callTool(client, 'decode_timestamp', {
518
+ timestamp: 1735200000000 // Known timestamp
519
+ });
520
+ expect(result.timestamp).toBe(1735200000000);
521
+ expect(result.iso8601).toBe('2024-12-26T08:00:00.000Z');
522
+ expect(result.formatted).toContain('2024');
523
+ });
524
+ it('should return current time when no timestamp provided', async () => {
525
+ const before = Date.now();
526
+ const result = await callTool(client, 'decode_timestamp', {});
527
+ const after = Date.now();
528
+ expect(result.timestamp).toBeGreaterThanOrEqual(before);
529
+ expect(result.timestamp).toBeLessThanOrEqual(after);
530
+ });
531
+ it('should include relative time when requested', async () => {
532
+ const oneHourAgo = Date.now() - 3600000;
533
+ const result = await callTool(client, 'decode_timestamp', {
534
+ timestamp: oneHourAgo,
535
+ relative: true
536
+ });
537
+ expect(result.relative).toContain('hour');
538
+ expect(result.relative).toContain('ago');
539
+ });
540
+ it('should handle future timestamps', async () => {
541
+ const oneHourFromNow = Date.now() + 3600000;
542
+ const result = await callTool(client, 'decode_timestamp', {
543
+ timestamp: oneHourFromNow,
544
+ relative: true
545
+ });
546
+ expect(result.relative).toContain('in');
547
+ });
548
+ });
549
+ describe('Random Walk', () => {
550
+ beforeEach(async () => {
551
+ // Create a small graph for walking
552
+ await callTool(client, 'create_entities', {
553
+ entities: [
554
+ { name: 'Center', entityType: 'Node', observations: ['Hub node'] },
555
+ { name: 'North', entityType: 'Node', observations: ['North node'] },
556
+ { name: 'South', entityType: 'Node', observations: ['South node'] },
557
+ { name: 'East', entityType: 'Node', observations: ['East node'] },
558
+ { name: 'Isolated', entityType: 'Node', observations: ['No connections'] },
559
+ ]
560
+ });
561
+ await callTool(client, 'create_relations', {
562
+ relations: [
563
+ { from: 'Center', to: 'North', relationType: 'connects' },
564
+ { from: 'Center', to: 'South', relationType: 'connects' },
565
+ { from: 'Center', to: 'East', relationType: 'connects' },
566
+ { from: 'North', to: 'South', relationType: 'connects' },
567
+ ]
568
+ });
569
+ });
570
+ it('should perform a walk and return path', async () => {
571
+ const result = await callTool(client, 'random_walk', {
572
+ start: 'Center',
573
+ depth: 2
574
+ });
575
+ expect(result.path[0]).toBe('Center');
576
+ expect(result.path.length).toBeGreaterThanOrEqual(1);
577
+ expect(result.path.length).toBeLessThanOrEqual(3);
578
+ expect(result.entity).toBe(result.path[result.path.length - 1]);
579
+ });
580
+ it('should terminate early at dead ends', async () => {
581
+ const result = await callTool(client, 'random_walk', {
582
+ start: 'Isolated',
583
+ depth: 5
584
+ });
585
+ expect(result.path).toEqual(['Isolated']);
586
+ expect(result.entity).toBe('Isolated');
587
+ });
588
+ it('should produce reproducible walks with same seed', async () => {
589
+ const result1 = await callTool(client, 'random_walk', {
590
+ start: 'Center',
591
+ depth: 3,
592
+ seed: 'test-seed-123'
593
+ });
594
+ const result2 = await callTool(client, 'random_walk', {
595
+ start: 'Center',
596
+ depth: 3,
597
+ seed: 'test-seed-123'
598
+ });
599
+ expect(result1.path).toEqual(result2.path);
600
+ });
601
+ it('should throw on non-existent start entity', async () => {
602
+ await expect(callTool(client, 'random_walk', { start: 'NonExistent', depth: 2 })).rejects.toThrow(/not found/);
603
+ });
604
+ });
605
+ describe('Sorting', () => {
606
+ // Helper to create entities with controlled timestamps
607
+ async function createEntitiesWithDelay(entities) {
608
+ for (const entity of entities) {
609
+ await callTool(client, 'create_entities', { entities: [entity] });
610
+ // Small delay to ensure distinct mtime values
611
+ await new Promise(resolve => setTimeout(resolve, 10));
612
+ }
613
+ }
614
+ describe('search_nodes sorting', () => {
615
+ beforeEach(async () => {
616
+ // Create entities with distinct timestamps
617
+ await createEntitiesWithDelay([
618
+ { name: 'Alpha', entityType: 'Letter', observations: ['First letter'] },
619
+ { name: 'Beta', entityType: 'Letter', observations: ['Second letter'] },
620
+ { name: 'Gamma', entityType: 'Letter', observations: ['Third letter'] }
621
+ ]);
622
+ });
623
+ it('should preserve insertion order when sortBy is omitted', async () => {
624
+ const result = await callTool(client, 'search_nodes', {
625
+ query: 'Letter'
626
+ });
627
+ const names = result.entities.items.map(e => e.name);
628
+ expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
629
+ });
630
+ it('should sort by name ascending', async () => {
631
+ const result = await callTool(client, 'search_nodes', {
632
+ query: 'Letter',
633
+ sortBy: 'name',
634
+ sortDir: 'asc'
635
+ });
636
+ const names = result.entities.items.map(e => e.name);
637
+ expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
638
+ });
639
+ it('should sort by name descending', async () => {
640
+ const result = await callTool(client, 'search_nodes', {
641
+ query: 'Letter',
642
+ sortBy: 'name',
643
+ sortDir: 'desc'
644
+ });
645
+ const names = result.entities.items.map(e => e.name);
646
+ expect(names).toEqual(['Gamma', 'Beta', 'Alpha']);
647
+ });
648
+ it('should sort by mtime descending by default', async () => {
649
+ const result = await callTool(client, 'search_nodes', {
650
+ query: 'Letter',
651
+ sortBy: 'mtime'
652
+ });
653
+ const names = result.entities.items.map(e => e.name);
654
+ // Gamma was created last, so should be first when sorted desc
655
+ expect(names).toEqual(['Gamma', 'Beta', 'Alpha']);
656
+ });
657
+ it('should sort by mtime ascending when specified', async () => {
658
+ const result = await callTool(client, 'search_nodes', {
659
+ query: 'Letter',
660
+ sortBy: 'mtime',
661
+ sortDir: 'asc'
662
+ });
663
+ const names = result.entities.items.map(e => e.name);
664
+ // Alpha was created first, so should be first when sorted asc
665
+ expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
666
+ });
667
+ it('should sort by obsMtime', async () => {
668
+ // Update observation on Alpha to make it have most recent obsMtime
669
+ await callTool(client, 'delete_observations', {
670
+ deletions: [{ entityName: 'Alpha', observations: ['First letter'] }]
671
+ });
672
+ await callTool(client, 'add_observations', {
673
+ observations: [{ entityName: 'Alpha', contents: ['Updated'] }]
674
+ });
675
+ const result = await callTool(client, 'search_nodes', {
676
+ query: 'Letter|Updated',
677
+ sortBy: 'obsMtime'
678
+ });
679
+ const names = result.entities.items.map(e => e.name);
680
+ // Alpha should be first because its obsMtime was just updated
681
+ expect(names[0]).toBe('Alpha');
682
+ });
683
+ });
684
+ describe('get_entities_by_type sorting', () => {
685
+ beforeEach(async () => {
686
+ await createEntitiesWithDelay([
687
+ { name: 'Zebra', entityType: 'Animal', observations: ['Striped'] },
688
+ { name: 'Aardvark', entityType: 'Animal', observations: ['Nocturnal'] },
689
+ { name: 'Monkey', entityType: 'Animal', observations: ['Clever'] }
690
+ ]);
691
+ });
692
+ it('should preserve insertion order when sortBy is omitted', async () => {
693
+ const result = await callTool(client, 'get_entities_by_type', {
694
+ entityType: 'Animal'
695
+ });
696
+ const names = result.items.map(e => e.name);
697
+ expect(names).toEqual(['Zebra', 'Aardvark', 'Monkey']);
698
+ });
699
+ it('should sort by name ascending (default for name)', async () => {
700
+ const result = await callTool(client, 'get_entities_by_type', {
701
+ entityType: 'Animal',
702
+ sortBy: 'name'
703
+ });
704
+ const names = result.items.map(e => e.name);
705
+ expect(names).toEqual(['Aardvark', 'Monkey', 'Zebra']);
706
+ });
707
+ it('should sort by name descending', async () => {
708
+ const result = await callTool(client, 'get_entities_by_type', {
709
+ entityType: 'Animal',
710
+ sortBy: 'name',
711
+ sortDir: 'desc'
712
+ });
713
+ const names = result.items.map(e => e.name);
714
+ expect(names).toEqual(['Zebra', 'Monkey', 'Aardvark']);
715
+ });
716
+ it('should sort by mtime descending (default for mtime)', async () => {
717
+ const result = await callTool(client, 'get_entities_by_type', {
718
+ entityType: 'Animal',
719
+ sortBy: 'mtime'
720
+ });
721
+ const names = result.items.map(e => e.name);
722
+ // Monkey was created last
723
+ expect(names).toEqual(['Monkey', 'Aardvark', 'Zebra']);
724
+ });
725
+ it('should sort by mtime ascending', async () => {
726
+ const result = await callTool(client, 'get_entities_by_type', {
727
+ entityType: 'Animal',
728
+ sortBy: 'mtime',
729
+ sortDir: 'asc'
730
+ });
731
+ const names = result.items.map(e => e.name);
732
+ // Zebra was created first
733
+ expect(names).toEqual(['Zebra', 'Aardvark', 'Monkey']);
734
+ });
735
+ });
736
+ describe('get_orphaned_entities sorting', () => {
737
+ beforeEach(async () => {
738
+ // Create orphaned entities (no relations)
739
+ await createEntitiesWithDelay([
740
+ { name: 'Orphan_Z', entityType: 'Orphan', observations: ['Alone'] },
741
+ { name: 'Orphan_A', entityType: 'Orphan', observations: ['Solo'] },
742
+ { name: 'Orphan_M', entityType: 'Orphan', observations: ['Isolated'] }
743
+ ]);
744
+ });
745
+ it('should preserve insertion order when sortBy is omitted', async () => {
746
+ const result = await callTool(client, 'get_orphaned_entities', {});
747
+ const names = result.items.map(e => e.name);
748
+ expect(names).toEqual(['Orphan_Z', 'Orphan_A', 'Orphan_M']);
749
+ });
750
+ it('should sort by name ascending', async () => {
751
+ const result = await callTool(client, 'get_orphaned_entities', {
752
+ sortBy: 'name'
753
+ });
754
+ const names = result.items.map(e => e.name);
755
+ expect(names).toEqual(['Orphan_A', 'Orphan_M', 'Orphan_Z']);
756
+ });
757
+ it('should sort by name descending', async () => {
758
+ const result = await callTool(client, 'get_orphaned_entities', {
759
+ sortBy: 'name',
760
+ sortDir: 'desc'
761
+ });
762
+ const names = result.items.map(e => e.name);
763
+ expect(names).toEqual(['Orphan_Z', 'Orphan_M', 'Orphan_A']);
764
+ });
765
+ it('should sort by mtime descending (default)', async () => {
766
+ const result = await callTool(client, 'get_orphaned_entities', {
767
+ sortBy: 'mtime'
768
+ });
769
+ const names = result.items.map(e => e.name);
770
+ // Orphan_M was created last
771
+ expect(names).toEqual(['Orphan_M', 'Orphan_A', 'Orphan_Z']);
772
+ });
773
+ it('should work with strict mode and sorting', async () => {
774
+ // Create Self and connect one orphan to it
775
+ await callTool(client, 'create_entities', {
776
+ entities: [{ name: 'Self', entityType: 'Agent', observations: [] }]
777
+ });
778
+ await callTool(client, 'create_relations', {
779
+ relations: [{ from: 'Self', to: 'Orphan_A', relationType: 'knows' }]
780
+ });
781
+ const result = await callTool(client, 'get_orphaned_entities', {
782
+ strict: true,
783
+ sortBy: 'name'
784
+ });
785
+ const names = result.items.map(e => e.name);
786
+ // Orphan_A is now connected to Self, so only M and Z are orphaned
787
+ expect(names).toEqual(['Orphan_M', 'Orphan_Z']);
788
+ });
789
+ });
790
+ describe('get_neighbors sorting', () => {
791
+ beforeEach(async () => {
792
+ // Create a hub with neighbors created at different times
793
+ await callTool(client, 'create_entities', {
794
+ entities: [{ name: 'Hub', entityType: 'Center', observations: [] }]
795
+ });
796
+ await createEntitiesWithDelay([
797
+ { name: 'Neighbor_Z', entityType: 'Node', observations: ['First'] },
798
+ { name: 'Neighbor_A', entityType: 'Node', observations: ['Second'] },
799
+ { name: 'Neighbor_M', entityType: 'Node', observations: ['Third'] }
800
+ ]);
801
+ // Connect all to Hub
802
+ await callTool(client, 'create_relations', {
803
+ relations: [
804
+ { from: 'Hub', to: 'Neighbor_Z', relationType: 'connects' },
805
+ { from: 'Hub', to: 'Neighbor_A', relationType: 'connects' },
806
+ { from: 'Hub', to: 'Neighbor_M', relationType: 'connects' }
807
+ ]
808
+ });
809
+ });
810
+ it('should return unsorted neighbors when sortBy is omitted', async () => {
811
+ const result = await callTool(client, 'get_neighbors', {
812
+ entityName: 'Hub'
813
+ });
814
+ expect(result.items).toHaveLength(3);
815
+ // Just verify all neighbors are present
816
+ const names = result.items.map(n => n.name);
817
+ expect(names).toContain('Neighbor_Z');
818
+ expect(names).toContain('Neighbor_A');
819
+ expect(names).toContain('Neighbor_M');
820
+ });
821
+ it('should sort neighbors by name ascending', async () => {
822
+ const result = await callTool(client, 'get_neighbors', {
823
+ entityName: 'Hub',
824
+ sortBy: 'name'
825
+ });
826
+ const names = result.items.map(n => n.name);
827
+ expect(names).toEqual(['Neighbor_A', 'Neighbor_M', 'Neighbor_Z']);
828
+ });
829
+ it('should sort neighbors by name descending', async () => {
830
+ const result = await callTool(client, 'get_neighbors', {
831
+ entityName: 'Hub',
832
+ sortBy: 'name',
833
+ sortDir: 'desc'
834
+ });
835
+ const names = result.items.map(n => n.name);
836
+ expect(names).toEqual(['Neighbor_Z', 'Neighbor_M', 'Neighbor_A']);
837
+ });
838
+ it('should sort neighbors by mtime descending (default)', async () => {
839
+ const result = await callTool(client, 'get_neighbors', {
840
+ entityName: 'Hub',
841
+ sortBy: 'mtime'
842
+ });
843
+ const names = result.items.map(n => n.name);
844
+ // Neighbor_M was created last
845
+ expect(names).toEqual(['Neighbor_M', 'Neighbor_A', 'Neighbor_Z']);
846
+ });
847
+ it('should sort neighbors by mtime ascending', async () => {
848
+ const result = await callTool(client, 'get_neighbors', {
849
+ entityName: 'Hub',
850
+ sortBy: 'mtime',
851
+ sortDir: 'asc'
852
+ });
853
+ const names = result.items.map(n => n.name);
854
+ // Neighbor_Z was created first
855
+ expect(names).toEqual(['Neighbor_Z', 'Neighbor_A', 'Neighbor_M']);
856
+ });
857
+ it('should include mtime and obsMtime in neighbor objects', async () => {
858
+ const result = await callTool(client, 'get_neighbors', {
859
+ entityName: 'Hub',
860
+ sortBy: 'name'
861
+ });
862
+ // Each neighbor should have timestamp fields
863
+ for (const neighbor of result.items) {
864
+ expect(neighbor.mtime).toBeDefined();
865
+ expect(neighbor.obsMtime).toBeDefined();
866
+ expect(typeof neighbor.mtime).toBe('number');
867
+ expect(typeof neighbor.obsMtime).toBe('number');
868
+ }
869
+ });
870
+ it('should sort by obsMtime after observation update', async () => {
871
+ // Update observation on Neighbor_Z to make it have most recent obsMtime
872
+ await callTool(client, 'delete_observations', {
873
+ deletions: [{ entityName: 'Neighbor_Z', observations: ['First'] }]
874
+ });
875
+ await callTool(client, 'add_observations', {
876
+ observations: [{ entityName: 'Neighbor_Z', contents: ['Updated recently'] }]
877
+ });
878
+ const result = await callTool(client, 'get_neighbors', {
879
+ entityName: 'Hub',
880
+ sortBy: 'obsMtime'
881
+ });
882
+ const names = result.items.map(n => n.name);
883
+ // Neighbor_Z should be first because its obsMtime was just updated
884
+ expect(names[0]).toBe('Neighbor_Z');
885
+ });
886
+ });
887
+ describe('sorting with pagination', () => {
888
+ it('should maintain sort order across paginated results', async () => {
889
+ // Create many entities to force pagination
890
+ const entities = [];
891
+ for (let i = 0; i < 20; i++) {
892
+ entities.push({
893
+ name: `Entity_${String(i).padStart(2, '0')}`,
894
+ entityType: 'Numbered',
895
+ observations: [`Number ${i}`]
896
+ });
897
+ }
898
+ await callTool(client, 'create_entities', { entities });
899
+ // Fetch all pages sorted by name descending
900
+ const allEntities = [];
901
+ let entityCursor = 0;
902
+ while (entityCursor !== null) {
903
+ const result = await callTool(client, 'search_nodes', {
904
+ query: 'Numbered',
905
+ sortBy: 'name',
906
+ sortDir: 'desc',
907
+ entityCursor
908
+ });
909
+ allEntities.push(...result.entities.items);
910
+ entityCursor = result.entities.nextCursor;
911
+ }
912
+ // Verify all entities are in descending order
913
+ const names = allEntities.map(e => e.name);
914
+ const sortedNames = [...names].sort().reverse();
915
+ expect(names).toEqual(sortedNames);
916
+ });
917
+ });
918
+ });
565
919
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "MCP server for enabling memory for Claude through a knowledge graph",
5
5
  "license": "MIT",
6
6
  "author": "Levalicious",
@@ -15,18 +15,19 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "tsc && shx chmod +x dist/*.js",
18
- "prepare": "npm run build",
18
+ "prepare": "husky && npm run build",
19
19
  "watch": "tsc --watch",
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
+ "husky": "^9.1.7",
30
31
  "jest": "^30.2.0",
31
32
  "shx": "^0.4.0",
32
33
  "ts-jest": "^29.4.5",