Forge
csharpfb8e0499
1using System.Text;
2using System.Text.RegularExpressions;
3
4namespace AgentNotes.Core;
5
6public sealed partial class NotesStorage
7{
8 private const string KnowledgeRootsRoutingSectionId = "knowledge-roots-routing-v1";
9 private const string KnowledgeRootsIndexRelativePath = "work/local/knowledge-roots-index-v1.md";
10 private const int KnowledgeRootPreviewMaxLines = 24;
11 private const int MaxRegistryOverlayEntries = 3;
12
13 private static readonly Regex KnowledgeRootIndexLineRegex = new(
14 @"^\s*(?<path>[^\s#]+?)\s*=>\s*(?<root>\w+)\s*$",
15 RegexOptions.Compiled | RegexOptions.CultureInvariant);
16
17 private static readonly string[] KnowledgeRootsQueryHints =
18 [
19 "group",
20 "public",
21 "knowledge",
22 "root",
23 "roots",
24 "chmod",
25 "ugo",
26 "multi-root",
27 "multiroot",
28 "knowledge_root",
29 "read_only",
30 "readonly",
31 "registry",
32 "org-kb",
33 "group-kb",
34 "knowledge-roots"
35 ];
36
37 private readonly record struct KnowledgeRootRegistryEntry(string RelativePath, string RootId, bool IsPrefix);
38
39 private void AppendKnowledgeRootsOverlayCandidates(
40 List<(string id, string content, int score, int matchCount)> candidates,
41 IReadOnlyList<string> tokens,
42 string query,
43 IReadOnlyDictionary<string, string> sections,
44 out bool overlayApplied,
45 out int registryHits)
46 {
47 overlayApplied = false;
48 registryHits = 0;
49
50 if (!AgentNotesRuntime.IsConfigured)
51 return;
52
53 if (AgentNotesRuntime.Settings.ReadOnlyKnowledgeRoots.Count == 0)
54 return;
55
56 var queryTouches = QueryTouchesKnowledgeRootsRouting(query, tokens);
57 var registry = LoadKnowledgeRootsIndex();
58 var registryMatches = new List<(string path, string rootId, int score, bool isPrefix)>();
59
60 foreach (var entry in registry)
61 {
62 if (!IsConfiguredReadOnlyRoot(entry.RootId))
63 continue;
64
65 var score = ScoreKnowledgeRootRegistryEntry(entry, tokens, query);
66 if (score <= 0)
67 continue;
68
69 registryMatches.Add((entry.RelativePath, entry.RootId, score, entry.IsPrefix));
70 }
71
72 registryHits = registryMatches.Count;
73 if (!queryTouches && registryMatches.Count == 0)
74 return;
75
76 overlayApplied = true;
77
78 if (sections.TryGetValue(KnowledgeRootsRoutingSectionId, out var routingContent))
79 {
80 var routingScore = registryMatches.Count > 0 ? 52 : queryTouches ? 44 : 36;
81 var routingMatches = CountMatches(routingContent, tokens) + (queryTouches ? 2 : 0);
82 var existingIndex = candidates.FindIndex(c =>
83 string.Equals(c.id, KnowledgeRootsRoutingSectionId, StringComparison.Ordinal));
84 if (existingIndex >= 0)
85 {
86 var cur = candidates[existingIndex];
87 if (routingScore > cur.score)
88 {
89 candidates[existingIndex] = (
90 cur.id,
91 cur.content,
92 routingScore,
93 Math.Max(cur.matchCount, routingMatches));
94 }
95 }
96 else
97 {
98 candidates.Add((
99 KnowledgeRootsRoutingSectionId,
100 routingContent,
101 routingScore,
102 Math.Max(1, routingMatches)));
103 }
104 }
105
106 foreach (var (path, rootId, score, isPrefix) in registryMatches
107 .OrderByDescending(x => x.score)
108 .ThenBy(x => x.path, StringComparer.Ordinal)
109 .Take(MaxRegistryOverlayEntries))
110 {
111 var id = BuildKnowledgeRootOverlaySectionId(rootId, path, isPrefix);
112 if (candidates.Any(c => string.Equals(c.id, id, StringComparison.Ordinal)))
113 continue;
114
115 var preview = TryReadKnowledgeRegistryPreview(rootId, path, isPrefix);
116
117 var content = BuildKnowledgeRootRegistryOverlayContent(path, rootId, isPrefix, preview);
118 var matchCount = Math.Max(1, CountPathTokenMatches(path, rootId, tokens, isPrefix));
119 candidates.Add((id, content, score + 32, matchCount));
120 }
121 }
122
123 private static bool QueryTouchesKnowledgeRootsRouting(string query, IReadOnlyList<string> tokens)
124 {
125 foreach (var hint in KnowledgeRootsQueryHints)
126 {
127 if (query.Contains(hint, StringComparison.OrdinalIgnoreCase))
128 return true;
129 }
130
131 foreach (var token in tokens)
132 {
133 foreach (var hint in KnowledgeRootsQueryHints)
134 {
135 if (token.Contains(hint, StringComparison.OrdinalIgnoreCase)
136 || hint.Contains(token, StringComparison.OrdinalIgnoreCase))
137 return true;
138 }
139 }
140
141 return false;
142 }
143
144 private static int ScoreKnowledgeRootRegistryEntry(
145 KnowledgeRootRegistryEntry entry,
146 IReadOnlyList<string> tokens,
147 string query)
148 {
149 var score = CountMatches(entry.RelativePath, tokens) * 4
150 + CountMatches(entry.RootId, tokens) * 6;
151
152 if (entry.RelativePath.Contains(query, StringComparison.OrdinalIgnoreCase))
153 score += 24;
154 if (entry.RootId.Contains(query, StringComparison.OrdinalIgnoreCase))
155 score += 16;
156
157 foreach (var token in tokens)
158 {
159 if (entry.RelativePath.Contains(token, StringComparison.OrdinalIgnoreCase))
160 score += 4;
161 if (string.Equals(entry.RootId, token, StringComparison.OrdinalIgnoreCase))
162 score += 8;
163 }
164
165 if (entry.IsPrefix)
166 score += ScorePrefixRegistryEntry(entry.RelativePath, tokens, query);
167
168 return score;
169 }
170
171 private static int ScorePrefixRegistryEntry(
172 string prefixPath,
173 IReadOnlyList<string> tokens,
174 string query)
175 {
176 var score = 0;
177 var segments = prefixPath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
178
179 foreach (var token in tokens)
180 {
181 if (token.Length < 2)
182 continue;
183
184 foreach (var segment in segments)
185 {
186 if (string.Equals(segment, token, StringComparison.OrdinalIgnoreCase))
187 score += 12;
188 else if (segment.Contains(token, StringComparison.OrdinalIgnoreCase))
189 score += 6;
190 }
191
192 if (prefixPath.Contains(token, StringComparison.OrdinalIgnoreCase))
193 score += 4;
194 }
195
196 if (query.Length >= 3 && prefixPath.Contains(query, StringComparison.OrdinalIgnoreCase))
197 score += 16;
198
199 return score;
200 }
201
202 private static int CountPathTokenMatches(
203 string path,
204 string rootId,
205 IReadOnlyList<string> tokens,
206 bool isPrefix)
207 {
208 var count = 0;
209 foreach (var token in tokens)
210 {
211 if (path.Contains(token, StringComparison.OrdinalIgnoreCase)
212 || string.Equals(rootId, token, StringComparison.OrdinalIgnoreCase))
213 count++;
214 }
215
216 if (isPrefix && count == 0 && tokens.Count > 0)
217 count = 1;
218
219 return count;
220 }
221
222 private string? TryReadKnowledgeRegistryPreview(string rootId, string path, bool isPrefix)
223 {
224 if (!isPrefix)
225 {
226 try
227 {
228 var exact = ReadKnowledgeFile(null, path, 1, KnowledgeRootPreviewMaxLines, knowledgeRootId: rootId);
229 return string.IsNullOrWhiteSpace(exact) ? null : exact;
230 }
231 catch
232 {
233 return null;
234 }
235 }
236
237 var normalized = path.TrimEnd('/');
238 foreach (var candidate in new[]
239 {
240 $"{normalized}/README.md",
241 $"{normalized}/scope-contour-map-v1.md",
242 })
243 {
244 try
245 {
246 var text = ReadKnowledgeFile(null, candidate, 1, KnowledgeRootPreviewMaxLines, knowledgeRootId: rootId);
247 if (!string.IsNullOrWhiteSpace(text))
248 return text;
249 }
250 catch
251 {
252 // try next candidate under prefix
253 }
254 }
255
256 return null;
257 }
258
259 private static bool IsConfiguredReadOnlyRoot(string rootId) =>
260 AgentNotesRuntime.Settings.ReadOnlyKnowledgeRoots
261 .Any(r => string.Equals(r.Id, rootId, StringComparison.OrdinalIgnoreCase));
262
263 private IReadOnlyList<KnowledgeRootRegistryEntry> LoadKnowledgeRootsIndex()
264 {
265 if (!AgentNotesRuntime.TryGetPrimaryKnowledgeRoot(out var primaryRoot))
266 return [];
267
268 var fullPath = Path.Combine(primaryRoot, KnowledgeDirName, KnowledgeRootsIndexRelativePath);
269 if (!File.Exists(fullPath))
270 return [];
271
272 var lines = File.ReadAllLines(fullPath, Encoding.UTF8);
273 var entries = new List<KnowledgeRootRegistryEntry>();
274 foreach (var line in lines)
275 {
276 var trimmed = line.Trim();
277 if (trimmed.Length == 0 || trimmed.StartsWith('#'))
278 continue;
279
280 var match = KnowledgeRootIndexLineRegex.Match(trimmed);
281 if (!match.Success)
282 continue;
283
284 var path = match.Groups["path"].Value.Trim().Replace('\\', '/');
285 var root = match.Groups["root"].Value.Trim();
286 if (path.Length == 0 || root.Length == 0)
287 continue;
288 if (string.Equals(root, "user", StringComparison.OrdinalIgnoreCase))
289 continue;
290
291 var isPrefix = path.EndsWith("/", StringComparison.Ordinal);
292 if (isPrefix)
293 path = path.TrimEnd('/');
294
295 entries.Add(new KnowledgeRootRegistryEntry(path, root, isPrefix));
296 }
297
298 return entries;
299 }
300
301 private static string BuildKnowledgeRootOverlaySectionId(string rootId, string relativePath, bool isPrefix)
302 {
303 var safePath = relativePath.Replace('\\', '.').Replace('/', '.');
304 var suffix = isPrefix ? ".prefix" : "";
305 return $"knowledge-root.{rootId}.{safePath}{suffix}";
306 }
307
308 private static string BuildKnowledgeRootRegistryOverlayContent(
309 string relativePath,
310 string rootId,
311 bool isPrefix,
312 string? preview)
313 {
314 var sb = new StringBuilder();
315 sb.AppendLine("**Knowledge root overlay** (from `work/local/knowledge-roots-index-v1.md`).");
316 sb.AppendLine();
317 if (isPrefix)
318 {
319 sb.AppendLine($"- Prefix: `knowledge/{relativePath}/` (all files under this path)");
320 }
321 else
322 {
323 sb.AppendLine($"- Path: `knowledge/{relativePath}`");
324 }
325
326 sb.AppendLine($"- Read: `read_knowledge_file` with `knowledge_root_id={rootId}`");
327 sb.AppendLine("- Write: primary KB only; this root is read-only.");
328 if (!string.IsNullOrWhiteSpace(preview))
329 {
330 sb.AppendLine();
331 sb.AppendLine("Preview:");
332 sb.AppendLine("```");
333 sb.Append(preview.TrimEnd());
334 sb.AppendLine();
335 sb.AppendLine("```");
336 }
337
338 return sb.ToString().TrimEnd();
339 }
340}
341
View only · write via MCP/CIDE