| 1 | using System.Text; |
| 2 | using System.Text.Json; |
| 3 | |
| 4 | namespace AgentNotes.Core; |
| 5 | |
| 6 | public sealed partial class NotesStorage |
| 7 | { |
| 8 | public string ReadHotContext(string workspacePath, string? activeScope) |
| 9 | { |
| 10 | var notes = Read(workspacePath); |
| 11 | if (string.IsNullOrWhiteSpace(notes)) |
| 12 | return ""; |
| 13 | |
| 14 | var sections = ParseSections(notes); |
| 15 | var scopeAliases = LoadScopeAliasesMerged(); |
| 16 | var resolvedScope = ResolveScope(activeScope, sections, workspacePath, scopeAliases); |
| 17 | |
| 18 | var notesPath = GetNotesPath(workspacePath); |
| 19 | var manifest = LoadMemoryArchitectureManifest(sections, notesPath); |
| 20 | var l0 = ResolveL0Ids(sections, notesPath, manifest); |
| 21 | var priorityIds = (l0 ?? HotContextDefaults.DefaultL0Ids).ToList(); |
| 22 | var scopeId = ResolveScopeSectionId(resolvedScope, sections, scopeAliases); |
| 23 | priorityIds.Add(scopeId); |
| 24 | |
| 25 | var loaded = new List<string>(); |
| 26 | var blocks = new List<string>(); |
| 27 | foreach (var id in priorityIds.Distinct(StringComparer.Ordinal)) |
| 28 | { |
| 29 | if (IsHotExcluded(id, manifest?.HotContextSectionExclusions)) |
| 30 | continue; |
| 31 | if (!sections.TryGetValue(id, out var content)) |
| 32 | continue; |
| 33 | |
| 34 | loaded.Add(id); |
| 35 | blocks.Add($"<!-- section:{id} -->\n{content}\n<!-- /section:{id} -->"); |
| 36 | } |
| 37 | |
| 38 | var payload = new |
| 39 | { |
| 40 | active_scope = resolvedScope, |
| 41 | loaded_sections = loaded, |
| 42 | content = JoinBlocks(blocks.ToArray()).TrimEnd('\n') |
| 43 | }; |
| 44 | |
| 45 | return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); |
| 46 | } |
| 47 | |
| 48 | public string MemoryHealth(string workspacePath, string? activeScope) |
| 49 | { |
| 50 | var notesPath = GetNotesPath(workspacePath); |
| 51 | var notes = Read(workspacePath); |
| 52 | var sections = ParseSections(notes); |
| 53 | var scopeAliases = LoadScopeAliasesMerged(); |
| 54 | var resolvedScope = ResolveScope(activeScope, sections, workspacePath, scopeAliases); |
| 55 | var hotSectionIds = BuildHotSectionIds(resolvedScope, sections, notesPath, scopeAliases); |
| 56 | var hotSections = hotSectionIds |
| 57 | .Where(sections.ContainsKey) |
| 58 | .Select(id => new |
| 59 | { |
| 60 | id, |
| 61 | chars = sections[id].Length, |
| 62 | lines = CountLines(sections[id]) |
| 63 | }) |
| 64 | .ToArray(); |
| 65 | |
| 66 | var hotChars = hotSections.Sum(x => x.chars); |
| 67 | var hotLines = hotSections.Sum(x => x.lines); |
| 68 | var manifestForHealth = LoadMemoryArchitectureManifest(sections, notesPath); |
| 69 | var (warnBudget, critBudget) = ResolveHotBudgetChars(manifestForHealth); |
| 70 | |
| 71 | var missingCoreSections = HotContextDefaults.RequiredCoreSectionIds |
| 72 | .Where(required => hotSectionIds.Contains(required, StringComparer.Ordinal)) |
| 73 | .Where(required => !sections.ContainsKey(required)) |
| 74 | .ToArray(); |
| 75 | |
| 76 | var warnings = new List<string>(); |
| 77 | var recommendCompaction = false; |
| 78 | warnings.AddRange(ValidateMemoryArchitecture(sections, notesPath)); |
| 79 | |
| 80 | if (hotChars > critBudget) |
| 81 | { |
| 82 | warnings.Add("hot_context_over_critical_budget"); |
| 83 | recommendCompaction = true; |
| 84 | } |
| 85 | else if (hotChars > warnBudget) |
| 86 | { |
| 87 | warnings.Add("hot_context_over_warning_budget"); |
| 88 | recommendCompaction = true; |
| 89 | } |
| 90 | |
| 91 | if (missingCoreSections.Length > 0) |
| 92 | warnings.Add("missing_core_sections"); |
| 93 | |
| 94 | var healthLevel = warnings.Contains("hot_context_over_critical_budget", StringComparer.Ordinal) |
| 95 | ? "critical" |
| 96 | : warnings.Count > 0 |
| 97 | ? "warning" |
| 98 | : "good"; |
| 99 | |
| 100 | var recommendations = new List<string>(); |
| 101 | if (recommendCompaction) |
| 102 | recommendations.Add("Run compact_hot_context with apply=true after preview to keep L0/L1 small."); |
| 103 | if (missingCoreSections.Length > 0) |
| 104 | recommendations.Add("Restore required core sections via upsert_agent_notes_section."); |
| 105 | if (recommendations.Count == 0) |
| 106 | recommendations.Add("Keep current memory shape; no immediate action required."); |
| 107 | |
| 108 | var payload = new |
| 109 | { |
| 110 | workspace_path = workspacePath, |
| 111 | notes_path = notesPath, |
| 112 | notes_exists = File.Exists(notesPath), |
| 113 | resolved_scope = resolvedScope, |
| 114 | total_chars = notes.Length, |
| 115 | total_lines = CountLines(notes), |
| 116 | section_count = sections.Count, |
| 117 | hot_context = new |
| 118 | { |
| 119 | section_ids = hotSectionIds, |
| 120 | loaded_section_count = hotSections.Length, |
| 121 | chars = hotChars, |
| 122 | lines = hotLines |
| 123 | }, |
| 124 | missing_core_sections = missingCoreSections, |
| 125 | largest_sections = sections |
| 126 | .Select(kv => new |
| 127 | { |
| 128 | id = kv.Key, |
| 129 | chars = kv.Value.Length, |
| 130 | lines = CountLines(kv.Value) |
| 131 | }) |
| 132 | .OrderByDescending(x => x.chars) |
| 133 | .Take(5) |
| 134 | .ToArray(), |
| 135 | warnings, |
| 136 | recommend_compaction = recommendCompaction, |
| 137 | health_level = healthLevel, |
| 138 | recommendations |
| 139 | }; |
| 140 | |
| 141 | return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); |
| 142 | } |
| 143 | |
| 144 | /// <summary>Hot-context section sizes for AgentNotesStatus <c>GET /hot-preview</c> (read-only JSON).</summary> |
| 145 | public string HotPreview(string workspacePath, string? activeScope) |
| 146 | { |
| 147 | var notesPath = GetNotesPath(workspacePath); |
| 148 | var notes = Read(workspacePath); |
| 149 | var sections = ParseSections(notes); |
| 150 | var scopeAliases = LoadScopeAliasesMerged(); |
| 151 | var resolvedScope = ResolveScope(activeScope, sections, workspacePath, scopeAliases); |
| 152 | var hotSectionIds = BuildHotSectionIds(resolvedScope, sections, notesPath, scopeAliases); |
| 153 | var hotSections = hotSectionIds |
| 154 | .Where(sections.ContainsKey) |
| 155 | .Select(id => new |
| 156 | { |
| 157 | id, |
| 158 | chars = sections[id].Length, |
| 159 | lines = CountLines(sections[id]) |
| 160 | }) |
| 161 | .ToArray(); |
| 162 | |
| 163 | var hotChars = hotSections.Sum(x => x.chars); |
| 164 | var hotLines = hotSections.Sum(x => x.lines); |
| 165 | var hotIdSet = hotSectionIds.ToHashSet(StringComparer.Ordinal); |
| 166 | |
| 167 | var payload = new |
| 168 | { |
| 169 | workspace_path = workspacePath, |
| 170 | notes_path = notesPath, |
| 171 | notes_exists = File.Exists(notesPath), |
| 172 | resolved_scope = resolvedScope, |
| 173 | hot_context = new |
| 174 | { |
| 175 | section_ids = hotSectionIds, |
| 176 | loaded_section_count = hotSections.Length, |
| 177 | chars = hotChars, |
| 178 | lines = hotLines |
| 179 | }, |
| 180 | sections = hotSections, |
| 181 | largest_other_sections = sections |
| 182 | .Where(kv => !hotIdSet.Contains(kv.Key)) |
| 183 | .Select(kv => new |
| 184 | { |
| 185 | id = kv.Key, |
| 186 | chars = kv.Value.Length, |
| 187 | lines = CountLines(kv.Value) |
| 188 | }) |
| 189 | .OrderByDescending(x => x.chars) |
| 190 | .Take(10) |
| 191 | .ToArray() |
| 192 | }; |
| 193 | |
| 194 | return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); |
| 195 | } |
| 196 | |
| 197 | public string RouteContext( |
| 198 | string workspacePath, |
| 199 | string query, |
| 200 | string? activeScope, |
| 201 | int maxSections, |
| 202 | int maxChars) |
| 203 | { |
| 204 | var notes = Read(workspacePath); |
| 205 | if (string.IsNullOrWhiteSpace(notes)) |
| 206 | return JsonSerializer.Serialize(new |
| 207 | { |
| 208 | query, |
| 209 | selected = Array.Empty<object>(), |
| 210 | assembled_context = "" |
| 211 | }, new JsonSerializerOptions { WriteIndented = true }); |
| 212 | |
| 213 | var sections = ParseSections(notes); |
| 214 | var scopeAliases = LoadScopeAliasesMerged(); |
| 215 | var resolvedScope = ResolveScope(activeScope, sections, workspacePath, scopeAliases); |
| 216 | var notesPath = GetNotesPath(workspacePath); |
| 217 | var hotSectionIds = BuildHotSectionIds(resolvedScope, sections, notesPath, scopeAliases); |
| 218 | var boosted = hotSectionIds |
| 219 | .Select((id, idx) => (id, bonus: Math.Max(0, 30 - idx * 2))) |
| 220 | .ToDictionary(x => x.id, x => x.bonus, StringComparer.Ordinal); |
| 221 | |
| 222 | var tokens = TokenizeQuery(query); |
| 223 | var candidates = new List<(string id, string content, int score, int matchCount)>(); |
| 224 | foreach (var (id, content) in sections) |
| 225 | { |
| 226 | var matchCount = CountMatches(content, tokens) + CountMatches(id, tokens); |
| 227 | var score = matchCount * 4; |
| 228 | |
| 229 | if (content.Contains(query, StringComparison.OrdinalIgnoreCase)) |
| 230 | score += 24; |
| 231 | if (id.Contains(query, StringComparison.OrdinalIgnoreCase)) |
| 232 | score += 20; |
| 233 | if (boosted.TryGetValue(id, out var bonus)) |
| 234 | score += bonus; |
| 235 | |
| 236 | if (score <= 0) |
| 237 | continue; |
| 238 | |
| 239 | candidates.Add((id, content, score, matchCount)); |
| 240 | } |
| 241 | |
| 242 | AppendKnowledgeRootsOverlayCandidates( |
| 243 | candidates, |
| 244 | tokens, |
| 245 | query, |
| 246 | sections, |
| 247 | out var knowledgeRootsOverlayApplied, |
| 248 | out var knowledgeRootsRegistryHits); |
| 249 | |
| 250 | var selected = candidates |
| 251 | .OrderByDescending(x => x.score) |
| 252 | .ThenBy(x => x.id, StringComparer.Ordinal) |
| 253 | .Take(maxSections) |
| 254 | .ToArray(); |
| 255 | |
| 256 | var assembled = new StringBuilder(); |
| 257 | var emitted = new List<object>(); |
| 258 | var truncated = false; |
| 259 | foreach (var item in selected) |
| 260 | { |
| 261 | var block = $"<!-- section:{item.id} -->\n{item.content}\n<!-- /section:{item.id} -->\n\n"; |
| 262 | if (assembled.Length + block.Length > maxChars) |
| 263 | { |
| 264 | truncated = true; |
| 265 | break; |
| 266 | } |
| 267 | |
| 268 | assembled.Append(block); |
| 269 | emitted.Add(new |
| 270 | { |
| 271 | id = item.id, |
| 272 | score = item.score, |
| 273 | match_count = item.matchCount, |
| 274 | chars = item.content.Length, |
| 275 | lines = CountLines(item.content), |
| 276 | preview = BuildPreview(item.content, 220) |
| 277 | }); |
| 278 | } |
| 279 | |
| 280 | var payload = new |
| 281 | { |
| 282 | query, |
| 283 | resolved_scope = resolvedScope, |
| 284 | total_candidates = candidates.Count, |
| 285 | selected_count = emitted.Count, |
| 286 | max_sections = maxSections, |
| 287 | max_chars = maxChars, |
| 288 | truncated, |
| 289 | knowledge_roots_overlay_applied = knowledgeRootsOverlayApplied, |
| 290 | knowledge_roots_registry_hits = knowledgeRootsRegistryHits, |
| 291 | selected = emitted, |
| 292 | assembled_context = assembled.ToString().TrimEnd('\n') |
| 293 | }; |
| 294 | |
| 295 | return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); |
| 296 | } |
| 297 | |
| 298 | public string ExtractFromArchive(string workspacePath, string query, string? revisionFile, int limit, int contextLines) |
| 299 | { |
| 300 | var notesPath = GetNotesPath(workspacePath); |
| 301 | var revisionsDir = GetRevisionsDir(notesPath); |
| 302 | if (!Directory.Exists(revisionsDir)) |
| 303 | throw new ArgumentException("No revisions found."); |
| 304 | |
| 305 | var resolvedRevisionFile = revisionFile |
| 306 | ?? Directory.GetFiles(revisionsDir, "*.md") |
| 307 | .Select(Path.GetFileName) |
| 308 | .OrderByDescending(name => name) |
| 309 | .FirstOrDefault(); |
| 310 | |
| 311 | if (string.IsNullOrWhiteSpace(resolvedRevisionFile)) |
| 312 | throw new ArgumentException("No revisions found."); |
| 313 | |
| 314 | var revisionPath = Path.Combine(revisionsDir, resolvedRevisionFile); |
| 315 | if (!File.Exists(revisionPath)) |
| 316 | throw new ArgumentException("revision_file not found."); |
| 317 | |
| 318 | var text = File.ReadAllText(revisionPath, Encoding.UTF8); |
| 319 | var lines = text.Replace("\r\n", "\n").Split('\n'); |
| 320 | |
| 321 | var totalMatches = 0; |
| 322 | var matches = new List<object>(); |
| 323 | for (var i = 0; i < lines.Length; i++) |
| 324 | { |
| 325 | if (!lines[i].Contains(query, StringComparison.OrdinalIgnoreCase)) |
| 326 | continue; |
| 327 | |
| 328 | totalMatches++; |
| 329 | if (matches.Count >= limit) |
| 330 | continue; |
| 331 | |
| 332 | var start = Math.Max(0, i - contextLines); |
| 333 | var end = Math.Min(lines.Length - 1, i + contextLines); |
| 334 | var window = new List<object>(); |
| 335 | for (var j = start; j <= end; j++) |
| 336 | { |
| 337 | window.Add(new |
| 338 | { |
| 339 | line = j + 1, |
| 340 | text = lines[j] |
| 341 | }); |
| 342 | } |
| 343 | |
| 344 | matches.Add(new |
| 345 | { |
| 346 | line = i + 1, |
| 347 | text = lines[i], |
| 348 | context = window |
| 349 | }); |
| 350 | } |
| 351 | |
| 352 | var payload = new |
| 353 | { |
| 354 | revision_file = resolvedRevisionFile, |
| 355 | query, |
| 356 | total_matches = totalMatches, |
| 357 | returned_matches = matches.Count, |
| 358 | matches |
| 359 | }; |
| 360 | |
| 361 | return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); |
| 362 | } |
| 363 | |
| 364 | public string CompactHotContext(string workspacePath, bool apply) |
| 365 | { |
| 366 | var notesPath = GetNotesPath(workspacePath); |
| 367 | var existing = File.Exists(notesPath) ? File.ReadAllText(notesPath, Encoding.UTF8) : ""; |
| 368 | var compacted = CompactNotes(existing, notesPath); |
| 369 | |
| 370 | if (!apply) |
| 371 | { |
| 372 | var payload = new |
| 373 | { |
| 374 | changed = !string.Equals(existing, compacted, StringComparison.Ordinal), |
| 375 | content = compacted.TrimEnd('\n') |
| 376 | }; |
| 377 | |
| 378 | return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); |
| 379 | } |
| 380 | |
| 381 | return SaveWithRevision(notesPath, compacted, "compact-hot-context"); |
| 382 | } |
| 383 | } |
| 384 | |