V1 / CHANGESET

Only the delta.

The exact changes from the empty initial baseline to V1. Unchanged files are excluded. Nothing from a later version is included.

the empty initial baseline→V18f6c47b125e8

Green + / red - count added and removed lines; signed bytes show the file size change. UTF-8 file sizes and total lines in this snapshot; deleted files use the previous snapshot. Sorting stays within each folder.

Repository root 3 files

NEW.gitignore353 B / 25 lines+25-0+353 B
GITIGNOREUnchangedRemoved / replacedAdded
# Build output and local SDK state
**/bin/
**/obj/
/Admin/Build/
/.dotnet/

# Machine-specific IDE settings
.vs/
.idea/
*.user
*.suo

# Generated checks, downloads, and media
/Admin/TestResults/
/Admin/Downloads/
/Admin/Videos/
*.onnx
*.onnx.data

# Local credentials and tooling state
.env
.env.*
!.env.example
/.aws/
/.codex/
NEWVersion.props223 B / 7 lines+7-0+223 B
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
  <!-- V1 baseline. Set VersionPrefix=2.0.0 and VersionSuffix=alpha.1 on v2. -->
  <PropertyGroup>
    <VersionPrefix>1.0.1</VersionPrefix>
    <VersionSuffix></VersionSuffix>
  </PropertyGroup>
</Project>
NEWdotnet-tools.json193 B / 13 lines+13-0+193 B
JSONUnchangedRemoved / replacedAdded
{
  "version": 1,
  "isRoot": true,
  "tools": {
    "csharpier": {
      "version": "1.3.0",
      "commands": [
        "csharpier"
      ],
      "rollForward": false
    }
  }
}

Interfaces/ConsoleApp/LilithConsole 2 files

NEWLilithConsole.csproj404 B / 15 lines+15-0+404 B
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>LilithConsole</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\..\Lilith\Agent\Agent.csproj" />
  </ItemGroup>

</Project>
NEWProgram.cs3,602 B / 75 lines+75-0+3,602 B
C#UnchangedRemoved / replacedAdded
using System;
using System.Text;
using System.Threading.Tasks;
using Lilith.Core.Logging;
using Lilith.Agent;
using LilithAgent = Lilith.Agent;
using LilithConsole.Setup;
using Lilith.Core.Kokoro;

namespace LilithConsole
{
    partial class Program
    {
        static async Task Main(string[] args)
        {
            Console.OutputEncoding = Encoding.UTF8;
            var consoleSink = new TextLogSink(Console.Out, overwriteProgress: !Console.IsOutputRedirected,
                width: () => Math.Max(Console.WindowWidth - 1, 1));
            Action<LogEntry> onLog = consoleSink.Handle;

            var logger = new Logger(onLog);
            try
            {
                string settingsPath = Environment.GetEnvironmentVariable("LILITH_CONSOLE_SETTINGS")
                    ?? System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "console-settings.json");
                var credentials = await ProviderSetup.RunAsync(Console.In, Console.Out, logger, settingsPath);
                if (credentials is null) { logger.SystemWriteLine("Setup stopped. Relaunch to continue."); return; }
                if (!Console.IsInputRedirected || Environment.GetEnvironmentVariable("LILITH_TTS") == "1")
                {
                    try
                    {
                        credentials = credentials with { Tts = await KokoroSetup.RunAsync(Console.In, Console.Out,
                            logger, settingsPath + ".kokoro.json") };
                    }
                    catch (Exception ex) { logger.SystemWriteLine($"Speech setup failed: {ex.Message}. Relaunch to retry; continuing with text chat.", LogColors.Orange); }
                }
                Console.Title = $"{credentials.AgentName} — {credentials.Model} Chat";
                await using var lilith = new LilithAgent.Lilith(onLog, credentials);
                await lilith.BootAsync(startServerIfNotRunning: true);

                while (true)
                {
                    string? input;
                    using (consoleSink.BeginInput())
                    {
                        lilith.Logger.Write("Input", "You: ", LogColors.Green);
                        input = Console.ReadLine()?.Trim();
                    }
                    if (input is null) break;
                    if (string.IsNullOrEmpty(input)) continue;
                    if (input.Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
                    try
                    {
                        var command = await lilith.ChatCommands.ExecuteAsync(input);
                        if (command.Output is { Length: > 0 }) Console.WriteLine(command.Output);
                        if (command.ExitRequested) break;
                        if (!command.Handled) await lilith.ChatAsync(input);
                    }
                    catch (Exception ex)
                    {
                        lilith.Logger.SystemWriteLine($"Request failed: {ex.Message}. Please try again.", LogColors.Red);
                    }
                }
                lilith.SaveConversation();
                lilith.Logger.SystemWriteLine("Goodbye.");
            }
            catch (Exception ex)
            {
                logger.SystemWriteLine($"Setup/chat failed: {ex.Message}. Relaunch to retry; completed setup steps are retained.", LogColors.Red);
                Environment.ExitCode = 1;
            }
            finally { consoleSink.Flush(); }
        }
    }
}

Interfaces/ConsoleApp/LilithConsole/Setup 6 files

NEWConsoleSettings.cs549 B / 14 lines+14-0+549 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;

namespace LilithConsole.Setup;

public record ConsoleSettings(string? AgentName = null, string? Model = null)
{
    public bool IsValid() => (AgentName is null || (!string.IsNullOrWhiteSpace(AgentName) && AgentName.Length <= 80 && !AgentName.Any(char.IsControl))) &&
        (Model is null || (Model.Length <= 200 && RegexPatterns.ModelIdentifier().IsMatch(Model)));
}
NEWConsoleSetup.cs6,590 B / 118 lines+118-0+6,590 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;

namespace LilithConsole.Setup;

/// <summary>Checks actual installation/model state on every launch, independent of saved preferences.</summary>
public sealed class ConsoleSetup(TextReader input, TextWriter output, ISetupServices services, Logger logger, string settingsPath)
{
    public static readonly IReadOnlyList<string> Models = Array.AsReadOnly<string>(["gemma4", "llama3.2", "qwen3:4b", "phi4-mini"]);

    public async Task<LilithCredentials?> RunAsync()
    {
        using var totalTimer = new ConsoleSetupTimer(logger);
        bool installed = await logger.TimeStageAsync("Installation check", () => Task.FromResult(services.IsInstalled()));
        if (!installed)
        {
            if (!await logger.TimeStageAsync("Installation choice", () => Task.FromResult(Confirm("Would you like to download and install Ollama? [y/N, exit]: ")))) return null;
            await logger.TimeStageAsync("Ollama installation", services.InstallAsync);
            if (!services.IsInstalled()) throw new InvalidOperationException("Ollama executable was not found after installation.");
        }
        else output.WriteLine("Ollama already installed; skipping installation.");
        await logger.TimeStageAsync("Server startup/check", services.EnsureServerAsync);

        ConsoleSettings settings = await logger.TimeStageAsync("Preferences load", () => Task.FromResult(Load()));
        if (settings.AgentName is null)
        {
            output.Write("Agent name (Enter for Lilith, /exit to leave): ");
            string? name = await logger.TimeStageAsync("Agent name selection", () => Task.FromResult(input.ReadLine()));
            if (name is null || name.Trim().Equals("/exit", StringComparison.OrdinalIgnoreCase)) return null;
            name = name.Trim();
            if (name.Length > 80 || name.Any(char.IsControl)) throw new ArgumentException("Agent name must be at most 80 characters without control characters.");
            settings = settings with { AgentName = name.Length == 0 ? "Lilith" : name };
            Save(settings); // Persist identity even if the application closes before a model is ready.
        }
        output.WriteLine($"Agent name: {settings.AgentName}");
        string? overrideModel = Environment.GetEnvironmentVariable("LILITH_MODEL");
        if (!string.IsNullOrWhiteSpace(overrideModel)) settings = settings with { Model = overrideModel.Trim() };

        bool available = settings.Model is not null &&
            await logger.TimeStageAsync("Saved model check", () => services.HasModelAsync(settings.Model));
        if (!available)
        {
            if (settings.Model is not null) output.WriteLine($"Model '{settings.Model}' is not installed. Choose a model to continue.");
            string? selected = await logger.TimeStageAsync("Model selection", () => Task.FromResult(SelectModel()));
            if (selected is null) return null;
            settings = settings with { Model = selected };
            Save(settings); // Remember selection even if a download is interrupted.
            available = await logger.TimeStageAsync("Selected model check", () => services.HasModelAsync(selected));
            if (!available)
            {
                if (!await logger.TimeStageAsync("Download choice", () => Task.FromResult(Confirm($"Download model '{selected}'? [y/N, exit]: ")))) return null;
                await logger.TimeStageAsync("Model download", () => services.DownloadAsync(selected));
                if (!await logger.TimeStageAsync("Downloaded model validation", () => services.HasModelAsync(selected)))
                    throw new InvalidOperationException("Model is still unavailable after download. Relaunch setup to retry.");
            }
            else output.WriteLine($"Model '{selected}' already installed; skipping download.");
        }
        else output.WriteLine($"Model '{settings.Model}' already installed; skipping selection and download.");
        Save(settings);
        totalTimer.Completed = true;
        return new LilithCredentials(settings.Model!,
            $"You are {settings.AgentName}, a helpful AI assistant. Greet the user warmly when the conversation begins.", settings.AgentName!);
    }

    private string? SelectModel()
    {
        while (true)
        {
            output.WriteLine("Choose a model:");
            for (int i = 0; i < Models.Count; i++) output.WriteLine($"{i + 1}. {Models[i]}");
            output.WriteLine("5. Custom model\n0. Exit");
            output.Write("Selection: ");
            string? selection = input.ReadLine()?.Trim();
            if (selection is null || selection.Equals("exit", StringComparison.OrdinalIgnoreCase) || selection == "0") return null;
            if (int.TryParse(selection, out int index) && index is >= 1 and <= 4) return Models[index - 1];
            if (selection == "5")
            {
                output.Write("Custom model name/tag (or exit): ");
                string? custom = input.ReadLine()?.Trim();
                if (custom is null || custom.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
                if (custom.Length <= 200 && RegexPatterns.OllamaModelIdentifier().IsMatch(custom)) return custom;
                output.WriteLine("Enter a valid model name, for example llama3.2:1b.");
            }
            else output.WriteLine("Choose 1–5, or 0 to exit.");
        }
    }

    private bool Confirm(string question)
    {
        while (true)
        {
            output.Write(question);
            string? answer = input.ReadLine()?.Trim().ToLowerInvariant();
            if (answer is null or "" or "n" or "no" or "exit" or "0") return false;
            if (answer is "y" or "yes") return true;
            output.WriteLine("Please enter y, n, or exit.");
        }
    }

    private ConsoleSettings Load()
    {
        if (!File.Exists(settingsPath)) return new();
        try
        {
            return JsonSettings.Load<ConsoleSettings>(settingsPath, settings => settings.IsValid());
        }
        catch (JsonException ex) { output.WriteLine($"Saved settings could not be read ({ex.Message}); setup will ask again."); return new(); }
    }

    private void Save(ConsoleSettings settings)
    {
        JsonSettings.Save(settingsPath, settings);
    }
}
NEWConsoleSetupTimer.cs644 B / 19 lines+19-0+644 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;

namespace LilithConsole.Setup;

internal sealed class ConsoleSetupTimer(Logger logger) : IDisposable
{
    private readonly System.Diagnostics.Stopwatch _timer = System.Diagnostics.Stopwatch.StartNew();
    public bool Completed { get; set; }
    public void Dispose()
    {
        _timer.Stop();
        logger.WriteLine("Boot", $"Console setup {(Completed ? "finished" : "stopped")} — elapsed {_timer.Elapsed.TotalSeconds:F2}s", LogColors.Gray);
    }
}
NEWISetupServices.cs412 B / 17 lines+17-0+412 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;

namespace LilithConsole.Setup;

public interface ISetupServices
{
    bool IsInstalled();
    Task InstallAsync();
    Task EnsureServerAsync();
    Task<bool> HasModelAsync(string model);
    Task DownloadAsync(string model);
}
NEWOllamaSetupServices.cs697 B / 17 lines+17-0+697 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;

namespace LilithConsole.Setup;

public sealed class OllamaSetupServices(Logger logger) : ISetupServices
{
    public bool IsInstalled() => OllamaClient.IsOllamaInstalled();
    public Task InstallAsync() => OllamaClient.InstallAsync(logger);
    public Task EnsureServerAsync() => OllamaClient.EnsureServerAsync(logger);
    public Task<bool> HasModelAsync(string model) => OllamaClient.IsModelAvailableAsync(model);
    public Task DownloadAsync(string model) => OllamaClient.DownloadModelAsync(model, logger);
}
NEWProviderSetup.cs4,577 B / 81 lines+81-0+4,577 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Agent;
using Lilith.Core.LMStudio;
using Lilith.Core.Logging;
using Lilith.Core.Extensions;

namespace LilithConsole.Setup;

public static class ProviderSetup
{
    public static async Task<LilithCredentials?> RunAsync(TextReader input, TextWriter output, Logger logger, string settingsPath)
    {
        string? selected = Environment.GetEnvironmentVariable("LILITH_PROVIDER");
        if (string.IsNullOrWhiteSpace(selected))
        {
            output.WriteLine("Choose your provider: 1. Ollama  2. LM Studio (Windows GUI)  0. Exit");
            output.Write("Provider [1]: ");
            selected = input.ReadLine()?.Trim();
            if (selected is null or "0" || selected.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
            if (selected == "") selected = "1";
        }
        if (selected is "1" || selected.Equals("Ollama", StringComparison.OrdinalIgnoreCase))
            return await new ConsoleSetup(input, output, new OllamaSetupServices(logger), logger, settingsPath).RunAsync();
        if (selected != "2" && !selected.Equals("LMStudio", StringComparison.OrdinalIgnoreCase))
            throw new ArgumentException("Select 1/Ollama or 2/LMStudio.");

        string path = settingsPath + ".lmstudio.json";
        ConsoleSettings preferences = new();
        if (File.Exists(path))
        {
            try { preferences = JsonSettings.Load<ConsoleSettings>(path, settings => settings.IsValid()); }
            catch (JsonException) { output.WriteLine("Saved LM Studio preferences are invalid; choose them again."); }
        }
        if (!LMStudioSetup.IsInstalled())
        {
            output.Write("Download and install the LM Studio Windows GUI? [y/N]: ");
            string? answer = input.ReadLine()?.Trim();
            if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) && !string.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) return null;
            await LMStudioSetup.InstallGuiAsync(logger);
        }
        await LMStudioSetup.EnsureServerAsync(logger);
        if (preferences.AgentName is null)
        {
            output.Write("Agent name [Lilith]: ");
            string? name = input.ReadLine()?.Trim();
            if (name is null || name.Equals("/exit", StringComparison.OrdinalIgnoreCase)) return null;
            if (name.Length > 80 || name.Any(char.IsControl)) throw new ArgumentException("Invalid agent name.");
            preferences = preferences with { AgentName = name.Length == 0 ? "Lilith" : name };
        }
        void Save() => JsonSettings.Save(path, preferences);
        Save();
        var models = await LMStudioSetup.ListModelsAsync();
        if (preferences.Model is null || !models.Any(m => m.Id == preferences.Model))
        {
            output.WriteLine("Downloaded LM Studio models:");
            for (int i = 0; i < models.Count; i++) output.WriteLine($"{i + 1}. {models[i].Name} ({models[i].Id})");
            output.Write("Choose a number or enter a catalog ID (e.g. ibm/granite-4-micro); 0 exits: ");
            string? model = input.ReadLine()?.Trim();
            if (model is null or "0" || model.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
            if (int.TryParse(model, out int index))
            {
                if (index < 1 || index > models.Count) throw new ArgumentException("Invalid model number.");
                model = models[index - 1].Id;
            }
            if (model.Length > 200 || !RegexPatterns.ModelIdentifier().IsMatch(model)) throw new ArgumentException("Invalid LM Studio model identifier.");
            preferences = preferences with { Model = model };
            Save();
            if (!models.Any(m => m.Id == model))
            {
                output.Write($"Download LM Studio model '{model}'? [y/N]: ");
                string? answer = input.ReadLine()?.Trim();
                if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) && !string.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) return null;
                await LMStudioSetup.DownloadModelAsync(model, logger);
            }
        }
        return new LilithCredentials(preferences.Model!,
            $"You are {preferences.AgentName}, a helpful AI assistant. Greet the user warmly when the conversation begins.",
            preferences.AgentName!, Lilith.Core.Providers.ProviderKind.LMStudio);
    }
}

