using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.Logging; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Recording; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; 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 string summariesRootPath; private readonly string transcriptsRootPath; private readonly string meetingNotesRootPath; private readonly string assistantContextRootPath; private readonly MeetingAssistantOptions options; private readonly SpeakerIdentificationOptions speakerOptions; private readonly IDbContextFactory? dbContextFactory; private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue; private readonly MeetingRecordingCoordinator? recordingCoordinator; private readonly IMeetingMetadataProvider? meetingMetadataProvider; private readonly ILaunchProfileOptionsProvider? launchProfiles; private readonly ISpeakerIdentityMergeService? identityMergeService; private readonly AsrDiagnosticService? asrDiagnosticService; private readonly IConfiguration? configuration; private readonly ILogger? logger; public WorkflowRulesEditorTools( MeetingAssistantOptions options, IDbContextFactory? dbContextFactory = null, IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null, MeetingRecordingCoordinator? recordingCoordinator = null, IMeetingMetadataProvider? meetingMetadataProvider = null, ILaunchProfileOptionsProvider? launchProfiles = null, ISpeakerIdentityMergeService? identityMergeService = null, AsrDiagnosticService? asrDiagnosticService = null, IConfiguration? configuration = null, string? configPath = null, string? configDocsPath = null, string? logDirectory = null, string? specRootPath = null, string? projectAgentsTemplatePath = null, ILogger? logger = null) { this.options = options; 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); summariesRootPath = VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder); transcriptsRootPath = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder); meetingNotesRootPath = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder); assistantContextRootPath = VaultPath.Resolve(options.Vault, options.Vault.AssistantContextFolder); speakerOptions = options.SpeakerIdentification; this.dbContextFactory = dbContextFactory; this.samplePlaybackQueue = samplePlaybackQueue; this.recordingCoordinator = recordingCoordinator; this.meetingMetadataProvider = meetingMetadataProvider; this.launchProfiles = launchProfiles; this.identityMergeService = identityMergeService; this.asrDiagnosticService = asrDiagnosticService; this.configuration = configuration; this.logger = logger; } public async Task 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 WriteRules(string yaml, bool replace_file = false) { if (rulesPath is null) { return LogRefusal(nameof(WriteRules), "Refused: workflow rules file is not configured."); } if (yaml is null) { return LogRefusal(nameof(WriteRules), "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 = await ValidateYamlAsync(updated); if (validation is not null) { return LogRefusal(nameof(WriteRules), validation); } Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!); await File.WriteAllTextAsync(rulesPath, updated); return rulesPath; } public async Task 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 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 WriteConfig(string json) { if (json is null) { return LogRefusal(nameof(WriteConfig), "Refused: json must not be null."); } var validation = ValidateJson(json); if (validation is not null) { return LogRefusal(nameof(WriteConfig), validation); } Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); await File.WriteAllTextAsync(configPath, json); return configPath; } private string LogRefusal(string toolName, string refusal) { logger?.LogWarning( "Workflow rules editor tool {ToolName} refused request: {Refusal}", toolName, refusal); return refusal; } public async Task 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 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 AgentFileToolContent.ReadAllTextSharedAsync(path); if (from.HasValue || to.HasValue) { return AgentFileToolContent.ReadLines(content, from, to); } return AgentFileToolContent.TailLines(content, Math.Clamp(tail, 1, 2000)); } public Task 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 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 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 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 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 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 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 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 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."; } if (normalizedSeed == "recommended" && !File.Exists(projectAgentsTemplatePath)) { return $"Refused: recommended project AGENTS.md template was not found: {projectAgentsTemplatePath}."; } 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 ListRecentSummaries(int limit = 20) { if (!Directory.Exists(summariesRootPath)) { return ""; } var summaries = new List(); foreach (var file in Directory.EnumerateFiles(summariesRootPath, "*.md", SearchOption.TopDirectoryOnly)) { summaries.Add(await ReadMeetingArtifactSummaryAsync(file)); } var lines = summaries .OrderByDescending(summary => summary.SortTime) .ThenByDescending(summary => summary.ToolPath, StringComparer.OrdinalIgnoreCase) .Take(Math.Clamp(limit, 1, 200)) .Select(summary => $"{summary.ToolPath} | {summary.StartTimeText} | title: {summary.Title} | projects: {string.Join(", ", summary.Projects)} | meeting_note: {summary.MeetingNotePath} | transcript: {summary.TranscriptPath} | context: {summary.ContextPath}"); return string.Join('\n', lines); } public Task ReadSummary(string path, int? from = null, int? to = null, int? tail = null) { return ReadMeetingArtifactFileAsync(summariesRootPath, path, "summary", from, to, tail); } public Task ReadTranscript(string path, int? from = null, int? to = null, int? tail = null) { return ReadMeetingArtifactFileAsync(transcriptsRootPath, path, "transcript", from, to, tail); } public Task ReadMeetingNote(string path, int? from = null, int? to = null, int? tail = null) { return ReadMeetingArtifactFileAsync(meetingNotesRootPath, path, "meeting note", from, to, tail); } public Task ReadContext(string path, int? from = null, int? to = null, int? tail = null) { return ReadMeetingArtifactFileAsync(assistantContextRootPath, path, "assistant context", from, to, tail); } public Task WriteSummary( string path, string content, int? from = null, int? to = null, int? insert = null, bool replace_file = false) { return WriteMeetingArtifactFileAsync(summariesRootPath, path, "summary", content, from, to, insert, replace_file); } public Task WriteTranscript( string path, string content, int? from = null, int? to = null, int? insert = null, bool replace_file = false) { return WriteMeetingArtifactFileAsync(transcriptsRootPath, path, "transcript", content, from, to, insert, replace_file); } public Task WriteMeetingNote( string path, string content, int? from = null, int? to = null, int? insert = null, bool replace_file = false) { return WriteMeetingArtifactFileAsync(meetingNotesRootPath, path, "meeting note", content, from, to, insert, replace_file); } public Task WriteContext( string path, string content, int? from = null, int? to = null, int? insert = null, bool replace_file = false) { return WriteMeetingArtifactFileAsync(assistantContextRootPath, path, "assistant context", content, from, to, insert, replace_file); } public Task WriteSummaryFrontmatter(string path, string yaml) { return WriteMeetingArtifactFrontmatterAsync(summariesRootPath, path, "summary", yaml); } public Task WriteTranscriptFrontmatter(string path, string yaml) { return WriteMeetingArtifactFrontmatterAsync(transcriptsRootPath, path, "transcript", yaml); } public Task WriteMeetingNoteFrontmatter(string path, string yaml) { return WriteMeetingArtifactFrontmatterAsync(meetingNotesRootPath, path, "meeting note", yaml); } public Task WriteContextFrontmatter(string path, string yaml) { return WriteMeetingArtifactFrontmatterAsync(assistantContextRootPath, path, "assistant context", yaml); } public Task SearchSummaries(string keywords, int maxMatches = 100, string? file_pattern = null) { return SearchMeetingArtifactFilesAsync(summariesRootPath, keywords, maxMatches, file_pattern); } public Task SearchTranscripts(string keywords, int maxMatches = 100, string? file_pattern = null) { return SearchMeetingArtifactFilesAsync(transcriptsRootPath, keywords, maxMatches, file_pattern); } public Task SearchMeetingNotes(string keywords, int maxMatches = 100, string? file_pattern = null) { return SearchMeetingArtifactFilesAsync(meetingNotesRootPath, keywords, maxMatches, file_pattern); } public Task SearchContext(string keywords, int maxMatches = 100, string? file_pattern = null) { return SearchMeetingArtifactFilesAsync(assistantContextRootPath, keywords, maxMatches, file_pattern); } public string GetHealth() { return ToJson(new { service = "meeting-assistant", status = "ok" }); } public string GetRecordingStatus() { return recordingCoordinator is null ? "Recording coordinator is not configured." : ToJson(recordingCoordinator.CurrentStatus); } public async Task DiagnoseCurrentMeeting( string? launch_profile = null, string? started_at = null, double? timeout_seconds = null) { if (meetingMetadataProvider is null) { return "Meeting metadata provider is not configured."; } if (!string.IsNullOrWhiteSpace(started_at) && !DateTimeOffset.TryParse(started_at, out _)) { return "Refused: started_at must be a valid date/time."; } try { var profileOptions = ResolveProfileOptions(launch_profile); var lookupStartedAt = DateTimeOffset.TryParse(started_at, out var parsedStartedAt) ? parsedStartedAt : DateTimeOffset.Now; var timeout = timeout_seconds is > 0 ? TimeSpan.FromSeconds(timeout_seconds.Value) : profileOptions.Recording.MetadataLookupTimeout; if (meetingMetadataProvider is IMeetingMetadataDiagnosticProvider diagnostics) { return ToJson(await diagnostics.DiagnoseCurrentMeetingAsync( lookupStartedAt, timeout, CancellationToken.None)); } var stopwatch = Stopwatch.StartNew(); var metadata = await meetingMetadataProvider.GetCurrentMeetingAsync(lookupStartedAt, CancellationToken.None); stopwatch.Stop(); return ToJson(new MeetingMetadataDiagnosticResult( meetingMetadataProvider.GetType().Name, lookupStartedAt, metadata is not null, false, stopwatch.ElapsedMilliseconds, metadata, metadata is null ? "No meeting metadata provider result was available." : null)); } catch (Exception exception) { return $"Diagnostic failed: {exception.Message}"; } } public async Task MergeRecentSpeakerIdentities(double? recent_days = null, string? launch_profile = null) { if (identityMergeService is null) { return "Speaker identity merge service is not configured."; } try { var profileOptions = ResolveProfileOptions(launch_profile); var recentAge = recent_days is > 0 ? TimeSpan.FromDays(recent_days.Value) : profileOptions.SpeakerIdentification.MergeRecentIdentityAge; return ToJson(await identityMergeService.MergeRecentIdentitiesAsync(recentAge, CancellationToken.None)); } catch (Exception exception) { return $"Diagnostic failed: {exception.Message}"; } } public string ReloadWorkflowConfiguration() { if (configuration is not IConfigurationRoot root) { return "Application configuration does not support reload."; } root.Reload(); var reloadedOptions = new MeetingAssistantOptions(); configuration.GetSection("MeetingAssistant").Bind(reloadedOptions); return ToJson(new { rulesPath = reloadedOptions.Automation.RulesPath }); } public async Task DiagnoseAsrTranscribeFile(string path, string? launch_profile = null) { if (asrDiagnosticService is null) { return "ASR diagnostic service is not configured."; } if (string.IsNullOrWhiteSpace(path)) { return "Refused: a WAV file path is required."; } try { return ToJson(await asrDiagnosticService.TranscribeWavAsync(path, launch_profile, CancellationToken.None)); } catch (FileNotFoundException exception) { return exception.Message; } catch (InvalidDataException exception) { return exception.Message; } catch (Exception exception) { return $"ASR backend failed while processing the WAV file: {exception.Message}"; } } public async Task DiagnoseAsrDiarizeFile( string path, int? num_speakers = null, string? launch_profile = null) { if (asrDiagnosticService is null) { return "ASR diagnostic service is not configured."; } if (string.IsNullOrWhiteSpace(path)) { return "Refused: a WAV file path is required."; } try { var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path)); return !File.Exists(fullPath) ? $"WAV file was not found at '{fullPath}'." : ToJson(await asrDiagnosticService.DiarizeWavAsync( fullPath, new SpeechRecognitionPipelineOptions(num_speakers), launch_profile, CancellationToken.None)); } catch (Exception exception) { return $"ASR diarization backend failed while processing the WAV file: {exception.Message}"; } } public async Task 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 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 Task CreateIdentity( string? canonicalName = null, string[]? aliases = null, string[]? candidateNames = null) { return Task.FromResult( "Refused: speaker identities require at least one audio sample. " + "Use a speaker override from an existing transcript speaker sample, or update/merge an existing sampled identity."); } public async Task 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 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 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 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 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 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."; } var sampleCount = await context.SpeakerSnippets.CountAsync(candidate => candidate.SpeakerIdentityId == sample.SpeakerIdentityId); if (sampleCount <= 1) { return $"Refused: sample {sampleId} is the last sample for identity {sample.SpeakerIdentityId}. Delete the identity instead."; } context.SpeakerSnippets.Remove(sample); await context.SaveChangesAsync(); return $"Deleted sample {sampleId}."; } } public async Task 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 async Task ValidateYamlAsync(string yaml) { if (string.IsNullOrWhiteSpace(yaml)) { return null; } MeetingWorkflowRulesFile rulesFile; try { rulesFile = YamlDeserializer.Deserialize(yaml) ?? new MeetingWorkflowRulesFile(); } catch (YamlException exception) { return $"Refused: workflow rules YAML is invalid. {exception.Message}"; } var errors = await MeetingWorkflowRulesValidator.ValidateAsync(rulesFile); return errors.Count == 0 ? null : "Refused: workflow rules are invalid. " + string.Join(" ", errors); } private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName) { var agentsContent = await File.ReadAllTextAsync(projectAgentsTemplatePath); 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 async Task ReadMeetingArtifactSummaryAsync(string path) { var content = await File.ReadAllTextAsync(path); var document = MarkdownDocumentParser.SplitOptional(content); var frontmatter = new MeetingArtifactSummaryFrontmatter(); if (document.HasFrontmatter) { try { frontmatter = YamlDeserializer.Deserialize(document.Frontmatter) ?? new MeetingArtifactSummaryFrontmatter(); } catch (YamlException) { frontmatter = new MeetingArtifactSummaryFrontmatter(); } } var startTime = ParseDateTime(frontmatter.StartTime); var projects = (frontmatter.Projects ?? []) .Where(project => !string.IsNullOrWhiteSpace(project)) .Select(project => project.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .Order(StringComparer.OrdinalIgnoreCase) .ToArray(); return new MeetingArtifactSummary( AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRootPath, path)), string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(), startTime?.ToString("O") ?? "", startTime ?? File.GetLastWriteTimeUtc(path), projects, ToArtifactToolPath(frontmatter.Meeting, path, meetingNotesRootPath), ToArtifactToolPath(frontmatter.Transcript, path, transcriptsRootPath), ToArtifactToolPath(frontmatter.AssistantContext, path, assistantContextRootPath)); } private static async Task ReadMeetingArtifactFileAsync( string root, string path, string artifactName, int? from, int? to, int? tail) { var resolved = ResolveMeetingArtifactPath(root, path, artifactName); if (resolved.Error is not null) { return resolved.Error; } if (!File.Exists(resolved.Path)) { return $"{artifactName} file was not found: {AgentFileToolContent.ToToolPath(path)}"; } return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(resolved.Path), from, to, tail); } private static async Task WriteMeetingArtifactFileAsync( string root, string path, string artifactName, string content, int? from, int? to, int? insert, bool replaceFile) { var editMode = AgentFileEditMode.Create(from, to, insert, replaceFile); 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 resolved = ResolveMeetingArtifactPath(root, path, artifactName); if (resolved.Error is not null) { return resolved.Error; } Directory.CreateDirectory(Path.GetDirectoryName(resolved.Path!)!); await AgentFileToolContent.WriteFileContentAsync(resolved.Path!, content, editMode); return AgentFileToolContent.ToToolPath(Path.GetRelativePath(Path.GetFullPath(root), resolved.Path!)); } private static async Task WriteMeetingArtifactFrontmatterAsync( string root, string path, string artifactName, string yaml) { if (yaml is null) { return "Refused: frontmatter YAML must not be null."; } var validation = ValidateFrontmatterYaml(yaml); if (validation is not null) { return validation; } var resolved = ResolveMeetingArtifactPath(root, path, artifactName); if (resolved.Error is not null) { return resolved.Error; } var existing = File.Exists(resolved.Path!) ? await File.ReadAllTextAsync(resolved.Path!) : ""; var document = MarkdownDocumentParser.SplitOptional(existing); var body = document.HasFrontmatter ? document.Body.TrimStart('\r', '\n') : existing.TrimStart('\r', '\n'); var frontmatter = yaml.Trim(); var updated = string.IsNullOrEmpty(body) ? $"---\n{frontmatter}\n---\n" : $"---\n{frontmatter}\n---\n\n{body}"; Directory.CreateDirectory(Path.GetDirectoryName(resolved.Path!)!); await File.WriteAllTextAsync(resolved.Path!, updated); return AgentFileToolContent.ToToolPath(Path.GetRelativePath(Path.GetFullPath(root), resolved.Path!)); } private static Task SearchMeetingArtifactFilesAsync( string root, string keywords, int maxMatches, string? filePattern) { if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(root)) { return Task.FromResult(""); } var sources = Directory.EnumerateFiles(root, "*.md", SearchOption.AllDirectories) .Order(StringComparer.OrdinalIgnoreCase) .Select(path => new AgentFileSearchSource( path, AgentFileToolContent.ToToolPath(Path.GetRelativePath(root, path)))) .Where(source => AgentFileToolContent.MatchesGlob(source.DisplayPath, filePattern)); return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches)); } private static (string? Path, string? Error) ResolveMeetingArtifactPath( string root, string path, string artifactName) { if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path)) { return (null, $"Refused: {artifactName} path must be relative to its configured folder."); } var rootPath = Path.GetFullPath(root); var candidate = Path.GetFullPath(Path.Combine( rootPath, path.Replace('/', Path.DirectorySeparatorChar) .Replace('\\', Path.DirectorySeparatorChar))); return AgentFileToolContent.IsWithinDirectory(rootPath, candidate) ? (candidate, null) : (null, $"Refused: {artifactName} path must stay inside {rootPath}."); } private static string ToArtifactToolPath(string? link, string sourcePath, string artifactRoot) { var resolved = ResolveArtifactLink(link, sourcePath); if (string.IsNullOrWhiteSpace(resolved)) { return ""; } var root = Path.GetFullPath(artifactRoot); if (!AgentFileToolContent.IsWithinDirectory(root, resolved)) { var byFileName = Path.GetFullPath(Path.Combine(root, Path.GetFileName(resolved))); if (!File.Exists(byFileName) || !AgentFileToolContent.IsWithinDirectory(root, byFileName)) { return ""; } resolved = byFileName; } return AgentFileToolContent.ToToolPath(Path.GetRelativePath(root, resolved)); } private static string ResolveArtifactLink(string? pathOrLink, string sourcePath) { if (string.IsNullOrWhiteSpace(pathOrLink)) { return ""; } var sourceFolder = Path.GetDirectoryName(sourcePath) ?? ""; var target = ExtractWikiLinkTarget(pathOrLink.Trim()) ?? pathOrLink.Trim(); target = target.Replace('/', Path.DirectorySeparatorChar); if (!Path.HasExtension(target)) { target += ".md"; } return Path.GetFullPath(Path.IsPathRooted(target) ? target : Path.Combine(sourceFolder, target)); } private static string? ExtractWikiLinkTarget(string value) { if (!value.StartsWith("[[", StringComparison.Ordinal) || !value.EndsWith("]]", StringComparison.Ordinal)) { return null; } var inner = value[2..^2]; var labelSeparator = inner.IndexOf('|', StringComparison.Ordinal); return labelSeparator < 0 ? inner : inner[..labelSeparator]; } private static DateTimeOffset? ParseDateTime(string? value) { return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; } private MeetingAssistantOptions ResolveProfileOptions(string? launchProfile) { if (launchProfiles is not null) { return launchProfiles.GetRequiredProfile(launchProfile).Options; } if (string.IsNullOrWhiteSpace(launchProfile) || launchProfile.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase)) { return options; } throw new InvalidOperationException( $"Launch profile '{launchProfile}' was requested, but no launch profile provider is registered."); } 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 string? ValidateFrontmatterYaml(string yaml) { if (string.IsNullOrWhiteSpace(yaml)) { return "Refused: frontmatter YAML must not be blank."; } try { _ = YamlDeserializer.Deserialize(yaml); return null; } catch (YamlException exception) { return $"Refused: frontmatter YAML is invalid. {exception.Message}"; } } private static async Task 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(); 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(); 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 CreateIdentityContextAsync(CancellationToken cancellationToken) { return dbContextFactory is null ? null : await dbContextFactory.CreateDbContextAsync(cancellationToken); } private async Task CreatePreparedIdentityContextAsync(CancellationToken cancellationToken) { var context = await CreateIdentityContextAsync(cancellationToken); if (context is null) { return null; } await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken); return context; } private static IQueryable 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 NormalizeNames(IEnumerable? 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 value) { return JsonSerializer.Serialize(value, JsonOptions); } private sealed record IdentitySummary( int Id, string? CanonicalName, string? DisplayName, IReadOnlyList Aliases, IReadOnlyList CandidateNames, int SampleCount, int ReferenceCount, DateTimeOffset UpdatedAt); private sealed record IdentityDetail( IdentitySummary Identity, IReadOnlyList References, IReadOnlyList 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); private sealed record MeetingArtifactSummary( string ToolPath, string Title, string StartTimeText, DateTimeOffset SortTime, IReadOnlyList Projects, string MeetingNotePath, string TranscriptPath, string ContextPath); private sealed class MeetingArtifactSummaryFrontmatter { [YamlMember(Alias = "title")] public string? Title { get; set; } [YamlMember(Alias = "start_time")] public string? StartTime { get; set; } [YamlMember(Alias = "projects")] public List? Projects { get; set; } [YamlMember(Alias = "meeting")] public string? Meeting { get; set; } [YamlMember(Alias = "transcript")] public string? Transcript { get; set; } [YamlMember(Alias = "assistant_context")] public string? AssistantContext { get; set; } } }