using MeetingAssistant.MeetingNotes; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; using System.Diagnostics; using System.Text.Json; using YamlDotNet.Serialization; namespace MeetingAssistant.Summary; public sealed class MeetingSummaryTools { private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() .IgnoreUnmatchedProperties() .Build(); private readonly MeetingSessionArtifacts artifacts; private readonly MeetingAssistantOptions options; private readonly IDictationWordStore? dictationWordStore; private readonly SummaryAgentWriteAudit? writeAudit; private readonly BoundMeetingProjectResolver projectResolver; public MeetingSummaryTools(MeetingSessionArtifacts artifacts) : this(artifacts, new MeetingAssistantOptions(), null) { } public MeetingSummaryTools( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, IDictationWordStore? dictationWordStore = null, SummaryAgentWriteAudit? writeAudit = null) { this.artifacts = artifacts; this.options = options; this.dictationWordStore = dictationWordStore; this.writeAudit = writeAudit; projectResolver = new BoundMeetingProjectResolver(options); } public Task ReadTranscript(int? @from = null, int? to = null) { return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to); } public Task ReadMeetingNote(int? @from = null, int? to = null) { return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to); } public Task ReadContext(int? @from = null, int? to = null) { return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to); } public async Task ReadUserNotes(int? @from = null, int? to = null) { if (!File.Exists(artifacts.MeetingNotePath)) { return ""; } var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath); var document = MarkdownDocumentParser.SplitOptional(content); var body = document.HasFrontmatter ? document.Body.Trim() : content; return ReadLines(body, @from, to); } public Task ReadGlossary(int? @from = null, int? to = null) { return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to); } public async Task AddDictationWord(string word) { if (dictationWordStore is null) { return "Dictation word store is not available."; } if (string.IsNullOrWhiteSpace(word)) { return "Refused: word must not be empty."; } if (writeAudit is not null) { IReadOnlyList? capturedWords = null; await writeAudit.CaptureFileWriteAsync( VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None)); return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s)."; } var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None); return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s)."; } public async Task AddAttendee(string attendee) { if (string.IsNullOrWhiteSpace(attendee)) { return "Refused: attendee must not be empty."; } var displayName = attendee.Trim(); var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName); if (string.IsNullOrWhiteSpace(normalized)) { return "Refused: attendee must not be empty."; } var note = await ReadMeetingNoteAsync(); if (note.Frontmatter.Attendees.Any(existing => string.Equals( MeetingAttendeeNames.NormalizeDisplayName(existing), normalized, StringComparison.OrdinalIgnoreCase))) { return $"Attendee {normalized} already exists."; } note.Frontmatter.Attendees.Add(displayName); await WriteMeetingNoteAsync(note); return $"Added attendee {displayName}."; } public async Task RemoveAttendee(string attendee) { if (string.IsNullOrWhiteSpace(attendee)) { return "Refused: attendee must not be empty."; } var normalized = MeetingAttendeeNames.NormalizeDisplayName(attendee); if (string.IsNullOrWhiteSpace(normalized)) { return "Refused: attendee must not be empty."; } var note = await ReadMeetingNoteAsync(); var removed = note.Frontmatter.Attendees.RemoveAll(existing => string.Equals( MeetingAttendeeNames.NormalizeDisplayName(existing), normalized, StringComparison.OrdinalIgnoreCase)); if (removed == 0) { return $"Attendee {normalized} was not present."; } await WriteMeetingNoteAsync(note); return $"Removed attendee {normalized}."; } public async Task OverrideSpeaker(string speaker, string replacement, bool merge = false) { if (string.IsNullOrWhiteSpace(speaker) || string.IsNullOrWhiteSpace(replacement)) { return "Refused: speaker and replacement must not be empty."; } if (!File.Exists(artifacts.AssistantContextPath)) { return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}."; } var speakerOverride = new SpeakerOverride(speaker.Trim(), replacement.Trim(), merge); if (string.Equals(speakerOverride.From, speakerOverride.To, StringComparison.OrdinalIgnoreCase)) { return "Refused: speaker and replacement must be different."; } if (!merge && await SpeakerOverrideArtifacts.TranscriptContainsSpeakerAsync( artifacts.TranscriptPath, speakerOverride.To, CancellationToken.None)) { return $"Refused: transcript already contains speaker {speakerOverride.To}. Call override_speaker with merge=true only when you are certain both speakers are the same identity."; } await SpeakerOverrideArtifacts.ApplyToTranscriptAsync(artifacts.TranscriptPath, speakerOverride); await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(artifacts.AssistantContextPath, speakerOverride); return merge ? $"Merged speaker {speakerOverride.From} into {speakerOverride.To}." : $"Overrode speaker {speakerOverride.From} with {speakerOverride.To}."; } public async Task DeleteIdentity(string identity) { if (string.IsNullOrWhiteSpace(identity)) { return "Refused: identity must not be empty."; } if (!File.Exists(artifacts.AssistantContextPath)) { return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}."; } var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync( artifacts.TranscriptPath, identity, CancellationToken.None); await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync( artifacts.AssistantContextPath, deletion, CancellationToken.None); return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}."; } public async Task WriteSummary(string markdown, string oneliner, string? title = null) { if (string.IsNullOrWhiteSpace(oneliner)) { return "Refused: oneliner must not be empty."; } var trimmedOneLiner = oneliner.Trim(); if (ContainsLineBreak(trimmedOneLiner)) { return "Refused: oneliner must be a single line."; } Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!); var meetingNote = await ReadMeetingNoteAsync(); var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title) ? title : meetingNote.Frontmatter.Title; var frontmatter = await MeetingSummaryFrontmatterFactory.CreateAsync( artifacts, meetingNote, string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle, CancellationToken.None); frontmatter.OneLiner = trimmedOneLiner; await File.WriteAllTextAsync( artifacts.SummaryPath, MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown)); SummaryWasWritten = true; return artifacts.SummaryPath; } private static bool ContainsLineBreak(string value) { return value.Contains('\n', StringComparison.Ordinal) || value.Contains('\r', StringComparison.Ordinal); } public bool SummaryWasWritten { get; private set; } public async Task WriteContext( string content, int? @from = null, int? to = null, int? insert = null) { var editMode = FileLineEditMode.Create(@from, to, insert); if (editMode is null) { return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite."; } if (!File.Exists(artifacts.AssistantContextPath)) { return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}."; } var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath); var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing); var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode); var updated = string.IsNullOrWhiteSpace(frontmatter) ? updatedBody : "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody; await File.WriteAllTextAsync(artifacts.AssistantContextPath, updated); return artifacts.AssistantContextPath; } private static string StripAccidentalFrontmatter(string content) { var document = MarkdownDocumentParser.SplitOptional(content); return document.HasFrontmatter ? document.Body : content; } public async Task ListProjects() { var projects = await GetBoundProjectsAsync(); return string.Join('\n', projects.Select(project => project.Name)); } public async Task ListProjectFiles(string project) { var projectRoot = await ResolveBoundProjectRootAsync(project); if (projectRoot is null) { return ""; } var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories) .Select(path => ToToolPath(Path.GetRelativePath(projectRoot, path))) .Order(StringComparer.Ordinal); return string.Join('\n', files); } public async Task ReadProjectFile(string project, string path, int? @from = null, int? to = null) { var filePath = await ResolveBoundProjectFilePathAsync(project, path); if (filePath is null || !File.Exists(filePath)) { return ""; } return ReadLines(await File.ReadAllTextAsync(filePath), @from, to); } public async Task WriteProjectFile( string project, string path, string content, int? @from = null, int? to = null, int? insert = null) { var editMode = FileLineEditMode.Create(@from, to, insert); if (editMode is null) { return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite."; } var target = ResolveExistingProjectFilePath(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)!); if (writeAudit is not null) { await writeAudit.CaptureFileWriteAsync( target.Path, () => WriteProjectFileContentAsync(target.Path, content, editMode)); } else { await WriteProjectFileContentAsync(target.Path, content, editMode); } return $"{target.Project.Name}/{ToToolPath(path)}"; } public async Task Search(string keywords, string[]? projects = null) { if (string.IsNullOrWhiteSpace(keywords)) { return ""; } var selectedProjects = await GetSearchProjectsAsync(projects); if (selectedProjects.Count == 0) { return ""; } var projectsRoot = GetProjectsRoot(); var result = await RunRipgrepAsync(projectsRoot, selectedProjects, keywords); return result is not null ? FormatRipgrepJson(result, selectedProjects.Count == 1) : SearchWithRegexFallback(selectedProjects, keywords, selectedProjects.Count == 1); } private static async Task ReadIfExistsAsync(string path, int? from = null, int? to = null) { if (!File.Exists(path)) { return ""; } var content = await File.ReadAllTextAsync(path); return ReadLines(content, from, to); } private async Task ReadMeetingNoteAsync() { if (!File.Exists(artifacts.MeetingNotePath)) { return new MeetingNote("", new MeetingNoteFrontmatter(), ""); } var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath); var document = MarkdownDocumentParser.SplitOptional(content); var frontmatter = new MeetingNoteFrontmatter(); if (document.HasFrontmatter) { var note = YamlDeserializer.Deserialize(document.Frontmatter) ?? new MeetingNoteFrontmatterYaml(); frontmatter.Title = note.Title ?? ""; frontmatter.StartTime = ParseDateTime(note.StartTime); frontmatter.EndTime = ParseDateTime(note.EndTime); frontmatter.Attendees = note.Attendees ?? []; frontmatter.Projects = note.Projects ?? []; frontmatter.Transcript = note.Transcript ?? ""; frontmatter.AssistantContext = note.AssistantContext ?? ""; frontmatter.Summary = note.Summary ?? ""; } return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes()); } private async Task WriteMeetingNoteAsync(MeetingNote note) { Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); await File.WriteAllTextAsync( artifacts.MeetingNotePath, RenderMeetingNote(note.Frontmatter, note.UserNotes)); } private static string RenderMeetingNote(MeetingNoteFrontmatter frontmatter, string userNotes) { var lines = new List { "---", $"title: {EscapeScalar(frontmatter.Title)}", $"start_time: {EscapeNullableDateTime(frontmatter.StartTime)}", $"end_time: {EscapeNullableDateTime(frontmatter.EndTime)}", "attendees:" }; lines.AddRange(frontmatter.Attendees.Select(attendee => $"- {EscapeListItem(attendee)}")); lines.Add("projects:"); lines.AddRange(frontmatter.Projects.Select(project => $"- {EscapeListItem(project)}")); lines.Add($"transcript: {EscapeQuoted(frontmatter.Transcript)}"); lines.Add($"assistant_context: {EscapeQuoted(frontmatter.AssistantContext)}"); lines.Add($"summary: {EscapeQuoted(frontmatter.Summary)}"); lines.Add("---"); lines.Add(""); lines.Add(userNotes); return string.Join(Environment.NewLine, lines); } private static string EscapeScalar(string value) { return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value); } private static string EscapeQuoted(string value) { return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; } private static string EscapeNullableDateTime(DateTimeOffset? value) { return value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O")); } private static string EscapeListItem(string value) { if (string.IsNullOrWhiteSpace(value)) { return "\"\""; } return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal) ? EscapeQuoted(value) : value; } private async Task> GetSearchProjectsAsync(string[]? projects) { var boundProjects = await GetBoundProjectsAsync(); if (projects is null || projects.Length == 0) { return boundProjects; } var requested = projects .Where(project => !string.IsNullOrWhiteSpace(project)) .ToHashSet(StringComparer.OrdinalIgnoreCase); return boundProjects .Where(project => requested.Contains(project.Name)) .ToList(); } private async Task ResolveBoundProjectAsync(string project) { if (string.IsNullOrWhiteSpace(project)) { return null; } return (await GetBoundProjectsAsync()) .FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase)); } private async Task ResolveBoundProjectRootAsync(string project) { return (await ResolveBoundProjectAsync(project))?.Path; } private async Task ResolveBoundProjectFilePathAsync(string project, string path) { var projectRoot = await ResolveBoundProjectRootAsync(project); if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path)) { return null; } var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path)); return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null; } private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path) { if (string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path)) { return null; } var projectsRoot = GetProjectsRoot(); if (!Directory.Exists(projectsRoot)) { return null; } var projectFolder = Directory.EnumerateDirectories(projectsRoot) .Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate)) .FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase)); if (projectFolder is null) { return null; } var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path)); return IsWithinDirectory(projectFolder.Path, fullPath) ? new ProjectFileTarget(projectFolder, fullPath) : null; } private static async Task WriteProjectFileContentAsync( string filePath, string content, FileLineEditMode editMode) { if (editMode.Kind == FileLineEditKind.Overwrite) { await File.WriteAllTextAsync(filePath, content); return; } var lines = File.Exists(filePath) ? (await File.ReadAllLinesAsync(filePath)).ToList() : []; var replacementLines = SplitLines(content); if (editMode.Kind == FileLineEditKind.Insert) { var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count); lines.InsertRange(insertIndex, replacementLines); await File.WriteAllTextAsync(filePath, string.Join('\n', lines)); return; } var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count); var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count); if (endIndexExclusive < startIndex) { endIndexExclusive = startIndex; } lines.RemoveRange(startIndex, endIndexExclusive - startIndex); lines.InsertRange(startIndex, replacementLines); await File.WriteAllTextAsync(filePath, string.Join('\n', lines)); } private static List SplitLines(string content) { if (content.Length == 0) { return []; } return content .Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n') .Split('\n') .ToList(); } private static string ReadLines(string content, int? from = null, int? to = null) { if (!from.HasValue && !to.HasValue) { return content; } var lines = SplitLines(content); 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)); } private static string ApplyLineEdit( string existingContent, string replacementContent, FileLineEditMode editMode) { if (editMode.Kind == FileLineEditKind.Overwrite) { return replacementContent; } var lines = SplitLines(existingContent); var replacementLines = SplitLines(replacementContent); if (editMode.Kind == FileLineEditKind.Insert) { var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count); lines.InsertRange(insertIndex, replacementLines); return string.Join('\n', lines); } var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count); var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count); if (endIndexExclusive < startIndex) { endIndexExclusive = startIndex; } lines.RemoveRange(startIndex, endIndexExclusive - startIndex); lines.InsertRange(startIndex, replacementLines); return string.Join('\n', lines); } private Task> GetBoundProjectsAsync() { return projectResolver.GetBoundProjectsAsync(artifacts); } private string GetProjectsRoot() { return projectResolver.ProjectsRoot; } private static async Task RunRipgrepAsync( string projectsRoot, IReadOnlyList projects, string keywords) { try { var startInfo = new ProcessStartInfo { FileName = "rg", WorkingDirectory = projectsRoot, 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); foreach (var project in projects) { startInfo.ArgumentList.Add(project.Name); } 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, bool singleProject) { 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($"{FormatSearchPath(path, singleProject)}:{lineNumber} {text.TrimEnd('\r', '\n')}"); } return string.Join('\n', matches); } private static string SearchWithRegexFallback(IReadOnlyList projects, string keywords, bool singleProject) { try { var regex = new System.Text.RegularExpressions.Regex(keywords); var matches = new List(); foreach (var project in projects) { foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories)) { var lines = File.ReadAllLines(file); for (var index = 0; index < lines.Length; index++) { if (regex.IsMatch(lines[index])) { var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file)); matches.Add($"{FormatSearchPath(relative, singleProject)}:{index + 1} {lines[index]}"); } } } } return string.Join('\n', matches); } catch { return ""; } } private static string FormatSearchPath(string path, bool singleProject) { var formatted = ToToolPath(path); if (!singleProject) { return formatted; } var slashIndex = formatted.IndexOf('/', StringComparison.Ordinal); return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..]; } private static bool IsWithinDirectory(string directory, string path) { var normalizedDirectory = Path.GetFullPath(directory) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var normalizedPath = Path.GetFullPath(path); return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase); } private static string ToToolPath(string path) { return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal) .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); } private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path); private sealed record FileLineEditMode( FileLineEditKind Kind, int? From = null, int? To = null, int? Insert = null) { public static FileLineEditMode? Create(int? from, int? to, int? insert) { if (insert.HasValue) { return from.HasValue || to.HasValue ? null : new FileLineEditMode(FileLineEditKind.Insert, Insert: insert); } if (from.HasValue || to.HasValue) { return from.HasValue && to.HasValue && from.Value <= to.Value ? new FileLineEditMode(FileLineEditKind.Replace, From: from, To: to) : null; } return new FileLineEditMode(FileLineEditKind.Overwrite); } } private enum FileLineEditKind { Overwrite, Replace, Insert } private sealed class MeetingNoteFrontmatterYaml { [YamlDotNet.Serialization.YamlMember(Alias = "title")] public string? Title { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "start_time")] public string? StartTime { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "end_time")] public string? EndTime { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "attendees")] public List? Attendees { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "projects")] public List? Projects { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "transcript")] public string? Transcript { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "assistant_context")] public string? AssistantContext { get; set; } [YamlDotNet.Serialization.YamlMember(Alias = "summary")] public string? Summary { get; set; } } private static DateTimeOffset? ParseDateTime(string? value) { return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; } }