Lilith/Agent 10 files

NEWAgent.csproj354 B / 14 lines+14-0+354 B
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>Lilith.Agent</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Core\Core.csproj" />
  </ItemGroup>

</Project>
NEWAssemblyInfo.cs101 B / 3 lines+3-0+101 B
C#UnchangedRemoved / replacedAdded
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Lilith.RegressionChecks")]
NEWGlobalUsings.cs70 B / 2 lines+2-0+70 B
C#UnchangedRemoved / replacedAdded
global using Lilith.Core.Chat;
global using Lilith.Core.Extensions;
NEWLilith.Boot.cs2,469 B / 59 lines+59-0+2,469 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;

namespace Lilith.Agent;

public partial class Lilith
{
    public bool IsBooted { get; private set; }

    public async Task BootAsync(bool startServerIfNotRunning = true)
    {
        using var operation = EnterOperation();
        try
        {
            if (IsBooted) return;
            if (string.IsNullOrWhiteSpace(Credentials.Model) || string.IsNullOrWhiteSpace(Credentials.SystemPrompt) ||
                string.IsNullOrWhiteSpace(Credentials.AgentName)) throw new ArgumentException("Model, system prompt, and agent name are required.");
            await Logger.TimeStageAsync("Total agent boot", () => BootCoreAsync(startServerIfNotRunning));
        }
        catch
        {
            _llmclient?.Dispose();
            _llmclient = null!;
            IsBooted = false;
            throw;
        }
    }

    private async Task BootCoreAsync(bool startServerIfNotRunning)
    {
        IsBooted = false;
        await Logger.TimeStageAsync("Client initialization", () => InitializeProviderAsync(startServerIfNotRunning));
        await Logger.TimeStageAsync("Chat readiness validation", () => { EnsureChatReadiness(); return Task.CompletedTask; });
        if (Credentials.Tts is { Enabled: true } options && _tts is null)
        {
            try
            {
                _tts = await Logger.TimeStageAsync("Kokoro initialization", () =>
                    global::Lilith.Core.Kokoro.KokoroTts.CreateAsync(Logger, options));
            }
            catch (Exception ex) { Logger.SystemWriteLine($"Speech unavailable: {ex.Message}. Text chat remains available.", LogColors.Orange); }
        }
        Logger.SystemWriteLine($"=== {Credentials.AgentName} AI Chat ({Credentials.Model} via {Credentials.Provider}) ===");
        Logger.SystemWriteLine("Type your message and press Enter. Type 'exit' to quit.\n");
        Logger.SystemPromptWriteLine($"SYSTEM: {Credentials.SystemPrompt}");
        await Logger.TimeStageAsync("Greeting generation", () => ChatCoreAsync("Please greet the user."));
        Logger.SystemWriteLine("", LogColors.White);
        IsBooted = true;
    }

    public async Task GreetAsync() => await ChatAsync("Please greet the user.");

    private void EnsureChatReadiness()
    {
        _llmclient.IsNull();
        this.EnsureSystemPromptContent();
        this.EnsureContextInitialized();
    }
}
NEWLilith.Chat.cs3,087 B / 86 lines+86-0+3,087 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using System.Diagnostics;

namespace Lilith.Agent;

public partial class Lilith
{
    public async Task ChatAsync(string input, List<string>? images = null)
    {
        using var operation = EnterOperation("Boot the agent before chatting.");
        await ChatCoreAsync(input, images);
    }

    private async Task ChatCoreAsync(string input, List<string>? images = null)
    {
        var timer = Stopwatch.StartNew();
        string reply;
        try { reply = await Client.StreamReply(input, images); }
        finally { timer.Stop(); }
        this.LogTokenCounts(timer.Elapsed);
        if (_tts is not null) await _tts.SpeakAsync(reply);
    }

    private T WithConversation<T>(Func<ProviderClient, T> action)
    {
        using var operation = EnterOperation("Boot the agent before using its conversation.");
        return action(Client);
    }

    private void WithConversation(Action<ProviderClient> action)
    {
        using var operation = EnterOperation("Boot the agent before using its conversation.");
        action(Client);
    }

    public void NewConversation() => WithConversation(client => client.NewConversation());

    public void SaveConversation() => WithConversation(client => client.SaveConversation());

    public IReadOnlyList<ChatSessionSummary> ListConversations() => WithConversation(client => client.ListConversations());

    public bool LoadConversation(int displayIndex) => WithConversation(client => client.LoadConversation(displayIndex));

    public bool LoadConversation(string id) => WithConversation(client => client.LoadConversation(id));

    public void HandleHistoryCommand(string input)
    {
        var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (parts.Length == 1)
        {
            var sessions = ListConversations();
            if (sessions.Count == 0)
            {
                Logger.HistoryWriteLine("No saved chats found.");
                return;
            }

            Logger.HistoryWriteLine("Saved chats:", LogColors.Cyan);
            for (int i = 0; i < sessions.Count; i++)
            {
                var session = sessions[i];
                Logger.HistoryWriteLine($"{i + 1}. {session.Title} ({session.UpdatedAt.LocalDateTime:g}, {session.MessageCount} messages)");
            }

            Logger.HistoryWriteLine("Use /history <number> to load a chat.");
            return;
        }

        if (!int.TryParse(parts[1], out int index))
        {
            Logger.HistoryWriteLine("Usage: /history or /history <number>", LogColors.Orange);
            return;
        }

        if (LoadConversation(index))
        {
            Logger.HistoryWriteLine($"Loaded saved chat #{index}.", LogColors.Cyan);
        }
        else
        {
            Logger.HistoryWriteLine($"Saved chat #{index} was not found.", LogColors.Orange);
        }
    }
}
NEWLilith.Commands.cs1,469 B / 27 lines+27-0+1,469 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Commands;

namespace Lilith.Agent;

public partial class Lilith
{
    private readonly object _commandSync = new();
    private ChatCommandRegistry? _chatCommands;
    public ChatCommandRegistry ChatCommands { get { lock (_commandSync) return _chatCommands ??= CreateCommands(); } }

    private ChatCommandRegistry CreateCommands()
    {
        var commands = new ChatCommandRegistry();
        commands.Register("help", "List available chat commands.", context => { context.RequireNoArguments(); return commands.Help(); });
        commands.Register("new", "Start a new conversation.", context => { context.RequireNoArguments(); NewConversation(); return null; });
        commands.Register("history", "List saved chats or load one with /history <index>.", context => { HandleHistoryCommand("/history " + context.Arguments); return null; });
        commands.RegisterAsync("exit", "Save and exit the chat host.", context => { context.RequireNoArguments(); return Task.FromResult(new ChatCommandResult(true, ExitRequested: true)); });
        RegisterScenarioCommands(commands);
        RegisterWorkspaceCommands(commands);
        RegisterScheduleCommands(commands);
        return commands;
    }

    partial void RegisterScenarioCommands(ChatCommandRegistry commands);
    partial void RegisterWorkspaceCommands(ChatCommandRegistry commands);
    partial void RegisterScheduleCommands(ChatCommandRegistry commands);
}
NEWLilith.Extensions.cs3,206 B / 67 lines+67-0+3,206 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.LMStudio;

namespace Lilith.Agent;

/// <summary>Agent client creation and logging helpers.</summary>
public static class LilithExtensions
{
    public static void EnsureSystemPromptSeeded(this Lilith agent)
    {
        if (agent.History.Count == 0 || !string.Equals(agent.History[0].Role, "system", StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException("Chat history was not seeded with a system prompt.");
    }

    public static void EnsureSystemPromptContent(this Lilith agent)
    {
        agent.EnsureSystemPromptSeeded();
        if (string.IsNullOrWhiteSpace(agent.History[0].Content))
            throw new InvalidOperationException("System prompt content is missing.");
    }

    public static void EnsureContextInitialized(this Lilith agent)
    {
        if (agent.ContextMax <= 0)
            throw new InvalidOperationException("Model context window was not initialized.");
    }

    public static async Task<ProviderClient> CreateClient(this Lilith agent, bool startServerIfNotRunning = true) =>
        agent.Credentials.Provider switch
        {
            ProviderKind.Ollama => await OllamaClient.CreateClient(agent.Credentials.Model, agent.Credentials.SystemPrompt,
                agent.Logger, startServerIfNotRunning, agent.Credentials.AgentName),
            ProviderKind.LMStudio => await LMStudioClient.CreateClient(agent.Credentials.Model, agent.Credentials.SystemPrompt,
                agent.Logger, startServerIfNotRunning, agent.Credentials.AgentName),
            _ => throw new ArgumentOutOfRangeException(nameof(agent.Credentials.Provider))
        };

    public static void HistoryWriteLine(this Logger logger, string description = "", string color = LogColors.Gray) =>
        logger.WriteLine("History", description, color);

    public static void TokensWriteLine(this Logger logger, string description = "", string color = LogColors.Gray) =>
        logger.WriteLine("Tokens", description, color);

    public static void SystemWriteLine(this Logger logger, string description = "", string color = LogColors.Cyan) =>
        logger.WriteLine("System", description, color);

    public static void SystemPromptWriteLine(this Logger logger, string description = "", string color = LogColors.Cyan) =>
        logger.WriteLine("SYSTEM", description, color);

    public static void LogTokenCounts(this Lilith agent, TimeSpan? elapsed = null, string color = LogColors.Gray)
    {
        if (agent.History.Count < 2)
        {
            return;
        }

        int? promptTokens = agent.History[^2].Tokens;
        int? completionTokens = agent.History[^1].Tokens;
        if (promptTokens.HasValue || completionTokens.HasValue)
        {
            string elapsedText = elapsed is { } duration ? $"  elapsed: {duration.TotalSeconds:F2}s" : string.Empty;
            agent.Logger.TokensWriteLine($"prompt: {promptTokens ?? 0}  completion: {completionTokens ?? 0}  total: {(promptTokens ?? 0) + (completionTokens ?? 0)}{elapsedText}", color);
        }
    }
}
NEWLilith.SystemPrompt.cs206 B / 7 lines+7-0+206 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Agent;

public partial class Lilith
{
    public const string DefaultSystemPrompt = "You are Lilith, a helpful AI assistant. Greet the user warmly when the conversation begins.";

}
NEWLilith.cs2,150 B / 55 lines+55-0+2,150 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;

namespace Lilith.Agent;

public partial class Lilith(Action<LogEntry> onLog, LilithCredentials? credentials = null) : IAsyncDisposable
{
    private KokoroTts? _tts;
    private readonly SemaphoreSlim _operation = new(1, 1);
    private bool _disposed;
    private ProviderClient _llmclient = null!;
    private readonly LilithCredentials _credentials = credentials ?? new();

    public LilithCredentials Credentials => _credentials;
    public Logger Logger { get; } = new(onLog);
    internal Func<bool, Task<ProviderClient>>? ProviderFactory { get; set; }

    private ProviderClient Client
    {
        get
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            return _llmclient ?? throw new InvalidOperationException("Boot the agent before using its conversation.");
        }
    }
    public ChatHistory History => Client.History;
    public int ContextMax => Client.ContextMax;
    public int CurrentContextTokens => Client.CurrentContextTokens;

    private async Task InitializeProviderAsync(bool startServerIfNotRunning) =>
        _llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);

    private IDisposable EnterOperation(string? bootRequiredMessage = null)
    {
        var operation = _operation.EnterOrThrow("An agent operation is already in progress.");
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            if (bootRequiredMessage is not null && !IsBooted) throw new InvalidOperationException(bootRequiredMessage);
            return operation;
        }
        catch { operation.Dispose(); throw; }
    }

    public async ValueTask DisposeAsync()
    {
        using var operation = await _operation.EnterAsync();
        if (_disposed) return;
        _disposed = true;
        try { if (_tts is not null) await _tts.DisposeAsync(); }
        finally { _llmclient?.Dispose(); IsBooted = false; }
    }
}
NEWLilithCredentials.cs380 B / 13 lines+13-0+380 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;

namespace Lilith.Agent;

public record LilithCredentials(
    string Model = "gemma4",
    string SystemPrompt = Lilith.DefaultSystemPrompt,
    string AgentName = "Lilith",
    ProviderKind Provider = ProviderKind.Ollama,
    KokoroTtsOptions? Tts = null);

Lilith/Core 3 files

NEWCore.cs2,795 B / 74 lines+74-0+2,795 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core;

/// <summary>
/// Root hub for agent addons. Create one instance and access <see cref="Logger"/>,
/// <see cref="OllamaClient"/>, and future services from the same object.
/// </summary>
public class Core(Action<LogEntry>? onLog = null) : IDisposable
{
    private static readonly Lazy<Core> Shared = new(() => new());
    private readonly object _sync = new();
    private OllamaClient? _client;
    private bool _ownsClient;
    private bool _disposed;

