@levalicious/server-memory 0.0.10 → 0.0.12

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.
@@ -44,9 +44,10 @@ describe('MCP Memory Server E2E Tests', () => {
44
44
  await callTool(client, 'create_entities', {
45
45
  entities: [{ name: 'Alice', entityType: 'Person', observations: ['First'] }]
46
46
  });
47
+ // Exact same entity should be silently skipped
47
48
  const result = await callTool(client, 'create_entities', {
48
49
  entities: [
49
- { name: 'Alice', entityType: 'Person', observations: ['Second'] },
50
+ { name: 'Alice', entityType: 'Person', observations: ['First'] },
50
51
  { name: 'Bob', entityType: 'Person', observations: ['New'] }
51
52
  ]
52
53
  });
@@ -54,6 +55,19 @@ describe('MCP Memory Server E2E Tests', () => {
54
55
  expect(result).toHaveLength(1);
55
56
  expect(result[0].name).toBe('Bob');
56
57
  });
58
+ it('should error on duplicate name with different data', async () => {
59
+ await callTool(client, 'create_entities', {
60
+ entities: [{ name: 'Alice', entityType: 'Person', observations: ['First'] }]
61
+ });
62
+ // Same name, different type — should error
63
+ await expect(callTool(client, 'create_entities', {
64
+ entities: [{ name: 'Alice', entityType: 'Organization', observations: ['First'] }]
65
+ })).rejects.toThrow(/already exists/);
66
+ // Same name, different observations — should error
67
+ await expect(callTool(client, 'create_entities', {
68
+ entities: [{ name: 'Alice', entityType: 'Person', observations: ['Different'] }]
69
+ })).rejects.toThrow(/already exists/);
70
+ });
57
71
  it('should reject entities with more than 2 observations', async () => {
58
72
  await expect(callTool(client, 'create_entities', {
59
73
  entities: [{
@@ -257,13 +271,6 @@ describe('MCP Memory Server E2E Tests', () => {
257
271
  // A->B and A->C both have from='A' which is in the set
258
272
  expect(result.relations.items).toHaveLength(2);
259
273
  });
260
- it('should open nodes filtered (only internal relations)', async () => {
261
- const result = await callTool(client, 'open_nodes_filtered', {
262
- names: ['B', 'C']
263
- });
264
- expect(result.entities.items).toHaveLength(2);
265
- expect(result.relations.items).toHaveLength(0); // No relations between B and C
266
- });
267
274
  });
268
275
  describe('Graph Traversal', () => {
269
276
  beforeEach(async () => {
@@ -446,44 +453,21 @@ describe('MCP Memory Server E2E Tests', () => {
446
453
  const names = strict.items.map(e => e.name).sort();
447
454
  expect(names).toEqual(['Island1', 'Island2']);
448
455
  });
449
- it('should validate graph and report violations', async () => {
450
- // Directly write invalid data to test validation
451
- const invalidData = [
452
- JSON.stringify({ type: 'entity', name: 'Valid', entityType: 'Test', observations: [] }),
453
- JSON.stringify({ type: 'relation', from: 'Valid', to: 'Missing', relationType: 'refs' })
454
- ].join('\n');
455
- await fs.writeFile(memoryFile, invalidData);
456
+ it('should validate graph and report no violations on clean graph', async () => {
457
+ // Create a valid graph through the API
458
+ await callTool(client, 'create_entities', {
459
+ entities: [
460
+ { name: 'Valid', entityType: 'Test', observations: [] },
461
+ { name: 'Also_Valid', entityType: 'Test', observations: ['Short obs'] }
462
+ ]
463
+ });
464
+ await callTool(client, 'create_relations', {
465
+ relations: [{ from: 'Valid', to: 'Also_Valid', relationType: 'refs' }]
466
+ });
456
467
  const result = await callTool(client, 'validate_graph', {});
457
- expect(result.missingEntities).toContain('Missing');
458
- });
459
- });
460
- describe('BCL Evaluator', () => {
461
- it('should evaluate K combinator (identity for first arg)', async () => {
462
- // K = 00, evaluating K applied to two args should return first
463
- // This is a simplified test - BCL semantics are complex
464
- const result = await callTool(client, 'evaluate_bcl', {
465
- program: '00',
466
- maxSteps: 100
467
- });
468
- expect(result.halted).toBe(true);
469
- });
470
- it('should construct BCL terms incrementally', async () => {
471
- let result = await callTool(client, 'add_bcl_term', { term: 'App' });
472
- expect(result).toContain('more term');
473
- result = await callTool(client, 'add_bcl_term', { term: 'K' });
474
- expect(result).toContain('more term');
475
- result = await callTool(client, 'add_bcl_term', { term: 'S' });
476
- expect(result).toContain('Constructed Program');
477
- });
478
- it('should clear BCL constructor state', async () => {
479
- await callTool(client, 'add_bcl_term', { term: 'App' });
480
- await callTool(client, 'clear_bcl_term', {});
481
- // After clearing, we should need to start fresh
482
- const result = await callTool(client, 'add_bcl_term', { term: 'K' });
483
- expect(result).toContain('Constructed Program');
484
- });
485
- it('should reject invalid BCL terms', async () => {
486
- await expect(callTool(client, 'add_bcl_term', { term: 'invalid' })).rejects.toThrow(/Invalid BCL term/);
468
+ // Binary store enforces referential integrity — no missing entities possible
469
+ expect(result.missingEntities).toHaveLength(0);
470
+ expect(result.observationViolations).toHaveLength(0);
487
471
  });
488
472
  });
489
473
  describe('Sequential Thinking', () => {
@@ -541,6 +525,96 @@ describe('MCP Memory Server E2E Tests', () => {
541
525
  expect(thought.obsMtime).toBeDefined();
542
526
  });
543
527
  });
528
+ describe('Timestamp Decoding', () => {
529
+ it('should decode a specific timestamp', async () => {
530
+ const result = await callTool(client, 'decode_timestamp', {
531
+ timestamp: 1735200000000 // Known timestamp
532
+ });
533
+ expect(result.timestamp).toBe(1735200000000);
534
+ expect(result.iso8601).toBe('2024-12-26T08:00:00.000Z');
535
+ expect(result.formatted).toContain('2024');
536
+ });
537
+ it('should return current time when no timestamp provided', async () => {
538
+ const before = Date.now();
539
+ const result = await callTool(client, 'decode_timestamp', {});
540
+ const after = Date.now();
541
+ expect(result.timestamp).toBeGreaterThanOrEqual(before);
542
+ expect(result.timestamp).toBeLessThanOrEqual(after);
543
+ });
544
+ it('should include relative time when requested', async () => {
545
+ const oneHourAgo = Date.now() - 3600000;
546
+ const result = await callTool(client, 'decode_timestamp', {
547
+ timestamp: oneHourAgo,
548
+ relative: true
549
+ });
550
+ expect(result.relative).toContain('hour');
551
+ expect(result.relative).toContain('ago');
552
+ });
553
+ it('should handle future timestamps', async () => {
554
+ const oneHourFromNow = Date.now() + 3600000;
555
+ const result = await callTool(client, 'decode_timestamp', {
556
+ timestamp: oneHourFromNow,
557
+ relative: true
558
+ });
559
+ expect(result.relative).toContain('in');
560
+ });
561
+ });
562
+ describe('Random Walk', () => {
563
+ beforeEach(async () => {
564
+ // Create a small graph for walking
565
+ await callTool(client, 'create_entities', {
566
+ entities: [
567
+ { name: 'Center', entityType: 'Node', observations: ['Hub node'] },
568
+ { name: 'North', entityType: 'Node', observations: ['North node'] },
569
+ { name: 'South', entityType: 'Node', observations: ['South node'] },
570
+ { name: 'East', entityType: 'Node', observations: ['East node'] },
571
+ { name: 'Isolated', entityType: 'Node', observations: ['No connections'] },
572
+ ]
573
+ });
574
+ await callTool(client, 'create_relations', {
575
+ relations: [
576
+ { from: 'Center', to: 'North', relationType: 'connects' },
577
+ { from: 'Center', to: 'South', relationType: 'connects' },
578
+ { from: 'Center', to: 'East', relationType: 'connects' },
579
+ { from: 'North', to: 'South', relationType: 'connects' },
580
+ ]
581
+ });
582
+ });
583
+ it('should perform a walk and return path', async () => {
584
+ const result = await callTool(client, 'random_walk', {
585
+ start: 'Center',
586
+ depth: 2
587
+ });
588
+ expect(result.path[0]).toBe('Center');
589
+ expect(result.path.length).toBeGreaterThanOrEqual(1);
590
+ expect(result.path.length).toBeLessThanOrEqual(3);
591
+ expect(result.entity).toBe(result.path[result.path.length - 1]);
592
+ });
593
+ it('should terminate early at dead ends', async () => {
594
+ const result = await callTool(client, 'random_walk', {
595
+ start: 'Isolated',
596
+ depth: 5
597
+ });
598
+ expect(result.path).toEqual(['Isolated']);
599
+ expect(result.entity).toBe('Isolated');
600
+ });
601
+ it('should produce reproducible walks with same seed', async () => {
602
+ const result1 = await callTool(client, 'random_walk', {
603
+ start: 'Center',
604
+ depth: 3,
605
+ seed: 'test-seed-123'
606
+ });
607
+ const result2 = await callTool(client, 'random_walk', {
608
+ start: 'Center',
609
+ depth: 3,
610
+ seed: 'test-seed-123'
611
+ });
612
+ expect(result1.path).toEqual(result2.path);
613
+ });
614
+ it('should throw on non-existent start entity', async () => {
615
+ await expect(callTool(client, 'random_walk', { start: 'NonExistent', depth: 2 })).rejects.toThrow(/not found/);
616
+ });
617
+ });
544
618
  describe('Sorting', () => {
545
619
  // Helper to create entities with controlled timestamps
546
620
  async function createEntitiesWithDelay(entities) {
@@ -559,12 +633,16 @@ describe('MCP Memory Server E2E Tests', () => {
559
633
  { name: 'Gamma', entityType: 'Letter', observations: ['Third letter'] }
560
634
  ]);
561
635
  });
562
- it('should preserve insertion order when sortBy is omitted', async () => {
636
+ it('should use llmrank as default sort when sortBy is omitted', async () => {
563
637
  const result = await callTool(client, 'search_nodes', {
564
638
  query: 'Letter'
565
639
  });
640
+ // With llmrank default, all entities returned (order varies due to random tiebreak)
566
641
  const names = result.entities.items.map(e => e.name);
567
- expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
642
+ expect(names).toHaveLength(3);
643
+ expect(names).toContain('Alpha');
644
+ expect(names).toContain('Beta');
645
+ expect(names).toContain('Gamma');
568
646
  });
569
647
  it('should sort by name ascending', async () => {
570
648
  const result = await callTool(client, 'search_nodes', {
@@ -628,12 +706,16 @@ describe('MCP Memory Server E2E Tests', () => {
628
706
  { name: 'Monkey', entityType: 'Animal', observations: ['Clever'] }
629
707
  ]);
630
708
  });
631
- it('should preserve insertion order when sortBy is omitted', async () => {
709
+ it('should use llmrank as default sort when sortBy is omitted', async () => {
632
710
  const result = await callTool(client, 'get_entities_by_type', {
633
711
  entityType: 'Animal'
634
712
  });
713
+ // With llmrank default, all entities returned (order varies due to random tiebreak)
635
714
  const names = result.items.map(e => e.name);
636
- expect(names).toEqual(['Zebra', 'Aardvark', 'Monkey']);
715
+ expect(names).toHaveLength(3);
716
+ expect(names).toContain('Zebra');
717
+ expect(names).toContain('Aardvark');
718
+ expect(names).toContain('Monkey');
637
719
  });
638
720
  it('should sort by name ascending (default for name)', async () => {
639
721
  const result = await callTool(client, 'get_entities_by_type', {
@@ -681,10 +763,14 @@ describe('MCP Memory Server E2E Tests', () => {
681
763
  { name: 'Orphan_M', entityType: 'Orphan', observations: ['Isolated'] }
682
764
  ]);
683
765
  });
684
- it('should preserve insertion order when sortBy is omitted', async () => {
766
+ it('should use llmrank as default sort when sortBy is omitted', async () => {
685
767
  const result = await callTool(client, 'get_orphaned_entities', {});
768
+ // With llmrank default, all entities returned (order varies due to random tiebreak)
686
769
  const names = result.items.map(e => e.name);
687
- expect(names).toEqual(['Orphan_Z', 'Orphan_A', 'Orphan_M']);
770
+ expect(names).toHaveLength(3);
771
+ expect(names).toContain('Orphan_Z');
772
+ expect(names).toContain('Orphan_A');
773
+ expect(names).toContain('Orphan_M');
688
774
  });
689
775
  it('should sort by name ascending', async () => {
690
776
  const result = await callTool(client, 'get_orphaned_entities', {
@@ -746,12 +832,12 @@ describe('MCP Memory Server E2E Tests', () => {
746
832
  ]
747
833
  });
748
834
  });
749
- it('should return unsorted neighbors when sortBy is omitted', async () => {
835
+ it('should use llmrank as default sort when sortBy is omitted', async () => {
750
836
  const result = await callTool(client, 'get_neighbors', {
751
837
  entityName: 'Hub'
752
838
  });
839
+ // With llmrank default, all neighbors returned (order varies due to random tiebreak)
753
840
  expect(result.items).toHaveLength(3);
754
- // Just verify all neighbors are present
755
841
  const names = result.items.map(n => n.name);
756
842
  expect(names).toContain('Neighbor_Z');
757
843
  expect(names).toContain('Neighbor_A');
@@ -854,5 +940,91 @@ describe('MCP Memory Server E2E Tests', () => {
854
940
  expect(names).toEqual(sortedNames);
855
941
  });
856
942
  });
943
+ describe('pagerank sorting', () => {
944
+ it('should sort by pagerank (structural rank)', async () => {
945
+ // Build a star graph: Hub -> A, Hub -> B, Hub -> C
946
+ // A, B, C are dangling nodes
947
+ await callTool(client, 'create_entities', {
948
+ entities: [
949
+ { name: 'Hub', entityType: 'Node', observations: ['Central node'] },
950
+ { name: 'LeafA', entityType: 'Node', observations: ['Leaf A'] },
951
+ { name: 'LeafB', entityType: 'Node', observations: ['Leaf B'] },
952
+ { name: 'LeafC', entityType: 'Node', observations: ['Leaf C'] },
953
+ ]
954
+ });
955
+ await callTool(client, 'create_relations', {
956
+ relations: [
957
+ { from: 'Hub', to: 'LeafA', relationType: 'LINKS' },
958
+ { from: 'Hub', to: 'LeafB', relationType: 'LINKS' },
959
+ { from: 'Hub', to: 'LeafC', relationType: 'LINKS' },
960
+ ]
961
+ });
962
+ // Sort by pagerank descending (default for ranks)
963
+ const result = await callTool(client, 'search_nodes', {
964
+ query: 'Node',
965
+ sortBy: 'pagerank'
966
+ });
967
+ // All entities should be returned
968
+ expect(result.entities.items).toHaveLength(4);
969
+ // With structural rank, the leaves should rank higher than the hub
970
+ // because they receive visits from Hub's walks.
971
+ // We just verify the sort works and returns all entities.
972
+ const names = result.entities.items.map(e => e.name);
973
+ expect(names).toContain('Hub');
974
+ expect(names).toContain('LeafA');
975
+ expect(names).toContain('LeafB');
976
+ expect(names).toContain('LeafC');
977
+ });
978
+ });
979
+ describe('llmrank sorting', () => {
980
+ it('should sort by llmrank (walker visits)', async () => {
981
+ await callTool(client, 'create_entities', {
982
+ entities: [
983
+ { name: 'Hot', entityType: 'Test', observations: ['Frequently accessed'] },
984
+ { name: 'Cold', entityType: 'Test', observations: ['Rarely accessed'] },
985
+ ]
986
+ });
987
+ // Access 'Hot' multiple times via open_nodes (which increments walker visits)
988
+ await callTool(client, 'open_nodes', { names: ['Hot'] });
989
+ await callTool(client, 'open_nodes', { names: ['Hot'] });
990
+ await callTool(client, 'open_nodes', { names: ['Hot'] });
991
+ await callTool(client, 'open_nodes', { names: ['Hot'] });
992
+ await callTool(client, 'open_nodes', { names: ['Hot'] });
993
+ // Access 'Cold' just once
994
+ await callTool(client, 'open_nodes', { names: ['Cold'] });
995
+ // Sort by llmrank descending
996
+ const result = await callTool(client, 'search_nodes', {
997
+ query: 'Test',
998
+ sortBy: 'llmrank'
999
+ });
1000
+ // 'Hot' should rank higher than 'Cold' due to more walker visits
1001
+ expect(result.entities.items).toHaveLength(2);
1002
+ expect(result.entities.items[0].name).toBe('Hot');
1003
+ expect(result.entities.items[1].name).toBe('Cold');
1004
+ });
1005
+ it('should fall back to pagerank on llmrank tie', async () => {
1006
+ // Create entities with no prior walker visits
1007
+ await callTool(client, 'create_entities', {
1008
+ entities: [
1009
+ { name: 'Center', entityType: 'Fallback', observations: ['Hub'] },
1010
+ { name: 'Spoke', entityType: 'Fallback', observations: ['Leaf'] },
1011
+ ]
1012
+ });
1013
+ // Create a relation so structural rank differs
1014
+ await callTool(client, 'create_relations', {
1015
+ relations: [{ from: 'Center', to: 'Spoke', relationType: 'POINTS_TO' }]
1016
+ });
1017
+ // Both have 0 walker visits (tie), so llmrank should fall back to pagerank
1018
+ const result = await callTool(client, 'search_nodes', {
1019
+ query: 'Fallback',
1020
+ sortBy: 'llmrank'
1021
+ });
1022
+ // Just verify we get both entities (ordering depends on structural rank + random tiebreak)
1023
+ expect(result.entities.items).toHaveLength(2);
1024
+ const names = result.entities.items.map(e => e.name);
1025
+ expect(names).toContain('Center');
1026
+ expect(names).toContain('Spoke');
1027
+ });
1028
+ });
857
1029
  });
858
1030
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "MCP server for enabling memory for Claude through a knowledge graph",
5
5
  "license": "MIT",
6
6
  "author": "Levalicious",
@@ -14,19 +14,21 @@
14
14
  "dist"
15
15
  ],
16
16
  "scripts": {
17
- "build": "tsc && shx chmod +x dist/*.js",
18
- "prepare": "npm run build",
17
+ "build": "node-gyp rebuild && tsc && shx chmod +x dist/*.js",
18
+ "build:native": "node-gyp rebuild",
19
+ "prepare": "husky && npm run build",
19
20
  "watch": "tsc --watch",
20
21
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
21
22
  },
22
23
  "dependencies": {
23
- "@modelcontextprotocol/sdk": "1.25.1",
24
+ "@modelcontextprotocol/sdk": "1.26.0",
24
25
  "proper-lockfile": "^4.1.2"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@types/jest": "^30.0.0",
28
29
  "@types/node": "^25",
29
30
  "@types/proper-lockfile": "^4.1.4",
31
+ "husky": "^9.1.7",
30
32
  "jest": "^30.2.0",
31
33
  "shx": "^0.4.0",
32
34
  "ts-jest": "^29.4.5",