diff --git a/MeetingAssistant.Tests/MeetingNoteStoreTests.cs b/MeetingAssistant.Tests/MeetingNoteStoreTests.cs index a5051f0..8f76a25 100644 --- a/MeetingAssistant.Tests/MeetingNoteStoreTests.cs +++ b/MeetingAssistant.Tests/MeetingNoteStoreTests.cs @@ -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() { diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index 3dc9fe1..44f9753 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -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.Instance); + NullLogger.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); @@ -1101,6 +1101,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 +1114,16 @@ public sealed class RecordingCoordinatorTests summaryPipeline, Options.Create(launchProfiles.GetRequiredProfile(null).Options), NullLogger.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 +2017,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.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() { @@ -2159,6 +2181,14 @@ public sealed class RecordingCoordinatorTests return Task.FromResult(new TranscriptSession(transcriptPath)); } + public Task CreateSessionAsync( + MeetingAssistantOptions options, + DateTimeOffset startedAt, + CancellationToken cancellationToken) + { + return CreateSessionAsync(cancellationToken); + } + public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) { segments.Add(ParseTranscriptLine(line)); @@ -2226,6 +2256,14 @@ public sealed class RecordingCoordinatorTests return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md")); } + public Task 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))); @@ -2887,10 +2925,13 @@ public sealed class RecordingCoordinatorTests public Task 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 +2972,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); } diff --git a/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs b/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs index 5cfdc44..835764a 100644 --- a/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs +++ b/MeetingAssistant/MeetingNotes/MarkdownMeetingNoteStore.cs @@ -35,7 +35,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 +108,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); diff --git a/MeetingAssistant/MeetingNotes/MeetingArtifactFileNames.cs b/MeetingAssistant/MeetingNotes/MeetingArtifactFileNames.cs new file mode 100644 index 0000000..f7d315d --- /dev/null +++ b/MeetingAssistant/MeetingNotes/MeetingArtifactFileNames.cs @@ -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"; + } +} diff --git a/MeetingAssistant/Recording/ITranscriptStore.cs b/MeetingAssistant/Recording/ITranscriptStore.cs index dfb5928..98dfe20 100644 --- a/MeetingAssistant/Recording/ITranscriptStore.cs +++ b/MeetingAssistant/Recording/ITranscriptStore.cs @@ -9,10 +9,8 @@ public interface ITranscriptStore Task CreateSessionAsync( MeetingAssistantOptions options, - CancellationToken cancellationToken) - { - return CreateSessionAsync(cancellationToken); - } + DateTimeOffset startedAt, + CancellationToken cancellationToken); Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken) { diff --git a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs index 2d5a07b..8a436d2 100644 --- a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs +++ b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs @@ -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) diff --git a/MeetingAssistant/Recording/VaultTranscriptStore.cs b/MeetingAssistant/Recording/VaultTranscriptStore.cs index a99b223..2968999 100644 --- a/MeetingAssistant/Recording/VaultTranscriptStore.cs +++ b/MeetingAssistant/Recording/VaultTranscriptStore.cs @@ -17,17 +17,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore public async Task CreateSessionAsync(CancellationToken cancellationToken) { - return await CreateSessionAsync(options, cancellationToken); + return await CreateSessionAsync(options, DateTimeOffset.Now, cancellationToken); } public async Task 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); diff --git a/openspec/specs/meeting-recording/spec.md b/openspec/specs/meeting-recording/spec.md index fb59b47..72910e4 100644 --- a/openspec/specs/meeting-recording/spec.md +++ b/openspec/specs/meeting-recording/spec.md @@ -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.