V1 / SOURCE EXPLORER
Read the whole
conversation.
Application and interface source, grouped by folder. Internal tests, editor settings and shared build configuration are excluded. Use Changes only for the work introduced in this release.
UTF-8 sizes and line counts for the complete snapshot. Green + / red - show changes since the preceding version. Sorting stays within each folder.
Repository root
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
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(); }
}
}
}
NEWSetup/ConsoleSettings.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)));
}
NEWSetup/ConsoleSetup.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);
}
}
NEWSetup/ConsoleSetupTimer.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);
}
}
NEWSetup/ISetupServices.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);
}
NEWSetup/OllamaSetupServices.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);
}
NEWSetup/ProviderSetup.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
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);
Core
Chat
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);
Commands
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 };
}
}
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;
Extensions
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);
}
}
Kokoro
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);
LMStudio
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}.");
}
}
Logging
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();
}
}
}
Ollama
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);
}
Properties
NEWAssemblyInfo.cs148 B / 4 lines+4-0+148 B+
C#UnchangedRemoved / replacedAdded
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Lilith.RegressionChecks")]
[assembly: InternalsVisibleTo("KokoroCheck")]
Providers
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 source files match that search.