13 Commits
Author SHA1 Message Date
renovate-bot 59a0de9e0f Update dependency NCalcSync to v6
PR and Push Build/Test / build-and-test (pull_request) Failing after 6m8s
PR and Push Build/Test / build-and-test (push) Failing after 6m3s
2026-06-12 02:31:39 +00:00
codex b22754ce5d Normalize scalar frontmatter lists
PR and Push Build/Test / build-and-test (push) Successful in 8m49s
2026-06-11 16:00:05 +02:00
codex bd786426bf Stabilize abort coordinator test
PR and Push Build/Test / build-and-test (push) Successful in 9m20s
2026-06-11 15:58:50 +02:00
codex 86e3efa3f6 Stabilize recording coordinator timing tests
PR and Push Build/Test / build-and-test (push) Failing after 9m40s
2026-06-11 15:42:05 +02:00
codex 1f57cb98b7 Merge pull request 'Update dependency Whisper.net to 1.9.1' (#14) from renovate/whisper.net-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m8s
2026-06-11 09:33:39 +02:00
codex 0f5692c173 Merge pull request 'Update dependency Microsoft.EntityFrameworkCore.Sqlite to 10.0.9' (#20) from renovate/microsoft.entityframeworkcore.sqlite-10.x into main
PR and Push Build/Test / build-and-test (push) Failing after 8m54s
2026-06-11 09:27:05 +02:00
codex 5233db80a8 Merge pull request 'Update dependency Microsoft.Agents.AI.OpenAI to 1.10.0' (#16) from renovate/microsoft.agents.ai.openai-1.x into main
PR and Push Build/Test / build-and-test (push) Successful in 9m9s
2026-06-11 09:26:58 +02:00
renovate-bot b7c01f1dfd Update dependency Microsoft.Agents.AI.OpenAI to 1.10.0
PR and Push Build/Test / build-and-test (push) Successful in 8m46s
PR and Push Build/Test / build-and-test (pull_request) Successful in 8m48s
2026-06-11 02:31:00 +00:00
codex e54b19d3a9 Use consistent meeting artifact filenames
PR and Push Build/Test / build-and-test (push) Successful in 10m14s
2026-06-10 16:56:10 +02:00
codex 5bbfd7b3c3 docs: refresh meeting assistant README
PR and Push Build/Test / build-and-test (push) Successful in 15m14s
2026-06-10 09:10:35 +02:00
renovate-bot bb82093980 Update dependency Microsoft.EntityFrameworkCore.Sqlite to 10.0.9
PR and Push Build/Test / build-and-test (push) Successful in 13m17s
PR and Push Build/Test / build-and-test (pull_request) Successful in 10m1s
2026-06-10 02:33:55 +00:00
codex ecdd23bde7 Handle settings logs agent failures
PR and Push Build/Test / build-and-test (push) Successful in 9m8s
2026-06-09 17:10:06 +02:00
renovate-bot 6b3b5ba7f3 Update dependency Whisper.net to 1.9.1
PR and Push Build/Test / build-and-test (push) Successful in 8m51s
PR and Push Build/Test / build-and-test (pull_request) Successful in 8m49s
2026-06-06 02:31:03 +00:00
25 changed files with 586 additions and 209 deletions
@@ -126,12 +126,14 @@ public sealed class LiteLlmResponsesChatClientTests
}
""")
});
using var client = CreateClient(handler, reconnectionAttempts: 1);
var retryCount = 0;
using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(2, handler.RequestCount);
Assert.Equal(1, retryCount);
}
[Fact]
@@ -303,7 +305,8 @@ public sealed class LiteLlmResponsesChatClientTests
private static LiteLlmResponsesChatClient CreateClient(
HttpMessageHandler handler,
int reconnectionAttempts,
LiteLlmResponsesCompactionOptions? compactionOptions = null)
LiteLlmResponsesCompactionOptions? compactionOptions = null,
Action? retrying = null)
{
return new LiteLlmResponsesChatClient(
new HttpClient(handler)
@@ -316,7 +319,8 @@ public sealed class LiteLlmResponsesChatClientTests
reasoningEffort: "none",
reconnectionAttempts,
TimeSpan.Zero,
compactionOptions);
compactionOptions,
retrying: retrying);
}
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
@@ -57,6 +57,19 @@ public sealed class MeetingNoteStoreTests
Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary);
}
[Fact]
public async Task StoreNamesGeneratedMeetingNoteWithMinutePrecision()
{
var (_, store) = CreateStore();
var saved = await store.SaveAsync(
MeetingNoteTemplate.Create(
title: "Leadership Sync",
startTime: DateTimeOffset.Parse("2026-05-19T10:03:42+02:00")),
CancellationToken.None);
Assert.Equal("20260519-1003-note.md", Path.GetFileName(saved.Path));
}
[Fact]
public async Task FrontmatterUpdatePreservesExistingUserNotesBody()
{
@@ -92,6 +105,36 @@ public sealed class MeetingNoteStoreTests
Assert.Equal(userNotes, reloaded.UserNotes);
}
[Fact]
public async Task ReadConvertsScalarListFrontmatterToSingleItemLists()
{
var (vaultRoot, store) = CreateStore();
var notePath = Path.Combine(vaultRoot, "Meetings", "Notes", "manual.md");
Directory.CreateDirectory(Path.GetDirectoryName(notePath)!);
await File.WriteAllTextAsync(
notePath,
"""
---
title: Manual frontmatter
start_time: "2026-05-19T10:00:00.0000000+02:00"
end_time: ""
attendees: Ada
projects: Meeting Assistant
transcript: "[[../Transcripts/manual-transcript|Transcript]]"
assistant_context: "[[../Assistant Context/manual-context|Assistant Context]]"
summary: "[[../Summaries/manual-summary|Summary]]"
---
User notes.
""");
var loaded = await store.ReadAsync(notePath, CancellationToken.None);
Assert.Equal(["Ada"], loaded.Frontmatter.Attendees);
Assert.Equal(["Meeting Assistant"], loaded.Frontmatter.Projects);
Assert.Equal("User notes.", loaded.UserNotes);
}
[Fact]
public void ActionLinkEscapesSummaryFileName()
{
@@ -63,6 +63,34 @@ public sealed class ProjectKnowledgeToolTests
await tools.Search("alpha"));
}
[Fact]
public async Task ListProjectsAcceptsScalarProjectFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
Directory.CreateDirectory(Path.Combine(projectsRoot, "MeetingAssistant"));
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
}
});
Assert.Equal("MeetingAssistant", await tools.ListProjects());
}
[Fact]
public async Task WriteProjectFileSupportsAppendReplaceInsertOverwriteAndCreate()
{
@@ -15,7 +15,7 @@ namespace MeetingAssistant.Tests;
public sealed class RecordingCoordinatorTests
{
private static async Task WaitUntilAsync(Func<bool> condition)
private static async Task WaitUntilAsync(Func<bool> condition, string? timeoutMessage = null)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
while (DateTimeOffset.UtcNow < deadline)
@@ -28,7 +28,7 @@ public sealed class RecordingCoordinatorTests
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met.");
throw new TimeoutException(timeoutMessage ?? "Condition was not met.");
}
private static async Task AppendTextWithRetryAsync(string path, string text)
@@ -186,6 +186,7 @@ public sealed class RecordingCoordinatorTests
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
var noteOpener = new CapturingMeetingNoteOpener();
var artifactStore = new InMemoryMeetingArtifactStore();
var audioArchive = new InMemoryRecordedAudioStore();
@@ -206,22 +207,21 @@ public sealed class RecordingCoordinatorTests
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance);
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityClock: clock);
var status = await coordinator.StartAsync(CancellationToken.None);
Assert.True(status.IsRecording);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
Assert.EndsWith("-summary.md", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext);
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal);
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath);
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", artifactStore.CreatedArtifacts?.SummaryPath);
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
@@ -365,6 +365,7 @@ public sealed class RecordingCoordinatorTests
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("latest transcript text", StringComparison.Ordinal)));
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2));
await promptService.WaitForPromptAsync();
await WaitUntilAsync(() => summaryPipeline.WasRun);
@@ -414,8 +415,10 @@ public sealed class RecordingCoordinatorTests
await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
await WaitUntilAsync(() => summaryPipeline.WasRun);
Assert.Empty(promptService.Requests);
Assert.False(coordinator.CurrentStatus.IsRecording);
@@ -459,6 +462,7 @@ public sealed class RecordingCoordinatorTests
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2));
await promptService.WaitForPromptAsync();
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
@@ -509,12 +513,14 @@ public sealed class RecordingCoordinatorTests
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("chunk:2", StringComparison.Ordinal)));
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => promptService.Requests.Count == 1);
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
segment.Text.Contains("chunk:4", StringComparison.Ordinal)));
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => promptService.Requests.Count == 2);
@@ -837,6 +843,7 @@ public sealed class RecordingCoordinatorTests
};
var audioSource = new ControlledAudioSource();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata("", [], ""));
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
@@ -847,37 +854,44 @@ public sealed class RecordingCoordinatorTests
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance);
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: metadataProvider);
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() =>
FileContainsText(started.AssistantContextPath!, "state: transcribing"));
var attachmentPath = Path.Combine(
Path.GetDirectoryName(started.AssistantContextPath!)!,
"Attachments",
"20260527-093135-123-000010-cropped.png");
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
await File.WriteAllTextAsync(attachmentPath, "image");
await AppendTextWithRetryAsync(
started.AssistantContextPath!,
Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine);
Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!);
await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
try
{
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => File.Exists(started.AssistantContextPath!));
var attachmentPath = Path.Combine(
Path.GetDirectoryName(started.AssistantContextPath!)!,
"Attachments",
"20260527-093135-123-000010-cropped.png");
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
await File.WriteAllTextAsync(attachmentPath, "image");
await AppendTextWithRetryAsync(
started.AssistantContextPath!,
Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine);
Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!);
await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
var aborted = await coordinator.AbortAsync(CancellationToken.None);
var aborted = await coordinator.AbortAsync(CancellationToken.None);
Assert.False(aborted.IsRecording);
Assert.Null(aborted.MeetingNotePath);
Assert.False(File.Exists(started.MeetingNotePath));
Assert.False(File.Exists(started.TranscriptPath));
Assert.False(File.Exists(started.AssistantContextPath));
Assert.False(File.Exists(started.SummaryPath));
Assert.False(File.Exists(attachmentPath));
Assert.Null(summaryPipeline.Artifacts);
Assert.False(aborted.IsRecording);
Assert.Null(aborted.MeetingNotePath);
Assert.False(File.Exists(started.MeetingNotePath));
Assert.False(File.Exists(started.TranscriptPath));
Assert.False(File.Exists(started.AssistantContextPath));
Assert.False(File.Exists(started.SummaryPath));
Assert.False(File.Exists(attachmentPath));
Assert.Null(summaryPipeline.Artifacts);
await Task.Delay(250);
Assert.False(File.Exists(started.AssistantContextPath));
await Task.Delay(250);
Assert.False(File.Exists(started.AssistantContextPath));
}
finally
{
metadataProvider.Release();
}
}
[Fact]
@@ -1101,6 +1115,7 @@ public sealed class RecordingCoordinatorTests
var defaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default");
var englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english");
var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot);
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)),
@@ -1113,15 +1128,16 @@ public sealed class RecordingCoordinatorTests
summaryPipeline,
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance,
launchProfiles: launchProfiles);
launchProfiles: launchProfiles,
inactivityClock: clock);
var started = await coordinator.StartAsync("english", CancellationToken.None);
await coordinator.StopAsync(CancellationToken.None);
Assert.StartsWith(Path.Combine(englishRoot, "Transcripts"), started.TranscriptPath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Notes"), started.MeetingNotePath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Context"), started.AssistantContextPath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Summaries"), started.SummaryPath, StringComparison.OrdinalIgnoreCase);
Assert.Equal(Path.Combine(englishRoot, "Transcripts", "20260519-1003-transcript.md"), started.TranscriptPath);
Assert.Equal(Path.Combine(englishRoot, "Notes", "20260519-1003-note.md"), started.MeetingNotePath);
Assert.Equal(Path.Combine(englishRoot, "Context", "20260519-1003-context.md"), started.AssistantContextPath);
Assert.Equal(Path.Combine(englishRoot, "Summaries", "20260519-1003-summary.md"), started.SummaryPath);
Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model);
}
@@ -2015,6 +2031,26 @@ public sealed class RecordingCoordinatorTests
Assert.DoesNotContain("transcript:", content);
}
[Fact]
public async Task VaultTranscriptStoreNamesGeneratedTranscriptWithMinutePrecision()
{
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
{
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
};
var store = new VaultTranscriptStore(
Options.Create(options),
NullLogger<VaultTranscriptStore>.Instance);
var session = await store.CreateSessionAsync(
options,
DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"),
CancellationToken.None);
Assert.Equal("20260519-1003-transcript.md", Path.GetFileName(session.TranscriptPath));
}
[Fact]
public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav()
{
@@ -2145,8 +2181,8 @@ public sealed class RecordingCoordinatorTests
private sealed class InMemoryTranscriptStore : ITranscriptStore
{
private readonly object gate = new();
private readonly List<TranscriptionSegment> segments = [];
private readonly TaskCompletionSource segmentWritten = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly string transcriptPath;
public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md")
@@ -2159,32 +2195,43 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession(transcriptPath));
}
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
segments.Add(ParseTranscriptLine(line));
segmentWritten.TrySetResult();
lock (gate)
{
segments.Add(ParseTranscriptLine(line));
}
return Task.CompletedTask;
}
public async Task WaitForTextAsync(string text)
public Task WaitForTextAsync(string text)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true))
{
return;
}
await segmentWritten.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"Segment containing '{text}' was not written.");
return WaitUntilAsync(
() => Segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true),
$"Segment containing '{text}' was not written.");
}
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
public IReadOnlyList<TranscriptionSegment> Segments => segments;
public IReadOnlyList<TranscriptionSegment> Segments
{
get
{
lock (gate)
{
return segments.ToArray();
}
}
}
public MeetingNote? MetadataMeetingNote { get; private set; }
@@ -2212,7 +2259,6 @@ public sealed class RecordingCoordinatorTests
{
private int created;
private int appendCount;
private TaskCompletionSource appendObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<TranscriptWrite> AppendHistory { get; } = [];
@@ -2226,33 +2272,26 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md"));
}
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
Interlocked.Increment(ref appendCount);
appendObserved.TrySetResult();
return Task.CompletedTask;
}
public async Task WaitForAppendCountAsync(int expectedCount)
public Task WaitForAppendCountAsync(int expectedCount)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (Volatile.Read(ref appendCount) >= expectedCount)
{
return;
}
var observed = appendObserved;
await observed.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
if (ReferenceEquals(observed, appendObserved))
{
appendObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
throw new TimeoutException($"Expected {expectedCount} transcript append(s).");
return WaitUntilAsync(
() => Volatile.Read(ref appendCount) >= expectedCount,
$"Expected {expectedCount} transcript append(s).");
}
public Task ReplaceLinesAsync(
@@ -2887,10 +2926,13 @@ public sealed class RecordingCoordinatorTests
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
return Task.FromResult(new TranscriptSession(Path.Combine(folder, "transcript.md")));
return Task.FromResult(new TranscriptSession(Path.Combine(
folder,
MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript))));
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
@@ -2931,7 +2973,16 @@ public sealed class RecordingCoordinatorTests
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
savedNote = note with { Path = string.IsNullOrWhiteSpace(note.Path) ? Path.Combine(folder, "meeting.md") : note.Path };
savedNote = note with
{
Path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(
folder,
MeetingArtifactFileNames.Create(
note.Frontmatter.StartTime ?? DateTimeOffset.Now,
MeetingArtifactFileNames.Note))
: note.Path
};
return Task.FromResult(savedNote);
}
@@ -853,6 +853,52 @@ public sealed class WorkflowRulesEditorTests
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
}
[Fact]
public async Task ViewModelShowsAgentErrorWhenRequestTimesOut()
{
var viewModel = new WorkflowRulesEditorChatViewModel(
new ThrowingRulesEditorPipeline(new TaskCanceledException("The request timed out.")))
{
Draft = "check logs"
};
await viewModel.SendAsync();
Assert.False(viewModel.IsThinking);
Assert.Equal("", viewModel.Draft);
Assert.Equal(
[
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"),
new WorkflowRulesEditorChatMessage(
WorkflowRulesEditorChatRole.Agent,
"Settings and logs failed: The request timed out.")
],
viewModel.Messages.ToArray());
}
[Fact]
public async Task ViewModelShowsReconnectStatusReportedByPipeline()
{
var pipeline = new StatusReportingRulesEditorPipeline("Updated rules.");
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "check logs"
};
var sendTask = viewModel.SendAsync();
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(viewModel.IsThinking);
Assert.Equal("Reconnecting...", viewModel.ActivityMessage);
pipeline.Release.SetResult();
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(viewModel.IsThinking);
Assert.Equal("Thinking...", viewModel.ActivityMessage);
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
}
[Theory]
[InlineData(0, 500, 0, true)]
[InlineData(0, 500, 400, true)]
@@ -1149,7 +1195,8 @@ public sealed class WorkflowRulesEditorTests
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
{
Started.SetResult();
await Release.Task.WaitAsync(cancellationToken);
@@ -1162,6 +1209,56 @@ public sealed class WorkflowRulesEditorTests
}
}
private sealed class ThrowingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly Exception exception;
public ThrowingRulesEditorPipeline(Exception exception)
{
this.exception = exception;
}
public Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
{
throw exception;
}
}
private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly string response;
public StatusReportingRulesEditorPipeline(string response)
{
this.response = response;
}
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
{
statusChanged?.Invoke("Reconnecting...");
Reported.SetResult();
await Release.Task.WaitAsync(cancellationToken);
return new WorkflowRulesEditorChatResult(
response,
conversation
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
.ToList());
}
}
private sealed class CapturingLogger : ILogger
{
public List<string> Messages { get; } = [];
+4 -4
View File
@@ -20,16 +20,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.10.0" />
<PackageReference Include="DiffPlex" Version="1.9.0" />
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="$(MicrosoftSpeechVersion)" />
<PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="NCalcSync" Version="6.1.0" />
<PackageReference Include="NCalcSync" Version="6.1.1" />
<PackageReference Include="RazorLight" Version="2.3.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
<PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net" Version="1.9.1" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageReference Include="YamlDotNet" Version="18.0.0" />
</ItemGroup>
@@ -0,0 +1,24 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.MeetingNotes;
internal static class FrontmatterYamlDeserializer
{
public static IDeserializer Create()
{
return new DeserializerBuilder()
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
public static IDeserializer CreateWithUnderscoredNaming()
{
return new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new ScalarStringListYamlTypeConverter())
.IgnoreUnmatchedProperties()
.Build();
}
}
@@ -16,9 +16,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
{
this.options = options.Value;
this.logger = logger;
yamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
yamlDeserializer = FrontmatterYamlDeserializer.Create();
}
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
@@ -35,7 +33,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-{Slugify(note.Frontmatter.Title)}.md")
? Path.Combine(folder, MeetingArtifactFileNames.Create(note.Frontmatter.StartTime ?? DateTimeOffset.Now, MeetingArtifactFileNames.Note))
: note.Path;
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
var content = Render(frontmatter, note.UserNotes);
@@ -108,16 +106,6 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
return string.Join(Environment.NewLine, lines);
}
private static string Slugify(string value)
{
var slug = new string(value
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray());
slug = string.Join("-", slug.Split('-', StringSplitOptions.RemoveEmptyEntries));
return string.IsNullOrWhiteSpace(slug) ? "meeting" : slug;
}
private static string EscapeScalar(string value)
{
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
@@ -0,0 +1,14 @@
namespace MeetingAssistant.MeetingNotes;
public static class MeetingArtifactFileNames
{
public const string Note = "note";
public const string Context = "context";
public const string Transcript = "transcript";
public const string Summary = "summary";
public static string Create(DateTimeOffset startedAt, string artifactType)
{
return $"{startedAt:yyyyMMdd-HHmm}-{artifactType}.md";
}
}
@@ -0,0 +1,38 @@
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace MeetingAssistant.MeetingNotes;
internal sealed class ScalarStringListYamlTypeConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
{
return type == typeof(List<string>);
}
public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
{
if (parser.TryConsume<Scalar>(out var scalar))
{
return string.IsNullOrWhiteSpace(scalar.Value)
? []
: new List<string> { scalar.Value };
}
var values = new List<string>();
parser.Consume<SequenceStart>();
while (!parser.TryConsume<SequenceEnd>(out _))
{
var item = parser.Consume<Scalar>();
values.Add(item.Value);
}
return values;
}
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
{
serializer(value, typeof(IEnumerable<string>));
}
}
@@ -9,10 +9,8 @@ public interface ITranscriptStore
Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
DateTimeOffset startedAt,
CancellationToken cancellationToken);
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
@@ -156,9 +156,9 @@ public sealed class MeetingRecordingCoordinator
var launchProfile = ResolveProfile(launchProfileName);
var runOptions = launchProfile.Options;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var startedAt = inactivityClock.Now;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, startedAt, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
var summaryPath = GetSummaryPath(startedAt, runOptions);
currentMeetingNote = await meetingNoteStore.SaveAsync(
@@ -1491,12 +1491,12 @@ public sealed class MeetingRecordingCoordinator
private string GetAssistantContextPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
{
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.AssistantContextFolder, startedAt, "assistant-context");
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.AssistantContextFolder, startedAt, MeetingArtifactFileNames.Context);
}
private string GetSummaryPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
{
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, "summary");
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, MeetingArtifactFileNames.Summary);
}
private static string GetMeetingArtifactPath(
@@ -1506,7 +1506,7 @@ public sealed class MeetingRecordingCoordinator
string artifactName)
{
var folder = VaultPath.Resolve(vault, configuredFolder);
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmm}-{artifactName}.md");
return Path.Combine(folder, MeetingArtifactFileNames.Create(startedAt, artifactName));
}
private static bool ShouldAbortRunOnStop(RecordingRun run, DateTimeOffset stoppedAt)
@@ -17,17 +17,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
return await CreateSessionAsync(options, cancellationToken);
return await CreateSessionAsync(options, DateTimeOffset.Now, cancellationToken);
}
public async Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
Directory.CreateDirectory(folder);
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-transcript.md";
var fileName = MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript);
var path = Path.Combine(folder, fileName);
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
logger.LogInformation("Created meeting transcript file {TranscriptPath}", path);
@@ -7,9 +7,7 @@ public sealed record BoundMeetingProject(string Name, string Path);
public sealed class BoundMeetingProjectResolver
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create();
private readonly MeetingAssistantOptions options;
@@ -21,6 +21,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
private readonly TimeSpan reconnectionDelay;
private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
private readonly ILogger? logger;
private readonly Action? retrying;
private int responsesRequestCount;
public LiteLlmResponsesChatClient(
@@ -33,7 +34,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null,
bool firstRequestIsUser = true)
bool firstRequestIsUser = true,
Action? retrying = null)
: this(
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
apiKey,
@@ -44,7 +46,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
reconnectionDelay,
compactionOptions,
logger,
firstRequestIsUser)
firstRequestIsUser,
retrying)
{
}
@@ -58,7 +61,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null,
bool firstRequestIsUser = true)
bool firstRequestIsUser = true,
Action? retrying = null)
{
this.httpClient = httpClient;
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
@@ -69,6 +73,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero;
this.compactionOptions = compactionOptions;
this.logger = logger;
this.retrying = retrying;
responsesRequestCount = firstRequestIsUser ? 0 : 1;
}
@@ -377,6 +382,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
lastException = exception;
}
retrying?.Invoke();
await DelayBeforeRetryAsync(cancellationToken).ConfigureAwait(false);
}
@@ -5,9 +5,7 @@ namespace MeetingAssistant.Summary;
internal static class MeetingSummaryFrontmatterFactory
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create();
public static async Task<MeetingArtifactFrontmatter> CreateAsync(
MeetingSessionArtifacts artifacts,
@@ -7,9 +7,7 @@ namespace MeetingAssistant.Summary;
public sealed class MeetingSummaryTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.Create();
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
@@ -19,5 +19,6 @@ public interface IWorkflowRulesEditorChatPipeline
Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken);
CancellationToken cancellationToken,
Action<string>? statusChanged = null);
}
@@ -58,7 +58,8 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Action<string>? statusChanged = null)
{
if (string.IsNullOrWhiteSpace(userMessage))
{
@@ -106,7 +107,8 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
agentOptions.ReconnectionDelay,
compactionOptions,
logger,
firstRequestIsUser: true);
firstRequestIsUser: true,
retrying: () => statusChanged?.Invoke("Reconnecting..."));
var functionClient = chatClient
.AsBuilder()
.UseFunctionInvocation(loggerFactory)
@@ -17,6 +17,8 @@ public sealed class WorkflowRulesEditorChatViewModel
public bool IsThinking { get; private set; }
public string ActivityMessage { get; private set; } = "Thinking...";
public event EventHandler? Changed;
public async Task SendAsync(CancellationToken cancellationToken = default)
@@ -31,6 +33,7 @@ public sealed class WorkflowRulesEditorChatViewModel
var priorConversation = Messages.ToList();
Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt));
IsThinking = true;
ActivityMessage = "Thinking...";
OnChanged();
try
@@ -38,26 +41,39 @@ public sealed class WorkflowRulesEditorChatViewModel
var result = await pipeline.SendAsync(
priorConversation,
prompt,
cancellationToken);
cancellationToken,
SetActivityMessage);
Messages.Clear();
foreach (var message in result.Conversation)
{
Messages.Add(message);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
Messages.Add(new WorkflowRulesEditorChatMessage(
WorkflowRulesEditorChatRole.Agent,
$"Rules editor failed: {exception.Message}"));
$"Settings and logs failed: {exception.Message}"));
}
finally
{
IsThinking = false;
ActivityMessage = "Thinking...";
OnChanged();
}
}
private void SetActivityMessage(string message)
{
if (!IsThinking || string.IsNullOrWhiteSpace(message))
{
return;
}
ActivityMessage = message;
OnChanged();
}
private void OnChanged()
{
Changed?.Invoke(this, EventArgs.Empty);
@@ -12,16 +12,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.CreateWithUnderscoredNaming();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
@@ -297,7 +297,7 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window
{
conversationPanel.Children.Add(new TextBlock
{
Text = "Thinking...",
Text = viewModel.ActivityMessage,
Foreground = MutedText,
Margin = new Thickness(4, 2, 4, 2)
});
+127 -70
View File
@@ -1,61 +1,36 @@
# Meeting Assistant
Meeting Assistant is a .NET 10 server application for capturing and enriching meetings.
Meeting Assistant is Manuel's local .NET meeting capture and knowledge service. It runs on the Windows workstation, records microphone plus system audio, writes Obsidian meeting artifacts, transcribes with speaker attribution, enriches meetings from local context, and drives summary/project agents without depending on Teams, Zoom, or another meeting-platform API for the primary flow.
Its core purpose is to work with any kind of meeting, including in-person meetings. Integrations with platforms such as Teams, Zoom, or similar tools may augment the experience later, but the system must not depend on those product APIs for its primary meeting flow.
## What This Repo Owns
The application is intended to:
This repository owns the application source, tests, OpenSpec requirements, configuration examples, local runtime documentation, and CI build/test workflow for Meeting Assistant. It does not own a homelab deployment stack, Traefik routing, Docker Compose deployment, or production image tag selection.
- create an Obsidian markdown note before transcription starts
- keep meeting metadata, user notes, detected context, and links to generated output in that note
- toggle recording/transcription mode through a configurable global hotkey
- optionally prompt to start recording when Outlook Classic shows a Teams meeting starting
- switch the active transcription profile during a meeting with a profile-specific toggle hotkey
- abort and discard an active recording through a configurable global hotkey
- show a Windows taskbar notification icon with state-aware recording controls
- capture active-window screenshots through a configurable global hotkey
- capture microphone input and computer output into one transcription stream
- transcribe meetings with speaker attribution
- use a configurable speech recognition pipeline, with local Whisper as the first version 1 fallback
- use FunASR or local Whisper plus pyannote as configurable speaker-attribution paths
- write transcript markdown files into the configured vault folder
- apply local workflow rules to meeting metadata, context, and transcript lines
- generate summaries, decisions, and next steps
- build and maintain a project knowledge base from meetings and future context sources
- use Microsoft Agent Framework-based agents to reason over meeting and project context
- give agents tools for project lookup, keyword lookup, retrieval-augmented generation, and project file updates in existing projects
The app is intentionally local-first:
## Repository Status
- Windows builds provide the tray icon, global hotkeys, NAudio capture, Outlook Classic COM enrichment, active-window screenshots, and notifications.
- Non-Windows builds keep the server/testable service surface but compile out Windows-only integrations.
- The normal runtime endpoint is local HTTP on port `5090`, with `/health` and `/recording/status` as the safe first checks.
- Behavior changes are OpenSpec-driven; current accepted requirements live under `openspec/specs`.
This repository currently contains the initial .NET 10 application skeleton, a health endpoint, and the first OpenSpec change for version 1.
## Quick Start
## Local Development
Restore, build, and test:
For a development build:
```powershell
dotnet restore MeetingAssistant.slnx
dotnet build MeetingAssistant.slnx
dotnet test MeetingAssistant.slnx
```
Run the server:
```powershell
dotnet run --project MeetingAssistant
```
## Local Autostart and Restart
For the local Windows workstation, use the snippets restart helper instead of running the app directly when you want a durable background instance:
For the durable local background instance, use the snippets restart helper:
```powershell
powershell -ExecutionPolicy Bypass -File C:\Manuel\snippets\restart-meeting-assistant.ps1
```
The restart helper publishes `MeetingAssistant\MeetingAssistant.csproj` as `net10.0-windows10.0.19041.0` into a fresh temp folder under `C:\Manuel\meeting-assistant\tmp\meeting-assistant-runtime`. Only after that publish succeeds does it stop any active Meeting Assistant instance on port `5090` and start the newly published executable detached from the script.
The detached starter writes logs and the last process id to:
The helper publishes `MeetingAssistant\MeetingAssistant.csproj` for `net10.0-windows10.0.19041.0` into timestamped folders below `C:\Manuel\meeting-assistant\tmp\meeting-assistant-runtime`. It stops the old port-`5090` instance only after publishing succeeds, starts the published `MeetingAssistant.exe`, waits for `/health`, and writes process logs plus the last PID to:
```text
%LOCALAPPDATA%\MeetingAssistant\Logs\meeting-assistant.out.log
@@ -63,49 +38,131 @@ The detached starter writes logs and the last process id to:
%LOCALAPPDATA%\MeetingAssistant\Logs\meeting-assistant.pid
```
This script is also the intended autostart target. Add a Windows Startup shortcut to `C:\Manuel\snippets\restart-meeting-assistant.ps1` when the app should rebuild and restart automatically after login.
Before restarting, always check the running process:
The initial endpoints are:
```text
GET /health
GET /recording/status
POST /recording/toggle
POST /recording/start
POST /recording/stop
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=...
```powershell
Invoke-RestMethod http://127.0.0.1:5090/recording/status
```
Do not restart while a meeting is recording, transcribing, finalizing speaker recognition, waiting on OCR, or summarizing unless the user explicitly approves the interruption.
## Runtime Behavior
Recording can be controlled through global hotkeys, the Windows tray icon, or local HTTP endpoints. The default hotkeys are:
- `Ctrl+Alt+M`: toggle the default recording profile.
- `Ctrl+Alt+L`: toggle or switch to the configured `english` launch profile.
- `Ctrl+Alt+Z`: abort the active recording and delete that run's artifacts.
- `Ctrl+Alt+S`: capture the active window into the current meeting context.
The main local endpoints are:
- `GET /health`
- `GET /recording/status`
- `POST /recording/start`, `/recording/stop`, `/recording/toggle`, `/recording/abort`
- `POST /profiles/{launchProfile}/recording/start`, `/stop`, `/toggle`, `/abort`
- `POST /asr/transcribe-file` and `/asr/diarize-file` for diagnostic WAV checks
- `POST /diagnostics/workflow/reload`
- `POST /diagnostics/settings-and-logs/show`
- `POST /diagnostics/workflow/rules-editor/show`
- `POST /meetings/current/summary/run`
- `POST` or `GET /meetings/summary/retry`
Stopping a recording stops new audio capture but lets that run continue transcription drain, speaker processing, screenshot OCR waits, summary generation, and final artifact updates. A later recording can start while an older stopped run is still finalizing; each run keeps isolated artifact paths and options.
## Data And Side Effects
Meeting Assistant writes durable meeting knowledge to the configured Obsidian vault:
- meeting notes
- transcripts
- assistant context notes
- summary notes
- project knowledge files that agents may read or update
- optional dictation words used as speech-recognition phrase hints
It also writes local runtime state outside the vault:
- `%LOCALAPPDATA%\MeetingAssistant\Recordings`: temporary mixed WAV files during active/finalizing runs; stale files are deleted at startup and completed runs delete their temporary audio.
- `%LOCALAPPDATA%\MeetingAssistant\SpeakerIdentity\speaker-identities.db`: local SQLite speaker identity database, aliases, meeting references, and bounded voice snippets.
- `%LOCALAPPDATA%\MeetingAssistant\FunASR\models`: persistent FunASR model and hotword cache when the managed FunASR backend is enabled.
- `%LOCALAPPDATA%\MeetingAssistant\Pyannote\models`: persistent pyannote/Hugging Face/torch cache when pyannote diarization or validation is enabled.
- `%TEMP%\MeetingAssistant\Logs\meeting-assistant.log`: application-owned rotating logs; the snippets starter separately captures stdout/stderr under `%LOCALAPPDATA%`.
The repo intentionally ignores local rule files, local appsettings overrides, models, recordings, runtime caches, build output, and temporary publish folders.
## Configuration
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.
Configuration is normal .NET configuration under `MeetingAssistant`; the checked-in `MeetingAssistant/appsettings.json` is the canonical example. Keep secret values out of source and use the configured environment-variable names instead.
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.
Important settings:
## Spec Workflow
- `Vault`: controls the Obsidian vault root and the relative folders for notes, transcripts, summaries, assistant context, project knowledge, and dictation words.
- `Recording:TranscriptionProvider`: selects `azure-speech`, `funasr`, or `whisper-local`.
- `Recording:TemporaryRecordingsFolder`: controls temporary mixed WAV storage.
- `Recording:InactivitySafeguard`: prompts and then auto-stops forgotten silent recordings without aborting artifacts.
- `LaunchProfiles`: overlays named profile settings onto the default profile; profile hotkeys must be distinct.
- `Automation:RulesPath`: points to the local YAML workflow-rules file. The default `meeting-rules.local.yaml` is ignored by git.
- `CalendarRecordingPrompts`: enables Outlook Classic Teams-start prompts on Windows.
- `Screenshots`: controls the capture hotkey, attachment folder, and optional OCR/vision model.
- `Agent`: configures the OpenAI-compatible summary/project agent endpoint, model, retries, output limits, and compaction.
- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched settings/logs assistant.
This repository is OpenSpec-driven. Before changing behavior, read:
Required or commonly used secrets:
1. `README.md`
2. `openspec/config.yaml`
3. existing specs in `openspec/specs`
4. active change specs in `openspec/changes/*/specs`
- `AZURE_SPEECH_KEY` for Azure Speech live transcription and speaker matching.
- `LITELLM_API_KEY` for summary, OCR, and workflow editor agents when their effective endpoint requires an API key.
- `HF_TOKEN` for pyannote model access when pyannote diarization or validation is enabled.
The initial active change is:
See `docs/meeting-assistant-configuration.md` for the full configuration reference.
```text
openspec/changes/define-meeting-assistant-v1
```
## Integrations
Validate it with:
- **Obsidian vault**: primary durable store for notes, transcripts, summaries, assistant context, project files, and generated links.
- **Outlook Classic on Windows**: optional COM metadata lookup and scheduled Teams-meeting start prompts.
- **Azure AI Speech**: default checked-in ASR path, live diarized conversation transcription, and speaker identity matching.
- **FunASR**: optional WebSocket streaming ASR. When managed backend startup is enabled, the app pulls and runs the configured Docker image as `meeting-assistant-funasr` on port `10095`.
- **Whisper.NET plus pyannote**: optional local Whisper fallback and Docker-backed final diarization.
- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, settings/logs assistant, and retry flows.
- **Docker Desktop or compatible Docker CLI**: required only for managed FunASR and pyannote paths.
```powershell
openspec validate define-meeting-assistant-v1 --strict
```
## Workflow Rules And Agents
Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, and transcript-line writes. They can add/remove attendees, set supported properties, add context, add projects, and rewrite a transcript line before persistence.
The tray menu exposes the settings/logs assistant. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools.
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
## Development And CI
The repo builds against .NET 10 and targets both `net10.0` and `net10.0-windows10.0.19041.0` for the app. Tests target `net10.0`.
The Gitea workflow `.gitea/workflows/pr-push-build-and-test.yaml` runs on pull requests, pushes, and manual dispatch. It restores/builds the Windows target on an Ubuntu runner, installs Wine, downloads a matching Windows .NET SDK, and runs the test project through the Windows dotnet host under Wine.
Before behavior changes:
1. Read `AGENTS.md`, this README, `openspec/config.yaml`, and relevant specs under `openspec/specs`.
2. Add or update the OpenSpec requirement/scenario for the behavior.
3. Add a failing behavior test through the public surface.
4. Implement the smallest passing change.
5. Run the narrowest useful tests, then broader tests when the blast radius justifies it.
6. Run `openspec validate <change-id> --strict` for active spec changes.
Documentation-only README maintenance does not need a new OpenSpec change.
## Operational Notes
- Treat the app as live user work. Check `/recording/status` before restarting, killing processes, deleting runtime files, or running scripts that might take over port `5090`.
- Abort is destructive for the active run: it removes that meeting's note, transcript, assistant context, summary if present, and linked screenshot attachments, and it skips summary generation.
- Too-short or empty normal stops can also delete generated artifacts instead of producing summaries.
- Summary retry links use `Api:PublicBaseUrl`, defaulting to `http://localhost:5090`.
- The workflow reload endpoint reloads configuration for future workflow reads, but a meeting run that already captured options may continue with those captured options.
- Public hostnames and homelab ingress are intentionally out of scope for this repo.
## More Documentation
- `docs/meeting-assistant-configuration.md`: full configuration reference.
- `docs/meeting-workflow-engine.md`: workflow rules engine reference.
- `openspec/specs`: accepted behavioral requirements.
- `openspec/changes/archive`: archived change proposals and designs behind the accepted specs.
+8 -1
View File
@@ -110,7 +110,7 @@ Meeting Assistant SHALL allow a new recording to start after the previous run's
Each run SHALL keep its own transcript session, meeting note path, artifact paths, options, audio buffer, live transcript buffer, and speaker mappings isolated from later runs.
Rapidly-created meeting note, transcript, assistant context, and summary artifact filenames SHALL be distinct.
Generated meeting note, assistant context, transcript, and summary artifact filenames SHALL use the meeting start time with minute precision in the form `yyyyMMdd-HHmm-{type}.md`, where `type` is one of `note`, `context`, `transcript`, or `summary`.
#### Scenario: Hotkey stops recording with buffered audio
- **WHEN** the user presses the hotkey to stop recording while captured audio is still buffered for transcription
@@ -123,6 +123,13 @@ Rapidly-created meeting note, transcript, assistant context, and summary artifac
- **AND** final transcription and summary output from the stopped run use the stopped run's files
- **AND** live transcription buffers from the stopped run are not written to the new run
#### Scenario: Generated artifact names use minute precision
- **WHEN** a recording starts at `2026-05-30T09:30:30`
- **THEN** Meeting Assistant uses `20260530-0930-note.md` for the meeting note filename
- **AND** uses `20260530-0930-transcript.md` for the transcript filename
- **AND** uses `20260530-0930-context.md` for the assistant context filename
- **AND** uses `20260530-0930-summary.md` for the summary filename
### Requirement: Transcripts are written to the configured vault folder
Meeting Assistant SHALL write live transcript text to a markdown file in the configured vault folder.
+13 -1
View File
@@ -48,6 +48,12 @@ Generated artifact notes SHALL link only to the other notes from the same run an
- **WHEN** Meeting Assistant creates a meeting note
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
#### Scenario: Scalar list frontmatter is normalized
- **GIVEN** a meeting note frontmatter field that Meeting Assistant expects as a list is stored as a scalar string
- **WHEN** Meeting Assistant reads the meeting note
- **THEN** it treats the scalar string as a one-item list
- **AND** continues processing the meeting note
#### Scenario: Generated artifacts do not self-reference
- **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter
- **THEN** the artifact frontmatter links to the other run notes
@@ -270,7 +276,7 @@ The tray icon menu SHALL show every configured launch profile when recording can
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
The chat window SHALL be titled `Edit rules and identities`, 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 for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
The chat window SHALL be titled `Edit rules and identities`, 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, MAY replace that line with `Reconnecting...` while retrying a transient agent request, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, 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.
@@ -350,6 +356,12 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru
- **AND** the first model request for that turn is marked as user-initiated
- **AND** the final assistant response replaces the thinking line
#### Scenario: Rules editor displays agent request failures
- **GIVEN** the rules editor chat window is open
- **WHEN** the agent request fails or times out without caller cancellation
- **THEN** the user message remains in the conversation
- **AND** the thinking line is replaced by a visible assistant error message
#### 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