V1 / CHANGESET
Only the delta.
The exact changes from the empty initial baseline to V1. Unchanged files are excluded. Nothing from a later version is included.
Green + / red - count added and removed lines; signed bytes show the file size change. UTF-8 file sizes and total lines in this snapshot; deleted files use the previous snapshot. Sorting stays within each folder.
Repository root 3 files
NEW.gitignore353 B / 25 lines+25-0+353 B+
GITIGNOREUnchangedRemoved / replacedAdded
# Build output and local SDK state
**/bin/
**/obj/
/Admin/Build/
/.dotnet/
# Machine-specific IDE settings
.vs/
.idea/
*.user
*.suo
# Generated checks, downloads, and media
/Admin/TestResults/
/Admin/Downloads/
/Admin/Videos/
*.onnx
*.onnx.data
# Local credentials and tooling state
.env
.env.*
!.env.example
/.aws/
/.codex/
NEWVersion.props223 B / 7 lines+7-0+223 B+
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
<!-- V1 baseline. Set VersionPrefix=2.0.0 and VersionSuffix=alpha.1 on v2. -->
<PropertyGroup>
<VersionPrefix>1.0.1</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
NEWdotnet-tools.json193 B / 13 lines+13-0+193 B+
JSONUnchangedRemoved / replacedAdded
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}Interfaces/ConsoleApp/LilithConsole 2 files
NEWLilithConsole.csproj404 B / 15 lines+15-0+404 B+
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>LilithConsole</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Lilith\Agent\Agent.csproj" />
</ItemGroup>
</Project>
NEWProgram.cs3,602 B / 75 lines+75-0+3,602 B+
C#UnchangedRemoved / replacedAdded
using System;
using System.Text;
using System.Threading.Tasks;
using Lilith.Core.Logging;
using Lilith.Agent;
using LilithAgent = Lilith.Agent;
using LilithConsole.Setup;
using Lilith.Core.Kokoro;
namespace LilithConsole
{
partial class Program
{
static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
var consoleSink = new TextLogSink(Console.Out, overwriteProgress: !Console.IsOutputRedirected,
width: () => Math.Max(Console.WindowWidth - 1, 1));
Action<LogEntry> onLog = consoleSink.Handle;
var logger = new Logger(onLog);
try
{
string settingsPath = Environment.GetEnvironmentVariable("LILITH_CONSOLE_SETTINGS")
?? System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "console-settings.json");
var credentials = await ProviderSetup.RunAsync(Console.In, Console.Out, logger, settingsPath);
if (credentials is null) { logger.SystemWriteLine("Setup stopped. Relaunch to continue."); return; }
if (!Console.IsInputRedirected || Environment.GetEnvironmentVariable("LILITH_TTS") == "1")
{
try
{
credentials = credentials with { Tts = await KokoroSetup.RunAsync(Console.In, Console.Out,
logger, settingsPath + ".kokoro.json") };
}
catch (Exception ex) { logger.SystemWriteLine($"Speech setup failed: {ex.Message}. Relaunch to retry; continuing with text chat.", LogColors.Orange); }
}
Console.Title = $"{credentials.AgentName} — {credentials.Model} Chat";
await using var lilith = new LilithAgent.Lilith(onLog, credentials);
await lilith.BootAsync(startServerIfNotRunning: true);
while (true)
{
string? input;
using (consoleSink.BeginInput())
{
lilith.Logger.Write("Input", "You: ", LogColors.Green);
input = Console.ReadLine()?.Trim();
}
if (input is null) break;
if (string.IsNullOrEmpty(input)) continue;
if (input.Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
try
{
var command = await lilith.ChatCommands.ExecuteAsync(input);
if (command.Output is { Length: > 0 }) Console.WriteLine(command.Output);
if (command.ExitRequested) break;
if (!command.Handled) await lilith.ChatAsync(input);
}
catch (Exception ex)
{
lilith.Logger.SystemWriteLine($"Request failed: {ex.Message}. Please try again.", LogColors.Red);
}
}
lilith.SaveConversation();
lilith.Logger.SystemWriteLine("Goodbye.");
}
catch (Exception ex)
{
logger.SystemWriteLine($"Setup/chat failed: {ex.Message}. Relaunch to retry; completed setup steps are retained.", LogColors.Red);
Environment.ExitCode = 1;
}
finally { consoleSink.Flush(); }
}
}
}
Interfaces/ConsoleApp/LilithConsole/Setup 6 files
NEWConsoleSettings.cs549 B / 14 lines+14-0+549 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;
namespace LilithConsole.Setup;
public record ConsoleSettings(string? AgentName = null, string? Model = null)
{
public bool IsValid() => (AgentName is null || (!string.IsNullOrWhiteSpace(AgentName) && AgentName.Length <= 80 && !AgentName.Any(char.IsControl))) &&
(Model is null || (Model.Length <= 200 && RegexPatterns.ModelIdentifier().IsMatch(Model)));
}
NEWConsoleSetup.cs6,590 B / 118 lines+118-0+6,590 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;
namespace LilithConsole.Setup;
/// <summary>Checks actual installation/model state on every launch, independent of saved preferences.</summary>
public sealed class ConsoleSetup(TextReader input, TextWriter output, ISetupServices services, Logger logger, string settingsPath)
{
public static readonly IReadOnlyList<string> Models = Array.AsReadOnly<string>(["gemma4", "llama3.2", "qwen3:4b", "phi4-mini"]);
public async Task<LilithCredentials?> RunAsync()
{
using var totalTimer = new ConsoleSetupTimer(logger);
bool installed = await logger.TimeStageAsync("Installation check", () => Task.FromResult(services.IsInstalled()));
if (!installed)
{
if (!await logger.TimeStageAsync("Installation choice", () => Task.FromResult(Confirm("Would you like to download and install Ollama? [y/N, exit]: ")))) return null;
await logger.TimeStageAsync("Ollama installation", services.InstallAsync);
if (!services.IsInstalled()) throw new InvalidOperationException("Ollama executable was not found after installation.");
}
else output.WriteLine("Ollama already installed; skipping installation.");
await logger.TimeStageAsync("Server startup/check", services.EnsureServerAsync);
ConsoleSettings settings = await logger.TimeStageAsync("Preferences load", () => Task.FromResult(Load()));
if (settings.AgentName is null)
{
output.Write("Agent name (Enter for Lilith, /exit to leave): ");
string? name = await logger.TimeStageAsync("Agent name selection", () => Task.FromResult(input.ReadLine()));
if (name is null || name.Trim().Equals("/exit", StringComparison.OrdinalIgnoreCase)) return null;
name = name.Trim();
if (name.Length > 80 || name.Any(char.IsControl)) throw new ArgumentException("Agent name must be at most 80 characters without control characters.");
settings = settings with { AgentName = name.Length == 0 ? "Lilith" : name };
Save(settings); // Persist identity even if the application closes before a model is ready.
}
output.WriteLine($"Agent name: {settings.AgentName}");
string? overrideModel = Environment.GetEnvironmentVariable("LILITH_MODEL");
if (!string.IsNullOrWhiteSpace(overrideModel)) settings = settings with { Model = overrideModel.Trim() };
bool available = settings.Model is not null &&
await logger.TimeStageAsync("Saved model check", () => services.HasModelAsync(settings.Model));
if (!available)
{
if (settings.Model is not null) output.WriteLine($"Model '{settings.Model}' is not installed. Choose a model to continue.");
string? selected = await logger.TimeStageAsync("Model selection", () => Task.FromResult(SelectModel()));
if (selected is null) return null;
settings = settings with { Model = selected };
Save(settings); // Remember selection even if a download is interrupted.
available = await logger.TimeStageAsync("Selected model check", () => services.HasModelAsync(selected));
if (!available)
{
if (!await logger.TimeStageAsync("Download choice", () => Task.FromResult(Confirm($"Download model '{selected}'? [y/N, exit]: ")))) return null;
await logger.TimeStageAsync("Model download", () => services.DownloadAsync(selected));
if (!await logger.TimeStageAsync("Downloaded model validation", () => services.HasModelAsync(selected)))
throw new InvalidOperationException("Model is still unavailable after download. Relaunch setup to retry.");
}
else output.WriteLine($"Model '{selected}' already installed; skipping download.");
}
else output.WriteLine($"Model '{settings.Model}' already installed; skipping selection and download.");
Save(settings);
totalTimer.Completed = true;
return new LilithCredentials(settings.Model!,
$"You are {settings.AgentName}, a helpful AI assistant. Greet the user warmly when the conversation begins.", settings.AgentName!);
}
private string? SelectModel()
{
while (true)
{
output.WriteLine("Choose a model:");
for (int i = 0; i < Models.Count; i++) output.WriteLine($"{i + 1}. {Models[i]}");
output.WriteLine("5. Custom model\n0. Exit");
output.Write("Selection: ");
string? selection = input.ReadLine()?.Trim();
if (selection is null || selection.Equals("exit", StringComparison.OrdinalIgnoreCase) || selection == "0") return null;
if (int.TryParse(selection, out int index) && index is >= 1 and <= 4) return Models[index - 1];
if (selection == "5")
{
output.Write("Custom model name/tag (or exit): ");
string? custom = input.ReadLine()?.Trim();
if (custom is null || custom.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
if (custom.Length <= 200 && RegexPatterns.OllamaModelIdentifier().IsMatch(custom)) return custom;
output.WriteLine("Enter a valid model name, for example llama3.2:1b.");
}
else output.WriteLine("Choose 1–5, or 0 to exit.");
}
}
private bool Confirm(string question)
{
while (true)
{
output.Write(question);
string? answer = input.ReadLine()?.Trim().ToLowerInvariant();
if (answer is null or "" or "n" or "no" or "exit" or "0") return false;
if (answer is "y" or "yes") return true;
output.WriteLine("Please enter y, n, or exit.");
}
}
private ConsoleSettings Load()
{
if (!File.Exists(settingsPath)) return new();
try
{
return JsonSettings.Load<ConsoleSettings>(settingsPath, settings => settings.IsValid());
}
catch (JsonException ex) { output.WriteLine($"Saved settings could not be read ({ex.Message}); setup will ask again."); return new(); }
}
private void Save(ConsoleSettings settings)
{
JsonSettings.Save(settingsPath, settings);
}
}
NEWConsoleSetupTimer.cs644 B / 19 lines+19-0+644 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;
namespace LilithConsole.Setup;
internal sealed class ConsoleSetupTimer(Logger logger) : IDisposable
{
private readonly System.Diagnostics.Stopwatch _timer = System.Diagnostics.Stopwatch.StartNew();
public bool Completed { get; set; }
public void Dispose()
{
_timer.Stop();
logger.WriteLine("Boot", $"Console setup {(Completed ? "finished" : "stopped")} — elapsed {_timer.Elapsed.TotalSeconds:F2}s", LogColors.Gray);
}
}
NEWISetupServices.cs412 B / 17 lines+17-0+412 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;
namespace LilithConsole.Setup;
public interface ISetupServices
{
bool IsInstalled();
Task InstallAsync();
Task EnsureServerAsync();
Task<bool> HasModelAsync(string model);
Task DownloadAsync(string model);
}
NEWOllamaSetupServices.cs697 B / 17 lines+17-0+697 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;
namespace LilithConsole.Setup;
public sealed class OllamaSetupServices(Logger logger) : ISetupServices
{
public bool IsInstalled() => OllamaClient.IsOllamaInstalled();
public Task InstallAsync() => OllamaClient.InstallAsync(logger);
public Task EnsureServerAsync() => OllamaClient.EnsureServerAsync(logger);
public Task<bool> HasModelAsync(string model) => OllamaClient.IsModelAvailableAsync(model);
public Task DownloadAsync(string model) => OllamaClient.DownloadModelAsync(model, logger);
}
NEWProviderSetup.cs4,577 B / 81 lines+81-0+4,577 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Agent;
using Lilith.Core.LMStudio;
using Lilith.Core.Logging;
using Lilith.Core.Extensions;
namespace LilithConsole.Setup;
public static class ProviderSetup
{
public static async Task<LilithCredentials?> RunAsync(TextReader input, TextWriter output, Logger logger, string settingsPath)
{
string? selected = Environment.GetEnvironmentVariable("LILITH_PROVIDER");
if (string.IsNullOrWhiteSpace(selected))
{
output.WriteLine("Choose your provider: 1. Ollama 2. LM Studio (Windows GUI) 0. Exit");
output.Write("Provider [1]: ");
selected = input.ReadLine()?.Trim();
if (selected is null or "0" || selected.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
if (selected == "") selected = "1";
}
if (selected is "1" || selected.Equals("Ollama", StringComparison.OrdinalIgnoreCase))
return await new ConsoleSetup(input, output, new OllamaSetupServices(logger), logger, settingsPath).RunAsync();
if (selected != "2" && !selected.Equals("LMStudio", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Select 1/Ollama or 2/LMStudio.");
string path = settingsPath + ".lmstudio.json";
ConsoleSettings preferences = new();
if (File.Exists(path))
{
try { preferences = JsonSettings.Load<ConsoleSettings>(path, settings => settings.IsValid()); }
catch (JsonException) { output.WriteLine("Saved LM Studio preferences are invalid; choose them again."); }
}
if (!LMStudioSetup.IsInstalled())
{
output.Write("Download and install the LM Studio Windows GUI? [y/N]: ");
string? answer = input.ReadLine()?.Trim();
if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) && !string.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) return null;
await LMStudioSetup.InstallGuiAsync(logger);
}
await LMStudioSetup.EnsureServerAsync(logger);
if (preferences.AgentName is null)
{
output.Write("Agent name [Lilith]: ");
string? name = input.ReadLine()?.Trim();
if (name is null || name.Equals("/exit", StringComparison.OrdinalIgnoreCase)) return null;
if (name.Length > 80 || name.Any(char.IsControl)) throw new ArgumentException("Invalid agent name.");
preferences = preferences with { AgentName = name.Length == 0 ? "Lilith" : name };
}
void Save() => JsonSettings.Save(path, preferences);
Save();
var models = await LMStudioSetup.ListModelsAsync();
if (preferences.Model is null || !models.Any(m => m.Id == preferences.Model))
{
output.WriteLine("Downloaded LM Studio models:");
for (int i = 0; i < models.Count; i++) output.WriteLine($"{i + 1}. {models[i].Name} ({models[i].Id})");
output.Write("Choose a number or enter a catalog ID (e.g. ibm/granite-4-micro); 0 exits: ");
string? model = input.ReadLine()?.Trim();
if (model is null or "0" || model.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
if (int.TryParse(model, out int index))
{
if (index < 1 || index > models.Count) throw new ArgumentException("Invalid model number.");
model = models[index - 1].Id;
}
if (model.Length > 200 || !RegexPatterns.ModelIdentifier().IsMatch(model)) throw new ArgumentException("Invalid LM Studio model identifier.");
preferences = preferences with { Model = model };
Save();
if (!models.Any(m => m.Id == model))
{
output.Write($"Download LM Studio model '{model}'? [y/N]: ");
string? answer = input.ReadLine()?.Trim();
if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) && !string.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) return null;
await LMStudioSetup.DownloadModelAsync(model, logger);
}
}
return new LilithCredentials(preferences.Model!,
$"You are {preferences.AgentName}, a helpful AI assistant. Greet the user warmly when the conversation begins.",
preferences.AgentName!, Lilith.Core.Providers.ProviderKind.LMStudio);
}
}
Lilith/Agent 10 files
NEWAgent.csproj354 B / 14 lines+14-0+354 B+
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Lilith.Agent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
</Project>
NEWAssemblyInfo.cs101 B / 3 lines+3-0+101 B+
C#UnchangedRemoved / replacedAdded
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Lilith.RegressionChecks")]
NEWGlobalUsings.cs70 B / 2 lines+2-0+70 B+
C#UnchangedRemoved / replacedAdded
global using Lilith.Core.Chat;
global using Lilith.Core.Extensions;
NEWLilith.Boot.cs2,469 B / 59 lines+59-0+2,469 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
namespace Lilith.Agent;
public partial class Lilith
{
public bool IsBooted { get; private set; }
public async Task BootAsync(bool startServerIfNotRunning = true)
{
using var operation = EnterOperation();
try
{
if (IsBooted) return;
if (string.IsNullOrWhiteSpace(Credentials.Model) || string.IsNullOrWhiteSpace(Credentials.SystemPrompt) ||
string.IsNullOrWhiteSpace(Credentials.AgentName)) throw new ArgumentException("Model, system prompt, and agent name are required.");
await Logger.TimeStageAsync("Total agent boot", () => BootCoreAsync(startServerIfNotRunning));
}
catch
{
_llmclient?.Dispose();
_llmclient = null!;
IsBooted = false;
throw;
}
}
private async Task BootCoreAsync(bool startServerIfNotRunning)
{
IsBooted = false;
await Logger.TimeStageAsync("Client initialization", () => InitializeProviderAsync(startServerIfNotRunning));
await Logger.TimeStageAsync("Chat readiness validation", () => { EnsureChatReadiness(); return Task.CompletedTask; });
if (Credentials.Tts is { Enabled: true } options && _tts is null)
{
try
{
_tts = await Logger.TimeStageAsync("Kokoro initialization", () =>
global::Lilith.Core.Kokoro.KokoroTts.CreateAsync(Logger, options));
}
catch (Exception ex) { Logger.SystemWriteLine($"Speech unavailable: {ex.Message}. Text chat remains available.", LogColors.Orange); }
}
Logger.SystemWriteLine($"=== {Credentials.AgentName} AI Chat ({Credentials.Model} via {Credentials.Provider}) ===");
Logger.SystemWriteLine("Type your message and press Enter. Type 'exit' to quit.\n");
Logger.SystemPromptWriteLine($"SYSTEM: {Credentials.SystemPrompt}");
await Logger.TimeStageAsync("Greeting generation", () => ChatCoreAsync("Please greet the user."));
Logger.SystemWriteLine("", LogColors.White);
IsBooted = true;
}
public async Task GreetAsync() => await ChatAsync("Please greet the user.");
private void EnsureChatReadiness()
{
_llmclient.IsNull();
this.EnsureSystemPromptContent();
this.EnsureContextInitialized();
}
}
NEWLilith.Chat.cs3,087 B / 86 lines+86-0+3,087 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using System.Diagnostics;
namespace Lilith.Agent;
public partial class Lilith
{
public async Task ChatAsync(string input, List<string>? images = null)
{
using var operation = EnterOperation("Boot the agent before chatting.");
await ChatCoreAsync(input, images);
}
private async Task ChatCoreAsync(string input, List<string>? images = null)
{
var timer = Stopwatch.StartNew();
string reply;
try { reply = await Client.StreamReply(input, images); }
finally { timer.Stop(); }
this.LogTokenCounts(timer.Elapsed);
if (_tts is not null) await _tts.SpeakAsync(reply);
}
private T WithConversation<T>(Func<ProviderClient, T> action)
{
using var operation = EnterOperation("Boot the agent before using its conversation.");
return action(Client);
}
private void WithConversation(Action<ProviderClient> action)
{
using var operation = EnterOperation("Boot the agent before using its conversation.");
action(Client);
}
public void NewConversation() => WithConversation(client => client.NewConversation());
public void SaveConversation() => WithConversation(client => client.SaveConversation());
public IReadOnlyList<ChatSessionSummary> ListConversations() => WithConversation(client => client.ListConversations());
public bool LoadConversation(int displayIndex) => WithConversation(client => client.LoadConversation(displayIndex));
public bool LoadConversation(string id) => WithConversation(client => client.LoadConversation(id));
public void HandleHistoryCommand(string input)
{
var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 1)
{
var sessions = ListConversations();
if (sessions.Count == 0)
{
Logger.HistoryWriteLine("No saved chats found.");
return;
}
Logger.HistoryWriteLine("Saved chats:", LogColors.Cyan);
for (int i = 0; i < sessions.Count; i++)
{
var session = sessions[i];
Logger.HistoryWriteLine($"{i + 1}. {session.Title} ({session.UpdatedAt.LocalDateTime:g}, {session.MessageCount} messages)");
}
Logger.HistoryWriteLine("Use /history <number> to load a chat.");
return;
}
if (!int.TryParse(parts[1], out int index))
{
Logger.HistoryWriteLine("Usage: /history or /history <number>", LogColors.Orange);
return;
}
if (LoadConversation(index))
{
Logger.HistoryWriteLine($"Loaded saved chat #{index}.", LogColors.Cyan);
}
else
{
Logger.HistoryWriteLine($"Saved chat #{index} was not found.", LogColors.Orange);
}
}
}
NEWLilith.Commands.cs1,469 B / 27 lines+27-0+1,469 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Commands;
namespace Lilith.Agent;
public partial class Lilith
{
private readonly object _commandSync = new();
private ChatCommandRegistry? _chatCommands;
public ChatCommandRegistry ChatCommands { get { lock (_commandSync) return _chatCommands ??= CreateCommands(); } }
private ChatCommandRegistry CreateCommands()
{
var commands = new ChatCommandRegistry();
commands.Register("help", "List available chat commands.", context => { context.RequireNoArguments(); return commands.Help(); });
commands.Register("new", "Start a new conversation.", context => { context.RequireNoArguments(); NewConversation(); return null; });
commands.Register("history", "List saved chats or load one with /history <index>.", context => { HandleHistoryCommand("/history " + context.Arguments); return null; });
commands.RegisterAsync("exit", "Save and exit the chat host.", context => { context.RequireNoArguments(); return Task.FromResult(new ChatCommandResult(true, ExitRequested: true)); });
RegisterScenarioCommands(commands);
RegisterWorkspaceCommands(commands);
RegisterScheduleCommands(commands);
return commands;
}
partial void RegisterScenarioCommands(ChatCommandRegistry commands);
partial void RegisterWorkspaceCommands(ChatCommandRegistry commands);
partial void RegisterScheduleCommands(ChatCommandRegistry commands);
}
NEWLilith.Extensions.cs3,206 B / 67 lines+67-0+3,206 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.LMStudio;
namespace Lilith.Agent;
/// <summary>Agent client creation and logging helpers.</summary>
public static class LilithExtensions
{
public static void EnsureSystemPromptSeeded(this Lilith agent)
{
if (agent.History.Count == 0 || !string.Equals(agent.History[0].Role, "system", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Chat history was not seeded with a system prompt.");
}
public static void EnsureSystemPromptContent(this Lilith agent)
{
agent.EnsureSystemPromptSeeded();
if (string.IsNullOrWhiteSpace(agent.History[0].Content))
throw new InvalidOperationException("System prompt content is missing.");
}
public static void EnsureContextInitialized(this Lilith agent)
{
if (agent.ContextMax <= 0)
throw new InvalidOperationException("Model context window was not initialized.");
}
public static async Task<ProviderClient> CreateClient(this Lilith agent, bool startServerIfNotRunning = true) =>
agent.Credentials.Provider switch
{
ProviderKind.Ollama => await OllamaClient.CreateClient(agent.Credentials.Model, agent.Credentials.SystemPrompt,
agent.Logger, startServerIfNotRunning, agent.Credentials.AgentName),
ProviderKind.LMStudio => await LMStudioClient.CreateClient(agent.Credentials.Model, agent.Credentials.SystemPrompt,
agent.Logger, startServerIfNotRunning, agent.Credentials.AgentName),
_ => throw new ArgumentOutOfRangeException(nameof(agent.Credentials.Provider))
};
public static void HistoryWriteLine(this Logger logger, string description = "", string color = LogColors.Gray) =>
logger.WriteLine("History", description, color);
public static void TokensWriteLine(this Logger logger, string description = "", string color = LogColors.Gray) =>
logger.WriteLine("Tokens", description, color);
public static void SystemWriteLine(this Logger logger, string description = "", string color = LogColors.Cyan) =>
logger.WriteLine("System", description, color);
public static void SystemPromptWriteLine(this Logger logger, string description = "", string color = LogColors.Cyan) =>
logger.WriteLine("SYSTEM", description, color);
public static void LogTokenCounts(this Lilith agent, TimeSpan? elapsed = null, string color = LogColors.Gray)
{
if (agent.History.Count < 2)
{
return;
}
int? promptTokens = agent.History[^2].Tokens;
int? completionTokens = agent.History[^1].Tokens;
if (promptTokens.HasValue || completionTokens.HasValue)
{
string elapsedText = elapsed is { } duration ? $" elapsed: {duration.TotalSeconds:F2}s" : string.Empty;
agent.Logger.TokensWriteLine($"prompt: {promptTokens ?? 0} completion: {completionTokens ?? 0} total: {(promptTokens ?? 0) + (completionTokens ?? 0)}{elapsedText}", color);
}
}
}
NEWLilith.SystemPrompt.cs206 B / 7 lines+7-0+206 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Agent;
public partial class Lilith
{
public const string DefaultSystemPrompt = "You are Lilith, a helpful AI assistant. Greet the user warmly when the conversation begins.";
}
NEWLilith.cs2,150 B / 55 lines+55-0+2,150 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;
namespace Lilith.Agent;
public partial class Lilith(Action<LogEntry> onLog, LilithCredentials? credentials = null) : IAsyncDisposable
{
private KokoroTts? _tts;
private readonly SemaphoreSlim _operation = new(1, 1);
private bool _disposed;
private ProviderClient _llmclient = null!;
private readonly LilithCredentials _credentials = credentials ?? new();
public LilithCredentials Credentials => _credentials;
public Logger Logger { get; } = new(onLog);
internal Func<bool, Task<ProviderClient>>? ProviderFactory { get; set; }
private ProviderClient Client
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _llmclient ?? throw new InvalidOperationException("Boot the agent before using its conversation.");
}
}
public ChatHistory History => Client.History;
public int ContextMax => Client.ContextMax;
public int CurrentContextTokens => Client.CurrentContextTokens;
private async Task InitializeProviderAsync(bool startServerIfNotRunning) =>
_llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);
private IDisposable EnterOperation(string? bootRequiredMessage = null)
{
var operation = _operation.EnterOrThrow("An agent operation is already in progress.");
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (bootRequiredMessage is not null && !IsBooted) throw new InvalidOperationException(bootRequiredMessage);
return operation;
}
catch { operation.Dispose(); throw; }
}
public async ValueTask DisposeAsync()
{
using var operation = await _operation.EnterAsync();
if (_disposed) return;
_disposed = true;
try { if (_tts is not null) await _tts.DisposeAsync(); }
finally { _llmclient?.Dispose(); IsBooted = false; }
}
}
NEWLilithCredentials.cs380 B / 13 lines+13-0+380 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;
namespace Lilith.Agent;
public record LilithCredentials(
string Model = "gemma4",
string SystemPrompt = Lilith.DefaultSystemPrompt,
string AgentName = "Lilith",
ProviderKind Provider = ProviderKind.Ollama,
KokoroTtsOptions? Tts = null);
Lilith/Core 3 files
NEWCore.cs2,795 B / 74 lines+74-0+2,795 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core;
/// <summary>
/// Root hub for agent addons. Create one instance and access <see cref="Logger"/>,
/// <see cref="OllamaClient"/>, and future services from the same object.
/// </summary>
public class Core(Action<LogEntry>? onLog = null) : IDisposable
{
private static readonly Lazy<Core> Shared = new(() => new());
private readonly object _sync = new();
private OllamaClient? _client;
private bool _ownsClient;
private bool _disposed;
/// <summary>Shared application core (optional).</summary>
public static Core Default => Shared.Value;
/// <summary>Subscribe-based logging (console, GUI, file, etc.).</summary>
public Logger Logger { get; } = new(onLog);
/// <summary>
/// Ollama client addon. Assign after construction or via <see cref="UseOllamaAsync"/>.
/// </summary>
public OllamaClient? OllamaClient
{
get { lock (_sync) { ObjectDisposedException.ThrowIf(_disposed, this); return _client; } }
set => Attach(value, owned: false);
}
/// <summary>Attach an Ollama client and return it for chaining.</summary>
public OllamaClient UseOllama(OllamaClient ollamaClient)
{
Attach(ollamaClient ?? throw new ArgumentNullException(nameof(ollamaClient)), owned: false);
return ollamaClient;
}
/// <summary>
/// Creates and attaches a client for an already prepared model. Installation/download
/// are explicit setup operations; this method only starts/checks the server and validates readiness.
/// </summary>
public async Task<OllamaClient> UseOllamaAsync(string model, string systemPrompt, bool startOllamaIfNotRunning = true)
{
lock (_sync) ObjectDisposedException.ThrowIf(_disposed, this);
var client = await OllamaClient.CreateClient(model, systemPrompt, Logger, startOllamaIfNotRunning);
try { Attach(client, owned: true); return client; }
catch { client.Dispose(); throw; }
}
/// <summary>External attachments are borrowed; clients created by UseOllamaAsync are owned.</summary>
internal void Attach(OllamaClient? client, bool owned)
{
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (ReferenceEquals(_client, client)) return;
if (_ownsClient) _client?.Dispose();
_client = client;
_ownsClient = owned;
}
}
public void Dispose()
{
lock (_sync)
{
if (_disposed) return;
if (_ownsClient) _client?.Dispose();
_disposed = true;
_client = null;
_ownsClient = false;
}
GC.SuppressFinalize(this);
}
}
NEWCore.csproj362 B / 13 lines+13-0+362 B+
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Lilith.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KokoroSharp.CPU" Version="0.8.4" />
</ItemGroup>
</Project>
NEWGlobalUsings.cs176 B / 5 lines+5-0+176 B+
C#UnchangedRemoved / replacedAdded
global using Lilith.Core.Logging;
global using Lilith.Core.Ollama;
global using Lilith.Core.Chat;
global using Lilith.Core.Extensions;
global using Lilith.Core.Providers;
Lilith/Core/Chat 12 files
NEWChatChunk.cs364 B / 9 lines+9-0+364 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
public record ChatChunk(
[property: JsonPropertyName("message")] ChatMessage? Message,
[property: JsonPropertyName("done")] bool Done,
[property: JsonPropertyName("prompt_eval_count")] int? PromptTokens,
[property: JsonPropertyName("eval_count")] int? CompletionTokens);
NEWChatHistory.cs259 B / 9 lines+9-0+259 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
public class ChatHistory : List<ChatMessage>
{
public void Add(string role, string content, int? tokens = null) =>
Add(new ChatMessage(role, content, Tokens: tokens));
}
NEWChatHistoryStore.cs9,138 B / 247 lines+247-0+9,138 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public class ChatHistoryStore
{
public string CurrentSessionId { get; private set; } = NewSessionId();
public string RootDirectory { get; }
public ChatHistoryStore() : this(null)
{
}
/// <param name="rootDirectoryOverride">Optional folder for tests or custom deployments.</param>
public ChatHistoryStore(string? rootDirectoryOverride)
{
if (!string.IsNullOrWhiteSpace(rootDirectoryOverride))
{
RootDirectory = rootDirectoryOverride;
return;
}
string? envRoot = Environment.GetEnvironmentVariable("AGENT_CORE_CHAT_HISTORY_ROOT");
if (!string.IsNullOrWhiteSpace(envRoot))
{
RootDirectory = envRoot;
return;
}
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (string.IsNullOrWhiteSpace(appData))
{
appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
if (string.IsNullOrWhiteSpace(appData))
{
appData = AppContext.BaseDirectory;
}
RootDirectory = Path.Combine(appData, "AgentCore", DetectVersionLabel(), "ChatHistory");
}
public void StartNewSession() => CurrentSessionId = NewSessionId();
public void UseSession(string id)
{
if (!string.IsNullOrWhiteSpace(id))
{
CurrentSessionId = SanitizeId(id);
}
}
public string SaveSession(string model, IEnumerable<ChatMessage> history) =>
SaveSession(CurrentSessionId, model, history);
public string SaveSession(string? sessionId, string model, IEnumerable<ChatMessage> history)
{
Directory.CreateDirectory(RootDirectory);
string id = string.IsNullOrWhiteSpace(sessionId) ? NewSessionId() : SanitizeId(sessionId);
string path = SessionPath(id);
DateTimeOffset now = DateTimeOffset.Now;
DateTimeOffset createdAt = ReadCreatedAt(path) ?? now;
var messages = history.Select(m => m.ToSavedMessage()).ToList();
var doc = new XDocument(
new XElement("chatSession",
new XAttribute("id", id),
new XAttribute("title", MakeTitle(messages)),
new XAttribute("model", model),
new XAttribute("createdAt", createdAt.ToString("O")),
new XAttribute("updatedAt", now.ToString("O")),
new XElement("messages",
messages.Select(m =>
new XElement("message",
new XAttribute("role", m.Role),
m.Tokens.HasValue ? new XAttribute("tokens", m.Tokens.Value) : null,
new XElement("content", m.Content),
m.Images is { Count: > 0 }
? new XElement("images", m.Images.Select(i => new XElement("image", i)))
: null)))));
AtomicFile.Write(path, doc.Save);
CurrentSessionId = id;
return id;
}
public IReadOnlyList<ChatSessionSummary> ListSessions()
{
if (!Directory.Exists(RootDirectory))
{
return [];
}
return Directory.EnumerateFiles(RootDirectory, "*.xml")
.Select(ReadSummary)
.Where(s => s is not null)
.Select(s => s!)
.OrderByDescending(s => s.UpdatedAt)
.ToList();
}
public SavedChatSession? LoadSession(string id)
{
string path = SessionPath(SanitizeId(id));
if (!File.Exists(path))
{
return null;
}
var session = ReadSession(path, SanitizeId(id));
CurrentSessionId = session.Id;
return session;
}
private static SavedChatSession ReadSession(string path, string id)
{
XDocument doc = XDocument.Load(path);
XElement root = doc.Root ?? throw new InvalidDataException("Chat XML has no root.");
if (root.Name != "chatSession" || root.Element("messages") is null ||
((string?)root.Attribute("id") is { } embedded && embedded != id))
throw new InvalidDataException("Invalid chat session structure or identity.");
var session = new SavedChatSession
{
Id = id,
Title = (string?)root.Attribute("title") ?? "New chat",
Model = (string?)root.Attribute("model") ?? string.Empty,
CreatedAt = ParseDate((string?)root.Attribute("createdAt")) ?? DateTimeOffset.Now,
UpdatedAt = ParseDate((string?)root.Attribute("updatedAt")) ?? DateTimeOffset.Now,
Messages = root.Element("messages")?.Elements("message").Select(ReadMessage).ToList() ?? []
};
if (session.Messages.Count == 0) throw new InvalidDataException("Chat has no messages.");
for (int i = 0; i < session.Messages.Count; i++)
{
var message = session.Messages[i];
if (message.Role is not ("system" or "user" or "assistant") || message.Tokens is < 0 ||
(message.Role == "system" && (i != 0 || string.IsNullOrWhiteSpace(message.Content))))
throw new InvalidDataException("Chat contains invalid messages.");
}
return session;
}
private string SessionPath(string id) => Path.Combine(RootDirectory, SanitizeId(id) + ".xml");
private static SavedChatMessage ReadMessage(XElement element)
{
int? tokens = null;
if (element.Attribute("tokens") is { } attribute)
{
if (!int.TryParse(attribute.Value, out int value)) throw new InvalidDataException("Invalid token count.");
tokens = value;
}
var images = element.Element("images")?.Elements("image")
.Select(i => i.Value)
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToList();
return new SavedChatMessage
{
Role = (string?)element.Attribute("role") ?? string.Empty,
Content = element.Element("content")?.Value ?? string.Empty,
Tokens = tokens,
Images = images is { Count: > 0 } ? images : null
};
}
private static ChatSessionSummary? ReadSummary(string path)
{
try
{
string id = Path.GetFileNameWithoutExtension(path);
var session = ReadSession(path, id);
return new ChatSessionSummary(
id,
session.Title, session.CreatedAt, session.UpdatedAt, session.Messages.Count, session.Model);
}
catch
{
return null;
}
}
private static DateTimeOffset? ReadCreatedAt(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
XDocument doc = XDocument.Load(path);
return ParseDate((string?)doc.Root?.Attribute("createdAt"));
}
catch
{
return null;
}
}
private static DateTimeOffset? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static string MakeTitle(IReadOnlyList<SavedChatMessage> messages)
{
string? firstUser = messages
.Where(m => m.Role.Equals("user", StringComparison.OrdinalIgnoreCase))
.Select(m => m.Content.Trim())
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
!m.StartsWith("Please greet", StringComparison.OrdinalIgnoreCase) &&
!m.Equals("hello", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(firstUser))
{
firstUser = messages.FirstOrDefault(m => m.Role != "system" && !string.IsNullOrWhiteSpace(m.Content))?.Content.Trim();
}
if (string.IsNullOrWhiteSpace(firstUser))
{
return "New chat";
}
firstUser = RegexPatterns.Whitespace().Replace(firstUser, " ");
return firstUser.Length <= 60 ? firstUser : firstUser[..57] + "...";
}
private static string DetectVersionLabel()
{
foreach (string source in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
var match = RegexPatterns.VersionLabel().Match(source);
if (match.Success)
{
return RegexPatterns.Whitespace().Replace(match.Value, " ");
}
}
return "Default";
}
private static string NewSessionId() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff") + "-" + Guid.NewGuid().ToString("N");
private static string SanitizeId(string id) =>
string.Concat(id.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));
}
NEWChatMessage.cs399 B / 9 lines+9-0+399 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
public record ChatMessage(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<string>? Images = null,
[property: JsonIgnore] int? Tokens = null);
NEWChatMessageExtensions.cs731 B / 18 lines+18-0+731 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Chat;
public static class ChatMessageExtensions
{
public static ChatMessage Snapshot(this ChatMessage message) =>
message with { Images = message.Images is null ? null : [.. message.Images] };
public static SavedChatMessage ToSavedMessage(this ChatMessage message) => new()
{
Role = message.Role,
Content = message.Content ?? string.Empty,
Tokens = message.Tokens,
Images = message.Images is { Count: > 0 } ? [.. message.Images] : null
};
public static ChatMessage ToChatMessage(this SavedChatMessage message) =>
new(message.Role, message.Content, message.Images is null ? null : [.. message.Images], message.Tokens);
}
NEWChatOptions.cs160 B / 6 lines+6-0+160 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
internal record ChatOptions(
[property: JsonPropertyName("num_ctx")] int NumCtx);
NEWChatRequest.cs365 B / 9 lines+9-0+365 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
internal record ChatRequest(
[property: JsonPropertyName("model")] string Model,
[property: JsonPropertyName("messages")] List<ChatMessage> Messages,
[property: JsonPropertyName("stream")] bool Stream,
[property: JsonPropertyName("options")] ChatOptions? Options = null);
NEWChatSessionSummary.cs271 B / 12 lines+12-0+271 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public record ChatSessionSummary(
string Id,
string Title,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
int MessageCount,
string Model);
NEWSavedChatMessage.cs329 B / 12 lines+12-0+329 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public class SavedChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int? Tokens { get; set; }
public List<string>? Images { get; set; }
}
NEWSavedChatSession.cs503 B / 14 lines+14-0+503 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public class SavedChatSession
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = "New chat";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Now;
public string Model { get; set; } = string.Empty;
public List<SavedChatMessage> Messages { get; set; } = [];
}
NEWShowModelRequest.cs165 B / 6 lines+6-0+165 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
internal record ShowModelRequest(
[property: JsonPropertyName("model")] string Model);
NEWShowModelResponse.cs218 B / 6 lines+6-0+218 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
internal record ShowModelResponse(
[property: JsonPropertyName("model_info")] Dictionary<string, System.Text.Json.JsonElement>? ModelInfo);
Lilith/Core/Commands 1 files
NEWChatCommandRegistry.cs2,811 B / 47 lines+47-0+2,811 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
namespace Lilith.Core.Commands;
public sealed record ChatCommandContext(string Arguments, CancellationToken CancellationToken)
{
public void RequireNoArguments() { if (Arguments.Length > 0) throw new ArgumentException("This command takes no arguments."); }
}
public sealed record ChatCommandResult(bool Handled, string? Output = null, bool ExitRequested = false);
public sealed record ChatCommandInfo(string Name, string Description);
/// <summary>Host commands are dispatched locally, never sent to the model as chat.</summary>
public sealed class ChatCommandRegistry
{
private readonly object _sync = new();
private readonly Dictionary<string, (ChatCommandInfo Info, Func<ChatCommandContext, Task<ChatCommandResult>> Handler)> _commands = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<ChatCommandInfo> List() { lock (_sync) return _commands.Values.Select(c => c.Info).OrderBy(c => c.Name, StringComparer.Ordinal).ToArray(); }
public string Help() => string.Join("\n", List().Select(c => $"/{c.Name} - {c.Description}"));
public void Register(string name, string description, Func<ChatCommandContext, string?> handler)
{
ArgumentNullException.ThrowIfNull(handler);
RegisterAsync(name, description, context => Task.FromResult(new ChatCommandResult(true, handler(context))));
}
public void RegisterAsync(string name, string description, Func<ChatCommandContext, Task<ChatCommandResult>> handler)
{
ArgumentNullException.ThrowIfNull(handler);
name = name.TrimStart('/');
if (!Regex.IsMatch(name, "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$") || string.IsNullOrWhiteSpace(description)) throw new ArgumentException("Supply a command name and description.");
lock (_sync)
if (!_commands.TryAdd(name, (new(name.ToLowerInvariant(), description), handler))) throw new ArgumentException("Command already registered: " + name);
}
public async Task<ChatCommandResult> ExecuteAsync(string input, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string text = input.TrimStart();
if (!text.StartsWith('/')) return new(false);
int end = 1;
while (end < text.Length && !char.IsWhiteSpace(text[end])) end++;
string name = text[1..end];
Func<ChatCommandContext, Task<ChatCommandResult>>? handler;
lock (_sync) handler = _commands.TryGetValue(name, out var command) ? command.Handler : null;
if (handler is null) return new(true, $"Unknown command /{name}. Use /help.");
var result = await handler(new(text[end..].Trim(), cancellationToken));
return result with { Handled = true };
}
}
Lilith/Core/Extensions 9 files
NEWAtomicFile.cs1,165 B / 28 lines+28-0+1,165 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;
public static class AtomicFile
{
private static readonly object[] Gates = [.. Enumerable.Range(0, 64).Select(_ => new object())];
public static void Write(string path, Action<string> write)
{
path = Path.GetFullPath(path);
lock (Gates[(uint)StringComparer.OrdinalIgnoreCase.GetHashCode(path) % Gates.Length])
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
write(temporary);
// Windows readers/other processes may briefly hold the destination without delete sharing.
for (int attempt = 0; ; attempt++)
{
try { File.Move(temporary, path, overwrite: true); break; }
catch (Exception ex) when (attempt < 10 && ex is IOException or UnauthorizedAccessException)
{ Thread.Sleep(20); }
}
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}
}
NEWBootTimingExtensions.cs888 B / 27 lines+27-0+888 B+
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
namespace Lilith.Core.Extensions;
public static class BootTimingExtensions
{
public static async Task<T> TimeStageAsync<T>(this Logger logger, string stage, Func<Task<T>> action)
{
var timer = Stopwatch.StartNew();
bool succeeded = false;
try
{
T result = await action();
succeeded = true;
return result;
}
finally
{
timer.Stop();
logger.WriteLine("Boot", $"{stage} {(succeeded ? "finished" : "failed")} — elapsed {timer.Elapsed.TotalSeconds:F2}s",
succeeded ? LogColors.Gray : LogColors.Red);
}
}
public static Task TimeStageAsync(this Logger logger, string stage, Func<Task> action) =>
logger.TimeStageAsync(stage, async () => { await action(); return true; });
}
NEWHttpExtensions.cs1,621 B / 43 lines+43-0+1,621 B+
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Json;
using System.Text.Json;
namespace Lilith.Core.Extensions;
public static class HttpExtensions
{
public static HttpRequestMessage JsonRequest<T>(string url, T body) =>
new(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
public static T? Deserialize<T>(this string json) =>
JsonSerializer.Deserialize<T>(json);
public static async Task<T?> SendJsonAsync<T>(this HttpClient http, HttpRequestMessage request,
CancellationToken ct = default)
{
using var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
return JsonSerializer.Deserialize<T>(await response.Content.ReadAsStringAsync(ct));
}
/// <summary>The caller owns the returned document; the request remains caller-owned.</summary>
public static async Task<JsonDocument> SendJsonDocumentAsync(this HttpClient http, HttpRequestMessage request,
CancellationToken ct = default)
{
using var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
}
public static async Task<bool> IsReachableAsync(this HttpClient http, string url)
{
try
{
using HttpResponseMessage response = await http.GetAsync(url);
return response.IsSuccessStatusCode;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return false;
}
}
}
NEWJsonSettings.cs631 B / 16 lines+16-0+631 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
namespace Lilith.Core.Extensions;
public static class JsonSettings
{
public static T Load<T>(string path, Func<T, bool> valid) where T : class
{
var value = JsonSerializer.Deserialize<T>(File.ReadAllText(path));
if (value is null || !valid(value)) throw new JsonException("Saved preferences contain invalid values.");
return value;
}
public static void Save<T>(string path, T value) => AtomicFile.Write(path, temporary =>
File.WriteAllText(temporary, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true })));
}
NEWProcessExtensions.cs639 B / 18 lines+18-0+639 B+
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
namespace Lilith.Core.Extensions;
public static class ProcessExtensions
{
/// <summary>On timeout, terminates the process tree and waits before releasing ownership.</summary>
public static async Task WaitForExitOrKillAsync(this Process process, TimeSpan timeout, string message)
{
try { await process.WaitForExitAsync().WaitAsync(timeout); }
catch (TimeoutException)
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
await process.WaitForExitAsync();
throw new TimeoutException(message);
}
}
}
NEWRegexPatterns.cs1,029 B / 31 lines+31-0+1,029 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
namespace Lilith.Core.Extensions;
/// <summary>Shared fixed patterns compiled by the regex source generator.</summary>
public static partial class RegexPatterns
{
[GeneratedRegex(@"\S+")]
public static partial Regex Words();
[GeneratedRegex(@"\s+")]
public static partial Regex Whitespace();
[GeneratedRegex(@"Version\s+\d+", RegexOptions.IgnoreCase)]
public static partial Regex VersionLabel();
[GeneratedRegex(@"^\s*[#\s]*\d+(?:\.\d+)?%\s*$")]
public static partial Regex InstallerProgress();
[GeneratedRegex(@"\x1b\[[0-?]*[ -/]*[@-~]")]
public static partial Regex AnsiEscape();
[GeneratedRegex(@"(?<![\d.])(\d+(?:\.\d+)?)%")]
public static partial Regex DownloadPercentage();
[GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._:/@-]*$")]
public static partial Regex ModelIdentifier();
[GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")]
public static partial Regex OllamaModelIdentifier();
}
NEWSemaphoreExtensions.cs503 B / 16 lines+16-0+503 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;
public static class SemaphoreExtensions
{
public static IDisposable EnterOrThrow(this SemaphoreSlim gate, string message)
{
if (!gate.Wait(0)) throw new InvalidOperationException(message);
return new SemaphoreLease(gate);
}
public static async Task<IDisposable> EnterAsync(this SemaphoreSlim gate, CancellationToken ct = default)
{
await gate.WaitAsync(ct);
return new SemaphoreLease(gate);
}
}
NEWSemaphoreLease.cs237 B / 7 lines+7-0+237 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;
internal sealed class SemaphoreLease(SemaphoreSlim gate) : IDisposable
{
private SemaphoreSlim? _gate = gate;
public void Dispose() => Interlocked.Exchange(ref _gate, null)?.Release();
}
NEWTextReaderExtensions.cs401 B / 11 lines+11-0+401 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;
public static class TextReaderExtensions
{
public static async Task<bool> ConfirmAsync(this TextReader input, TextWriter output, string prompt,
CancellationToken ct = default)
{
await output.WriteAsync(prompt);
return string.Equals((await input.ReadLineAsync(ct))?.Trim(), "y", StringComparison.OrdinalIgnoreCase);
}
}
Lilith/Core/Kokoro 9 files
NEWAsyncResourceOwner.cs3,122 B / 81 lines+81-0+3,122 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Kokoro;
/// <summary>Bounds callers' waits while retaining ownership until native work actually ends.</summary>
internal sealed class AsyncResourceOwner(Action cleanup, TimeSpan timeout, TimeSpan shutdownTimeout)
{
private readonly object _sync = new();
private readonly CancellationTokenSource _lifetime = new();
private readonly HashSet<Task> _work = [];
private bool _closing;
private bool _faulted;
private Task? _dispose;
public bool IsClosing { get { lock (_sync) return _closing; } }
public void WhileOpen(Action action) { lock (_sync) { if (!_closing) action(); } }
public Task RunAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
Task work;
lock (_sync)
{
ObjectDisposedException.ThrowIf(_closing, this);
if (_faulted) throw new InvalidOperationException("Speech engine timed out; restart the agent before retrying speech.");
ct.ThrowIfCancellationRequested();
work = Task.Run(async () =>
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
await action(linked.Token);
});
_work.Add(work);
}
_ = work.ContinueWith(completed =>
{
_ = completed.Exception; // Observe errors even if the caller stopped waiting.
lock (_sync) _work.Remove(completed);
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
return WaitAsync(work, ct);
}
private async Task WaitAsync(Task work, CancellationToken ct)
{
try { await work.WaitAsync(timeout, ct); }
catch (TimeoutException)
{
lock (_sync)
{
_faulted = true;
if (!_closing) _lifetime.Cancel();
}
throw new TimeoutException("Kokoro operation timed out. Native work still owns its resources until it finishes.");
}
}
public Task DisposeAsync()
{
lock (_sync)
{
if (_dispose is not null) return _dispose;
_closing = true;
_lifetime.Cancel();
Task[] pending = [.. _work];
_dispose = DisposeCoreAsync(pending);
return _dispose;
}
}
private async Task DisposeCoreAsync(Task[] pending)
{
var cleanupTask = Task.Run(async () =>
{
try { await Task.WhenAll(pending); }
catch { /* Completion, including failure, releases ownership. */ }
finally { try { cleanup(); } finally { _lifetime.Dispose(); } }
});
// If native work is stuck, cleanup remains attached to it and never frees live resources.
try { await cleanupTask.WaitAsync(shutdownTimeout); }
catch (TimeoutException)
{
_ = cleanupTask.ContinueWith(task => _ = task.Exception, TaskScheduler.Default);
}
}
}
NEWKokoroModelFiles.cs1,241 B / 29 lines+29-0+1,241 B+
C#UnchangedRemoved / replacedAdded
using Microsoft.ML.OnnxRuntime;
namespace Lilith.Core.Kokoro;
internal static class KokoroModelFiles
{
public static async Task<bool> IsUsableAsync(string path)
{
if (!File.Exists(path) || new FileInfo(path).Length == 0) return false;
try
{
return await Task.Run(() =>
{
using var session = new InferenceSession(path);
return session.InputMetadata.ContainsKey("tokens") && session.InputMetadata.ContainsKey("style") &&
session.InputMetadata.ContainsKey("speed") && session.OutputMetadata.Count > 0;
});
}
// ONNX invalid protobuf (7) or invalid graph (10) identifies model data damage.
// Other runtime failures propagate: they must not quarantine a usable model.
catch (OnnxRuntimeException ex) when (ex.Message.StartsWith("[ErrorCode:InvalidProtobuf]", StringComparison.Ordinal) ||
ex.Message.StartsWith("[ErrorCode:InvalidGraph]", StringComparison.Ordinal)) { return false; }
}
public static void Quarantine(string path)
{
if (File.Exists(path)) File.Move(path, path + ".invalid-" + Guid.NewGuid().ToString("N"));
}
}
NEWKokoroNarration.cs307 B / 10 lines+10-0+307 B+
C#UnchangedRemoved / replacedAdded
using KokoroSharp.Processing;
using System.Text.RegularExpressions;
using KokoroSharp;
using KokoroSharp.Core;
using Lilith.Core.Logging;
using NAudio.Wave;
namespace Lilith.Core.Kokoro;
public sealed record KokoroNarration(byte[] Audio, KokoroWord[] Words, string TimingSource = "phoneme");
NEWKokoroRegex.cs1,641 B / 39 lines+39-0+1,641 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
namespace Lilith.Core.Kokoro;
internal static partial class KokoroRegex
{
[GeneratedRegex(@"(?:`{3,}|~{3,})[\s\S]*?(?:(?:`{3,}|~{3,})|\z)", RegexOptions.CultureInvariant)]
public static partial Regex CodeFence();
[GeneratedRegex(@"!?\[(?<label>[^\]]*)\]\((?:[^()\r\n]|\([^()\r\n]*\))*\)", RegexOptions.CultureInvariant)]
public static partial Regex MarkdownLink();
[GeneratedRegex(@"https?://[^\s<>]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
public static partial Regex WebUrl();
[GeneratedRegex(@"(?<![\w\\])(?<mark>\*{1,3}|_{1,3})(?<value>\S(?:[^\r\n]*?\S)?)\k<mark>(?!\w)", RegexOptions.CultureInvariant)]
public static partial Regex Emphasis();
[GeneratedRegex(@"~~(?<value>[^\r\n]*?)~~", RegexOptions.CultureInvariant)]
public static partial Regex StrikeThrough();
[GeneratedRegex(@"^(?:[-*+]|\d+[.)])[ \t]+", RegexOptions.CultureInvariant)]
public static partial Regex ListMarker();
[GeneratedRegex(@"^#{1,6}[ \t]+", RegexOptions.CultureInvariant)]
public static partial Regex HeadingMarker();
[GeneratedRegex(@"^(?:>[ \t]*)+", RegexOptions.CultureInvariant)]
public static partial Regex QuoteMarker();
[GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
public static partial Regex Whitespace();
[GeneratedRegex(@"([!?])\1+", RegexOptions.CultureInvariant)]
public static partial Regex RepeatedPunctuation();
[GeneratedRegex(@"[ \t]+([,.;:!?])", RegexOptions.CultureInvariant)]
public static partial Regex SpaceBeforePunctuation();
}
NEWKokoroSetup.cs6,136 B / 117 lines+117-0+6,136 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Logging;
namespace Lilith.Core.Kokoro;
public static class KokoroSetup
{
public const string ModelUrl = "https://github.com/Lyrcaxis/KokoroSharpBinaries/releases/download/v2.0.0/kokoro.onnx";
public static async Task DownloadAsync(KokoroTtsOptions options, Logger logger,
CancellationToken ct = default, HttpClient? client = null)
=> await DownloadCoreAsync(options, logger, TimeSpan.FromMinutes(30), ct, client);
internal static async Task DownloadCoreAsync(KokoroTtsOptions options, Logger logger, TimeSpan timeout,
CancellationToken ct = default, HttpClient? client = null)
{
ct.ThrowIfCancellationRequested();
string path = options.ResolvedModelPath;
if (await KokoroModelFiles.IsUsableAsync(path)) return;
KokoroModelFiles.Quarantine(path);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temporary = path + "." + Guid.NewGuid().ToString("N") + ".partial";
using var ownedClient = client is null ? new HttpClient { Timeout = TimeSpan.FromMinutes(30) } : null;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout);
ct = deadline.Token;
try
{
using var response = await (client ?? ownedClient!).GetAsync(ModelUrl, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(ct);
long total = response.Content.Headers.ContentLength ?? 0;
long received = 0;
await using (var destination = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
{
var buffer = new byte[81920];
int count;
while ((count = await source.ReadAsync(buffer, ct)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, count), ct);
received += count;
if (total > 0) logger.WriteProgress("TTS", received, total);
else logger.WriteProgress("TTS", $"Downloaded {received / 1048576.0:F1} MB");
}
}
if (received == 0 || (total > 0 && received != total)) throw new InvalidDataException("Kokoro download is incomplete.");
ct.ThrowIfCancellationRequested();
File.Move(temporary, path, overwrite: true);
logger.WriteLine("TTS", "Kokoro model download complete.");
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
/// <summary>Stores preferences before downloading, so an interrupted first boot can resume.</summary>
public static async Task<KokoroTtsOptions> RunAsync(TextReader input, TextWriter output, Logger logger,
string settingsPath, CancellationToken ct = default)
=> await RunCoreAsync(input, output, logger, settingsPath, KokoroModelFiles.IsUsableAsync, ct);
internal static async Task<KokoroTtsOptions> RunCoreAsync(TextReader input, TextWriter output, Logger logger,
string settingsPath, Func<string, Task<bool>> validateModel, CancellationToken ct = default)
{
// Automated or redirected sessions keep their existing input protocol and never download implicitly.
string? enabled = Environment.GetEnvironmentVariable("LILITH_TTS");
if (enabled is "0" or "false") return new();
KokoroTtsOptions? options = null;
if (File.Exists(settingsPath))
{
try { options = JsonSettings.Load<KokoroTtsOptions>(settingsPath, ValidOptions); }
catch (JsonException) { logger.WriteWarning("TTS", "TTS settings are invalid; configure speech again."); }
}
if (options is null)
{
if (!await input.ConfirmAsync(output, "Enable Kokoro voice replies? [y/N]: ", ct)) options = new();
else
{
await output.WriteAsync("Voice (af_heart, af_bella, am_adam, bf_emma; or custom ID) [af_heart]: ");
string voice = (await input.ReadLineAsync(ct))?.Trim() ?? "";
voice = voice.Length == 0 ? "af_heart" : voice;
KokoroTts.ValidateVoice(voice);
bool wait = await input.ConfirmAsync(output, "Wait for speech before accepting the next message? [y/N]: ", ct);
options = new(true, voice, wait);
}
ct.ThrowIfCancellationRequested();
JsonSettings.Save(settingsPath, options);
}
if (!options.Enabled) return options;
KokoroTts.ValidateVoice(options.VoiceId);
if (!await validateModel(options.ResolvedModelPath))
{
if (File.Exists(options.ResolvedModelPath))
{
KokoroModelFiles.Quarantine(options.ResolvedModelPath);
logger.WriteWarning("TTS", "The Kokoro model is damaged or invalid; download it again to repair speech.");
}
if (!await input.ConfirmAsync(output, "Download Kokoro model (about 320 MB)? [y/N]: ", ct))
return options with { Enabled = false };
await logger.TimeStageAsync("Kokoro model download", () => DownloadAsync(options, logger, ct));
if (!await validateModel(options.ResolvedModelPath))
{
KokoroModelFiles.Quarantine(options.ResolvedModelPath);
throw new InvalidDataException("The downloaded Kokoro model is invalid. Relaunch setup to retry.");
}
}
return options;
}
private static bool ValidOptions(KokoroTtsOptions options)
{
try
{
_ = options.ResolvedModelPath;
KokoroTts.ValidateVoice(options.VoiceId);
return true;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { return false; }
}
}
NEWKokoroTts.cs10,975 B / 229 lines+229-0+10,975 B+
C#UnchangedRemoved / replacedAdded
using KokoroSharp.Processing;
using System.Text.RegularExpressions;
using KokoroSharp;
using KokoroSharp.Core;
using Lilith.Core.Logging;
using NAudio.Wave;
namespace Lilith.Core.Kokoro;
/// <summary>Local synthesis and Windows playback with tracked native ownership.</summary>
public sealed class KokoroTts : IAsyncDisposable
{
private readonly Logger _logger;
private readonly KokoroTtsOptions _options;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly AsyncResourceOwner _owner;
private KokoroWavSynthesizer? _model;
private KokoroVoice? _voice;
private static readonly object VoiceGate = new();
public KokoroTts(Logger logger, KokoroTtsOptions? options = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? new();
_owner = new AsyncResourceOwner(() => { _model?.Dispose(); _model = null; _voice = null; _gate.Dispose(); },
TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(5));
}
public string VoiceId => _voice?.Name ?? _options.VoiceId;
public bool WaitForTts => _options.WaitForTts;
public bool IsReady => !_owner.IsClosing && _model is not null && _voice is not null;
public static void ValidateVoice(string id) => _ = GetVoice(id);
private static KokoroVoice GetVoice(string id)
{
if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Voice ID is required.", nameof(id));
lock (VoiceGate)
{
try { return KokoroVoiceManager.GetVoice(id); }
catch (InvalidOperationException ex) { throw new ArgumentException($"Unknown Kokoro voice '{id}'.", nameof(id), ex); }
}
}
public static async Task<KokoroTts> CreateAsync(Logger logger, KokoroTtsOptions? options = null, CancellationToken ct = default)
{
var service = new KokoroTts(logger, options);
try { await service.InitializeAsync(ct); return service; }
catch { await service.DisposeAsync(); throw; }
}
public Task InitializeAsync(CancellationToken ct = default) => _owner.RunAsync(async token =>
{
using var operation = await _gate.EnterAsync(token);
if (_model is not null) return;
var voice = GetVoice(_options.VoiceId);
string path = _options.ResolvedModelPath;
if (!File.Exists(path)) throw new FileNotFoundException("Run Kokoro setup to download the speech model.", path);
var model = await Task.Run(() => KokoroWavSynthesizer.LoadModel(path), CancellationToken.None);
if (token.IsCancellationRequested) { model.Dispose(); token.ThrowIfCancellationRequested(); }
_voice = voice;
_model = model;
Log("Kokoro ready (voice: " + VoiceId + ").", LogColors.Cyan);
}, ct);
public Task SetVoiceAsync(string id, CancellationToken ct = default) => _owner.RunAsync(async token =>
{
var voice = GetVoice(id);
using var operation = await _gate.EnterAsync(token);
_voice = voice;
}, ct);
public async Task SpeakAsync(string text, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
string spoken = KokoroTtsSanitizer.Sanitize(text);
if (spoken.Length == 0) return;
if (!IsReady) await InitializeAsync(ct);
Task monitored = ObserveSpeechAsync(_owner.RunAsync(token => SpeakCoreAsync(spoken, token), ct));
if (WaitForTts) await monitored;
}
/// <summary>Returns the actual synthesized 24 kHz, 16-bit mono PCM for recording/export.</summary>
public async Task<byte[]> SynthesizeAsync(string text, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
string spoken = KokoroTtsSanitizer.Sanitize(text);
if (spoken.Length == 0) return [];
if (!IsReady) await InitializeAsync(ct);
byte[] audio = [];
await _owner.RunAsync(async token =>
{
using var operation = await _gate.EnterAsync(token);
audio = await _model!.SynthesizeAsync(spoken, _voice!);
token.ThrowIfCancellationRequested();
if (audio.Length == 0) throw new InvalidDataException("Kokoro generated no audio.");
}, ct);
return audio;
}
/// <summary>Synthesizes once and aligns written words to the generated phoneme timestamps.</summary>
public async Task<KokoroNarration> SynthesizeTimedAsync(string text, CancellationToken ct = default)
{
string spoken = KokoroTtsSanitizer.Sanitize(text);
if (spoken.Length == 0) throw new InvalidDataException("Reply contains no speakable text.");
if (!IsReady) await InitializeAsync(ct);
KokoroNarration? narration = null;
await _owner.RunAsync(async token =>
{
using var operation = await _gate.EnterAsync(token);
var result = await _model!.SynthesizeWithTimestampsAsync(spoken, _voice!);
token.ThrowIfCancellationRequested();
if (result.AudioBytes.Length == 0) throw new InvalidDataException("Kokoro returned no speech audio.");
if (result.Timestamps.Length == 0)
{
narration = new(result.AudioBytes, EstimateWordTimes(spoken, result.AudioBytes), "estimated");
Log("This Kokoro model has no duration output; caption word timings are estimated.", LogColors.Orange);
return;
}
List<KokoroWord> words = [];
int previous = 0;
foreach (Match word in RegexPatterns.Words().Matches(spoken))
{
while (previous < result.Timestamps.Length - 1 && result.Timestamps[previous].Phoneme is ' ' or '$') previous++;
int first = Math.Clamp(previous, 0, result.Timestamps.Length - 1);
string phonemes = word.Value.Any(char.IsLetterOrDigit)
? Tokenizer.Phonemize(word.Value, _voice!.GetLangCode()) : word.Value;
int available = Math.Min(result.Timestamps.Length - first, phonemes.Length + 6);
int[] costs = [.. Enumerable.Range(0, available + 1)];
for (int row = 1; row <= phonemes.Length; row++)
{
int diagonal = costs[0]; costs[0] = row;
for (int column = 1; column <= available; column++)
{
int old = costs[column];
costs[column] = Math.Min(Math.Min(costs[column] + 1, costs[column - 1] + 1),
diagonal + (phonemes[row - 1] == result.Timestamps[first + column - 1].Phoneme ? 0 : 1));
diagonal = old;
}
}
int length = Enumerable.Range(1, available)
.OrderBy(n => costs[n] + Math.Abs(n - phonemes.Length) * .05).First();
int end = first + length, last = end - 1;
words.Add(new(word.Value, result.Timestamps[first].StartSecond, result.Timestamps[last].EndSecond));
previous = end;
}
narration = new(result.AudioBytes, words.ToArray());
}, ct);
return narration!;
}
private static KokoroWord[] EstimateWordTimes(string spoken, byte[] pcm)
{
// Map word-length weights onto voiced 10 ms windows, skipping actual silence.
List<double> windows = [];
const int samplesPerWindow = 240;
for (int offset = 0; offset < pcm.Length; offset += samplesPerWindow * 2)
{
double energy = 0; int count = 0;
for (int index = offset; index + 1 < Math.Min(pcm.Length, offset + samplesPerWindow * 2); index += 2)
{
double sample = (short)(pcm[index] | pcm[index + 1] << 8) / 32768.0;
energy += sample * sample; count++;
}
if (count > 0 && Math.Sqrt(energy / count) > .004) windows.Add(offset / 48000.0);
}
if (windows.Count == 0) throw new InvalidDataException("Kokoro speech audio is silent.");
var matches = RegexPatterns.Words().Matches(spoken).Cast<Match>().ToArray();
var weights = matches.Select(m => Math.Max(1, m.Value.Count(char.IsLetterOrDigit))).ToArray();
double total = weights.Sum(), used = 0;
List<KokoroWord> words = [];
for (int i = 0; i < matches.Length; i++)
{
int first = Math.Min(windows.Count - 1, (int)(used / total * windows.Count));
used += weights[i];
int last = Math.Min(windows.Count - 1, Math.Max(first, (int)(used / total * windows.Count) - 1));
words.Add(new(matches[i].Value, windows[first], windows[last] + .01));
}
return words.ToArray();
}
private async Task ObserveSpeechAsync(Task speech)
{
try { await speech; }
catch (OperationCanceledException) { Log("Speech canceled.", LogColors.Orange); }
catch (Exception ex) { Log($"Kokoro speech failed: {ex.Message}", LogColors.Red); }
}
private async Task SpeakCoreAsync(string text, CancellationToken token)
{
using var operation = await _gate.EnterAsync(token);
Progress("Generating speech...");
// Cancellation/deadlines bound the caller, but this work keeps ownership until native completion.
byte[] audio = await _model!.SynthesizeAsync(text, _voice!);
token.ThrowIfCancellationRequested();
if (audio.Length == 0) throw new InvalidDataException("Kokoro generated no audio.");
using var stream = new RawSourceWaveStream(audio, 0, audio.Length, new WaveFormat(24000, 16, 1));
using var player = new WaveOutEvent();
var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void Stopped(object? sender, StoppedEventArgs e)
{
if (e.Exception is not null) complete.TrySetException(e.Exception);
else complete.TrySetResult();
}
player.PlaybackStopped += Stopped;
try
{
player.Init(stream);
player.Play();
Progress("Speaking...");
await complete.Task.WaitAsync(TimeSpan.FromSeconds(audio.Length / 48000.0 + 10), token);
Log("Speech complete.");
}
finally { player.Stop(); player.PlaybackStopped -= Stopped; }
}
private void Log(string text, string color = LogColors.White)
{
_owner.WhileOpen(() => _logger.WriteLine("TTS", text, color));
}
private void Progress(string text)
{
_owner.WhileOpen(() => _logger.WriteProgress("TTS", text));
}
public async ValueTask DisposeAsync() => await _owner.DisposeAsync();
}
NEWKokoroTtsOptions.cs465 B / 14 lines+14-0+465 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Kokoro;
public sealed record KokoroTtsOptions(
bool Enabled = false,
string VoiceId = "af_heart",
bool WaitForTts = false,
string? ModelPath = null)
{
[JsonIgnore]
public string ResolvedModelPath => Path.GetFullPath(ModelPath ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "Kokoro", "kokoro.onnx"));
}
NEWKokoroTtsSanitizer.cs3,774 B / 68 lines+68-0+3,774 B+
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text;
namespace Lilith.Core.Kokoro;
/// <summary>Conservative speech cleanup; leaves pronunciation of numbers/abbreviations to the phonemizer.</summary>
public static class KokoroTtsSanitizer
{
public static string Sanitize(string text)
{
ArgumentNullException.ThrowIfNull(text);
// Remove fenced code before interpreting its contents as prose, including an unclosed fence.
text = KokoroRegex.CodeFence().Replace(text, " ");
text = KokoroRegex.MarkdownLink().Replace(text, "${label}");
text = KokoroRegex.WebUrl().Replace(text, match =>
{
// Discard the address but retain punctuation ending the surrounding sentence.
string url = match.Value.TrimEnd(')', ']', '}', '"', '\'');
int end = url.Length;
while (end > 0 && ".!?;:".Contains(url[end - 1])) end--;
return " " + url[end..];
});
for (int pass = 0; pass < 3; pass++) text = KokoroRegex.Emphasis().Replace(text, "${value}");
text = KokoroRegex.StrikeThrough().Replace(text, "${value}").Replace("`", "");
// Misaki can split surrogate pairs before normalization; spoken input excludes pictographs.
// Grapheme rendering and the original chat/history text remain untouched.
var spoken = new StringBuilder(text.Length);
foreach (var rune in text.EnumerateRunes())
{
if (rune.Value is 0x200B or 0x2060 or 0xFEFF or 0x00AD) continue;
bool unsupported = rune.Utf16SequenceLength == 2 || Rune.GetUnicodeCategory(rune) == UnicodeCategory.OtherSymbol ||
rune.Value is 0x200D or 0xFE0E or 0xFE0F or 0xFFFD ||
(Rune.GetUnicodeCategory(rune) == UnicodeCategory.Control && rune.Value is not (9 or 10 or 13));
spoken.Append(unsupported ? " " : rune.ToString());
}
text = spoken.ToString().Normalize(NormalizationForm.FormC).Replace("\r\n", "\n").Replace('\r', '\n');
var result = new StringBuilder(text.Length);
bool paragraphBreak = false, previousList = false;
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.Length == 0) { paragraphBreak = result.Length > 0; continue; }
bool listItem = KokoroRegex.ListMarker().IsMatch(line);
bool heading = KokoroRegex.HeadingMarker().IsMatch(line);
line = KokoroRegex.ListMarker().Replace(line, "");
line = KokoroRegex.HeadingMarker().Replace(line, "");
line = KokoroRegex.QuoteMarker().Replace(line, "");
line = KokoroRegex.Whitespace().Replace(line, " ").Trim();
if (line.Length == 0) continue;
if (result.Length > 0)
{
// Wrapped prose is joined without new pauses; structural boundaries retain a sentence break.
int lastIndex = result.Length - 1;
while (lastIndex > 0 && " )]\"'".Contains(result[lastIndex])) lastIndex--;
char last = result[lastIndex];
if ((paragraphBreak || listItem || heading || (previousList && !char.IsWhiteSpace(raw[0]))) && !".!?;:…".Contains(last)) result.Append('.');
result.Append(' ');
}
result.Append(line);
paragraphBreak = false;
previousList = listItem || (previousList && char.IsWhiteSpace(raw[0]));
}
text = KokoroRegex.Whitespace().Replace(result.ToString(), " ").Trim();
text = KokoroRegex.RepeatedPunctuation().Replace(text, "$1");
return KokoroRegex.SpaceBeforePunctuation().Replace(text, "$1");
}
}
NEWKokoroWord.cs274 B / 10 lines+10-0+274 B+
C#UnchangedRemoved / replacedAdded
using KokoroSharp.Processing;
using System.Text.RegularExpressions;
using KokoroSharp;
using KokoroSharp.Core;
using Lilith.Core.Logging;
using NAudio.Wave;
namespace Lilith.Core.Kokoro;
public sealed record KokoroWord(string Text, double Start, double End);
Lilith/Core/LMStudio 3 files
NEWLMStudioClient.cs3,791 B / 68 lines+68-0+3,791 B+
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Headers;
using System.Text.Json;
namespace Lilith.Core.LMStudio;
public sealed class LMStudioClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null)
: ProviderClient(logger, historyStore, httpClient)
{
public string BaseUrl { get; init; } = LMStudioSetup.BaseUrl;
protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
{
if (messages.Any(m => m.Images?.Count > 0))
throw new NotSupportedException("LM Studio image input is not implemented in this interface.");
using var request = HttpExtensions.JsonRequest(BaseUrl + "/v1/chat/completions", new
{
model = Model,
stream = false,
messages = messages.Select(m => new { role = m.Role, content = m.Content })
});
LMStudioSetup.Authorize(request);
using var json = await Http.SendJsonDocumentAsync(request);
var choice = json.RootElement.GetProperty("choices")[0];
// A length limit/tool-call response is not a complete text turn.
if (choice.GetProperty("finish_reason").GetString() != "stop")
throw new InvalidDataException("LM Studio did not return a complete text reply.");
var message = choice.GetProperty("message");
if (message.GetProperty("role").GetString() != "assistant" || message.GetProperty("content").ValueKind != JsonValueKind.String)
throw new InvalidDataException("LM Studio returned an invalid assistant message.");
int? prompt = null, completion = null;
if (json.RootElement.TryGetProperty("usage", out var usage))
{
if (usage.TryGetProperty("prompt_tokens", out var p)) prompt = p.GetInt32();
if (usage.TryGetProperty("completion_tokens", out var c)) completion = c.GetInt32();
}
return new ChatChunk(new ChatMessage("assistant", message.GetProperty("content").GetString()!), true, prompt, completion);
}
public static async Task<LMStudioClient> CreateClient(string model, string systemPrompt, Logger logger,
bool startServerIfNotRunning, string agentName = "Lilith", ChatHistoryStore? historyStore = null)
{
await LMStudioSetup.EnsureServerAsync(logger, startServerIfNotRunning);
var client = new LMStudioClient(logger, historyStore) { Model = model, SystemPrompt = systemPrompt, AgentName = agentName };
try
{
var models = await LMStudioSetup.ListModelsAsync();
var selected = models.FirstOrDefault(m => m.Id == model)
?? throw new InvalidOperationException($"LM Studio model '{model}' is not downloaded. Run setup first.");
if (selected.InstanceId is not null && selected.ContextLength is > 0)
{
client.Model = selected.InstanceId;
client.NumCtx = selected.ContextLength.Value;
}
else
{
using var request = HttpExtensions.JsonRequest(client.BaseUrl + "/api/v1/models/load", new { model, echo_load_config = true });
LMStudioSetup.Authorize(request);
using var json = await client.Http.SendJsonDocumentAsync(request);
client.Model = json.RootElement.GetProperty("instance_id").GetString()!;
client.NumCtx = json.RootElement.GetProperty("load_config").GetProperty("context_length").GetInt32();
}
if (client.NumCtx <= 0) throw new InvalidDataException("LM Studio reported an invalid context window.");
client.InitializeConversation();
return client;
}
catch { client.Dispose(); throw; }
}
}
NEWLMStudioModel.cs258 B / 7 lines+7-0+258 B+
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Lilith.Core.LMStudio;
public record LMStudioModel(string Id, string Name, string? InstanceId = null, int? ContextLength = null, int LoadedInstanceCount = 0);
NEWLMStudioSetup.cs10,505 B / 165 lines+165-0+10,505 B+
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Lilith.Core.LMStudio;
public static class LMStudioSetup
{
public static string BaseUrl => (Environment.GetEnvironmentVariable("LILITH_LMSTUDIO_URL") ?? "http://127.0.0.1:1234").TrimEnd('/');
public static void Authorize(HttpRequestMessage request)
{
string? token = Environment.GetEnvironmentVariable("LMSTUDIO_API_TOKEN");
if (!string.IsNullOrWhiteSpace(token)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
public static string? GuiPath => new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "LM Studio", "LM Studio.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LM Studio", "LM Studio.exe")
}.FirstOrDefault(File.Exists);
private static string? CliPath
{
get
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string pointer = Path.Combine(home, ".lmstudio-home-pointer");
string studioHome = File.Exists(pointer) ? File.ReadAllText(pointer).Trim() : Path.Combine(home, ".lmstudio");
return new[] { Path.Combine(studioHome, "bin", "lms.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "lm-studio", "bin", "lms.exe") }
.FirstOrDefault(File.Exists);
}
}
public static bool IsInstalled() => GuiPath is not null || CliPath is not null;
public static async Task InstallGuiAsync(Logger logger)
{
logger.WriteLine("LM Studio", "Downloading the official Windows GUI installer…", LogColors.Cyan);
string installer = Path.Combine(Path.GetTempPath(), "LM-Studio-" + Guid.NewGuid().ToString("N") + ".exe");
string verifier = installer + ".verify.ps1";
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(15) };
using var downloadDeadline = new CancellationTokenSource(TimeSpan.FromMinutes(15));
string arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64";
using var response = await http.GetAsync($"https://lmstudio.ai/download/latest/win32/{arch}", HttpCompletionOption.ResponseHeadersRead, downloadDeadline.Token);
response.EnsureSuccessStatusCode();
await using (var file = File.Create(installer))
await using (var source = await response.Content.ReadAsStreamAsync(downloadDeadline.Token))
{
long total = response.Content.Headers.ContentLength ?? 0, received = 0;
var buffer = new byte[81920];
int count;
while ((count = await source.ReadAsync(buffer, downloadDeadline.Token)) > 0)
{
await file.WriteAsync(buffer.AsMemory(0, count), downloadDeadline.Token);
received += count;
if (total > 0) logger.WriteProgress("LM Studio", received, total);
}
if (received == 0 || (total > 0 && received != total)) throw new InvalidDataException("LM Studio installer download is incomplete.");
}
await File.WriteAllTextAsync(verifier, "param([string]$Installer)\n$ErrorActionPreference = 'Stop'\nif ((Get-AuthenticodeSignature -LiteralPath $Installer).Status -ne 'Valid') { throw 'LM Studio installer signature is invalid.' }\n");
await RunProcessAsync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", verifier, "-Installer", installer], logger, TimeSpan.FromSeconds(30));
await RunProcessAsync(installer, ["/S"], logger, TimeSpan.FromMinutes(15));
if (GuiPath is null) throw new InvalidOperationException("LM Studio GUI was not found after installation. Install it from lmstudio.ai/download and retry.");
}
finally
{
if (File.Exists(installer)) File.Delete(installer);
if (File.Exists(verifier)) File.Delete(verifier);
}
}
public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
{
if (await ProbeAsync()) return;
if (!startIfMissing) throw new InvalidOperationException("LM Studio server is not ready. Enable Local Server in its Developer tab.");
if (GuiPath is { } gui)
{
var start = new ProcessStartInfo(gui) { UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden };
start.Environment.Remove("ELECTRON_RUN_AS_NODE");
Process.Start(start)?.Dispose();
}
// The GUI initializes its bundled CLI on first launch.
for (int i = 0; i < 20 && CliPath is null; i++) await Task.Delay(500);
if (CliPath is not { } cli) throw new InvalidOperationException("Open LM Studio once and enable Developer → Local Server, then retry. The lms CLI is not available yet.");
if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out var uri) || !uri.IsLoopback)
throw new InvalidOperationException("Automatic LM Studio startup only supports a local endpoint.");
try
{
await RunProcessAsync(cli, ["server", "start", "--port", uri.Port.ToString(), "--bind", "127.0.0.1"], logger, TimeSpan.FromSeconds(60));
}
catch (Exception ex) when (ex is InvalidOperationException or TimeoutException)
{
throw new InvalidOperationException("LM Studio could not start its local service. Open the GUI, complete first-launch setup, and enable Developer → Local Server, then retry.", ex);
}
for (int i = 0; i < 30; i++) { if (await ProbeAsync()) return; await Task.Delay(500); }
throw new TimeoutException("LM Studio API did not become ready. Check its Developer tab and API token settings.");
}
private static async Task<bool> ProbeAsync()
{
try { await ListModelsAsync(TimeSpan.FromSeconds(2)); return true; }
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { return false; }
}
public static async Task<IReadOnlyList<LMStudioModel>> ListModelsAsync(TimeSpan? timeout = null)
{
using var http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(15) };
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + "/api/v1/models");
Authorize(request);
using var json = await http.SendJsonDocumentAsync(request);
return json.RootElement.GetProperty("models").EnumerateArray()
.Where(m => m.GetProperty("type").GetString() == "llm")
.Select(m =>
{
var instances = m.GetProperty("loaded_instances");
JsonElement? loaded = instances.GetArrayLength() > 0 ? instances[0] : null;
return new LMStudioModel(m.GetProperty("key").GetString()!, m.GetProperty("display_name").GetString()!,
loaded?.GetProperty("id").GetString(), loaded?.GetProperty("config").GetProperty("context_length").GetInt32(), instances.GetArrayLength());
}).ToList();
}
public static async Task DownloadModelAsync(string model, Logger logger)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(40));
async Task<JsonDocument> GetStatusAsync(string? job = null)
{
using var request = job is null ? HttpExtensions.JsonRequest(BaseUrl + "/api/v1/models/download", new { model })
: new HttpRequestMessage(HttpMethod.Get, BaseUrl + "/api/v1/models/download/status/" + Uri.EscapeDataString(job));
Authorize(request);
return await http.SendJsonDocumentAsync(request, deadline.Token);
}
using var first = await GetStatusAsync();
string? job = first.RootElement.TryGetProperty("job_id", out var id) ? id.GetString() : null;
string? status = first.RootElement.GetProperty("status").GetString();
if (status is "completed" or "already_downloaded") return;
if (status != "downloading" || job is null) throw new InvalidOperationException($"LM Studio download returned {status}.");
while (true)
{
await Task.Delay(750, deadline.Token);
using var current = await GetStatusAsync(job);
status = current.RootElement.GetProperty("status").GetString();
if (status == "completed") { logger.WriteLine("LM Studio", "Model download completed.", LogColors.Green); return; }
if (status is "failed" or "paused") throw new InvalidOperationException($"LM Studio download is {status}. Resume it in LM Studio and retry setup.");
if (current.RootElement.TryGetProperty("downloaded_bytes", out var bytes) && current.RootElement.TryGetProperty("total_size_bytes", out var total))
logger.WriteProgress("LM Studio", bytes.GetInt64(), total.GetInt64());
}
}
private static async Task RunProcessAsync(string executable, string[] arguments, Logger logger, TimeSpan timeout)
{
var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
start.Environment.Remove("ELECTRON_RUN_AS_NODE");
foreach (string argument in arguments) start.ArgumentList.Add(argument);
using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start LM Studio setup.");
async Task DrainAsync(StreamReader reader) { while (await reader.ReadLineAsync() is { } line) logger.WriteLine("LM Studio", line, LogColors.Gray); }
Task output = DrainAsync(process.StandardOutput), error = DrainAsync(process.StandardError);
try { await process.WaitForExitOrKillAsync(timeout, "LM Studio setup process timed out."); }
finally { await Task.WhenAll(output, error); }
if (process.ExitCode != 0) throw new InvalidOperationException($"LM Studio setup exited with code {process.ExitCode}.");
}
}
Lilith/Core/Logging 8 files
NEWLogColors.cs377 B / 12 lines+12-0+377 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
public static class LogColors
{
public const string White = "#FFFFFF";
public const string Green = "#00FF00";
public const string Yellow = "#FFFF00";
public const string Cyan = "#00FFFF";
public const string Orange = "#FF8800";
public const string Red = "#FF4444";
public const string Gray = "#808080";
}
NEWLogEntry.cs348 B / 14 lines+14-0+348 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
public record LogEntry(
string Sender,
string Description,
string Color,
DateTime Timestamp,
LogType Type)
{
public long? ProgressCurrent { get; init; }
public long? ProgressTotal { get; init; }
public string FormattedTimestamp => Timestamp.ToString("HH:mm:ss.fff");
}
NEWLogType.cs104 B / 8 lines+8-0+104 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
public enum LogType
{
Write,
WriteLine,
Progress,
}
NEWLogTypeExtensions.cs978 B / 42 lines+42-0+978 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
public static class LogTypeExtensions
{
/// <summary>
/// Two-action overload. Progress falls through to onWrite (inline, no newline).
/// </summary>
public static void Handle(this LogType type, Action onWrite, Action onWriteLine)
{
if (type == LogType.Write || type == LogType.Progress)
{
onWrite();
}
else
{
onWriteLine();
}
}
/// <summary>
/// Three-action overload with explicit progress handling.
/// </summary>
public static void Handle(
this LogType type,
Action onWrite,
Action onWriteLine,
Action onProgress)
{
if (type == LogType.Write)
{
onWrite();
}
else if (type == LogType.Progress)
{
onProgress();
}
else
{
onWriteLine();
}
}
}
NEWLogger.cs5,607 B / 178 lines+178-0+5,607 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
/// <summary>
/// Central logging hub. Subscribers receive every <see cref="LogEntry"/> via
/// <see cref="OnLog"/> or <see cref="Subscribe"/>.
/// </summary>
public class Logger(Action<LogEntry>? onLog = null)
{
private readonly object _sync = new();
private readonly List<Action<LogEntry>> _subscribers = onLog is null
? []
: [onLog];
private static Logger? _default;
/// <summary>Shared logger for apps that want a single global instance.</summary>
public static Logger Default => _default ??= new();
/// <summary>Configure the shared <see cref="Default"/> logger (optional initial handler).</summary>
public static Logger ConfigureDefault(Action<LogEntry>? onLog = null)
{
_default = new Logger(onLog);
return _default;
}
/// <summary>
/// Fired for every log entry. Use <c>+=</c> or <see cref="Subscribe"/>.
/// </summary>
public event Action<LogEntry>? OnLog
{
add
{
if (value == null)
{
return;
}
lock (_sync)
{
_subscribers.Add(value);
}
}
remove
{
if (value == null)
{
return;
}
lock (_sync)
{
_subscribers.Remove(value);
}
}
}
/// <summary>Number of active subscribers.</summary>
public int SubscriberCount
{
get
{
lock (_sync)
{
return _subscribers.Count;
}
}
}
/// <summary>
/// The most recent log entry that used <see cref="LogType.Write"/> (same-line).
/// Cleared when a <see cref="WriteLine"/> closes the line.
/// </summary>
public LogEntry? CurrentLog { get; private set; }
/// <summary>Subscribe to all log output. Returns a disposable that unsubscribes.</summary>
public IDisposable Subscribe(Action<LogEntry> handler)
{
ArgumentNullException.ThrowIfNull(handler);
OnLog += handler;
return new LoggerSubscription(this, handler);
}
/// <summary>Remove a subscriber added via <see cref="Subscribe"/> or <c>OnLog +=</c>.</summary>
public void Unsubscribe(Action<LogEntry> handler)
{
if (handler == null)
{
return;
}
OnLog -= handler;
}
/// <summary>Remove every subscriber.</summary>
public void ClearSubscribers()
{
lock (_sync)
{
_subscribers.Clear();
}
}
public void Write(string sender, string description, string color = LogColors.White) =>
Publish(CurrentLog = new LogEntry(sender, description, color, DateTime.Now, LogType.Write));
public void WriteLine(string sender, string description = "", string color = LogColors.White)
{
var entry = new LogEntry(sender, description, color, DateTime.Now, LogType.WriteLine);
CurrentLog = null;
Publish(entry);
}
public void WriteInfo(string sender, string description) =>
WriteLine(sender, description, LogColors.Cyan);
public void WriteWarning(string sender, string description) =>
WriteLine(sender, description, LogColors.Orange);
public void WriteError(string sender, string description) =>
WriteLine(sender, description, LogColors.Red);
public void WriteException(string sender, Exception ex, string color = LogColors.Red)
{
WriteLine(sender, ex.Message, color);
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
{
WriteLine(sender, ex.StackTrace, LogColors.Gray);
}
}
/// <summary>Overwrite-in-place line (progress / status).</summary>
public void WriteSingleLine(string sender, string description, string color = LogColors.Green) =>
Publish(new LogEntry(sender, description, color, DateTime.Now, LogType.Progress));
public void WriteProgress(string sender, string description, string color = LogColors.Green) =>
WriteSingleLine(sender, description, color);
/// <summary>
/// Progress bar (20 blocks): [██████░░░░░░░░░░░░░░] 30% (30 MB / 100 MB)
/// </summary>
public void WriteProgress(string sender, long current, long total, string color = LogColors.Green)
{
var percent = total > 0 ? (int)(current * 100L / total) : 0;
var filled = Math.Clamp(percent / 5, 0, 20);
var bar = new string('\u2588', filled) + new string('\u2591', 20 - filled);
var size = total > 0
? $" ({current / 1_048_576} MB / {total / 1_048_576} MB)"
: "";
var description = $"[{bar}] {percent,3}%{size}";
Publish(new LogEntry(sender, description, color, DateTime.Now, LogType.Progress)
{
ProgressCurrent = current,
ProgressTotal = total,
});
}
private void Publish(LogEntry entry)
{
Action<LogEntry>[] snapshot;
lock (_sync)
{
snapshot = _subscribers.ToArray();
}
foreach (var handler in snapshot)
{
try
{
handler(entry);
}
catch
{
// Subscribers must not break the logging pipeline.
}
}
}
}
NEWLoggerSubscription.cs349 B / 16 lines+16-0+349 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
internal sealed class LoggerSubscription(Logger logger, Action<LogEntry> handler) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1)
{
return;
}
logger.Unsubscribe(handler);
}
}
NEWTextLogInputLease.cs232 B / 7 lines+7-0+232 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
internal sealed class TextLogInputLease(Action complete) : IDisposable
{
private int _disposed;
public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) complete(); }
}
NEWTextLogSink.cs2,910 B / 86 lines+86-0+2,910 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
/// <summary>Renders log types consistently; files retain the last update of each progress phase.</summary>
public sealed class TextLogSink(TextWriter writer, bool overwriteProgress = false, Func<int>? width = null)
{
private readonly object _sync = new();
private string? _progress;
private int _drawnLength;
private bool _inputActive;
private readonly List<LogEntry> _deferred = [];
/// <summary>Defer background output until the current console input has finished.</summary>
public IDisposable BeginInput()
{
lock (_sync)
{
if (_inputActive) throw new InvalidOperationException("Console input is already active.");
FinishProgress();
_inputActive = true;
return new TextLogInputLease(EndInput);
}
}
public void Handle(LogEntry entry)
{
lock (_sync)
{
if (_inputActive && !(entry.Sender == "Input" && entry.Type == LogType.Write))
{
if (entry.Type == LogType.Progress && _deferred.Count > 0 &&
_deferred[^1].Type == LogType.Progress && _deferred[^1].Sender == entry.Sender)
_deferred[^1] = entry;
else _deferred.Add(entry);
return;
}
if (entry.Type == LogType.Progress)
{
_progress = entry.Description;
if (overwriteProgress)
{
int available = Math.Max(1, width?.Invoke() ?? 120);
string text = entry.Description.Replace('\r', ' ').Replace('\n', ' ');
if (text.Length > available) text = text[..available];
writer.Write("\r" + text.PadRight(Math.Min(available, Math.Max(_drawnLength, text.Length))));
_drawnLength = text.Length;
writer.Flush();
}
return;
}
FinishProgress();
if (entry.Type == LogType.Write) writer.Write(entry.Description);
else writer.WriteLine(entry.Description);
writer.Flush();
}
}
public void Flush()
{
lock (_sync)
{
FinishProgress();
writer.Flush();
}
}
private void FinishProgress()
{
if (_progress is null) return;
if (overwriteProgress) writer.WriteLine();
else writer.WriteLine(_progress);
_progress = null;
_drawnLength = 0;
}
private void EndInput()
{
lock (_sync)
{
_inputActive = false;
if (_deferred.Count > 0) writer.WriteLine();
foreach (var entry in _deferred) Handle(entry);
_deferred.Clear();
}
}
}
Lilith/Core/Ollama 4 files
NEWOllamaCliProgressTracker.cs2,662 B / 93 lines+93-0+2,662 B+
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Lilith.Core.Ollama;
/// <summary>
/// Reads <c>ollama pull</c> stdout/stderr and exposes the latest status for single-line logging.
/// </summary>
internal sealed class OllamaCliProgressTracker(string model)
{
private readonly object _sync = new();
private string _lastStatus = "starting…";
private int? _percent;
public string Model { get; } = model;
public void Update(string raw)
{
string line = StripAnsi(raw.Trim());
if (string.IsNullOrWhiteSpace(line))
{
return;
}
lock (_sync)
{
if (TryParsePullJson(line, out string? status, out int? percent))
{
_lastStatus = status ?? line;
if (percent.HasValue)
{
_percent = percent;
}
return;
}
_lastStatus = line;
}
}
public string GetDisplayMessage(TimeSpan elapsed, TimeSpan remaining)
{
lock (_sync)
{
if (_percent is { } pct)
{
return $"Pulling '{Model}': {_lastStatus} ({pct}%) — {elapsed:mm\\:ss} elapsed, {remaining:mm\\:ss} left";
}
return $"Pulling '{Model}': {_lastStatus} — {elapsed:mm\\:ss} elapsed, {remaining:mm\\:ss} left";
}
}
private static bool TryParsePullJson(string line, out string? status, out int? percent)
{
status = null;
percent = null;
if (!line.StartsWith('{'))
{
return false;
}
try
{
using JsonDocument doc = JsonDocument.Parse(line);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("status", out JsonElement statusEl))
{
status = statusEl.GetString();
}
if (root.TryGetProperty("completed", out JsonElement doneEl) &&
root.TryGetProperty("total", out JsonElement totalEl) &&
totalEl.TryGetInt64(out long total) &&
total > 0 &&
doneEl.TryGetInt64(out long done))
{
percent = (int)(done * 100 / total);
}
return status is not null || percent.HasValue;
}
catch
{
return false;
}
}
private static string StripAnsi(string text) =>
RegexPatterns.AnsiEscape().Replace(text, string.Empty);
}
NEWOllamaCliStreamReader.cs1,273 B / 49 lines+49-0+1,273 B+
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Lilith.Core.Ollama;
internal static class OllamaCliStreamReader
{
public static async Task PumpAsync(TextReader reader, OllamaCliProgressTracker tracker, CancellationToken cancellationToken = default)
{
var buffer = new char[512];
var segment = new StringBuilder();
while (!cancellationToken.IsCancellationRequested)
{
int read = await reader.ReadAsync(buffer, cancellationToken);
if (read <= 0)
{
break;
}
for (int i = 0; i < read; i++)
{
char c = buffer[i];
if (c is '\r' or '\n')
{
Flush(segment, tracker);
}
else
{
segment.Append(c);
}
}
}
Flush(segment, tracker);
}
private static void Flush(StringBuilder segment, OllamaCliProgressTracker tracker)
{
if (segment.Length == 0)
{
return;
}
tracker.Update(segment.ToString());
segment.Clear();
}
}
NEWOllamaClient.Extensions.cs357 B / 11 lines+11-0+357 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Ollama;
public static class OllamaClientExtensions
{
/// <summary>Throws if the client has not been initialized.</summary>
public static void IsNull(this ProviderClient? client)
{
if (client is null)
throw new InvalidOperationException("Model provider client was not initialized.");
}
}
NEWOllamaClient.cs13,168 B / 324 lines+324-0+13,168 B+
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
namespace Lilith.Core.Ollama;
public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : ProviderClient(logger, historyStore, httpClient)
{
private const string StatusBlue = "#AAAAFF";
private const string StatusGray = "#AAAAAA";
private bool _contextKnown;
public string BaseUrl { get; } = "http://localhost:11434";
public string ChatApiUrl => $"{BaseUrl}/api/chat";
public string ShowApiUrl => $"{BaseUrl}/api/show";
protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
{
var request = new ChatRequest(Model, messages, Stream: false, Options: _contextKnown ? new ChatOptions(NumCtx) : null);
using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request);
return await Http.SendJsonAsync<ChatChunk>(httpReq);
}
/// <summary>Connects to a prepared model; never installs Ollama or downloads a model.</summary>
public static async Task<OllamaClient> CreateClient(
string model,
string systemPrompt,
Logger logger,
bool startOllamaIfNotRunning,
string agentName = "Lilith", ChatHistoryStore? historyStore = null)
{
var client = new OllamaClient(logger, historyStore)
{
Model = model,
SystemPrompt = systemPrompt,
AgentName = agentName
};
client.InitializeConversation();
client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue);
try
{
await logger.TimeStageAsync("Server readiness", () => EnsureServerAsync(logger, startOllamaIfNotRunning));
bool available = await logger.TimeStageAsync("Model readiness", () => IsModelAvailableAsync(model));
if (!available) throw new InvalidOperationException($"Model '{model}' is not installed. Run console setup to choose and download a model.");
client.NumCtx = await logger.TimeStageAsync("Model specifications", client.GetModelContextSizeAsync);
client.PrintStatus(client._contextKnown ? $"Context window: {client.NumCtx:N0} tokens." :
$"Context size is unknown; usage uses a {client.NumCtx:N0}-token estimate and Ollama selects its own request default.",
client._contextKnown ? LogColors.Green : LogColors.Orange);
return client;
}
catch
{
client.Http.Dispose();
throw;
}
}
internal async Task<int> GetModelContextSizeAsync()
{
_contextKnown = false;
try
{
var request = new ShowModelRequest(Model);
using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request);
using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);
if (!httpResp.IsSuccessStatusCode)
{
PrintStatus("Could not retrieve model specs; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
return 4096;
}
string json = await httpResp.Content.ReadAsStringAsync();
ShowModelResponse? response = json.Deserialize<ShowModelResponse>();
if (response?.ModelInfo is { } info)
{
foreach (var kv in info)
{
if (kv.Key.EndsWith(".context_length", StringComparison.Ordinal) && kv.Value.TryGetInt32(out int ctx) && ctx > 0)
{
_contextKnown = true;
return ctx;
}
}
}
}
catch (Exception ex)
{
PrintStatus($"Model spec query failed ({ex.Message}); retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
}
PrintStatus("Valid model context metadata is unavailable; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
return 4096;
}
public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
if (await http.IsReachableAsync("http://localhost:11434/")) { logger.WriteInfo("Ollama", "Ollama server already running; skipping server start."); return; }
if (!startIfMissing) throw new InvalidOperationException("Ollama server is not running.");
var psi = new ProcessStartInfo(ResolveExecutable()) { UseShellExecute = false, CreateNoWindow = true };
psi.ArgumentList.Add("serve");
using var server = Process.Start(psi) ?? throw new InvalidOperationException("Could not start Ollama server.");
var timer = Stopwatch.StartNew();
while (timer.Elapsed < TimeSpan.FromSeconds(60))
{
if (await http.IsReachableAsync("http://localhost:11434/")) return;
// The installer/app may already own the port while still discovering GPUs.
// An exited second serve process does not prove that the API cannot become ready.
await Task.Delay(500);
}
if (!server.HasExited) server.Kill(entireProcessTree: true);
throw new TimeoutException(server.HasExited
? $"Ollama API did not become ready within 60 seconds (startup process exit {server.ExitCode})."
: "Ollama server did not become ready within 60 seconds.");
}
private static string ResolveExecutable()
{
string installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Ollama", "ollama.exe");
return File.Exists(installed) ? installed : "ollama";
}
public static bool IsOllamaInstalled()
{
try
{
var psi = new ProcessStartInfo(ResolveExecutable(), "--version")
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return false;
}
Task stdout = proc.StandardOutput.ReadToEndAsync();
Task stderr = proc.StandardError.ReadToEndAsync();
if (!proc.WaitForExit(5000))
{
try
{
proc.Kill();
}
catch
{
// ignore
}
return false;
}
Task.WaitAll(stdout, stderr);
return proc.ExitCode == 0;
}
catch
{
return false;
}
}
private static async Task<bool> PullModelAsync(OllamaClient client)
{
try
{
client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue);
var psi = new ProcessStartInfo
{
FileName = ResolveExecutable(),
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("pull");
psi.ArgumentList.Add(client.Model);
using var proc = Process.Start(psi);
if (proc is null)
{
client.PrintStatus("Could not start ollama pull process.", LogColors.Red);
return false;
}
var tracker = new OllamaCliProgressTracker(client.Model);
Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker);
Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker);
DateTime started = DateTime.UtcNow;
DateTime deadline = started.AddMinutes(30);
while (!proc.HasExited && DateTime.UtcNow < deadline)
{
TimeSpan elapsed = DateTime.UtcNow - started;
TimeSpan remaining = deadline - DateTime.UtcNow;
client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray);
await Task.Delay(250);
}
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
await proc.WaitForExitAsync();
await Task.WhenAll(stdoutTask, stderrTask);
throw new TimeoutException("Model download exceeded 30 minutes.");
}
await proc.WaitForExitAsync();
await Task.WhenAll(stdoutTask, stderrTask);
if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model))
{
client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red);
return false;
}
if (await IsModelAvailableAsync(client.Model))
{
client.PrintStatus(
$"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).",
LogColors.Green);
return true;
}
client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange);
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
}
return false;
}
catch (Exception ex)
{
client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red);
return false;
}
}
public static async Task<bool> IsModelAvailableAsync(string model)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = HttpExtensions.JsonRequest("http://localhost:11434/api/show", new ShowModelRequest(model));
using var response = await http.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return false;
response.EnsureSuccessStatusCode();
return true;
}
public static async Task InstallAsync(Logger logger)
{
using var client = new OllamaClient(logger);
if (!await InstallOllamaAsync(client)) throw new InvalidOperationException("Ollama installation failed. Relaunch setup to retry.");
}
public static async Task DownloadModelAsync(string model, Logger logger)
{
using var client = new OllamaClient(logger) { Model = model };
if (!await PullModelAsync(client)) throw new InvalidOperationException($"Model '{model}' download failed. Relaunch setup to retry.");
}
private static async Task<bool> InstallOllamaAsync(OllamaClient client)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " +
"\"irm https://ollama.com/install.ps1 | iex\"",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return false;
}
proc.OutputDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
client.PrintInstallerOutput(e.Data, StatusGray);
}
};
proc.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
client.PrintInstallerOutput(e.Data, LogColors.Orange);
}
};
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitOrKillAsync(TimeSpan.FromMinutes(10), "Ollama installation timed out.");
return proc.ExitCode == 0 && IsOllamaInstalled();
}
catch (Exception ex)
{
client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red);
return false;
}
}
private void PrintStatus(string message, string color) =>
Logger.WriteLine("Ollama", message, color);
internal void PrintInstallerOutput(string message, string color)
{
if (RegexPatterns.InstallerProgress().IsMatch(message))
PrintProgress(message.Trim(), color);
else
PrintStatus(message, color);
}
private void PrintProgress(string message, string color) =>
Logger.WriteProgress("Ollama", message, color);
}
Lilith/Core/Properties 1 files
NEWAssemblyInfo.cs148 B / 4 lines+4-0+148 B+
C#UnchangedRemoved / replacedAdded
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Lilith.RegressionChecks")]
[assembly: InternalsVisibleTo("KokoroCheck")]
Lilith/Core/Providers 2 files
NEWProviderClient.cs7,559 B / 158 lines+158-0+7,559 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;
/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
private readonly bool _ownsHttp = httpClient is null;
private readonly SemaphoreSlim _operation = new(1, 1);
private bool _disposed;
public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
public string Model { get; set; } = "gemma4";
protected int NumCtx { get; set; } = 131072;
public int ContextMax => NumCtx;
public string SystemPrompt { get; protected set; } = string.Empty;
/// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
public ChatHistory History { get; } = [];
public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
public Logger Logger { get; } = logger;
public string AgentName { get; set; } = "Lilith";
public int ContextUsagePercent { get; private set; }
public int CurrentContextTokens { get; private set; }
public int ContextWarningThreshold { get; set; } = 60;
public event Action<int>? OnContextHighUsage;
public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);
protected void InitializeConversation()
{
History.Add("system", SystemPrompt);
ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
}
private IDisposable EnterOperation()
{
var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
return operation;
}
public void NewConversation()
{
using var operation = EnterOperation();
SaveCore();
HistoryStore.StartNewSession();
ClearCore();
Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
}
public void SaveConversation() => SaveCurrentConversation();
public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
public bool LoadConversation(string id) => LoadSavedConversation(id);
public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }
private void ClearCore()
{
History.Clear();
ResetUsage();
if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
}
private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }
private void SaveCore()
{
try
{
if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
HistoryStore.SaveSession(Model, History);
}
catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
}
public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
{
using var operation = EnterOperation();
return HistoryStore.ListSessions();
}
public bool LoadSavedConversation(int displayIndex)
{
using var operation = EnterOperation();
var sessions = HistoryStore.ListSessions();
return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
}
public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }
private bool LoadCore(string id)
{
string previousId = HistoryStore.CurrentSessionId;
try
{
var session = HistoryStore.LoadSession(id);
if (session is null) return false;
var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
if (messages[0].Role != "system")
{
if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
messages.Insert(0, new ChatMessage("system", SystemPrompt));
}
History.Clear();
History.AddRange(messages);
ResetUsage();
return true;
}
catch (Exception ex)
{
HistoryStore.UseSession(previousId);
Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
return false;
}
}
protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);
public async Task<string> StreamReply(string userInput, List<string>? images = null)
{
using var operation = EnterOperation();
if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
var requestHistory = History.Select(m => m.Snapshot()).ToList();
requestHistory.Add(userMessage);
Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
ChatChunk? chunk = await GenerateReplyAsync(requestHistory);
if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
string reply = chunk.Message.Content;
History.Add(userMessage with { Tokens = chunk.PromptTokens });
History.Add("assistant", reply, chunk.CompletionTokens);
SaveCore();
Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
ResetUsage();
if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
{
CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
try { handler(ContextUsagePercent); }
catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
}
return reply;
}
public void Dispose()
{
using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
if (_disposed) return;
_disposed = true;
if (_ownsHttp) Http.Dispose();
GC.SuppressFinalize(this);
}
}
NEWProviderKind.cs83 B / 3 lines+3-0+83 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;
public enum ProviderKind { Ollama, LMStudio }
No changed files match that search.