Forge
csharpdeeb25a2
1using System.Text;
2using System.Text.Json;
3using CascadeIDE.Models.AgentChat;
4
5namespace CascadeIDE.Features.Chat;
6
7/// <summary>
8/// Простое локальное хранилище истории чата.
9/// Канон: append-only NDJSON по событиям + отдельный meta.json + current.session.json.
10/// </summary>
11public sealed class ChatSessionStore
12{
13 private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
14 {
15 WriteIndented = false
16 };
17
18 private const string CurrentPointerFileName = "current.session.json";
19
20 private string _rootPath = "";
21
22 public ChatSessionStore(string? workspaceRootPath) => SetWorkspaceRoot(workspaceRootPath);
23
24 /// <summary>Каталог <c>{workspace}/.cascade-ide/chat-sessions</c>.</summary>
25 public string RootPath => _rootPath;
26
27 public void SetWorkspaceRoot(string? workspaceRootPath)
28 {
29 var root = string.IsNullOrWhiteSpace(workspaceRootPath)
30 ? Environment.CurrentDirectory
31 : workspaceRootPath.Trim();
32 _rootPath = Path.Combine(root, ".cascade-ide", "chat-sessions");
33 }
34
35 /// <summary>
36 /// Возвращает стабильный id сессии: указатель → последняя meta по <see cref="ChatSessionMetadata.UpdatedAtUtc"/> → новая сессия.
37 /// </summary>
38 public async Task<Guid> ResolveOrCreateCurrentSessionIdAsync(CancellationToken ct = default)
39 {
40 Directory.CreateDirectory(_rootPath);
41
42 if (TryReadCurrentPointer(out var pointerId) && MetaExists(pointerId))
43 return pointerId;
44
45 var latest = FindLatestSessionIdByMetadata();
46 if (latest == Guid.Empty)
47 latest = FindLatestSessionIdByEventsLog();
48 if (latest != Guid.Empty)
49 {
50 await WriteCurrentPointerAsync(latest, ct).ConfigureAwait(false);
51 await EnsureMetadataForSessionAsync(latest, ct).ConfigureAwait(false);
52 return latest;
53 }
54
55 var id = Guid.NewGuid();
56 var now = DateTimeOffset.UtcNow;
57 await WriteCurrentPointerAsync(id, ct).ConfigureAwait(false);
58 await SaveMetadataAsync(new ChatSessionMetadata(id, now, now, Title: "Chat session", MainThreadId: Guid.NewGuid()), ct)
59 .ConfigureAwait(false);
60 return id;
61 }
62
63 public async Task BindCurrentSessionAsync(Guid sessionId, CancellationToken ct = default)
64 {
65 if (sessionId == Guid.Empty)
66 return;
67 Directory.CreateDirectory(_rootPath);
68 await WriteCurrentPointerAsync(sessionId, ct).ConfigureAwait(false);
69 }
70
71 [Obsolete("Use ResolveOrCreateCurrentSessionIdAsync — иначе каждый старт создаёт новую сессию.")]
72 public Guid EnsureSessionId(Guid? preferred = null) =>
73 preferred is { } id && id != Guid.Empty ? id : Guid.NewGuid();
74
75 public async Task<ChatSessionMetadata> LoadOrCreateMetadataAsync(Guid sessionId, CancellationToken ct = default)
76 {
77 Directory.CreateDirectory(_rootPath);
78 var path = MetaPath(sessionId);
79 if (File.Exists(path))
80 {
81 await using var inStream = File.OpenRead(path);
82 var existing = await JsonSerializer.DeserializeAsync<ChatSessionMetadata>(inStream, Json, ct).ConfigureAwait(false);
83 if (existing is not null)
84 return existing;
85 }
86
87 var now = DateTimeOffset.UtcNow;
88 var meta = new ChatSessionMetadata(sessionId, now, now, Title: "Chat session");
89 await SaveMetadataAsync(meta, ct).ConfigureAwait(false);
90 return meta;
91 }
92
93 public async Task SaveMetadataAsync(ChatSessionMetadata metadata, CancellationToken ct = default)
94 {
95 Directory.CreateDirectory(_rootPath);
96 await using var outStream = File.Create(MetaPath(metadata.SessionId));
97 await JsonSerializer.SerializeAsync(outStream, metadata, Json, ct).ConfigureAwait(false);
98 }
99
100 public async Task AppendEventAsync(ChatHistoryEvent ev, CancellationToken ct = default)
101 {
102 Directory.CreateDirectory(_rootPath);
103 var line = JsonSerializer.Serialize(ev, Json);
104 await File.AppendAllTextAsync(EventsPath(ev.SessionId), line + Environment.NewLine, Encoding.UTF8, ct)
105 .ConfigureAwait(false);
106 }
107
108 public async Task<IReadOnlyList<ChatHistoryEvent>> ReadEventsAsync(Guid sessionId, CancellationToken ct = default)
109 {
110 var path = EventsPath(sessionId);
111 if (!File.Exists(path))
112 return [];
113
114 var result = new List<ChatHistoryEvent>();
115 await using var stream = File.OpenRead(path);
116 using var reader = new StreamReader(stream, Encoding.UTF8, true);
117 while (true)
118 {
119 ct.ThrowIfCancellationRequested();
120 var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
121 if (line is null)
122 break;
123 if (string.IsNullOrWhiteSpace(line))
124 continue;
125 try
126 {
127 var ev = JsonSerializer.Deserialize<ChatHistoryEvent>(line, Json);
128 if (ev is not null)
129 result.Add(ev);
130 }
131 catch (JsonException)
132 {
133 // Пропускаем битые строки NDJSON, остальная история всё равно поднимается.
134 }
135 }
136
137 return result;
138 }
139
140 private bool TryReadCurrentPointer(out Guid sessionId)
141 {
142 sessionId = Guid.Empty;
143 var path = CurrentPointerPath();
144 if (!File.Exists(path))
145 return false;
146
147 try
148 {
149 var json = File.ReadAllText(path);
150 var pointer = JsonSerializer.Deserialize<ChatSessionCurrentPointer>(json, Json);
151 if (pointer is null || pointer.SessionId == Guid.Empty)
152 return false;
153 sessionId = pointer.SessionId;
154 return true;
155 }
156 catch
157 {
158 return false;
159 }
160 }
161
162 private async Task WriteCurrentPointerAsync(Guid sessionId, CancellationToken ct)
163 {
164 Directory.CreateDirectory(_rootPath);
165 await using var outStream = File.Create(CurrentPointerPath());
166 await JsonSerializer.SerializeAsync(outStream, new ChatSessionCurrentPointer(sessionId), Json, ct)
167 .ConfigureAwait(false);
168 }
169
170 private Guid FindLatestSessionIdByMetadata()
171 {
172 if (!Directory.Exists(_rootPath))
173 return Guid.Empty;
174
175 Guid bestId = Guid.Empty;
176 var bestUpdated = DateTimeOffset.MinValue;
177 foreach (var path in Directory.EnumerateFiles(_rootPath, "session-*.meta.json"))
178 {
179 if (!TryParseSessionIdFromMetaFileName(path, out var id))
180 continue;
181 try
182 {
183 var json = File.ReadAllText(path);
184 var meta = JsonSerializer.Deserialize<ChatSessionMetadata>(json, Json);
185 if (meta is null)
186 continue;
187 var updated = meta.UpdatedAtUtc;
188 if (updated >= bestUpdated)
189 {
190 bestUpdated = updated;
191 bestId = meta.SessionId != Guid.Empty ? meta.SessionId : id;
192 }
193 }
194 catch
195 {
196 // ignore corrupt meta
197 }
198 }
199
200 return bestId;
201 }
202
203 private Guid FindLatestSessionIdByEventsLog()
204 {
205 if (!Directory.Exists(_rootPath))
206 return Guid.Empty;
207
208 Guid bestId = Guid.Empty;
209 var bestWrite = DateTime.MinValue;
210 foreach (var path in Directory.EnumerateFiles(_rootPath, "session-*.events.ndjson"))
211 {
212 if (!TryParseSessionIdFromEventsFileName(path, out var id))
213 continue;
214 var write = File.GetLastWriteTimeUtc(path);
215 if (write >= bestWrite)
216 {
217 bestWrite = write;
218 bestId = id;
219 }
220 }
221
222 return bestId;
223 }
224
225 private async Task EnsureMetadataForSessionAsync(Guid sessionId, CancellationToken ct)
226 {
227 if (MetaExists(sessionId))
228 return;
229 var events = await ReadEventsAsync(sessionId, ct).ConfigureAwait(false);
230 var mainThreadId = ChatHistoryMessageProjector.InferMainThreadId(events);
231 var now = DateTimeOffset.UtcNow;
232 await SaveMetadataAsync(new ChatSessionMetadata(sessionId, now, now, Title: "Chat session", MainThreadId: mainThreadId), ct)
233 .ConfigureAwait(false);
234 }
235
236 private static bool TryParseSessionIdFromMetaFileName(string path, out Guid sessionId) =>
237 TryParseSessionIdFromSessionFileName(path, ".meta", out sessionId);
238
239 private static bool TryParseSessionIdFromEventsFileName(string path, out Guid sessionId) =>
240 TryParseSessionIdFromSessionFileName(path, ".events", out sessionId);
241
242 private static bool TryParseSessionIdFromSessionFileName(string path, string middleSuffix, out Guid sessionId)
243 {
244 sessionId = Guid.Empty;
245 var name = Path.GetFileNameWithoutExtension(path);
246 const string prefix = "session-";
247 if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
248 return false;
249 var tail = name[prefix.Length..];
250 if (!tail.EndsWith(middleSuffix, StringComparison.OrdinalIgnoreCase))
251 return false;
252 var hex = tail[..^middleSuffix.Length];
253 return Guid.TryParseExact(hex, "N", out sessionId);
254 }
255
256 private bool MetaExists(Guid sessionId) =>
257 sessionId != Guid.Empty && File.Exists(MetaPath(sessionId));
258
259 private string CurrentPointerPath() => Path.Combine(_rootPath, CurrentPointerFileName);
260
261 private string EventsPath(Guid sessionId) => Path.Combine(_rootPath, $"session-{sessionId:N}.events.ndjson");
262
263 private string MetaPath(Guid sessionId) => Path.Combine(_rootPath, $"session-{sessionId:N}.meta.json");
264}
265
View only · write via MCP/CIDE