← All source files

V6 / 920e1e35d097

ChatHistoryStore.cs

Lilith/Core/Chat/ChatHistoryStore.cs

Unchanged from the preceding version
CS
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 '_'));
}