using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using UnityEngine; namespace OnDeviceAgent.AgentCore { public sealed class JsonlConversationStore : IConversationStore { readonly IPersistentPathProvider _paths; readonly AgentMemoryCompactionPolicy _policy; readonly string _conversationFileName; readonly string _summaryFileName; readonly SemaphoreSlim _fileLock = new SemaphoreSlim(1, 1); public JsonlConversationStore( IPersistentPathProvider paths, AgentMemoryCompactionPolicy compactionPolicy, string conversationFileName = "conversation.jsonl", string summaryFileName = "summary.md") { _paths = paths ?? throw new ArgumentNullException(nameof(paths)); _policy = compactionPolicy ?? throw new ArgumentNullException(nameof(compactionPolicy)); _conversationFileName = conversationFileName; _summaryFileName = summaryFileName; } public async Task AppendAsync(ConversationTurn turn, CancellationToken ct) { if (turn == null) throw new ArgumentNullException(nameof(turn)); ct.ThrowIfCancellationRequested(); await _fileLock.WaitAsync(ct).ConfigureAwait(false); try { await Task.Run(() => { EnsureDirectory(); var line = JsonConvert.SerializeObject(turn); File.AppendAllText(GetLogPath(), line + Environment.NewLine, Encoding.UTF8); var turns = LoadTurnsFromDisk(); if (!_policy.ShouldCompact(turns)) return; var recentCount = Mathf.Max(1, _policy.RecentTurnCount); var olderCount = turns.Count - recentCount; if (olderCount > 0) CompactToDisk(turns, olderCount); }, ct).ConfigureAwait(false); } finally { _fileLock.Release(); } } public async Task BuildSummaryAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); await _fileLock.WaitAsync(ct).ConfigureAwait(false); try { return await Task.Run(() => { var summary = LoadSummaryFromDisk(); var turns = LoadTurnsFromDisk(); if (string.IsNullOrWhiteSpace(summary) && turns.Count == 0) return string.Empty; return _policy.BuildContext(summary, turns); }, ct).ConfigureAwait(false); } finally { _fileLock.Release(); } } public async Task CompactNowAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); await _fileLock.WaitAsync(ct).ConfigureAwait(false); try { await Task.Run(() => { var turns = LoadTurnsFromDisk(); var recentCount = Mathf.Max(1, _policy.RecentTurnCount); var olderCount = turns.Count - recentCount; if (olderCount > 0) CompactToDisk(turns, olderCount); }, ct).ConfigureAwait(false); } finally { _fileLock.Release(); } } public void ResetSession() { _fileLock.Wait(); try { DeleteIfExists(GetLogPath()); DeleteIfExists(GetSummaryPath()); } finally { _fileLock.Release(); } } string GetLogPath() => Path.Combine(_paths.GetPersistentDataPath(), _conversationFileName); string GetSummaryPath() => Path.Combine(_paths.GetPersistentDataPath(), _summaryFileName); void EnsureDirectory() { var dir = _paths.GetPersistentDataPath(); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); } void CompactToDisk(List turns, int olderCount) { var olderTurns = turns.GetRange(0, olderCount); File.WriteAllText(GetSummaryPath(), _policy.BuildSummary(olderTurns), Encoding.UTF8); var recentTurns = turns.GetRange(olderCount, turns.Count - olderCount); var sb = new StringBuilder(recentTurns.Count * 256); for (var i = 0; i < recentTurns.Count; i++) sb.Append(JsonConvert.SerializeObject(recentTurns[i])).Append(Environment.NewLine); File.WriteAllText(GetLogPath(), sb.ToString(), Encoding.UTF8); } List LoadTurnsFromDisk() { var turns = new List(); var path = GetLogPath(); if (!File.Exists(path)) return turns; foreach (var line in File.ReadAllLines(path)) { if (string.IsNullOrWhiteSpace(line)) continue; try { var turn = JsonConvert.DeserializeObject(line); if (turn != null) turns.Add(turn); } catch (Exception ex) { Debug.LogWarning("[JsonlConversationStore] Skipped invalid memory line: " + ex.Message); } } return turns; } string LoadSummaryFromDisk() { var path = GetSummaryPath(); return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : string.Empty; } static void DeleteIfExists(string path) { if (File.Exists(path)) File.Delete(path); } } }