Forge
csharpfb8e0499
1using System.Text;
2using System.Text.RegularExpressions;
3
4namespace AgentNotes.Core;
5
6public sealed partial class NotesStorage
7{
8 /// <summary>Reads scope alias file (path from TOML <c>[workspace]</c> or embedded defaults). No hardcoded alias table in code.</summary>
9 private static IReadOnlyDictionary<string, string> LoadScopeAliasesMerged()
10 {
11 var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
12 try
13 {
14 var root = ResolveKnowledgeRoot(null);
15 var (_, aliasRel) = ReadWorkspacePathsOrDefaults(root);
16 MergeScopeAliasFile(Path.Combine(root, KnowledgeDirName, aliasRel.Replace('/', Path.DirectorySeparatorChar)), dict);
17 }
18 catch (ArgumentException)
19 {
20 // Canon cannot be resolved — no alias file (e.g. misconfigured env in edge cases).
21 }
22 catch (IOException)
23 {
24 }
25
26 return dict;
27 }
28
29 private static void MergeScopeAliasFile(string path, IDictionary<string, string> sink)
30 {
31 if (!File.Exists(path))
32 return;
33
34 var text = File.ReadAllText(path, Encoding.UTF8);
35 foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
36 {
37 var parsed = ParseScopeMapLine(line);
38 if (parsed is null || !LooksLikeScopeAliasKey(parsed.Value.Item1))
39 continue;
40
41 var alias = parsed.Value.Item1.Trim().ToLowerInvariant();
42 var canonical = parsed.Value.Item2.Trim().ToLowerInvariant();
43 if (alias.Length != 0 && canonical.Length != 0)
44 sink[alias] = canonical;
45 }
46 }
47
48 /// <summary>Alias keys must be single tokens — not filesystem paths (<c>c:\...</c>). Workspace lines in a mis-placed alias file are ignored.</summary>
49 private static bool LooksLikeScopeAliasKey(string key)
50 {
51 var t = key.Trim();
52 if (t.Length == 0)
53 return false;
54 foreach (var c in t)
55 {
56 if (char.IsLetterOrDigit(c) || c is '.' or '_' or '-')
57 continue;
58 return false;
59 }
60
61 return true;
62 }
63
64 /// <summary>Maps legacy shorthand to canonical ids when defined in merged alias dictionary.</summary>
65 private static string NormalizeScope(string scope, IReadOnlyDictionary<string, string> aliases)
66 {
67 var s = scope.Trim().ToLowerInvariant();
68 return aliases.TryGetValue(s, out var mapped) ? mapped : s;
69 }
70
71 private static string ResolveScope(string? requestedScope, IReadOnlyDictionary<string, string> sections, string workspacePath, IReadOnlyDictionary<string, string> aliases)
72 {
73 if (!string.IsNullOrWhiteSpace(requestedScope))
74 return NormalizeScope(requestedScope, aliases);
75
76 var mappedScope = TryResolveScopeFromWorkspaceMap(workspacePath, sections);
77 if (!string.IsNullOrWhiteSpace(mappedScope))
78 return NormalizeScope(mappedScope, aliases);
79
80 if (!sections.TryGetValue("active-scope", out var activeScopeContent))
81 return NormalizeScope("door-to-singularity", aliases);
82
83 var match = Regex.Match(activeScopeContent, @"current\s*:\s*(?<scope>[A-Za-z0-9._-]+)", RegexOptions.IgnoreCase);
84 var raw = match.Success
85 ? match.Groups["scope"].Value.Trim().ToLowerInvariant()
86 : "door-to-singularity";
87 return NormalizeScope(raw, aliases);
88 }
89
90 private static string? TryResolveScopeFromWorkspaceMap(string workspacePath, IReadOnlyDictionary<string, string> sections)
91 {
92 // Prefer machine-local map under canon (single source); else hot sections (legacy).
93 var fromFile = TryLoadWorkspaceScopeMapFromWorkLocal();
94 var sectionPrimary = sections.TryGetValue("workspace-scope-map-v1", out var pm) ? pm : null;
95 var sectionLegacy = sections.TryGetValue("scope-map-v1", out var lm) ? lm : null;
96 var mapContent = !string.IsNullOrWhiteSpace(fromFile)
97 ? fromFile
98 : !string.IsNullOrWhiteSpace(sectionPrimary)
99 ? sectionPrimary
100 : !string.IsNullOrWhiteSpace(sectionLegacy)
101 ? sectionLegacy
102 : null;
103
104 if (string.IsNullOrWhiteSpace(mapContent))
105 return null;
106
107 var normalizedWorkspace = NormalizePathKey(workspacePath);
108 var lines = mapContent.Replace("\r\n", "\n").Split('\n');
109 string? bestScope = null;
110 var bestKeyLength = -1;
111 foreach (var line in lines)
112 {
113 var parsed = ParseScopeMapLine(line);
114 if (parsed is null)
115 continue;
116
117 var (workspaceKey, scope) = parsed.Value;
118 var normalizedKey = NormalizePathKey(workspaceKey);
119 if (!IsPrefixPathMatch(normalizedWorkspace, normalizedKey))
120 continue;
121
122 if (normalizedKey.Length <= bestKeyLength)
123 continue;
124
125 bestKeyLength = normalizedKey.Length;
126 bestScope = scope;
127 }
128
129 return bestScope;
130 }
131
132 /// <summary>Optional map lines (same format as hot section): TOML <c>[workspace]</c>, META JSON, or defaults. Overrides empty/missing hot sections when <see cref="ResolveKnowledgeRoot"/> succeeds.</summary>
133 private static string? TryLoadWorkspaceScopeMapFromWorkLocal()
134 {
135 try
136 {
137 var root = ResolveKnowledgeRoot(null);
138 var (workspaceRel, _) = ReadWorkspacePathsOrDefaults(root);
139 var path = Path.Combine(root, KnowledgeDirName, workspaceRel.Replace('/', Path.DirectorySeparatorChar));
140 if (!File.Exists(path))
141 return null;
142 return File.ReadAllText(path, Encoding.UTF8);
143 }
144 catch (ArgumentException)
145 {
146 return null;
147 }
148 catch (IOException)
149 {
150 return null;
151 }
152 }
153
154 private static (string workspaceKey, string scope)? ParseScopeMapLine(string rawLine)
155 {
156 var line = rawLine.Trim();
157 if (line.Length == 0 || line.StartsWith('#'))
158 return null;
159
160 if (line.StartsWith('-'))
161 line = line[1..].Trim();
162
163 var arrowParts = line.Split("=>", StringSplitOptions.TrimEntries);
164 if (arrowParts.Length == 2)
165 return (arrowParts[0], arrowParts[1].ToLowerInvariant());
166
167 var colonParts = line.Split(':', 2, StringSplitOptions.TrimEntries);
168 if (colonParts.Length == 2)
169 return (colonParts[0], colonParts[1].ToLowerInvariant());
170
171 var eqParts = line.Split('=', 2, StringSplitOptions.TrimEntries);
172 if (eqParts.Length == 2)
173 return (eqParts[0], eqParts[1].ToLowerInvariant());
174
175 return null;
176 }
177
178 private static string NormalizePathKey(string path) =>
179 path.Trim().Replace('/', '\\').TrimEnd('\\');
180
181 private static string ResolveDtsDefaultSectionId(IReadOnlyDictionary<string, string> sections)
182 {
183 if (sections.ContainsKey("scope-door-to-singularity"))
184 return "scope-door-to-singularity";
185 if (sections.ContainsKey("scope-current-projects"))
186 return "scope-current-projects";
187 return "scope-door-to-singularity";
188 }
189
190 private static string ResolveScopeSectionId(string resolvedScope, IReadOnlyDictionary<string, string> sections, IReadOnlyDictionary<string, string> aliases)
191 {
192 if (string.IsNullOrWhiteSpace(resolvedScope))
193 return ResolveDtsDefaultSectionId(sections);
194
195 var normalizedScope = NormalizeScope(resolvedScope.Trim(), aliases);
196 var genericScopeId = $"scope-{normalizedScope}";
197 if (sections.ContainsKey(genericScopeId))
198 return genericScopeId;
199
200 if (normalizedScope == "door-to-singularity" && sections.ContainsKey("scope-current-projects"))
201 return "scope-current-projects";
202
203 return genericScopeId;
204 }
205
206 private static bool IsPrefixPathMatch(string workspacePath, string mapKeyPath)
207 {
208 if (string.Equals(workspacePath, mapKeyPath, StringComparison.OrdinalIgnoreCase))
209 return true;
210
211 if (!workspacePath.StartsWith(mapKeyPath, StringComparison.OrdinalIgnoreCase))
212 return false;
213
214 return workspacePath.Length > mapKeyPath.Length && workspacePath[mapKeyPath.Length] == '\\';
215 }
216}
217
View only · write via MCP/CIDE