Forge
csharpfb8e0499
1using System.Text;
2using System.Text.Json;
3using System.Text.RegularExpressions;
4
5namespace AgentNotes.Core;
6
7public 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
View only · write via MCP/CIDE