Forge
csharpdeeb25a2
1#nullable enable
2using System.Text.Json;
3using AgentNotes.Core;
4using CascadeIDE.Models;
5
6namespace CascadeIDE.Services;
7
8/// <summary>
9/// Обёртка над <see cref="NotesStorage"/> для MCP-команд заметок и knowledge: разрешение workspace,
10/// те же ответы/ошибки, что раньше собирались во ViewModel.
11/// </summary>
12public sealed class McpAgentNotesService
13{
14 private static readonly JsonDocumentOptions JsonDocOptions = new() { CommentHandling = JsonCommentHandling.Skip };
15
16 private readonly Func<CascadeIdeSettings> _settingsProvider;
17 private readonly NotesStorage _storage;
18
19 /// <inheritdoc cref="McpAgentNotesService" />
20 public McpAgentNotesService(Func<CascadeIdeSettings>? settingsProvider = null, NotesStorage? storage = null)
21 {
22 _settingsProvider = settingsProvider ?? SettingsDefaultsLoader.CreateDefault;
23 _storage = storage ?? new();
24 }
25
26 private const string EmptyKnowledgeListPayload = """{"path":"","files":[],"total":0}""";
27
28 /// <remarks>
29 /// Чтение <c>knowledge/</c> без явного <c>knowledge_path</c>: primary root из TOML (<see cref="AgentNotesRuntimeLoader"/>),
30 /// иначе встроенный KB-Base + оверлей <see cref="AgentNotesSettings.KbBaseOverlayPath"/>.
31 /// </remarks>
32
33 public const string WorkspaceRequiredMessage =
34 "Error: no notes workspace. Open a solution, or set AGENT_NOTES_FILE to a global agent-notes path.";
35
36 private CascadeIdeSettings Settings => _settingsProvider();
37
38 private bool TryEnsureRuntime()
39 {
40 var settings = Settings;
41 if (string.IsNullOrWhiteSpace(settings.AgentNotes.ConfigPath))
42 return AgentNotesRuntime.IsConfigured;
43
44 return AgentNotesRuntimeLoader.EnsureInitialized(settings);
45 }
46
47 /// <summary>
48 /// Каталог workspace для <see cref="NotesStorage"/> (read/write/list).
49 /// Сначала каталог решения; если решения нет, но задан <c>AGENT_NOTES_FILE</c> — <see cref="Environment.CurrentDirectory"/>
50 /// (как в agent-notes-mcp).
51 /// </summary>
52 public static string? ResolveNotesWorkspacePath(string? solutionPath)
53 {
54 if (!string.IsNullOrWhiteSpace(solutionPath) && File.Exists(solutionPath))
55 return Path.GetDirectoryName(solutionPath);
56
57 var globalNotes = Environment.GetEnvironmentVariable("AGENT_NOTES_FILE");
58 if (!string.IsNullOrWhiteSpace(globalNotes))
59 return Environment.CurrentDirectory;
60
61 return null;
62 }
63
64 public string WriteAgentNotes(string? workspace, string content)
65 {
66 TryEnsureRuntime();
67 if (string.IsNullOrEmpty(workspace))
68 return WorkspaceRequiredMessage;
69 try
70 {
71 var result = _storage.Write(workspace, content);
72 return result == "NO_CHANGES" ? "OK" : result;
73 }
74 catch (Exception ex)
75 {
76 return "Error: " + ex.Message;
77 }
78 }
79
80 public string ReadAgentNotes(string? workspace)
81 {
82 TryEnsureRuntime();
83 if (string.IsNullOrEmpty(workspace))
84 return "";
85 try
86 {
87 return _storage.Read(workspace);
88 }
89 catch
90 {
91 return "";
92 }
93 }
94
95 public string AppendAgentNotes(string? workspace, string content)
96 {
97 TryEnsureRuntime();
98 if (string.IsNullOrEmpty(workspace))
99 return WorkspaceRequiredMessage;
100 try
101 {
102 var result = _storage.Append(workspace, content ?? "");
103 return result == "NO_CHANGES" ? "OK" : result;
104 }
105 catch (Exception ex)
106 {
107 return "Error: " + ex.Message;
108 }
109 }
110
111 public string ListAgentNotesRevisions(string? workspace, int? limit)
112 {
113 TryEnsureRuntime();
114 if (string.IsNullOrEmpty(workspace))
115 return "[]";
116 try
117 {
118 var resolved = limit is null or <= 0 ? 20 : Math.Min(limit.Value, 200);
119 return _storage.ListRevisions(workspace, resolved);
120 }
121 catch
122 {
123 return "[]";
124 }
125 }
126
127 public string RollbackAgentNotes(string? workspace, string? revisionFile)
128 {
129 TryEnsureRuntime();
130 if (string.IsNullOrEmpty(workspace))
131 return WorkspaceRequiredMessage;
132 try
133 {
134 return _storage.Rollback(workspace, revisionFile);
135 }
136 catch (Exception ex)
137 {
138 return "Error: " + ex.Message;
139 }
140 }
141
142 public string ReadHotContext(string? workspace, string? activeScope)
143 {
144 TryEnsureRuntime();
145 if (string.IsNullOrEmpty(workspace))
146 return "{\"content\":\"\"}";
147 try
148 {
149 return _storage.ReadHotContext(workspace, activeScope);
150 }
151 catch (Exception ex)
152 {
153 return "Error: " + ex.Message;
154 }
155 }
156
157 public string RouteContext(string? workspace, string query, string? activeScope, int? maxSections, int? maxChars)
158 {
159 TryEnsureRuntime();
160 if (string.IsNullOrEmpty(workspace))
161 return "{\"assembled_context\":\"\"}";
162 if (string.IsNullOrWhiteSpace(query))
163 return "{\"assembled_context\":\"\"}";
164 try
165 {
166 var ms = maxSections is null or <= 0 ? 5 : Math.Clamp(maxSections.Value, 1, 20);
167 var mc = maxChars is null or <= 0 ? 12000 : Math.Clamp(maxChars.Value, 1000, 40000);
168 return _storage.RouteContext(workspace, query, activeScope, ms, mc);
169 }
170 catch (Exception ex)
171 {
172 return "Error: " + ex.Message;
173 }
174 }
175
176 public string MemoryHealth(string? workspace, string? activeScope)
177 {
178 TryEnsureRuntime();
179 if (string.IsNullOrEmpty(workspace))
180 return "{\"health\":\"unknown\"}";
181 try
182 {
183 return _storage.MemoryHealth(workspace, activeScope);
184 }
185 catch (Exception ex)
186 {
187 return "Error: " + ex.Message;
188 }
189 }
190
191 public string CompactHotContext(string? workspace, bool apply)
192 {
193 TryEnsureRuntime();
194 if (string.IsNullOrEmpty(workspace))
195 return "{\"changed\":false}";
196 try
197 {
198 return _storage.CompactHotContext(workspace, apply);
199 }
200 catch (Exception ex)
201 {
202 return "Error: " + ex.Message;
203 }
204 }
205
206 public string ExtractFromArchive(string? workspace, string query, string? revisionFile, int? headLimit, int? contextLines)
207 {
208 TryEnsureRuntime();
209 if (string.IsNullOrEmpty(workspace))
210 return "{\"matches\":[]}";
211 if (string.IsNullOrWhiteSpace(query))
212 return "{\"matches\":[]}";
213 try
214 {
215 var hl = headLimit is null or <= 0 ? 10 : Math.Clamp(headLimit.Value, 1, 100);
216 var cl = contextLines is null or < 0 ? 2 : Math.Clamp(contextLines.Value, 0, 20);
217 return _storage.ExtractFromArchive(workspace, query, revisionFile, hl, cl);
218 }
219 catch (Exception ex)
220 {
221 return "Error: " + ex.Message;
222 }
223 }
224
225 public string UpsertAgentNotesSection(string? workspace, string sectionId, string content)
226 {
227 TryEnsureRuntime();
228 if (string.IsNullOrWhiteSpace(sectionId))
229 return "Error: missing section_id.";
230 if (string.IsNullOrEmpty(workspace))
231 return WorkspaceRequiredMessage;
232 try
233 {
234 var result = _storage.UpsertSection(workspace, sectionId.Trim(), content ?? "");
235 return result == "NO_CHANGES" ? "OK" : result;
236 }
237 catch (Exception ex)
238 {
239 return "Error: " + ex.Message;
240 }
241 }
242
243 public string SearchAgentNotes(string? workspace, string query, int? headLimit)
244 {
245 TryEnsureRuntime();
246 if (string.IsNullOrWhiteSpace(query))
247 return "{\"matches\":[]}";
248 if (string.IsNullOrEmpty(workspace))
249 return "{\"matches\":[]}";
250 try
251 {
252 var limit = headLimit is null or <= 0 ? 20 : Math.Min(headLimit.Value, 200);
253 return _storage.Search(workspace, query, limit);
254 }
255 catch
256 {
257 return "{\"matches\":[]}";
258 }
259 }
260
261 public string ReadKnowledgeFile(string filePath, int? offset = null, int? limit = null, string? knowledgeRootId = null)
262 {
263 TryEnsureRuntime();
264 if (string.IsNullOrWhiteSpace(filePath))
265 return "";
266 try
267 {
268 if (AgentNotesRuntime.IsConfigured)
269 return _storage.ReadKnowledgeFile(knowledgePath: null, filePath, offset, limit, knowledgeRootId);
270
271 if (!string.IsNullOrWhiteSpace(knowledgeRootId))
272 return "Error: knowledge_root_id requires agent-notes TOML ([agent_notes].config_path).";
273
274 var overlayCanon = KbBaseOverlayPathResolver.TryResolveCanonRoot(Settings);
275 if (overlayCanon is not null)
276 {
277 var overlayFullPath = _storage.GetKnowledgeFilePath(overlayCanon, filePath);
278 if (File.Exists(overlayFullPath))
279 return _storage.ReadKnowledgeFile(overlayCanon, filePath, offset, limit);
280 }
281
282 var embeddedCanon = KbBaseEmbeddedBundleProvisioner.TryGetEmbeddedCanonRoot();
283 if (embeddedCanon is not null)
284 {
285 var embeddedFullPath = _storage.GetKnowledgeFilePath(embeddedCanon, filePath);
286 if (File.Exists(embeddedFullPath))
287 return _storage.ReadKnowledgeFile(embeddedCanon, filePath, offset, limit);
288 }
289
290 return "";
291 }
292 catch (Exception ex)
293 {
294 return "Error: " + ex.Message;
295 }
296 }
297
298 public string ListKnowledgeFiles(string? subdir, string? knowledgeRootId = null)
299 {
300 TryEnsureRuntime();
301 try
302 {
303 if (AgentNotesRuntime.IsConfigured)
304 return _storage.ListKnowledgeFiles(knowledgePath: null, subdir, knowledgeRootId);
305
306 if (!string.IsNullOrWhiteSpace(knowledgeRootId))
307 return "{\"error\":\"knowledge_root_id requires agent-notes TOML ([agent_notes].config_path).\"}";
308
309 var overlayCanon = KbBaseOverlayPathResolver.TryResolveCanonRoot(Settings);
310 var overlayJson =
311 overlayCanon is null ? EmptyKnowledgeListPayload : _storage.ListKnowledgeFiles(overlayCanon, subdir);
312
313 var embeddedCanon = KbBaseEmbeddedBundleProvisioner.TryGetEmbeddedCanonRoot();
314 var embeddedJson =
315 embeddedCanon is null ? EmptyKnowledgeListPayload : _storage.ListKnowledgeFiles(embeddedCanon, subdir);
316
317 var hint =
318 TryReadKnowledgeListSearchPathFirstNonEmpty(overlayJson)
319 ?? TryReadKnowledgeListSearchPathFirstNonEmpty(embeddedJson)
320 ?? "";
321 var mergedHint = hint.Length > 0 ? $"{hint} (overlay + embedded merge)" : "knowledge (overlay + embedded merge)";
322
323 return KbBaseKnowledgeListMerger.Merge(overlayJson, embeddedJson, mergedHint);
324 }
325 catch (Exception ex)
326 {
327 return "{\"error\":\"" + ex.Message.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"}";
328 }
329 }
330
331 private static string? TryReadKnowledgeListSearchPathFirstNonEmpty(string jsonPayload)
332 {
333 if (string.IsNullOrWhiteSpace(jsonPayload))
334 return null;
335 try
336 {
337 using var doc = JsonDocument.Parse(jsonPayload, JsonDocOptions);
338 if (!doc.RootElement.TryGetProperty("path", out var inner) || inner.ValueKind != JsonValueKind.String)
339 return null;
340 var trimmed = (inner.GetString() ?? "").Trim();
341 return trimmed.Length > 0 ? trimmed : null;
342 }
343 catch
344 {
345 return null;
346 }
347 }
348
349 public string WriteKnowledgeFile(string filePath, string content, string? knowledgePath, bool saveRevision, string? knowledgeRootId = null)
350 {
351 TryEnsureRuntime();
352 try
353 {
354 return _storage.WriteKnowledgeFile(knowledgePath, filePath, content, saveRevision, knowledgeRootId);
355 }
356 catch (Exception ex)
357 {
358 return "Error: " + ex.Message;
359 }
360 }
361
362 public string AppendKnowledgeFile(string filePath, string content, string? knowledgePath, bool saveRevision, string? knowledgeRootId = null)
363 {
364 TryEnsureRuntime();
365 try
366 {
367 return _storage.AppendKnowledgeFile(knowledgePath, filePath, content, saveRevision, knowledgeRootId);
368 }
369 catch (Exception ex)
370 {
371 return "Error: " + ex.Message;
372 }
373 }
374
375 public string UpsertKnowledgeSection(string filePath, string sectionId, string content, string? knowledgePath, bool saveRevision, string? knowledgeRootId = null)
376 {
377 TryEnsureRuntime();
378 try
379 {
380 return _storage.UpsertKnowledgeSection(knowledgePath, filePath, sectionId, content, saveRevision, knowledgeRootId);
381 }
382 catch (Exception ex)
383 {
384 return "Error: " + ex.Message;
385 }
386 }
387
388 public string DeleteKnowledgeFile(string filePath, string? knowledgePath, string? knowledgeRootId = null)
389 {
390 TryEnsureRuntime();
391 try
392 {
393 return _storage.DeleteKnowledgeFile(knowledgePath, filePath, knowledgeRootId);
394 }
395 catch (Exception ex)
396 {
397 return "Error: " + ex.Message;
398 }
399 }
400
401 public string DeleteKnowledgeSection(string filePath, string sectionId, string? knowledgePath, string? knowledgeRootId = null)
402 {
403 TryEnsureRuntime();
404 try
405 {
406 return _storage.DeleteKnowledgeSection(knowledgePath, filePath, sectionId, knowledgeRootId);
407 }
408 catch (Exception ex)
409 {
410 return "Error: " + ex.Message;
411 }
412 }
413}
414
View only · write via MCP/CIDE