using MeetingAssistant.MeetingNotes; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; using YamlDotNet.Serialization; namespace MeetingAssistant.Summary; public sealed class MeetingSummaryTools { private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create(); 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, int? tail = null) { return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to, tail); } public Task ReadMeetingNote(int? @from = null, int? to = null, int? tail = null) { return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to, tail); } public Task ReadContext(int? @from = null, int? to = null, int? tail = null) { return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to, tail); } public async Task ReadUserNotes(int? @from = null, int? to = null, int? tail = 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 AgentFileToolContent.ReadLines(body, @from, to, tail); } public Task ReadGlossary(int? @from = null, int? to = null, int? tail = null) { return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to, tail); } 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 trimmedTitle = title?.Trim(); if (!string.IsNullOrWhiteSpace(trimmedTitle)) { meetingNote.Frontmatter.Title = trimmedTitle; await WriteMeetingNoteAsync(meetingNote); } var summaryTitle = 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 Path.GetFileName(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, 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."; } 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 = AgentFileToolContent.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 => AgentFileToolContent.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, int? tail = null) { var filePath = await ResolveBoundProjectFilePathAsync(project, path); if (filePath is null || !File.Exists(filePath)) { return ""; } return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), @from, to, tail); } public async Task ListPastProjectMeetings( string[]? projects = null, int page = 1, int page_size = 20) { var scope = await ResolveCurrentMeetingProjectScopeAsync(projects); if (scope.Refusal is not null) { return scope.Refusal; } var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames); var effectivePageSize = Math.Clamp(page_size, 1, 100); var requestedPage = Math.Max(1, page); var totalPages = Math.Max(1, (int)Math.Ceiling(summaries.Count / (double)effectivePageSize)); var effectivePage = Math.Min(requestedPage, totalPages); var pageItems = summaries .Skip((effectivePage - 1) * effectivePageSize) .Take(effectivePageSize) .ToList(); var lines = new List { $"page {effectivePage}/{totalPages}, page_size {effectivePageSize}, total {summaries.Count}" }; lines.AddRange(pageItems.Select(summary => $"{summary.ToolPath} | {FormatSummaryStartTime(summary.StartTime)} | {summary.Title} | projects: {string.Join(", ", summary.Projects)}")); return string.Join('\n', lines); } public async Task ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null, int? tail = null) { var summary = await ResolvePastSummaryAsync(path); if (summary.Refusal is not null) { return summary.Refusal; } return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @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 = 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, () => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode)); } else { await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode); } return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}"; } public async Task Search(string keywords, string[]? projects = null, string? file_pattern = null) { if (string.IsNullOrWhiteSpace(keywords)) { return ""; } var scope = await ResolveCurrentMeetingProjectScopeAsync(projects); if (scope.Refusal is not null) { return scope.Refusal; } var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames); var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames); return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1, file_pattern); } private static async Task ReadIfExistsAsync(string path, int? from = null, int? to = null, int? tail = null) { if (!File.Exists(path)) { return ""; } var content = await File.ReadAllTextAsync(path); return AgentFileToolContent.ReadLines(content, from, to, tail); } 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 ResolveCurrentMeetingProjectScopeAsync(string[]? projects) { var requested = (projects ?? []) .Where(project => !string.IsNullOrWhiteSpace(project)) .Select(project => project.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var currentProjects = await ReadCurrentMeetingProjectNamesAsync(); if (requested.Count == 0) { return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase)); } var outOfScope = requested .Where(project => !currentProjects.Contains(project)) .Order(StringComparer.OrdinalIgnoreCase) .ToList(); if (outOfScope.Count > 0) { return new ProjectScope( new HashSet(StringComparer.OrdinalIgnoreCase), $"Refused: project(s) not assigned to current meeting: {string.Join(", ", outOfScope)}."); } return new ProjectScope(requested.ToHashSet(StringComparer.OrdinalIgnoreCase)); } private async Task> ReadCurrentMeetingProjectNamesAsync() { return await projectResolver.ReadMeetingProjectNamesAsync(artifacts); } private async Task> GetExistingProjectsInScopeAsync(IReadOnlySet projectNames) { if (projectNames.Count == 0) { return []; } var projectsRoot = GetProjectsRoot(); if (!Directory.Exists(projectsRoot)) { return []; } return Directory.EnumerateDirectories(projectsRoot) .Select(path => new BoundMeetingProject(Path.GetFileName(path), path)) .Where(project => projectNames.Contains(project.Name)) .OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase) .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 AgentFileToolContent.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 AgentFileToolContent.IsWithinDirectory(projectFolder.Path, fullPath) ? new ProjectFileTarget(projectFolder, fullPath) : null; } private Task> GetBoundProjectsAsync() { return projectResolver.GetBoundProjectsAsync(artifacts); } private string GetProjectsRoot() { return projectResolver.ProjectsRoot; } private static string SearchSources( IReadOnlyList projects, IReadOnlyList summaries, string keywords, bool singleProject, string? filePattern) { System.Text.RegularExpressions.Regex regex; try { regex = new System.Text.RegularExpressions.Regex(keywords); } catch (ArgumentException ex) { return $"Search failed: invalid .NET regular expression: {ex.Message}"; } try { var matches = new List(); foreach (var project in projects) { foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories)) { var projectRelative = AgentFileToolContent.ToToolPath(Path.GetRelativePath(project.Path, file)); if (!AgentFileToolContent.MatchesGlob(projectRelative, filePattern)) { continue; } var relative = Path.Combine(project.Name, projectRelative); AddFileMatches(matches, regex, file, FormatSearchPath(relative, singleProject)); } } foreach (var summary in summaries) { AddFileMatches(matches, regex, summary.Path, $"past-meetings/{summary.ToolPath}"); } return string.Join('\n', matches); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return $"Search failed: {ex.Message}"; } } private static void AddFileMatches( List matches, System.Text.RegularExpressions.Regex regex, string file, string formattedPath) { var lines = File.ReadAllLines(file); for (var index = 0; index < lines.Length; index++) { if (regex.IsMatch(lines[index])) { matches.Add($"{AgentFileToolContent.ToToolPath(formattedPath)}:{index + 1} {lines[index]}"); } } } private static string FormatSearchPath(string path, bool singleProject) { var formatted = AgentFileToolContent.ToToolPath(path); if (!singleProject) { return formatted; } var slashIndex = formatted.IndexOf('/', StringComparison.Ordinal); return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..]; } private async Task> GetScopedPastSummariesAsync(IReadOnlySet projectNames) { if (projectNames.Count == 0) { return []; } var summariesRoot = GetSummariesRoot(); if (!Directory.Exists(summariesRoot)) { return []; } var summaries = new List(); foreach (var file in Directory.EnumerateFiles(summariesRoot, "*.md", SearchOption.TopDirectoryOnly)) { var summary = await TryReadPastSummaryAsync(file, summariesRoot); if (summary is null || IsSamePath(summary.Path, artifacts.SummaryPath) || !summary.Projects.Any(projectNames.Contains)) { continue; } summaries.Add(summary); } return summaries .OrderByDescending(summary => summary.StartTime) .ThenByDescending(summary => summary.ToolPath, StringComparer.OrdinalIgnoreCase) .ToList(); } private async Task ResolvePastSummaryAsync(string path) { path = NormalizePastMeetingSummaryToolPath(path); if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path)) { return PastMeetingSummaryResolution.Refused("Refused: summary path must be relative to the configured summaries folder."); } var scope = await ResolveCurrentMeetingProjectScopeAsync(null); if (scope.Refusal is not null) { return PastMeetingSummaryResolution.Refused(scope.Refusal); } var summariesRoot = GetSummariesRoot(); var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path)); if (!AgentFileToolContent.IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath)) { return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder."); } if (IsSamePath(fullPath, artifacts.SummaryPath)) { return PastMeetingSummaryResolution.Refused("Refused: read_past_project_meeting_summary cannot read the current meeting summary; use write_summary only for the current meeting summary."); } var summary = await TryReadPastSummaryAsync(fullPath, summariesRoot); if (summary is null) { return PastMeetingSummaryResolution.Refused("Refused: file is not a summary note with project frontmatter."); } if (!summary.Projects.Any(scope.ProjectNames.Contains)) { return PastMeetingSummaryResolution.Refused("Refused: summary file is not assigned to a project in the current meeting scope."); } return new PastMeetingSummaryResolution(summary); } private static string NormalizePastMeetingSummaryToolPath(string path) { var toolPath = AgentFileToolContent.ToToolPath(path.Trim()); const string pastMeetingPrefix = "past-meetings/"; return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase) ? toolPath[pastMeetingPrefix.Length..] : path; } private async Task TryReadPastSummaryAsync(string path, string summariesRoot) { var content = await File.ReadAllTextAsync(path); var document = MarkdownDocumentParser.SplitOptional(content); if (!document.HasFrontmatter) { return null; } SummaryFrontmatterYaml frontmatter; try { frontmatter = YamlDeserializer.Deserialize(document.Frontmatter) ?? new SummaryFrontmatterYaml(); } catch (YamlDotNet.Core.YamlException) { return null; } var projects = (frontmatter.Projects ?? []) .Where(project => !string.IsNullOrWhiteSpace(project)) .Select(project => project.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (projects.Count == 0) { return null; } return new PastMeetingSummary( path, AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRoot, path)), string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(), ParseDateTime(frontmatter.StartTime), projects); } private string GetSummariesRoot() { return VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder); } private static string FormatSummaryStartTime(DateTimeOffset? startTime) { return startTime?.ToString("O") ?? ""; } private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path); private sealed record ProjectScope(IReadOnlySet ProjectNames, string? Refusal = null); private sealed record PastMeetingSummary( string Path, string ToolPath, string Title, DateTimeOffset? StartTime, IReadOnlyList Projects); private sealed record PastMeetingSummaryResolution(PastMeetingSummary? Summary, string? Refusal = null) { public static PastMeetingSummaryResolution Refused(string message) { return new PastMeetingSummaryResolution(null, message); } } 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 sealed class SummaryFrontmatterYaml { [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 = "projects")] public List? Projects { get; set; } } private static DateTimeOffset? ParseDateTime(string? value) { return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; } private static bool IsSamePath(string first, string second) { return string.Equals( Path.GetFullPath(first), Path.GetFullPath(second), StringComparison.OrdinalIgnoreCase); } }