diff --git a/MeetingAssistant.Tests/PastProjectMeetingSummaryToolTests.cs b/MeetingAssistant.Tests/PastProjectMeetingSummaryToolTests.cs new file mode 100644 index 0000000..4542c8e --- /dev/null +++ b/MeetingAssistant.Tests/PastProjectMeetingSummaryToolTests.cs @@ -0,0 +1,178 @@ +using MeetingAssistant.MeetingNotes; +using MeetingAssistant.Summary; + +namespace MeetingAssistant.Tests; + +public sealed class PastProjectMeetingSummaryToolTests +{ + [Fact] + public async Task ListPastProjectMeetingsReturnsPagedSummariesForCurrentMeetingProjects() + { + var root = CreateRoot(); + var artifacts = CreateArtifacts(root); + var summariesRoot = Path.Combine(root, "Meetings", "Summaries"); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + Directory.CreateDirectory(summariesRoot); + await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "current-summary.md"), "Current", "2026-05-29T09:00:00+02:00", "Project X"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x-new.md"), "Project X New", "2026-05-28T09:00:00+02:00", "Project X"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-y.md"), "Project Y", "2026-05-27T09:00:00+02:00", "Project Y"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-26T09:00:00+02:00", "Project Z"); + await File.WriteAllTextAsync(Path.Combine(summariesRoot, "not-a-summary.md"), "No frontmatter"); + artifacts = artifacts with { SummaryPath = Path.Combine(summariesRoot, "current-summary.md") }; + var tools = CreateTools(artifacts, root); + + var firstPage = await tools.ListPastProjectMeetings(page: 1, page_size: 1); + var secondPage = await tools.ListPastProjectMeetings(page: 2, page_size: 1); + + Assert.Contains("page 1/2", firstPage); + Assert.Contains("project-x-new.md | 2026-05-28T09:00:00.0000000+02:00 | Project X New | projects: Project X", firstPage); + Assert.DoesNotContain("project-y.md", firstPage); + Assert.Contains("page 2/2", secondPage); + Assert.Contains("project-y.md", secondPage); + Assert.DoesNotContain("current-summary.md", firstPage + secondPage); + Assert.DoesNotContain("project-z.md", firstPage + secondPage); + Assert.DoesNotContain("not-a-summary.md", firstPage + secondPage); + } + + [Fact] + public async Task ListPastProjectMeetingsFiltersAndRejectsOutOfScopeProjects() + { + var root = CreateRoot(); + var artifacts = CreateArtifacts(root); + var summariesRoot = Path.Combine(root, "Meetings", "Summaries"); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + Directory.CreateDirectory(summariesRoot); + await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-y.md"), "Project Y", "2026-05-27T09:00:00+02:00", "Project Y"); + var tools = CreateTools(artifacts, root); + + var projectX = await tools.ListPastProjectMeetings(["Project X"]); + var refused = await tools.ListPastProjectMeetings(["Project Z"]); + + Assert.Contains("project-x.md", projectX); + Assert.DoesNotContain("project-y.md", projectX); + Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused); + } + + [Fact] + public async Task ReadPastProjectMeetingSummaryReadsOnlyScopedPastSummaryFiles() + { + var root = CreateRoot(); + var artifacts = CreateArtifacts(root); + var summariesRoot = Path.Combine(root, "Meetings", "Summaries"); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + Directory.CreateDirectory(summariesRoot); + await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X"); + await WriteSummaryAsync( + Path.Combine(summariesRoot, "project-x.md"), + "Project X", + "2026-05-28T09:00:00+02:00", + "Project X", + "line one\nline two\nline three"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-27T09:00:00+02:00", "Project Z"); + await WriteSummaryAsync(artifacts.SummaryPath, "Current", "2026-05-29T09:00:00+02:00", "Project X"); + var outside = Path.Combine(root, "outside.md"); + await File.WriteAllTextAsync(outside, "outside"); + var tools = CreateTools(artifacts, root); + + Assert.Equal("line two\nline three", await tools.ReadPastProjectMeetingSummary("project-x.md", from: 9, to: 99)); + Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary("project-z.md")); + Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary("current-summary.md")); + Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary(outside)); + } + + [Fact] + public async Task SearchIncludesProjectFilesAndScopedPastMeetingSummaries() + { + var root = CreateRoot(); + var artifacts = CreateArtifacts(root); + var projectsRoot = Path.Combine(root, "Projects"); + var summariesRoot = Path.Combine(root, "Meetings", "Summaries"); + Directory.CreateDirectory(Path.Combine(projectsRoot, "Project X")); + Directory.CreateDirectory(Path.Combine(projectsRoot, "Project Y")); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + Directory.CreateDirectory(summariesRoot); + await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y"); + await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Project X", "notes.md"), "alpha project file"); + await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Project Y", "notes.md"), "alpha other project"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X", "alpha past meeting"); + await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-27T09:00:00+02:00", "Project Z", "alpha hidden meeting"); + var tools = CreateTools(artifacts, root); + + var projectX = await tools.Search("alpha", ["Project X"]); + var refused = await tools.Search("alpha", ["Project Z"]); + + Assert.Contains("notes.md:1 alpha project file", projectX); + Assert.Contains("past-meetings/project-x.md:", projectX); + Assert.DoesNotContain("Project Y/notes.md", projectX); + Assert.DoesNotContain("project-z.md", projectX); + Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused); + } + + private static string CreateRoot() + { + return Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + } + + private static MeetingSessionArtifacts CreateArtifacts(string root) + { + return new MeetingSessionArtifacts( + MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"), + TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), + AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"), + SummaryPath: Path.Combine(root, "Meetings", "Summaries", "current-summary.md")); + } + + private static MeetingSummaryTools CreateTools(MeetingSessionArtifacts artifacts, string root) + { + return new MeetingSummaryTools( + artifacts, + new MeetingAssistantOptions + { + Vault = + { + ProjectsFolder = Path.Combine(root, "Projects"), + SummariesFolder = Path.Combine(root, "Meetings", "Summaries") + } + }); + } + + private static Task WriteMeetingNoteAsync(string path, params string[] projects) + { + var projectLines = string.Join('\n', projects.Select(project => $"- {project}")); + return File.WriteAllTextAsync( + path, + $""" + --- + title: Current Meeting + projects: + {projectLines} + --- + + Current notes. + """); + } + + private static Task WriteSummaryAsync( + string path, + string title, + string startTime, + string project, + string body = "summary body") + { + return File.WriteAllTextAsync( + path, + $$""" + --- + title: {{title}} + start_time: "{{startTime}}" + projects: + - {{project}} + --- + + {{body}} + """); + } +} diff --git a/MeetingAssistant.Tests/ProcessCommandRunnerTests.cs b/MeetingAssistant.Tests/ProcessCommandRunnerTests.cs new file mode 100644 index 0000000..4655d0e --- /dev/null +++ b/MeetingAssistant.Tests/ProcessCommandRunnerTests.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using MeetingAssistant.Transcription; + +namespace MeetingAssistant.Tests; + +public sealed class ProcessCommandRunnerTests +{ + [Fact] + public void CreateStartInfoRunsCliProcessesWithoutVisibleWindows() + { + var startInfo = ProcessCommandRunner.CreateStartInfo( + "docker", + ["run", "--rm", "meeting-assistant-pyannote:local"], + new Dictionary { ["HF_TOKEN"] = "token" }); + + Assert.False(startInfo.UseShellExecute); + Assert.True(startInfo.CreateNoWindow); + Assert.Equal(ProcessWindowStyle.Hidden, startInfo.WindowStyle); + Assert.True(startInfo.RedirectStandardOutput); + Assert.True(startInfo.RedirectStandardError); + Assert.Equal("docker", startInfo.FileName); + Assert.Equal(["run", "--rm", "meeting-assistant-pyannote:local"], startInfo.ArgumentList); + Assert.Equal("token", startInfo.Environment["HF_TOKEN"]); + } +} diff --git a/MeetingAssistant/Properties/AssemblyInfo.cs b/MeetingAssistant/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..bc884bf --- /dev/null +++ b/MeetingAssistant/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MeetingAssistant.Tests")] diff --git a/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs b/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs index 4c0a527..7e314e2 100644 --- a/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs +++ b/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs @@ -8,9 +8,10 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio public const string DefaultInitialPrompt = """ You are the Meeting Assistant summary agent. - Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files. + Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, project files, and scoped past project meeting summaries. All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines. Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important. + write_summary is only for the current meeting's summary file. Past project meeting summaries are read-only historical context; use list_past_project_meetings and read_past_project_meeting_summary to inspect them, and never try to mutate them. If the meeting note has no title, provide a concise title parameter to write_summary. Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time. Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. write_context appends by default; use replace_file=true only when you are intentionally replacing the whole assistant context body. Keep user-facing summary content in the summary note. @@ -19,7 +20,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them. Use delete_identity only when you are certain that an existing speaker identity was wrongfully matched. Provide the exact identity name currently used in the transcript. After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context. - Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files. + Use list_projects first to see which projects are bound to this meeting. Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope. The summary note should contain concise sections for summary, decisions, open questions, and next steps. If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important. Keep the output grounded in the source material and explicitly say when a section has no known items. diff --git a/MeetingAssistant/Summary/MeetingSummaryTools.cs b/MeetingAssistant/Summary/MeetingSummaryTools.cs index e54526a..83b452f 100644 --- a/MeetingAssistant/Summary/MeetingSummaryTools.cs +++ b/MeetingAssistant/Summary/MeetingSummaryTools.cs @@ -1,8 +1,6 @@ using MeetingAssistant.MeetingNotes; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; -using System.Diagnostics; -using System.Text.Json; using YamlDotNet.Serialization; namespace MeetingAssistant.Summary; @@ -311,6 +309,45 @@ public sealed class MeetingSummaryTools return ReadLines(await File.ReadAllTextAsync(filePath), @from, to); } + 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 effectivePage = Math.Max(1, page); + var totalPages = Math.Max(1, (int)Math.Ceiling(summaries.Count / (double)effectivePageSize)); + var pageItems = summaries + .Skip((effectivePage - 1) * effectivePageSize) + .Take(effectivePageSize) + .ToList(); + var lines = new List + { + $"page {Math.Min(effectivePage, totalPages)}/{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) + { + var summary = await ResolvePastSummaryAsync(path); + if (summary.Refusal is not null) + { + return summary.Refusal; + } + + return ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to); + } + public async Task WriteProjectFile( string project, string path, @@ -354,17 +391,15 @@ public sealed class MeetingSummaryTools return ""; } - var selectedProjects = await GetSearchProjectsAsync(projects); - if (selectedProjects.Count == 0) + var scope = await ResolveCurrentMeetingProjectScopeAsync(projects); + if (scope.Refusal is not null) { - return ""; + return scope.Refusal; } - 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); + var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames); + var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames); + return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1); } private static async Task ReadIfExistsAsync(string path, int? from = null, int? to = null) @@ -462,19 +497,64 @@ public sealed class MeetingSummaryTools : value; } - private async Task> GetSearchProjectsAsync(string[]? projects) + private async Task ResolveCurrentMeetingProjectScopeAsync(string[]? projects) { - var boundProjects = await GetBoundProjectsAsync(); + var currentProjects = await ReadCurrentMeetingProjectNamesAsync(); + if (currentProjects.Count == 0) + { + return new ProjectScope(new HashSet(StringComparer.OrdinalIgnoreCase)); + } + if (projects is null || projects.Length == 0) { - return boundProjects; + return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase)); } var requested = projects .Where(project => !string.IsNullOrWhiteSpace(project)) + .Select(project => project.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + 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() + { + var note = await ReadMeetingNoteAsync(); + return note.Frontmatter.Projects + .Where(project => !string.IsNullOrWhiteSpace(project)) + .Select(project => project.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); - return boundProjects - .Where(project => requested.Contains(project.Name)) + } + + 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(); } @@ -681,74 +761,11 @@ public sealed class MeetingSummaryTools return projectResolver.ProjectsRoot; } - private static async Task RunRipgrepAsync( - string projectsRoot, + private static string SearchSources( 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) + IReadOnlyList summaries, + string keywords, + bool singleProject) { try { @@ -770,6 +787,11 @@ public sealed class MeetingSummaryTools } } + foreach (var summary in summaries) + { + AddFileMatches(matches, regex, summary.Path, $"past-meetings/{summary.ToolPath}"); + } + return string.Join('\n', matches); } catch @@ -778,6 +800,22 @@ public sealed class MeetingSummaryTools } } + 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($"{ToToolPath(formattedPath)}:{index + 1} {lines[index]}"); + } + } + } + private static string FormatSearchPath(string path, bool singleProject) { var formatted = ToToolPath(path); @@ -804,8 +842,136 @@ public sealed class MeetingSummaryTools .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); } + 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) + { + 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 (!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 async Task TryReadPastSummaryAsync(string path, string summariesRoot) + { + var content = await File.ReadAllTextAsync(path); + var document = MarkdownDocumentParser.SplitOptional(content); + if (!document.HasFrontmatter) + { + return null; + } + + var frontmatter = YamlDeserializer.Deserialize(document.Frontmatter) + ?? new SummaryFrontmatterYaml(); + 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, + 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 record FileLineEditMode( FileLineEditKind Kind, int? From = null, @@ -872,10 +1038,30 @@ public sealed class MeetingSummaryTools 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); + } } diff --git a/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs b/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs index ba02e82..4e67b09 100644 --- a/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs +++ b/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs @@ -188,10 +188,18 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline tools.WriteProjectFile, "write_projectfile", "Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."), + AIFunctionFactory.Create( + tools.ListPastProjectMeetings, + "list_past_project_meetings", + "List paginated past meeting summary notes from the configured summaries folder for projects assigned to the current meeting. Optional projects must be assigned to the current meeting."), + AIFunctionFactory.Create( + tools.ReadPastProjectMeetingSummary, + "read_past_project_meeting_summary", + "Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. With from and to, read that clamped inclusive 1-based line range. Cannot read or write the current meeting summary."), AIFunctionFactory.Create( tools.Search, "search", - "Run ripgrep syntax keywords against the requested bound projects, or all projects bound to the meeting note.") + "Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted.") ]; } diff --git a/MeetingAssistant/Transcription/CommandRunner.cs b/MeetingAssistant/Transcription/CommandRunner.cs index 14fc483..1ca6446 100644 --- a/MeetingAssistant/Transcription/CommandRunner.cs +++ b/MeetingAssistant/Transcription/CommandRunner.cs @@ -27,26 +27,7 @@ public sealed class ProcessCommandRunner : ICommandRunner CancellationToken cancellationToken, IReadOnlyDictionary? environment = null) { - var startInfo = new ProcessStartInfo - { - FileName = fileName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - foreach (var argument in arguments) - { - startInfo.ArgumentList.Add(argument); - } - - if (environment is not null) - { - foreach (var (key, value) in environment) - { - startInfo.Environment[key] = value; - } - } + var startInfo = CreateStartInfo(fileName, arguments, environment); logger.LogInformation("Running command {Command} {Arguments}", fileName, string.Join(' ', arguments)); using var process = Process.Start(startInfo) @@ -82,6 +63,37 @@ public sealed class ProcessCommandRunner : ICommandRunner return result; } + internal static ProcessStartInfo CreateStartInfo( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach (var (key, value) in environment) + { + startInfo.Environment[key] = value; + } + } + + return startInfo; + } + private static async Task ReadLinesAsync( StreamReader reader, Action log, diff --git a/MeetingAssistant/Transcription/PyannoteDiarizationWarmupHostedService.cs b/MeetingAssistant/Transcription/PyannoteDiarizationWarmupHostedService.cs index 8689049..e7fbb23 100644 --- a/MeetingAssistant/Transcription/PyannoteDiarizationWarmupHostedService.cs +++ b/MeetingAssistant/Transcription/PyannoteDiarizationWarmupHostedService.cs @@ -66,6 +66,11 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService diarization.Image, diarization.Model); await finalizer.WarmUpAsync(diarization, cancellationToken); + if (cancellationToken.IsCancellationRequested) + { + return; + } + logger.LogInformation( "Finished pyannote warm-up for image {Image} and model {Model}", diarization.Image, @@ -76,6 +81,10 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService logger.LogInformation("Pyannote warm-up was cancelled during application shutdown"); throw; } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + return; + } catch (Exception exception) { logger.LogError( diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index 6e32049..61f8ae8 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -110,7 +110,7 @@ "MergeRecentIdentityAge": "14.00:00:00", "MatchTimeout": "00:03:00", "PyannoteValidation": { - "Enabled": false, + "Enabled": true, "MinimumSingleSpeakerCoverage": 0.9, "MinimumMatchingKnownSnippetRatio": 0.5, "Diarization": { diff --git a/README.md b/README.md index 1f1d719..637c847 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se "MinimumSampleSpeechDuration": "00:00:30", "MaximumSampleSegmentGap": "00:00:01", "PyannoteValidation": { - "Enabled": false, + "Enabled": true, "MinimumSingleSpeakerCoverage": 0.9, "MinimumMatchingKnownSnippetRatio": 0.5, "Diarization": { @@ -290,7 +290,7 @@ GET /meetings/summary/retry?summaryPath=... Failure summary notes include a clickable retry link using `Api:PublicBaseUrl`, for example `http://localhost:5090/meetings/summary/retry?summaryPath=20260519-124110-summary.md`. The GET endpoint exists for Obsidian/browser clicks and performs the same overwrite behavior as the POST endpoint. Meeting note bodies stay reserved for user-authored notes. -The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, and project files. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using append-by-default, explicit whole-file replacement, replace, and insert modes. Project lookup and search tools are constrained to the projects listed in the meeting note frontmatter: +The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, project files, and past project meeting summaries. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts and past summaries incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using append-by-default, explicit whole-file replacement, replace, and insert modes. Project lookup, past-meeting lookup, and search tools are constrained to the projects listed in the meeting note frontmatter: When assistant context contains cropped screenshot links produced by OCR, the summary instructions tell the agent to include only the most relevant cropped screenshots in the summary and markdown-link them near the related summary text. @@ -299,6 +299,8 @@ When assistant context contains cropped screenshot links produced by OCR, the su - `list_projectfiles` - `read_projectfile` - `write_projectfile` +- `list_past_project_meetings` +- `read_past_project_meeting_summary` - `search` - `write_context` - `add_attendee` @@ -306,7 +308,9 @@ When assistant context contains cropped screenshot links produced by OCR, the su - `override_speaker` - `delete_identity` -`search` accepts ripgrep syntax and returns matches as `filename:line text`. If one project is searched, paths are relative to that project; if multiple projects are searched, paths include the project folder name. +`list_past_project_meetings` reads only summary notes from the configured summaries folder and lists prior summaries assigned to the current meeting's projects, excluding the current summary file. It supports pagination through `page` and `page_size` and can optionally filter to a subset of the current meeting's projects. If the requested project is not assigned to the current meeting, the tool refuses the request. `read_past_project_meeting_summary` reads one of those prior summary files as read-only historical context, using the same `from` and `to` line range behavior as other read tools. It cannot write or mutate past summaries; `write_summary` is only for the current meeting's summary. + +`search` accepts .NET/ripgrep-style regular expression syntax and returns matches as `filename:line text`. It searches both project files and past meeting summaries for the requested current-meeting project scope. If one project is searched, project-file paths are relative to that project; if multiple projects are searched, project-file paths include the project folder name. Past summary matches are prefixed with `past-meetings/`. `write_projectfile` writes inside an existing project folder. With no line arguments it appends or creates the file. With `replace_file: true` it replaces the whole file. With `from` and `to` it replaces that inclusive 1-based line range. With `insert` it inserts content at that 1-based line position. diff --git a/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/proposal.md b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/proposal.md new file mode 100644 index 0000000..7100b19 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/proposal.md @@ -0,0 +1,12 @@ +## Why +Summary agents need project-aware access to prior meeting summaries so current summaries can reuse past decisions and context without mutating historical summary notes. + +## What Changes +- Add read-only summary-agent tools to list paginated past meeting summaries scoped to the current meeting's projects. +- Add a read-only tool to read a past project meeting summary with the same line-range behavior as other read tools. +- Extend summary-agent search so project-scoped searches include both bound project files and past summary notes for the scoped projects. +- Reject requested project scopes that are not assigned to the current meeting. + +## Impact +- Affected specs: `meeting-summary`, `agent-project-tools` +- Affected code: summary tools, summary agent tool registration, summary agent instructions, README diff --git a/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/specs/agent-project-tools/spec.md b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/specs/agent-project-tools/spec.md new file mode 100644 index 0000000..d058113 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/specs/agent-project-tools/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements +### Requirement: Agents can use project context tools +The summary agent SHALL expose these project and retrieval tools: + +- `list_projects` +- `list_projectfiles` +- `read_projectfile` +- `write_projectfile` +- `list_past_project_meetings` +- `read_past_project_meeting_summary` +- `search` + +The `search` tool SHALL search the requested current-meeting project scope, or all projects assigned to the current meeting when no project scope is supplied. + +The `search` tool SHALL search both configured project files for the scoped projects and past meeting summary notes in the configured summaries folder whose frontmatter projects intersect the scoped projects. + +The `search` tool SHALL refuse requested project scopes that are not assigned to the current meeting. + +#### Scenario: Agent searches past project meeting summaries +- **GIVEN** the current meeting note is assigned to `Project X` +- **AND** a prior summary note in the configured summaries folder is assigned to `Project X` +- **WHEN** the summary agent searches for a keyword +- **THEN** Meeting Assistant returns matches from both `Project X` project files and matching past summary notes + +#### Scenario: Agent search rejects out-of-scope project +- **GIVEN** the current meeting note is assigned to `Project X` +- **WHEN** the summary agent searches with project scope `Project Z` +- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting diff --git a/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/specs/meeting-summary/spec.md b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/specs/meeting-summary/spec.md new file mode 100644 index 0000000..b5fa43b --- /dev/null +++ b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/specs/meeting-summary/spec.md @@ -0,0 +1,57 @@ +## MODIFIED Requirements +### Requirement: Meeting Assistant generates meeting outputs +The summary pipeline SHALL expose tools scoped to the current meeting: + +- `read_meetingnote` +- `read_transcript` +- `read_context` +- `read_usernotes` +- `read_glossary` +- `add_dictation_word` +- `add_attendee` +- `remove_attendee` +- `override_speaker` +- `delete_identity` +- `write_summary` +- `write_context` +- `list_projects` +- `list_projectfiles` +- `read_projectfile` +- `write_projectfile` +- `list_past_project_meetings` +- `read_past_project_meeting_summary` +- `search` + +All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied. + +The summary agent SHALL be able to list paginated past meeting summary notes for projects assigned to the current meeting. + +The past meeting summary listing SHALL read summary notes from the configured summaries folder only. + +When no project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include past summaries assigned to any project assigned to the current meeting. + +When a project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include only past summaries assigned to those requested projects and SHALL refuse requested projects not assigned to the current meeting. + +The summary agent SHALL be able to read past project meeting summary files through a read-only tool. Meeting Assistant SHALL refuse reads for summary files outside the configured summaries folder, the current summary file, or summary files that do not belong to one of the current meeting's assigned projects. + +The summary instructions SHALL clearly distinguish `write_summary`, which writes the current meeting's summary, from past meeting summary read tools, which are read-only historical context. + +#### Scenario: Past project meetings are listed by current meeting project scope +- **GIVEN** the current meeting note is assigned to `Project X` and `Project Y` +- **AND** the summaries folder contains prior summary notes for `Project X`, `Project Y`, and `Project Z` +- **WHEN** the summary agent lists past project meetings without a project filter +- **THEN** Meeting Assistant returns prior summaries for `Project X` and `Project Y` +- **AND** excludes prior summaries for `Project Z` +- **AND** excludes the current meeting's summary file + +#### Scenario: Past project meeting listing rejects out-of-scope projects +- **GIVEN** the current meeting note is assigned to `Project X` +- **WHEN** the summary agent lists past project meetings for `Project Z` +- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting + +#### Scenario: Past project meeting summary is read-only historical context +- **GIVEN** the current meeting note is assigned to `Project X` +- **AND** a prior summary note in the configured summaries folder is assigned to `Project X` +- **WHEN** the summary agent reads that prior summary note with line bounds +- **THEN** Meeting Assistant returns the clamped inclusive line range +- **AND** exposes no tool to write that prior summary note diff --git a/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/tasks.md b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/tasks.md new file mode 100644 index 0000000..9703023 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-add-past-project-meeting-summary-tools/tasks.md @@ -0,0 +1,10 @@ +## Implementation +- [x] Add behavior tests for listing scoped past project meeting summaries with pagination. +- [x] Add behavior tests for rejecting out-of-scope project filters. +- [x] Add behavior tests for reading scoped past summary files with line ranges. +- [x] Add behavior tests for searching project files and scoped past summaries together. +- [x] Implement read-only past project meeting summary tools. +- [x] Extend generic search to include past project summaries. +- [x] Register the new tools and update summary-agent instructions/docs. +- [x] Run focused tests. +- [x] Validate OpenSpec change. diff --git a/openspec/specs/agent-project-tools/spec.md b/openspec/specs/agent-project-tools/spec.md index 5e4ab34..fba1a94 100644 --- a/openspec/specs/agent-project-tools/spec.md +++ b/openspec/specs/agent-project-tools/spec.md @@ -14,8 +14,16 @@ The summary agent SHALL expose these project tools: - `list_projectfiles` - `read_projectfile` - `write_projectfile` +- `list_past_project_meetings` +- `read_past_project_meeting_summary` - `search` +The `search` tool SHALL search the requested current-meeting project scope, or all projects assigned to the current meeting when no project scope is supplied. + +The `search` tool SHALL search both configured project files for the scoped projects and past meeting summary notes in the configured summaries folder whose frontmatter projects intersect the scoped projects. + +The `search` tool SHALL refuse requested project scopes that are not assigned to the current meeting. + #### Scenario: Agent looks up meeting project context - **WHEN** a meeting note identifies a project - **THEN** the agent can retrieve relevant project context through Meeting Assistant tools @@ -32,6 +40,17 @@ The summary agent SHALL expose these project tools: - **WHEN** the agent calls `search` with ripgrep syntax keywords - **THEN** Meeting Assistant runs the search against the requested projects or the meeting-bound projects and returns matches as `filename:line text` +#### Scenario: Agent searches past project meeting summaries +- **GIVEN** the current meeting note is assigned to `Project X` +- **AND** a prior summary note in the configured summaries folder is assigned to `Project X` +- **WHEN** the summary agent searches for a keyword +- **THEN** Meeting Assistant returns matches from both `Project X` project files and matching past summary notes + +#### Scenario: Agent search rejects out-of-scope project +- **GIVEN** the current meeting note is assigned to `Project X` +- **WHEN** the summary agent searches with project scope `Project Z` +- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting + ### Requirement: Agents can write files in existing projects Meeting Assistant SHALL expose a `write_projectfile` tool that can create or update files inside an existing project folder. diff --git a/openspec/specs/meeting-summary/spec.md b/openspec/specs/meeting-summary/spec.md index 33082f1..c8da393 100644 --- a/openspec/specs/meeting-summary/spec.md +++ b/openspec/specs/meeting-summary/spec.md @@ -42,10 +42,24 @@ The summary pipeline SHALL expose tools scoped to the current meeting: - `list_projectfiles` - `read_projectfile` - `write_projectfile` +- `list_past_project_meetings` +- `read_past_project_meeting_summary` - `search` All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied. +The summary agent SHALL be able to list paginated past meeting summary notes for projects assigned to the current meeting. + +The past meeting summary listing SHALL read summary notes from the configured summaries folder only. + +When no project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include past summaries assigned to any project assigned to the current meeting. + +When a project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include only past summaries assigned to those requested projects and SHALL refuse requested projects not assigned to the current meeting. + +The summary agent SHALL be able to read past project meeting summary files through a read-only tool. Meeting Assistant SHALL refuse reads for summary files outside the configured summaries folder, the current summary file, or summary files that do not belong to one of the current meeting's assigned projects. + +The summary instructions SHALL clearly distinguish `write_summary`, which writes the current meeting's summary, from past meeting summary read tools, which are read-only historical context. + The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context. The summary instructions SHALL tell the summary agent to include only the most relevant cropped screenshot links from assistant context in the summary, placing them near the related summary text. @@ -196,6 +210,26 @@ When `delete_identity` is called with an existing transcript speaker identity, M - **THEN** Meeting Assistant rewrites the transcript speaker label to `Removed-1` - **AND** records the deletion for final speaker identity processing +#### Scenario: Past project meetings are listed by current meeting project scope +- **GIVEN** the current meeting note is assigned to `Project X` and `Project Y` +- **AND** the summaries folder contains prior summary notes for `Project X`, `Project Y`, and `Project Z` +- **WHEN** the summary agent lists past project meetings without a project filter +- **THEN** Meeting Assistant returns prior summaries for `Project X` and `Project Y` +- **AND** excludes prior summaries for `Project Z` +- **AND** excludes the current meeting's summary file + +#### Scenario: Past project meeting listing rejects out-of-scope projects +- **GIVEN** the current meeting note is assigned to `Project X` +- **WHEN** the summary agent lists past project meetings for `Project Z` +- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting + +#### Scenario: Past project meeting summary is read-only historical context +- **GIVEN** the current meeting note is assigned to `Project X` +- **AND** a prior summary note in the configured summaries folder is assigned to `Project X` +- **WHEN** the summary agent reads that prior summary note with line bounds +- **THEN** Meeting Assistant returns the clamped inclusive line range +- **AND** exposes no tool to write that prior summary note + ### Requirement: Meeting screenshots are captured into assistant context Meeting Assistant SHALL expose a configurable screenshot hotkey.