    /// <summary>Shared application core (optional).</summary>
    public static Core Default => Shared.Value;

    /// <summary>Subscribe-based logging (console, GUI, file, etc.).</summary>
    public Logger Logger { get; } = new(onLog);

    /// <summary>
    /// Ollama client addon. Assign after construction or via <see cref="UseOllamaAsync"/>.
    /// </summary>
    public OllamaClient? OllamaClient
    {
        get { lock (_sync) { ObjectDisposedException.ThrowIf(_disposed, this); return _client; } }
        set => Attach(value, owned: false);
    }

    /// <summary>Attach an Ollama client and return it for chaining.</summary>
    public OllamaClient UseOllama(OllamaClient ollamaClient)
    {
        Attach(ollamaClient ?? throw new ArgumentNullException(nameof(ollamaClient)), owned: false);
        return ollamaClient;
    }

    /// <summary>
    /// Creates and attaches a client for an already prepared model. Installation/download
    /// are explicit setup operations; this method only starts/checks the server and validates readiness.
    /// </summary>
    public async Task<OllamaClient> UseOllamaAsync(string model, string systemPrompt, bool startOllamaIfNotRunning = true)
    {
        lock (_sync) ObjectDisposedException.ThrowIf(_disposed, this);
        var client = await OllamaClient.CreateClient(model, systemPrompt, Logger, startOllamaIfNotRunning);
        try { Attach(client, owned: true); return client; }
        catch { client.Dispose(); throw; }
    }

    /// <summary>External attachments are borrowed; clients created by UseOllamaAsync are owned.</summary>
    internal void Attach(OllamaClient? client, bool owned)
    {
        lock (_sync)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            if (ReferenceEquals(_client, client)) return;
            if (_ownsClient) _client?.Dispose();
            _client = client;
            _ownsClient = owned;
        }
    }

    public void Dispose()
    {
        lock (_sync)
        {
            if (_disposed) return;
            if (_ownsClient) _client?.Dispose();
            _disposed = true;
            _client = null;
            _ownsClient = false;
        }
        GC.SuppressFinalize(this);
    }
}
NEWCore.csproj362 B / 13 lines+13-0+362 B
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>Lilith.Core</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="KokoroSharp.CPU" Version="0.8.4" />
  </ItemGroup>
</Project>
NEWGlobalUsings.cs176 B / 5 lines+5-0+176 B
C#UnchangedRemoved / replacedAdded
global using Lilith.Core.Logging;
global using Lilith.Core.Ollama;
global using Lilith.Core.Chat;
global using Lilith.Core.Extensions;
global using Lilith.Core.Providers;

Lilith/Core/Chat 12 files

NEWChatChunk.cs364 B / 9 lines+9-0+364 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

public record ChatChunk(
    [property: JsonPropertyName("message")] ChatMessage? Message,
    [property: JsonPropertyName("done")] bool Done,
    [property: JsonPropertyName("prompt_eval_count")] int? PromptTokens,
    [property: JsonPropertyName("eval_count")] int? CompletionTokens);
NEWChatHistory.cs259 B / 9 lines+9-0+259 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

public class ChatHistory : List<ChatMessage>
{
    public void Add(string role, string content, int? tokens = null) =>
        Add(new ChatMessage(role, content, Tokens: tokens));
}
NEWChatHistoryStore.cs9,138 B / 247 lines+247-0+9,138 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace Lilith.Core.Chat;

public class ChatHistoryStore
{
    public string CurrentSessionId { get; private set; } = NewSessionId();
    public string RootDirectory { get; }

    public ChatHistoryStore() : this(null)
    {
    }

    /// <param name="rootDirectoryOverride">Optional folder for tests or custom deployments.</param>
    public ChatHistoryStore(string? rootDirectoryOverride)
    {
        if (!string.IsNullOrWhiteSpace(rootDirectoryOverride))
        {
            RootDirectory = rootDirectoryOverride;
            return;
        }

        string? envRoot = Environment.GetEnvironmentVariable("AGENT_CORE_CHAT_HISTORY_ROOT");
        if (!string.IsNullOrWhiteSpace(envRoot))
        {
            RootDirectory = envRoot;
            return;
        }

        string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
        if (string.IsNullOrWhiteSpace(appData))
        {
            appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
        }

        if (string.IsNullOrWhiteSpace(appData))
        {
            appData = AppContext.BaseDirectory;
        }

        RootDirectory = Path.Combine(appData, "AgentCore", DetectVersionLabel(), "ChatHistory");
    }

    public void StartNewSession() => CurrentSessionId = NewSessionId();

    public void UseSession(string id)
    {
        if (!string.IsNullOrWhiteSpace(id))
        {
            CurrentSessionId = SanitizeId(id);
        }
    }

    public string SaveSession(string model, IEnumerable<ChatMessage> history) =>
        SaveSession(CurrentSessionId, model, history);

    public string SaveSession(string? sessionId, string model, IEnumerable<ChatMessage> history)
    {
        Directory.CreateDirectory(RootDirectory);

        string id = string.IsNullOrWhiteSpace(sessionId) ? NewSessionId() : SanitizeId(sessionId);
        string path = SessionPath(id);
        DateTimeOffset now = DateTimeOffset.Now;
        DateTimeOffset createdAt = ReadCreatedAt(path) ?? now;
        var messages = history.Select(m => m.ToSavedMessage()).ToList();

        var doc = new XDocument(
            new XElement("chatSession",
                new XAttribute("id", id),
                new XAttribute("title", MakeTitle(messages)),
                new XAttribute("model", model),
                new XAttribute("createdAt", createdAt.ToString("O")),
                new XAttribute("updatedAt", now.ToString("O")),
                new XElement("messages",
                    messages.Select(m =>
                        new XElement("message",
                            new XAttribute("role", m.Role),
                            m.Tokens.HasValue ? new XAttribute("tokens", m.Tokens.Value) : null,
                            new XElement("content", m.Content),
                            m.Images is { Count: > 0 }
                                ? new XElement("images", m.Images.Select(i => new XElement("image", i)))
                                : null)))));

        AtomicFile.Write(path, doc.Save);
        CurrentSessionId = id;
        return id;
    }

    public IReadOnlyList<ChatSessionSummary> ListSessions()
    {
        if (!Directory.Exists(RootDirectory))
        {
            return [];
        }

        return Directory.EnumerateFiles(RootDirectory, "*.xml")
            .Select(ReadSummary)
            .Where(s => s is not null)
            .Select(s => s!)
            .OrderByDescending(s => s.UpdatedAt)
            .ToList();
    }

    public SavedChatSession? LoadSession(string id)
    {
        string path = SessionPath(SanitizeId(id));
        if (!File.Exists(path))
        {
            return null;
        }

        var session = ReadSession(path, SanitizeId(id));
        CurrentSessionId = session.Id;
        return session;
    }

    private static SavedChatSession ReadSession(string path, string id)
    {
        XDocument doc = XDocument.Load(path);
        XElement root = doc.Root ?? throw new InvalidDataException("Chat XML has no root.");
        if (root.Name != "chatSession" || root.Element("messages") is null ||
            ((string?)root.Attribute("id") is { } embedded && embedded != id))
            throw new InvalidDataException("Invalid chat session structure or identity.");
        var session = new SavedChatSession
        {
            Id = id,
            Title = (string?)root.Attribute("title") ?? "New chat",
            Model = (string?)root.Attribute("model") ?? string.Empty,
            CreatedAt = ParseDate((string?)root.Attribute("createdAt")) ?? DateTimeOffset.Now,
            UpdatedAt = ParseDate((string?)root.Attribute("updatedAt")) ?? DateTimeOffset.Now,
            Messages = root.Element("messages")?.Elements("message").Select(ReadMessage).ToList() ?? []
        };
        if (session.Messages.Count == 0) throw new InvalidDataException("Chat has no messages.");
        for (int i = 0; i < session.Messages.Count; i++)
        {
            var message = session.Messages[i];
            if (message.Role is not ("system" or "user" or "assistant") || message.Tokens is < 0 ||
                (message.Role == "system" && (i != 0 || string.IsNullOrWhiteSpace(message.Content))))
                throw new InvalidDataException("Chat contains invalid messages.");
        }
        return session;
    }

    private string SessionPath(string id) => Path.Combine(RootDirectory, SanitizeId(id) + ".xml");

    private static SavedChatMessage ReadMessage(XElement element)
    {
        int? tokens = null;
        if (element.Attribute("tokens") is { } attribute)
        {
            if (!int.TryParse(attribute.Value, out int value)) throw new InvalidDataException("Invalid token count.");
            tokens = value;
        }
        var images = element.Element("images")?.Elements("image")
            .Select(i => i.Value)
            .Where(i => !string.IsNullOrWhiteSpace(i))
            .ToList();

        return new SavedChatMessage
        {
            Role = (string?)element.Attribute("role") ?? string.Empty,
            Content = element.Element("content")?.Value ?? string.Empty,
            Tokens = tokens,
            Images = images is { Count: > 0 } ? images : null
        };
    }

    private static ChatSessionSummary? ReadSummary(string path)
    {
        try
        {
            string id = Path.GetFileNameWithoutExtension(path);
            var session = ReadSession(path, id);
            return new ChatSessionSummary(
                id,
                session.Title, session.CreatedAt, session.UpdatedAt, session.Messages.Count, session.Model);
        }
        catch
        {
            return null;
        }
    }

    private static DateTimeOffset? ReadCreatedAt(string path)
    {
        if (!File.Exists(path))
        {
            return null;
        }

        try
        {
            XDocument doc = XDocument.Load(path);
            return ParseDate((string?)doc.Root?.Attribute("createdAt"));
        }
        catch
        {
            return null;
        }
    }

    private static DateTimeOffset? ParseDate(string? value) =>
        DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;

    private static string MakeTitle(IReadOnlyList<SavedChatMessage> messages)
    {
        string? firstUser = messages
            .Where(m => m.Role.Equals("user", StringComparison.OrdinalIgnoreCase))
            .Select(m => m.Content.Trim())
            .FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
                !m.StartsWith("Please greet", StringComparison.OrdinalIgnoreCase) &&
                !m.Equals("hello", StringComparison.OrdinalIgnoreCase));

        if (string.IsNullOrWhiteSpace(firstUser))
        {
            firstUser = messages.FirstOrDefault(m => m.Role != "system" && !string.IsNullOrWhiteSpace(m.Content))?.Content.Trim();
        }

        if (string.IsNullOrWhiteSpace(firstUser))
        {
            return "New chat";
        }

        firstUser = RegexPatterns.Whitespace().Replace(firstUser, " ");
        return firstUser.Length <= 60 ? firstUser : firstUser[..57] + "...";
    }

    private static string DetectVersionLabel()
    {
        foreach (string source in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
        {
            var match = RegexPatterns.VersionLabel().Match(source);
            if (match.Success)
            {
                return RegexPatterns.Whitespace().Replace(match.Value, " ");
            }
        }

        return "Default";
    }

    private static string NewSessionId() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff") + "-" + Guid.NewGuid().ToString("N");

    private static string SanitizeId(string id) =>
        string.Concat(id.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));
}
NEWChatMessage.cs399 B / 9 lines+9-0+399 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

