Classes/SessionIdentityJournal.cs
|
using System;
using System.Collections.Generic; using System.IO; using System.Text; namespace Shmuelie.Copilot { public sealed class LifecycleJournalRecord { public string Json { get; set; } public bool Incomplete { get; set; } } public sealed class SessionIdentityJournal : IDisposable { private readonly IEnumerator<LifecycleJournalRecord> records; private SessionIdentityJournal(string path) { records = ReadRecords(path).GetEnumerator(); } public static SessionIdentityJournal Read(string path) { return new SessionIdentityJournal(path); } public LifecycleJournalRecord Current { get { return records.Current; } } public bool MoveNext() { return records.MoveNext(); } public void Dispose() { records.Dispose(); } private static IEnumerable<LifecycleJournalRecord> ReadRecords(string path) { long length = new FileInfo(path).Length; using var scan = Open(path, length); using var replay = Open(path, length); var buffer = new char[16384]; long replayPosition = 0; long position = 0; while (true) { long start = position; int depth = 0; bool quoted = false, escaped = false, expectType = false, foundType = false; string pending = null, type = null; var token = new StringBuilder(256); bool tokenOverflow = false; int c; while ((c = scan.Read()) != -1) { position++; if (c == '\n' || c == '\r') break; if (quoted) { if (!escaped && c == '"') { quoted = false; if (depth == 1) pending = tokenOverflow ? null : token.ToString(); } else { if (depth == 1 && !tokenOverflow) { if (token.Length < 256) token.Append((char)c); else tokenOverflow = true; } if (escaped) escaped = false; else if (c == '\\') escaped = true; } continue; } if (char.IsWhiteSpace((char)c)) continue; if (pending != null) { if (c == ':') expectType = !foundType && Decode(pending) == "type"; else if (expectType && !foundType) { type = Decode(pending); foundType = true; expectType = false; } pending = null; } if (c == '"') { quoted = true; escaped = false; token.Clear(); tokenOverflow = false; } else if (c == '{' || c == '[') { if (expectType && depth == 1) throw new FormatException("Event type must be a string."); depth++; } else if (c == '}' || c == ']') depth--; } if (position == start) yield break; if (pending != null && expectType && !foundType) type = Decode(pending); if (c == -1 && (quoted || depth > 0)) { yield return new LifecycleJournalRecord { Incomplete = true }; yield break; } bool lifecycle = type == "session.start" || type == "session.resume" || type == "session.context_changed"; // Replay only lifecycle records; a conversation line can be arbitrarily large. Advance(replay, buffer, start - replayPosition, null); StringBuilder json = lifecycle ? new StringBuilder() : null; Advance(replay, buffer, position - start, json); replayPosition = position; if (lifecycle) yield return new LifecycleJournalRecord { Json = json.ToString() }; if (c == -1) yield break; } } private static StreamReader Open(string path, long length) { return new StreamReader(new SnapshotStream(path, length), Encoding.UTF8, true, 16384); } private sealed class SnapshotStream : FileStream { private readonly long length; public SnapshotStream(string path, long length) : base(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete) { this.length = length; } public override int Read(byte[] buffer, int offset, int count) { return base.Read(buffer, offset, (int)Math.Min(count, Math.Max(0, length - Position))); } public override int Read(Span<byte> buffer) { return base.Read(buffer.Slice(0, (int)Math.Min(buffer.Length, Math.Max(0, length - Position)))); } } private static void Advance(StreamReader reader, char[] buffer, long count, StringBuilder text) { while (count > 0) { int read = reader.Read(buffer, 0, (int)Math.Min(count, buffer.Length)); if (read == 0) throw new EndOfStreamException("The identity recovery journal changed while being read."); text?.Append(buffer, 0, read); count -= read; } } private static string Decode(string text) { if (text.IndexOf('\\') < 0) return text; var decoded = new StringBuilder(text.Length); for (int i = 0; i < text.Length; i++) { char c = text[i]; if (c != '\\') { decoded.Append(c); continue; } if (++i == text.Length) throw new FormatException("Invalid JSON string escape."); switch (text[i]) { case '"': decoded.Append('"'); break; case '\\': decoded.Append('\\'); break; case '/': decoded.Append('/'); break; case 'b': decoded.Append('\b'); break; case 'f': decoded.Append('\f'); break; case 'n': decoded.Append('\n'); break; case 'r': decoded.Append('\r'); break; case 't': decoded.Append('\t'); break; case 'u': if (i + 4 >= text.Length || !ushort.TryParse(text.Substring(i + 1, 4), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out ushort value)) throw new FormatException("Invalid JSON Unicode escape."); decoded.Append((char)value); i += 4; break; default: throw new FormatException("Invalid JSON string escape."); } } return decoded.ToString(); } } } |