Files
meeting-assistant/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs
T
codex 250d3b7a1e
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
Generalize settings and logs assistant
2026-05-30 12:57:51 +02:00

1127 lines
39 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using MeetingAssistant.Logging;
using MeetingAssistant.Speakers;
using Microsoft.EntityFrameworkCore;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
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;
public WorkflowRulesEditorTools(
MeetingAssistantOptions options,
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = 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, int? tail = null)
{
if (rulesPath is null)
{
return "Workflow rules file is not configured.";
}
if (!File.Exists(rulesPath))
{
return "";
}
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(rulesPath), from, to, tail);
}
public async Task<string> WriteRules(string yaml, bool replace_file = false)
{
if (rulesPath is null)
{
return "Refused: workflow rules file is not configured.";
}
if (yaml is null)
{
return "Refused: yaml must not be null.";
}
var existing = File.Exists(rulesPath)
? await File.ReadAllTextAsync(rulesPath)
: "";
var updated = replace_file
? yaml
: AgentFileToolContent.AppendContent(existing, yaml);
var validation = ValidateYaml(updated);
if (validation is not null)
{
return validation;
}
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
await File.WriteAllTextAsync(rulesPath, updated);
return rulesPath;
}
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);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identities = await LoadIdentities(context)
.OrderBy(identity => identity.CanonicalName ?? "")
.ThenBy(identity => identity.Id)
.ToListAsync();
if (!string.IsNullOrWhiteSpace(query))
{
var needle = query.Trim();
identities = identities
.Where(identity => IdentityMatches(identity, needle))
.ToList();
}
return ToJson(identities
.Take(Math.Clamp(limit, 1, 100))
.Select(ToIdentitySummary)
.ToList());
}
}
public async Task<string> ReadIdentity(int identityId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identity = await LoadIdentities(context)
.SingleOrDefaultAsync(identity => identity.Id == identityId);
return identity is null
? $"Identity {identityId} was not found."
: ToJson(ToIdentityDetail(identity));
}
}
public async Task<string> CreateIdentity(
string? canonicalName = null,
string[]? aliases = null,
string[]? candidateNames = null)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var now = DateTimeOffset.UtcNow;
var identity = new SpeakerIdentity
{
CanonicalName = NormalizeNullable(canonicalName),
CreatedAt = now,
UpdatedAt = now,
Aliases = NormalizeNames(aliases)
.Select(name => new SpeakerAlias { Name = name })
.ToList(),
CandidateNames = NormalizeNames(candidateNames)
.Select(name => new SpeakerCandidateName { Name = name })
.ToList()
};
context.SpeakerIdentities.Add(identity);
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(identity));
}
}
public async Task<string> UpdateIdentity(
int identityId,
string? canonicalName = null,
string[]? aliases = null,
string[]? candidateNames = null)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identity = await LoadIdentities(context)
.SingleOrDefaultAsync(identity => identity.Id == identityId);
if (identity is null)
{
return $"Identity {identityId} was not found.";
}
identity.CanonicalName = NormalizeNullable(canonicalName);
identity.Aliases.Clear();
identity.Aliases.AddRange(NormalizeNames(aliases)
.Select(name => new SpeakerAlias { Name = name }));
identity.CandidateNames.Clear();
identity.CandidateNames.AddRange(NormalizeNames(candidateNames)
.Select(name => new SpeakerCandidateName { Name = name }));
identity.UpdatedAt = DateTimeOffset.UtcNow;
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(identity));
}
}
public async Task<string> DeleteIdentity(int identityId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identity = await LoadIdentities(context)
.SingleOrDefaultAsync(identity => identity.Id == identityId);
if (identity is null)
{
return $"Identity {identityId} was not found.";
}
context.SpeakerIdentities.Remove(identity);
await context.SaveChangesAsync();
return $"Deleted identity {identityId}.";
}
}
public async Task<string> MergeIdentities(int targetIdentityId, int sourceIdentityId)
{
if (targetIdentityId == sourceIdentityId)
{
return "Refused: target and source identity are the same.";
}
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var target = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == targetIdentityId);
var source = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == sourceIdentityId);
if (target is null || source is null)
{
return $"Could not find target {targetIdentityId} or source {sourceIdentityId}.";
}
SpeakerIdentityMerger.MergeInto(
target,
source,
speakerOptions.MaxSnippetsPerSpeaker);
context.SpeakerIdentities.Remove(source);
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(target));
}
}
public async Task<string> ListIdentitySamples(int identityId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var samples = await context.SpeakerSnippets
.Where(sample => sample.SpeakerIdentityId == identityId)
.ToListAsync();
return ToJson(samples
.OrderBy(sample => sample.CreatedAt)
.Select(ToIdentitySampleSummary)
.ToList());
}
}
public async Task<string> ReadIdentitySample(int sampleId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
return sample is null
? $"Sample {sampleId} was not found."
: ToJson(new IdentitySampleDetail(
sample.Id,
sample.SpeakerIdentityId,
sample.CreatedAt,
sample.WavBytes.Length,
Convert.ToBase64String(sample.WavBytes)));
}
}
public async Task<string> DeleteIdentitySample(int sampleId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
if (sample is null)
{
return $"Sample {sampleId} was not found.";
}
context.SpeakerSnippets.Remove(sample);
await context.SaveChangesAsync();
return $"Deleted sample {sampleId}.";
}
}
public async Task<string> QueuePlayIdentitySample(int sampleId)
{
if (samplePlaybackQueue is null)
{
return "Sample playback queue is not configured.";
}
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
return sample is null
? $"Sample {sampleId} was not found."
: await samplePlaybackQueue.QueueAsync(sample.Id, sample.WavBytes);
}
}
private static string? ValidateYaml(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
return null;
}
try
{
_ = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml);
return null;
}
catch (YamlException exception)
{
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
}
}
private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName)
{
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 "Refused: appsettings JSON must not be blank.";
}
try
{
using var _ = JsonDocument.Parse(json);
return null;
}
catch (JsonException exception)
{
return $"Refused: appsettings JSON is invalid. {exception.Message}";
}
}
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "rg",
WorkingDirectory = Path.GetDirectoryName(rulesPath)!,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("--json");
startInfo.ArgumentList.Add("--line-number");
startInfo.ArgumentList.Add("--color");
startInfo.ArgumentList.Add("never");
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add(keywords);
startInfo.ArgumentList.Add(Path.GetFileName(rulesPath));
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode is 0 or 1 ? output : null;
}
catch
{
return null;
}
}
private static string FormatRipgrepJson(string output)
{
var matches = new List<string>();
using var reader = new StringReader(output);
string? line;
while ((line = reader.ReadLine()) is not null)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var type) ||
type.GetString() != "match" ||
!root.TryGetProperty("data", out var data))
{
continue;
}
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($"{AgentFileToolContent.ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
}
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(string rulesPath, string keywords)
{
try
{
var regex = new Regex(keywords, RegexOptions.IgnoreCase);
var matches = new List<string>();
var lines = File.ReadAllLines(rulesPath);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{Path.GetFileName(rulesPath)}:{index + 1} {lines[index]}");
}
}
return string.Join('\n', matches);
}
catch
{
return "";
}
}
private ProjectFileTarget? ResolveExistingProjectFileTarget(string project, string path)
{
var projectRoot = ResolveExistingProjectRoot(project);
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
{
return null;
}
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath)
? new ProjectFileTarget(Path.GetFileName(projectRoot), projectRoot, fullPath)
: null;
}
private string? ResolveExistingProjectFilePath(string project, string path)
{
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)
{
return dbContextFactory is null
? null
: await dbContextFactory.CreateDbContextAsync(cancellationToken);
}
private async Task<SpeakerIdentityDbContext?> CreatePreparedIdentityContextAsync(CancellationToken cancellationToken)
{
var context = await CreateIdentityContextAsync(cancellationToken);
if (context is null)
{
return null;
}
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
return context;
}
private static IQueryable<SpeakerIdentity> LoadIdentities(SpeakerIdentityDbContext context)
{
return context.SpeakerIdentities
.Include(identity => identity.Aliases)
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Snippets)
.Include(identity => identity.References);
}
private static bool IdentityMatches(SpeakerIdentity identity, string query)
{
return new[] { identity.CanonicalName }
.Concat(identity.Aliases.Select(alias => alias.Name))
.Concat(identity.CandidateNames.Select(candidate => candidate.Name))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Any(value => value!.Contains(query, StringComparison.OrdinalIgnoreCase));
}
private static IdentitySummary ToIdentitySummary(SpeakerIdentity identity)
{
return new IdentitySummary(
identity.Id,
identity.CanonicalName,
identity.GetDisplayName(),
identity.Aliases.Select(alias => alias.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
identity.CandidateNames.Select(candidate => candidate.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
identity.Snippets.Count,
identity.References.Count,
identity.UpdatedAt);
}
private static IdentityDetail ToIdentityDetail(SpeakerIdentity identity)
{
return new IdentityDetail(
ToIdentitySummary(identity),
identity.References
.OrderByDescending(reference => reference.CreatedAt)
.Select(reference => new IdentityReferenceDetail(
reference.Id,
reference.MeetingNotePath,
reference.TranscriptPath,
reference.CreatedAt))
.ToArray(),
identity.Snippets
.OrderBy(sample => sample.CreatedAt)
.Select(ToIdentitySampleSummary)
.ToArray());
}
private static IdentitySampleSummary ToIdentitySampleSummary(SpeakerSnippet sample)
{
return new IdentitySampleSummary(
sample.Id,
sample.SpeakerIdentityId,
sample.CreatedAt,
sample.WavBytes.Length);
}
private static IReadOnlyList<string> NormalizeNames(IEnumerable<string>? names)
{
return names?
.Select(name => name.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray()
?? [];
}
private static string? NormalizeNullable(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static string ToJson<T>(T value)
{
return JsonSerializer.Serialize(value, JsonOptions);
}
private sealed record IdentitySummary(
int Id,
string? CanonicalName,
string? DisplayName,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> CandidateNames,
int SampleCount,
int ReferenceCount,
DateTimeOffset UpdatedAt);
private sealed record IdentityDetail(
IdentitySummary Identity,
IReadOnlyList<IdentityReferenceDetail> References,
IReadOnlyList<IdentitySampleSummary> Samples);
private sealed record IdentityReferenceDetail(
int Id,
string MeetingNotePath,
string TranscriptPath,
DateTimeOffset CreatedAt);
private sealed record IdentitySampleSummary(
int Id,
int IdentityId,
DateTimeOffset CreatedAt,
int ByteCount);
private sealed record IdentitySampleDetail(
int Id,
int IdentityId,
DateTimeOffset CreatedAt,
int ByteCount,
string Base64Wav);
private sealed record ProjectFileTarget(string ProjectName, string ProjectRoot, string Path);
}