public record ChatMessage(
    [property: JsonPropertyName("role")] string Role,
    [property: JsonPropertyName("content")] string Content,
    [property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<string>? Images = null,
    [property: JsonIgnore] int? Tokens = null);
NEWChatMessageExtensions.cs731 B / 18 lines+18-0+731 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Chat;

public static class ChatMessageExtensions
{
    public static ChatMessage Snapshot(this ChatMessage message) =>
        message with { Images = message.Images is null ? null : [.. message.Images] };

    public static SavedChatMessage ToSavedMessage(this ChatMessage message) => new()
    {
        Role = message.Role,
        Content = message.Content ?? string.Empty,
        Tokens = message.Tokens,
        Images = message.Images is { Count: > 0 } ? [.. message.Images] : null
    };

    public static ChatMessage ToChatMessage(this SavedChatMessage message) =>
        new(message.Role, message.Content, message.Images is null ? null : [.. message.Images], message.Tokens);
}
NEWChatOptions.cs160 B / 6 lines+6-0+160 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

internal record ChatOptions(
    [property: JsonPropertyName("num_ctx")] int NumCtx);
NEWChatRequest.cs365 B / 9 lines+9-0+365 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

internal record ChatRequest(
    [property: JsonPropertyName("model")] string Model,
    [property: JsonPropertyName("messages")] List<ChatMessage> Messages,
    [property: JsonPropertyName("stream")] bool Stream,
    [property: JsonPropertyName("options")] ChatOptions? Options = null);
NEWChatSessionSummary.cs271 B / 12 lines+12-0+271 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace Lilith.Core.Chat;

public record ChatSessionSummary(
    string Id,
    string Title,
    DateTimeOffset CreatedAt,
    DateTimeOffset UpdatedAt,
    int MessageCount,
    string Model);
NEWSavedChatMessage.cs329 B / 12 lines+12-0+329 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace Lilith.Core.Chat;

public class SavedChatMessage
{
    public string Role { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public int? Tokens { get; set; }
    public List<string>? Images { get; set; }
}
NEWSavedChatSession.cs503 B / 14 lines+14-0+503 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace Lilith.Core.Chat;

public class SavedChatSession
{
    public string Id { get; set; } = string.Empty;
    public string Title { get; set; } = "New chat";
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now;
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Now;
    public string Model { get; set; } = string.Empty;
    public List<SavedChatMessage> Messages { get; set; } = [];
}
NEWShowModelRequest.cs165 B / 6 lines+6-0+165 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

internal record ShowModelRequest(
    [property: JsonPropertyName("model")] string Model);
NEWShowModelResponse.cs218 B / 6 lines+6-0+218 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Chat;

internal record ShowModelResponse(
    [property: JsonPropertyName("model_info")] Dictionary<string, System.Text.Json.JsonElement>? ModelInfo);

Lilith/Core/Commands 1 files

NEWChatCommandRegistry.cs2,811 B / 47 lines+47-0+2,811 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;

namespace Lilith.Core.Commands;

public sealed record ChatCommandContext(string Arguments, CancellationToken CancellationToken)
{
    public void RequireNoArguments() { if (Arguments.Length > 0) throw new ArgumentException("This command takes no arguments."); }
}
public sealed record ChatCommandResult(bool Handled, string? Output = null, bool ExitRequested = false);
public sealed record ChatCommandInfo(string Name, string Description);

/// <summary>Host commands are dispatched locally, never sent to the model as chat.</summary>
public sealed class ChatCommandRegistry
{
    private readonly object _sync = new();
    private readonly Dictionary<string, (ChatCommandInfo Info, Func<ChatCommandContext, Task<ChatCommandResult>> Handler)> _commands = new(StringComparer.OrdinalIgnoreCase);
    public IReadOnlyList<ChatCommandInfo> List() { lock (_sync) return _commands.Values.Select(c => c.Info).OrderBy(c => c.Name, StringComparer.Ordinal).ToArray(); }
    public string Help() => string.Join("\n", List().Select(c => $"/{c.Name} - {c.Description}"));

    public void Register(string name, string description, Func<ChatCommandContext, string?> handler)
    {
        ArgumentNullException.ThrowIfNull(handler);
        RegisterAsync(name, description, context => Task.FromResult(new ChatCommandResult(true, handler(context))));
    }
    public void RegisterAsync(string name, string description, Func<ChatCommandContext, Task<ChatCommandResult>> handler)
    {
        ArgumentNullException.ThrowIfNull(handler);
        name = name.TrimStart('/');
        if (!Regex.IsMatch(name, "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$") || string.IsNullOrWhiteSpace(description)) throw new ArgumentException("Supply a command name and description.");
        lock (_sync)
            if (!_commands.TryAdd(name, (new(name.ToLowerInvariant(), description), handler))) throw new ArgumentException("Command already registered: " + name);
    }
    public async Task<ChatCommandResult> ExecuteAsync(string input, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        string text = input.TrimStart();
        if (!text.StartsWith('/')) return new(false);
        int end = 1;
        while (end < text.Length && !char.IsWhiteSpace(text[end])) end++;
        string name = text[1..end];
        Func<ChatCommandContext, Task<ChatCommandResult>>? handler;
        lock (_sync) handler = _commands.TryGetValue(name, out var command) ? command.Handler : null;
        if (handler is null) return new(true, $"Unknown command /{name}. Use /help.");
        var result = await handler(new(text[end..].Trim(), cancellationToken));
        return result with { Handled = true };
    }
}

Lilith/Core/Extensions 9 files

NEWAtomicFile.cs1,165 B / 28 lines+28-0+1,165 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;

public static class AtomicFile
{
    private static readonly object[] Gates = [.. Enumerable.Range(0, 64).Select(_ => new object())];

    public static void Write(string path, Action<string> write)
    {
        path = Path.GetFullPath(path);
        lock (Gates[(uint)StringComparer.OrdinalIgnoreCase.GetHashCode(path) % Gates.Length])
        {
            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
            string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
            try
            {
                write(temporary);
                // Windows readers/other processes may briefly hold the destination without delete sharing.
                for (int attempt = 0; ; attempt++)
                {
                    try { File.Move(temporary, path, overwrite: true); break; }
                    catch (Exception ex) when (attempt < 10 && ex is IOException or UnauthorizedAccessException)
                    { Thread.Sleep(20); }
                }
            }
            finally { if (File.Exists(temporary)) File.Delete(temporary); }
        }
    }
}
NEWBootTimingExtensions.cs888 B / 27 lines+27-0+888 B
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;

namespace Lilith.Core.Extensions;

public static class BootTimingExtensions
{
    public static async Task<T> TimeStageAsync<T>(this Logger logger, string stage, Func<Task<T>> action)
    {
        var timer = Stopwatch.StartNew();
        bool succeeded = false;
        try
        {
            T result = await action();
            succeeded = true;
            return result;
        }
        finally
        {
            timer.Stop();
            logger.WriteLine("Boot", $"{stage} {(succeeded ? "finished" : "failed")} — elapsed {timer.Elapsed.TotalSeconds:F2}s",
                succeeded ? LogColors.Gray : LogColors.Red);
        }
    }

    public static Task TimeStageAsync(this Logger logger, string stage, Func<Task> action) =>
        logger.TimeStageAsync(stage, async () => { await action(); return true; });
}
NEWHttpExtensions.cs1,621 B / 43 lines+43-0+1,621 B
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Json;
using System.Text.Json;

namespace Lilith.Core.Extensions;

public static class HttpExtensions
{
    public static HttpRequestMessage JsonRequest<T>(string url, T body) =>
        new(HttpMethod.Post, url) { Content = JsonContent.Create(body) };

    public static T? Deserialize<T>(this string json) =>
        JsonSerializer.Deserialize<T>(json);

    public static async Task<T?> SendJsonAsync<T>(this HttpClient http, HttpRequestMessage request,
        CancellationToken ct = default)
    {
        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();
        return JsonSerializer.Deserialize<T>(await response.Content.ReadAsStringAsync(ct));
    }

    /// <summary>The caller owns the returned document; the request remains caller-owned.</summary>
    public static async Task<JsonDocument> SendJsonDocumentAsync(this HttpClient http, HttpRequestMessage request,
        CancellationToken ct = default)
    {
        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();
        return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
    }

    public static async Task<bool> IsReachableAsync(this HttpClient http, string url)
    {
        try
        {
            using HttpResponseMessage response = await http.GetAsync(url);
            return response.IsSuccessStatusCode;
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            return false;
        }
    }
}
NEWJsonSettings.cs631 B / 16 lines+16-0+631 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;

namespace Lilith.Core.Extensions;

public static class JsonSettings
{
    public static T Load<T>(string path, Func<T, bool> valid) where T : class
    {
        var value = JsonSerializer.Deserialize<T>(File.ReadAllText(path));
        if (value is null || !valid(value)) throw new JsonException("Saved preferences contain invalid values.");
        return value;
    }

    public static void Save<T>(string path, T value) => AtomicFile.Write(path, temporary =>
        File.WriteAllText(temporary, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true })));
}
NEWProcessExtensions.cs639 B / 18 lines+18-0+639 B
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;

namespace Lilith.Core.Extensions;

public static class ProcessExtensions
{
    /// <summary>On timeout, terminates the process tree and waits before releasing ownership.</summary>
    public static async Task WaitForExitOrKillAsync(this Process process, TimeSpan timeout, string message)
    {
        try { await process.WaitForExitAsync().WaitAsync(timeout); }
        catch (TimeoutException)
        {
            if (!process.HasExited) process.Kill(entireProcessTree: true);
            await process.WaitForExitAsync();
            throw new TimeoutException(message);
        }
    }
}
NEWRegexPatterns.cs1,029 B / 31 lines+31-0+1,029 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;

namespace Lilith.Core.Extensions;

/// <summary>Shared fixed patterns compiled by the regex source generator.</summary>
public static partial class RegexPatterns
{
    [GeneratedRegex(@"\S+")]
    public static partial Regex Words();

    [GeneratedRegex(@"\s+")]
    public static partial Regex Whitespace();

    [GeneratedRegex(@"Version\s+\d+", RegexOptions.IgnoreCase)]
    public static partial Regex VersionLabel();

    [GeneratedRegex(@"^\s*[#\s]*\d+(?:\.\d+)?%\s*$")]
    public static partial Regex InstallerProgress();

    [GeneratedRegex(@"\x1b\[[0-?]*[ -/]*[@-~]")]
    public static partial Regex AnsiEscape();

    [GeneratedRegex(@"(?<![\d.])(\d+(?:\.\d+)?)%")]
    public static partial Regex DownloadPercentage();

    [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._:/@-]*$")]
    public static partial Regex ModelIdentifier();

    [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")]
    public static partial Regex OllamaModelIdentifier();
}
NEWSemaphoreExtensions.cs503 B / 16 lines+16-0+503 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;

public static class SemaphoreExtensions
{
    public static IDisposable EnterOrThrow(this SemaphoreSlim gate, string message)
    {
        if (!gate.Wait(0)) throw new InvalidOperationException(message);
        return new SemaphoreLease(gate);
    }

    public static async Task<IDisposable> EnterAsync(this SemaphoreSlim gate, CancellationToken ct = default)
    {
        await gate.WaitAsync(ct);
        return new SemaphoreLease(gate);
    }
}
NEWSemaphoreLease.cs237 B / 7 lines+7-0+237 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;

internal sealed class SemaphoreLease(SemaphoreSlim gate) : IDisposable
{
    private SemaphoreSlim? _gate = gate;
    public void Dispose() => Interlocked.Exchange(ref _gate, null)?.Release();
}
NEWTextReaderExtensions.cs401 B / 11 lines+11-0+401 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Extensions;

public static class TextReaderExtensions
{
    public static async Task<bool> ConfirmAsync(this TextReader input, TextWriter output, string prompt,
        CancellationToken ct = default)
    {
        await output.WriteAsync(prompt);
        return string.Equals((await input.ReadLineAsync(ct))?.Trim(), "y", StringComparison.OrdinalIgnoreCase);
    }
}

Lilith/Core/Kokoro 9 files

NEWAsyncResourceOwner.cs3,122 B / 81 lines+81-0+3,122 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Kokoro;

/// <summary>Bounds callers' waits while retaining ownership until native work actually ends.</summary>
internal sealed class AsyncResourceOwner(Action cleanup, TimeSpan timeout, TimeSpan shutdownTimeout)
{
    private readonly object _sync = new();
    private readonly CancellationTokenSource _lifetime = new();
    private readonly HashSet<Task> _work = [];
    private bool _closing;
    private bool _faulted;
    private Task? _dispose;

    public bool IsClosing { get { lock (_sync) return _closing; } }
    public void WhileOpen(Action action) { lock (_sync) { if (!_closing) action(); } }

    public Task RunAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
    {
        Task work;
        lock (_sync)
        {
            ObjectDisposedException.ThrowIf(_closing, this);
            if (_faulted) throw new InvalidOperationException("Speech engine timed out; restart the agent before retrying speech.");
            ct.ThrowIfCancellationRequested();
            work = Task.Run(async () =>
            {
                using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
                await action(linked.Token);
            });
            _work.Add(work);
        }
        _ = work.ContinueWith(completed =>
        {
            _ = completed.Exception; // Observe errors even if the caller stopped waiting.
            lock (_sync) _work.Remove(completed);
        }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
        return WaitAsync(work, ct);
    }

    private async Task WaitAsync(Task work, CancellationToken ct)
    {
        try { await work.WaitAsync(timeout, ct); }
        catch (TimeoutException)
        {
            lock (_sync)
            {
                _faulted = true;
                if (!_closing) _lifetime.Cancel();
            }
            throw new TimeoutException("Kokoro operation timed out. Native work still owns its resources until it finishes.");
        }
    }

    public Task DisposeAsync()
    {
        lock (_sync)
        {
            if (_dispose is not null) return _dispose;
            _closing = true;
            _lifetime.Cancel();
            Task[] pending = [.. _work];
            _dispose = DisposeCoreAsync(pending);
            return _dispose;
        }
    }

    private async Task DisposeCoreAsync(Task[] pending)
    {
        var cleanupTask = Task.Run(async () =>
        {
            try { await Task.WhenAll(pending); }
            catch { /* Completion, including failure, releases ownership. */ }
            finally { try { cleanup(); } finally { _lifetime.Dispose(); } }
        });
        // If native work is stuck, cleanup remains attached to it and never frees live resources.
        try { await cleanupTask.WaitAsync(shutdownTimeout); }
        catch (TimeoutException)
        {
            _ = cleanupTask.ContinueWith(task => _ = task.Exception, TaskScheduler.Default);
        }
    }
}
NEWKokoroModelFiles.cs1,241 B / 29 lines+29-0+1,241 B
C#UnchangedRemoved / replacedAdded
using Microsoft.ML.OnnxRuntime;

namespace Lilith.Core.Kokoro;

internal static class KokoroModelFiles
{
    public static async Task<bool> IsUsableAsync(string path)
    {
        if (!File.Exists(path) || new FileInfo(path).Length == 0) return false;
        try
        {
            return await Task.Run(() =>
            {
                using var session = new InferenceSession(path);
                return session.InputMetadata.ContainsKey("tokens") && session.InputMetadata.ContainsKey("style") &&
                    session.InputMetadata.ContainsKey("speed") && session.OutputMetadata.Count > 0;
            });
        }
        // ONNX invalid protobuf (7) or invalid graph (10) identifies model data damage.
        // Other runtime failures propagate: they must not quarantine a usable model.
        catch (OnnxRuntimeException ex) when (ex.Message.StartsWith("[ErrorCode:InvalidProtobuf]", StringComparison.Ordinal) ||
            ex.Message.StartsWith("[ErrorCode:InvalidGraph]", StringComparison.Ordinal)) { return false; }
    }

    public static void Quarantine(string path)
    {
        if (File.Exists(path)) File.Move(path, path + ".invalid-" + Guid.NewGuid().ToString("N"));
    }
}
NEWKokoroNarration.cs307 B / 10 lines+10-0+307 B
C#UnchangedRemoved / replacedAdded
using KokoroSharp.Processing;
using System.Text.RegularExpressions;
using KokoroSharp;
using KokoroSharp.Core;
using Lilith.Core.Logging;
using NAudio.Wave;

namespace Lilith.Core.Kokoro;

public sealed record KokoroNarration(byte[] Audio, KokoroWord[] Words, string TimingSource = "phoneme");
NEWKokoroRegex.cs1,641 B / 39 lines+39-0+1,641 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;

namespace Lilith.Core.Kokoro;

internal static partial class KokoroRegex
{
    [GeneratedRegex(@"(?:`{3,}|~{3,})[\s\S]*?(?:(?:`{3,}|~{3,})|\z)", RegexOptions.CultureInvariant)]
    public static partial Regex CodeFence();

    [GeneratedRegex(@"!?\[(?<label>[^\]]*)\]\((?:[^()\r\n]|\([^()\r\n]*\))*\)", RegexOptions.CultureInvariant)]
    public static partial Regex MarkdownLink();

