using System.Text.RegularExpressions;using System.Xml.Linq;namespace Agent.Core.Ollama;public record ChatSessionSummary( string Id, string Title, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, int MessageCount, string Model);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; } = [];}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 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 => new SavedChatMessage { Role = m.Role, Content = m.Content ?? string.Empty, Tokens = m.Tokens, Images = m.Images is { Count: > 0 } ? [.. m.Images] : null }) .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))))); doc.Save(path); 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; } XDocument doc = XDocument.Load(path); XElement root = doc.Root ?? new XElement("chatSession"); var session = new SavedChatSession { Id = (string?)root.Attribute("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() ?? [] }; CurrentSessionId = session.Id; return session; } private string SessionPath(string id) => Path.Combine(RootDirectory, SanitizeId(id) + ".xml"); private static SavedChatMessage ReadMessage(XElement element) { int? tokens = int.TryParse((string?)element.Attribute("tokens"), out int value) ? value : null; 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 { XDocument doc = XDocument.Load(path); XElement root = doc.Root ?? new XElement("chatSession"); string id = (string?)root.Attribute("id") ?? Path.GetFileNameWithoutExtension(path); int count = root.Element("messages")?.Elements("message").Count() ?? 0; return new ChatSessionSummary( id, (string?)root.Attribute("title") ?? "New chat", ParseDate((string?)root.Attribute("createdAt")) ?? new DateTimeOffset(File.GetCreationTime(path)), ParseDate((string?)root.Attribute("updatedAt")) ?? new DateTimeOffset(File.GetLastWriteTime(path)), count, (string?)root.Attribute("model") ?? string.Empty); } 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 => !string.IsNullOrWhiteSpace(m.Content))?.Content.Trim(); } if (string.IsNullOrWhiteSpace(firstUser)) { return "New chat"; } firstUser = Regex.Replace(firstUser, @"\s+", " "); return firstUser.Length <= 60 ? firstUser : firstUser[..57] + "..."; } private static string DetectVersionLabel() { foreach (string source in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory }) { var match = Regex.Match(source, @"Version\s+\d+", RegexOptions.IgnoreCase); if (match.Success) { return Regex.Replace(match.Value, @"\s+", " "); } } return "Default"; } private static string NewSessionId() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff"); private static string SanitizeId(string id) => string.Concat(id.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));}
Documentation
ChatHistoryStore
Persist chat sessions to disk as XML.
ChatHistoryStore.cs
ChatHistoryStore— save, load, and list conversation files under a data directory.