@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.
- package/README.md +41 -27
- package/dist/index.js +4 -0
- package/dist/scripts/migrate-jsonl.js +169 -0
- package/dist/scripts/verify-migration.js +39 -0
- package/dist/server.js +763 -536
- package/dist/src/graphfile.js +560 -0
- package/dist/src/memoryfile.js +121 -0
- package/dist/src/pagerank.js +78 -0
- package/dist/src/stringtable.js +373 -0
- package/dist/tests/concurrency.test.js +189 -0
- package/dist/tests/memory-server.test.js +225 -53
- package/package.json +6 -4
|
@@ -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: ['
|
|
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
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
|
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).
|
|
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
|
|
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).
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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",
|