    [GeneratedRegex(@"https?://[^\s<>]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    public static partial Regex WebUrl();

    [GeneratedRegex(@"(?<![\w\\])(?<mark>\*{1,3}|_{1,3})(?<value>\S(?:[^\r\n]*?\S)?)\k<mark>(?!\w)", RegexOptions.CultureInvariant)]
    public static partial Regex Emphasis();

    [GeneratedRegex(@"~~(?<value>[^\r\n]*?)~~", RegexOptions.CultureInvariant)]
    public static partial Regex StrikeThrough();

    [GeneratedRegex(@"^(?:[-*+]|\d+[.)])[ \t]+", RegexOptions.CultureInvariant)]
    public static partial Regex ListMarker();

    [GeneratedRegex(@"^#{1,6}[ \t]+", RegexOptions.CultureInvariant)]
    public static partial Regex HeadingMarker();

    [GeneratedRegex(@"^(?:>[ \t]*)+", RegexOptions.CultureInvariant)]
    public static partial Regex QuoteMarker();

    [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
    public static partial Regex Whitespace();

    [GeneratedRegex(@"([!?])\1+", RegexOptions.CultureInvariant)]
    public static partial Regex RepeatedPunctuation();

    [GeneratedRegex(@"[ \t]+([,.;:!?])", RegexOptions.CultureInvariant)]
    public static partial Regex SpaceBeforePunctuation();
}
NEWKokoroSetup.cs6,136 B / 117 lines+117-0+6,136 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Logging;

namespace Lilith.Core.Kokoro;

public static class KokoroSetup
{
    public const string ModelUrl = "https://github.com/Lyrcaxis/KokoroSharpBinaries/releases/download/v2.0.0/kokoro.onnx";

    public static async Task DownloadAsync(KokoroTtsOptions options, Logger logger,
        CancellationToken ct = default, HttpClient? client = null)
        => await DownloadCoreAsync(options, logger, TimeSpan.FromMinutes(30), ct, client);

    internal static async Task DownloadCoreAsync(KokoroTtsOptions options, Logger logger, TimeSpan timeout,
        CancellationToken ct = default, HttpClient? client = null)
    {
        ct.ThrowIfCancellationRequested();
        string path = options.ResolvedModelPath;
        if (await KokoroModelFiles.IsUsableAsync(path)) return;
        KokoroModelFiles.Quarantine(path);
        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        string temporary = path + "." + Guid.NewGuid().ToString("N") + ".partial";
        using var ownedClient = client is null ? new HttpClient { Timeout = TimeSpan.FromMinutes(30) } : null;
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
        deadline.CancelAfter(timeout);
        ct = deadline.Token;
        try
        {
            using var response = await (client ?? ownedClient!).GetAsync(ModelUrl, HttpCompletionOption.ResponseHeadersRead, ct);
            response.EnsureSuccessStatusCode();
            await using var source = await response.Content.ReadAsStreamAsync(ct);
            long total = response.Content.Headers.ContentLength ?? 0;
            long received = 0;
            await using (var destination = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
            {
                var buffer = new byte[81920];
                int count;
                while ((count = await source.ReadAsync(buffer, ct)) > 0)
                {
                    await destination.WriteAsync(buffer.AsMemory(0, count), ct);
                    received += count;
                    if (total > 0) logger.WriteProgress("TTS", received, total);
                    else logger.WriteProgress("TTS", $"Downloaded {received / 1048576.0:F1} MB");
                }
            }
            if (received == 0 || (total > 0 && received != total)) throw new InvalidDataException("Kokoro download is incomplete.");
            ct.ThrowIfCancellationRequested();
            File.Move(temporary, path, overwrite: true);
            logger.WriteLine("TTS", "Kokoro model download complete.");
        }
        finally { if (File.Exists(temporary)) File.Delete(temporary); }
    }

    /// <summary>Stores preferences before downloading, so an interrupted first boot can resume.</summary>
    public static async Task<KokoroTtsOptions> RunAsync(TextReader input, TextWriter output, Logger logger,
        string settingsPath, CancellationToken ct = default)
        => await RunCoreAsync(input, output, logger, settingsPath, KokoroModelFiles.IsUsableAsync, ct);

    internal static async Task<KokoroTtsOptions> RunCoreAsync(TextReader input, TextWriter output, Logger logger,
        string settingsPath, Func<string, Task<bool>> validateModel, CancellationToken ct = default)
    {
        // Automated or redirected sessions keep their existing input protocol and never download implicitly.
        string? enabled = Environment.GetEnvironmentVariable("LILITH_TTS");
        if (enabled is "0" or "false") return new();
        KokoroTtsOptions? options = null;
        if (File.Exists(settingsPath))
        {
            try { options = JsonSettings.Load<KokoroTtsOptions>(settingsPath, ValidOptions); }
            catch (JsonException) { logger.WriteWarning("TTS", "TTS settings are invalid; configure speech again."); }
        }
        if (options is null)
        {
            if (!await input.ConfirmAsync(output, "Enable Kokoro voice replies? [y/N]: ", ct)) options = new();
            else
            {
                await output.WriteAsync("Voice (af_heart, af_bella, am_adam, bf_emma; or custom ID) [af_heart]: ");
                string voice = (await input.ReadLineAsync(ct))?.Trim() ?? "";
                voice = voice.Length == 0 ? "af_heart" : voice;
                KokoroTts.ValidateVoice(voice);
                bool wait = await input.ConfirmAsync(output, "Wait for speech before accepting the next message? [y/N]: ", ct);
                options = new(true, voice, wait);
            }
            ct.ThrowIfCancellationRequested();
            JsonSettings.Save(settingsPath, options);
        }
        if (!options.Enabled) return options;
        KokoroTts.ValidateVoice(options.VoiceId);
        if (!await validateModel(options.ResolvedModelPath))
        {
            if (File.Exists(options.ResolvedModelPath))
            {
                KokoroModelFiles.Quarantine(options.ResolvedModelPath);
                logger.WriteWarning("TTS", "The Kokoro model is damaged or invalid; download it again to repair speech.");
            }
            if (!await input.ConfirmAsync(output, "Download Kokoro model (about 320 MB)? [y/N]: ", ct))
                return options with { Enabled = false };
            await logger.TimeStageAsync("Kokoro model download", () => DownloadAsync(options, logger, ct));
            if (!await validateModel(options.ResolvedModelPath))
            {
                KokoroModelFiles.Quarantine(options.ResolvedModelPath);
                throw new InvalidDataException("The downloaded Kokoro model is invalid. Relaunch setup to retry.");
            }
        }
        return options;
    }

    private static bool ValidOptions(KokoroTtsOptions options)
    {
        try
        {
            _ = options.ResolvedModelPath;
            KokoroTts.ValidateVoice(options.VoiceId);
            return true;
        }
        catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { return false; }
    }
}
NEWKokoroTts.cs10,975 B / 229 lines+229-0+10,975 B
C#UnchangedRemoved / replacedAdded
using KokoroSharp.Processing;
using System.Text.RegularExpressions;
using KokoroSharp;
using KokoroSharp.Core;
using Lilith.Core.Logging;
using NAudio.Wave;

namespace Lilith.Core.Kokoro;

/// <summary>Local synthesis and Windows playback with tracked native ownership.</summary>
public sealed class KokoroTts : IAsyncDisposable
{
    private readonly Logger _logger;
    private readonly KokoroTtsOptions _options;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly AsyncResourceOwner _owner;
    private KokoroWavSynthesizer? _model;
    private KokoroVoice? _voice;
    private static readonly object VoiceGate = new();

    public KokoroTts(Logger logger, KokoroTtsOptions? options = null)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _options = options ?? new();
        _owner = new AsyncResourceOwner(() => { _model?.Dispose(); _model = null; _voice = null; _gate.Dispose(); },
            TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(5));
    }

    public string VoiceId => _voice?.Name ?? _options.VoiceId;
    public bool WaitForTts => _options.WaitForTts;
    public bool IsReady => !_owner.IsClosing && _model is not null && _voice is not null;
    public static void ValidateVoice(string id) => _ = GetVoice(id);

    private static KokoroVoice GetVoice(string id)
    {
        if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Voice ID is required.", nameof(id));
        lock (VoiceGate)
        {
            try { return KokoroVoiceManager.GetVoice(id); }
            catch (InvalidOperationException ex) { throw new ArgumentException($"Unknown Kokoro voice '{id}'.", nameof(id), ex); }
        }
    }

    public static async Task<KokoroTts> CreateAsync(Logger logger, KokoroTtsOptions? options = null, CancellationToken ct = default)
    {
        var service = new KokoroTts(logger, options);
        try { await service.InitializeAsync(ct); return service; }
        catch { await service.DisposeAsync(); throw; }
    }

    public Task InitializeAsync(CancellationToken ct = default) => _owner.RunAsync(async token =>
    {
        using var operation = await _gate.EnterAsync(token);

        if (_model is not null) return;
        var voice = GetVoice(_options.VoiceId);
        string path = _options.ResolvedModelPath;
        if (!File.Exists(path)) throw new FileNotFoundException("Run Kokoro setup to download the speech model.", path);
        var model = await Task.Run(() => KokoroWavSynthesizer.LoadModel(path), CancellationToken.None);
        if (token.IsCancellationRequested) { model.Dispose(); token.ThrowIfCancellationRequested(); }
        _voice = voice;
        _model = model;
        Log("Kokoro ready (voice: " + VoiceId + ").", LogColors.Cyan);
    }, ct);

    public Task SetVoiceAsync(string id, CancellationToken ct = default) => _owner.RunAsync(async token =>
    {
        var voice = GetVoice(id);
        using var operation = await _gate.EnterAsync(token);
        _voice = voice;
    }, ct);

    public async Task SpeakAsync(string text, CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();
        string spoken = KokoroTtsSanitizer.Sanitize(text);
        if (spoken.Length == 0) return;
        if (!IsReady) await InitializeAsync(ct);
        Task monitored = ObserveSpeechAsync(_owner.RunAsync(token => SpeakCoreAsync(spoken, token), ct));
        if (WaitForTts) await monitored;
    }

    /// <summary>Returns the actual synthesized 24 kHz, 16-bit mono PCM for recording/export.</summary>
    public async Task<byte[]> SynthesizeAsync(string text, CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();
        string spoken = KokoroTtsSanitizer.Sanitize(text);
        if (spoken.Length == 0) return [];
        if (!IsReady) await InitializeAsync(ct);
        byte[] audio = [];
        await _owner.RunAsync(async token =>
        {
            using var operation = await _gate.EnterAsync(token);
            audio = await _model!.SynthesizeAsync(spoken, _voice!);
            token.ThrowIfCancellationRequested();
            if (audio.Length == 0) throw new InvalidDataException("Kokoro generated no audio.");
        }, ct);
        return audio;
    }

    /// <summary>Synthesizes once and aligns written words to the generated phoneme timestamps.</summary>
    public async Task<KokoroNarration> SynthesizeTimedAsync(string text, CancellationToken ct = default)
    {
        string spoken = KokoroTtsSanitizer.Sanitize(text);
        if (spoken.Length == 0) throw new InvalidDataException("Reply contains no speakable text.");
        if (!IsReady) await InitializeAsync(ct);
        KokoroNarration? narration = null;
        await _owner.RunAsync(async token =>
        {
            using var operation = await _gate.EnterAsync(token);
            var result = await _model!.SynthesizeWithTimestampsAsync(spoken, _voice!);
            token.ThrowIfCancellationRequested();
            if (result.AudioBytes.Length == 0) throw new InvalidDataException("Kokoro returned no speech audio.");
            if (result.Timestamps.Length == 0)
            {
                narration = new(result.AudioBytes, EstimateWordTimes(spoken, result.AudioBytes), "estimated");
                Log("This Kokoro model has no duration output; caption word timings are estimated.", LogColors.Orange);
                return;
            }
            List<KokoroWord> words = [];
            int previous = 0;
            foreach (Match word in RegexPatterns.Words().Matches(spoken))
            {
                while (previous < result.Timestamps.Length - 1 && result.Timestamps[previous].Phoneme is ' ' or '$') previous++;
                int first = Math.Clamp(previous, 0, result.Timestamps.Length - 1);
                string phonemes = word.Value.Any(char.IsLetterOrDigit)
                    ? Tokenizer.Phonemize(word.Value, _voice!.GetLangCode()) : word.Value;
                int available = Math.Min(result.Timestamps.Length - first, phonemes.Length + 6);
                int[] costs = [.. Enumerable.Range(0, available + 1)];
                for (int row = 1; row <= phonemes.Length; row++)
                {
                    int diagonal = costs[0]; costs[0] = row;
                    for (int column = 1; column <= available; column++)
                    {
                        int old = costs[column];
                        costs[column] = Math.Min(Math.Min(costs[column] + 1, costs[column - 1] + 1),
                            diagonal + (phonemes[row - 1] == result.Timestamps[first + column - 1].Phoneme ? 0 : 1));
                        diagonal = old;
                    }
                }
                int length = Enumerable.Range(1, available)
                    .OrderBy(n => costs[n] + Math.Abs(n - phonemes.Length) * .05).First();
                int end = first + length, last = end - 1;
                words.Add(new(word.Value, result.Timestamps[first].StartSecond, result.Timestamps[last].EndSecond));
                previous = end;
            }
            narration = new(result.AudioBytes, words.ToArray());
        }, ct);
        return narration!;
    }

    private static KokoroWord[] EstimateWordTimes(string spoken, byte[] pcm)
    {
        // Map word-length weights onto voiced 10 ms windows, skipping actual silence.
        List<double> windows = [];
        const int samplesPerWindow = 240;
        for (int offset = 0; offset < pcm.Length; offset += samplesPerWindow * 2)
        {
            double energy = 0; int count = 0;
            for (int index = offset; index + 1 < Math.Min(pcm.Length, offset + samplesPerWindow * 2); index += 2)
            {
                double sample = (short)(pcm[index] | pcm[index + 1] << 8) / 32768.0;
                energy += sample * sample; count++;
            }
            if (count > 0 && Math.Sqrt(energy / count) > .004) windows.Add(offset / 48000.0);
        }
        if (windows.Count == 0) throw new InvalidDataException("Kokoro speech audio is silent.");
        var matches = RegexPatterns.Words().Matches(spoken).Cast<Match>().ToArray();
        var weights = matches.Select(m => Math.Max(1, m.Value.Count(char.IsLetterOrDigit))).ToArray();
        double total = weights.Sum(), used = 0;
        List<KokoroWord> words = [];
        for (int i = 0; i < matches.Length; i++)
        {
            int first = Math.Min(windows.Count - 1, (int)(used / total * windows.Count));
            used += weights[i];
            int last = Math.Min(windows.Count - 1, Math.Max(first, (int)(used / total * windows.Count) - 1));
            words.Add(new(matches[i].Value, windows[first], windows[last] + .01));
        }
        return words.ToArray();
    }

    private async Task ObserveSpeechAsync(Task speech)
    {
        try { await speech; }
        catch (OperationCanceledException) { Log("Speech canceled.", LogColors.Orange); }
        catch (Exception ex) { Log($"Kokoro speech failed: {ex.Message}", LogColors.Red); }
    }

    private async Task SpeakCoreAsync(string text, CancellationToken token)
    {
        using var operation = await _gate.EnterAsync(token);

        Progress("Generating speech...");
        // Cancellation/deadlines bound the caller, but this work keeps ownership until native completion.
        byte[] audio = await _model!.SynthesizeAsync(text, _voice!);
        token.ThrowIfCancellationRequested();
        if (audio.Length == 0) throw new InvalidDataException("Kokoro generated no audio.");
        using var stream = new RawSourceWaveStream(audio, 0, audio.Length, new WaveFormat(24000, 16, 1));
        using var player = new WaveOutEvent();
        var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        void Stopped(object? sender, StoppedEventArgs e)
        {
            if (e.Exception is not null) complete.TrySetException(e.Exception);
            else complete.TrySetResult();
        }
        player.PlaybackStopped += Stopped;
        try
        {
            player.Init(stream);
            player.Play();
            Progress("Speaking...");
            await complete.Task.WaitAsync(TimeSpan.FromSeconds(audio.Length / 48000.0 + 10), token);
            Log("Speech complete.");
        }
        finally { player.Stop(); player.PlaybackStopped -= Stopped; }
    }

    private void Log(string text, string color = LogColors.White)
    {
        _owner.WhileOpen(() => _logger.WriteLine("TTS", text, color));
    }

    private void Progress(string text)
    {
        _owner.WhileOpen(() => _logger.WriteProgress("TTS", text));
    }

    public async ValueTask DisposeAsync() => await _owner.DisposeAsync();
}
NEWKokoroTtsOptions.cs465 B / 14 lines+14-0+465 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;

namespace Lilith.Core.Kokoro;

public sealed record KokoroTtsOptions(
    bool Enabled = false,
    string VoiceId = "af_heart",
    bool WaitForTts = false,
    string? ModelPath = null)
{
    [JsonIgnore]
    public string ResolvedModelPath => Path.GetFullPath(ModelPath ?? Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "Kokoro", "kokoro.onnx"));
}
NEWKokoroTtsSanitizer.cs3,774 B / 68 lines+68-0+3,774 B
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text;

namespace Lilith.Core.Kokoro;

/// <summary>Conservative speech cleanup; leaves pronunciation of numbers/abbreviations to the phonemizer.</summary>
public static class KokoroTtsSanitizer
{
    public static string Sanitize(string text)
    {
        ArgumentNullException.ThrowIfNull(text);
        // Remove fenced code before interpreting its contents as prose, including an unclosed fence.
        text = KokoroRegex.CodeFence().Replace(text, " ");
        text = KokoroRegex.MarkdownLink().Replace(text, "${label}");
        text = KokoroRegex.WebUrl().Replace(text, match =>
        {
            // Discard the address but retain punctuation ending the surrounding sentence.
            string url = match.Value.TrimEnd(')', ']', '}', '"', '\'');
            int end = url.Length;
            while (end > 0 && ".!?;:".Contains(url[end - 1])) end--;
            return " " + url[end..];
        });
        for (int pass = 0; pass < 3; pass++) text = KokoroRegex.Emphasis().Replace(text, "${value}");
        text = KokoroRegex.StrikeThrough().Replace(text, "${value}").Replace("`", "");

        // Misaki can split surrogate pairs before normalization; spoken input excludes pictographs.
        // Grapheme rendering and the original chat/history text remain untouched.
        var spoken = new StringBuilder(text.Length);
        foreach (var rune in text.EnumerateRunes())
        {
            if (rune.Value is 0x200B or 0x2060 or 0xFEFF or 0x00AD) continue;
            bool unsupported = rune.Utf16SequenceLength == 2 || Rune.GetUnicodeCategory(rune) == UnicodeCategory.OtherSymbol ||
                rune.Value is 0x200D or 0xFE0E or 0xFE0F or 0xFFFD ||
                (Rune.GetUnicodeCategory(rune) == UnicodeCategory.Control && rune.Value is not (9 or 10 or 13));
            spoken.Append(unsupported ? " " : rune.ToString());
        }
        text = spoken.ToString().Normalize(NormalizationForm.FormC).Replace("\r\n", "\n").Replace('\r', '\n');
        var result = new StringBuilder(text.Length);
        bool paragraphBreak = false, previousList = false;
        foreach (string raw in text.Split('\n'))
        {
            string line = raw.Trim();
            if (line.Length == 0) { paragraphBreak = result.Length > 0; continue; }
            bool listItem = KokoroRegex.ListMarker().IsMatch(line);
            bool heading = KokoroRegex.HeadingMarker().IsMatch(line);
            line = KokoroRegex.ListMarker().Replace(line, "");
            line = KokoroRegex.HeadingMarker().Replace(line, "");
            line = KokoroRegex.QuoteMarker().Replace(line, "");
            line = KokoroRegex.Whitespace().Replace(line, " ").Trim();
            if (line.Length == 0) continue;
            if (result.Length > 0)
            {
                // Wrapped prose is joined without new pauses; structural boundaries retain a sentence break.
                int lastIndex = result.Length - 1;
                while (lastIndex > 0 && " )]\"'".Contains(result[lastIndex])) lastIndex--;
                char last = result[lastIndex];
                if ((paragraphBreak || listItem || heading || (previousList && !char.IsWhiteSpace(raw[0]))) && !".!?;:…".Contains(last)) result.Append('.');
                result.Append(' ');
            }
            result.Append(line);
            paragraphBreak = false;
            previousList = listItem || (previousList && char.IsWhiteSpace(raw[0]));
        }
        text = KokoroRegex.Whitespace().Replace(result.ToString(), " ").Trim();
        text = KokoroRegex.RepeatedPunctuation().Replace(text, "$1");
        return KokoroRegex.SpaceBeforePunctuation().Replace(text, "$1");
    }
}
NEWKokoroWord.cs274 B / 10 lines+10-0+274 B
C#UnchangedRemoved / replacedAdded
using KokoroSharp.Processing;
using System.Text.RegularExpressions;
using KokoroSharp;
using KokoroSharp.Core;
using Lilith.Core.Logging;
using NAudio.Wave;

namespace Lilith.Core.Kokoro;

public sealed record KokoroWord(string Text, double Start, double End);

Lilith/Core/LMStudio 3 files

NEWLMStudioClient.cs3,791 B / 68 lines+68-0+3,791 B
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Headers;
using System.Text.Json;

namespace Lilith.Core.LMStudio;

public sealed class LMStudioClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null)
    : ProviderClient(logger, historyStore, httpClient)
{
    public string BaseUrl { get; init; } = LMStudioSetup.BaseUrl;

    protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
    {
        if (messages.Any(m => m.Images?.Count > 0))
            throw new NotSupportedException("LM Studio image input is not implemented in this interface.");
        using var request = HttpExtensions.JsonRequest(BaseUrl + "/v1/chat/completions", new
        {
            model = Model,
            stream = false,
            messages = messages.Select(m => new { role = m.Role, content = m.Content })
        });
        LMStudioSetup.Authorize(request);
        using var json = await Http.SendJsonDocumentAsync(request);
        var choice = json.RootElement.GetProperty("choices")[0];
        // A length limit/tool-call response is not a complete text turn.
        if (choice.GetProperty("finish_reason").GetString() != "stop")
            throw new InvalidDataException("LM Studio did not return a complete text reply.");
        var message = choice.GetProperty("message");
        if (message.GetProperty("role").GetString() != "assistant" || message.GetProperty("content").ValueKind != JsonValueKind.String)
            throw new InvalidDataException("LM Studio returned an invalid assistant message.");
        int? prompt = null, completion = null;
        if (json.RootElement.TryGetProperty("usage", out var usage))
        {
            if (usage.TryGetProperty("prompt_tokens", out var p)) prompt = p.GetInt32();
            if (usage.TryGetProperty("completion_tokens", out var c)) completion = c.GetInt32();
        }
        return new ChatChunk(new ChatMessage("assistant", message.GetProperty("content").GetString()!), true, prompt, completion);
    }

    public static async Task<LMStudioClient> CreateClient(string model, string systemPrompt, Logger logger,
        bool startServerIfNotRunning, string agentName = "Lilith", ChatHistoryStore? historyStore = null)
    {
        await LMStudioSetup.EnsureServerAsync(logger, startServerIfNotRunning);
        var client = new LMStudioClient(logger, historyStore) { Model = model, SystemPrompt = systemPrompt, AgentName = agentName };
        try
        {
            var models = await LMStudioSetup.ListModelsAsync();
            var selected = models.FirstOrDefault(m => m.Id == model)
                ?? throw new InvalidOperationException($"LM Studio model '{model}' is not downloaded. Run setup first.");
            if (selected.InstanceId is not null && selected.ContextLength is > 0)
            {
                client.Model = selected.InstanceId;
                client.NumCtx = selected.ContextLength.Value;
            }
            else
            {
                using var request = HttpExtensions.JsonRequest(client.BaseUrl + "/api/v1/models/load", new { model, echo_load_config = true });
                LMStudioSetup.Authorize(request);
                using var json = await client.Http.SendJsonDocumentAsync(request);
                client.Model = json.RootElement.GetProperty("instance_id").GetString()!;
                client.NumCtx = json.RootElement.GetProperty("load_config").GetProperty("context_length").GetInt32();
            }
            if (client.NumCtx <= 0) throw new InvalidDataException("LM Studio reported an invalid context window.");
            client.InitializeConversation();
            return client;
        }
        catch { client.Dispose(); throw; }
    }
}
NEWLMStudioModel.cs258 B / 7 lines+7-0+258 B
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;

namespace Lilith.Core.LMStudio;

public record LMStudioModel(string Id, string Name, string? InstanceId = null, int? ContextLength = null, int LoadedInstanceCount = 0);
NEWLMStudioSetup.cs10,505 B / 165 lines+165-0+10,505 B
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text.Json;

namespace Lilith.Core.LMStudio;

public static class LMStudioSetup
{
    public static string BaseUrl => (Environment.GetEnvironmentVariable("LILITH_LMSTUDIO_URL") ?? "http://127.0.0.1:1234").TrimEnd('/');
    public static void Authorize(HttpRequestMessage request)
    {
        string? token = Environment.GetEnvironmentVariable("LMSTUDIO_API_TOKEN");
        if (!string.IsNullOrWhiteSpace(token)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    }

    public static string? GuiPath => new[]
    {
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "LM Studio", "LM Studio.exe"),
        Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LM Studio", "LM Studio.exe")
    }.FirstOrDefault(File.Exists);

