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