| 1 | using System.Reflection; |
| 2 | using System.Text.Json; |
| 3 | using AgentNotes.Core; |
| 4 | |
| 5 | namespace AgentNotesMcp.Status; |
| 6 | |
| 7 | internal sealed class AgentNotesStatusSnapshot |
| 8 | { |
| 9 | internal required string McpVersion { get; init; } |
| 10 | |
| 11 | internal required int ProcessId { get; init; } |
| 12 | |
| 13 | internal required long UptimeSeconds { get; init; } |
| 14 | |
| 15 | internal required DateTimeOffset StartedAtUtc { get; init; } |
| 16 | |
| 17 | internal required string ConfigPath { get; init; } |
| 18 | |
| 19 | internal required string StatusUrl { get; init; } |
| 20 | |
| 21 | internal required string? BindWarning { get; init; } |
| 22 | |
| 23 | internal required KnowledgeBlock Knowledge { get; init; } |
| 24 | |
| 25 | internal required WorkspaceBlock Workspace { get; init; } |
| 26 | |
| 27 | internal required IReadOnlyList<ToolSummary> Tools { get; init; } |
| 28 | |
| 29 | internal required IReadOnlyList<RecentToolCall> RecentToolCalls { get; init; } |
| 30 | |
| 31 | internal static AgentNotesStatusSnapshot Create( |
| 32 | NotesStorage storage, |
| 33 | DateTimeOffset startedAt, |
| 34 | string statusUrl, |
| 35 | string? bindWarning, |
| 36 | string? workspacePath, |
| 37 | bool verbose) |
| 38 | { |
| 39 | var settings = AgentNotesRuntime.Settings; |
| 40 | var configPath = AgentNotesRuntime.ConfigFilePath ?? "(unknown)"; |
| 41 | var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "2.0.0"; |
| 42 | var uptime = (long)Math.Max(0, (DateTimeOffset.UtcNow - startedAt).TotalSeconds); |
| 43 | |
| 44 | var previewWorkspace = settings.Status.PreviewWorkspace; |
| 45 | var effectiveWorkspace = !string.IsNullOrWhiteSpace(workspacePath) |
| 46 | ? Path.GetFullPath(workspacePath.Trim()) |
| 47 | : previewWorkspace; |
| 48 | |
| 49 | MemoryHealthBlock? memory = null; |
| 50 | if (!string.IsNullOrWhiteSpace(effectiveWorkspace)) |
| 51 | memory = MemoryHealthBlock.TryParse(storage.MemoryHealth(effectiveWorkspace, activeScope: null)); |
| 52 | |
| 53 | return new AgentNotesStatusSnapshot |
| 54 | { |
| 55 | McpVersion = version, |
| 56 | ProcessId = Environment.ProcessId, |
| 57 | UptimeSeconds = uptime, |
| 58 | StartedAtUtc = startedAt, |
| 59 | ConfigPath = configPath, |
| 60 | StatusUrl = statusUrl, |
| 61 | BindWarning = bindWarning, |
| 62 | Knowledge = KnowledgeBlock.FromSettings(settings, verbose), |
| 63 | Workspace = WorkspaceBlock.FromSettings(settings, effectiveWorkspace, previewWorkspace, memory), |
| 64 | Tools = ToolCatalog.ListSummaries(), |
| 65 | MemoryHealth = memory, |
| 66 | RecentToolCalls = AgentNotesToolCallRingBuffer.Snapshot() |
| 67 | .Select(e => RecentToolCall.From(e)) |
| 68 | .ToArray() |
| 69 | }; |
| 70 | } |
| 71 | |
| 72 | internal MemoryHealthBlock? MemoryHealth { get; init; } |
| 73 | |
| 74 | internal void WriteJson(Utf8JsonWriter writer, bool verbose) |
| 75 | { |
| 76 | writer.WriteStartObject(); |
| 77 | writer.WriteString("mcp_version", McpVersion); |
| 78 | writer.WriteNumber("pid", ProcessId); |
| 79 | writer.WriteNumber("uptime_seconds", UptimeSeconds); |
| 80 | writer.WriteString("started_at_utc", StartedAtUtc.ToString("O")); |
| 81 | writer.WriteString("config_path", ConfigPath); |
| 82 | writer.WriteString("status_url", StatusUrl); |
| 83 | if (BindWarning is not null) |
| 84 | writer.WriteString("bind_warning", BindWarning); |
| 85 | |
| 86 | Knowledge.WriteTo(writer, verbose); |
| 87 | Workspace.WriteTo(writer, verbose); |
| 88 | MemoryHealth?.WriteTo(writer, verbose); |
| 89 | |
| 90 | writer.WritePropertyName("tools"); |
| 91 | writer.WriteStartArray(); |
| 92 | foreach (var tool in Tools) |
| 93 | { |
| 94 | writer.WriteStartObject(); |
| 95 | writer.WriteString("name", tool.Name); |
| 96 | writer.WriteString("description", tool.Description); |
| 97 | writer.WriteEndObject(); |
| 98 | } |
| 99 | |
| 100 | writer.WriteEndArray(); |
| 101 | |
| 102 | writer.WritePropertyName("recent_tool_calls"); |
| 103 | writer.WriteStartArray(); |
| 104 | foreach (var call in RecentToolCalls) |
| 105 | call.WriteTo(writer, verbose); |
| 106 | writer.WriteEndArray(); |
| 107 | |
| 108 | writer.WriteEndObject(); |
| 109 | } |
| 110 | |
| 111 | internal sealed record RecentToolCall( |
| 112 | DateTimeOffset AtUtc, |
| 113 | string ToolName, |
| 114 | string? WorkspacePath, |
| 115 | bool IsError, |
| 116 | long DurationMs, |
| 117 | string ResultPreview) |
| 118 | { |
| 119 | internal static RecentToolCall From(AgentNotesToolCallRingBuffer.ToolCallEntry e) => |
| 120 | new(e.AtUtc, e.ToolName, e.WorkspacePath, e.IsError, e.DurationMs, e.ResultPreview); |
| 121 | |
| 122 | internal void WriteTo(Utf8JsonWriter writer, bool verbose) |
| 123 | { |
| 124 | writer.WriteStartObject(); |
| 125 | writer.WriteString("at_utc", AtUtc.ToString("O")); |
| 126 | writer.WriteString("tool", ToolName); |
| 127 | if (WorkspacePath is not null) |
| 128 | writer.WriteString("workspace_path", verbose ? WorkspacePath : SummarizePath(WorkspacePath)); |
| 129 | writer.WriteBoolean("is_error", IsError); |
| 130 | writer.WriteNumber("duration_ms", DurationMs); |
| 131 | writer.WriteString("result_preview", ResultPreview); |
| 132 | writer.WriteEndObject(); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | internal sealed class KnowledgeBlock |
| 137 | { |
| 138 | internal required string PrimaryRoot { get; init; } |
| 139 | |
| 140 | internal required string NotesPath { get; init; } |
| 141 | |
| 142 | internal required bool NotesExists { get; init; } |
| 143 | |
| 144 | internal required IReadOnlyList<NamedRoot> NamedRoots { get; init; } |
| 145 | |
| 146 | internal required IReadOnlyList<ReadOnlyRoot> ReadOnlyRoots { get; init; } |
| 147 | |
| 148 | internal required bool ReadOnlyRoutingEnabled { get; init; } |
| 149 | |
| 150 | internal static KnowledgeBlock FromSettings(LocalSettings settings, bool verbose) |
| 151 | { |
| 152 | var primary = settings.PrimaryKnowledgeRoot; |
| 153 | var notesPath = Path.Combine(primary, "agent-notes.md"); |
| 154 | var named = settings.KnowledgeRoots |
| 155 | .Select(kv => new NamedRoot(kv.Key, kv.Value, Directory.Exists(kv.Value))) |
| 156 | .OrderBy(r => r.Id, StringComparer.Ordinal) |
| 157 | .ToArray(); |
| 158 | |
| 159 | var readOnly = settings.ReadOnlyKnowledgeRoots |
| 160 | .Select(r => new ReadOnlyRoot(r.Id, r.Path, Directory.Exists(r.Path))) |
| 161 | .ToArray(); |
| 162 | |
| 163 | return new KnowledgeBlock |
| 164 | { |
| 165 | PrimaryRoot = verbose ? primary : SummarizePath(primary), |
| 166 | NotesPath = verbose ? notesPath : SummarizePath(notesPath), |
| 167 | NotesExists = File.Exists(notesPath), |
| 168 | NamedRoots = named, |
| 169 | ReadOnlyRoots = readOnly, |
| 170 | ReadOnlyRoutingEnabled = readOnly.Length > 0 |
| 171 | }; |
| 172 | } |
| 173 | |
| 174 | internal void WriteTo(Utf8JsonWriter writer, bool verbose) |
| 175 | { |
| 176 | writer.WritePropertyName("knowledge"); |
| 177 | writer.WriteStartObject(); |
| 178 | writer.WriteString("primary_root", PrimaryRoot); |
| 179 | writer.WriteString("notes_path", NotesPath); |
| 180 | writer.WriteBoolean("notes_exists", NotesExists); |
| 181 | writer.WriteBoolean("read_only_routing_enabled", ReadOnlyRoutingEnabled); |
| 182 | |
| 183 | writer.WritePropertyName("named_roots"); |
| 184 | writer.WriteStartArray(); |
| 185 | foreach (var root in NamedRoots) |
| 186 | { |
| 187 | writer.WriteStartObject(); |
| 188 | writer.WriteString("id", root.Id); |
| 189 | writer.WriteString("path", verbose ? root.Path : SummarizePath(root.Path)); |
| 190 | writer.WriteBoolean("exists", root.Exists); |
| 191 | writer.WriteEndObject(); |
| 192 | } |
| 193 | |
| 194 | writer.WriteEndArray(); |
| 195 | |
| 196 | writer.WritePropertyName("read_only_roots"); |
| 197 | writer.WriteStartArray(); |
| 198 | foreach (var root in ReadOnlyRoots) |
| 199 | { |
| 200 | writer.WriteStartObject(); |
| 201 | writer.WriteString("id", root.Id); |
| 202 | writer.WriteString("path", verbose ? root.Path : SummarizePath(root.Path)); |
| 203 | writer.WriteBoolean("exists", root.Exists); |
| 204 | writer.WriteEndObject(); |
| 205 | } |
| 206 | |
| 207 | writer.WriteEndArray(); |
| 208 | writer.WriteEndObject(); |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | internal sealed record NamedRoot(string Id, string Path, bool Exists); |
| 213 | |
| 214 | internal sealed record ReadOnlyRoot(string Id, string Path, bool Exists); |
| 215 | |
| 216 | internal sealed class WorkspaceBlock |
| 217 | { |
| 218 | internal required string? EffectiveWorkspace { get; init; } |
| 219 | |
| 220 | internal required string? PreviewWorkspace { get; init; } |
| 221 | |
| 222 | internal required string DefaultScope { get; init; } |
| 223 | |
| 224 | internal required string ScopeMapRelative { get; init; } |
| 225 | |
| 226 | internal required string ScopeAliasMapRelative { get; init; } |
| 227 | |
| 228 | internal required bool ScopeMapExists { get; init; } |
| 229 | |
| 230 | internal required bool ScopeAliasMapExists { get; init; } |
| 231 | |
| 232 | internal required string? ResolvedScope { get; init; } |
| 233 | |
| 234 | internal static WorkspaceBlock FromSettings( |
| 235 | LocalSettings settings, |
| 236 | string? effectiveWorkspace, |
| 237 | string? previewWorkspace, |
| 238 | MemoryHealthBlock? memory) |
| 239 | { |
| 240 | var primary = settings.PrimaryKnowledgeRoot; |
| 241 | var scopeMapPath = Path.Combine(primary, "knowledge", settings.Workspace.ScopeMapRelative); |
| 242 | var aliasPath = Path.Combine(primary, "knowledge", settings.Workspace.ScopeAliasMapRelative); |
| 243 | |
| 244 | return new WorkspaceBlock |
| 245 | { |
| 246 | EffectiveWorkspace = effectiveWorkspace, |
| 247 | PreviewWorkspace = previewWorkspace, |
| 248 | DefaultScope = settings.Workspace.DefaultScope, |
| 249 | ScopeMapRelative = settings.Workspace.ScopeMapRelative, |
| 250 | ScopeAliasMapRelative = settings.Workspace.ScopeAliasMapRelative, |
| 251 | ScopeMapExists = File.Exists(scopeMapPath), |
| 252 | ScopeAliasMapExists = File.Exists(aliasPath), |
| 253 | ResolvedScope = memory?.ResolvedScope |
| 254 | }; |
| 255 | } |
| 256 | |
| 257 | internal void WriteTo(Utf8JsonWriter writer, bool verbose) |
| 258 | { |
| 259 | writer.WritePropertyName("workspace"); |
| 260 | writer.WriteStartObject(); |
| 261 | if (EffectiveWorkspace is not null) |
| 262 | writer.WriteString("effective_path", verbose ? EffectiveWorkspace : SummarizePath(EffectiveWorkspace)); |
| 263 | if (PreviewWorkspace is not null) |
| 264 | writer.WriteString("preview_path", verbose ? PreviewWorkspace : SummarizePath(PreviewWorkspace)); |
| 265 | writer.WriteString("default_scope", DefaultScope); |
| 266 | writer.WriteString("scope_map", ScopeMapRelative); |
| 267 | writer.WriteBoolean("scope_map_exists", ScopeMapExists); |
| 268 | writer.WriteString("scope_aliases", ScopeAliasMapRelative); |
| 269 | writer.WriteBoolean("scope_aliases_exists", ScopeAliasMapExists); |
| 270 | if (ResolvedScope is not null) |
| 271 | writer.WriteString("resolved_scope", ResolvedScope); |
| 272 | writer.WriteEndObject(); |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | internal sealed class MemoryHealthBlock |
| 277 | { |
| 278 | internal required string HealthLevel { get; init; } |
| 279 | |
| 280 | internal required string ResolvedScope { get; init; } |
| 281 | |
| 282 | internal required string NotesPath { get; init; } |
| 283 | |
| 284 | internal required bool NotesExists { get; init; } |
| 285 | |
| 286 | internal required int HotChars { get; init; } |
| 287 | |
| 288 | internal required int HotLines { get; init; } |
| 289 | |
| 290 | internal required int SectionCount { get; init; } |
| 291 | |
| 292 | internal required IReadOnlyList<string> Warnings { get; init; } |
| 293 | |
| 294 | internal required IReadOnlyList<string> Recommendations { get; init; } |
| 295 | |
| 296 | internal required IReadOnlyList<string> HotSectionIds { get; init; } |
| 297 | |
| 298 | internal static MemoryHealthBlock? TryParse(string json) |
| 299 | { |
| 300 | try |
| 301 | { |
| 302 | using var doc = JsonDocument.Parse(json); |
| 303 | var root = doc.RootElement; |
| 304 | var hot = root.GetProperty("hot_context"); |
| 305 | var sectionIds = hot.TryGetProperty("section_ids", out var ids) |
| 306 | ? ids.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => s.Length > 0).ToArray() |
| 307 | : Array.Empty<string>(); |
| 308 | |
| 309 | return new MemoryHealthBlock |
| 310 | { |
| 311 | HealthLevel = root.GetProperty("health_level").GetString() ?? "unknown", |
| 312 | ResolvedScope = root.GetProperty("resolved_scope").GetString() ?? "", |
| 313 | NotesPath = root.GetProperty("notes_path").GetString() ?? "", |
| 314 | NotesExists = root.TryGetProperty("notes_exists", out var ne) && ne.GetBoolean(), |
| 315 | HotChars = hot.GetProperty("chars").GetInt32(), |
| 316 | HotLines = hot.GetProperty("lines").GetInt32(), |
| 317 | SectionCount = root.GetProperty("section_count").GetInt32(), |
| 318 | Warnings = ReadStringArray(root, "warnings"), |
| 319 | Recommendations = ReadStringArray(root, "recommendations"), |
| 320 | HotSectionIds = sectionIds |
| 321 | }; |
| 322 | } |
| 323 | catch |
| 324 | { |
| 325 | return null; |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | internal void WriteTo(Utf8JsonWriter writer, bool verbose) |
| 330 | { |
| 331 | writer.WritePropertyName("memory_health"); |
| 332 | writer.WriteStartObject(); |
| 333 | writer.WriteString("health_level", HealthLevel); |
| 334 | writer.WriteString("resolved_scope", ResolvedScope); |
| 335 | writer.WriteString("notes_path", verbose ? NotesPath : SummarizePath(NotesPath)); |
| 336 | writer.WriteBoolean("notes_exists", NotesExists); |
| 337 | writer.WriteNumber("hot_chars", HotChars); |
| 338 | writer.WriteNumber("hot_lines", HotLines); |
| 339 | writer.WriteNumber("section_count", SectionCount); |
| 340 | WriteStringArray(writer, "warnings", Warnings); |
| 341 | WriteStringArray(writer, "recommendations", Recommendations); |
| 342 | WriteStringArray(writer, "hot_section_ids", HotSectionIds); |
| 343 | writer.WriteEndObject(); |
| 344 | } |
| 345 | |
| 346 | private static IReadOnlyList<string> ReadStringArray(JsonElement root, string name) |
| 347 | { |
| 348 | if (!root.TryGetProperty(name, out var arr) || arr.ValueKind != JsonValueKind.Array) |
| 349 | return Array.Empty<string>(); |
| 350 | |
| 351 | return arr.EnumerateArray() |
| 352 | .Select(e => e.GetString() ?? "") |
| 353 | .Where(s => s.Length > 0) |
| 354 | .ToArray(); |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | internal sealed record ToolSummary(string Name, string Description); |
| 359 | |
| 360 | private static string SummarizePath(string path) |
| 361 | { |
| 362 | if (path.Length <= 64) |
| 363 | return path; |
| 364 | |
| 365 | var file = Path.GetFileName(path); |
| 366 | var root = Path.GetPathRoot(path) ?? ""; |
| 367 | var tail = path.Length > root.Length + 20 |
| 368 | ? "…" + path[^36..] |
| 369 | : path; |
| 370 | return string.IsNullOrEmpty(file) ? tail : $"{root}…{file}"; |
| 371 | } |
| 372 | |
| 373 | private static void WriteStringArray(Utf8JsonWriter writer, string name, IReadOnlyList<string> values) |
| 374 | { |
| 375 | writer.WritePropertyName(name); |
| 376 | writer.WriteStartArray(); |
| 377 | foreach (var value in values) |
| 378 | writer.WriteStringValue(value); |
| 379 | writer.WriteEndArray(); |
| 380 | } |
| 381 | } |
| 382 | |