    private static string? CliPath
    {
        get
        {
            string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
            string pointer = Path.Combine(home, ".lmstudio-home-pointer");
            string studioHome = File.Exists(pointer) ? File.ReadAllText(pointer).Trim() : Path.Combine(home, ".lmstudio");
            return new[] { Path.Combine(studioHome, "bin", "lms.exe"),
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "lm-studio", "bin", "lms.exe") }
                .FirstOrDefault(File.Exists);
        }
    }

    public static bool IsInstalled() => GuiPath is not null || CliPath is not null;

    public static async Task InstallGuiAsync(Logger logger)
    {
        logger.WriteLine("LM Studio", "Downloading the official Windows GUI installer…", LogColors.Cyan);
        string installer = Path.Combine(Path.GetTempPath(), "LM-Studio-" + Guid.NewGuid().ToString("N") + ".exe");
        string verifier = installer + ".verify.ps1";
        try
        {
            using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(15) };
            using var downloadDeadline = new CancellationTokenSource(TimeSpan.FromMinutes(15));
            string arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "arm64" : "x64";
            using var response = await http.GetAsync($"https://lmstudio.ai/download/latest/win32/{arch}", HttpCompletionOption.ResponseHeadersRead, downloadDeadline.Token);
            response.EnsureSuccessStatusCode();
            await using (var file = File.Create(installer))
            await using (var source = await response.Content.ReadAsStreamAsync(downloadDeadline.Token))
            {
                long total = response.Content.Headers.ContentLength ?? 0, received = 0;
                var buffer = new byte[81920];
                int count;
                while ((count = await source.ReadAsync(buffer, downloadDeadline.Token)) > 0)
                {
                    await file.WriteAsync(buffer.AsMemory(0, count), downloadDeadline.Token);
                    received += count;
                    if (total > 0) logger.WriteProgress("LM Studio", received, total);
                }
                if (received == 0 || (total > 0 && received != total)) throw new InvalidDataException("LM Studio installer download is incomplete.");
            }
            await File.WriteAllTextAsync(verifier, "param([string]$Installer)\n$ErrorActionPreference = 'Stop'\nif ((Get-AuthenticodeSignature -LiteralPath $Installer).Status -ne 'Valid') { throw 'LM Studio installer signature is invalid.' }\n");
            await RunProcessAsync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", verifier, "-Installer", installer], logger, TimeSpan.FromSeconds(30));
            await RunProcessAsync(installer, ["/S"], logger, TimeSpan.FromMinutes(15));
            if (GuiPath is null) throw new InvalidOperationException("LM Studio GUI was not found after installation. Install it from lmstudio.ai/download and retry.");
        }
        finally
        {
            if (File.Exists(installer)) File.Delete(installer);
            if (File.Exists(verifier)) File.Delete(verifier);
        }
    }

    public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
    {
        if (await ProbeAsync()) return;
        if (!startIfMissing) throw new InvalidOperationException("LM Studio server is not ready. Enable Local Server in its Developer tab.");
        if (GuiPath is { } gui)
        {
            var start = new ProcessStartInfo(gui) { UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden };
            start.Environment.Remove("ELECTRON_RUN_AS_NODE");
            Process.Start(start)?.Dispose();
        }
        // The GUI initializes its bundled CLI on first launch.
        for (int i = 0; i < 20 && CliPath is null; i++) await Task.Delay(500);
        if (CliPath is not { } cli) throw new InvalidOperationException("Open LM Studio once and enable Developer → Local Server, then retry. The lms CLI is not available yet.");
        if (!Uri.TryCreate(BaseUrl, UriKind.Absolute, out var uri) || !uri.IsLoopback)
            throw new InvalidOperationException("Automatic LM Studio startup only supports a local endpoint.");
        try
        {
            await RunProcessAsync(cli, ["server", "start", "--port", uri.Port.ToString(), "--bind", "127.0.0.1"], logger, TimeSpan.FromSeconds(60));
        }
        catch (Exception ex) when (ex is InvalidOperationException or TimeoutException)
        {
            throw new InvalidOperationException("LM Studio could not start its local service. Open the GUI, complete first-launch setup, and enable Developer → Local Server, then retry.", ex);
        }
        for (int i = 0; i < 30; i++) { if (await ProbeAsync()) return; await Task.Delay(500); }
        throw new TimeoutException("LM Studio API did not become ready. Check its Developer tab and API token settings.");
    }

    private static async Task<bool> ProbeAsync()
    {
        try { await ListModelsAsync(TimeSpan.FromSeconds(2)); return true; }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { return false; }
    }

