V2 / CHANGESET

Only the delta.

The exact changes from V1 to V2. Unchanged files are excluded. Nothing from a later version is included.

V1→V2df3fc286644d

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 1 files

ModifiedVersion.props217 B / 7 lines+2-2-6 B
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
  <!-- Shared V2 release version. V1 remains on release/v1 and v1.0.0. -->
  <PropertyGroup>
    <VersionPrefix>2.0.1</VersionPrefix>
    <VersionSuffix></VersionSuffix>
  </PropertyGroup>
</Project>

Lilith/Agent 1 files

ModifiedLilith.cs2,448 B / 61 lines+7-1+298 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);
        foreach (var tool in Tools) _llmclient.RegisterTool(tool);
    }

    /// <summary>Configure before boot. The template is deliberately not enabled.</summary>
    public List<global::Lilith.Core.Tools.ILilithTool> Tools { get; } = [new global::Lilith.Core.Tools.CurrentTimeTool()];

    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; }
    }
}

Lilith/Core/Chat 5 files

ModifiedChatHistoryStore.cs11,173 B / 278 lines+33-2+2,035 B
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
using System.Text.Json;
using Lilith.Core.Tools;

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.ToolCalls is { Count: > 0 } ? new XElement("toolCalls", JsonSerializer.Serialize(m.ToolCalls)) : null,
                            m.ToolName is not null ? new XElement("toolName", m.ToolName) : null,
                            m.ToolCallId is not null ? new XElement("toolCallId", m.ToolCallId) : null,
                            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.");
        var pending = new Queue<ToolCall>();
        for (int i = 0; i < session.Messages.Count; i++)
        {
            var message = session.Messages[i];
            if (message.Role is not ("system" or "user" or "assistant" or "tool") || message.Tokens is < 0 ||
                (message.Role == "system" && (i != 0 || string.IsNullOrWhiteSpace(message.Content))))
                throw new InvalidDataException("Chat contains invalid messages.");
            if (message.Role == "tool")
            {
                if (!pending.TryDequeue(out var call) || message.ToolName != call.Function.Name || message.ToolCallId != call.Id ||
                    message.ToolCalls is { Count: > 0 })
                    throw new InvalidDataException("Chat contains an unmatched tool result.");
            }
            else
            {
                if (pending.Count != 0 || message.ToolName is not null || message.ToolCallId is not null)
                    throw new InvalidDataException("Chat contains an incomplete tool exchange.");
                if (message.ToolCalls is { Count: > 0 } calls)
                {
                    if (message.Role != "assistant") throw new InvalidDataException("Only assistants may call tools.");
                    foreach (var call in calls)
                    {
                        if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) || call.Function.Arguments.ValueKind != JsonValueKind.Object)
                            throw new InvalidDataException("Invalid saved tool call.");
                        pending.Enqueue(call);
                    }
                }
            }
        }
        if (pending.Count != 0) throw new InvalidDataException("Chat ends with pending tool calls.");
        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,
            ToolCalls = element.Element("toolCalls") is { } calls ? JsonSerializer.Deserialize<List<ToolCall>>(calls.Value) : null,
            ToolName = element.Element("toolName")?.Value,
            ToolCallId = element.Element("toolCallId")?.Value
        };
    }

    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 '_'));
}
ModifiedChatMessage.cs748 B / 12 lines+4-1+349 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,
    [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<Lilith.Core.Tools.ToolCall>? ToolCalls = null,
    [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null,
    [property: JsonIgnore] string? ToolCallId = null);
ModifiedChatMessageExtensions.cs1,081 B / 24 lines+9-3+350 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],
            ToolCalls = message.ToolCalls?.Select(c => c with { Function = c.Function with { Arguments = c.Function.Arguments.Clone() } }).ToList()
        };

    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,
        ToolCalls = message.Snapshot().ToolCalls, ToolName = message.ToolName, ToolCallId = message.ToolCallId
    };

    public static ChatMessage ToChatMessage(this SavedChatMessage message) =>
        new ChatMessage(message.Role, message.Content, message.Images, message.Tokens,
            message.ToolCalls, message.ToolName, message.ToolCallId).Snapshot();
}
ModifiedChatRequest.cs493 B / 10 lines+2-1+128 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,
    [property: JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] object[]? Tools = null);
