| 1 | using System.Text; |
| 2 | using System.Text.Json; |
| 3 | using System.Text.RegularExpressions; |
| 4 | |
| 5 | namespace AgentNotes.Core; |
| 6 | |
| 7 | public sealed partial class NotesStorage |
| 8 | { |
| 9 | private const string DefaultMemoryArchitectureManifestRelativePath = "knowledge/META/memory-architecture-v1.json"; |
| 10 | |
| 11 | /// <summary>Parse L0 section IDs from memory-architecture-v1 content (block after "### L0:" until next "###").</summary> |
| 12 | private static IReadOnlyList<string>? ParseL0FromMemoryArchitecture(string? content) |
| 13 | { |
| 14 | if (string.IsNullOrWhiteSpace(content)) |
| 15 | return null; |
| 16 | |
| 17 | var lines = content.Replace("\r\n", "\n").Split('\n'); |
| 18 | var inL0 = false; |
| 19 | var ids = new List<string>(); |
| 20 | foreach (var line in lines) |
| 21 | { |
| 22 | var t = line.Trim(); |
| 23 | if (t.StartsWith("### L0:", StringComparison.OrdinalIgnoreCase)) |
| 24 | { |
| 25 | inL0 = true; |
| 26 | continue; |
| 27 | } |
| 28 | if (inL0) |
| 29 | { |
| 30 | if (t.StartsWith("### ", StringComparison.Ordinal)) |
| 31 | break; |
| 32 | if (t.StartsWith("- ", StringComparison.Ordinal)) |
| 33 | { |
| 34 | var rest = t[2..].Trim(); |
| 35 | var id = rest.Split([' ', '(', '\t'], 2, StringSplitOptions.None)[0].Trim(); |
| 36 | if (id.Length > 0 && Regex.IsMatch(id, "^[A-Za-z0-9._-]+$")) |
| 37 | ids.Add(id); |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | return ids.Count > 0 ? ids : null; |
| 43 | } |
| 44 | |
| 45 | private static string ResolveCanonRootFromNotesPath(string notesPath) |
| 46 | { |
| 47 | var dir = Path.GetDirectoryName(notesPath); |
| 48 | if (string.IsNullOrWhiteSpace(dir)) |
| 49 | throw new ArgumentException("Invalid notes path."); |
| 50 | return dir; |
| 51 | } |
| 52 | |
| 53 | private static string? TryParseManifestRelativePath(string? memoryArchitectureContent) |
| 54 | { |
| 55 | if (string.IsNullOrWhiteSpace(memoryArchitectureContent)) |
| 56 | return null; |
| 57 | var match = MemoryArchitectureManifestRegex.Match(memoryArchitectureContent); |
| 58 | return match.Success ? match.Groups["path"].Value.Trim() : null; |
| 59 | } |
| 60 | |
| 61 | private static string? TryResolveManifestFullPath(string notesPath, string? manifestPath) |
| 62 | { |
| 63 | if (string.IsNullOrWhiteSpace(manifestPath)) |
| 64 | return null; |
| 65 | var p = manifestPath.Trim().Trim('"'); |
| 66 | if (p.Length == 0) |
| 67 | return null; |
| 68 | |
| 69 | var canonRoot = ResolveCanonRootFromNotesPath(notesPath); |
| 70 | |
| 71 | if (p.StartsWith("knowledge/", StringComparison.OrdinalIgnoreCase) || p.StartsWith("knowledge\\", StringComparison.OrdinalIgnoreCase)) |
| 72 | return Path.GetFullPath(Path.Combine(canonRoot, p)); |
| 73 | |
| 74 | if (p.StartsWith("./", StringComparison.Ordinal) || p.StartsWith(".\\", StringComparison.Ordinal)) |
| 75 | return Path.GetFullPath(Path.Combine(canonRoot, p)); |
| 76 | |
| 77 | return Path.IsPathRooted(p) ? Path.GetFullPath(p) : Path.GetFullPath(Path.Combine(canonRoot, "knowledge", p)); |
| 78 | } |
| 79 | |
| 80 | private static MemoryArchitectureManifestData? TryLoadMemoryArchitectureManifest(string notesPath, string manifestPath) |
| 81 | { |
| 82 | var fullPath = TryResolveManifestFullPath(notesPath, manifestPath); |
| 83 | if (string.IsNullOrWhiteSpace(fullPath) || !File.Exists(fullPath)) |
| 84 | return null; |
| 85 | |
| 86 | try |
| 87 | { |
| 88 | using var doc = JsonDocument.Parse(File.ReadAllText(fullPath, Encoding.UTF8)); |
| 89 | var root = doc.RootElement; |
| 90 | |
| 91 | var l0 = new List<string>(); |
| 92 | if (root.TryGetProperty("l0", out var l0El) && l0El.ValueKind == JsonValueKind.Array) |
| 93 | AppendManifestIds(l0, l0El); |
| 94 | |
| 95 | if (root.TryGetProperty("l0_owner", out var l0OwnerEl) && l0OwnerEl.ValueKind == JsonValueKind.Array) |
| 96 | AppendManifestIds(l0, l0OwnerEl); |
| 97 | |
| 98 | IReadOnlyList<string>? suffix = null; |
| 99 | if (root.TryGetProperty("compact_order_suffix", out var suffixEl) && suffixEl.ValueKind == JsonValueKind.Array) |
| 100 | { |
| 101 | var list = new List<string>(); |
| 102 | foreach (var item in suffixEl.EnumerateArray()) |
| 103 | { |
| 104 | var id = item.ValueKind == JsonValueKind.String ? (item.GetString() ?? "").Trim() : ""; |
| 105 | if (id.Length == 0) |
| 106 | continue; |
| 107 | if (Regex.IsMatch(id, "^[A-Za-z0-9._-]+$")) |
| 108 | list.Add(id); |
| 109 | } |
| 110 | suffix = list.Count > 0 ? list : null; |
| 111 | } |
| 112 | |
| 113 | int? warnBudget = null; |
| 114 | int? critBudget = null; |
| 115 | if (root.TryGetProperty("hot_context_budget_warning_chars", out var wEl) && wEl.ValueKind == JsonValueKind.Number) |
| 116 | warnBudget = wEl.GetInt32(); |
| 117 | if (root.TryGetProperty("hot_context_budget_critical_chars", out var cEl) && cEl.ValueKind == JsonValueKind.Number) |
| 118 | critBudget = cEl.GetInt32(); |
| 119 | |
| 120 | IReadOnlyList<string>? exclusions = null; |
| 121 | if (root.TryGetProperty("hot_context_section_exclusions", out var exEl) && exEl.ValueKind == JsonValueKind.Array) |
| 122 | { |
| 123 | var list = new List<string>(); |
| 124 | foreach (var item in exEl.EnumerateArray()) |
| 125 | { |
| 126 | var id = item.ValueKind == JsonValueKind.String ? (item.GetString() ?? "").Trim() : ""; |
| 127 | if (id.Length == 0) |
| 128 | continue; |
| 129 | if (Regex.IsMatch(id, "^[A-Za-z0-9._-]+$")) |
| 130 | list.Add(id); |
| 131 | } |
| 132 | exclusions = list.Count > 0 ? list : null; |
| 133 | } |
| 134 | |
| 135 | return new MemoryArchitectureManifestData(l0, suffix, warnBudget, critBudget, exclusions); |
| 136 | } |
| 137 | catch |
| 138 | { |
| 139 | return null; |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | private static void AppendManifestIds(List<string> target, JsonElement array) |
| 144 | { |
| 145 | foreach (var item in array.EnumerateArray()) |
| 146 | { |
| 147 | var id = item.ValueKind == JsonValueKind.String ? (item.GetString() ?? "").Trim() : ""; |
| 148 | if (id.Length == 0) |
| 149 | continue; |
| 150 | if (Regex.IsMatch(id, "^[A-Za-z0-9._-]+$")) |
| 151 | target.Add(id); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | private static MemoryArchitectureManifestData? LoadMemoryArchitectureManifest(IReadOnlyDictionary<string, string> sections, string notesPath) |
| 156 | { |
| 157 | var memoryArch = sections.GetValueOrDefault("memory-architecture-v1"); |
| 158 | var manifestPath = TryParseManifestRelativePath(memoryArch); |
| 159 | if (string.IsNullOrWhiteSpace(manifestPath)) |
| 160 | manifestPath = DefaultMemoryArchitectureManifestRelativePath; |
| 161 | return TryLoadMemoryArchitectureManifest(notesPath, manifestPath); |
| 162 | } |
| 163 | |
| 164 | private static (int Warning, int Critical) ResolveHotBudgetChars(MemoryArchitectureManifestData? manifest) |
| 165 | { |
| 166 | var w = manifest?.HotBudgetWarningChars ?? HotContextDefaults.HotContextBudgetWarningChars; |
| 167 | var c = manifest?.HotBudgetCriticalChars ?? HotContextDefaults.HotContextBudgetCriticalChars; |
| 168 | if (w < 1) |
| 169 | w = HotContextDefaults.HotContextBudgetWarningChars; |
| 170 | if (c < 1) |
| 171 | c = HotContextDefaults.HotContextBudgetCriticalChars; |
| 172 | if (w >= c) |
| 173 | { |
| 174 | return (HotContextDefaults.HotContextBudgetWarningChars, HotContextDefaults.HotContextBudgetCriticalChars); |
| 175 | } |
| 176 | |
| 177 | return (w, c); |
| 178 | } |
| 179 | |
| 180 | private static bool IsHotExcluded(string sectionId, IReadOnlyList<string>? manifestExclusions) |
| 181 | { |
| 182 | if (HotContextDefaults.IsBuiltInHotExclusion(sectionId)) |
| 183 | return true; |
| 184 | if (manifestExclusions is null) |
| 185 | return false; |
| 186 | foreach (var x in manifestExclusions) |
| 187 | { |
| 188 | if (sectionId.Equals(x, StringComparison.Ordinal)) |
| 189 | return true; |
| 190 | } |
| 191 | |
| 192 | return false; |
| 193 | } |
| 194 | |
| 195 | private static IReadOnlyList<string>? ResolveL0Ids(IReadOnlyDictionary<string, string> sections, string notesPath, MemoryArchitectureManifestData? manifest = null) |
| 196 | { |
| 197 | manifest ??= LoadMemoryArchitectureManifest(sections, notesPath); |
| 198 | if (manifest is { L0.Count: > 0 }) |
| 199 | return manifest.L0; |
| 200 | |
| 201 | var memoryArch = sections.GetValueOrDefault("memory-architecture-v1"); |
| 202 | return ParseL0FromMemoryArchitecture(memoryArch); |
| 203 | } |
| 204 | |
| 205 | private static IReadOnlyList<string>? ResolveCompactOrderSuffix(IReadOnlyDictionary<string, string> sections, string notesPath) |
| 206 | { |
| 207 | return LoadMemoryArchitectureManifest(sections, notesPath)?.CompactOrderSuffix; |
| 208 | } |
| 209 | |
| 210 | private static string[] BuildHotSectionIds(string resolvedScope, IReadOnlyDictionary<string, string> sections, string notesPath, IReadOnlyDictionary<string, string> scopeAliases) |
| 211 | { |
| 212 | var manifest = LoadMemoryArchitectureManifest(sections, notesPath); |
| 213 | var l0 = ResolveL0Ids(sections, notesPath, manifest); |
| 214 | var ids = (l0 ?? HotContextDefaults.DefaultL0Ids).ToList(); |
| 215 | ids.Add(ResolveScopeSectionId(resolvedScope, sections, scopeAliases)); |
| 216 | return ids.Where(id => !IsHotExcluded(id, manifest?.HotContextSectionExclusions)).Distinct(StringComparer.Ordinal).ToArray(); |
| 217 | } |
| 218 | |
| 219 | private static int CountLines(string content) |
| 220 | { |
| 221 | if (string.IsNullOrEmpty(content)) |
| 222 | return 0; |
| 223 | return content.Replace("\r\n", "\n").Split('\n').Length; |
| 224 | } |
| 225 | |
| 226 | private static string[] TokenizeQuery(string query) |
| 227 | { |
| 228 | var tokens = Regex.Split(query.ToLowerInvariant(), @"[^a-zа-я0-9._-]+") |
| 229 | .Where(token => token.Length >= 3) |
| 230 | .Distinct(StringComparer.Ordinal) |
| 231 | .ToArray(); |
| 232 | return tokens.Length > 0 ? tokens : [query.ToLowerInvariant()]; |
| 233 | } |
| 234 | |
| 235 | private static int CountMatches(string text, IReadOnlyList<string> tokens) |
| 236 | { |
| 237 | var normalized = text.ToLowerInvariant(); |
| 238 | var count = 0; |
| 239 | foreach (var token in tokens) |
| 240 | { |
| 241 | if (normalized.Contains(token, StringComparison.Ordinal)) |
| 242 | count++; |
| 243 | } |
| 244 | |
| 245 | return count; |
| 246 | } |
| 247 | |
| 248 | private static string BuildPreview(string content, int maxChars) |
| 249 | { |
| 250 | var normalized = Regex.Replace(content.Replace("\r\n", "\n"), @"\s+", " ").Trim(); |
| 251 | if (normalized.Length <= maxChars) |
| 252 | return normalized; |
| 253 | return normalized[..maxChars] + "..."; |
| 254 | } |
| 255 | |
| 256 | private static string CompactNotes(string notes, string notesPath) |
| 257 | { |
| 258 | var sections = ParseSections(notes); |
| 259 | if (sections.Count == 0) |
| 260 | return NormalizeWhitespace(notes); |
| 261 | |
| 262 | var l0 = ResolveL0Ids(sections, notesPath); |
| 263 | var startIds = (l0 ?? HotContextDefaults.DefaultL0Ids).ToList(); |
| 264 | var manifestSuffix = ResolveCompactOrderSuffix(sections, notesPath); |
| 265 | var suffixSeed = manifestSuffix?.ToArray() ?? HotContextDefaults.DefaultCompactOrderSuffix; |
| 266 | var suffixIds = suffixSeed.Where(id => !startIds.Contains(id, StringComparer.Ordinal)); |
| 267 | var preferredOrder = startIds.Concat(suffixIds).ToArray(); |
| 268 | |
| 269 | var blocks = new List<string>(); |
| 270 | foreach (var id in preferredOrder) |
| 271 | { |
| 272 | if (!sections.TryGetValue(id, out var content)) |
| 273 | continue; |
| 274 | |
| 275 | blocks.Add($"<!-- section:{id} -->\n{content}\n<!-- /section:{id} -->"); |
| 276 | sections.Remove(id); |
| 277 | } |
| 278 | |
| 279 | foreach (var id in sections.Keys.OrderBy(x => x, StringComparer.Ordinal)) |
| 280 | { |
| 281 | blocks.Add($"<!-- section:{id} -->\n{sections[id]}\n<!-- /section:{id} -->"); |
| 282 | } |
| 283 | |
| 284 | return JoinBlocks(blocks.ToArray()); |
| 285 | } |
| 286 | |
| 287 | private static IReadOnlyList<string> ValidateMemoryArchitecture(IReadOnlyDictionary<string, string> sections, string notesPath) |
| 288 | { |
| 289 | var warnings = new List<string>(); |
| 290 | var memoryArch = sections.GetValueOrDefault("memory-architecture-v1"); |
| 291 | var manifestRel = TryParseManifestRelativePath(memoryArch); |
| 292 | if (string.IsNullOrWhiteSpace(manifestRel)) |
| 293 | return warnings; |
| 294 | |
| 295 | var fullPath = TryResolveManifestFullPath(notesPath, manifestRel); |
| 296 | if (string.IsNullOrWhiteSpace(fullPath) || !File.Exists(fullPath)) |
| 297 | { |
| 298 | warnings.Add("memory_arch_manifest_missing"); |
| 299 | return warnings; |
| 300 | } |
| 301 | |
| 302 | var manifest = TryLoadMemoryArchitectureManifest(notesPath, manifestRel); |
| 303 | if (manifest is null) |
| 304 | { |
| 305 | warnings.Add("memory_arch_manifest_invalid_json"); |
| 306 | return warnings; |
| 307 | } |
| 308 | |
| 309 | var ids = manifest.L0; |
| 310 | if (ids.Count == 0) |
| 311 | { |
| 312 | warnings.Add("memory_arch_manifest_l0_empty"); |
| 313 | return warnings; |
| 314 | } |
| 315 | |
| 316 | var missing = ids.Where(id => !sections.ContainsKey(id)).Take(8).ToArray(); |
| 317 | if (missing.Length > 0) |
| 318 | warnings.Add("memory_arch_manifest_missing_sections:" + string.Join(",", missing)); |
| 319 | |
| 320 | return warnings; |
| 321 | } |
| 322 | |
| 323 | private static string NormalizeWhitespace(string text) |
| 324 | { |
| 325 | var normalized = text.Replace("\r\n", "\n"); |
| 326 | normalized = Regex.Replace(normalized, @"\n{3,}", "\n\n"); |
| 327 | return normalized.EndsWith('\n') ? normalized : normalized + "\n"; |
| 328 | } |
| 329 | } |
| 330 | |