    public static async Task<IReadOnlyList<LMStudioModel>> ListModelsAsync(TimeSpan? timeout = null)
    {
        using var http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(15) };
        using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + "/api/v1/models");
        Authorize(request);
        using var json = await http.SendJsonDocumentAsync(request);
        return json.RootElement.GetProperty("models").EnumerateArray()
            .Where(m => m.GetProperty("type").GetString() == "llm")
            .Select(m =>
            {
                var instances = m.GetProperty("loaded_instances");
                JsonElement? loaded = instances.GetArrayLength() > 0 ? instances[0] : null;
                return new LMStudioModel(m.GetProperty("key").GetString()!, m.GetProperty("display_name").GetString()!,
                    loaded?.GetProperty("id").GetString(), loaded?.GetProperty("config").GetProperty("context_length").GetInt32(), instances.GetArrayLength());
            }).ToList();
    }

    public static async Task DownloadModelAsync(string model, Logger logger)
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
        using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(40));
        async Task<JsonDocument> GetStatusAsync(string? job = null)
        {
            using var request = job is null ? HttpExtensions.JsonRequest(BaseUrl + "/api/v1/models/download", new { model })
                : new HttpRequestMessage(HttpMethod.Get, BaseUrl + "/api/v1/models/download/status/" + Uri.EscapeDataString(job));
            Authorize(request);
            return await http.SendJsonDocumentAsync(request, deadline.Token);
        }
        using var first = await GetStatusAsync();
        string? job = first.RootElement.TryGetProperty("job_id", out var id) ? id.GetString() : null;
        string? status = first.RootElement.GetProperty("status").GetString();
        if (status is "completed" or "already_downloaded") return;
        if (status != "downloading" || job is null) throw new InvalidOperationException($"LM Studio download returned {status}.");
        while (true)
        {
            await Task.Delay(750, deadline.Token);
            using var current = await GetStatusAsync(job);
            status = current.RootElement.GetProperty("status").GetString();
            if (status == "completed") { logger.WriteLine("LM Studio", "Model download completed.", LogColors.Green); return; }
            if (status is "failed" or "paused") throw new InvalidOperationException($"LM Studio download is {status}. Resume it in LM Studio and retry setup.");
            if (current.RootElement.TryGetProperty("downloaded_bytes", out var bytes) && current.RootElement.TryGetProperty("total_size_bytes", out var total))
                logger.WriteProgress("LM Studio", bytes.GetInt64(), total.GetInt64());
        }
    }

    private static async Task RunProcessAsync(string executable, string[] arguments, Logger logger, TimeSpan timeout)
    {
        var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
        start.Environment.Remove("ELECTRON_RUN_AS_NODE");
        foreach (string argument in arguments) start.ArgumentList.Add(argument);
        using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start LM Studio setup.");
        async Task DrainAsync(StreamReader reader) { while (await reader.ReadLineAsync() is { } line) logger.WriteLine("LM Studio", line, LogColors.Gray); }
        Task output = DrainAsync(process.StandardOutput), error = DrainAsync(process.StandardError);
        try { await process.WaitForExitOrKillAsync(timeout, "LM Studio setup process timed out."); }
        finally { await Task.WhenAll(output, error); }
        if (process.ExitCode != 0) throw new InvalidOperationException($"LM Studio setup exited with code {process.ExitCode}.");
    }
}

Lilith/Core/Logging 8 files

NEWLogColors.cs377 B / 12 lines+12-0+377 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

public static class LogColors
{
    public const string White = "#FFFFFF";
    public const string Green = "#00FF00";
    public const string Yellow = "#FFFF00";
    public const string Cyan = "#00FFFF";
    public const string Orange = "#FF8800";
    public const string Red = "#FF4444";
    public const string Gray = "#808080";
}
NEWLogEntry.cs348 B / 14 lines+14-0+348 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

public record LogEntry(
    string Sender,
    string Description,
    string Color,
    DateTime Timestamp,
    LogType Type)
{
    public long? ProgressCurrent { get; init; }
    public long? ProgressTotal { get; init; }

    public string FormattedTimestamp => Timestamp.ToString("HH:mm:ss.fff");
}
NEWLogType.cs104 B / 8 lines+8-0+104 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

public enum LogType
{
    Write,
    WriteLine,
    Progress,
}
NEWLogTypeExtensions.cs978 B / 42 lines+42-0+978 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

public static class LogTypeExtensions
{
    /// <summary>
    /// Two-action overload. Progress falls through to onWrite (inline, no newline).
    /// </summary>
    public static void Handle(this LogType type, Action onWrite, Action onWriteLine)
    {
        if (type == LogType.Write || type == LogType.Progress)
        {
            onWrite();
        }
        else
        {
            onWriteLine();
        }
    }

    /// <summary>
    /// Three-action overload with explicit progress handling.
    /// </summary>
    public static void Handle(
        this LogType type,
        Action onWrite,
        Action onWriteLine,
        Action onProgress)
    {
        if (type == LogType.Write)
        {
            onWrite();
        }
        else if (type == LogType.Progress)
        {
            onProgress();
        }
        else
        {
            onWriteLine();
        }
    }
}
NEWLogger.cs5,607 B / 178 lines+178-0+5,607 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

/// <summary>
/// Central logging hub. Subscribers receive every <see cref="LogEntry"/> via
/// <see cref="OnLog"/> or <see cref="Subscribe"/>.
/// </summary>
public class Logger(Action<LogEntry>? onLog = null)
{
    private readonly object _sync = new();
    private readonly List<Action<LogEntry>> _subscribers = onLog is null
        ? []
        : [onLog];

    private static Logger? _default;

    /// <summary>Shared logger for apps that want a single global instance.</summary>
    public static Logger Default => _default ??= new();

    /// <summary>Configure the shared <see cref="Default"/> logger (optional initial handler).</summary>
    public static Logger ConfigureDefault(Action<LogEntry>? onLog = null)
    {
        _default = new Logger(onLog);
        return _default;
    }

    /// <summary>
    /// Fired for every log entry. Use <c>+=</c> or <see cref="Subscribe"/>.
    /// </summary>
    public event Action<LogEntry>? OnLog
    {
        add
        {
            if (value == null)
            {
                return;
            }

            lock (_sync)
            {
                _subscribers.Add(value);
            }
        }
        remove
        {
            if (value == null)
            {
                return;
            }

            lock (_sync)
            {
                _subscribers.Remove(value);
            }
        }
    }

    /// <summary>Number of active subscribers.</summary>
    public int SubscriberCount
    {
        get
        {
            lock (_sync)
            {
                return _subscribers.Count;
            }
        }
    }

    /// <summary>
    /// The most recent log entry that used <see cref="LogType.Write"/> (same-line).
    /// Cleared when a <see cref="WriteLine"/> closes the line.
    /// </summary>
    public LogEntry? CurrentLog { get; private set; }

    /// <summary>Subscribe to all log output. Returns a disposable that unsubscribes.</summary>
    public IDisposable Subscribe(Action<LogEntry> handler)
    {
        ArgumentNullException.ThrowIfNull(handler);
        OnLog += handler;
        return new LoggerSubscription(this, handler);
    }

    /// <summary>Remove a subscriber added via <see cref="Subscribe"/> or <c>OnLog +=</c>.</summary>
    public void Unsubscribe(Action<LogEntry> handler)
    {
        if (handler == null)
        {
            return;
        }

        OnLog -= handler;
    }

    /// <summary>Remove every subscriber.</summary>
    public void ClearSubscribers()
    {
        lock (_sync)
        {
            _subscribers.Clear();
        }
    }

    public void Write(string sender, string description, string color = LogColors.White) =>
        Publish(CurrentLog = new LogEntry(sender, description, color, DateTime.Now, LogType.Write));

    public void WriteLine(string sender, string description = "", string color = LogColors.White)
    {
        var entry = new LogEntry(sender, description, color, DateTime.Now, LogType.WriteLine);
        CurrentLog = null;
        Publish(entry);
    }

    public void WriteInfo(string sender, string description) =>
        WriteLine(sender, description, LogColors.Cyan);

    public void WriteWarning(string sender, string description) =>
        WriteLine(sender, description, LogColors.Orange);

    public void WriteError(string sender, string description) =>
        WriteLine(sender, description, LogColors.Red);

    public void WriteException(string sender, Exception ex, string color = LogColors.Red)
    {
        WriteLine(sender, ex.Message, color);
        if (!string.IsNullOrWhiteSpace(ex.StackTrace))
        {
            WriteLine(sender, ex.StackTrace, LogColors.Gray);
        }
    }

    /// <summary>Overwrite-in-place line (progress / status).</summary>
    public void WriteSingleLine(string sender, string description, string color = LogColors.Green) =>
        Publish(new LogEntry(sender, description, color, DateTime.Now, LogType.Progress));

    public void WriteProgress(string sender, string description, string color = LogColors.Green) =>
        WriteSingleLine(sender, description, color);

    /// <summary>
    /// Progress bar (20 blocks): [██████░░░░░░░░░░░░░░]  30% (30 MB / 100 MB)
    /// </summary>
    public void WriteProgress(string sender, long current, long total, string color = LogColors.Green)
    {
        var percent = total > 0 ? (int)(current * 100L / total) : 0;
        var filled = Math.Clamp(percent / 5, 0, 20);
        var bar = new string('\u2588', filled) + new string('\u2591', 20 - filled);
        var size = total > 0
            ? $" ({current / 1_048_576} MB / {total / 1_048_576} MB)"
            : "";
        var description = $"[{bar}] {percent,3}%{size}";

        Publish(new LogEntry(sender, description, color, DateTime.Now, LogType.Progress)
        {
            ProgressCurrent = current,
            ProgressTotal = total,
        });
    }

    private void Publish(LogEntry entry)
    {
        Action<LogEntry>[] snapshot;
        lock (_sync)
        {
            snapshot = _subscribers.ToArray();
        }

        foreach (var handler in snapshot)
        {
            try
            {
                handler(entry);
            }
            catch
            {
                // Subscribers must not break the logging pipeline.
            }
        }
    }
}
NEWLoggerSubscription.cs349 B / 16 lines+16-0+349 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

internal sealed class LoggerSubscription(Logger logger, Action<LogEntry> handler) : IDisposable
{
    private int _disposed;

    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) == 1)
        {
            return;
        }

        logger.Unsubscribe(handler);
    }
}
NEWTextLogInputLease.cs232 B / 7 lines+7-0+232 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

internal sealed class TextLogInputLease(Action complete) : IDisposable
{
    private int _disposed;
    public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) complete(); }
}
NEWTextLogSink.cs2,910 B / 86 lines+86-0+2,910 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

/// <summary>Renders log types consistently; files retain the last update of each progress phase.</summary>
public sealed class TextLogSink(TextWriter writer, bool overwriteProgress = false, Func<int>? width = null)
{
    private readonly object _sync = new();
    private string? _progress;
    private int _drawnLength;
    private bool _inputActive;
    private readonly List<LogEntry> _deferred = [];

    /// <summary>Defer background output until the current console input has finished.</summary>
    public IDisposable BeginInput()
    {
        lock (_sync)
        {
            if (_inputActive) throw new InvalidOperationException("Console input is already active.");
            FinishProgress();
            _inputActive = true;
            return new TextLogInputLease(EndInput);
        }
    }

    public void Handle(LogEntry entry)
    {
        lock (_sync)
        {
            if (_inputActive && !(entry.Sender == "Input" && entry.Type == LogType.Write))
            {
                if (entry.Type == LogType.Progress && _deferred.Count > 0 &&
                    _deferred[^1].Type == LogType.Progress && _deferred[^1].Sender == entry.Sender)
                    _deferred[^1] = entry;
                else _deferred.Add(entry);
                return;
            }
            if (entry.Type == LogType.Progress)
            {
                _progress = entry.Description;
                if (overwriteProgress)
                {
                    int available = Math.Max(1, width?.Invoke() ?? 120);
                    string text = entry.Description.Replace('\r', ' ').Replace('\n', ' ');
                    if (text.Length > available) text = text[..available];
                    writer.Write("\r" + text.PadRight(Math.Min(available, Math.Max(_drawnLength, text.Length))));
                    _drawnLength = text.Length;
                    writer.Flush();
                }
                return;
            }

            FinishProgress();
            if (entry.Type == LogType.Write) writer.Write(entry.Description);
            else writer.WriteLine(entry.Description);
            writer.Flush();
        }
    }

    public void Flush()
    {
        lock (_sync)
        {
            FinishProgress();
            writer.Flush();
        }
    }

    private void FinishProgress()
    {
        if (_progress is null) return;
        if (overwriteProgress) writer.WriteLine();
        else writer.WriteLine(_progress);
        _progress = null;
        _drawnLength = 0;
    }

    private void EndInput()
    {
        lock (_sync)
        {
            _inputActive = false;
            if (_deferred.Count > 0) writer.WriteLine();
            foreach (var entry in _deferred) Handle(entry);
            _deferred.Clear();
        }
    }
}

Lilith/Core/Ollama 4 files

NEWOllamaCliProgressTracker.cs2,662 B / 93 lines+93-0+2,662 B
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Lilith.Core.Ollama;

/// <summary>
/// Reads <c>ollama pull</c> stdout/stderr and exposes the latest status for single-line logging.
/// </summary>
internal sealed class OllamaCliProgressTracker(string model)
{
    private readonly object _sync = new();
    private string _lastStatus = "starting…";
    private int? _percent;

    public string Model { get; } = model;

    public void Update(string raw)
    {
        string line = StripAnsi(raw.Trim());
        if (string.IsNullOrWhiteSpace(line))
        {
            return;
        }

        lock (_sync)
        {
            if (TryParsePullJson(line, out string? status, out int? percent))
            {
                _lastStatus = status ?? line;
                if (percent.HasValue)
                {
                    _percent = percent;
                }

                return;
            }

            _lastStatus = line;
        }
    }

    public string GetDisplayMessage(TimeSpan elapsed, TimeSpan remaining)
    {
        lock (_sync)
        {
            if (_percent is { } pct)
            {
                return $"Pulling '{Model}': {_lastStatus} ({pct}%) — {elapsed:mm\\:ss} elapsed, {remaining:mm\\:ss} left";
            }

            return $"Pulling '{Model}': {_lastStatus} — {elapsed:mm\\:ss} elapsed, {remaining:mm\\:ss} left";
        }
    }

    private static bool TryParsePullJson(string line, out string? status, out int? percent)
    {
        status = null;
        percent = null;
        if (!line.StartsWith('{'))
        {
            return false;
        }

        try
        {
            using JsonDocument doc = JsonDocument.Parse(line);
            JsonElement root = doc.RootElement;
            if (root.TryGetProperty("status", out JsonElement statusEl))
            {
                status = statusEl.GetString();
            }

            if (root.TryGetProperty("completed", out JsonElement doneEl) &&
                root.TryGetProperty("total", out JsonElement totalEl) &&
                totalEl.TryGetInt64(out long total) &&
                total > 0 &&
                doneEl.TryGetInt64(out long done))
            {
                percent = (int)(done * 100 / total);
            }

            return status is not null || percent.HasValue;
        }
        catch
        {
            return false;
        }
    }