ModifiedSavedChatMessage.cs487 B / 15 lines+3-0+158 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; }
    public List<Lilith.Core.Tools.ToolCall>? ToolCalls { get; set; }
    public string? ToolName { get; set; }
    public string? ToolCallId { get; set; }
}

Lilith/Core/LMStudio 1 files

ModifiedLMStudioClient.cs5,848 B / 105 lines+42-5+2,057 B
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Headers;
using System.Text.Json;
using Lilith.Core.Tools;

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(ToWireMessage),
            tools = ToolDefinitions
        });
        LMStudioSetup.Authorize(request);
        using var json = await Http.SendJsonDocumentAsync(request);
        var choice = json.RootElement.GetProperty("choices")[0];
        string? finish = choice.GetProperty("finish_reason").GetString();
        if (finish is not ("stop" or "tool_calls"))
            throw new InvalidDataException("LM Studio did not return a complete text reply.");
        var message = choice.GetProperty("message");
        if (message.GetProperty("role").GetString() != "assistant")
            throw new InvalidDataException("LM Studio returned an invalid assistant message.");
        List<ToolCall>? calls = null;
        if (message.TryGetProperty("tool_calls", out var toolCalls) && toolCalls.ValueKind != JsonValueKind.Null)
        {
            calls = [];
            foreach (var call in toolCalls.EnumerateArray())
            {
                if (call.GetProperty("type").GetString() != "function" ||
                    string.IsNullOrWhiteSpace(call.GetProperty("id").GetString()))
                    throw new InvalidDataException("Invalid LM Studio function call.");
                var function = call.GetProperty("function");
                using var arguments = JsonDocument.Parse(function.GetProperty("arguments").GetString()!);
                calls.Add(new ToolCall(new ToolFunction(function.GetProperty("name").GetString()!, arguments.RootElement.Clone()),
                    call.GetProperty("id").GetString()));
            }
            if (calls.Select(c => c.Id).Distinct().Count() != calls.Count)
                throw new InvalidDataException("Duplicate LM Studio tool call IDs.");
        }
        if ((finish == "tool_calls") != (calls is { Count: > 0 }))
            throw new InvalidDataException("LM Studio tool calls do not match the finish reason.");
        string? content = message.TryGetProperty("content", out var text) && text.ValueKind == JsonValueKind.String ? text.GetString() : null;
        if (calls is not { Count: > 0 } && content is null)
            throw new InvalidDataException("LM Studio returned no assistant content.");
        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", content ?? string.Empty, ToolCalls: calls), true, prompt, completion);
    }

    private static object ToWireMessage(ChatMessage message)
    {
        var wire = new Dictionary<string, object?> { ["role"] = message.Role, ["content"] = message.Content };
        if (message.ToolCallId is { } id) wire["tool_call_id"] = id;
        if (message.ToolCalls is { Count: > 0 } calls)
            wire["tool_calls"] = calls.Select(c => new
            {
                id = c.Id, type = "function",
                function = new { name = c.Function.Name, arguments = c.Function.Arguments.GetRawText() }
            }).ToArray();
        return wire;
    }

    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; }
    }
}

Lilith/Core/Ollama 1 files

ModifiedOllamaClient.cs13,205 B / 325 lines+2-1+37 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,
            Tools: ToolDefinitions);
        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/Providers 1 files

ModifiedProviderClient.cs11,042 B / 217 lines+61-2+3,483 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;

using System.Text.Json;
using Lilith.Core.Tools;

