V12 / ea1357ad1734
ChatHistoryStore.cs
Lilith/Core/Chat/ChatHistoryStore.cs
Unchanged from the preceding versionCS
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 '_'));
}