| 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<string> 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<ConversationTurn> 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<ConversationTurn> LoadTurnsFromDisk() |
| { |
| var turns = new List<ConversationTurn>(); |
| 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<ConversationTurn>(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); |
| } |
|
|
| } |
| } |
|
|