/// <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;
    private readonly Dictionary<string, ILilithTool> _tools = new(StringComparer.Ordinal);
    protected object[]? ToolDefinitions => _tools.Count == 0 ? null : _tools.Values.Select(t => (object)new
    {
        type = "function", function = new { name = t.Name, description = t.Description, parameters = t.Parameters }
    }).ToArray();

    public void RegisterTool(ILilithTool tool)
    {
        using var operation = EnterOperation();
        ArgumentNullException.ThrowIfNull(tool);
        if (string.IsNullOrWhiteSpace(tool.Name) || tool.Name.Length > 64 ||
            tool.Name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')) ||
            tool.Parameters.ValueKind != JsonValueKind.Object)
            throw new ArgumentException("A tool requires a valid function name and JSON parameter schema.", nameof(tool));
        if (!_tools.TryAdd(tool.Name, tool)) throw new ArgumentException($"Tool '{tool.Name}' is already registered.", nameof(tool));
    }
    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;
        int originalCount = requestHistory.Count - 1;
        int rounds = 0;
        while (true)
        {
            chunk = await GenerateReplyAsync(requestHistory);
            if (chunk is not { Done: true, Message.Role: "assistant" } ||
                chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
                throw new InvalidDataException("The model provider returned an invalid assistant response.");
            if (chunk.Message.ToolCalls is not { Count: > 0 } calls) break;
            if (++rounds > 8 || calls.Count > 16)
                throw new InvalidDataException("The model exceeded the tool execution limit.");
            foreach (var call in calls)
                if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) ||
                    call.Function.Arguments.ValueKind != JsonValueKind.Object)
                    throw new InvalidDataException("The model returned a malformed tool call.");
            requestHistory.Add(chunk.Message with { Content = chunk.Message.Content ?? string.Empty });
            foreach (var call in calls)
            {
                string result;
                if (!_tools.TryGetValue(call.Function.Name, out var tool))
                    result = JsonSerializer.Serialize(new { error = "Unknown tool: " + call.Function.Name });
                else
                {
                    using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
                    try
                    {
                        // Await actual completion: custom tools must cooperate with cancellation.
                        result = await tool.ExecuteAsync(call.Function.Arguments, deadline.Token);
                        deadline.Token.ThrowIfCancellationRequested();
                    }
                    catch (Exception ex) when (ex is not OutOfMemoryException)
                    {
                        result = JsonSerializer.Serialize(new { error = ex is OperationCanceledException ? "Tool execution canceled or timed out." : ex.Message });
                    }
                }
                requestHistory.Add(new ChatMessage("tool", result ?? string.Empty, ToolName: call.Function.Name, ToolCallId: call.Id));
            }
        }
        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;
        // Commit only after a successful final answer.
        requestHistory[originalCount] = userMessage with { Tokens = chunk.PromptTokens };
        History.AddRange(requestHistory.Skip(originalCount).Select(m => m.Snapshot()));
        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);
    }

}

Lilith/Core/Tools 4 files

NEWCurrentTimeTool.cs1,125 B / 26 lines+26-0+1,125 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;

namespace Lilith.Core.Tools;

public sealed class CurrentTimeTool : ILilithTool
{
    public string Name => "get_current_time";
    public string Description => "Get the current date and time on this computer, including its time zone and UTC time.";
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new
    {
        type = "object", properties = new { }, additionalProperties = false
    });

    public Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (arguments.ValueKind != JsonValueKind.Object || arguments.EnumerateObject().Any())
            throw new ArgumentException("get_current_time expects an empty argument object.");
        var now = DateTimeOffset.UtcNow;
        return Task.FromResult(JsonSerializer.Serialize(new
        {
            local_time = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Local).ToString("O"),
            utc_time = now.ToString("O"), time_zone = TimeZoneInfo.Local.Id
        }));
    }
}
NEWILilithTool.cs409 B / 12 lines+12-0+409 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;

namespace Lilith.Core.Tools;

/// <summary>Only explicitly registered tools can be executed. Implementations validate their arguments.</summary>
public interface ILilithTool
{
    string Name { get; }
    string Description { get; }
    JsonElement Parameters { get; }
    Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default);
}
NEWToolCall.cs484 B / 12 lines+12-0+484 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Lilith.Core.Tools;

public sealed record ToolFunction(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("arguments")] JsonElement Arguments);

public sealed record ToolCall(
    [property: JsonPropertyName("function")] ToolFunction Function,
    [property: JsonPropertyName("id"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Id = null);
NEWToolTemplate.cs1,337 B / 27 lines+27-0+1,337 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;

namespace Lilith.Core.Tools;

/// <summary>Copy and rename this class to create a tool. Not registered by default.</summary>
public sealed class ToolTemplate : ILilithTool
{
    public string Name => "example_echo";
    public string Description => "Return the supplied text. Replace this description with your tool's purpose.";
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new
    {
        type = "object",
        properties = new { text = new { type = "string", description = "Text to return." } },
        required = new[] { "text" }, additionalProperties = false
    });

    public Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (arguments.ValueKind != JsonValueKind.Object ||
            arguments.EnumerateObject().Count() != 1 ||
            !arguments.TryGetProperty("text", out var text) || text.ValueKind != JsonValueKind.String)
            throw new ArgumentException("Expected exactly one string argument named 'text'.");
        // Replace this with your implementation; await asynchronous work and honor cancellation.
        return Task.FromResult(JsonSerializer.Serialize(new { text = text.GetString() }));
    }
}