Public Access
Generalize settings and logs assistant
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.Logging;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YamlDotNet.Core;
|
||||
@@ -21,6 +22,12 @@ public sealed class WorkflowRulesEditorTools
|
||||
};
|
||||
|
||||
private readonly string? rulesPath;
|
||||
private readonly string configPath;
|
||||
private readonly string configDocsPath;
|
||||
private readonly string logDirectory;
|
||||
private readonly string specRootPath;
|
||||
private readonly string projectsRootPath;
|
||||
private readonly string projectAgentsTemplatePath;
|
||||
private readonly SpeakerIdentificationOptions speakerOptions;
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory;
|
||||
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
|
||||
@@ -28,15 +35,26 @@ public sealed class WorkflowRulesEditorTools
|
||||
public WorkflowRulesEditorTools(
|
||||
MeetingAssistantOptions options,
|
||||
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
|
||||
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
|
||||
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null,
|
||||
string? configPath = null,
|
||||
string? configDocsPath = null,
|
||||
string? logDirectory = null,
|
||||
string? specRootPath = null,
|
||||
string? projectAgentsTemplatePath = null)
|
||||
{
|
||||
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||
this.configPath = ResolveConfigPath(configPath);
|
||||
this.configDocsPath = ResolveConfigDocsPath(configDocsPath);
|
||||
this.logDirectory = logDirectory ?? MeetingAssistantLogFiles.DefaultLogDirectory;
|
||||
this.specRootPath = ResolveSpecRootPath(specRootPath);
|
||||
projectsRootPath = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
this.projectAgentsTemplatePath = ResolveProjectAgentsTemplatePath(projectAgentsTemplatePath);
|
||||
speakerOptions = options.SpeakerIdentification;
|
||||
this.dbContextFactory = dbContextFactory;
|
||||
this.samplePlaybackQueue = samplePlaybackQueue;
|
||||
}
|
||||
|
||||
public async Task<string> ReadRules(int? from = null, int? to = null)
|
||||
public async Task<string> ReadRules(int? from = null, int? to = null, int? tail = null)
|
||||
{
|
||||
if (rulesPath is null)
|
||||
{
|
||||
@@ -48,7 +66,7 @@ public sealed class WorkflowRulesEditorTools
|
||||
return "";
|
||||
}
|
||||
|
||||
return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to);
|
||||
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(rulesPath), from, to, tail);
|
||||
}
|
||||
|
||||
public async Task<string> WriteRules(string yaml, bool replace_file = false)
|
||||
@@ -68,7 +86,7 @@ public sealed class WorkflowRulesEditorTools
|
||||
: "";
|
||||
var updated = replace_file
|
||||
? yaml
|
||||
: AppendContent(existing, yaml);
|
||||
: AgentFileToolContent.AppendContent(existing, yaml);
|
||||
var validation = ValidateYaml(updated);
|
||||
if (validation is not null)
|
||||
{
|
||||
@@ -80,19 +98,259 @@ public sealed class WorkflowRulesEditorTools
|
||||
return rulesPath;
|
||||
}
|
||||
|
||||
public async Task<string> Search(string keywords)
|
||||
public async Task<string> Search(string keywords, string? file_pattern = null)
|
||||
{
|
||||
if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!AgentFileToolContent.MatchesGlob(Path.GetFileName(rulesPath), file_pattern))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var rgResult = await RunRipgrepAsync(rulesPath, keywords);
|
||||
return rgResult is not null
|
||||
? FormatRipgrepJson(rgResult)
|
||||
: SearchWithRegexFallback(rulesPath, keywords);
|
||||
}
|
||||
|
||||
public async Task<string> ReadConfig(int? from = null, int? to = null, int? tail = null)
|
||||
{
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return $"Configuration file was not found: {configPath}";
|
||||
}
|
||||
|
||||
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configPath), from, to, tail);
|
||||
}
|
||||
|
||||
public async Task<string> WriteConfig(string json)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return "Refused: json must not be null.";
|
||||
}
|
||||
|
||||
var validation = ValidateJson(json);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
||||
await File.WriteAllTextAsync(configPath, json);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
public async Task<string> ReadConfigDocs(int? from = null, int? to = null, int? tail = null)
|
||||
{
|
||||
if (!File.Exists(configDocsPath))
|
||||
{
|
||||
return "Configuration documentation file docs/meeting-assistant-configuration.md was not available at runtime.";
|
||||
}
|
||||
|
||||
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configDocsPath), from, to, tail);
|
||||
}
|
||||
|
||||
public async Task<string> ReadLogs(
|
||||
string? logFile = null,
|
||||
int? from = null,
|
||||
int? to = null,
|
||||
int tail = 200)
|
||||
{
|
||||
var path = ResolveLogFilePath(logFile);
|
||||
if (path is null)
|
||||
{
|
||||
return $"Log file was not found: {logFile ?? MeetingAssistantLogFiles.CurrentLogFileName}";
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
if (from.HasValue || to.HasValue)
|
||||
{
|
||||
return AgentFileToolContent.ReadLines(content, from, to);
|
||||
}
|
||||
|
||||
return AgentFileToolContent.TailLines(content, Math.Clamp(tail, 1, 2000));
|
||||
}
|
||||
|
||||
public Task<string> SearchLogs(string keywords, int maxMatches = 100, string? file_pattern = null)
|
||||
{
|
||||
var sources = MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory)
|
||||
.Where(path => AgentFileToolContent.MatchesGlob(Path.GetFileName(path), file_pattern))
|
||||
.Select(path => new AgentFileSearchSource(path, Path.GetFileName(path)));
|
||||
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
|
||||
}
|
||||
|
||||
public async Task<string> ReadSpecFile(string path, int? from = null, int? to = null, int? tail = null)
|
||||
{
|
||||
var resolved = ResolveSpecFilePath(path);
|
||||
if (resolved.Error is not null)
|
||||
{
|
||||
return resolved.Error;
|
||||
}
|
||||
|
||||
if (!File.Exists(resolved.Path))
|
||||
{
|
||||
return $"Spec file was not found: {AgentFileToolContent.ToToolPath(path)}";
|
||||
}
|
||||
|
||||
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(resolved.Path), from, to, tail);
|
||||
}
|
||||
|
||||
public Task<string> SearchSpec(string keywords, int maxMatches = 100, string? file_pattern = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(specRootPath))
|
||||
{
|
||||
return Task.FromResult("");
|
||||
}
|
||||
|
||||
var sources = Directory.EnumerateFiles(specRootPath, "*.md", SearchOption.AllDirectories)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => new AgentFileSearchSource(
|
||||
path,
|
||||
AgentFileToolContent.ToToolPath(Path.GetRelativePath(specRootPath, path))))
|
||||
.Where(source => AgentFileToolContent.MatchesGlob(source.DisplayPath, file_pattern));
|
||||
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
|
||||
}
|
||||
|
||||
public Task<string> ListProjects()
|
||||
{
|
||||
if (!Directory.Exists(projectsRootPath))
|
||||
{
|
||||
return Task.FromResult("");
|
||||
}
|
||||
|
||||
var projects = Directory.EnumerateDirectories(projectsRootPath)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Order(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult(string.Join('\n', projects));
|
||||
}
|
||||
|
||||
public Task<string> ListProjectFiles(string project)
|
||||
{
|
||||
var projectRoot = ResolveExistingProjectRoot(project);
|
||||
if (projectRoot is null)
|
||||
{
|
||||
return Task.FromResult("Refused: project does not exist.");
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
|
||||
.Select(path => AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path)))
|
||||
.Order(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult(string.Join('\n', files));
|
||||
}
|
||||
|
||||
public async Task<string> ReadProjectFile(
|
||||
string project,
|
||||
string path,
|
||||
int? from = null,
|
||||
int? to = null,
|
||||
int? tail = null)
|
||||
{
|
||||
var filePath = ResolveExistingProjectFilePath(project, path);
|
||||
if (filePath is null)
|
||||
{
|
||||
return "Refused: project does not exist or the path escapes the project folder.";
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return $"Project file was not found: {project}/{AgentFileToolContent.ToToolPath(path)}";
|
||||
}
|
||||
|
||||
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), from, to, tail);
|
||||
}
|
||||
|
||||
public async Task<string> WriteProjectFile(
|
||||
string project,
|
||||
string path,
|
||||
string content,
|
||||
int? from = null,
|
||||
int? to = null,
|
||||
int? insert = null,
|
||||
bool replace_file = false)
|
||||
{
|
||||
var editMode = AgentFileEditMode.Create(from, to, insert, replace_file);
|
||||
if (editMode is null)
|
||||
{
|
||||
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
|
||||
}
|
||||
|
||||
var target = ResolveExistingProjectFileTarget(project, path);
|
||||
if (target is null)
|
||||
{
|
||||
return "Refused: project does not exist or the path escapes the project folder.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
||||
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
|
||||
return $"{target.ProjectName}/{AgentFileToolContent.ToToolPath(path)}";
|
||||
}
|
||||
|
||||
public Task<string> SearchProjects(string keywords, int maxMatches = 100, string? file_pattern = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(projectsRootPath))
|
||||
{
|
||||
return Task.FromResult("");
|
||||
}
|
||||
|
||||
var sources = Directory.EnumerateDirectories(projectsRootPath)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(projectRoot =>
|
||||
{
|
||||
var projectName = Path.GetFileName(projectRoot);
|
||||
return Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path =>
|
||||
{
|
||||
var relativePath = AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path));
|
||||
return new AgentFileSearchSource(path, $"{projectName}/{relativePath}");
|
||||
})
|
||||
.Where(source => AgentFileToolContent.MatchesGlob(
|
||||
source.DisplayPath[(source.DisplayPath.IndexOf('/', StringComparison.Ordinal) + 1)..],
|
||||
file_pattern));
|
||||
});
|
||||
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
|
||||
}
|
||||
|
||||
public async Task<string> CreateProject(string name, string seed = "recommended", string? agents_md = null)
|
||||
{
|
||||
var projectRoot = ResolveNewProjectRoot(name);
|
||||
if (projectRoot.Error is not null)
|
||||
{
|
||||
return projectRoot.Error;
|
||||
}
|
||||
|
||||
var normalizedSeed = string.IsNullOrWhiteSpace(seed) ? "recommended" : seed.Trim().ToLowerInvariant();
|
||||
if (normalizedSeed is not ("recommended" or "agents_md" or "none"))
|
||||
{
|
||||
return "Refused: seed must be recommended, agents_md, or none.";
|
||||
}
|
||||
|
||||
if (normalizedSeed == "agents_md" && string.IsNullOrWhiteSpace(agents_md))
|
||||
{
|
||||
return "Refused: agents_md seed requires AGENTS.md content.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(projectRoot.Path!);
|
||||
switch (normalizedSeed)
|
||||
{
|
||||
case "recommended":
|
||||
await WriteRecommendedProjectSeedAsync(projectRoot.Path!, projectRoot.Name!);
|
||||
break;
|
||||
case "agents_md":
|
||||
await File.WriteAllTextAsync(Path.Combine(projectRoot.Path!, "AGENTS.md"), agents_md!);
|
||||
break;
|
||||
case "none":
|
||||
break;
|
||||
}
|
||||
|
||||
return projectRoot.Name!;
|
||||
}
|
||||
|
||||
public async Task<string> SearchIdentities(string? query = null, int limit = 25)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
@@ -365,22 +623,115 @@ public sealed class WorkflowRulesEditorTools
|
||||
}
|
||||
}
|
||||
|
||||
private static string AppendContent(string existingContent, string content)
|
||||
private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(existingContent))
|
||||
var agentsContent = File.Exists(projectAgentsTemplatePath)
|
||||
? await File.ReadAllTextAsync(projectAgentsTemplatePath)
|
||||
: DefaultProjectAgentsContent;
|
||||
await File.WriteAllTextAsync(Path.Combine(projectRoot, "AGENTS.md"), agentsContent);
|
||||
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), CreateDefaultProjectFile(projectName));
|
||||
await File.WriteAllTextAsync(Path.Combine(projectRoot, "JOURNAL.md"), CreateDefaultJournalFile(projectName));
|
||||
await File.WriteAllTextAsync(Path.Combine(projectRoot, "DECISIONS.md"), CreateDefaultDecisionsFile());
|
||||
}
|
||||
|
||||
private static string CreateDefaultProjectFile(string projectName)
|
||||
{
|
||||
return $"""
|
||||
# Executive Summary
|
||||
|
||||
Project knowledge base for {projectName}.
|
||||
|
||||
## Business Goals
|
||||
|
||||
## Priorities and High Level Constraints
|
||||
|
||||
## Current Phase & Status
|
||||
|
||||
## Next Milestones
|
||||
|
||||
## Open Risks
|
||||
|
||||
## Important Stakeholders
|
||||
|
||||
## Key Documents
|
||||
|
||||
Links to: Architecture Overview, Technical Onboarding, etc.
|
||||
|
||||
[[JOURNAL.md]]
|
||||
[[DECISIONS.md]]
|
||||
""";
|
||||
}
|
||||
|
||||
private static string CreateDefaultJournalFile(string projectName)
|
||||
{
|
||||
return $"""
|
||||
## {DateTimeOffset.Now:yyyy-MM-dd}
|
||||
### Project Setup
|
||||
|
||||
The project folder was created for {projectName}.
|
||||
""";
|
||||
}
|
||||
|
||||
private static string CreateDefaultDecisionsFile()
|
||||
{
|
||||
return """
|
||||
# Decisions
|
||||
|
||||
Important decisions belong here when they are expensive to change or materially affect timeline, goals, governance, team, or process.
|
||||
|
||||
## How are decisions recorded?
|
||||
Important decisions are summarized with the question, the decision, and a source link to an ADR or meeting summary.
|
||||
""";
|
||||
}
|
||||
|
||||
private const string DefaultProjectAgentsContent = """
|
||||
The project is split in 3 important files:
|
||||
PROJECT.md
|
||||
DECISIONS.md
|
||||
JOURNAL.md
|
||||
|
||||
The idea is a hierarchical summary:
|
||||
|
||||
Meeting Summary -> detailed (what was discussed)
|
||||
then Journal Entry -> compressed (why should I care this event happened?)
|
||||
then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.)
|
||||
|
||||
The journal should always be appended and read using tail or searched.
|
||||
|
||||
Journal entries use this shape:
|
||||
|
||||
```markdown
|
||||
## 2026-05-30
|
||||
### Team Daily
|
||||
|
||||
New Blocker: HLS delayed
|
||||
|
||||
Impact: Release likely delayed, team implements Z-Levels in the meantime.
|
||||
|
||||
[[20260529-093030-6187486-summary|Summary]]
|
||||
```
|
||||
|
||||
DECISIONS.md logs only important decisions in a compressed way, with a link to ADRs and/or meeting summaries for details.
|
||||
Important decisions are architecture decisions that are expensive to change, or project decisions about timeline, goals, governance, team, or process.
|
||||
Do not log small implementation details such as how UI elements are aligned.
|
||||
""";
|
||||
|
||||
private static string? ValidateJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return content;
|
||||
return "Refused: appsettings JSON must not be blank.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(content))
|
||||
try
|
||||
{
|
||||
return existingContent;
|
||||
using var _ = JsonDocument.Parse(json);
|
||||
return null;
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
return $"Refused: appsettings JSON is invalid. {exception.Message}";
|
||||
}
|
||||
|
||||
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
|
||||
? ""
|
||||
: "\n";
|
||||
return existingContent + separator + content;
|
||||
}
|
||||
|
||||
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
|
||||
@@ -438,7 +789,7 @@ public sealed class WorkflowRulesEditorTools
|
||||
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
|
||||
var lineNumber = data.GetProperty("line_number").GetInt32();
|
||||
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
|
||||
matches.Add($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
|
||||
matches.Add($"{AgentFileToolContent.ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
|
||||
}
|
||||
|
||||
return string.Join('\n', matches);
|
||||
@@ -467,37 +818,175 @@ public sealed class WorkflowRulesEditorTools
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadLines(string content, int? from = null, int? to = null)
|
||||
private ProjectFileTarget? ResolveExistingProjectFileTarget(string project, string path)
|
||||
{
|
||||
if (!from.HasValue && !to.HasValue)
|
||||
var projectRoot = ResolveExistingProjectRoot(project);
|
||||
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
||||
{
|
||||
return content;
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = content
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.ToList();
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
|
||||
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
|
||||
if (end < start)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return string.Join('\n', lines.GetRange(start, end - start + 1));
|
||||
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
|
||||
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath)
|
||||
? new ProjectFileTarget(Path.GetFileName(projectRoot), projectRoot, fullPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ToToolPath(string path)
|
||||
private string? ResolveExistingProjectFilePath(string project, string path)
|
||||
{
|
||||
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
|
||||
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
||||
return ResolveExistingProjectFileTarget(project, path)?.Path;
|
||||
}
|
||||
|
||||
private string? ResolveExistingProjectRoot(string project)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project) || !Directory.Exists(projectsRootPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(projectsRootPath)
|
||||
.FirstOrDefault(path => string.Equals(Path.GetFileName(path), project.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private (string? Path, string? Name, string? Error) ResolveNewProjectRoot(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return (null, null, "Refused: project name must not be blank.");
|
||||
}
|
||||
|
||||
var projectName = name.Trim();
|
||||
if (projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
|
||||
projectName.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
||||
projectName.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
|
||||
{
|
||||
return (null, null, "Refused: project name must be a single folder name.");
|
||||
}
|
||||
|
||||
var root = Path.GetFullPath(projectsRootPath);
|
||||
var candidate = Path.GetFullPath(Path.Combine(root, projectName));
|
||||
if (!AgentFileToolContent.IsWithinDirectory(root, candidate))
|
||||
{
|
||||
return (null, null, $"Refused: project path must stay inside {root}.");
|
||||
}
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return (null, null, $"Refused: project already exists: {projectName}.");
|
||||
}
|
||||
|
||||
return (candidate, projectName, null);
|
||||
}
|
||||
|
||||
private string? ResolveLogFilePath(string? logFile)
|
||||
{
|
||||
var requestedFileName = string.IsNullOrWhiteSpace(logFile)
|
||||
? MeetingAssistantLogFiles.CurrentLogFileName
|
||||
: Path.GetFileName(logFile.Trim());
|
||||
return MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory)
|
||||
.SingleOrDefault(path => string.Equals(
|
||||
Path.GetFileName(path),
|
||||
requestedFileName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ResolveConfigPath(string? configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.GetFullPath(configuredPath);
|
||||
}
|
||||
|
||||
var basePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (File.Exists(basePath))
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
return Path.GetFullPath("appsettings.json");
|
||||
}
|
||||
|
||||
private static string ResolveConfigDocsPath(string? configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.GetFullPath(configuredPath);
|
||||
}
|
||||
|
||||
foreach (var path in RuntimeContentLocator.CandidateDocumentationPaths("meeting-assistant-configuration.md"))
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, "docs", "meeting-assistant-configuration.md");
|
||||
}
|
||||
|
||||
private static string ResolveSpecRootPath(string? configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.GetFullPath(configuredPath);
|
||||
}
|
||||
|
||||
foreach (var path in RuntimeContentLocator.CandidateDirectoryPaths("openspec", "specs"))
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, "openspec", "specs");
|
||||
}
|
||||
|
||||
private static string ResolveProjectAgentsTemplatePath(string? configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.GetFullPath(configuredPath);
|
||||
}
|
||||
|
||||
foreach (var path in RuntimeContentLocator.CandidateContentPaths("Project-AGENTS.md"))
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, "Content", "Project-AGENTS.md");
|
||||
}
|
||||
|
||||
private (string? Path, string? Error) ResolveSpecFilePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return (null, "Refused: spec path must not be blank.");
|
||||
}
|
||||
|
||||
var root = Path.GetFullPath(specRootPath);
|
||||
var candidate = Path.GetFullPath(Path.Combine(
|
||||
root,
|
||||
path.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar)));
|
||||
var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
|
||||
? root
|
||||
: root + Path.DirectorySeparatorChar;
|
||||
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(candidate, root, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, $"Refused: spec path must stay inside {root}.");
|
||||
}
|
||||
|
||||
if (!candidate.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, "Refused: spec path must point to a markdown spec file.");
|
||||
}
|
||||
|
||||
return (candidate, null);
|
||||
}
|
||||
|
||||
private async Task<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
|
||||
@@ -631,4 +1120,7 @@ public sealed class WorkflowRulesEditorTools
|
||||
DateTimeOffset CreatedAt,
|
||||
int ByteCount,
|
||||
string Base64Wav);
|
||||
|
||||
private sealed record ProjectFileTarget(string ProjectName, string ProjectRoot, string Path);
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user