Zaria.AI
1.5.0
dotnet add package Zaria.AI --version 1.5.0
NuGet\Install-Package Zaria.AI -Version 1.5.0
<PackageReference Include="Zaria.AI" Version="1.5.0" />
paket add Zaria.AI --version 1.5.0
#r "nuget: Zaria.AI, 1.5.0"
// Install Zaria.AI as a Cake Addin #addin nuget:?package=Zaria.AI&version=1.5.0 // Install Zaria.AI as a Cake Tool #tool nuget:?package=Zaria.AI&version=1.5.0
Zaria.AI
This framework provides a simple, attribute-driven, low-code API for building text based interactive dialogs. It Can be used to build AI BOTs, CLIs, general command processing and much more!
Release 1.5.*
- Upgraded to live version of openai library
- Added support for breaking out of tool call chains
SkillResponse
now has an additional boolean constructor parametercontinue
which can be used to indicate whether you want to tool call to pass its results back to the AI or drop out of the chain entirely and just return with the results directly to the caller. - SkillResponse can now accept an object as the response parameter. The object will be serialized to a json string. The new constructor signature is as follows:
public SkillResponse(object response, bool continue_processing = false, params List<string> errors)
- You no longer need to pass a prompt string into
AIChatProcessor.InitializeAsync()
. The new signature for this method isInitializeAsync(string system_prompt = "", params string[] referenced_plugin_assemblynames)
- AIPluginAttribute now requires a prompt to be specified as the secondary parameter of its constructor. For all plugins this prompt should be used to define the behavior of the plugin and should be aligned in concept with any skills that are in the plugin. The new signature is
public AIPluginAttribute(string name, string prompt)
.
Release 1.3.*
- Skill methods can now be instance methods of any class derived from
AIAgent
. When this pattern is used, several additional capabilities like the following are available.- Conversation state
- Helper methods for returning SkillResponse
- Ability to monitor skill liketime
- Ability to function in a multi-threaded environment with state.
- Removed the ability to have non public Skill methods.
- Changed
ProcessPromptAsync
toProcessUserMessageAsync
. - New methods and properties are now available on the SkillResponse object.
ParameterAttribute
can now be used to decorate properties in addition to parameters of methods.- Skill methods can now be defined with a single parameter of type
SkillParameters
. You can now define any parameters that you would normally define as part of the method definition as a public property of this type. The properties of the class still need to be decorated with theParameter
attribute.
Consider the following skill method where the parameters are defined as part of the method.
public SkillResponse ListPlayersByTotal(
[Parameter("The statistical category to order by")] string statistical_category,
[Parameter("An indicator that the user is looking for less than or greater than a given value")] string operand,
[Parameter("An indicator that the user is looking for ascending or descending order")] string order_type)
The method can be collapsed to the following:
public SkillResponse GetOrderedList(StatQuery query)
StatQuery
would then be defined as follows:
public class StatQuery
{
[Parameter("The statistical category to order by")]
public string StatisticalCategory { get; set; }
[Parameter("An indicator that the user is looking for ascending or descending order")]
public string OrderType { get; set; }
[Parameter("An indicator on whether to order by rank or total")]
public string Operand { get; set; }
}
Release 1.2.*
Upgraded to .NET 8 AIChatProcessor now exposes a Conversation property that can be iterated through. It contains the conversation context for a given iteraction.
Release 1.1.1 Notes
Removed dependency to AspNetCore - moved those capabilities to a new nuget focused on using Zaria with web-style applications. To use those capabilities add the Zaria.AI.AspNetCore package.
Release 1.1.0 Notes
Added support for Azure Open AI chat completion with attribute based routing to tool calls. To use the new functionality put the namespace Zaria.AI.Chat
in scope wherever needed. This capability requires an Azure AI account. To get detailed tutorial on how to setup an Azure AI account visit our YouTube playlist here:
https://www.youtube.com/watch?v=0c0QJN8E3Kg&list=PLWTTcslB-3dUgpFQtBZCp9YXgBi4h4IGW
You can initialize and call the open ai processor as follows:
AIChatProcessor chat_processor = new AIChatProcessor(ai_endpoint, ai_key, deployment_name);
await chat_processor.InitializeAsync(prompt_str,"NBAStatLibrary");
var ai_response = await chat_processor.ProcessPromptAsync(user_message);
The framework will handle maintaining conversation context so ProcessPromptAsync
can be called repeatedly to build a natural flowing conversation with the user. To start a new conversation and forget the previous context call NewConversation
. There is no reason to call this the first time as the AI starts with no information other than what you provide in the InitializeAsyc
call. The chat processor framework allows for the use of tool calls via plugin assemblies. For the framework to see an assembly it must be decorated with the AIPlugin
assembly level attribute. The example below illustrates:
[assembly:AIPlugin("NBA Stats")]
Within the plugin assembly, a tool method must be static
, must be decorated with the SkillAttribute
attribute, and must return a SkillResonse
to be recognized. Each parameter of the method that you want mapped to information from the AI must additionally be decorated with a ParameterAttribute
Attribute. The following example illustrates a simple skill for returning statistical data for an NBA player given his name.
[assembly: AIPlugin("NBA Stats")]
public static class NBAStatManager
{
[Skill("Call when the user wants to get the player's rankings in a particular statistical category")]
public static SkillResponse GetPlayerStats(
[Parameter("The name of the NBA player that the user wants the statistics for")]
string player_name,
[Parameter("The statistical category to get the data for")]
string statistical_category
)
{
//get ranking for player in the given category
return new SkillResponse($"You know that {player_name}'s ranking in the category of {statistical_category} is {ranking}.");
}
}
This scenario under which the AI calls this method will depend on the description given to the skill and parameters. The description given to the parameters will also determine what is passed into them from the AI.
Release 1.0.9 Notes
Included in this build is a minor change to the Execute method. The version of execute that takes command line arguments has been modified to pull those values directly from the executing program. This means that you no longer need to pass the arguments in when you call Execute.
Order of precedence for configured rules.
- Rules configured at runtime are always processed first. As such they will supercede any rules defined anywhere else. If no hanlder is found matching the pattern then the processor will move to the next set
- Rules configured statically in the assembly/target type are processed next.
- If there are remote patterns configured and no patterns were matched in any other approach then rules defined in the patterns json file are used. The patterns json file takes the format:
{
"Patterns": [
{
"Pattern": "<pattern>",
"HandlerURI": "<URL to call>",
"Method": "<http method to use>"
},
{
"Pattern": "<pattern>",
"HandlerURI": "<URL to call>",
"Method": "<http method to use>"
}
]
}
Any remote handlers must return a json object the follows the format
{
"MessageType":"<Information | Warning | Error>",
"Message": "<the message that was sent>"
}
With this version, state management is no longer handled within the framework. Rather, a set of delegates can be injected into the framework that will be called when read, write, and delete operations are called. The signature for
Initialize
has been updated to account for this new capability. The follwowing snipet illustrates how the handles can be used to add state management to a console application. In a web based clustered scenario one mightuse a database or session state to store, retrievea, and delete state.
Dictionary<string, object> state = new Dictionary<string, object>();
var processor = CommandProcessor.Initialize(new InitializationSettings
{
StateManagement = new PipelineStateManager
{
StoreHandler = (a, b) =>
{
state[a] = b;
},
RetrieveHandler = (x) =>
{
if (!state.ContainsKey(x))
return (false, null);
return (true, state[x]);
},
RemoveHandler = (x) => state.Remove(x)
},
});
Added response handler delegate as an optional argument to all execute methods. The object passed into this delegate is of type
HandlerResponse
and contains the response message as well as an indicator of the kind of message it is (Error, Warning, or Informational). This ensures that each unique host can be provided with a host appropriate way to display responses to commands. The ideal way to display messages sent back to the application hosting zaria is now to provide the display code directly in this delegate. This allows different hosts to processes the response message from command handlers in a manner suitable to themselves. Ostensibly allowing the same handler to be used in multiple hosting applications without the need to recode (assuming there are no otehr significant differences). The following code shows how a console application might display results from handler.
while (true)
{
Console.Write("> ");
var command = Console.ReadLine();
if (!processor.Execute(command, handler => Console.WriteLine(handler.Message)))
{
break;
}
}
A web application might handle responsed from handlers as follows:
processor.Execute(input_command, async message =>
{
await context.Response.WriteAsync(message);
});
The following code shows the updated execute method.
public bool Execute(string input_instructions, Action<string> response_handler = null)
Added
WriteResponse(string)
method to CommandContext. This method is used to return responses from command handlers in a host agnostic manner. The hosting application must process these calls by passing a delegate into theExecute
method that renders the response message in a host appropriate manner.
Types that can be used as parameters in a handler method have been locked down to:
int
string
bool
guid
double
CommandContext
Any other type used as a parameter in a handler method will cause that method to be ignored.
Reference variables directly in input commands. To reference a variable use the
$
sumbol before it. So if there was a variable set in codecontext.Pipeline.Store("age", 25);
then it could be referenced when the user is inputing commands as followsSet age to $age
. This value will be replaced by the value of the variable (25) so that the resulting input string will beSet age to 25
. It is important to note that this is what will be processed, not the original string.
Changed
CommandContext.Pipeline.Get
to return a tuple(bool Exists, object Value)
where the boolean value indicates whether there was a key found in the pipeline state. This change was made to remove ambiguity as to the meaning of a default value such as null getting returned (since a default value can mean both that the variable does not exist or that the value does exist but is null). Using this approach retrieving state can be accomplished as follows:
[Pattern("display @var")]
bool Display(string var, CommandContext context)
{
var value1 = context.Pipeline.Get<int>(var);
if (value1.Exists)
context.WriteResponse($"Your answer is {value1.Value}");
else
context.WriteResponse("Variable does not exist");
return true;
}
Added
File
property to Pattern. You can specify a fully qualified path to a .pidgin file. This is simply a text file with command seperated into individual lines. This approach supports all the same capabilities as all other approaches to declaring patterns however this does not work with injected patterns. The sample below creates a handler that gets its patterns from a file called arithmetic-commands.pidgin
[Pattern(File = @"C:\CommandTestHarness\arithmetic-commands.pidgin")]
bool ProcessPatternsFromFile(int x, int y)
{
int answer = 0;
string operand = "";
switch (_context.InputTokens[0].Text)
{
case "add":
operand = "+";
answer = x + y;
break;
case "subtract":
operand = "-";
answer = y - x;
break;
}
context.WriteResponse($"{x} {operand} {y} = {answer}");
return true;
}
Added
URL
property to Pattern. You can specify a url to an endpoint that returns a string with command seperated into individual lines. The command processor will use an HTTP Get to attempt to retrieve the pattern data. This approach supports all the same capabilities as all other approaches to declaring patterns however this does not work with injected patterns. The following sample gets its patterns from a uri on the cloud. The endpoint must return the patterns as plain text with one pattern on each line.
[Pattern(URL = @"http://<some service>/api/Function1")]
bool ProcessPatternsFromURL(int x, int y)
{
int answer = 0;
string operand = "";
switch (_context.InputTokens[0].Text)
{
case "multiply":
operand = "*";
answer = x * y;
break;
case "divide":
operand = "/";
answer = x / y;
break;
}
context.WriteResponse($"{x} {operand} {y} = {answer}");
return true;
}
Added Provider property to Pattern. Any public type that derives from IPatternProvider can be used as a value for this property. The type exposes a function which can be used to retrive or generate patterns that will be associated with the given hander declaring the attribute.
public class TestPatternProvider : IPatternProvider
{
public List<string> GetPatterns()
{
//go to database and retrieve value
return new List<string>
{
"sample1",
"sample2",
};
}
}
The provider is declared as follows:
[Pattern(Provider = typeof(TestPatternProvider))]
bool Test(CommandContext context)
{
context.WriteResponse(context.InputTokens[0].Text);
return true;
}
Added Pipeline state to process. Pipeline state allows the user to store state to the context such that it can be used by future commands down the pipe. This is for scenarios where multiple commands need to be processed in order to complete a given task (and the state needs to be passed between the command handlers. The command processor provides the state but all management must be handled by consumers of this library. This state does not persist beyond a given instance of the command processor. The actual management of the state is routed to the delegates you inject during processor initialization. The following sample illustrates:
[Pattern("var @name = @value")]
bool SetVariable(string name, int value, CommandContext context)
{
context.Pipeline.Store(name, value);
return true;
}
This can be used in a subsequent example as follows:
[Pattern("display @var")]
bool Display(string var, CommandContext context)
{
var value1 = context.Pipeline.Get<int>(var);
context.WriteResponse($"Your answer is {value1}");
return true;
}
This command handler basically prints the given pipeline variable to the console.
You can build a very simple calculator by combining these two command patterns with another for the actual arithmetic work as shown below.
[Pattern("calc @name1 @operand @name2 into @var")]
bool Calculate(string name1, string name2, string operand, string var, CommandContext context)
{
var x = context.Pipeline.Get<int>(name1);
var y = context.Pipeline.Get<int>(name2);
int answer = 0;
switch (operand)
{
case "+":
answer = x + y;
break;
case "-":
answer = x - y;
break;
case "*":
answer = x * y;
break;
case "/":
answer = x / y;
break;
}
context.Pipeline.Store(var, answer);
return true;
}
Quotes are now processed verbatim so case is maintained for them. Everything else is case insensitive
Both versions of Execute now return
bool
. This is done to help with the transition from single use to CLI. If args are passed the default behavior is to return false (as the expectation is that this is a single run). It is up to the user and designer of the command interface to decide.
Usage
Creating patterns
For any given class -
- For each command pattern you want to process add static method with a bool return type. Each of these will serve as a handler for when the user's input matches the pattern this method will advertise.
- Now decorate the method with PatternAttribute attribute.
- As a catch-all for any input that does not match a pattern you advertise you can optionally provide another method (with a similar signature as described above) and decorate this one with NotFoundAttribute instead. Note that only 1 method, the first encountered with the proper attribute, will be used for the fallback route even if you decorate multiple methods with it.
PatternAttribute accepts a string as an argument. This string represents the pattern that must be matched for the method to be called. This string can contain any sequence of characters except those with special meaning listed below:
char | Description |
---|---|
@ | Used to define placeholders in the pattern. When a user types in thier input, the parts that match the criteria for the placeholder will be passed into the method hosting the attribute. To add placeholders to the pattern use the @ symbol in front of the placeholder name. |
[ | Both square brackets are used to denote a section of your pattern that the user can place anywhere in the input string |
] | Both square brackets are used to denote a section of your pattern that the user can place anywhere in the input string |
? | Indicates that the given text is optional. It is meant to be placed at the end of the item being matched (so the pattern hello world? will trigger the underlying method if the user passes in hello or hello world. Placeholders cannot be optional. If a placeholder has the ? character after it, the request will be ignored or will generate an error. |
Some points to note:
The parsing system does a token (word) for token match on the input string using the pattern defined in the PatternAttribute as a psuedo-schema. By default, word positions are respected when matching in this way. This approach is somewhat rigid from a users perspective however; forcing them to enter commands in an exact order can be challenging. To address this, Pidgin also includes a mechanism to that allows for sections of the overall pattern to be entered without respect to position. While position is still maintained within the section, the resulting sections as a whole can be keyed-in in any order.
- To indicate that a section of the pattern is not tied to a position, encase that section in square brackets ([]).
- Note that you cannot start a pattern with square brackets (the first part of your pattern maintain its position).
- Note that you cannot start any part of your pattern with placeholders. Additionally, in all scenarios placeholders must come after static text. This is also true for all sections of the pattern; meaning if you elect to break the pattern up into sections then those sections will also need text before placeholders.
- You cannot put sections inside of sections. This means you can't place square brackets inside of a block enclosed by square brackets. This will cause a runtime error.
- The parsing process is sensitive to white spaces, if the user's intent is to pass in a parameter with white spaces they must use double quotes (") to surround that text. This means a pattern like
hello @message
can be triggered with the texthello john
but can also be triggered withhello "john the baptist"
- Be careful where you put placeholders in the pattern. A good rule of thumb is to have static text between your variables and any ] character.
A pattern like The [quick @fox_color fox jumped] @jump_direction over the lazy dog
will produce three sections.
- The
- quick @fox_color fox jumped
- @jump_direction over the lazy dog
A pattern like the [@quick @fox_color fox jumped] jump_direction over the lazy dog
will also produce three sections.
- The
- @quick @fox_color fox jumped
- jump_direction over the lazy dog
As can be seen, during pattern evaluation sections of the Pidgin may get resolve as starting with a variable. To prevent this, never put a placeholder directly after a partitioned block.
Types
CommandProcessor
Provides functions that can be used to initiaize the processor and execute commands on it. Also contains properties that can be used to inspect the input string/array that is being executed
PipelineContext
Represents the context of the overarching process flow driving the commands entered by the user. All state management is controlled through this type.
PatternAttribute
Use this attribute to decorate any methods you want to be used to process a command pattern.
NotFound
Use this attribute to decorate the method you want to be called when there is no command pattern found
InputToken
Represents a token (word) in the input string/array that is passed into the processor.
PatternRepository
Collection where dynamic commands are stored. Dynamic commands are the commands that are injected into the CommandLineProcessor as opposed to getting discovered through the initialization process.
CommandContext
Context of an executing command. This class can be passed into any command handler (including in NotFound scenarios). Provides a view into the parameters that were passed in. You can use the type to walk back and forth through the text that was passed into your tool token (word) by token. Note that quoted text is treated as one token
CommandPatternException
Represents an exception that occurs while parsing the input text
IPatternProvider
Classes that implement this interface can be used to provide patterns to a given PatternAttribute. The following illustrates a sample of this:
ZariaMiddlewareExtensions
Provides helper functions for injecting Zaria.AI pattern recognition into AspNetCore web applications
public class TestPatternProvider : IPatternProvider
{
public List<string> GetPatterns()
{
//go to database and retrieve value
return new List<string>
{
"sample1",
"sample2",
};
}
}
The provider is declared as follows:
[Pattern(Provider = typeof(TestPatternProvider))]
bool Test(CommandContext context)
{
context.WriteResponse(context.InputTokens[0].Text);
return true;
}
Examples
In the following example a command pattern handler is created
[Pattern("say [-what @something] [-to @someone]")]
static bool SaySomething(string something, string someone, int i)
{
context.WriteResponse($"You said {something} to {someone}");
return true;
}
This method will be invoked whenever the following pattern is passed to the interpreter say -what [...] -to [...]
.
Any placeholders in the pattern must match the parameter names on the method that it decorates (similar to Web API). If placeholders are provided but the method does not have paramters (or they dont match) then the parameters will hold their default value when the method is run. This is also true in the reverse scenario; if no placeholders are included the method will be called with the default values of any parameters that exist. The return value is not used by the processor; rather, it is meant to be used in REPL scenarios to indicate whether the loop should continue. THe convension is to return true to continue the program and false to exit it.
In the following example we pass the following into a command-line that has the code above deployed:
say -what "hello world" -to "the man next door"
Note: Quotes are required when spaces exist between your placeholder tokens (if not displayed in your browser)
The result of this would be:
You said hello world to the man next door
A single method may be decorated with more than one command pattern (thus allowing it to handle multiple scenarios). In the following example the same SayHello method is used to handle three different patterns.
[Pattern("test @arg")]
[Pattern("test")]
[Pattern("hello there")]
static bool SayHello(string arg)
{
Console.WriteLine($"Calling hello there command with {arg}");
return true;
}
Note that this sample is based on hosting Zaria.AI in a console application. For other hosting scenarios it is recommended that a
CommandContext
be declared as a parameter on the hander method (the runtime will pass that value in automatically when the parameter is detected) and then use theWriteResponse
method to pass a response message back to the host. The host can then decide how to display that message. This approach allows for the handler to be host agnostic.
This method will be invoked if any of the following patterns is passed into the processor.
- test
- test 123
- hello there
You can also represent these different patterns on a given handler by using 1 Pattern attribute with multiple patterns as follows
[Pattern("test","test @arg","hello there")]
static bool SayHello(string arg)
{
Console.WriteLine($"Calling hello there command with {arg}");
return true;
}
Running the Processor
To initialize the processor use the following code:
CommandProcessor.Initialize();
This will traverse the current assembly and any referenced assemblies and identify all the command patterns that have been specified. A command pattern is declared by creating a method with a boolean return type and decorating it with the PatternAttribute attribute. Initialize must always be called first, and must be called again each time a new assembly is added to the AppDomain it was initially called in. To limit the commands patterns used to a smaller scope you can use Initialize with a type parameter representing the class who's methods will be inspected for command patterns. In the following case we are limiting the call to only initialize methods in the SamplePatternHost class.
CommandProcessor.Initialize<SamplePatternHost>();
The processor can be used as follows:
processor.Execute();
This version of the execute method reads the values from the executing program's commandline. No special processing is performed on the input other than removing the path to the executing program. For creating something like a REPL (Read-evaluate-print loop), or for use in generic command processing, the other version of the method is more ideal.
processor.Execute(string);
This version of the method takes the entire string as input and processes it with no additional manipulation. This version also returns a bool that can be used to determine whether it should be called again.
The following shows how a simple console REPL might look.
static void Main(string[] args)
{
Console.WriteLine("Pidgin REPL Sample");
//call initialization
var processor = CommandProcessor.Initialize();
while (true)
{
Console.Write("> ");
var command_line = Console.ReadLine();
var continue_to_run = processor.Execute(command_line, message=>Console.WriteLine(message));
if (!continue_to_run)
break;
}
}
There will be scenarios where a procedural approach is more advanageous to you. For instance, if a quick and easy command is needed, it might not make sense to create an entire method for it. In those situations you can use the Pattern
property of CommandProcessor. It allows you to inject pattern evaluators directly into the CommandLineProcessor without having to create a class method.
processor.Pattern["reset @parameter"] = (placeholders, context) =>
{
foreach(var placeholder in placeholders.Keys)
context.WriteResponse($"{placeholder} = {placeholders[placeholder]}");
return true;
};
One key difference between the two approaches is that with this, the placeholders are not automatically converted and mapped to the method's parameters. With this approach a dictionary(of string) is passed into the lambda. If multiple patterns need to be mapped to one lambda they can be passed comma separated. An example of multiple dynamic patterns using the same lamda is shown below.
processor.Pattern
[
"run @code_name [override? @role]",
"stop @application"
] = (placeholders, context) =>
{
var is_run = context.HasToken("run");
if (is_run)
{
var allow_override = context.HasToken("override");
if (allow_override)
{
context.WriteResponse($"*** Running {placeholders["code_name"]} with [{placeholders["role"]}] access ***");
}
else
context.WriteResponse($"Running {placeholders["code_name"]} with normal access");
}
else
{
context.WriteResponse($"Stopping {placeholders["application"]}");
}
return true;
};
Please note that this collection Cannot be updated or iterated through. This is mainly a mechanism for injecting additional patterns with their associated handlers into the processor. Also note that patterns declared here will be processed before any patterns statically declared (declared with method and attribute notation). In this way they can override the intended functionality of a given pattern so be careful with them.
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. |
-
net8.0
- Azure.AI.Language.QuestionAnswering (>= 1.1.0)
- Azure.AI.OpenAI (>= 2.0.0)
- Thinkmine.Zaria.Core (>= 1.4.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Version | Downloads | Last updated |
---|---|---|
1.5.0 | 19 | 11/21/2024 |
1.4.0 | 40 | 11/18/2024 |
1.3.6 | 34 | 11/18/2024 |
1.3.5 | 30 | 11/18/2024 |
1.3.4 | 36 | 11/15/2024 |
1.3.2 | 39 | 11/15/2024 |
1.3.1 | 67 | 9/7/2024 |
1.3.0 | 72 | 9/5/2024 |
1.2.8 | 82 | 8/20/2024 |
1.2.7 | 83 | 8/19/2024 |
1.2.6 | 86 | 8/19/2024 |
1.2.5 | 86 | 8/18/2024 |
1.2.4 | 77 | 8/18/2024 |
1.2.3 | 81 | 8/18/2024 |
1.2.2 | 83 | 8/16/2024 |
1.2.1 | 73 | 8/15/2024 |
1.1.4 | 58 | 8/6/2024 |
1.1.2 | 65 | 8/5/2024 |
1.1.1 | 53 | 8/5/2024 |
1.1.0 | 43 | 8/5/2024 |
1.0.10 | 218 | 4/19/2023 |
1.0.9 | 178 | 4/19/2023 |
1.0.7 | 194 | 4/18/2023 |
1.0.6 | 256 | 3/5/2023 |
1.0.5 | 261 | 2/14/2023 |
1.0.4-beta | 164 | 2/2/2023 |
1.0.3 | 314 | 1/26/2023 |
1.0.2 | 293 | 1/26/2023 |
1.0.1 | 291 | 1/25/2023 |
1.0.0 | 280 | 1/25/2023 |