    private static string StripAnsi(string text) =>
        RegexPatterns.AnsiEscape().Replace(text, string.Empty);
}
NEWOllamaCliStreamReader.cs1,273 B / 49 lines+49-0+1,273 B
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Lilith.Core.Ollama;

internal static class OllamaCliStreamReader
{
    public static async Task PumpAsync(TextReader reader, OllamaCliProgressTracker tracker, CancellationToken cancellationToken = default)
    {
        var buffer = new char[512];
        var segment = new StringBuilder();

        while (!cancellationToken.IsCancellationRequested)
        {
            int read = await reader.ReadAsync(buffer, cancellationToken);
            if (read <= 0)
            {
                break;
            }

            for (int i = 0; i < read; i++)
            {
                char c = buffer[i];
                if (c is '\r' or '\n')
                {
                    Flush(segment, tracker);
                }
                else
                {
                    segment.Append(c);
                }
            }
        }

        Flush(segment, tracker);
    }

    private static void Flush(StringBuilder segment, OllamaCliProgressTracker tracker)
    {
        if (segment.Length == 0)
        {
            return;
        }

        tracker.Update(segment.ToString());
        segment.Clear();
    }
}
NEWOllamaClient.Extensions.cs357 B / 11 lines+11-0+357 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Ollama;

public static class OllamaClientExtensions
{
    /// <summary>Throws if the client has not been initialized.</summary>
    public static void IsNull(this ProviderClient? client)
    {
        if (client is null)
            throw new InvalidOperationException("Model provider client was not initialized.");
    }
}
NEWOllamaClient.cs13,168 B / 324 lines+324-0+13,168 B
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;

namespace Lilith.Core.Ollama;

public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : ProviderClient(logger, historyStore, httpClient)
{
    private const string StatusBlue = "#AAAAFF";
    private const string StatusGray = "#AAAAAA";
    private bool _contextKnown;

    public string BaseUrl { get; } = "http://localhost:11434";
    public string ChatApiUrl => $"{BaseUrl}/api/chat";
    public string ShowApiUrl => $"{BaseUrl}/api/show";
    protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
    {
        var request = new ChatRequest(Model, messages, Stream: false, Options: _contextKnown ? new ChatOptions(NumCtx) : null);
        using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request);
        return await Http.SendJsonAsync<ChatChunk>(httpReq);
    }
    /// <summary>Connects to a prepared model; never installs Ollama or downloads a model.</summary>
    public static async Task<OllamaClient> CreateClient(
        string model,
        string systemPrompt,
        Logger logger,
        bool startOllamaIfNotRunning,
        string agentName = "Lilith", ChatHistoryStore? historyStore = null)
    {
        var client = new OllamaClient(logger, historyStore)
        {
            Model = model,
            SystemPrompt = systemPrompt,
            AgentName = agentName
        };
        client.InitializeConversation();

        client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue);

        try
        {
            await logger.TimeStageAsync("Server readiness", () => EnsureServerAsync(logger, startOllamaIfNotRunning));
            bool available = await logger.TimeStageAsync("Model readiness", () => IsModelAvailableAsync(model));
            if (!available) throw new InvalidOperationException($"Model '{model}' is not installed. Run console setup to choose and download a model.");
            client.NumCtx = await logger.TimeStageAsync("Model specifications", client.GetModelContextSizeAsync);
            client.PrintStatus(client._contextKnown ? $"Context window: {client.NumCtx:N0} tokens." :
                $"Context size is unknown; usage uses a {client.NumCtx:N0}-token estimate and Ollama selects its own request default.",
                client._contextKnown ? LogColors.Green : LogColors.Orange);
            return client;
        }
        catch
        {
            client.Http.Dispose();
            throw;
        }
    }

    internal async Task<int> GetModelContextSizeAsync()
    {
        _contextKnown = false;
        try
        {
            var request = new ShowModelRequest(Model);
            using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request);
            using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);
            if (!httpResp.IsSuccessStatusCode)
            {
                PrintStatus("Could not retrieve model specs; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
                return 4096;
            }

            string json = await httpResp.Content.ReadAsStringAsync();
            ShowModelResponse? response = json.Deserialize<ShowModelResponse>();
            if (response?.ModelInfo is { } info)
            {
                foreach (var kv in info)
                {
                    if (kv.Key.EndsWith(".context_length", StringComparison.Ordinal) && kv.Value.TryGetInt32(out int ctx) && ctx > 0)
                    {
                        _contextKnown = true;
                        return ctx;
                    }
                }
            }
        }
        catch (Exception ex)
        {
            PrintStatus($"Model spec query failed ({ex.Message}); retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
        }

        PrintStatus("Valid model context metadata is unavailable; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
        return 4096;
    }

    public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
        if (await http.IsReachableAsync("http://localhost:11434/")) { logger.WriteInfo("Ollama", "Ollama server already running; skipping server start."); return; }
        if (!startIfMissing) throw new InvalidOperationException("Ollama server is not running.");
        var psi = new ProcessStartInfo(ResolveExecutable()) { UseShellExecute = false, CreateNoWindow = true };
        psi.ArgumentList.Add("serve");
        using var server = Process.Start(psi) ?? throw new InvalidOperationException("Could not start Ollama server.");
        var timer = Stopwatch.StartNew();
        while (timer.Elapsed < TimeSpan.FromSeconds(60))
        {
            if (await http.IsReachableAsync("http://localhost:11434/")) return;
            // The installer/app may already own the port while still discovering GPUs.
            // An exited second serve process does not prove that the API cannot become ready.
            await Task.Delay(500);
        }
        if (!server.HasExited) server.Kill(entireProcessTree: true);
        throw new TimeoutException(server.HasExited
            ? $"Ollama API did not become ready within 60 seconds (startup process exit {server.ExitCode})."
            : "Ollama server did not become ready within 60 seconds.");
    }

    private static string ResolveExecutable()
    {
        string installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Ollama", "ollama.exe");
        return File.Exists(installed) ? installed : "ollama";
    }

    public static bool IsOllamaInstalled()
    {
        try
        {
            var psi = new ProcessStartInfo(ResolveExecutable(), "--version")
            {
                CreateNoWindow = true,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };
            using var proc = Process.Start(psi);
            if (proc == null)
            {
                return false;
            }

            Task stdout = proc.StandardOutput.ReadToEndAsync();
            Task stderr = proc.StandardError.ReadToEndAsync();

            if (!proc.WaitForExit(5000))
            {
                try
                {
                    proc.Kill();
                }
                catch
                {
                    // ignore
                }

                return false;
            }

            Task.WaitAll(stdout, stderr);
            return proc.ExitCode == 0;
        }
        catch
        {
            return false;
        }
    }

    private static async Task<bool> PullModelAsync(OllamaClient client)
    {
        try
        {
            client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue);

            var psi = new ProcessStartInfo
            {
                FileName = ResolveExecutable(),
                CreateNoWindow = true,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };
            psi.ArgumentList.Add("pull");
            psi.ArgumentList.Add(client.Model);

            using var proc = Process.Start(psi);
            if (proc is null)
            {
                client.PrintStatus("Could not start ollama pull process.", LogColors.Red);
                return false;
            }

            var tracker = new OllamaCliProgressTracker(client.Model);
            Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker);
            Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker);

            DateTime started = DateTime.UtcNow;
            DateTime deadline = started.AddMinutes(30);
            while (!proc.HasExited && DateTime.UtcNow < deadline)
            {
                TimeSpan elapsed = DateTime.UtcNow - started;
                TimeSpan remaining = deadline - DateTime.UtcNow;
                client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray);
                await Task.Delay(250);
            }

            if (!proc.HasExited)
            {
                proc.Kill(entireProcessTree: true);
                await proc.WaitForExitAsync();
                await Task.WhenAll(stdoutTask, stderrTask);
                throw new TimeoutException("Model download exceeded 30 minutes.");
            }
            await proc.WaitForExitAsync();
            await Task.WhenAll(stdoutTask, stderrTask);

            if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model))
            {
                client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red);
                return false;
            }

            if (await IsModelAvailableAsync(client.Model))
            {
                client.PrintStatus(
                    $"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).",
                    LogColors.Green);
                return true;
            }

            client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange);
            if (!proc.HasExited)
            {
                proc.Kill(entireProcessTree: true);
            }

            return false;
        }
        catch (Exception ex)
        {
            client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red);
            return false;
        }
    }

    public static async Task<bool> IsModelAvailableAsync(string model)
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
        using var request = HttpExtensions.JsonRequest("http://localhost:11434/api/show", new ShowModelRequest(model));
        using var response = await http.SendAsync(request);
        if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return false;
        response.EnsureSuccessStatusCode();
        return true;
    }

    public static async Task InstallAsync(Logger logger)
    {
        using var client = new OllamaClient(logger);
        if (!await InstallOllamaAsync(client)) throw new InvalidOperationException("Ollama installation failed. Relaunch setup to retry.");
    }

    public static async Task DownloadModelAsync(string model, Logger logger)
    {
        using var client = new OllamaClient(logger) { Model = model };
        if (!await PullModelAsync(client)) throw new InvalidOperationException($"Model '{model}' download failed. Relaunch setup to retry.");
    }

    private static async Task<bool> InstallOllamaAsync(OllamaClient client)
    {
        try
        {
            var psi = new ProcessStartInfo
            {
                FileName = "powershell",
                Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " +
                            "\"irm https://ollama.com/install.ps1 | iex\"",
                CreateNoWindow = true,
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };

            using var proc = Process.Start(psi);
            if (proc == null)
            {
                return false;
            }

            proc.OutputDataReceived += (_, e) =>
            {
                if (!string.IsNullOrEmpty(e.Data))
                {
                    client.PrintInstallerOutput(e.Data, StatusGray);
                }
            };
            proc.ErrorDataReceived += (_, e) =>
            {
                if (!string.IsNullOrEmpty(e.Data))
                {
                    client.PrintInstallerOutput(e.Data, LogColors.Orange);
                }
            };
            proc.BeginOutputReadLine();
            proc.BeginErrorReadLine();

            await proc.WaitForExitOrKillAsync(TimeSpan.FromMinutes(10), "Ollama installation timed out.");
            return proc.ExitCode == 0 && IsOllamaInstalled();
        }
        catch (Exception ex)
        {
            client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red);
            return false;
        }
    }

    private void PrintStatus(string message, string color) =>
        Logger.WriteLine("Ollama", message, color);

    internal void PrintInstallerOutput(string message, string color)
    {
        if (RegexPatterns.InstallerProgress().IsMatch(message))
            PrintProgress(message.Trim(), color);
        else
            PrintStatus(message, color);
    }

    private void PrintProgress(string message, string color) =>
        Logger.WriteProgress("Ollama", message, color);
}

Lilith/Core/Properties 1 files

NEWAssemblyInfo.cs148 B / 4 lines+4-0+148 B
C#UnchangedRemoved / replacedAdded
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Lilith.RegressionChecks")]
[assembly: InternalsVisibleTo("KokoroCheck")]

Lilith/Core/Providers 2 files

NEWProviderClient.cs7,559 B / 158 lines+158-0+7,559 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;

/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
    private readonly bool _ownsHttp = httpClient is null;
    private readonly SemaphoreSlim _operation = new(1, 1);
    private bool _disposed;
    public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
    public string Model { get; set; } = "gemma4";
    protected int NumCtx { get; set; } = 131072;
    public int ContextMax => NumCtx;
    public string SystemPrompt { get; protected set; } = string.Empty;
    /// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
    public ChatHistory History { get; } = [];
    public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
    public Logger Logger { get; } = logger;
    public string AgentName { get; set; } = "Lilith";
    public int ContextUsagePercent { get; private set; }
    public int CurrentContextTokens { get; private set; }
    public int ContextWarningThreshold { get; set; } = 60;
    public event Action<int>? OnContextHighUsage;

    public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
        OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);

    protected void InitializeConversation()
    {
        History.Add("system", SystemPrompt);
        ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
    }

    private IDisposable EnterOperation()
    {
        var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
        if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
        return operation;
    }

    public void NewConversation()
    {
        using var operation = EnterOperation();
        SaveCore();
        HistoryStore.StartNewSession();
        ClearCore();
        Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
    }

    public void SaveConversation() => SaveCurrentConversation();
    public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
    public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
    public bool LoadConversation(string id) => LoadSavedConversation(id);
    public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }

    private void ClearCore()
    {
        History.Clear();
        ResetUsage();
        if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
    }

    private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
    public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
    public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }

    private void SaveCore()
    {
        try
        {
            if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
                HistoryStore.SaveSession(Model, History);
        }
        catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
    }

    public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
    {
        using var operation = EnterOperation();
        return HistoryStore.ListSessions();
    }

    public bool LoadSavedConversation(int displayIndex)
    {
        using var operation = EnterOperation();
        var sessions = HistoryStore.ListSessions();
        return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
    }

    public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }

    private bool LoadCore(string id)
    {
        string previousId = HistoryStore.CurrentSessionId;
        try
        {
            var session = HistoryStore.LoadSession(id);
            if (session is null) return false;
            var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
            if (messages[0].Role != "system")
            {
                if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
                messages.Insert(0, new ChatMessage("system", SystemPrompt));
            }
            History.Clear();
            History.AddRange(messages);
            ResetUsage();
            return true;
        }
        catch (Exception ex)
        {
            HistoryStore.UseSession(previousId);
            Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
            return false;
        }
    }

    protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);

    public async Task<string> StreamReply(string userInput, List<string>? images = null)
    {
        using var operation = EnterOperation();
        if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
        var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
        var requestHistory = History.Select(m => m.Snapshot()).ToList();
        requestHistory.Add(userMessage);
        Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
        ChatChunk? chunk = await GenerateReplyAsync(requestHistory);
        if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
            string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
            throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
        string reply = chunk.Message.Content;
        History.Add(userMessage with { Tokens = chunk.PromptTokens });
        History.Add("assistant", reply, chunk.CompletionTokens);
        SaveCore();
        Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
        ResetUsage();
        if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
        {
            CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
            ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
            if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
                foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
                    try { handler(ContextUsagePercent); }
                    catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
        }
        return reply;
    }

    public void Dispose()
    {
        using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
        if (_disposed) return;
        _disposed = true;
        if (_ownsHttp) Http.Dispose();
        GC.SuppressFinalize(this);
    }

}
NEWProviderKind.cs83 B / 3 lines+3-0+83 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;

public enum ProviderKind { Ollama, LMStudio }