diff --git a/AGENTS.md b/AGENTS.md index d05db89..9a7e706 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ A spec/change is not considered done merely because code is merged, tests pass, Before archiving any OpenSpec change, perform a refactoring pass over the code and specs touched by that change. The pass must inspect both the current diff and the surrounding implementation context, because a small diff may reveal repeated patterns or structural problems that only become obvious when compared with nearby code. -Do the refactoring pass in these distinct areas, first start a subagent for each area to identify potential improvements in parallel, then implement them: +Do the refactoring pass in these distinct areas in sequence so they are less likely to converge on the same issues, first start a subagent to identify potential improvements using `gpt-5.3-codex` if available, then implement them, then start the repeat for the next area: 1. Check for DRYness. Look for duplication introduced by the change and for existing nearby duplication that the change now makes worth consolidating. A change may be small on its own, but if it is the fifth copy of the same idea, it is a refactoring target. 2. Check for SOLID violations. Look for responsibilities that are mixed together, abstractions that are hard to replace or test, interface shapes that force unrelated dependencies, and code paths that require modifying stable code for each new variant. @@ -85,6 +85,10 @@ If production or end-to-end verification is not possible, state exactly why and ## Implementation Notes +Meeting Assistant is Manuel's local meeting capture and knowledge system. It runs as a background .NET service with a Windows tray icon, captures microphone plus system audio, transcribes meetings, maintains Obsidian meeting artifacts, enriches meetings from Outlook metadata when available, applies local workflow rules, identifies speakers, captures screenshots, and runs agentic summarization against a LiteLLM/OpenAI-compatible endpoint. + +The app is used during real meetings. Treat active recording, transcription finalization, OCR, and summarization as live user work. Prefer local endpoints, generated note files, and application logs for verification, and avoid restart or cleanup actions that could interrupt an active run unless the user explicitly requested them. + Keep source and deployment ownership separate: - `Manuel/meeting-assistant` owns application source, tests, build configuration, container image publishing, and OpenSpec source changes. diff --git a/MeetingAssistant.Tests/MeetingSummaryToolTests.cs b/MeetingAssistant.Tests/MeetingSummaryToolTests.cs index 6c2ab24..426ac64 100644 --- a/MeetingAssistant.Tests/MeetingSummaryToolTests.cs +++ b/MeetingAssistant.Tests/MeetingSummaryToolTests.cs @@ -50,7 +50,7 @@ public sealed class MeetingSummaryToolTests Assert.False(tools.SummaryWasWritten); var result = await tools.WriteSummary("# Summary\n\n- Done.", "Done items"); - Assert.Equal(artifacts.SummaryPath, result); + Assert.Equal(Path.GetFileName(artifacts.SummaryPath), result); Assert.True(tools.SummaryWasWritten); var summary = await File.ReadAllTextAsync(artifacts.SummaryPath); Assert.Contains("title: Meeting", summary); @@ -478,6 +478,22 @@ public sealed class MeetingSummaryToolTests Assert.Equal("", await tools.ReadTranscript(from: 3, to: 2)); } + [Fact] + public async Task ReadToolsSupportTail() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var artifacts = 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", "summary.md")); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!); + await File.WriteAllTextAsync(artifacts.TranscriptPath, "one\ntwo\nthree"); + var tools = new MeetingSummaryTools(artifacts); + + Assert.Equal("two\nthree", await tools.ReadTranscript(tail: 2)); + } + [Fact] public async Task ToolsWriteAssistantContextBodyWithLineModes() { diff --git a/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs b/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs index 6fe7184..f304aa1 100644 --- a/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs +++ b/MeetingAssistant.Tests/ProjectKnowledgeToolTests.cs @@ -121,6 +121,41 @@ public sealed class ProjectKnowledgeToolTests Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true)); } + [Fact] + public async Task SearchCanBeLimitedByProjectFilePattern() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var projectsRoot = Path.Combine(root, "Projects"); + var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant"); + Directory.CreateDirectory(projectRoot); + await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), "durable needle"); + await File.WriteAllTextAsync(Path.Combine(projectRoot, "scratch.txt"), "scratch needle"); + var artifacts = CreateArtifacts(root); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + await File.WriteAllTextAsync( + artifacts.MeetingNotePath, + """ + --- + projects: + - MeetingAssistant + --- + """); + var tools = new MeetingSummaryTools( + artifacts, + new MeetingAssistantOptions + { + Vault = + { + ProjectsFolder = projectsRoot + } + }); + + var search = await tools.Search("needle", file_pattern: "*.md"); + + Assert.Contains("PROJECT.md:1 durable needle", search); + Assert.DoesNotContain("scratch.txt", search); + } + private static MeetingSessionArtifacts CreateArtifacts(string root) { return new MeetingSessionArtifacts( diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index a8a19e4..6752a84 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -145,6 +145,40 @@ public sealed class RecordingCoordinatorTests await coordinator.StopAsync(CancellationToken.None); } + [Fact] + public async Task StopTreatsRunsShorterThanConfiguredMinimumAsAbort() + { + var audioSource = new ControlledAudioSource(); + var artifactCleaner = new CapturingMeetingRunArtifactCleaner(); + var summaryPipeline = new CapturingMeetingSummaryPipeline(); + var coordinator = new MeetingRecordingCoordinator( + audioSource, + new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), + new InMemoryTranscriptStore(), + new InMemoryMeetingNoteStore(), + new CapturingMeetingNoteOpener(), + new InMemoryMeetingArtifactStore(), + new InMemoryRecordedAudioStore(), + summaryPipeline, + Options.Create(new MeetingAssistantOptions + { + Recording = + { + MinimumCompletedMeetingDuration = TimeSpan.FromMinutes(1) + } + }), + NullLogger.Instance, + artifactCleaner: artifactCleaner); + + var started = await coordinator.StartAsync(CancellationToken.None); + var stopped = await coordinator.StopAsync(CancellationToken.None); + + Assert.False(stopped.IsRecording); + Assert.Null(stopped.MeetingNotePath); + Assert.False(summaryPipeline.WasRun); + Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath); + } + [Fact] public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable() { @@ -2240,6 +2274,8 @@ public sealed class RecordingCoordinatorTests public MeetingAssistantOptions? Options { get; private set; } + public bool WasRun => Artifacts is not null; + public Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) @@ -2262,6 +2298,23 @@ public sealed class RecordingCoordinatorTests } } + private sealed class CapturingMeetingRunArtifactCleaner : IMeetingRunArtifactCleaner + { + public MeetingSessionArtifacts? DeletedArtifacts { get; private set; } + + public MeetingAssistantOptions? Options { get; private set; } + + public Task DeleteRunArtifactsAsync( + MeetingSessionArtifacts artifacts, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + DeletedArtifacts = artifacts; + Options = options; + return Task.CompletedTask; + } + } + private sealed class RecordingSummaryPipeline : IMeetingSummaryPipeline { public List ArtifactHistory { get; } = []; diff --git a/MeetingAssistant.Tests/TaskbarIconTests.cs b/MeetingAssistant.Tests/TaskbarIconTests.cs index 9416f8f..b4301f2 100644 --- a/MeetingAssistant.Tests/TaskbarIconTests.cs +++ b/MeetingAssistant.Tests/TaskbarIconTests.cs @@ -16,7 +16,7 @@ public sealed class TaskbarIconTests Assert.Equal(RecordingProcessState.Idle, menu.State); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.EditRules && - item.Text == "Edit rules and identities"); + item.Text == "Settings and logs"); Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StartRecording && item.ProfileName == "default" && diff --git a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs index 9736365..29d063c 100644 --- a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs +++ b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs @@ -1,6 +1,8 @@ using MeetingAssistant.Workflow; +using MeetingAssistant.Logging; using MeetingAssistant.Speakers; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; @@ -82,6 +84,239 @@ public sealed class WorkflowRulesEditorTests Assert.DoesNotContain("other.yaml", searchResult); } + [Fact] + public async Task SettingsToolsReadWriteAndValidateConfiguredAppsettingsFile() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var configPath = Path.Combine(root, "appsettings.json"); + await File.WriteAllTextAsync( + configPath, + """ + { + "MeetingAssistant": { + "Agent": { + "Model": "old-model" + } + } + } + """); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions(), + configPath: configPath); + + var readResult = await tools.ReadConfig(from: 3, to: 5); + var writeResult = await tools.WriteConfig(""" + { + "MeetingAssistant": { + "Agent": { + "Model": "new-model" + } + } + } + """); + var invalidResult = await tools.WriteConfig("{ invalid json"); + + Assert.Contains("\"Agent\"", readResult); + Assert.Equal(configPath, writeResult); + Assert.StartsWith("Refused: appsettings JSON is invalid.", invalidResult); + Assert.Contains("new-model", await File.ReadAllTextAsync(configPath)); + Assert.DoesNotContain("invalid json", await File.ReadAllTextAsync(configPath)); + } + + [Fact] + public async Task SettingsToolsReadConfigurationDocumentation() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var docsPath = Path.Combine(root, "meeting-assistant-configuration.md"); + await File.WriteAllTextAsync(docsPath, "# Config Docs\n\nAgent model settings."); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions(), + configDocsPath: docsPath); + + var docs = await tools.ReadConfigDocs(); + + Assert.Contains("Agent model settings", docs); + } + + [Fact] + public async Task SettingsToolsReadAndSearchApplicationLogs() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + await File.WriteAllTextAsync( + Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName), + "first line\nsecond needle\nthird line\n"); + await File.WriteAllTextAsync( + Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)), + "rotated needle\n"); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions(), + logDirectory: root); + + var tail = await tools.ReadLogs(tail: 2); + var range = await tools.ReadLogs(from: 2, to: 2); + var search = await tools.SearchLogs("needle"); + + Assert.DoesNotContain("first line", tail); + Assert.Contains("second needle", tail); + Assert.Equal("second needle", range.Trim()); + Assert.Contains("meeting-assistant.log:2 second needle", search); + Assert.Contains("meeting-assistant.log.1:1 rotated needle", search); + } + + [Fact] + public async Task SettingsToolsReadAndSearchOnlyCopiedSpecFiles() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var sessionSpec = Path.Combine(root, "meeting-session", "spec.md"); + Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!); + await File.WriteAllTextAsync( + sessionSpec, + """ + # meeting-session Specification + + Meeting Assistant creates notes. + """); + await File.WriteAllTextAsync( + Path.Combine(root, "root-spec.md"), + "Root spec mentions Assistant."); + var outside = Path.Combine(Path.GetDirectoryName(root)!, "outside.md"); + await File.WriteAllTextAsync(outside, "outside Assistant"); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions(), + specRootPath: root); + + var read = await tools.ReadSpecFile("meeting-session/spec.md", from: 3, to: 3); + var escaped = await tools.ReadSpecFile("../outside.md"); + var search = await tools.SearchSpec("Assistant"); + + Assert.Equal("Meeting Assistant creates notes.", read.Trim()); + Assert.StartsWith("Refused: spec path must stay inside", escaped); + Assert.Contains("meeting-session/spec.md:3 Meeting Assistant creates notes.", search); + Assert.Contains("root-spec.md:1 Root spec mentions Assistant.", search); + Assert.DoesNotContain("outside.md", search); + } + + [Fact] + public async Task SettingsToolsReadTailAndSearchSpecsWithFilePattern() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var sessionSpec = Path.Combine(root, "meeting-session", "spec.md"); + var otherSpec = Path.Combine(root, "other", "notes.md"); + Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!); + Directory.CreateDirectory(Path.GetDirectoryName(otherSpec)!); + await File.WriteAllTextAsync(sessionSpec, "one\nsession needle\nthree\nfour"); + await File.WriteAllTextAsync(otherSpec, "other needle"); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions(), + specRootPath: root); + + var tail = await tools.ReadSpecFile("meeting-session/spec.md", tail: 2); + var search = await tools.SearchSpec("needle", file_pattern: "meeting-session/*.md"); + + Assert.Equal("three\nfour", tail); + Assert.Contains("meeting-session/spec.md:2 session needle", search); + Assert.DoesNotContain("other/notes.md", search); + } + + [Fact] + public async Task SettingsToolsCreateProjectWithRecommendedSeedOrDirectAgentsFile() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var projectsRoot = Path.Combine(root, "Projects"); + var agentsTemplatePath = Path.Combine(root, "Project-AGENTS.md"); + Directory.CreateDirectory(root); + await File.WriteAllTextAsync(agentsTemplatePath, "Recommended project instructions."); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions + { + Vault = + { + ProjectsFolder = projectsRoot + } + }, + projectAgentsTemplatePath: agentsTemplatePath); + + var recommended = await tools.CreateProject("Alpha Project"); + var direct = await tools.CreateProject( + "Beta Project", + seed: "agents_md", + agents_md: "Direct project instructions."); + + var alphaRoot = Path.Combine(projectsRoot, "Alpha Project"); + var betaRoot = Path.Combine(projectsRoot, "Beta Project"); + Assert.Equal("Alpha Project", recommended); + Assert.Contains("Alpha Project", await tools.ListProjects()); + Assert.Equal("Recommended project instructions.", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "AGENTS.md"))); + Assert.Contains("# Executive Summary", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md"))); + Assert.Contains("project folder was created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md"))); + Assert.Contains("Important decisions", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "DECISIONS.md"))); + Assert.Equal("Direct project instructions.", await File.ReadAllTextAsync(Path.Combine(betaRoot, "AGENTS.md"))); + Assert.False(File.Exists(Path.Combine(betaRoot, "PROJECT.md"))); + Assert.StartsWith("Refused:", await tools.CreateProject("../Escape")); + } + + [Fact] + public async Task SettingsToolsReadWriteAndSearchExistingProjectFiles() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var projectsRoot = Path.Combine(root, "Projects"); + var alphaRoot = Path.Combine(projectsRoot, "Alpha"); + var betaRoot = Path.Combine(projectsRoot, "Beta"); + Directory.CreateDirectory(alphaRoot); + Directory.CreateDirectory(betaRoot); + await File.WriteAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md"), "alpha one\nalpha needle\nalpha three"); + await File.WriteAllTextAsync(Path.Combine(alphaRoot, "notes.txt"), "alpha needle hidden from md search"); + await File.WriteAllTextAsync(Path.Combine(betaRoot, "PROJECT.md"), "beta needle"); + var tools = new WorkflowRulesEditorTools( + new MeetingAssistantOptions + { + Vault = + { + ProjectsFolder = projectsRoot + } + }); + + var files = await tools.ListProjectFiles("Alpha"); + var tail = await tools.ReadProjectFile("Alpha", "PROJECT.md", tail: 2); + var write = await tools.WriteProjectFile("Alpha", "JOURNAL.md", "created"); + var search = await tools.SearchProjects("needle", file_pattern: "*.md"); + + Assert.Equal("notes.txt\nPROJECT.md", files); + Assert.Equal("alpha needle\nalpha three", tail); + Assert.Equal("Alpha/JOURNAL.md", write); + Assert.Equal("created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md"))); + Assert.Contains("Alpha/PROJECT.md:2 alpha needle", search); + Assert.Contains("Beta/PROJECT.md:1 beta needle", search); + Assert.DoesNotContain("notes.txt", search); + Assert.StartsWith("Refused:", await tools.ReadProjectFile("Missing", "PROJECT.md")); + Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "../escape.md", "bad")); + } + + [Fact] + public void FileLoggerRotatesCurrentLogAndKeepsFourOlderFiles() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName), "current"); + File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)), "old1"); + File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(2)), "old2"); + File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(3)), "old3"); + File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4)), "old4"); + + using (var provider = new MeetingAssistantFileLoggerProvider(root)) + { + provider.CreateLogger("Test").LogInformation("fresh"); + } + + Assert.Contains("fresh", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName))); + Assert.Equal("current", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)))); + Assert.Equal("old3", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4)))); + Assert.False(File.Exists(Path.Combine(root, "meeting-assistant.log.5"))); + } + [Fact] public async Task RulesEditorToolsAppendByDefaultAndReplaceOnlyWhenRequested() { @@ -157,6 +392,9 @@ public sealed class WorkflowRulesEditorTests Assert.Contains("Meeting Workflow Engine", instructions); Assert.Contains("Workflow rules reference documentation", instructions); Assert.Contains("speaker identity database", instructions); + Assert.Contains("read_config", instructions); + Assert.Contains("read_logs", instructions); + Assert.Contains("read_spec_file", instructions); } [Fact] diff --git a/MeetingAssistant/AgentFileToolContent.cs b/MeetingAssistant/AgentFileToolContent.cs new file mode 100644 index 0000000..cd63437 --- /dev/null +++ b/MeetingAssistant/AgentFileToolContent.cs @@ -0,0 +1,255 @@ +using System.Text.RegularExpressions; + +namespace MeetingAssistant; + +internal static class AgentFileToolContent +{ + public static string ReadLines(string content, int? from = null, int? to = null, int? tail = null) + { + if (!from.HasValue && !to.HasValue) + { + return tail.HasValue + ? TailLines(content, Math.Clamp(tail.Value, 1, 2000)) + : 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)); + } + + public static string TailLines(string content, int count) + { + var lines = SplitLines(content); + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + if (lines.Count <= count) + { + return string.Join('\n', lines); + } + + return string.Join('\n', lines.GetRange(lines.Count - count, count)); + } + + public static string ApplyLineEdit( + string existingContent, + string replacementContent, + AgentFileEditMode editMode) + { + if (editMode.Kind == AgentFileEditKind.Overwrite) + { + return replacementContent; + } + + if (editMode.Kind == AgentFileEditKind.Append) + { + return AppendContent(existingContent, replacementContent); + } + + var lines = SplitLines(existingContent); + var replacementLines = SplitLines(replacementContent); + if (editMode.Kind == AgentFileEditKind.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); + } + + public static async Task WriteFileContentAsync( + string filePath, + string content, + AgentFileEditMode editMode) + { + if (editMode.Kind == AgentFileEditKind.Overwrite) + { + await File.WriteAllTextAsync(filePath, content); + return; + } + + if (editMode.Kind == AgentFileEditKind.Append) + { + var existing = File.Exists(filePath) + ? await File.ReadAllTextAsync(filePath) + : ""; + await File.WriteAllTextAsync(filePath, AppendContent(existing, content)); + return; + } + + var existingContent = File.Exists(filePath) + ? await File.ReadAllTextAsync(filePath) + : ""; + await File.WriteAllTextAsync(filePath, ApplyLineEdit(existingContent, content, editMode)); + } + + public static string AppendContent(string existingContent, string content) + { + if (string.IsNullOrEmpty(existingContent)) + { + return content; + } + + if (string.IsNullOrEmpty(content)) + { + return existingContent; + } + + var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r') + ? "" + : "\n"; + return existingContent + separator + content; + } + + public static List SplitLines(string content) + { + if (content.Length == 0) + { + return []; + } + + return content + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .ToList(); + } + + public 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); + } + + public static bool MatchesGlob(string path, string? pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + return true; + } + + var normalizedPath = ToToolPath(path); + var normalizedPattern = ToToolPath(pattern.Trim()); + var placeholder = "\u0000"; + var regexPattern = Regex.Escape(normalizedPattern) + .Replace("\\*\\*", placeholder, StringComparison.Ordinal) + .Replace("\\*", "[^/]*", StringComparison.Ordinal) + .Replace("\\?", "[^/]", StringComparison.Ordinal) + .Replace(placeholder, ".*", StringComparison.Ordinal); + return Regex.IsMatch(normalizedPath, $"^{regexPattern}$", RegexOptions.IgnoreCase); + } + + public static string ToToolPath(string path) + { + return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal) + .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); + } + + public static string SearchFiles( + string keywords, + IEnumerable sources, + int maxMatches = 100, + RegexOptions regexOptions = RegexOptions.IgnoreCase) + { + if (string.IsNullOrWhiteSpace(keywords)) + { + return ""; + } + + try + { + var regex = new Regex(keywords, regexOptions); + var matches = new List(); + var limit = Math.Clamp(maxMatches, 1, 1000); + foreach (var source in sources) + { + var lines = File.ReadAllLines(source.Path); + for (var index = 0; index < lines.Length; index++) + { + if (regex.IsMatch(lines[index])) + { + matches.Add($"{ToToolPath(source.DisplayPath)}:{index + 1} {lines[index]}"); + } + + if (matches.Count >= limit) + { + return string.Join('\n', matches); + } + } + } + + return string.Join('\n', matches); + } + catch + { + return ""; + } + } +} + +internal sealed record AgentFileSearchSource(string Path, string DisplayPath); + +internal sealed record AgentFileEditMode( + AgentFileEditKind Kind, + int? From = null, + int? To = null, + int? Insert = null) +{ + public static AgentFileEditMode? Create(int? from, int? to, int? insert, bool replaceFile) + { + if (replaceFile && (from.HasValue || to.HasValue || insert.HasValue)) + { + return null; + } + + if (insert.HasValue) + { + return from.HasValue || to.HasValue + ? null + : new AgentFileEditMode(AgentFileEditKind.Insert, Insert: insert); + } + + if (from.HasValue || to.HasValue) + { + return from.HasValue && to.HasValue && from.Value <= to.Value + ? new AgentFileEditMode(AgentFileEditKind.Replace, From: from, To: to) + : null; + } + + return new AgentFileEditMode(replaceFile ? AgentFileEditKind.Overwrite : AgentFileEditKind.Append); + } +} + +internal enum AgentFileEditKind +{ + Append, + Overwrite, + Replace, + Insert +} diff --git a/MeetingAssistant/Content/Project-AGENTS.md b/MeetingAssistant/Content/Project-AGENTS.md new file mode 100644 index 0000000..b2e2d1b --- /dev/null +++ b/MeetingAssistant/Content/Project-AGENTS.md @@ -0,0 +1,74 @@ +The project is split in 3 important files: +PROJECT.md +DECISIONS.md +JOURNAL.md + +The idea is a hierarchical summary: + +Meeting Summary -> detailed (what was discussed) +then Journal Entry -> compressed (why should I care this event happened?) +then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.) + +The journal should always be appended and read using tail or searched. + +Example journal entry: + +```markdown +## 2026-05-30 +### Team Daily + +New Blocker: HLS delayed + +Impact: Release likely delayed, team implements Z-Levels in the meantime. + +[[20260529-093030-6187486-summary|Summary]] + +### Refinement + +Calendar-Feature is refined, Golem feature needs more work. + +[[20260529-103030-3465634-summary|Summary]] +``` + +PROJECT.md should use this structure: + +```markdown +# Executive Summary + + + +## Business Goals + +## Priorities and High Level Constraints + +## Current Phase & Status + +## Next Milestones + +## Open Risks + +## Important Stakeholders + +## Key Documents + +Links to: Architecture Overview, Technical Onboarding, etc. + +[[JOURNAL.md]] +[[DECISIONS.md]] +``` + +DECISIONS.md logs decisions in the same compressed way that JOURNAL.md logs meetings, with links to ADRs and/or meeting summaries for details. +Only include important decisions: architecture decisions that are expensive to change, and project decisions about timeline, goals, governance, team, or process. +Do not log small implementation details such as how UI elements are aligned. + +Example decision entries: + +```markdown +## How do we handle project delays? +Communicate to first, let them decide further actions. +[[20260529-093030-6187486-summary|Source]] + +## How will authentication be handled? +Company SSO with provided UI package. +[[ADR-0027-use-sso-and-ui-package-for-auth|Source]] +``` diff --git a/MeetingAssistant/Logging/MeetingAssistantFileLoggerProvider.cs b/MeetingAssistant/Logging/MeetingAssistantFileLoggerProvider.cs new file mode 100644 index 0000000..b4dfc36 --- /dev/null +++ b/MeetingAssistant/Logging/MeetingAssistantFileLoggerProvider.cs @@ -0,0 +1,192 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace MeetingAssistant.Logging; + +public static class MeetingAssistantLogFiles +{ + public const string CurrentLogFileName = "meeting-assistant.log"; + public const int RetainedRotatedLogFiles = 4; + + public static string DefaultLogDirectory => + Path.Combine(Path.GetTempPath(), "MeetingAssistant", "Logs"); + + public static string CurrentLogPath(string? directory = null) + { + return Path.Combine(directory ?? DefaultLogDirectory, CurrentLogFileName); + } + + public static string GetRotatedLogFileName(int index) + { + return $"{CurrentLogFileName}.{index}"; + } + + public static IReadOnlyList EnumerateLogPaths(string? directory = null) + { + var root = directory ?? DefaultLogDirectory; + return Enumerable.Range(0, RetainedRotatedLogFiles + 1) + .Select(index => index == 0 + ? Path.Combine(root, CurrentLogFileName) + : Path.Combine(root, GetRotatedLogFileName(index))) + .Where(File.Exists) + .ToArray(); + } + + public static void Rotate(string? directory = null) + { + var root = directory ?? DefaultLogDirectory; + Directory.CreateDirectory(root); + + try + { + var oldest = Path.Combine(root, GetRotatedLogFileName(RetainedRotatedLogFiles)); + if (File.Exists(oldest)) + { + File.Delete(oldest); + } + + for (var index = RetainedRotatedLogFiles - 1; index >= 1; index--) + { + var source = Path.Combine(root, GetRotatedLogFileName(index)); + if (!File.Exists(source)) + { + continue; + } + + var target = Path.Combine(root, GetRotatedLogFileName(index + 1)); + File.Move(source, target, overwrite: true); + } + + var current = Path.Combine(root, CurrentLogFileName); + if (File.Exists(current)) + { + File.Move(current, Path.Combine(root, GetRotatedLogFileName(1)), overwrite: true); + } + } + catch (IOException) + { + // Another app instance or test host may still have the log open. + // Logging should keep working, so append to the existing current log. + } + } +} + +public sealed class MeetingAssistantFileLoggerProvider : ILoggerProvider +{ + private readonly ConcurrentDictionary loggers = new(StringComparer.Ordinal); + private readonly object sync = new(); + private readonly StreamWriter writer; + private bool disposed; + + public MeetingAssistantFileLoggerProvider(string? directory = null) + { + var logDirectory = directory ?? MeetingAssistantLogFiles.DefaultLogDirectory; + MeetingAssistantLogFiles.Rotate(logDirectory); + var stream = new FileStream( + MeetingAssistantLogFiles.CurrentLogPath(logDirectory), + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite); + writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true + }; + } + + public ILogger CreateLogger(string categoryName) + { + return loggers.GetOrAdd(categoryName, name => new MeetingAssistantFileLogger(name, Write)); + } + + public void Dispose() + { + lock (sync) + { + if (disposed) + { + return; + } + + disposed = true; + writer.Dispose(); + } + } + + private void Write( + string categoryName, + LogLevel logLevel, + EventId eventId, + string message, + Exception? exception) + { + if (disposed) + { + return; + } + + lock (sync) + { + if (disposed) + { + return; + } + + writer.Write(DateTimeOffset.Now.ToString("O")); + writer.Write(' '); + writer.Write(logLevel); + writer.Write(" ["); + writer.Write(categoryName); + writer.Write(']'); + if (eventId.Id != 0 || !string.IsNullOrWhiteSpace(eventId.Name)) + { + writer.Write(" ("); + writer.Write(eventId.Id); + if (!string.IsNullOrWhiteSpace(eventId.Name)) + { + writer.Write(':'); + writer.Write(eventId.Name); + } + + writer.Write(')'); + } + + writer.Write(' '); + writer.WriteLine(message); + if (exception is not null) + { + writer.WriteLine(exception); + } + } + } + + private sealed class MeetingAssistantFileLogger( + string categoryName, + Action write) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel != LogLevel.None; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + write(categoryName, logLevel, eventId, formatter(state, exception), exception); + } + } +} diff --git a/MeetingAssistant/MeetingAssistant.csproj b/MeetingAssistant/MeetingAssistant.csproj index 6fec471..9f1f9ca 100644 --- a/MeetingAssistant/MeetingAssistant.csproj +++ b/MeetingAssistant/MeetingAssistant.csproj @@ -41,7 +41,10 @@ - + + + + diff --git a/MeetingAssistant/MeetingAssistantOptions.cs b/MeetingAssistant/MeetingAssistantOptions.cs index 97cb04b..48bf874 100644 --- a/MeetingAssistant/MeetingAssistantOptions.cs +++ b/MeetingAssistant/MeetingAssistantOptions.cs @@ -120,6 +120,8 @@ public sealed class RecordingOptions public TimeSpan StopProcessingTimeout { get; set; } = TimeSpan.FromMinutes(10); + public TimeSpan MinimumCompletedMeetingDuration { get; set; } = TimeSpan.Zero; + public TimeSpan MetadataLookupTimeout { get; set; } = TimeSpan.FromSeconds(10); public TimeSpan BackgroundMetadataLookupTimeout { get; set; } = TimeSpan.FromMinutes(1); diff --git a/MeetingAssistant/Program.cs b/MeetingAssistant/Program.cs index 45d41af..f2e42b4 100644 --- a/MeetingAssistant/Program.cs +++ b/MeetingAssistant/Program.cs @@ -1,6 +1,7 @@ using MeetingAssistant; using MeetingAssistant.Hotkeys; using MeetingAssistant.LaunchProfiles; +using MeetingAssistant.Logging; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Recording; using MeetingAssistant.Screenshots; @@ -13,6 +14,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); +builder.Logging.AddProvider(new MeetingAssistantFileLoggerProvider()); builder.Services.Configure(builder.Configuration.GetSection("MeetingAssistant")); builder.Services.AddSingleton(); #if WINDOWS @@ -232,6 +234,12 @@ app.MapPost("/diagnostics/workflow/rules-editor/show", ( rulesEditorWindow.Show(); return Results.Accepted(); }); +app.MapPost("/diagnostics/settings-and-logs/show", ( + IWorkflowRulesEditorWindowService rulesEditorWindow) => +{ + rulesEditorWindow.Show(); + return Results.Accepted(); +}); static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration) { diff --git a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs index dfc1bac..c95cc8e 100644 --- a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs +++ b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs @@ -203,6 +203,7 @@ public sealed class MeetingRecordingCoordinator pipeline, pipelineOptions, runOptions, + startedAt, launchProfile.Name, runOptions.SpeakerIdentification.LiveSampleBufferDuration, runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker); @@ -232,6 +233,7 @@ public sealed class MeetingRecordingCoordinator public async Task StopAsync(CancellationToken cancellationToken) { RecordingRun run; + var abortAsTooShort = false; await gate.WaitAsync(cancellationToken); try @@ -241,14 +243,29 @@ public sealed class MeetingRecordingCoordinator return CurrentStatus; } - currentRun.StopCapture(); run = currentRun; + abortAsTooShort = ShouldAbortRunOnStop(run, DateTimeOffset.Now); + if (abortAsTooShort) + { + run.Abort(); + } + else + { + run.StopCapture(); + } } finally { gate.Release(); } + if (abortAsTooShort) + { + await CompleteAbortedRunAsync(run, cancellationToken); + logger.LogInformation("Meeting recording was shorter than the configured minimum and was aborted"); + return CurrentStatus; + } + try { await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken); @@ -271,7 +288,6 @@ public sealed class MeetingRecordingCoordinator public async Task AbortAsync(CancellationToken cancellationToken) { RecordingRun run; - MeetingSessionArtifacts artifacts; await gate.WaitAsync(cancellationToken); try @@ -282,7 +298,6 @@ public sealed class MeetingRecordingCoordinator } run = currentRun; - artifacts = run.Artifacts; run.Abort(); } finally @@ -290,6 +305,13 @@ public sealed class MeetingRecordingCoordinator gate.Release(); } + await CompleteAbortedRunAsync(run, cancellationToken); + logger.LogInformation("Meeting recording aborted and artifacts deleted"); + return CurrentStatus; + } + + private async Task CompleteAbortedRunAsync(RecordingRun run, CancellationToken cancellationToken) + { try { await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken); @@ -302,12 +324,10 @@ public sealed class MeetingRecordingCoordinator } await screenshotService.CancelPendingOcrAsync( - artifacts, + run.Artifacts, run.Options.Screenshots.Ocr.GetEffectiveTimeout(), cancellationToken); - await artifactCleaner.DeleteRunArtifactsAsync(artifacts, run.Options, cancellationToken); - logger.LogInformation("Meeting recording aborted and artifacts deleted"); - return CurrentStatus; + await artifactCleaner.DeleteRunArtifactsAsync(run.Artifacts, run.Options, cancellationToken); } public Task CaptureScreenshotAsync(CancellationToken cancellationToken) @@ -1265,7 +1285,13 @@ public sealed class MeetingRecordingCoordinator string artifactName) { var folder = VaultPath.Resolve(vault, configuredFolder); - return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss-fffffff}-{artifactName}.md"); + return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmm}-{artifactName}.md"); + } + + private static bool ShouldAbortRunOnStop(RecordingRun run, DateTimeOffset stoppedAt) + { + var minimumDuration = run.Options.Recording.MinimumCompletedMeetingDuration; + return minimumDuration > TimeSpan.Zero && stoppedAt - run.StartedAt < minimumDuration; } private async Task CompleteRunAsync(RecordingRun run) @@ -1318,6 +1344,7 @@ public sealed class MeetingRecordingCoordinator ISpeechRecognitionPipeline pipeline, SpeechRecognitionPipelineOptions pipelineOptions, MeetingAssistantOptions options, + DateTimeOffset startedAt, string launchProfileName, TimeSpan liveSampleBufferDuration, int maxSpeakerSamples) @@ -1332,6 +1359,7 @@ public sealed class MeetingRecordingCoordinator Pipeline = pipeline; PipelineOptions = pipelineOptions; Options = options; + StartedAt = startedAt; LaunchProfileName = launchProfileName; pipelines.Add(pipeline); speakerSampleCollector = new SpeakerAudioSampleCollector( @@ -1363,6 +1391,8 @@ public sealed class MeetingRecordingCoordinator public MeetingAssistantOptions Options { get; private set; } + public DateTimeOffset StartedAt { get; } + public string LaunchProfileName { get; private set; } public CancellationToken CaptureCancellation => CaptureCancellationSource.Token; diff --git a/MeetingAssistant/RuntimeContentLocator.cs b/MeetingAssistant/RuntimeContentLocator.cs new file mode 100644 index 0000000..45dde54 --- /dev/null +++ b/MeetingAssistant/RuntimeContentLocator.cs @@ -0,0 +1,39 @@ +namespace MeetingAssistant; + +internal static class RuntimeContentLocator +{ + public static IEnumerable CandidateDocumentationPaths(string fileName) + { + foreach (var directory in EnumerateBaseDirectoryAndParents()) + { + yield return Path.Combine(directory, "docs", fileName); + } + } + + public static IEnumerable CandidateDirectoryPaths(params string[] segments) + { + foreach (var directory in EnumerateBaseDirectoryAndParents()) + { + yield return Path.Combine([directory, .. segments]); + } + } + + public static IEnumerable CandidateContentPaths(string fileName) + { + foreach (var directory in EnumerateBaseDirectoryAndParents()) + { + yield return Path.Combine(directory, "MeetingAssistant", "Content", fileName); + yield return Path.Combine(directory, "Content", fileName); + } + } + + private static IEnumerable EnumerateBaseDirectoryAndParents(int maxParentDepth = 8) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var i = 0; i < maxParentDepth && current is not null; i++) + { + yield return current.FullName; + current = current.Parent; + } + } +} diff --git a/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs b/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs index 7e314e2..1d6bdd5 100644 --- a/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs +++ b/MeetingAssistant/Summary/MeetingSummaryInstructionBuilder.cs @@ -10,7 +10,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio 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. + 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 returns the written summary filename so project journal entries can link to it. 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. diff --git a/MeetingAssistant/Summary/MeetingSummaryTools.cs b/MeetingAssistant/Summary/MeetingSummaryTools.cs index bf86af2..1358697 100644 --- a/MeetingAssistant/Summary/MeetingSummaryTools.cs +++ b/MeetingAssistant/Summary/MeetingSummaryTools.cs @@ -35,22 +35,22 @@ public sealed class MeetingSummaryTools projectResolver = new BoundMeetingProjectResolver(options); } - public Task ReadTranscript(int? @from = null, int? to = null) + public Task ReadTranscript(int? @from = null, int? to = null, int? tail = null) { - return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to); + return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to, tail); } - public Task ReadMeetingNote(int? @from = null, int? to = null) + public Task ReadMeetingNote(int? @from = null, int? to = null, int? tail = null) { - return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to); + return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to, tail); } - public Task ReadContext(int? @from = null, int? to = null) + public Task ReadContext(int? @from = null, int? to = null, int? tail = null) { - return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to); + return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to, tail); } - public async Task ReadUserNotes(int? @from = null, int? to = null) + public async Task ReadUserNotes(int? @from = null, int? to = null, int? tail = null) { if (!File.Exists(artifacts.MeetingNotePath)) { @@ -60,12 +60,12 @@ public sealed class MeetingSummaryTools 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); + return AgentFileToolContent.ReadLines(body, @from, to, tail); } - public Task ReadGlossary(int? @from = null, int? to = null) + public Task ReadGlossary(int? @from = null, int? to = null, int? tail = null) { - return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to); + return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to, tail); } public async Task AddDictationWord(string word) @@ -232,7 +232,7 @@ public sealed class MeetingSummaryTools artifacts.SummaryPath, MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown)); SummaryWasWritten = true; - return artifacts.SummaryPath; + return Path.GetFileName(artifacts.SummaryPath); } private static bool ContainsLineBreak(string value) @@ -250,7 +250,7 @@ public sealed class MeetingSummaryTools int? insert = null, bool replace_file = false) { - var editMode = FileLineEditMode.Create(@from, to, insert, replace_file); + 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."; @@ -263,7 +263,7 @@ public sealed class MeetingSummaryTools var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath); var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing); - var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode); + var updatedBody = AgentFileToolContent.ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode); var updated = string.IsNullOrWhiteSpace(frontmatter) ? updatedBody : "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody; @@ -293,12 +293,12 @@ public sealed class MeetingSummaryTools } var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories) - .Select(path => ToToolPath(Path.GetRelativePath(projectRoot, path))) + .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) + 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)) @@ -306,7 +306,7 @@ public sealed class MeetingSummaryTools return ""; } - return ReadLines(await File.ReadAllTextAsync(filePath), @from, to); + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), @from, to, tail); } public async Task ListPastProjectMeetings( @@ -338,7 +338,7 @@ public sealed class MeetingSummaryTools return string.Join('\n', lines); } - public async Task ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null) + 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) @@ -346,7 +346,7 @@ public sealed class MeetingSummaryTools return summary.Refusal; } - return ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to); + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to, tail); } public async Task WriteProjectFile( @@ -358,7 +358,7 @@ public sealed class MeetingSummaryTools int? insert = null, bool replace_file = false) { - var editMode = FileLineEditMode.Create(@from, to, insert, replace_file); + 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."; @@ -375,17 +375,17 @@ public sealed class MeetingSummaryTools { await writeAudit.CaptureFileWriteAsync( target.Path, - () => WriteProjectFileContentAsync(target.Path, content, editMode)); + () => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode)); } else { - await WriteProjectFileContentAsync(target.Path, content, editMode); + await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode); } - return $"{target.Project.Name}/{ToToolPath(path)}"; + return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}"; } - public async Task Search(string keywords, string[]? projects = null) + public async Task Search(string keywords, string[]? projects = null, string? file_pattern = null) { if (string.IsNullOrWhiteSpace(keywords)) { @@ -400,10 +400,10 @@ public sealed class MeetingSummaryTools var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames); var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames); - return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1); + return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1, file_pattern); } - private static async Task ReadIfExistsAsync(string path, int? from = null, int? to = null) + private static async Task ReadIfExistsAsync(string path, int? from = null, int? to = null, int? tail = null) { if (!File.Exists(path)) { @@ -411,7 +411,7 @@ public sealed class MeetingSummaryTools } var content = await File.ReadAllTextAsync(path); - return ReadLines(content, from, to); + return AgentFileToolContent.ReadLines(content, from, to, tail); } private async Task ReadMeetingNoteAsync() @@ -575,7 +575,7 @@ public sealed class MeetingSummaryTools } var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path)); - return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null; + return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null; } private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path) @@ -602,147 +602,11 @@ public sealed class MeetingSummaryTools } var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path)); - return IsWithinDirectory(projectFolder.Path, fullPath) + return AgentFileToolContent.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; - } - - if (editMode.Kind == FileLineEditKind.Append) - { - var existing = File.Exists(filePath) - ? await File.ReadAllTextAsync(filePath) - : ""; - await File.WriteAllTextAsync(filePath, AppendContent(existing, 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; - } - - if (editMode.Kind == FileLineEditKind.Append) - { - return AppendContent(existingContent, 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 static string AppendContent(string existingContent, string content) - { - if (string.IsNullOrEmpty(existingContent)) - { - return content; - } - - if (string.IsNullOrEmpty(content)) - { - return existingContent; - } - - var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r') - ? "" - : "\n"; - return existingContent + separator + content; - } - private Task> GetBoundProjectsAsync() { return projectResolver.GetBoundProjectsAsync(artifacts); @@ -757,7 +621,8 @@ public sealed class MeetingSummaryTools IReadOnlyList projects, IReadOnlyList summaries, string keywords, - bool singleProject) + bool singleProject, + string? filePattern) { System.Text.RegularExpressions.Regex regex; try @@ -776,7 +641,13 @@ public sealed class MeetingSummaryTools { foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories)) { - var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file)); + 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)); } } @@ -805,14 +676,14 @@ public sealed class MeetingSummaryTools { if (regex.IsMatch(lines[index])) { - matches.Add($"{ToToolPath(formattedPath)}:{index + 1} {lines[index]}"); + matches.Add($"{AgentFileToolContent.ToToolPath(formattedPath)}:{index + 1} {lines[index]}"); } } } private static string FormatSearchPath(string path, bool singleProject) { - var formatted = ToToolPath(path); + var formatted = AgentFileToolContent.ToToolPath(path); if (!singleProject) { return formatted; @@ -822,20 +693,6 @@ public sealed class MeetingSummaryTools 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 async Task> GetScopedPastSummariesAsync(IReadOnlySet projectNames) { if (projectNames.Count == 0) @@ -885,7 +742,7 @@ public sealed class MeetingSummaryTools var summariesRoot = GetSummariesRoot(); var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path)); - if (!IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath)) + if (!AgentFileToolContent.IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath)) { return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder."); } @@ -911,7 +768,7 @@ public sealed class MeetingSummaryTools private static string NormalizePastMeetingSummaryToolPath(string path) { - var toolPath = ToToolPath(path.Trim()); + var toolPath = AgentFileToolContent.ToToolPath(path.Trim()); const string pastMeetingPrefix = "past-meetings/"; return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase) ? toolPath[pastMeetingPrefix.Length..] @@ -950,7 +807,7 @@ public sealed class MeetingSummaryTools return new PastMeetingSummary( path, - ToToolPath(Path.GetRelativePath(summariesRoot, path)), + AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRoot, path)), string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(), ParseDateTime(frontmatter.StartTime), projects); @@ -985,45 +842,6 @@ public sealed class MeetingSummaryTools } } - 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, bool replaceFile) - { - if (replaceFile && (from.HasValue || to.HasValue || insert.HasValue)) - { - return null; - } - - 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(replaceFile ? FileLineEditKind.Overwrite : FileLineEditKind.Append); - } - } - - private enum FileLineEditKind - { - Append, - Overwrite, - Replace, - Insert - } - private sealed class MeetingNoteFrontmatterYaml { [YamlDotNet.Serialization.YamlMember(Alias = "title")] diff --git a/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs b/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs index 4e67b09..d5eb544 100644 --- a/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs +++ b/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs @@ -127,19 +127,19 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline AIFunctionFactory.Create( tools.ReadMeetingNote, "read_meetingnote", - "Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), + "Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. Supports from/to or tail."), AIFunctionFactory.Create( tools.ReadTranscript, "read_transcript", - "Read the transcript markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), + "Read the transcript markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."), AIFunctionFactory.Create( tools.ReadContext, "read_context", - "Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), + "Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."), AIFunctionFactory.Create( tools.ReadUserNotes, "read_usernotes", - "Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. With from and to, read that clamped inclusive 1-based body line range."), + "Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. Supports from/to or tail."), AIFunctionFactory.Create( tools.WriteContext, "write_context", @@ -147,7 +147,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline AIFunctionFactory.Create( tools.ReadGlossary, "read_glossary", - "Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. May be empty."), + "Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. Supports from/to or tail. May be empty."), AIFunctionFactory.Create( tools.AddDictationWord, "add_dictation_word", @@ -171,7 +171,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline AIFunctionFactory.Create( tools.WriteSummary, "write_summary", - "Write the finished summary markdown to the configured summary note. Provide title when the meeting note has no title."), + "Write the finished summary markdown to the configured summary note and return the written summary filename. Provide title when the meeting note has no title."), AIFunctionFactory.Create( tools.ListProjects, "list_projects", @@ -183,7 +183,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline AIFunctionFactory.Create( tools.ReadProjectFile, "read_projectfile", - "Read a bound project file, optionally clamping to from and to inclusive line numbers."), + "Read a bound project file. Supports from/to or tail."), AIFunctionFactory.Create( tools.WriteProjectFile, "write_projectfile", @@ -195,11 +195,11 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline 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."), + "Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. Supports from/to or tail. Cannot read or write the current meeting summary."), AIFunctionFactory.Create( tools.Search, "search", - "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.") + "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. Use file_pattern to limit project files by glob.") ]; } diff --git a/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs b/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs index 850a115..1c60f37 100644 --- a/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs +++ b/MeetingAssistant/Taskbar/MeetingTaskbarMenu.cs @@ -30,7 +30,7 @@ public static class MeetingTaskbarMenuBuilder { var items = new List { - new("Edit rules and identities", MeetingTaskbarAction.EditRules) + new("Settings and logs", MeetingTaskbarAction.EditRules) }; if (status.IsRecording) diff --git a/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs b/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs index 1b78241..1f5d063 100644 --- a/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs +++ b/MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs @@ -92,7 +92,7 @@ internal sealed class WorkflowRulesEditorMewWindow input .Height(92) .Wrap(true) - .Placeholder("Ask to make a rule or to list identities") + .Placeholder("ask me what I can do for you") .OnTextChanged(text => viewModel.Draft = text) .OnKeyDown(args => { @@ -108,7 +108,7 @@ internal sealed class WorkflowRulesEditorMewWindow .OnClick(() => _ = SendAsync()); var window = new Window() - .Title("Edit rules and identities") + .Title("Settings and logs") .Resizable(680, 760, 520, 460) .Padding(0) .OnLoaded(Render) diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs index f89b726..d718728 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs @@ -120,7 +120,7 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi AIFunctionFactory.Create( tools.ReadRules, "read_rules", - "Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."), + "Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."), AIFunctionFactory.Create( tools.WriteRules, "write_rules", @@ -128,7 +128,59 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi AIFunctionFactory.Create( tools.Search, "search", - "Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file."), + "Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file and supports an optional file_pattern glob."), + AIFunctionFactory.Create( + tools.ReadConfig, + "read_config", + "Read the local appsettings JSON configuration file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."), + AIFunctionFactory.Create( + tools.WriteConfig, + "write_config", + "Replace the local appsettings JSON configuration file after validating that the supplied content is valid JSON. Configuration stores environment variable names, not secret values."), + AIFunctionFactory.Create( + tools.ReadConfigDocs, + "read_config_docs", + "Read Meeting Assistant configuration documentation from docs/meeting-assistant-configuration.md. Supports from/to or tail. Use this before explaining or changing unfamiliar configuration settings."), + AIFunctionFactory.Create( + tools.ReadLogs, + "read_logs", + "Read the application-owned log files from the temp log folder. Defaults to tailing the current log. Set logFile to meeting-assistant.log.1 through .4 for rotated files, or supply from/to for a clamped 1-based line range."), + AIFunctionFactory.Create( + tools.SearchLogs, + "search_logs", + "Search current and rotated application-owned log files with .NET regular expression syntax. Supports an optional file_pattern glob and returns filename:line text matches."), + AIFunctionFactory.Create( + tools.SearchSpec, + "search_spec", + "Search copied OpenSpec specification markdown files with .NET regular expression syntax. Use file_pattern to limit searched files by glob. Returns relative/path.md:line text matches scoped to openspec/specs."), + AIFunctionFactory.Create( + tools.ReadSpecFile, + "read_spec_file", + "Read one copied OpenSpec specification markdown file by path relative to openspec/specs. Supports from/to or tail."), + AIFunctionFactory.Create( + tools.CreateProject, + "create_project", + "Create a new project folder under the configured projects folder. Use seed=recommended for PROJECT.md, JOURNAL.md, DECISIONS.md, and AGENTS.md from Project-AGENTS.md; seed=agents_md with agents_md to write AGENTS.md directly; or seed=none for an empty folder."), + AIFunctionFactory.Create( + tools.ListProjects, + "list_projects", + "List all existing project folders under the configured projects folder."), + AIFunctionFactory.Create( + tools.ListProjectFiles, + "list_projectfiles", + "List files inside an existing project folder."), + AIFunctionFactory.Create( + tools.ReadProjectFile, + "read_projectfile", + "Read a file inside an existing project folder. With no line arguments, read the whole file. With from/to, read that clamped inclusive 1-based line range. With tail, read the last lines."), + AIFunctionFactory.Create( + tools.WriteProjectFile, + "write_projectfile", + "Create or update a file inside an existing project folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."), + AIFunctionFactory.Create( + tools.SearchProjects, + "search_projects", + "Search files in all existing projects with .NET regular expression syntax. Use file_pattern to limit searched files by glob, for example *.md or **/*.md."), AIFunctionFactory.Create( tools.SearchIdentities, "search_identities", diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs index bd605be..67a899f 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorInstructionBuilder.cs @@ -10,14 +10,20 @@ public interface IWorkflowRulesEditorInstructionBuilder public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditorInstructionBuilder { private const string DefaultPrompt = """ - You are the Meeting Assistant workflow rules and identities editor. + You are the Meeting Assistant settings and logs assistant. - Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant. + Your purpose is to help operate Meeting Assistant locally: edit the configured local workflow rules YAML file, manage the local speaker identity database, inspect and update appsettings configuration, and read application logs. Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files. Read the existing rules before making changes unless the user explicitly asks to replace the whole file. write_rules appends by default. Pass replace_file=true only when you intentionally replace the complete rules file. Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal. When writing rules, prefer existing rule style and names. + Use read_config_docs before explaining or changing configuration settings unless the setting is already clear from the current conversation. + Use read_config before write_config. write_config replaces the complete appsettings JSON file and must preserve valid JSON. The appsettings file stores environment variable names and local paths, not secret values. + Use read_logs and search_logs to inspect application behavior. Prefer search_logs for known errors or keywords and read_logs tailing for recent startup or runtime context. + Use search_spec and read_spec_file to inspect the OpenSpec product requirements before making behavior claims or configuration changes that depend on intended behavior. + Use list_projects, list_projectfiles, read_projectfile, write_projectfile, and search_projects for project knowledge files under the configured projects folder. These tools are intentionally scoped to existing project folders. + Use create_project when the user wants a new project folder. Prefer seed=recommended unless the user explicitly asks for an empty project or provides AGENTS.md content directly. Use search_identities and read_identity before changing identities unless the user gives an exact identity id. Prefer updating identities by id, not by guessed name. If a user asks about names, search first and confirm ambiguity in your final response. For identity merges, merge the duplicate/source identity into the identity that should remain as target. @@ -45,6 +51,10 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor return configuredPrompt.Trim() + Environment.NewLine + Environment.NewLine + $"Configured workflow rules file: {rulesPath}" + Environment.NewLine + Environment.NewLine + + "Configuration tools can read and replace the local appsettings JSON file. Use read_config_docs for the configuration reference." + Environment.NewLine + Environment.NewLine + + "Log tools can read and search the current application-owned log file and four rotated older files under the temp log folder." + Environment.NewLine + Environment.NewLine + + "Spec tools can search and read copied OpenSpec markdown files from openspec/specs." + Environment.NewLine + Environment.NewLine + + "Project tools can create project folders and read, write, list, and search files in configured projects." + Environment.NewLine + Environment.NewLine + "Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine + "Workflow rules reference documentation:" + Environment.NewLine + "```markdown" + Environment.NewLine + @@ -54,7 +64,7 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor private async Task ReadWorkflowDocsAsync(CancellationToken cancellationToken) { - foreach (var path in CandidateDocumentationPaths()) + foreach (var path in RuntimeContentLocator.CandidateDocumentationPaths("meeting-workflow-engine.md")) { if (File.Exists(path)) { @@ -66,16 +76,4 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor return "Workflow documentation file docs/meeting-workflow-engine.md was not available at runtime."; } - private static IEnumerable CandidateDocumentationPaths() - { - var baseDirectory = AppContext.BaseDirectory; - yield return Path.Combine(baseDirectory, "docs", "meeting-workflow-engine.md"); - - var current = new DirectoryInfo(baseDirectory); - for (var i = 0; i < 8 && current is not null; i++) - { - yield return Path.Combine(current.FullName, "docs", "meeting-workflow-engine.md"); - current = current.Parent; - } - } } diff --git a/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs b/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs index debe669..8224958 100644 --- a/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs +++ b/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; +using MeetingAssistant.Logging; using MeetingAssistant.Speakers; using Microsoft.EntityFrameworkCore; using YamlDotNet.Core; @@ -21,6 +22,12 @@ public sealed class WorkflowRulesEditorTools }; private readonly string? rulesPath; + private readonly string configPath; + private readonly string configDocsPath; + private readonly string logDirectory; + private readonly string specRootPath; + private readonly string projectsRootPath; + private readonly string projectAgentsTemplatePath; private readonly SpeakerIdentificationOptions speakerOptions; private readonly IDbContextFactory? dbContextFactory; private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue; @@ -28,15 +35,26 @@ public sealed class WorkflowRulesEditorTools public WorkflowRulesEditorTools( MeetingAssistantOptions options, IDbContextFactory? dbContextFactory = null, - IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null) + IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null, + string? configPath = null, + string? configDocsPath = null, + string? logDirectory = null, + string? specRootPath = null, + string? projectAgentsTemplatePath = null) { rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath); + this.configPath = ResolveConfigPath(configPath); + this.configDocsPath = ResolveConfigDocsPath(configDocsPath); + this.logDirectory = logDirectory ?? MeetingAssistantLogFiles.DefaultLogDirectory; + this.specRootPath = ResolveSpecRootPath(specRootPath); + projectsRootPath = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder); + this.projectAgentsTemplatePath = ResolveProjectAgentsTemplatePath(projectAgentsTemplatePath); speakerOptions = options.SpeakerIdentification; this.dbContextFactory = dbContextFactory; this.samplePlaybackQueue = samplePlaybackQueue; } - public async Task ReadRules(int? from = null, int? to = null) + public async Task ReadRules(int? from = null, int? to = null, int? tail = null) { if (rulesPath is null) { @@ -48,7 +66,7 @@ public sealed class WorkflowRulesEditorTools return ""; } - return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to); + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(rulesPath), from, to, tail); } public async Task WriteRules(string yaml, bool replace_file = false) @@ -68,7 +86,7 @@ public sealed class WorkflowRulesEditorTools : ""; var updated = replace_file ? yaml - : AppendContent(existing, yaml); + : AgentFileToolContent.AppendContent(existing, yaml); var validation = ValidateYaml(updated); if (validation is not null) { @@ -80,19 +98,259 @@ public sealed class WorkflowRulesEditorTools return rulesPath; } - public async Task Search(string keywords) + public async Task Search(string keywords, string? file_pattern = null) { if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath)) { return ""; } + if (!AgentFileToolContent.MatchesGlob(Path.GetFileName(rulesPath), file_pattern)) + { + return ""; + } + var rgResult = await RunRipgrepAsync(rulesPath, keywords); return rgResult is not null ? FormatRipgrepJson(rgResult) : SearchWithRegexFallback(rulesPath, keywords); } + public async Task ReadConfig(int? from = null, int? to = null, int? tail = null) + { + if (!File.Exists(configPath)) + { + return $"Configuration file was not found: {configPath}"; + } + + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configPath), from, to, tail); + } + + public async Task WriteConfig(string json) + { + if (json is null) + { + return "Refused: json must not be null."; + } + + var validation = ValidateJson(json); + if (validation is not null) + { + return validation; + } + + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + await File.WriteAllTextAsync(configPath, json); + return configPath; + } + + public async Task ReadConfigDocs(int? from = null, int? to = null, int? tail = null) + { + if (!File.Exists(configDocsPath)) + { + return "Configuration documentation file docs/meeting-assistant-configuration.md was not available at runtime."; + } + + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configDocsPath), from, to, tail); + } + + public async Task ReadLogs( + string? logFile = null, + int? from = null, + int? to = null, + int tail = 200) + { + var path = ResolveLogFilePath(logFile); + if (path is null) + { + return $"Log file was not found: {logFile ?? MeetingAssistantLogFiles.CurrentLogFileName}"; + } + + var content = await File.ReadAllTextAsync(path); + if (from.HasValue || to.HasValue) + { + return AgentFileToolContent.ReadLines(content, from, to); + } + + return AgentFileToolContent.TailLines(content, Math.Clamp(tail, 1, 2000)); + } + + public Task SearchLogs(string keywords, int maxMatches = 100, string? file_pattern = null) + { + var sources = MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory) + .Where(path => AgentFileToolContent.MatchesGlob(Path.GetFileName(path), file_pattern)) + .Select(path => new AgentFileSearchSource(path, Path.GetFileName(path))); + return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches)); + } + + public async Task ReadSpecFile(string path, int? from = null, int? to = null, int? tail = null) + { + var resolved = ResolveSpecFilePath(path); + if (resolved.Error is not null) + { + return resolved.Error; + } + + if (!File.Exists(resolved.Path)) + { + return $"Spec file was not found: {AgentFileToolContent.ToToolPath(path)}"; + } + + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(resolved.Path), from, to, tail); + } + + public Task SearchSpec(string keywords, int maxMatches = 100, string? file_pattern = null) + { + if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(specRootPath)) + { + return Task.FromResult(""); + } + + var sources = Directory.EnumerateFiles(specRootPath, "*.md", SearchOption.AllDirectories) + .Order(StringComparer.OrdinalIgnoreCase) + .Select(path => new AgentFileSearchSource( + path, + AgentFileToolContent.ToToolPath(Path.GetRelativePath(specRootPath, path)))) + .Where(source => AgentFileToolContent.MatchesGlob(source.DisplayPath, file_pattern)); + return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches)); + } + + public Task ListProjects() + { + if (!Directory.Exists(projectsRootPath)) + { + return Task.FromResult(""); + } + + var projects = Directory.EnumerateDirectories(projectsRootPath) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Order(StringComparer.OrdinalIgnoreCase); + return Task.FromResult(string.Join('\n', projects)); + } + + public Task ListProjectFiles(string project) + { + var projectRoot = ResolveExistingProjectRoot(project); + if (projectRoot is null) + { + return Task.FromResult("Refused: project does not exist."); + } + + var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories) + .Select(path => AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path))) + .Order(StringComparer.OrdinalIgnoreCase); + return Task.FromResult(string.Join('\n', files)); + } + + public async Task ReadProjectFile( + string project, + string path, + int? from = null, + int? to = null, + int? tail = null) + { + var filePath = ResolveExistingProjectFilePath(project, path); + if (filePath is null) + { + return "Refused: project does not exist or the path escapes the project folder."; + } + + if (!File.Exists(filePath)) + { + return $"Project file was not found: {project}/{AgentFileToolContent.ToToolPath(path)}"; + } + + return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), from, to, tail); + } + + public async Task WriteProjectFile( + string project, + string path, + string content, + int? from = null, + int? to = null, + int? insert = null, + bool replace_file = false) + { + var editMode = AgentFileEditMode.Create(from, to, insert, replace_file); + if (editMode is null) + { + return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement."; + } + + var target = ResolveExistingProjectFileTarget(project, path); + if (target is null) + { + return "Refused: project does not exist or the path escapes the project folder."; + } + + Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!); + await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode); + return $"{target.ProjectName}/{AgentFileToolContent.ToToolPath(path)}"; + } + + public Task SearchProjects(string keywords, int maxMatches = 100, string? file_pattern = null) + { + if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(projectsRootPath)) + { + return Task.FromResult(""); + } + + var sources = Directory.EnumerateDirectories(projectsRootPath) + .Order(StringComparer.OrdinalIgnoreCase) + .SelectMany(projectRoot => + { + var projectName = Path.GetFileName(projectRoot); + return Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories) + .Order(StringComparer.OrdinalIgnoreCase) + .Select(path => + { + var relativePath = AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path)); + return new AgentFileSearchSource(path, $"{projectName}/{relativePath}"); + }) + .Where(source => AgentFileToolContent.MatchesGlob( + source.DisplayPath[(source.DisplayPath.IndexOf('/', StringComparison.Ordinal) + 1)..], + file_pattern)); + }); + return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches)); + } + + public async Task CreateProject(string name, string seed = "recommended", string? agents_md = null) + { + var projectRoot = ResolveNewProjectRoot(name); + if (projectRoot.Error is not null) + { + return projectRoot.Error; + } + + var normalizedSeed = string.IsNullOrWhiteSpace(seed) ? "recommended" : seed.Trim().ToLowerInvariant(); + if (normalizedSeed is not ("recommended" or "agents_md" or "none")) + { + return "Refused: seed must be recommended, agents_md, or none."; + } + + if (normalizedSeed == "agents_md" && string.IsNullOrWhiteSpace(agents_md)) + { + return "Refused: agents_md seed requires AGENTS.md content."; + } + + Directory.CreateDirectory(projectRoot.Path!); + switch (normalizedSeed) + { + case "recommended": + await WriteRecommendedProjectSeedAsync(projectRoot.Path!, projectRoot.Name!); + break; + case "agents_md": + await File.WriteAllTextAsync(Path.Combine(projectRoot.Path!, "AGENTS.md"), agents_md!); + break; + case "none": + break; + } + + return projectRoot.Name!; + } + public async Task SearchIdentities(string? query = null, int limit = 25) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); @@ -365,22 +623,115 @@ public sealed class WorkflowRulesEditorTools } } - private static string AppendContent(string existingContent, string content) + private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName) { - if (string.IsNullOrEmpty(existingContent)) + var agentsContent = File.Exists(projectAgentsTemplatePath) + ? await File.ReadAllTextAsync(projectAgentsTemplatePath) + : DefaultProjectAgentsContent; + await File.WriteAllTextAsync(Path.Combine(projectRoot, "AGENTS.md"), agentsContent); + await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), CreateDefaultProjectFile(projectName)); + await File.WriteAllTextAsync(Path.Combine(projectRoot, "JOURNAL.md"), CreateDefaultJournalFile(projectName)); + await File.WriteAllTextAsync(Path.Combine(projectRoot, "DECISIONS.md"), CreateDefaultDecisionsFile()); + } + + private static string CreateDefaultProjectFile(string projectName) + { + return $""" + # Executive Summary + + Project knowledge base for {projectName}. + + ## Business Goals + + ## Priorities and High Level Constraints + + ## Current Phase & Status + + ## Next Milestones + + ## Open Risks + + ## Important Stakeholders + + ## Key Documents + + Links to: Architecture Overview, Technical Onboarding, etc. + + [[JOURNAL.md]] + [[DECISIONS.md]] + """; + } + + private static string CreateDefaultJournalFile(string projectName) + { + return $""" + ## {DateTimeOffset.Now:yyyy-MM-dd} + ### Project Setup + + The project folder was created for {projectName}. + """; + } + + private static string CreateDefaultDecisionsFile() + { + return """ + # Decisions + + Important decisions belong here when they are expensive to change or materially affect timeline, goals, governance, team, or process. + + ## How are decisions recorded? + Important decisions are summarized with the question, the decision, and a source link to an ADR or meeting summary. + """; + } + + private const string DefaultProjectAgentsContent = """ + The project is split in 3 important files: + PROJECT.md + DECISIONS.md + JOURNAL.md + + The idea is a hierarchical summary: + + Meeting Summary -> detailed (what was discussed) + then Journal Entry -> compressed (why should I care this event happened?) + then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.) + + The journal should always be appended and read using tail or searched. + + Journal entries use this shape: + + ```markdown + ## 2026-05-30 + ### Team Daily + + New Blocker: HLS delayed + + Impact: Release likely delayed, team implements Z-Levels in the meantime. + + [[20260529-093030-6187486-summary|Summary]] + ``` + + DECISIONS.md logs only important decisions in a compressed way, with a link to ADRs and/or meeting summaries for details. + Important decisions are architecture decisions that are expensive to change, or project decisions about timeline, goals, governance, team, or process. + Do not log small implementation details such as how UI elements are aligned. + """; + + private static string? ValidateJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) { - return content; + return "Refused: appsettings JSON must not be blank."; } - if (string.IsNullOrEmpty(content)) + try { - return existingContent; + using var _ = JsonDocument.Parse(json); + return null; + } + catch (JsonException exception) + { + return $"Refused: appsettings JSON is invalid. {exception.Message}"; } - - var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r') - ? "" - : "\n"; - return existingContent + separator + content; } private static async Task RunRipgrepAsync(string rulesPath, string keywords) @@ -438,7 +789,7 @@ public sealed class WorkflowRulesEditorTools 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($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}"); + matches.Add($"{AgentFileToolContent.ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}"); } return string.Join('\n', matches); @@ -467,37 +818,175 @@ public sealed class WorkflowRulesEditorTools } } - private static string ReadLines(string content, int? from = null, int? to = null) + private ProjectFileTarget? ResolveExistingProjectFileTarget(string project, string path) { - if (!from.HasValue && !to.HasValue) + var projectRoot = ResolveExistingProjectRoot(project); + if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path)) { - return content; + return null; } - var lines = content - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n') - .Split('\n') - .ToList(); - 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)); + var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path)); + return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) + ? new ProjectFileTarget(Path.GetFileName(projectRoot), projectRoot, fullPath) + : null; } - private static string ToToolPath(string path) + private string? ResolveExistingProjectFilePath(string project, string path) { - return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal) - .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); + return ResolveExistingProjectFileTarget(project, path)?.Path; + } + + private string? ResolveExistingProjectRoot(string project) + { + if (string.IsNullOrWhiteSpace(project) || !Directory.Exists(projectsRootPath)) + { + return null; + } + + return Directory.EnumerateDirectories(projectsRootPath) + .FirstOrDefault(path => string.Equals(Path.GetFileName(path), project.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + private (string? Path, string? Name, string? Error) ResolveNewProjectRoot(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return (null, null, "Refused: project name must not be blank."); + } + + var projectName = name.Trim(); + if (projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + projectName.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + projectName.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + return (null, null, "Refused: project name must be a single folder name."); + } + + var root = Path.GetFullPath(projectsRootPath); + var candidate = Path.GetFullPath(Path.Combine(root, projectName)); + if (!AgentFileToolContent.IsWithinDirectory(root, candidate)) + { + return (null, null, $"Refused: project path must stay inside {root}."); + } + + if (Directory.Exists(candidate)) + { + return (null, null, $"Refused: project already exists: {projectName}."); + } + + return (candidate, projectName, null); + } + + private string? ResolveLogFilePath(string? logFile) + { + var requestedFileName = string.IsNullOrWhiteSpace(logFile) + ? MeetingAssistantLogFiles.CurrentLogFileName + : Path.GetFileName(logFile.Trim()); + return MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory) + .SingleOrDefault(path => string.Equals( + Path.GetFileName(path), + requestedFileName, + StringComparison.OrdinalIgnoreCase)); + } + + private static string ResolveConfigPath(string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + var basePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (File.Exists(basePath)) + { + return basePath; + } + + return Path.GetFullPath("appsettings.json"); + } + + private static string ResolveConfigDocsPath(string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + foreach (var path in RuntimeContentLocator.CandidateDocumentationPaths("meeting-assistant-configuration.md")) + { + if (File.Exists(path)) + { + return path; + } + } + + return Path.Combine(AppContext.BaseDirectory, "docs", "meeting-assistant-configuration.md"); + } + + private static string ResolveSpecRootPath(string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + foreach (var path in RuntimeContentLocator.CandidateDirectoryPaths("openspec", "specs")) + { + if (Directory.Exists(path)) + { + return path; + } + } + + return Path.Combine(AppContext.BaseDirectory, "openspec", "specs"); + } + + private static string ResolveProjectAgentsTemplatePath(string? configuredPath) + { + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + foreach (var path in RuntimeContentLocator.CandidateContentPaths("Project-AGENTS.md")) + { + if (File.Exists(path)) + { + return path; + } + } + + return Path.Combine(AppContext.BaseDirectory, "Content", "Project-AGENTS.md"); + } + + private (string? Path, string? Error) ResolveSpecFilePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return (null, "Refused: spec path must not be blank."); + } + + var root = Path.GetFullPath(specRootPath); + var candidate = Path.GetFullPath(Path.Combine( + root, + path.Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar))); + var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate, root, StringComparison.OrdinalIgnoreCase)) + { + return (null, $"Refused: spec path must stay inside {root}."); + } + + if (!candidate.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + return (null, "Refused: spec path must point to a markdown spec file."); + } + + return (candidate, null); } private async Task CreateIdentityContextAsync(CancellationToken cancellationToken) @@ -631,4 +1120,7 @@ public sealed class WorkflowRulesEditorTools DateTimeOffset CreatedAt, int ByteCount, string Base64Wav); + + private sealed record ProjectFileTarget(string ProjectName, string ProjectRoot, string Path); + } diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index 2f03f4b..dfb9ced 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -20,6 +20,7 @@ "MicrophoneMixGain": 1, "SystemAudioMixGain": 1, "StopProcessingTimeout": "00:10:00", + "MinimumCompletedMeetingDuration": "00:01:00", "MetadataLookupTimeout": "00:00:10", "BackgroundMetadataLookupTimeout": "00:01:00", "MaxMetadataAttendeeImportCount": 30, diff --git a/README.md b/README.md index 425b9f5..253af1c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ POST /recording/abort POST /asr/transcribe-file POST /asr/diarize-file POST /diagnostics/workflow/reload +POST /diagnostics/settings-and-logs/show POST /meetings/current/summary/run POST /meetings/summary/retry GET /meetings/summary/retry?summaryPath=... @@ -82,239 +83,9 @@ GET /meetings/summary/retry?summaryPath=... ## Configuration -Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` section: +Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` section. The repository `appsettings.json` stores local paths, endpoint URLs, model names, and environment variable names; secret values should stay in environment variables referenced by `KeyEnv` settings. -```json -{ - "MeetingAssistant": { - "Hotkey": { - "Toggle": "Ctrl+Alt+M", - "Abort": "Ctrl+Alt+Z" - }, - "Vault": { - "TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts", - "MeetingNotesFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Notes", - "AssistantContextFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Assistant Context", - "SummariesFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Summaries", - "ProjectsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Projects", - "DictationWordsPath": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\dictation-words.md" - }, - "Recording": { - "TranscriptionProvider": "funasr", - "SampleRate": 16000, - "Channels": 1, - "MicrophoneMixGain": 1, - "SystemAudioMixGain": 1, - "StopProcessingTimeout": "00:10:00", - "MaxMetadataAttendeeImportCount": 30, - "TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings" - }, - "FunAsr": { - "Endpoint": "ws://127.0.0.1:10095", - "Mode": "2pass", - "ChunkSize": [5, 10, 5], - "ChunkInterval": 10, - "EncoderChunkLookBack": 4, - "DecoderChunkLookBack": 1, - "Itn": true, - "EmitOnlinePartials": false, - "WavName": "meeting", - "FinalResultTimeout": "00:00:10", - "Backend": { - "Enabled": true, - "DockerCommand": "docker", - "Image": "registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.13", - "ContainerName": "meeting-assistant-funasr", - "ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\FunASR\\models", - "ContainerPort": 10095, - "Privileged": true, - "CommandTimeout": "00:15:00", - "StartupTimeout": "00:10:00", - "StopOnDispose": true, - "ServerCommand": "cd /workspace/FunASR/runtime && bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --itn-dir thuduj12/fst_itn_zh --certfile 0 --hotword /workspace/models/hotwords.txt && tail -f /dev/null" - }, - "Diarization": { - "Enabled": true, - "AsrModel": "paraformer-zh", - "VadModel": "fsmn-vad", - "PunctuationModel": "ct-punc", - "SpeakerModel": "cam++", - "BatchSizeSeconds": 300, - "BatchSizeThresholdSeconds": 60, - "CommandTimeout": "00:30:00" - } - }, - "WhisperLocal": { - "ModelPath": "models\\ggml-base.bin", - "Language": "auto", - "WindowSeconds": 15, - "Diarization": { - "Enabled": true, - "DockerCommand": "docker", - "BaseImage": "python:3.11-slim", - "Image": "meeting-assistant-pyannote:local", - "BuildImage": true, - "ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models", - "Model": "pyannote/speaker-diarization-3.1", - "AnnotationSource": "SpeakerDiarization", - "AlignmentMode": "PyannoteTurns", - "TokenEnv": "HF_TOKEN", - "CommandTimeout": "00:30:00" - } - }, - "AzureSpeech": { - "Endpoint": "https://germanywestcentral.api.cognitive.microsoft.com/", - "Region": "germanywestcentral", - "Language": "en-US", - "KeyEnv": "AZURE_SPEECH_KEY", - "RecognitionStopTimeout": "00:03:00", - "DiarizeIntermediateResults": true - }, - "SpeakerIdentification": { - "MinimumSampleSpeechDuration": "00:00:30", - "MaximumSampleSegmentGap": "00:00:01", - "PyannoteValidation": { - "Enabled": true, - "MinimumSingleSpeakerCoverage": 0.9, - "MinimumMatchingKnownSnippetRatio": 0.5, - "Diarization": { - "Enabled": false, - "DockerCommand": "docker", - "Image": "meeting-assistant-pyannote:local", - "BuildImage": true, - "ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models", - "Model": "pyannote/speaker-diarization-3.1", - "TokenEnv": "HF_TOKEN", - "CommandTimeout": "01:00:00" - } - } - }, - "Automation": { - "RulesPath": "meeting-rules.local.yaml" - }, - "WorkflowRulesEditor": { - "Endpoint": "", - "KeyEnv": "", - "Model": "" - }, - "Screenshots": { - "Hotkey": "Ctrl+Alt+S", - "AttachmentsFolder": "Attachments", - "Ocr": { - "Enabled": false, - "Endpoint": "", - "KeyEnv": "LITELLM_API_KEY", - "Model": "", - "Timeout": "00:02:00", - "Prompt": "This screenshot was captured during a meeting..." - } - }, - "Agent": { - "Endpoint": "https://litellm.schweigert.cloud", - "KeyEnv": "LITELLM_API_KEY", - "Model": "chatgpt/gpt-5.5", - "EnableThinking": true, - "ReasoningEffort": "Medium", - "ReconnectionAttempts": 2, - "ReconnectionDelay": "00:00:02", - "ContextWindowTokens": 128000, - "MaxOutputTokens": 8192, - "EnableCompaction": true, - "CompactionRemainingRatio": 0.10, - "ResponsesCompactPath": "responses/compact" - }, - "Api": { - "PublicBaseUrl": "http://localhost:5090" - } - } -} -``` - -`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime: on application startup it begins a non-blocking warm-up for the default launch profile and any named launch profile that selects `funasr`. That warm-up pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops it when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. Docker commands are bounded by `CommandTimeout`, and backend WebSocket readiness is bounded by `StartupTimeout`. FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`. - -During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription. The active launch profile's `Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during that final mix and default to `1`, because the signals are expected to be distinct after echo cancellation. The recorder writes only the mixed stream to the temporary WAV under `TemporaryRecordingsFolder`. After capture stops and the streaming ASR provider drains the already-captured audio, FunASR Python `AutoModel` can run with VAD, punctuation, and CAMPPlus (`spk_model="cam++"`) over that temporary WAV. If sentence-level speaker labels are returned, the transcript markdown is rewritten with the final speaker-attributed segments. If final diarization is disabled or returns no segments, the live streaming transcript is kept. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. - -`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `ModelPath` before using live transcription. When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. When `BuildImage` is true, Meeting Assistant builds the configured local pyannote Docker image if Docker does not already have it, so Python packages are installed once instead of during every diarization call. The configured `ModelsFolder` is mounted as the persistent pyannote model cache and stores Hugging Face, torch, and pip cache artifacts so model downloads are reused across runs. Active pyannote runtimes are also warmed up on application start so image setup and model download do not wait for the first diarization request. If pyannote is disabled, has no token, or returns no turns, the live Whisper transcript is kept. - -`azure-speech` streams Meeting Assistant's captured PCM chunks to Azure AI Speech using the Speech SDK `ConversationTranscriber`; it does not use an Azure-owned microphone capture or MAS/AEC input path. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed; the default is 3 minutes so Azure has time to finalize longer speaker-recognition samples without letting diagnostic runs wait indefinitely. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote. - -Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization, reads its Hugging Face token from the nested diarization options, warms that runtime on application start, and defaults the validation command timeout to 1 hour because the local model can need substantial setup time. - -ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider. - -`POST /asr/transcribe-file` is a local diagnostic endpoint for the configured ASR backend. Send `{ "path": "C:\\path\\to\\sample.wav" }` with a 16-bit PCM WAV file to stream it through the configured speech recognition pipeline and return the emitted live transcript segments. - -For real meeting recordings, Meeting Assistant derives the optional finished-transcript `numSpeakers` hint from the meeting note `attendees` frontmatter. The hint is set to the attendee count only when at least two attendees are listed; empty and single-person attendee lists are ignored. For pyannote-backed finalization, that hint is passed to pyannote as `num_speakers`. - -`POST /asr/diarize-file` streams a local 16-bit PCM WAV through the configured speech recognition pipeline and returns the finished transcript. This is useful for checking the FunASR CAMPPlus path or the local Whisper plus pyannote path without starting a live recording. The request can include an optional speaker-count hint: `{ "path": "C:\\path\\to\\sample.wav", "numSpeakers": 5 }`. - -`DictationWordsPath` is reserved for providers that can accept vocabulary hints. It points to a markdown word list containing unusual project, person, product, or domain terms that should be biased during transcription when the provider supports it. - -Meeting notes link to separate transcript, assistant context, and summary markdown artifacts using the configured vault folders. - -`Hotkey:Abort` configures a global discard shortcut. The default is `Ctrl+Alt+Z`. Aborting an active recording stops capture/transcription, deletes the meeting note, transcript, assistant context, summary file if present, and linked screenshot attachments for that run, and skips automatic summary generation. - -Launch profile toggle hotkeys start a meeting when idle. When a different launch profile toggle is pressed during an active meeting, Meeting Assistant keeps the same meeting note, transcript, assistant context, and metadata; drains the current speech recognition pipeline without summarizing; appends a transcript marker; clears run-local speaker mappings; and continues transcription through the target profile. Pressing the same active profile toggle still stops the meeting normally. - -On Windows, Meeting Assistant shows a taskbar notification icon. The icon indicates idle, recording, or post-recording summarizing/processing state. Its right-click menu can start a recording for any configured launch profile, stop and transcribe the active recording, cancel and discard the active recording, or switch an active recording to another configured launch profile. A newer active recording takes priority in the icon state even if an older stopped run is still summarizing. - -Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary. - -`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings. - -Assistant context notes keep their artifact links in frontmatter and expose a lifecycle `state`: `collecting metadata`, `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`. Meeting Assistant updates this state as metadata lookup, recording, final speaker recognition, and summary generation progress. - -`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter. - -`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`. The initial supported steps are `add_attendee`, `remove_attendee`, `set_property`, `add_context`, and `add_project`. - -See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes. - -`POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options. - -`Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context. Launch profiles can override the screenshot hotkey with `LaunchProfiles:{profile}:Screenshots:Hotkey`; only explicitly configured profile hotkeys are registered, so profile-specific hotkeys do not silently inherit and collide with the default profile. - -`Screenshots:Ocr` optionally enables vision extraction for screenshots. When `Enabled` is true, Meeting Assistant sends the screenshot and prompt to an OpenAI-compatible Responses endpoint and replaces the screenshot OCR placeholder with the model output. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model, which keeps local OCR on the same LiteLLM backend as summarization. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. The default prompt asks the model to say whether visible participants are clearly the exact meeting participants or only a partial result, and to return pixel crop coordinates when it can confidently isolate only the presentation or shared-screen content; valid crop boxes are saved as `*-cropped.png` beside the original screenshot and linked before the OCR block. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`. - -`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks. The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear; partial participant evidence from screenshots should not be used to remove invitees by itself. It can also override a transcript speaker label with a named speaker only when the evidence is very certain, such as a user note, OCR at a correlating meeting timestamp, or a clear context cue. If that named speaker already exists in the transcript, `override_speaker` refuses by default; the agent must pass `merge=true` only when it is certain both labels are the same identity. When it is certain that a speaker identity was wrongfully matched, it can delete that identity, relabeling transcript speaker labels to `Removed-` and removing the local speaker identity database row before future matching. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts. - -`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget. - -The summary endpoints are: - -```text -POST /meetings/current/summary/run -POST /meetings/summary/retry -GET /meetings/summary/retry?summaryPath=... -``` - -`POST /meetings/summary/retry` accepts `{ "summaryPath": "20260519-124110-summary.md" }` or an absolute path under the configured summaries folder. It resolves the linked meeting note from frontmatter and reruns the summary pipeline, overwriting the existing summary file with either the new summary or a new failure report. - -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, 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. - -- `read_meetingnote` -- `list_projects` -- `list_projectfiles` -- `read_projectfile` -- `write_projectfile` -- `list_past_project_meetings` -- `read_past_project_meeting_summary` -- `search` -- `write_context` -- `add_attendee` -- `remove_attendee` -- `override_speaker` -- `delete_identity` - -`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 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/`, and `read_past_project_meeting_summary` accepts those prefixed paths directly. - -`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. +See `docs/meeting-assistant-configuration.md` for the detailed configuration reference, including recording providers, FunASR, Whisper, Azure Speech, speaker identification, automation rules, screenshots, summary agents, and the settings/logs assistant. ## Spec Workflow diff --git a/docs/meeting-assistant-configuration.md b/docs/meeting-assistant-configuration.md new file mode 100644 index 0000000..beff3c3 --- /dev/null +++ b/docs/meeting-assistant-configuration.md @@ -0,0 +1,337 @@ +# Meeting Assistant Configuration + +Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` section. The checked-in `appsettings.json` stores local paths, model names, endpoints, and environment variable names. It should not store secret values; use the configured `KeyEnv` fields for API keys. + +## Example + +This example is abbreviated so the most common shape is readable. The checked-in [appsettings.json](../MeetingAssistant/appsettings.json) is the canonical full example. + +```json +{ + "MeetingAssistant": { + "Hotkey": { + "Toggle": "Ctrl+Alt+M", + "Abort": "Ctrl+Alt+Z" + }, + "Vault": { + "BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta", + "TranscriptsFolder": "Meetings\\Transcripts", + "MeetingNotesFolder": "Meetings\\Notes", + "AssistantContextFolder": "Meetings\\Assistant Context", + "SummariesFolder": "Meetings\\Summaries", + "ProjectsFolder": "Projects", + "DictationWordsPath": "Meetings\\dictation-words.md" + }, + "Recording": { + "TranscriptionProvider": "funasr", + "SampleRate": 16000, + "Channels": 1, + "MicrophoneMixGain": 1, + "SystemAudioMixGain": 1, + "StopProcessingTimeout": "00:10:00", + "MinimumCompletedMeetingDuration": "00:01:00", + "MaxMetadataAttendeeImportCount": 30, + "TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings" + }, + "Automation": { + "RulesPath": "meeting-rules.local.yaml" + }, + "WorkflowRulesEditor": { + "Endpoint": "", + "KeyEnv": "", + "Model": "" + }, + "Screenshots": { + "Hotkey": "Ctrl+Alt+S", + "AttachmentsFolder": "Attachments" + }, + "Agent": { + "Endpoint": "https://litellm.schweigert.cloud", + "KeyEnv": "LITELLM_API_KEY", + "Model": "chatgpt/gpt-5.5" + }, + "Api": { + "PublicBaseUrl": "http://localhost:5090" + }, + "LaunchProfiles": { + "english": { + "Hotkey": { + "Toggle": "Ctrl+Alt+E" + }, + "AzureSpeech": { + "Language": "en-US", + "AutoDetectLanguages": [ "en-US" ] + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} +``` + +## Paths And Overrides + +Meeting Assistant expands environment variables such as `%USERPROFILE%` and supports `~` at the start of paths. + +Relative vault paths are resolved under `Vault:BaseFolder`. Absolute paths bypass `BaseFolder`. For example, `Vault:MeetingNotesFolder` set to `Meetings\Notes` resolves below `BaseFolder`, while `C:\Notes` is used directly. + +Launch profiles live under `MeetingAssistant:LaunchProfiles:{profileName}`. A profile starts with the default `MeetingAssistant` options and then binds its profile section over those defaults. This allows profile-specific language, provider, hotkey, screenshot, or agent overrides without duplicating the whole configuration. + +The default profile is always named `default`. Non-default profile hotkeys are registered only when the specific profile explicitly configures that hotkey section. This avoids accidental hotkey collisions from inherited defaults. Array options currently need explicit profile override handling for `AzureSpeech:AutoDetectLanguages` and `FunAsr:ChunkSize`; both are supported. + +## Vault + +`Vault` configures where Meeting Assistant writes meeting notes, transcripts, assistant context notes, generated summaries, project knowledge, and dictation words. Meeting notes link to separate transcript, assistant context, and summary markdown artifacts using these configured folders. + +`BaseFolder` is the root for relative vault paths. Use relative paths for files that should move with the vault root, and absolute paths for external files. + +`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter. + +`DictationWordsPath` is reserved for providers that can accept vocabulary hints. It points to a markdown word list containing unusual project, person, product, or domain terms that should be biased during transcription when the provider supports it. + +## Recording + +`Recording:TranscriptionProvider` selects the speech recognition pipeline. Supported values include `funasr`, `whisper-local`, and `azure-speech`. + +| Setting | Purpose | +| --- | --- | +| `SampleRate` | PCM sample rate for captured audio. The current providers expect 16000 Hz. | +| `Channels` | PCM channel count for the mixed stream. The current providers expect mono (`1`). | +| `MicrophoneMixGain` | Gain applied to cleaned microphone samples before final mixing. | +| `SystemAudioMixGain` | Gain applied to system loopback samples before final mixing. | +| `StopProcessingTimeout` | Maximum time to wait for post-stop processing such as transcription drain, finalization, OCR wait, and summary handoff. | +| `MinimumCompletedMeetingDuration` | Runs stopped before this duration are treated like aborts, so accidental same-minute starts do not produce meeting artifacts. | +| `MetadataLookupTimeout` | Foreground timeout for initial metadata lookup when starting a meeting. | +| `BackgroundMetadataLookupTimeout` | Longer background timeout for metadata that can continue after transcription starts. | +| `MaxMetadataAttendeeImportCount` | Maximum Outlook attendee count that will be copied into meeting frontmatter. | +| `OpenMeetingNoteDelay` | Delay before opening the newly created Obsidian note, giving the file system and app time to settle. | +| `TemporaryRecordingsFolder` | Folder for temporary mixed WAV files while a run is active. | + +During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription. + +`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. + +`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings. + +`Hotkey:Abort` configures a global discard shortcut. The default is `Ctrl+Alt+Z`. Aborting an active recording stops capture/transcription, deletes the meeting note, transcript, assistant context, summary file if present, and linked screenshot attachments for that run, and skips automatic summary generation. + +Launch profile toggle hotkeys start a meeting when idle. When a different launch profile toggle is pressed during an active meeting, Meeting Assistant keeps the same meeting note, transcript, assistant context, and metadata; drains the current speech recognition pipeline without summarizing; appends a transcript marker; clears run-local speaker mappings; and continues transcription through the target profile. + +## FunASR + +`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime. On application startup it begins a non-blocking warm-up for the default launch profile and any named launch profile that selects `funasr`. + +| Setting | Purpose | +| --- | --- | +| `Endpoint` | WebSocket endpoint for the FunASR streaming server. | +| `Mode` | FunASR mode sent in streaming requests, usually `2pass`. | +| `ChunkSize` | Three-part FunASR chunk size array. | +| `ChunkInterval` | FunASR chunk interval sent with streaming requests. | +| `EncoderChunkLookBack` | Encoder lookback count for streaming context. | +| `DecoderChunkLookBack` | Decoder lookback count for streaming context. | +| `Itn` | Enables inverse text normalization when the backend supports it. | +| `EmitOnlinePartials` | Emits online partial transcripts instead of waiting only for final two-pass text. | +| `WavName` | Logical WAV name sent to FunASR. | +| `FinalResultTimeout` | Time to wait for a final FunASR response after the stream ends. | + +The warm-up pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops it when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. + +`FunAsr:Backend` controls the managed Docker runtime: + +| Setting | Purpose | +| --- | --- | +| `Enabled` | Starts and warms up the Docker-backed backend from Meeting Assistant. | +| `DockerCommand` | Docker executable name or path. | +| `Image` | FunASR runtime image. | +| `ContainerName` | Container name used for replacement, startup, and shutdown. | +| `ModelsFolder` | Host folder mounted as persistent model and hotword storage. | +| `ContainerPort` | Container and host port used by the WebSocket endpoint. | +| `Privileged` | Runs the container with Docker privileged mode when true. | +| `CommandTimeout` | Timeout for Docker commands such as pull/run/build. | +| `StartupTimeout` | Timeout for backend WebSocket readiness. | +| `StopOnDispose` | Stops the managed container when the app shuts down. | +| `ServerCommand` | Shell command executed inside the container to start FunASR. | + +FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`. + +If `FunAsr:Diarization:Enabled` is true, FunASR Python `AutoModel` can run with VAD, punctuation, and CAMPPlus over the temporary WAV after capture stops and the streaming ASR provider drains the already-captured audio. If sentence-level speaker labels are returned, the transcript markdown is rewritten with the final speaker-attributed segments. If final diarization is disabled or returns no segments, the live streaming transcript is kept. + +`FunAsr:Diarization` controls that final pass: + +| Setting | Purpose | +| --- | --- | +| `Enabled` | Runs the final FunASR diarization/finalization pass over the temporary WAV. | +| `AsrModel` | FunASR ASR model name for finalization. | +| `VadModel` | FunASR VAD model name. | +| `PunctuationModel` | FunASR punctuation model name. | +| `SpeakerModel` | FunASR speaker model, for example `cam++`. | +| `BatchSizeSeconds` | Audio batch size for finalization. | +| `BatchSizeThresholdSeconds` | Threshold used by the finalizer before batching. | +| `PresetSpeakerCount` | Optional fixed speaker-count hint for diarization. | +| `CommandTimeout` | Timeout for the final FunASR command. | + +## Whisper Local + +`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `WhisperLocal:ModelPath` before using live transcription. + +| Setting | Purpose | +| --- | --- | +| `ModelPath` | Path to the local ggml Whisper model file. | +| `Language` | Whisper language hint, or `auto` for automatic detection. | +| `WindowSeconds` | Window size used when streaming audio into local Whisper. | + +When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. + +Active pyannote runtimes are warmed up on application start so image setup and model download do not wait for the first diarization request. + +Pyannote diarization settings are shared by local Whisper finalization and speaker-identification validation: + +| Setting | Purpose | +| --- | --- | +| `Enabled` | Enables the pyannote-backed pass. | +| `DockerCommand` | Docker executable name or path. | +| `BaseImage` | Python base image used when building the local pyannote image. | +| `Image` | Local pyannote Docker image tag. | +| `BuildImage` | Builds the local image when it is missing. | +| `ModelsFolder` | Host folder mounted as Hugging Face, torch, and pip cache. | +| `Model` | Pyannote model name. | +| `AnnotationSource` | Selects `SpeakerDiarization` or `ExclusiveSpeakerDiarization`. | +| `AlignmentMode` | Selects `BestOverlap` or `PyannoteTurns` transcript alignment. | +| `Token` | Optional Hugging Face token value. Prefer `TokenEnv` for normal use. | +| `TokenEnv` | Environment variable name that contains the Hugging Face token. | +| `CommandTimeout` | Timeout for the pyannote command. | + +## Azure Speech + +`azure-speech` streams Meeting Assistant's captured PCM chunks to Azure AI Speech using the Speech SDK `ConversationTranscriber`; it does not use an Azure-owned microphone capture or MAS/AEC input path. + +Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. + +| Setting | Purpose | +| --- | --- | +| `Endpoint` | Optional Azure Speech endpoint. When blank, `Region` plus subscription key is used. | +| `Region` | Azure Speech region used for subscription-based config. | +| `Language` | Fixed recognition language. Set to `auto` to enable Azure source language auto-detection. | +| `AutoDetectLanguages` | Candidate languages used only when `Language` is `auto`. | +| `LanguageIdMode` | Azure language identification mode; `AtStart` or `Continuous`. Other values normalize to `Continuous`. | +| `Key` | Optional inline Azure Speech key. Prefer `KeyEnv`. | +| `KeyEnv` | Environment variable name that contains the Azure Speech key. | +| `RecognitionStopTimeout` | Timeout while stopping the ConversationTranscriber after closing the audio stream. | +| `DiarizeIntermediateResults` | Requests diarized intermediate results from Azure. | +| `PostProcessingOption` | Accepted by config but currently skipped for the live `ConversationTranscriber` path; the app logs a warning if set. | +| `PhraseListWeight` | Weight applied to dictation-word phrase-list hints. | + +Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed; the default is 3 minutes so Azure has time to finalize longer speaker-recognition samples. + +## Speaker Identification + +Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. + +| Setting | Purpose | +| --- | --- | +| `Enabled` | Enables live speaker identity matching and sample collection. | +| `DatabasePath` | SQLite database path for identities, aliases, references, and samples. | +| `InitialDelay` | Delay after recording starts before the first live identity pass. | +| `Interval` | Interval between live identity passes. | +| `MatchBatchSize` | Number of identities processed per model matching batch. | +| `MaxMatchCandidates` | Maximum known identities considered during one match pass. | +| `MatchIdentityActiveAge` | Age window for identities considered active enough for automatic matching. | +| `MaxSnippetsPerSpeaker` | Maximum stored voice snippets retained per speaker identity. | +| `MinimumSampleSpeechDuration` | Minimum uninterrupted same-speaker speech span needed before storing a sample. | +| `MaximumSampleSegmentGap` | Maximum gap allowed when combining adjacent same-speaker segments into one sample. | +| `SilenceBetweenSnippetsSeconds` | Silence padding inserted between snippets during Azure identity matching. | +| `LiveSampleBufferDuration` | How long live transcript/audio material is retained for extracting samples. | +| `MergeRecentIdentityAge` | Age window used by diagnostics that merge recent duplicate identities. | +| `MatchTimeout` | Timeout for an Azure-backed identity matching request. | + +`SpeakerIdentification:AzureSpeech` is an advanced nested override for speaker identity matching. It uses the same shape as `AzureSpeech` and lets identity matching use different Azure language, endpoint, or key settings than live transcription when needed. If it is left unset, the normal Azure Speech settings remain the practical default. + +`SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization and defaults the validation command timeout to 1 hour because local model setup can take substantial time. + +| Setting | Purpose | +| --- | --- | +| `MinimumSingleSpeakerCoverage` | Required fraction of the tested sample that pyannote must attribute to a single speaker. | +| `MinimumMatchingKnownSnippetRatio` | Required pyannote agreement ratio between the new sample and known snippets for an accepted identity. | +| `Diarization` | Nested pyannote settings used for this validation pass. | + +## Automation + +`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`. + +See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes. + +`POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options. + +## Screenshots + +`Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context. + +`Screenshots:Ocr` optionally enables vision extraction for screenshots. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`. + +| Setting | Purpose | +| --- | --- | +| `Enabled` | Sends screenshots to the OCR/vision model when true. | +| `Endpoint` | Optional OpenAI-compatible Responses endpoint override. | +| `Key` | Optional inline API key. Prefer `KeyEnv`. | +| `KeyEnv` | Environment variable name for the OCR API key. | +| `Model` | Optional OCR model override. Blank falls back to `Agent:Model`. | +| `Prompt` | OCR instruction prompt. The default asks for visible speakers/presenters, slide text as markdown, diagrams as Mermaid, scene descriptions, participant certainty, and presentation/shared-screen crop coordinates when confidently isolatable. | +| `Timeout` | Maximum time to wait for OCR before summarization continues. | + +## Agent And Summary + +`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. + +| Setting | Purpose | +| --- | --- | +| `Endpoint` | OpenAI-compatible endpoint base URL. | +| `Key` | Optional inline API key. Prefer `KeyEnv`. | +| `KeyEnv` | Environment variable name for the agent API key. | +| `Model` | Model id sent to the endpoint. | +| `EnableThinking` | Enables reasoning options in requests when supported by the model/backend. | +| `ReasoningEffort` | Reasoning effort: `None`, `Low`, `Medium`, `High`, or `ExtraHigh`. | +| `ReconnectionAttempts` | Retry count for transient model endpoint failures. | +| `ReconnectionDelay` | Delay between retry attempts. | +| `ContextWindowTokens` | Estimated context-window size used by compaction decisions. | +| `MaxOutputTokens` | Maximum output tokens for agent responses. | +| `EnableCompaction` | Enables conversation compaction before requests exceed the configured context budget. | +| `CompactionRemainingRatio` | Remaining-context ratio that triggers compaction. | +| `ResponsesCompactPath` | Relative Responses compaction path, usually `responses/compact`. | +| `InitialPrompt` | Optional complete replacement for the default summary-agent system instructions. | + +After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks. + +The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear. It can override transcript speaker labels only when the evidence is very certain, and it can delete wrongfully matched identities. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes. + +`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. + +## Workflow Rules Editor + +`WorkflowRulesEditor` configures the tray-launched settings/logs assistant. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden. + +The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`. + +The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, and inspect application logs. + +The `search_spec` and `read_spec_file` tools are scoped to the copied `openspec/specs` markdown tree. Spec files are copied into build and publish output through an MSBuild glob, so newly added folders under `openspec/specs` are included automatically. + +## Logs + +The top-level `Logging` section is standard ASP.NET Core logging configuration and controls framework/console log levels, for example `Logging:LogLevel:Microsoft.AspNetCore`. + +Independently from stdout and stderr redirection, Meeting Assistant writes an application-owned log under: + +```text +%TEMP%\MeetingAssistant\Logs\meeting-assistant.log +``` + +On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The settings/logs assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked. + +## API + +`Api:PublicBaseUrl` is used to build clickable local links, such as summary retry links in generated failure notes. diff --git a/docs/meeting-workflow-engine.md b/docs/meeting-workflow-engine.md index 20822f9..eb334db 100644 --- a/docs/meeting-workflow-engine.md +++ b/docs/meeting-workflow-engine.md @@ -22,7 +22,7 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing. -The tray menu includes `Edit rules and identities`, which opens a small MewUI chat editor for this configured rules file and the local speaker identity database. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`. +The tray menu includes `Settings and logs`, which opens a small MewUI chat assistant for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`. ```json { diff --git a/openspec/changes/generalize-settings-and-logs-agent/proposal.md b/openspec/changes/generalize-settings-and-logs-agent/proposal.md new file mode 100644 index 0000000..d5ea4bb --- /dev/null +++ b/openspec/changes/generalize-settings-and-logs-agent/proposal.md @@ -0,0 +1,16 @@ +# Generalize settings and logs assistant + +## Why +The tray-launched rules and identities chat is becoming the local operational assistant for Meeting Assistant. It should be presented as a broader settings and logs window and should be able to inspect and edit local configuration, explain configuration from docs, and inspect runtime logs without relying on external stdout redirection. + +## What Changes +- Rename the tray/menu/window surface from `Edit rules and identities` to `Settings and logs` and generalize its placeholder text. +- Add agent tools to read and edit the local appsettings configuration file, read configuration documentation, tail/read logs, and search logs. +- Move detailed configuration documentation out of `README.md` into a dedicated docs file that is copied with the application. +- Add application-owned file logging under the temp folder with four rotated older files. +- Update repository agent instructions with concise Meeting Assistant context for future agents. + +## Impact +- Existing rules and speaker identity tools remain available. +- The local chat assistant can now help with configuration and logs in addition to rules and identities. +- Runtime logs are available even when stdout/stderr redirection changes. diff --git a/openspec/changes/generalize-settings-and-logs-agent/specs/agent-project-tools/spec.md b/openspec/changes/generalize-settings-and-logs-agent/specs/agent-project-tools/spec.md new file mode 100644 index 0000000..ea9ea69 --- /dev/null +++ b/openspec/changes/generalize-settings-and-logs-agent/specs/agent-project-tools/spec.md @@ -0,0 +1,51 @@ +## MODIFIED Requirements +### Requirement: Agents can use project context tools +Meeting Assistant SHALL expose tools that allow agents to look up project information, retrieve keyword-relevant context, and inspect meeting-derived knowledge. + +Project tools SHALL treat each direct subfolder of the configured projects folder as one project. A meeting note binds projects by listing those subfolder names in the `projects` frontmatter field. + +The summary agent SHALL expose these project tools: + +- `list_projects` +- `list_projectfiles` +- `read_projectfile` +- `write_projectfile` +- `list_past_project_meetings` +- `read_past_project_meeting_summary` +- `search` + +The settings/logs agent SHALL expose project tools that operate across all existing project folders under the configured projects folder: + +- `list_projects` +- `list_projectfiles` +- `read_projectfile` +- `write_projectfile` +- `search_projects` +- `create_project` + +The settings/logs `create_project` tool SHALL create one new direct project subfolder by project name. + +The settings/logs `create_project` tool SHALL support a recommended seed that writes `AGENTS.md` from the bundled `Project-AGENTS.md` content file and creates starter `PROJECT.md`, `JOURNAL.md`, and `DECISIONS.md` files. + +The settings/logs `create_project` tool SHALL support writing caller-supplied `AGENTS.md` content directly instead of the recommended seed. + +The settings/logs project read/write/search tools SHALL refuse project names that do not already exist, except for `create_project`. + +The `search` and `search_projects` tools SHALL support an optional glob file pattern that limits searched project files. + +#### Scenario: Settings/logs agent creates a recommended project +- **WHEN** the settings/logs agent creates project `Alpha` with the recommended seed +- **THEN** Meeting Assistant creates the `Alpha` project folder +- **AND** writes `AGENTS.md` from the bundled `Project-AGENTS.md` +- **AND** writes starter `PROJECT.md`, `JOURNAL.md`, and `DECISIONS.md` files + +#### Scenario: Settings/logs agent writes direct project instructions +- **WHEN** the settings/logs agent creates project `Beta` with direct `AGENTS.md` content +- **THEN** Meeting Assistant creates the `Beta` project folder +- **AND** writes that content to `AGENTS.md` + +#### Scenario: Settings/logs agent searches existing projects by file glob +- **GIVEN** existing projects contain markdown and non-markdown files +- **WHEN** the settings/logs agent searches projects with file pattern `*.md` +- **THEN** Meeting Assistant returns matches from matching markdown files +- **AND** excludes non-matching files diff --git a/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-recording/spec.md b/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-recording/spec.md new file mode 100644 index 0000000..054ec42 --- /dev/null +++ b/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-recording/spec.md @@ -0,0 +1,18 @@ +## MODIFIED Requirements +### Requirement: Recording mode is controlled by a configurable hotkey +Meeting Assistant SHALL let the user start and stop recording mode through a configurable global hotkey. + +Meeting Assistant SHALL treat recordings stopped before the configured `Recording:MinimumCompletedMeetingDuration` as aborted runs, deleting run artifacts and skipping summary generation. + +Meeting Assistant SHALL name generated summary and assistant-context artifacts with minute precision so routine completed meetings do not include seconds or sub-second randomness in those filenames. + +#### Scenario: Same-minute run is treated as abort +- **GIVEN** `Recording:MinimumCompletedMeetingDuration` is `00:01:00` +- **WHEN** a recording is started and stopped before one minute has elapsed +- **THEN** Meeting Assistant deletes the run artifacts +- **AND** skips summary generation + +#### Scenario: Summary artifact names use minute precision +- **WHEN** a recording starts at `2026-05-30T09:30:30` +- **THEN** Meeting Assistant uses `20260530-0930-summary.md` for the summary artifact filename +- **AND** uses `20260530-0930-assistant-context.md` for the assistant context artifact filename diff --git a/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-session/spec.md b/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-session/spec.md new file mode 100644 index 0000000..c204325 --- /dev/null +++ b/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-session/spec.md @@ -0,0 +1,134 @@ +## MODIFIED Requirements +### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant +Meeting Assistant SHALL expose a `Settings and logs` item from the tray icon menu. + +The tray icon SHALL be implemented through the Uno notification icon stack. + +The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item. + +When the user selects `Settings and logs`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file, managing speaker identities, inspecting configuration, and reading logs. + +The chat window SHALL be titled `Settings and logs`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text `ask me what I can do for you`, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button. + +When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append. + +The Windows executable and settings/logs editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon. + +The settings/logs agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults. + +The settings/logs agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file. + +The rules editor `write_rules` tool SHALL append to the configured workflow rules file by default and SHALL validate the complete resulting YAML document before writing it. + +The rules editor `write_rules` tool SHALL replace the whole configured workflow rules file only when `replace_file` is true. + +The rules editor `write_rules` tool SHALL refuse invalid YAML without changing the configured workflow rules file. + +The settings/logs agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database. + +The settings/logs agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities. + +The settings/logs agent SHALL receive tools to read the local appsettings configuration file, write a complete valid JSON replacement for that file, read Meeting Assistant configuration documentation, read current or rotated application log files by tail or 1-based line range, search current or rotated application log files, search copied OpenSpec specification files, and read copied OpenSpec specification files by relative path and optional 1-based line range. + +Meeting Assistant SHALL copy specification markdown files from `openspec/specs` into build and publish output through a recursive include so future spec folders are included automatically. + +The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`. + +Meeting Assistant SHALL provide a diagnostic endpoint that opens the settings/logs editor through the same window service used by the tray menu. + +#### Scenario: Tray menu opens the editor +- **WHEN** the user opens the tray icon menu +- **THEN** the menu includes `Settings and logs` +- **WHEN** the user selects `Settings and logs` +- **THEN** Meeting Assistant opens the settings/logs editor chat window + +#### Scenario: Tray menu shows configured profile hotkeys +- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M` +- **AND** the `english` launch profile uses `Ctrl+Alt+E` +- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle +- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M` +- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E` + +#### Scenario: Settings and logs editor can be opened diagnostically +- **WHEN** a diagnostic caller requests the settings/logs editor to open +- **THEN** Meeting Assistant invokes the settings/logs editor window service + +#### Scenario: Rules editor is scoped to the configured rules file +- **GIVEN** a configured workflow rules file +- **WHEN** the settings/logs agent runs +- **THEN** its read, write, and search tools can access only that configured workflow rules file +- **AND** its system prompt includes the workflow engine documentation + +#### Scenario: Settings and logs assistant reads and writes configuration +- **GIVEN** a local appsettings JSON file +- **WHEN** the settings/logs agent reads configuration +- **THEN** it receives the file content or requested clamped line range +- **WHEN** it writes a valid JSON replacement +- **THEN** Meeting Assistant replaces the configuration file +- **WHEN** it writes invalid JSON +- **THEN** Meeting Assistant refuses the write and preserves the existing file + +#### Scenario: Settings and logs assistant reads configuration docs +- **WHEN** the settings/logs agent requests configuration documentation +- **THEN** Meeting Assistant returns the dedicated configuration documentation markdown + +#### Scenario: Settings and logs assistant reads and searches logs +- **GIVEN** Meeting Assistant has written application-owned logs under the temp log folder +- **WHEN** the settings/logs agent reads logs with a tail count or line range +- **THEN** it receives matching log lines from the selected log file +- **WHEN** the settings/logs agent searches logs +- **THEN** it receives matching `filename:line text` entries from current and rotated log files + +#### Scenario: Settings and logs assistant reads and searches copied specs +- **GIVEN** OpenSpec specification markdown files are copied into the application output +- **WHEN** the settings/logs agent searches specs +- **THEN** it receives matching `relative/spec.md:line text` entries scoped to `openspec/specs` +- **WHEN** the settings/logs agent reads a spec file by relative path +- **THEN** it receives the requested file content or clamped 1-based line range +- **AND** paths outside the copied specs tree are refused + +#### Scenario: New spec folders are included in output +- **WHEN** a new markdown spec file is added under `openspec/specs` +- **THEN** the application build includes it in the output under `openspec/specs` +- **AND** publish includes it in the published output under `openspec/specs` + +#### Scenario: Application keeps its own rotating log files +- **WHEN** Meeting Assistant starts +- **THEN** it writes application logs to the configured temp log folder independently from stdout and stderr redirection +- **AND** it preserves at most four older rotated log files + +#### Scenario: Rules editor appends by default +- **GIVEN** a configured workflow rules file with existing valid rules +- **WHEN** the rules editor writes an additional valid rule without `replace_file` +- **THEN** Meeting Assistant appends the new rule +- **AND** preserves the existing rules + +#### Scenario: Rules editor replaces only when requested +- **GIVEN** a configured workflow rules file with existing rules +- **WHEN** the rules editor writes valid YAML with `replace_file` set to true +- **THEN** Meeting Assistant replaces the complete rules file with the supplied YAML + +#### Scenario: Rules editor refuses invalid YAML +- **GIVEN** a configured workflow rules file with valid existing rules +- **WHEN** the rules editor writes YAML that would make the file invalid +- **THEN** Meeting Assistant refuses the write +- **AND** keeps the existing rules file unchanged + +#### Scenario: Rules editor can manage speaker identities +- **GIVEN** the speaker identity database contains speaker identities and samples +- **WHEN** the rules editor agent runs +- **THEN** it can search, read, create, update, delete, and merge speaker identities +- **AND** it can list, read, delete, and queue playback of identity samples + +#### Scenario: User sends a rules-editing chat turn +- **GIVEN** the settings/logs editor chat window is open +- **WHEN** the user types a prompt and presses Enter +- **THEN** the user message appears in the conversation +- **AND** a plain `Thinking...` line appears while the agent is running +- **AND** the first model request for that turn is marked as user-initiated +- **AND** the final assistant response replaces the thinking line + +#### Scenario: Chat auto-scrolls after appended content +- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom +- **WHEN** a new user or assistant message is appended +- **THEN** the conversation scrolls to the bottom of the newly rendered message content diff --git a/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-summary/spec.md b/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-summary/spec.md new file mode 100644 index 0000000..1db36ea --- /dev/null +++ b/openspec/changes/generalize-settings-and-logs-agent/specs/meeting-summary/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements +### Requirement: Meeting Assistant generates meeting outputs +Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context. + +All summary-agent read tools that return file-like content SHALL support reading the whole file, reading a clamped inclusive 1-based line range when `from` and `to` are supplied, or reading the last `tail` lines when `tail` is supplied without a line range. + +The `write_summary` tool SHALL return the written summary filename after writing the summary note. + +The summary-agent `search` tool SHALL support an optional glob file pattern that limits searched project files. + +#### Scenario: Summary read tools support tail +- **WHEN** the summary agent reads a transcript with `tail` set to `2` +- **THEN** Meeting Assistant returns the last two transcript lines + +#### Scenario: Summary write returns filename +- **WHEN** the summary agent writes the current meeting summary +- **THEN** Meeting Assistant returns the written summary filename + +#### Scenario: Summary search is limited by file pattern +- **GIVEN** a bound project has `PROJECT.md` and `scratch.txt` +- **WHEN** the summary agent searches with file pattern `*.md` +- **THEN** Meeting Assistant searches `PROJECT.md` +- **AND** excludes `scratch.txt` diff --git a/openspec/changes/generalize-settings-and-logs-agent/tasks.md b/openspec/changes/generalize-settings-and-logs-agent/tasks.md new file mode 100644 index 0000000..7e88e71 --- /dev/null +++ b/openspec/changes/generalize-settings-and-logs-agent/tasks.md @@ -0,0 +1,20 @@ +## 1. Specification +- [x] 1.1 Add OpenSpec scenarios for the generalized settings/logs assistant. +- [x] 1.2 Move configuration docs into `docs/meeting-assistant-configuration.md` and keep README as an overview. +- [x] 1.3 Update AGENTS.md with application context for future agents. + +## 2. Tests +- [x] 2.1 Cover user-facing tray/window/placeholder rename behavior. +- [x] 2.2 Cover config read/write validation tools. +- [x] 2.3 Cover config docs tool. +- [x] 2.4 Cover log tail/range/search tools and file logging rotation. +- [x] 2.5 Cover copied spec read/search tools and scoped path handling. +- [x] 2.6 Cover project creation, project read/write/search, read tail, search glob, summary filename return, and short-run abort behavior. + +## 3. Implementation +- [x] 3.1 Add app-owned temp-file logging with four older rotated files. +- [x] 3.2 Add settings/logs tools to the editor agent pipeline. +- [x] 3.3 Update instructions, UI title/menu text, and docs copy settings. +- [x] 3.4 Add recursive spec markdown copy to build and publish output. +- [x] 3.5 Run focused tests and strict OpenSpec validation. +- [x] 3.6 Add settings/logs project tools, bundled Project-AGENTS.md seed content, summary filename return, minute-level summary/context names, and configurable short-run abort.