Public Access
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5689ab95 | ||
|
|
740f93f185 | ||
|
|
9d0f6a1295 | ||
|
|
689df3dbcb | ||
|
|
626640e26b |
@@ -63,6 +63,13 @@ Do the refactoring pass in these distinct areas, first start a subagent for each
|
||||
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.
|
||||
3. Check whether the implementation can be made simpler under KISS. Remove accidental abstractions, reduce branching, clarify names, and prefer the smallest structure that still supports the tested behavior and current spec.
|
||||
|
||||
Do not say subagents are unavailable unless:
|
||||
|
||||
1. tool discovery for "subagent", "delegate", and "agent" found no callable tool, or
|
||||
2. spawning a subagent returned a concrete tool error.
|
||||
|
||||
In either case, report the exact discovery result or error.
|
||||
|
||||
Preserve behavior during this pass and run the relevant tests again afterward.
|
||||
|
||||
For Meeting Assistant, prefer verification through:
|
||||
|
||||
@@ -6,40 +6,129 @@ namespace MeetingAssistant.Tests;
|
||||
public sealed class AudioMixingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceMixesMicrophoneAndSystemAudioIntoOnePcmStream()
|
||||
public async Task CompositeAudioSourceCleansMicrophoneBeforeAddingSystemAudio()
|
||||
{
|
||||
var microphone = new FixedAudioSource(Pcm16(10_000));
|
||||
var system = new FixedAudioSource(Pcm16(20_000));
|
||||
var source = new CompositeMeetingAudioSource(
|
||||
microphone,
|
||||
system,
|
||||
NullLogger<CompositeMeetingAudioSource>.Instance);
|
||||
var microphone = new FixedAudioSource(Pcm16(12_000));
|
||||
var system = new FixedAudioSource(Pcm16(10_000));
|
||||
var source = CreateSource(microphone, system, new FixedEchoCanceller(Pcm16(2_000)));
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
Assert.Single(chunks);
|
||||
Assert.Equal(30_000, BitConverter.ToInt16(chunks[0].Pcm));
|
||||
Assert.Equal(12_000, BitConverter.ToInt16(chunks[0].Pcm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceClampsMixedSamples()
|
||||
public async Task CompositeAudioSourceClampsAfterConfiguredGain()
|
||||
{
|
||||
var microphone = new FixedAudioSource(Pcm16(30_000));
|
||||
var system = new FixedAudioSource(Pcm16(10_000));
|
||||
var source = new CompositeMeetingAudioSource(
|
||||
microphone,
|
||||
system,
|
||||
NullLogger<CompositeMeetingAudioSource>.Instance);
|
||||
var source = CreateSource(microphone, system);
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
Assert.Equal(short.MaxValue, BitConverter.ToInt16(chunks[0].Pcm));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AudioChunk>> ReadChunks(IMeetingAudioSource source)
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceUsesRunOptionsForChildSourcesAndGains()
|
||||
{
|
||||
var microphone = new OptionsCapturingAudioSource(Pcm16(10_000));
|
||||
var system = new OptionsCapturingAudioSource(Pcm16(10_000));
|
||||
var source = CreateSource(microphone, system);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
SampleRate = 24000,
|
||||
Channels = 1,
|
||||
MicrophoneMixGain = 0.5,
|
||||
SystemAudioMixGain = 0.25
|
||||
}
|
||||
};
|
||||
|
||||
var chunks = await ReadChunks(source, options);
|
||||
|
||||
var chunk = Assert.Single(chunks);
|
||||
Assert.Equal(7_500, BitConverter.ToInt16(chunk.Pcm));
|
||||
Assert.Same(options, microphone.CapturedOptions);
|
||||
Assert.Same(options, system.CapturedOptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceWaitsForMatchingStreamsBeforeMixing()
|
||||
{
|
||||
var microphone = new DelayedAudioSource(TimeSpan.Zero, Pcm16(2_000));
|
||||
var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(100), Pcm16(10_000));
|
||||
var source = CreateSource(microphone, system);
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
var chunk = Assert.Single(chunks);
|
||||
Assert.Equal(12_000, BitConverter.ToInt16(chunk.Pcm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceEmitsMicrophoneWithSilentSystemWhenSystemAudioIsQuiet()
|
||||
{
|
||||
var microphone = new DelayedAudioSource(TimeSpan.Zero, Pcm16(2_000));
|
||||
var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(500), Pcm16(10_000));
|
||||
var source = CreateSource(microphone, system);
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
Assert.Collection(
|
||||
chunks,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal(2_000, BitConverter.ToInt16(first.Pcm));
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal(10_000, BitConverter.ToInt16(second.Pcm));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeAudioSourceFlushesContinuouslyArrivingMicrophoneBeforeLateSystemAudio()
|
||||
{
|
||||
var microphone = new RepeatedAudioSource(50, TimeSpan.FromMilliseconds(10), Pcm16(2_000));
|
||||
var system = new DelayedAudioSource(TimeSpan.FromMilliseconds(750), Pcm16(10_000));
|
||||
var source = CreateSource(microphone, system);
|
||||
|
||||
var chunks = await ReadChunks(source);
|
||||
|
||||
Assert.NotEmpty(chunks);
|
||||
Assert.Equal(2_000, BitConverter.ToInt16(chunks[0].Pcm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdaptiveEchoCancellerReducesEchoFromMicrophoneSignal()
|
||||
{
|
||||
var canceller = new AdaptiveFilterAcousticEchoCanceller();
|
||||
var system = Enumerable.Repeat(Pcm16(8_000), 80).SelectMany(bytes => bytes).ToArray();
|
||||
var microphone = Enumerable.Repeat(Pcm16(8_000), 80).SelectMany(bytes => bytes).ToArray();
|
||||
|
||||
AudioChunk cleaned = new(microphone, 16000, 1);
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
cleaned = canceller.CleanMicrophone(
|
||||
new AudioChunk(microphone, 16000, 1),
|
||||
new AudioChunk(system, 16000, 1));
|
||||
}
|
||||
|
||||
var average = Enumerable.Range(0, cleaned.Pcm.Length / sizeof(short))
|
||||
.Select(index => (double)Math.Abs(BitConverter.ToInt16(cleaned.Pcm, index * sizeof(short))))
|
||||
.Average();
|
||||
Assert.True(average < 1_000);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AudioChunk>> ReadChunks(
|
||||
IMeetingAudioSource source,
|
||||
MeetingAssistantOptions? options = null)
|
||||
{
|
||||
var chunks = new List<AudioChunk>();
|
||||
await foreach (var chunk in source.CaptureAsync(CancellationToken.None))
|
||||
await foreach (var chunk in source.CaptureAsync(options ?? new MeetingAssistantOptions(), CancellationToken.None))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
@@ -52,6 +141,18 @@ public sealed class AudioMixingTests
|
||||
return BitConverter.GetBytes(sample);
|
||||
}
|
||||
|
||||
private static CompositeMeetingAudioSource CreateSource(
|
||||
IMeetingAudioSource microphone,
|
||||
IMeetingAudioSource system,
|
||||
IAcousticEchoCanceller? echoCanceller = null)
|
||||
{
|
||||
return new CompositeMeetingAudioSource(
|
||||
microphone,
|
||||
system,
|
||||
new FixedEchoCancellerFactory(echoCanceller ?? new NoopAcousticEchoCanceller()),
|
||||
NullLogger<CompositeMeetingAudioSource>.Instance);
|
||||
}
|
||||
|
||||
private sealed class FixedAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly AudioChunk chunk;
|
||||
@@ -68,4 +169,115 @@ public sealed class AudioMixingTests
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DelayedAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly TimeSpan delay;
|
||||
private readonly AudioChunk chunk;
|
||||
|
||||
public DelayedAudioSource(TimeSpan delay, byte[] pcm)
|
||||
{
|
||||
this.delay = delay;
|
||||
chunk = new AudioChunk(pcm, 16000, 1);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RepeatedAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly int count;
|
||||
private readonly TimeSpan interval;
|
||||
private readonly AudioChunk chunk;
|
||||
|
||||
public RepeatedAudioSource(int count, TimeSpan interval, byte[] pcm)
|
||||
{
|
||||
this.count = count;
|
||||
this.interval = interval;
|
||||
chunk = new AudioChunk(pcm, 16000, 1);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
if (interval > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken);
|
||||
}
|
||||
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OptionsCapturingAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly AudioChunk chunk;
|
||||
|
||||
public OptionsCapturingAudioSource(byte[] pcm)
|
||||
{
|
||||
chunk = new AudioChunk(pcm, 16000, 1);
|
||||
}
|
||||
|
||||
public MeetingAssistantOptions? CapturedOptions { get; private set; }
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
CapturedOptions = options;
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedEchoCanceller : IAcousticEchoCanceller
|
||||
{
|
||||
private readonly byte[] cleanedPcm;
|
||||
|
||||
public FixedEchoCanceller(byte[] cleanedPcm)
|
||||
{
|
||||
this.cleanedPcm = cleanedPcm;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
}
|
||||
|
||||
public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio)
|
||||
{
|
||||
return microphone with { Pcm = cleanedPcm };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedEchoCancellerFactory : IAcousticEchoCancellerFactory
|
||||
{
|
||||
private readonly IAcousticEchoCanceller echoCanceller;
|
||||
|
||||
public FixedEchoCancellerFactory(IAcousticEchoCanceller echoCanceller)
|
||||
{
|
||||
this.echoCanceller = echoCanceller;
|
||||
}
|
||||
|
||||
public IAcousticEchoCanceller Create()
|
||||
{
|
||||
return echoCanceller;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class PastProjectMeetingSummaryToolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsReturnsPagedSummariesForCurrentMeetingProjects()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "current-summary.md"), "Current", "2026-05-29T09:00:00+02:00", "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x-new.md"), "Project X New", "2026-05-28T09:00:00+02:00", "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-y.md"), "Project Y", "2026-05-27T09:00:00+02:00", "Project Y");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-26T09:00:00+02:00", "Project Z");
|
||||
await File.WriteAllTextAsync(Path.Combine(summariesRoot, "not-a-summary.md"), "No frontmatter");
|
||||
artifacts = artifacts with { SummaryPath = Path.Combine(summariesRoot, "current-summary.md") };
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var firstPage = await tools.ListPastProjectMeetings(page: 1, page_size: 1);
|
||||
var secondPage = await tools.ListPastProjectMeetings(page: 2, page_size: 1);
|
||||
|
||||
Assert.Contains("page 1/2", firstPage);
|
||||
Assert.Contains("project-x-new.md | 2026-05-28T09:00:00.0000000+02:00 | Project X New | projects: Project X", firstPage);
|
||||
Assert.DoesNotContain("project-y.md", firstPage);
|
||||
Assert.Contains("page 2/2", secondPage);
|
||||
Assert.Contains("project-y.md", secondPage);
|
||||
Assert.DoesNotContain("current-summary.md", firstPage + secondPage);
|
||||
Assert.DoesNotContain("project-z.md", firstPage + secondPage);
|
||||
Assert.DoesNotContain("not-a-summary.md", firstPage + secondPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsFiltersAndRejectsOutOfScopeProjects()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-y.md"), "Project Y", "2026-05-27T09:00:00+02:00", "Project Y");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var projectX = await tools.ListPastProjectMeetings(["Project X"]);
|
||||
var refused = await tools.ListPastProjectMeetings(["Project Z"]);
|
||||
|
||||
Assert.Contains("project-x.md", projectX);
|
||||
Assert.DoesNotContain("project-y.md", projectX);
|
||||
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsRejectsOutOfScopeProjectsWhenMeetingHasNoProjects()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.Combine(root, "Meetings", "Summaries"));
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath);
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var refused = await tools.ListPastProjectMeetings(["Project Z"]);
|
||||
|
||||
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPastProjectMeetingSummaryReadsOnlyScopedPastSummaryFiles()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(
|
||||
Path.Combine(summariesRoot, "project-x.md"),
|
||||
"Project X",
|
||||
"2026-05-28T09:00:00+02:00",
|
||||
"Project X",
|
||||
"line one\nline two\nline three");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-27T09:00:00+02:00", "Project Z");
|
||||
await WriteSummaryAsync(artifacts.SummaryPath, "Current", "2026-05-29T09:00:00+02:00", "Project X");
|
||||
var outside = Path.Combine(root, "outside.md");
|
||||
await File.WriteAllTextAsync(outside, "outside");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
Assert.Equal("line two\nline three", await tools.ReadPastProjectMeetingSummary("project-x.md", from: 9, to: 99));
|
||||
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary("project-z.md"));
|
||||
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary("current-summary.md"));
|
||||
Assert.StartsWith("Refused:", await tools.ReadPastProjectMeetingSummary(outside));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPastProjectMeetingSummaryAcceptsSearchResultPastMeetingPrefix()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(
|
||||
Path.Combine(summariesRoot, "project-x.md"),
|
||||
"Project X",
|
||||
"2026-05-28T09:00:00+02:00",
|
||||
"Project X",
|
||||
"searchable past meeting");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var search = await tools.Search("searchable", ["Project X"]);
|
||||
var content = await tools.ReadPastProjectMeetingSummary("past-meetings/project-x.md");
|
||||
|
||||
Assert.Contains("past-meetings/project-x.md:", search);
|
||||
Assert.Contains("searchable past meeting", search);
|
||||
Assert.Contains("searchable past meeting", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsIgnoresMalformedSummaryFrontmatter()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(summariesRoot, "broken.md"),
|
||||
"""
|
||||
---
|
||||
projects: [not valid
|
||||
---
|
||||
|
||||
broken
|
||||
""");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var result = await tools.ListPastProjectMeetings();
|
||||
|
||||
Assert.Contains("project-x.md", result);
|
||||
Assert.DoesNotContain("broken.md", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPastProjectMeetingsClampsRequestedPageToLastPage()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var result = await tools.ListPastProjectMeetings(page: 99, page_size: 1);
|
||||
|
||||
Assert.Contains("page 1/1", result);
|
||||
Assert.Contains("project-x.md", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIncludesProjectFilesAndScopedPastMeetingSummaries()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
var summariesRoot = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Project X"));
|
||||
Directory.CreateDirectory(Path.Combine(projectsRoot, "Project Y"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X", "Project Y");
|
||||
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Project X", "notes.md"), "alpha project file");
|
||||
await File.WriteAllTextAsync(Path.Combine(projectsRoot, "Project Y", "notes.md"), "alpha other project");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-x.md"), "Project X", "2026-05-28T09:00:00+02:00", "Project X", "alpha past meeting");
|
||||
await WriteSummaryAsync(Path.Combine(summariesRoot, "project-z.md"), "Project Z", "2026-05-27T09:00:00+02:00", "Project Z", "alpha hidden meeting");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var projectX = await tools.Search("alpha", ["Project X"]);
|
||||
var refused = await tools.Search("alpha", ["Project Z"]);
|
||||
|
||||
Assert.Contains("notes.md:1 alpha project file", projectX);
|
||||
Assert.Contains("past-meetings/project-x.md:", projectX);
|
||||
Assert.DoesNotContain("Project Y/notes.md", projectX);
|
||||
Assert.DoesNotContain("project-z.md", projectX);
|
||||
Assert.Equal("Refused: project(s) not assigned to current meeting: Project Z.", refused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchReportsInvalidPatternInsteadOfNoMatches()
|
||||
{
|
||||
var root = CreateRoot();
|
||||
var artifacts = CreateArtifacts(root);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, "Project X");
|
||||
var tools = CreateTools(artifacts, root);
|
||||
|
||||
var result = await tools.Search("[", ["Project X"]);
|
||||
|
||||
Assert.StartsWith("Search failed: invalid .NET regular expression:", result);
|
||||
}
|
||||
|
||||
private static string CreateRoot()
|
||||
{
|
||||
return Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
private static MeetingSessionArtifacts CreateArtifacts(string root)
|
||||
{
|
||||
return new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "current-summary.md"));
|
||||
}
|
||||
|
||||
private static MeetingSummaryTools CreateTools(MeetingSessionArtifacts artifacts, string root)
|
||||
{
|
||||
return new MeetingSummaryTools(
|
||||
artifacts,
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
ProjectsFolder = Path.Combine(root, "Projects"),
|
||||
SummariesFolder = Path.Combine(root, "Meetings", "Summaries")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Task WriteMeetingNoteAsync(string path, params string[] projects)
|
||||
{
|
||||
var projectLines = string.Join('\n', projects.Select(project => $"- {project}"));
|
||||
return File.WriteAllTextAsync(
|
||||
path,
|
||||
$"""
|
||||
---
|
||||
title: Current Meeting
|
||||
projects:
|
||||
{projectLines}
|
||||
---
|
||||
|
||||
Current notes.
|
||||
""");
|
||||
}
|
||||
|
||||
private static Task WriteSummaryAsync(
|
||||
string path,
|
||||
string title,
|
||||
string startTime,
|
||||
string project,
|
||||
string body = "summary body")
|
||||
{
|
||||
return File.WriteAllTextAsync(
|
||||
path,
|
||||
$$"""
|
||||
---
|
||||
title: {{title}}
|
||||
start_time: "{{startTime}}"
|
||||
projects:
|
||||
- {{project}}
|
||||
---
|
||||
|
||||
{{body}}
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Diagnostics;
|
||||
using MeetingAssistant.Transcription;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ProcessCommandRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateStartInfoRunsCliProcessesWithoutVisibleWindows()
|
||||
{
|
||||
var startInfo = ProcessCommandRunner.CreateStartInfo(
|
||||
"docker",
|
||||
["run", "--rm", "meeting-assistant-pyannote:local"],
|
||||
new Dictionary<string, string> { ["HF_TOKEN"] = "token" });
|
||||
|
||||
Assert.False(startInfo.UseShellExecute);
|
||||
Assert.True(startInfo.CreateNoWindow);
|
||||
Assert.Equal(ProcessWindowStyle.Hidden, startInfo.WindowStyle);
|
||||
Assert.True(startInfo.RedirectStandardOutput);
|
||||
Assert.True(startInfo.RedirectStandardError);
|
||||
Assert.Equal("docker", startInfo.FileName);
|
||||
Assert.Equal(["run", "--rm", "meeting-assistant-pyannote:local"], startInfo.ArgumentList);
|
||||
Assert.Equal("token", startInfo.Environment["HF_TOKEN"]);
|
||||
}
|
||||
}
|
||||
@@ -1613,6 +1613,37 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.True(new FileInfo(session.AudioPath).Length > 44);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemporaryRecordedAudioStoreDoesNotWriteDiagnosticSidecars()
|
||||
{
|
||||
var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new TemporaryRecordedAudioStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
SampleRate = 16000,
|
||||
Channels = 1,
|
||||
TemporaryRecordingsFolder = recordingFolder
|
||||
}
|
||||
}),
|
||||
NullLogger<TemporaryRecordedAudioStore>.Instance);
|
||||
|
||||
await using var session = await store.CreateSessionAsync(CancellationToken.None);
|
||||
await session.AppendAsync(new AudioChunk([3, 0], 16000, 1), CancellationToken.None);
|
||||
await session.CompleteAsync(CancellationToken.None);
|
||||
|
||||
var microphonePath = Path.Combine(
|
||||
recordingFolder,
|
||||
$"{Path.GetFileNameWithoutExtension(session.AudioPath)}-microphone.wav");
|
||||
var systemPath = Path.Combine(
|
||||
recordingFolder,
|
||||
$"{Path.GetFileNameWithoutExtension(session.AudioPath)}-system.wav");
|
||||
Assert.True(new FileInfo(session.AudioPath).Length > 44);
|
||||
Assert.False(File.Exists(microphonePath));
|
||||
Assert.False(File.Exists(systemPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemporaryRecordedAudioStoreDeletesStaleRecordingsOnStartup()
|
||||
{
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
<MicrosoftSpeechVersion>1.50.0</MicrosoftSpeechVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<OutputType>WinExe</OutputType>
|
||||
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -19,7 +21,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.8.0" />
|
||||
<PackageReference Include="DiffPlex" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.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="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="NCalcSync" Version="5.12.0" />
|
||||
@@ -41,6 +44,11 @@
|
||||
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\aec_v1.fpie" Link="runtimes\win-x64\native\MASmodels\aec_v1.fpie" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\pns_avg4.fpie" Link="runtimes\win-x64\native\MASmodels\pns_avg4.fpie" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MeetingAssistantRootConfig Include="$(MSBuildProjectDirectory)\appsettings*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -114,6 +114,10 @@ public sealed class RecordingOptions
|
||||
|
||||
public int Channels { get; set; } = 1;
|
||||
|
||||
public double MicrophoneMixGain { get; set; } = 1;
|
||||
|
||||
public double SystemAudioMixGain { get; set; } = 1;
|
||||
|
||||
public TimeSpan StopProcessingTimeout { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
public TimeSpan MetadataLookupTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
@@ -18,9 +18,11 @@ builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunch
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||
builder.Services.AddSingleton<SystemAudioSource>();
|
||||
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
||||
builder.Services.AddSingleton<IMeetingAudioSource>(services => new CompositeMeetingAudioSource(
|
||||
services.GetRequiredService<MicrophoneAudioSource>(),
|
||||
services.GetRequiredService<SystemAudioSource>(),
|
||||
services.GetRequiredService<IAcousticEchoCancellerFactory>(),
|
||||
services.GetRequiredService<ILogger<CompositeMeetingAudioSource>>()));
|
||||
builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMetadataProvider>();
|
||||
#else
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MeetingAssistant.Tests")]
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IAcousticEchoCanceller
|
||||
{
|
||||
void Reset();
|
||||
|
||||
AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio);
|
||||
}
|
||||
|
||||
public interface IAcousticEchoCancellerFactory
|
||||
{
|
||||
IAcousticEchoCanceller Create();
|
||||
}
|
||||
|
||||
public sealed class AdaptiveFilterAcousticEchoCancellerFactory : IAcousticEchoCancellerFactory
|
||||
{
|
||||
public IAcousticEchoCanceller Create()
|
||||
{
|
||||
return new AdaptiveFilterAcousticEchoCanceller();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoopAcousticEchoCanceller : IAcousticEchoCanceller
|
||||
{
|
||||
public void Reset()
|
||||
{
|
||||
}
|
||||
|
||||
public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio)
|
||||
{
|
||||
return microphone;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdaptiveFilterAcousticEchoCanceller : IAcousticEchoCanceller
|
||||
{
|
||||
private const int DefaultFilterLength = 256;
|
||||
private const double DefaultAdaptationStep = 0.25;
|
||||
private const double Epsilon = 1.0;
|
||||
private readonly double[] coefficients;
|
||||
private readonly double[] renderHistory;
|
||||
private int historyPosition;
|
||||
|
||||
public AdaptiveFilterAcousticEchoCanceller()
|
||||
: this(DefaultFilterLength)
|
||||
{
|
||||
}
|
||||
|
||||
public AdaptiveFilterAcousticEchoCanceller(int filterLength)
|
||||
{
|
||||
var effectiveFilterLength = Math.Max(16, filterLength);
|
||||
coefficients = new double[effectiveFilterLength];
|
||||
renderHistory = new double[effectiveFilterLength];
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Array.Clear(coefficients);
|
||||
Array.Clear(renderHistory);
|
||||
historyPosition = 0;
|
||||
}
|
||||
|
||||
public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio)
|
||||
{
|
||||
if (microphone.Pcm.Length == 0 ||
|
||||
systemAudio.Pcm.Length == 0 ||
|
||||
microphone.SampleRate != systemAudio.SampleRate ||
|
||||
microphone.Channels != 1 ||
|
||||
systemAudio.Channels != 1)
|
||||
{
|
||||
return microphone;
|
||||
}
|
||||
|
||||
var byteCount = Math.Min(microphone.Pcm.Length, systemAudio.Pcm.Length);
|
||||
byteCount -= byteCount % sizeof(short);
|
||||
if (byteCount <= 0)
|
||||
{
|
||||
return microphone;
|
||||
}
|
||||
|
||||
var cleaned = new byte[microphone.Pcm.Length];
|
||||
for (var byteIndex = 0; byteIndex < byteCount; byteIndex += sizeof(short))
|
||||
{
|
||||
var render = BitConverter.ToInt16(systemAudio.Pcm, byteIndex) / 32768.0;
|
||||
var capture = BitConverter.ToInt16(microphone.Pcm, byteIndex) / 32768.0;
|
||||
renderHistory[historyPosition] = render;
|
||||
|
||||
var estimatedEcho = 0.0;
|
||||
var energy = Epsilon;
|
||||
for (var tap = 0; tap < coefficients.Length; tap++)
|
||||
{
|
||||
var sample = renderHistory[HistoryIndex(tap)];
|
||||
estimatedEcho += coefficients[tap] * sample;
|
||||
energy += sample * sample;
|
||||
}
|
||||
|
||||
var error = capture - estimatedEcho;
|
||||
var updateScale = DefaultAdaptationStep * error / energy;
|
||||
for (var tap = 0; tap < coefficients.Length; tap++)
|
||||
{
|
||||
coefficients[tap] += updateScale * renderHistory[HistoryIndex(tap)];
|
||||
}
|
||||
|
||||
WriteSample(cleaned, byteIndex, error);
|
||||
historyPosition = historyPosition == 0 ? renderHistory.Length - 1 : historyPosition - 1;
|
||||
}
|
||||
|
||||
if (byteCount < microphone.Pcm.Length)
|
||||
{
|
||||
Buffer.BlockCopy(microphone.Pcm, byteCount, cleaned, byteCount, microphone.Pcm.Length - byteCount);
|
||||
}
|
||||
|
||||
return microphone with { Pcm = cleaned };
|
||||
}
|
||||
|
||||
private int HistoryIndex(int offset)
|
||||
{
|
||||
var index = historyPosition + offset;
|
||||
return index >= renderHistory.Length ? index - renderHistory.Length : index;
|
||||
}
|
||||
|
||||
private static void WriteSample(byte[] target, int byteIndex, double sample)
|
||||
{
|
||||
var scaled = Math.Clamp(
|
||||
(int)Math.Round(sample * 32768.0),
|
||||
short.MinValue,
|
||||
short.MaxValue);
|
||||
BitConverter.GetBytes((short)scaled).CopyTo(target, byteIndex);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed record AudioChunk(byte[] Pcm, int SampleRate, int Channels)
|
||||
public sealed record AudioChunk(
|
||||
byte[] Pcm,
|
||||
int SampleRate,
|
||||
int Channels)
|
||||
{
|
||||
public TimeSpan Duration
|
||||
{
|
||||
|
||||
@@ -5,26 +5,44 @@ namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private static readonly TimeSpan SourceSkewTimeout = TimeSpan.FromMilliseconds(250);
|
||||
private readonly IMeetingAudioSource microphone;
|
||||
private readonly IMeetingAudioSource systemAudio;
|
||||
private readonly IAcousticEchoCancellerFactory echoCancellerFactory;
|
||||
private readonly ILogger<CompositeMeetingAudioSource> logger;
|
||||
|
||||
public CompositeMeetingAudioSource(
|
||||
IMeetingAudioSource microphone,
|
||||
IMeetingAudioSource systemAudio,
|
||||
IAcousticEchoCancellerFactory echoCancellerFactory,
|
||||
ILogger<CompositeMeetingAudioSource> logger)
|
||||
{
|
||||
this.microphone = microphone;
|
||||
this.systemAudio = systemAudio;
|
||||
this.echoCancellerFactory = echoCancellerFactory;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in CaptureAsync(new MeetingAssistantOptions(), cancellationToken))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var echoCanceller = echoCancellerFactory.Create();
|
||||
echoCanceller.Reset();
|
||||
var microphoneGain = NormalizeGain(options.Recording.MicrophoneMixGain);
|
||||
var systemAudioGain = NormalizeGain(options.Recording.SystemAudioMixGain);
|
||||
var channel = Channel.CreateUnbounded<SourceChunk>();
|
||||
var microphoneTask = PumpAsync(AudioSourceKind.Microphone, microphone, channel.Writer, cancellationToken);
|
||||
var systemTask = PumpAsync(AudioSourceKind.System, systemAudio, channel.Writer, cancellationToken);
|
||||
var microphoneTask = PumpAsync(AudioSourceKind.Microphone, microphone, options, channel.Writer, cancellationToken);
|
||||
var systemTask = PumpAsync(AudioSourceKind.System, systemAudio, options, channel.Writer, cancellationToken);
|
||||
_ = Task.WhenAll(microphoneTask, systemTask)
|
||||
.ContinueWith(
|
||||
task =>
|
||||
@@ -41,8 +59,8 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
AudioChunk? pendingMicrophone = null;
|
||||
AudioChunk? pendingSystem = null;
|
||||
var pendingMicrophone = new PendingAudioStream();
|
||||
var pendingSystem = new PendingAudioStream();
|
||||
|
||||
await foreach (var sourceChunk in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
@@ -52,22 +70,32 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
sourceChunk.Kind);
|
||||
if (sourceChunk.Kind == AudioSourceKind.Microphone)
|
||||
{
|
||||
pendingMicrophone = sourceChunk.Chunk;
|
||||
pendingMicrophone.Enqueue(sourceChunk.Chunk);
|
||||
}
|
||||
else
|
||||
{
|
||||
pendingSystem = sourceChunk.Chunk;
|
||||
pendingSystem.Enqueue(sourceChunk.Chunk);
|
||||
}
|
||||
|
||||
if (pendingMicrophone is not null && pendingSystem is not null)
|
||||
while (TryReadAlignedPair(pendingMicrophone, pendingSystem, out var microphoneChunk, out var systemChunk))
|
||||
{
|
||||
yield return Mix(pendingMicrophone, pendingSystem);
|
||||
pendingMicrophone = null;
|
||||
pendingSystem = null;
|
||||
continue;
|
||||
yield return CleanAndMix(microphoneChunk, systemChunk, echoCanceller, microphoneGain, systemAudioGain);
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||
if (ShouldFlushTimedOutRemainder(pendingMicrophone, pendingSystem))
|
||||
{
|
||||
while (TryReadRemainder(pendingMicrophone, pendingSystem, out var bufferedMicrophoneChunk, out var bufferedSystemChunk))
|
||||
{
|
||||
yield return CleanAndMix(
|
||||
bufferedMicrophoneChunk,
|
||||
bufferedSystemChunk,
|
||||
echoCanceller,
|
||||
microphoneGain,
|
||||
systemAudioGain);
|
||||
}
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(SourceSkewTimeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
try
|
||||
{
|
||||
@@ -80,39 +108,33 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
{
|
||||
}
|
||||
|
||||
if (pendingMicrophone is not null)
|
||||
while (TryReadRemainder(pendingMicrophone, pendingSystem, out var timeoutMicrophoneChunk, out var timeoutSystemChunk))
|
||||
{
|
||||
yield return pendingMicrophone;
|
||||
pendingMicrophone = null;
|
||||
}
|
||||
|
||||
if (pendingSystem is not null)
|
||||
{
|
||||
yield return pendingSystem;
|
||||
pendingSystem = null;
|
||||
yield return CleanAndMix(
|
||||
timeoutMicrophoneChunk,
|
||||
timeoutSystemChunk,
|
||||
echoCanceller,
|
||||
microphoneGain,
|
||||
systemAudioGain);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMicrophone is not null)
|
||||
while (TryReadRemainder(pendingMicrophone, pendingSystem, out var microphoneChunk, out var systemChunk))
|
||||
{
|
||||
yield return pendingMicrophone;
|
||||
}
|
||||
|
||||
if (pendingSystem is not null)
|
||||
{
|
||||
yield return pendingSystem;
|
||||
yield return CleanAndMix(microphoneChunk, systemChunk, echoCanceller, microphoneGain, systemAudioGain);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(
|
||||
AudioSourceKind kind,
|
||||
IMeetingAudioSource source,
|
||||
MeetingAssistantOptions options,
|
||||
ChannelWriter<SourceChunk> writer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in source.CaptureAsync(cancellationToken))
|
||||
await foreach (var chunk in source.CaptureAsync(options, cancellationToken))
|
||||
{
|
||||
await writer.WriteAsync(new SourceChunk(kind, chunk), cancellationToken);
|
||||
}
|
||||
@@ -128,32 +150,49 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
}
|
||||
}
|
||||
|
||||
private AudioChunk Mix(AudioChunk left, AudioChunk right)
|
||||
private AudioChunk CleanAndMix(
|
||||
AudioChunk microphoneChunk,
|
||||
AudioChunk systemChunk,
|
||||
IAcousticEchoCanceller echoCanceller,
|
||||
double microphoneGain,
|
||||
double systemAudioGain)
|
||||
{
|
||||
if (left.SampleRate != right.SampleRate || left.Channels != right.Channels)
|
||||
if (microphoneChunk.SampleRate != systemChunk.SampleRate || microphoneChunk.Channels != systemChunk.Channels)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Cannot mix audio chunks with different formats. Using microphone format {MicrophoneSampleRate}/{MicrophoneChannels} and dropping system chunk format {SystemSampleRate}/{SystemChannels}.",
|
||||
left.SampleRate,
|
||||
left.Channels,
|
||||
right.SampleRate,
|
||||
right.Channels);
|
||||
return left;
|
||||
microphoneChunk.SampleRate,
|
||||
microphoneChunk.Channels,
|
||||
systemChunk.SampleRate,
|
||||
systemChunk.Channels);
|
||||
return microphoneChunk;
|
||||
}
|
||||
|
||||
var mixed = new byte[Math.Max(left.Pcm.Length, right.Pcm.Length)];
|
||||
var cleanedMicrophone = echoCanceller.CleanMicrophone(microphoneChunk, systemChunk);
|
||||
var mixed = new byte[Math.Max(cleanedMicrophone.Pcm.Length, systemChunk.Pcm.Length)];
|
||||
var sampleCount = mixed.Length / sizeof(short);
|
||||
|
||||
for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++)
|
||||
{
|
||||
var byteIndex = sampleIndex * sizeof(short);
|
||||
var leftSample = ReadSample(left.Pcm, byteIndex);
|
||||
var rightSample = ReadSample(right.Pcm, byteIndex);
|
||||
var sample = Math.Clamp(leftSample + rightSample, short.MinValue, short.MaxValue);
|
||||
var leftSample = ReadSample(cleanedMicrophone.Pcm, byteIndex);
|
||||
var rightSample = ReadSample(systemChunk.Pcm, byteIndex);
|
||||
var sample = Math.Clamp(
|
||||
(int)Math.Round(leftSample * microphoneGain + rightSample * systemAudioGain),
|
||||
short.MinValue,
|
||||
short.MaxValue);
|
||||
BitConverter.GetBytes((short)sample).CopyTo(mixed, byteIndex);
|
||||
}
|
||||
|
||||
return new AudioChunk(mixed, left.SampleRate, left.Channels);
|
||||
return new AudioChunk(
|
||||
mixed,
|
||||
cleanedMicrophone.SampleRate,
|
||||
cleanedMicrophone.Channels);
|
||||
}
|
||||
|
||||
private static double NormalizeGain(double gain)
|
||||
{
|
||||
return double.IsFinite(gain) && gain >= 0 ? gain : 0;
|
||||
}
|
||||
|
||||
private static int ReadSample(byte[] pcm, int byteIndex)
|
||||
@@ -161,6 +200,144 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
return byteIndex + 1 < pcm.Length ? BitConverter.ToInt16(pcm, byteIndex) : 0;
|
||||
}
|
||||
|
||||
private static bool TryReadAlignedPair(
|
||||
PendingAudioStream microphone,
|
||||
PendingAudioStream systemAudio,
|
||||
out AudioChunk microphoneChunk,
|
||||
out AudioChunk systemChunk)
|
||||
{
|
||||
microphoneChunk = null!;
|
||||
systemChunk = null!;
|
||||
var byteCount = Math.Min(microphone.ByteCount, systemAudio.ByteCount);
|
||||
byteCount -= byteCount % sizeof(short);
|
||||
if (byteCount <= 0 || microphone.Format is null || systemAudio.Format is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
microphoneChunk = microphone.Read(byteCount);
|
||||
systemChunk = systemAudio.Read(byteCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadRemainder(
|
||||
PendingAudioStream microphone,
|
||||
PendingAudioStream systemAudio,
|
||||
out AudioChunk microphoneChunk,
|
||||
out AudioChunk systemChunk)
|
||||
{
|
||||
microphoneChunk = null!;
|
||||
systemChunk = null!;
|
||||
var byteCount = Math.Max(microphone.ByteCount, systemAudio.ByteCount);
|
||||
byteCount -= byteCount % sizeof(short);
|
||||
var format = microphone.Format ?? systemAudio.Format;
|
||||
if (byteCount <= 0 || format is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
microphoneChunk = microphone.ByteCount > 0
|
||||
? microphone.Read(Math.Min(byteCount, microphone.ByteCount))
|
||||
: new AudioChunk(new byte[byteCount], format.SampleRate, format.Channels);
|
||||
systemChunk = systemAudio.ByteCount > 0
|
||||
? systemAudio.Read(Math.Min(byteCount, systemAudio.ByteCount))
|
||||
: new AudioChunk(new byte[byteCount], format.SampleRate, format.Channels);
|
||||
|
||||
if (microphoneChunk.Pcm.Length < byteCount)
|
||||
{
|
||||
microphoneChunk = microphoneChunk with { Pcm = PadSilence(microphoneChunk.Pcm, byteCount) };
|
||||
}
|
||||
|
||||
if (systemChunk.Pcm.Length < byteCount)
|
||||
{
|
||||
systemChunk = systemChunk with { Pcm = PadSilence(systemChunk.Pcm, byteCount) };
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ShouldFlushTimedOutRemainder(PendingAudioStream microphone, PendingAudioStream systemAudio)
|
||||
{
|
||||
return microphone.ByteCount > 0 && systemAudio.ByteCount == 0 && microphone.HasBufferedFor(SourceSkewTimeout) ||
|
||||
systemAudio.ByteCount > 0 && microphone.ByteCount == 0 && systemAudio.HasBufferedFor(SourceSkewTimeout);
|
||||
}
|
||||
|
||||
private static byte[] PadSilence(byte[] pcm, int byteCount)
|
||||
{
|
||||
var padded = new byte[byteCount];
|
||||
Buffer.BlockCopy(pcm, 0, padded, 0, pcm.Length);
|
||||
return padded;
|
||||
}
|
||||
|
||||
private sealed class PendingAudioStream
|
||||
{
|
||||
private readonly LinkedList<ArraySegment<byte>> chunks = new();
|
||||
private long? firstBufferedAt;
|
||||
|
||||
public int ByteCount { get; private set; }
|
||||
|
||||
public AudioFormat? Format { get; private set; }
|
||||
|
||||
public void Enqueue(AudioChunk chunk)
|
||||
{
|
||||
if (chunk.Pcm.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
firstBufferedAt ??= Environment.TickCount64;
|
||||
Format ??= new AudioFormat(chunk.SampleRate, chunk.Channels);
|
||||
chunks.AddLast(new ArraySegment<byte>(chunk.Pcm));
|
||||
ByteCount += chunk.Pcm.Length;
|
||||
}
|
||||
|
||||
public bool HasBufferedFor(TimeSpan duration)
|
||||
{
|
||||
return firstBufferedAt is not null &&
|
||||
TimeSpan.FromMilliseconds(Environment.TickCount64 - firstBufferedAt.Value) >= duration;
|
||||
}
|
||||
|
||||
public AudioChunk Read(int byteCount)
|
||||
{
|
||||
if (Format is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot read audio before a stream format has been captured.");
|
||||
}
|
||||
|
||||
var target = new byte[byteCount];
|
||||
var written = 0;
|
||||
while (written < byteCount && chunks.First is not null)
|
||||
{
|
||||
var chunk = chunks.First.Value;
|
||||
var copyCount = Math.Min(byteCount - written, chunk.Count);
|
||||
Buffer.BlockCopy(chunk.Array!, chunk.Offset, target, written, copyCount);
|
||||
written += copyCount;
|
||||
ByteCount -= copyCount;
|
||||
|
||||
if (copyCount == chunk.Count)
|
||||
{
|
||||
chunks.RemoveFirst();
|
||||
}
|
||||
else
|
||||
{
|
||||
chunks.First.Value = new ArraySegment<byte>(
|
||||
chunk.Array!,
|
||||
chunk.Offset + copyCount,
|
||||
chunk.Count - copyCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (ByteCount == 0)
|
||||
{
|
||||
firstBufferedAt = null;
|
||||
}
|
||||
|
||||
return new AudioChunk(target, Format.SampleRate, Format.Channels);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AudioFormat(int SampleRate, int Channels);
|
||||
|
||||
private sealed record SourceChunk(AudioSourceKind Kind, AudioChunk Chunk);
|
||||
|
||||
private enum AudioSourceKind
|
||||
|
||||
@@ -3,4 +3,11 @@ namespace MeetingAssistant.Recording;
|
||||
public interface IMeetingAudioSource
|
||||
{
|
||||
IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +485,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
long byteCount = 0;
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in audioSource.CaptureAsync(run.CaptureCancellation))
|
||||
await foreach (var chunk in audioSource.CaptureAsync(run.Options, run.CaptureCancellation))
|
||||
{
|
||||
chunkCount++;
|
||||
byteCount += chunk.Pcm.Length;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
@@ -7,23 +6,29 @@ namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||
|
||||
public MicrophoneAudioSource(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<MicrophoneAudioSource> logger)
|
||||
public MicrophoneAudioSource(ILogger<MicrophoneAudioSource> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new WasapiCapture(), cancellationToken);
|
||||
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
||||
}
|
||||
|
||||
private IAsyncEnumerable<AudioChunk> CaptureAsync(IWaveIn capture, CancellationToken cancellationToken)
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new WasapiCapture(), options, cancellationToken);
|
||||
}
|
||||
|
||||
private IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
IWaveIn capture,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
capture.WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
|
||||
return CaptureWith(capture, "microphone", logger, cancellationToken);
|
||||
@@ -94,18 +99,21 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
|
||||
public sealed class SystemAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<SystemAudioSource> logger;
|
||||
|
||||
public SystemAudioSource(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<SystemAudioSource> logger)
|
||||
public SystemAudioSource(ILogger<SystemAudioSource> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var capture = new WasapiLoopbackCapture
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class BoundMeetingProjectResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts, cancellationToken);
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
@@ -42,6 +42,13 @@ public sealed class BoundMeetingProjectResolver
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
string meetingNotePath,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,9 +8,10 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
public const string DefaultInitialPrompt = """
|
||||
You are the Meeting Assistant summary agent.
|
||||
|
||||
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
|
||||
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, project files, and scoped past project meeting summaries.
|
||||
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important.
|
||||
write_summary is only for the current meeting's summary file. Past project meeting summaries are read-only historical context; use list_past_project_meetings and read_past_project_meeting_summary to inspect them, and never try to mutate them.
|
||||
If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
|
||||
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. write_context appends by default; use replace_file=true only when you are intentionally replacing the whole assistant context body. Keep user-facing summary content in the summary note.
|
||||
@@ -19,7 +20,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
|
||||
Use delete_identity only when you are certain that an existing speaker identity was wrongfully matched. Provide the exact identity name currently used in the transcript.
|
||||
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
|
||||
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
|
||||
Use list_projects first to see which projects are bound to this meeting. Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope.
|
||||
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
|
||||
If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important.
|
||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
@@ -311,6 +309,46 @@ public sealed class MeetingSummaryTools
|
||||
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
|
||||
}
|
||||
|
||||
public async Task<string> ListPastProjectMeetings(
|
||||
string[]? projects = null,
|
||||
int page = 1,
|
||||
int page_size = 20)
|
||||
{
|
||||
var scope = await ResolveCurrentMeetingProjectScopeAsync(projects);
|
||||
if (scope.Refusal is not null)
|
||||
{
|
||||
return scope.Refusal;
|
||||
}
|
||||
|
||||
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
|
||||
var effectivePageSize = Math.Clamp(page_size, 1, 100);
|
||||
var requestedPage = Math.Max(1, page);
|
||||
var totalPages = Math.Max(1, (int)Math.Ceiling(summaries.Count / (double)effectivePageSize));
|
||||
var effectivePage = Math.Min(requestedPage, totalPages);
|
||||
var pageItems = summaries
|
||||
.Skip((effectivePage - 1) * effectivePageSize)
|
||||
.Take(effectivePageSize)
|
||||
.ToList();
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"page {effectivePage}/{totalPages}, page_size {effectivePageSize}, total {summaries.Count}"
|
||||
};
|
||||
lines.AddRange(pageItems.Select(summary =>
|
||||
$"{summary.ToolPath} | {FormatSummaryStartTime(summary.StartTime)} | {summary.Title} | projects: {string.Join(", ", summary.Projects)}"));
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
public async Task<string> ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null)
|
||||
{
|
||||
var summary = await ResolvePastSummaryAsync(path);
|
||||
if (summary.Refusal is not null)
|
||||
{
|
||||
return summary.Refusal;
|
||||
}
|
||||
|
||||
return ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to);
|
||||
}
|
||||
|
||||
public async Task<string> WriteProjectFile(
|
||||
string project,
|
||||
string path,
|
||||
@@ -354,17 +392,15 @@ public sealed class MeetingSummaryTools
|
||||
return "";
|
||||
}
|
||||
|
||||
var selectedProjects = await GetSearchProjectsAsync(projects);
|
||||
if (selectedProjects.Count == 0)
|
||||
var scope = await ResolveCurrentMeetingProjectScopeAsync(projects);
|
||||
if (scope.Refusal is not null)
|
||||
{
|
||||
return "";
|
||||
return scope.Refusal;
|
||||
}
|
||||
|
||||
var projectsRoot = GetProjectsRoot();
|
||||
var result = await RunRipgrepAsync(projectsRoot, selectedProjects, keywords);
|
||||
return result is not null
|
||||
? FormatRipgrepJson(result, selectedProjects.Count == 1)
|
||||
: SearchWithRegexFallback(selectedProjects, keywords, selectedProjects.Count == 1);
|
||||
var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames);
|
||||
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
|
||||
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
|
||||
@@ -462,19 +498,55 @@ public sealed class MeetingSummaryTools
|
||||
: value;
|
||||
}
|
||||
|
||||
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
|
||||
private async Task<ProjectScope> ResolveCurrentMeetingProjectScopeAsync(string[]? projects)
|
||||
{
|
||||
var boundProjects = await GetBoundProjectsAsync();
|
||||
if (projects is null || projects.Length == 0)
|
||||
var requested = (projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var currentProjects = await ReadCurrentMeetingProjectNamesAsync();
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
return boundProjects;
|
||||
return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var requested = projects
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return boundProjects
|
||||
.Where(project => requested.Contains(project.Name))
|
||||
var outOfScope = requested
|
||||
.Where(project => !currentProjects.Contains(project))
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (outOfScope.Count > 0)
|
||||
{
|
||||
return new ProjectScope(
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
||||
$"Refused: project(s) not assigned to current meeting: {string.Join(", ", outOfScope)}.");
|
||||
}
|
||||
|
||||
return new ProjectScope(requested.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> ReadCurrentMeetingProjectNamesAsync()
|
||||
{
|
||||
return await projectResolver.ReadMeetingProjectNamesAsync(artifacts);
|
||||
}
|
||||
|
||||
private async Task<List<BoundMeetingProject>> GetExistingProjectsInScopeAsync(IReadOnlySet<string> projectNames)
|
||||
{
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectsRoot = GetProjectsRoot();
|
||||
if (!Directory.Exists(projectsRoot))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(projectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
.Where(project => projectNames.Contains(project.Name))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -681,100 +753,60 @@ public sealed class MeetingSummaryTools
|
||||
return projectResolver.ProjectsRoot;
|
||||
}
|
||||
|
||||
private static async Task<string?> RunRipgrepAsync(
|
||||
string projectsRoot,
|
||||
private static string SearchSources(
|
||||
IReadOnlyList<BoundMeetingProject> projects,
|
||||
string keywords)
|
||||
IReadOnlyList<PastMeetingSummary> summaries,
|
||||
string keywords,
|
||||
bool singleProject)
|
||||
{
|
||||
System.Text.RegularExpressions.Regex regex;
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "rg",
|
||||
WorkingDirectory = projectsRoot,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
startInfo.ArgumentList.Add("--json");
|
||||
startInfo.ArgumentList.Add("--line-number");
|
||||
startInfo.ArgumentList.Add("--color");
|
||||
startInfo.ArgumentList.Add("never");
|
||||
startInfo.ArgumentList.Add("--");
|
||||
startInfo.ArgumentList.Add(keywords);
|
||||
foreach (var project in projects)
|
||||
{
|
||||
startInfo.ArgumentList.Add(project.Name);
|
||||
}
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return process.ExitCode is 0 or 1 ? output : null;
|
||||
regex = new System.Text.RegularExpressions.Regex(keywords);
|
||||
}
|
||||
catch
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatRipgrepJson(string output, bool singleProject)
|
||||
{
|
||||
var matches = new List<string>();
|
||||
using var reader = new StringReader(output);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
using var document = JsonDocument.Parse(line);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("type", out var type) ||
|
||||
type.GetString() != "match" ||
|
||||
!root.TryGetProperty("data", out var data))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
|
||||
var lineNumber = data.GetProperty("line_number").GetInt32();
|
||||
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
|
||||
matches.Add($"{FormatSearchPath(path, singleProject)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
|
||||
return $"Search failed: invalid .NET regular expression: {ex.Message}";
|
||||
}
|
||||
|
||||
return string.Join('\n', matches);
|
||||
}
|
||||
|
||||
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
|
||||
{
|
||||
try
|
||||
{
|
||||
var regex = new System.Text.RegularExpressions.Regex(keywords);
|
||||
var matches = new List<string>();
|
||||
foreach (var project in projects)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
if (regex.IsMatch(lines[index]))
|
||||
{
|
||||
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
|
||||
matches.Add($"{FormatSearchPath(relative, singleProject)}:{index + 1} {lines[index]}");
|
||||
}
|
||||
}
|
||||
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
|
||||
AddFileMatches(matches, regex, file, FormatSearchPath(relative, singleProject));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
AddFileMatches(matches, regex, summary.Path, $"past-meetings/{summary.ToolPath}");
|
||||
}
|
||||
|
||||
return string.Join('\n', matches);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return "";
|
||||
return $"Search failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFileMatches(
|
||||
List<string> matches,
|
||||
System.Text.RegularExpressions.Regex regex,
|
||||
string file,
|
||||
string formattedPath)
|
||||
{
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
if (regex.IsMatch(lines[index]))
|
||||
{
|
||||
matches.Add($"{ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,8 +836,155 @@ public sealed class MeetingSummaryTools
|
||||
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private async Task<List<PastMeetingSummary>> GetScopedPastSummariesAsync(IReadOnlySet<string> projectNames)
|
||||
{
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var summariesRoot = GetSummariesRoot();
|
||||
if (!Directory.Exists(summariesRoot))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var summaries = new List<PastMeetingSummary>();
|
||||
foreach (var file in Directory.EnumerateFiles(summariesRoot, "*.md", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
var summary = await TryReadPastSummaryAsync(file, summariesRoot);
|
||||
if (summary is null ||
|
||||
IsSamePath(summary.Path, artifacts.SummaryPath) ||
|
||||
!summary.Projects.Any(projectNames.Contains))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
summaries.Add(summary);
|
||||
}
|
||||
|
||||
return summaries
|
||||
.OrderByDescending(summary => summary.StartTime)
|
||||
.ThenByDescending(summary => summary.ToolPath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<PastMeetingSummaryResolution> ResolvePastSummaryAsync(string path)
|
||||
{
|
||||
path = NormalizePastMeetingSummaryToolPath(path);
|
||||
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused("Refused: summary path must be relative to the configured summaries folder.");
|
||||
}
|
||||
|
||||
var scope = await ResolveCurrentMeetingProjectScopeAsync(null);
|
||||
if (scope.Refusal is not null)
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused(scope.Refusal);
|
||||
}
|
||||
|
||||
var summariesRoot = GetSummariesRoot();
|
||||
var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path));
|
||||
if (!IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder.");
|
||||
}
|
||||
|
||||
if (IsSamePath(fullPath, artifacts.SummaryPath))
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused("Refused: read_past_project_meeting_summary cannot read the current meeting summary; use write_summary only for the current meeting summary.");
|
||||
}
|
||||
|
||||
var summary = await TryReadPastSummaryAsync(fullPath, summariesRoot);
|
||||
if (summary is null)
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused("Refused: file is not a summary note with project frontmatter.");
|
||||
}
|
||||
|
||||
if (!summary.Projects.Any(scope.ProjectNames.Contains))
|
||||
{
|
||||
return PastMeetingSummaryResolution.Refused("Refused: summary file is not assigned to a project in the current meeting scope.");
|
||||
}
|
||||
|
||||
return new PastMeetingSummaryResolution(summary);
|
||||
}
|
||||
|
||||
private static string NormalizePastMeetingSummaryToolPath(string path)
|
||||
{
|
||||
var toolPath = ToToolPath(path.Trim());
|
||||
const string pastMeetingPrefix = "past-meetings/";
|
||||
return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? toolPath[pastMeetingPrefix.Length..]
|
||||
: path;
|
||||
}
|
||||
|
||||
private async Task<PastMeetingSummary?> TryReadPastSummaryAsync(string path, string summariesRoot)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
SummaryFrontmatterYaml frontmatter;
|
||||
try
|
||||
{
|
||||
frontmatter = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter)
|
||||
?? new SummaryFrontmatterYaml();
|
||||
}
|
||||
catch (YamlDotNet.Core.YamlException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projects = (frontmatter.Projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PastMeetingSummary(
|
||||
path,
|
||||
ToToolPath(Path.GetRelativePath(summariesRoot, path)),
|
||||
string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(),
|
||||
ParseDateTime(frontmatter.StartTime),
|
||||
projects);
|
||||
}
|
||||
|
||||
private string GetSummariesRoot()
|
||||
{
|
||||
return VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder);
|
||||
}
|
||||
|
||||
private static string FormatSummaryStartTime(DateTimeOffset? startTime)
|
||||
{
|
||||
return startTime?.ToString("O") ?? "";
|
||||
}
|
||||
|
||||
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
|
||||
|
||||
private sealed record ProjectScope(IReadOnlySet<string> ProjectNames, string? Refusal = null);
|
||||
|
||||
private sealed record PastMeetingSummary(
|
||||
string Path,
|
||||
string ToolPath,
|
||||
string Title,
|
||||
DateTimeOffset? StartTime,
|
||||
IReadOnlyList<string> Projects);
|
||||
|
||||
private sealed record PastMeetingSummaryResolution(PastMeetingSummary? Summary, string? Refusal = null)
|
||||
{
|
||||
public static PastMeetingSummaryResolution Refused(string message)
|
||||
{
|
||||
return new PastMeetingSummaryResolution(null, message);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FileLineEditMode(
|
||||
FileLineEditKind Kind,
|
||||
int? From = null,
|
||||
@@ -872,10 +1051,30 @@ public sealed class MeetingSummaryTools
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SummaryFrontmatterYaml
|
||||
{
|
||||
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
|
||||
public string? StartTime { get; set; }
|
||||
|
||||
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
||||
public List<string>? Projects { get; set; }
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDateTime(string? value)
|
||||
{
|
||||
return DateTimeOffset.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsSamePath(string first, string second)
|
||||
{
|
||||
return string.Equals(
|
||||
Path.GetFullPath(first),
|
||||
Path.GetFullPath(second),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +188,18 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
tools.WriteProjectFile,
|
||||
"write_projectfile",
|
||||
"Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListPastProjectMeetings,
|
||||
"list_past_project_meetings",
|
||||
"List paginated past meeting summary notes from the configured summaries folder for projects assigned to the current meeting. Optional projects must be assigned to the current meeting."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadPastProjectMeetingSummary,
|
||||
"read_past_project_meeting_summary",
|
||||
"Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. With from and to, read that clamped inclusive 1-based line range. Cannot read or write the current meeting summary."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.Search,
|
||||
"search",
|
||||
"Run ripgrep syntax keywords against the requested bound projects, or all projects bound to the meeting note.")
|
||||
"Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted.")
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -28,74 +28,74 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
var enumerator = audio.GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!await enumerator.MoveNextAsync())
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var firstChunk = enumerator.Current;
|
||||
using var pushStream = AudioInputStream.CreatePushStream(
|
||||
AudioStreamFormat.GetWaveFormatPCM(
|
||||
(uint)firstChunk.SampleRate,
|
||||
16,
|
||||
(byte)firstChunk.Channels));
|
||||
using var audioConfig = AudioConfig.FromStreamInput(pushStream);
|
||||
var speechConfig = CreateSpeechConfig();
|
||||
using var recognizer = CreateTranscriber(speechConfig, audioConfig);
|
||||
AddPhraseList(recognizer, pipelineOptions.DictationWords);
|
||||
var segments = Channel.CreateUnbounded<TranscriptionSegment>();
|
||||
|
||||
recognizer.Transcribed += (_, args) =>
|
||||
{
|
||||
if (args.Result.Reason != ResultReason.RecognizedSpeech
|
||||
|| string.IsNullOrWhiteSpace(args.Result.Text))
|
||||
if (!await enumerator.MoveNextAsync())
|
||||
{
|
||||
return;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var start = TimeSpan.FromTicks(args.Result.OffsetInTicks);
|
||||
var segment = new TranscriptionSegment(
|
||||
start,
|
||||
start + args.Result.Duration,
|
||||
NormalizeSpeakerId(args.Result.SpeakerId),
|
||||
args.Result.Text.Trim());
|
||||
logger.LogInformation(
|
||||
"Azure Speech emitted segment at {Start} from {Speaker}: {Text}",
|
||||
segment.Start,
|
||||
segment.Speaker,
|
||||
segment.Text);
|
||||
segments.Writer.TryWrite(segment);
|
||||
};
|
||||
recognizer.Canceled += (_, args) =>
|
||||
{
|
||||
if (args.Reason == CancellationReason.Error)
|
||||
var firstChunk = enumerator.Current;
|
||||
using var pushStream = AudioInputStream.CreatePushStream(
|
||||
AudioStreamFormat.GetWaveFormatPCM(
|
||||
(uint)firstChunk.SampleRate,
|
||||
16,
|
||||
(byte)firstChunk.Channels));
|
||||
using var audioConfig = AudioConfig.FromStreamInput(pushStream);
|
||||
var speechConfig = CreateSpeechConfig();
|
||||
using var recognizer = CreateTranscriber(speechConfig, audioConfig);
|
||||
AddPhraseList(recognizer, pipelineOptions.DictationWords);
|
||||
var segments = Channel.CreateUnbounded<TranscriptionSegment>();
|
||||
|
||||
recognizer.Transcribed += (_, args) =>
|
||||
{
|
||||
segments.Writer.TryComplete(new InvalidOperationException(
|
||||
$"Azure Speech recognition canceled: {args.ErrorCode} {args.ErrorDetails}"));
|
||||
return;
|
||||
if (args.Result.Reason != ResultReason.RecognizedSpeech
|
||||
|| string.IsNullOrWhiteSpace(args.Result.Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var start = TimeSpan.FromTicks(args.Result.OffsetInTicks);
|
||||
var segment = new TranscriptionSegment(
|
||||
start,
|
||||
start + args.Result.Duration,
|
||||
NormalizeSpeakerId(args.Result.SpeakerId),
|
||||
args.Result.Text.Trim());
|
||||
logger.LogInformation(
|
||||
"Azure Speech emitted segment at {Start} from {Speaker}: {Text}",
|
||||
segment.Start,
|
||||
segment.Speaker,
|
||||
segment.Text);
|
||||
segments.Writer.TryWrite(segment);
|
||||
};
|
||||
recognizer.Canceled += (_, args) =>
|
||||
{
|
||||
if (args.Reason == CancellationReason.Error)
|
||||
{
|
||||
segments.Writer.TryComplete(new InvalidOperationException(
|
||||
$"Azure Speech recognition canceled: {args.ErrorCode} {args.ErrorDetails}"));
|
||||
return;
|
||||
}
|
||||
|
||||
segments.Writer.TryComplete();
|
||||
};
|
||||
|
||||
await recognizer.StartTranscribingAsync();
|
||||
var pumpTask = Task.Run(
|
||||
() => PumpAudioAsync(
|
||||
firstChunk,
|
||||
enumerator,
|
||||
pushStream,
|
||||
recognizer,
|
||||
segments.Writer,
|
||||
options.AzureSpeech.RecognitionStopTimeout,
|
||||
cancellationToken),
|
||||
CancellationToken.None);
|
||||
|
||||
await foreach (var segment in segments.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
|
||||
segments.Writer.TryComplete();
|
||||
};
|
||||
|
||||
await recognizer.StartTranscribingAsync();
|
||||
var pumpTask = Task.Run(
|
||||
() => PumpAudioAsync(
|
||||
firstChunk,
|
||||
enumerator,
|
||||
pushStream,
|
||||
recognizer,
|
||||
segments.Writer,
|
||||
options.AzureSpeech.RecognitionStopTimeout,
|
||||
cancellationToken),
|
||||
CancellationToken.None);
|
||||
|
||||
await foreach (var segment in segments.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
|
||||
await pumpTask.WaitAsync(cancellationToken);
|
||||
await pumpTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -27,26 +27,7 @@ public sealed class ProcessCommandRunner : ICommandRunner
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (environment is not null)
|
||||
{
|
||||
foreach (var (key, value) in environment)
|
||||
{
|
||||
startInfo.Environment[key] = value;
|
||||
}
|
||||
}
|
||||
var startInfo = CreateStartInfo(fileName, arguments, environment);
|
||||
|
||||
logger.LogInformation("Running command {Command} {Arguments}", fileName, string.Join(' ', arguments));
|
||||
using var process = Process.Start(startInfo)
|
||||
@@ -82,6 +63,37 @@ public sealed class ProcessCommandRunner : ICommandRunner
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateStartInfo(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (environment is not null)
|
||||
{
|
||||
foreach (var (key, value) in environment)
|
||||
{
|
||||
startInfo.Environment[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLinesAsync(
|
||||
StreamReader reader,
|
||||
Action<string> log,
|
||||
|
||||
@@ -66,6 +66,11 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
|
||||
diarization.Image,
|
||||
diarization.Model);
|
||||
await finalizer.WarmUpAsync(diarization, cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Finished pyannote warm-up for image {Image} and model {Model}",
|
||||
diarization.Image,
|
||||
@@ -76,6 +81,10 @@ public sealed class PyannoteDiarizationWarmupHostedService : IHostedService
|
||||
logger.LogInformation("Pyannote warm-up was cancelled during application shutdown");
|
||||
throw;
|
||||
}
|
||||
catch (Exception) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"TranscriptionProvider": "azure-speech",
|
||||
"SampleRate": 16000,
|
||||
"Channels": 1,
|
||||
"MicrophoneMixGain": 1,
|
||||
"SystemAudioMixGain": 1,
|
||||
"StopProcessingTimeout": "00:10:00",
|
||||
"MetadataLookupTimeout": "00:00:10",
|
||||
"BackgroundMetadataLookupTimeout": "00:01:00",
|
||||
@@ -110,7 +112,7 @@
|
||||
"MergeRecentIdentityAge": "14.00:00:00",
|
||||
"MatchTimeout": "00:03:00",
|
||||
"PyannoteValidation": {
|
||||
"Enabled": false,
|
||||
"Enabled": true,
|
||||
"MinimumSingleSpeakerCoverage": 0.9,
|
||||
"MinimumMatchingKnownSnippetRatio": 0.5,
|
||||
"Diarization": {
|
||||
|
||||
@@ -103,6 +103,8 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"TranscriptionProvider": "funasr",
|
||||
"SampleRate": 16000,
|
||||
"Channels": 1,
|
||||
"MicrophoneMixGain": 1,
|
||||
"SystemAudioMixGain": 1,
|
||||
"StopProcessingTimeout": "00:10:00",
|
||||
"MaxMetadataAttendeeImportCount": 30,
|
||||
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings"
|
||||
@@ -172,7 +174,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"MinimumSampleSpeechDuration": "00:00:30",
|
||||
"MaximumSampleSegmentGap": "00:00:01",
|
||||
"PyannoteValidation": {
|
||||
"Enabled": false,
|
||||
"Enabled": true,
|
||||
"MinimumSingleSpeakerCoverage": 0.9,
|
||||
"MinimumMatchingKnownSnippetRatio": 0.5,
|
||||
"Diarization": {
|
||||
@@ -230,11 +232,11 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
|
||||
`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 writes the mixed PCM stream to a temporary WAV under `TemporaryRecordingsFolder` only so the final diarization pass has audio to process. 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.
|
||||
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 captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. 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.
|
||||
`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.
|
||||
|
||||
@@ -290,7 +292,7 @@ GET /meetings/summary/retry?summaryPath=...
|
||||
|
||||
Failure summary notes include a clickable retry link using `Api:PublicBaseUrl`, for example `http://localhost:5090/meetings/summary/retry?summaryPath=20260519-124110-summary.md`. The GET endpoint exists for Obsidian/browser clicks and performs the same overwrite behavior as the POST endpoint. Meeting note bodies stay reserved for user-authored notes.
|
||||
|
||||
The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, and project files. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using append-by-default, explicit whole-file replacement, replace, and insert modes. Project lookup and search tools are constrained to the projects listed in the meeting note frontmatter:
|
||||
The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, project files, and past project meeting summaries. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts and past summaries incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using append-by-default, explicit whole-file replacement, replace, and insert modes. Project lookup, past-meeting lookup, and search tools are constrained to the projects listed in the meeting note frontmatter:
|
||||
|
||||
When assistant context contains cropped screenshot links produced by OCR, the summary instructions tell the agent to include only the most relevant cropped screenshots in the summary and markdown-link them near the related summary text.
|
||||
|
||||
@@ -299,6 +301,8 @@ When assistant context contains cropped screenshot links produced by OCR, the su
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `list_past_project_meetings`
|
||||
- `read_past_project_meeting_summary`
|
||||
- `search`
|
||||
- `write_context`
|
||||
- `add_attendee`
|
||||
@@ -306,7 +310,9 @@ When assistant context contains cropped screenshot links produced by OCR, the su
|
||||
- `override_speaker`
|
||||
- `delete_identity`
|
||||
|
||||
`search` accepts ripgrep syntax and returns matches as `filename:line text`. If one project is searched, paths are relative to that project; if multiple projects are searched, paths include the project folder name.
|
||||
`list_past_project_meetings` reads only summary notes from the configured summaries folder and lists prior summaries assigned to the current meeting's projects, excluding the current summary file. It supports pagination through `page` and `page_size` and can optionally filter to a subset of the current meeting's projects. If the requested project is not assigned to the current meeting, the tool refuses the request. `read_past_project_meeting_summary` reads one of those prior summary files as read-only historical context, using the same `from` and `to` line range behavior as other read tools. It cannot write or mutate past summaries; `write_summary` is only for the current meeting's summary.
|
||||
|
||||
`search` accepts .NET 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Add Local Echo Cancellation and MAS Assets
|
||||
|
||||
## Why
|
||||
The current mono meeting mix adds microphone and system samples directly, which can clip when both streams are loud. Speaker identity samples are extracted from this stream, so clipping and loopback bleed reduce sample quality.
|
||||
|
||||
Azure Speech SDK's Microsoft Audio Stack can perform local acoustic echo cancellation when the Azure backend is used, but it does not expose reusable cleaned PCM chunks back to Meeting Assistant. Meeting Assistant should keep the MAS package/assets available for experiments while using an owned local echo cleanup stage that produces normal 16 kHz mono PCM chunks for storage and every ASR provider.
|
||||
|
||||
## What Changes
|
||||
- Capture microphone and system loopback as separate PCM streams inside the composite audio source.
|
||||
- Clean the microphone stream with a local adaptive echo canceller that uses system loopback as the far-end reference.
|
||||
- Mix the cleaned microphone and system streams by addition, with configurable gains still available.
|
||||
- Write only the mixed temporary WAV used by transcription/finalization; separate channel files are not kept outside diagnostics.
|
||||
- Keep Microsoft MAS package/assets in the publish output without exposing an Azure AEC runtime feature flag.
|
||||
|
||||
## Impact
|
||||
- Current mixed WAV output should contain less loopback bleed on the microphone side while preserving remote/system audio.
|
||||
- Azure, FunASR, Whisper, pyannote, and speaker samples continue consuming 16 kHz mono PCM chunks.
|
||||
- Sidecar temporary WAV files are diagnostic artifacts and are deleted with the main temporary recording.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Meeting Assistant captures meeting audio
|
||||
Meeting Assistant SHALL capture audio as 16 kHz mono PCM chunks for the existing recording and transcription pipeline.
|
||||
|
||||
Meeting Assistant SHALL capture microphone and system loopback as separate input streams before producing the final mono chunks.
|
||||
|
||||
Meeting Assistant SHALL clean the microphone stream with a local acoustic echo cancellation stage that uses system loopback as the far-end reference.
|
||||
|
||||
Meeting Assistant SHALL produce final mono chunks by adding the cleaned microphone samples and system samples.
|
||||
|
||||
Meeting Assistant SHALL align microphone and system samples through per-source buffers before mixing and SHALL NOT emit normal live audio chunks that contain only one source while the other source is merely delayed.
|
||||
|
||||
When one source stays quiet beyond the alignment timeout, Meeting Assistant SHALL mix the available source with synthetic silence for the missing source instead of blocking transcription.
|
||||
|
||||
Meeting Assistant SHALL allow the final microphone/system mono mix to apply configurable microphone and system gain before combining samples.
|
||||
|
||||
Meeting Assistant SHALL use the active run or launch profile recording options when configuring capture format and final microphone/system gains.
|
||||
|
||||
Meeting Assistant SHALL clamp mixed samples after gain is applied.
|
||||
|
||||
Meeting Assistant SHALL write only the mixed stream to the temporary WAV used by transcription and finalization.
|
||||
|
||||
#### Scenario: Mixed audio uses cleaned microphone and system audio
|
||||
- **GIVEN** the echo canceller cleans a microphone chunk to sample `2000`
|
||||
- **AND** the matching system chunk has sample `10000`
|
||||
- **WHEN** microphone and system chunks are mixed with gains `1` and `1`
|
||||
- **THEN** the mixed sample is `12000`
|
||||
|
||||
#### Scenario: Temporary recording stores only the mixed stream
|
||||
- **GIVEN** Meeting Assistant has mixed microphone and system audio into one PCM chunk
|
||||
- **WHEN** Meeting Assistant appends the chunk to the temporary recording
|
||||
- **THEN** the main temporary WAV contains the mixed PCM
|
||||
- **AND** no microphone or system sidecar WAV is written
|
||||
|
||||
#### Scenario: Launch profile recording options configure capture and gains
|
||||
- **GIVEN** an active launch profile configures sample format and microphone/system mix gains
|
||||
- **WHEN** Meeting Assistant captures and mixes audio for that run
|
||||
- **THEN** the microphone and system capture sources receive that launch profile recording configuration
|
||||
- **AND** the mixed output uses that launch profile's microphone/system gains
|
||||
|
||||
#### Scenario: Delayed sources are buffered before mixing
|
||||
- **GIVEN** microphone audio arrives before matching system audio
|
||||
- **WHEN** matching system audio arrives after a short delay
|
||||
- **THEN** Meeting Assistant emits one mixed chunk for the aligned samples
|
||||
- **AND** it does not emit separate microphone-only and system-only chunks for that delayed pair
|
||||
|
||||
#### Scenario: Quiet system audio does not block microphone transcription
|
||||
- **GIVEN** microphone audio arrives
|
||||
- **AND** system loopback audio does not arrive within the alignment timeout
|
||||
- **WHEN** Meeting Assistant mixes the available audio
|
||||
- **THEN** it emits the microphone audio mixed with silent system audio
|
||||
- **AND** live transcription can continue while system loopback is quiet
|
||||
|
||||
#### Scenario: Continuous microphone audio does not suppress the alignment timeout
|
||||
- **GIVEN** microphone audio keeps arriving
|
||||
- **AND** system loopback audio stays unavailable past the alignment timeout
|
||||
- **WHEN** Meeting Assistant checks the buffered microphone audio
|
||||
- **THEN** it emits the buffered microphone audio mixed with silent system audio
|
||||
- **AND** it does not wait indefinitely for a loopback chunk
|
||||
@@ -0,0 +1,12 @@
|
||||
## 1. Implementation
|
||||
- [x] Keep Microsoft MAS package/assets available without an Azure AEC runtime feature flag.
|
||||
- [x] Capture and buffer microphone/system input streams separately before mixing.
|
||||
- [x] Clean microphone chunks with a local echo cancellation stage before mixing.
|
||||
- [x] Mix cleaned microphone and system audio by addition with configurable gains.
|
||||
- [x] Keep temporary recording output to the mixed WAV; do not persist diagnostic channel sidecars.
|
||||
- [x] Add focused tests for local echo cleanup, additive mixing, aligned mixing, and mixed-only temporary recording.
|
||||
|
||||
## 2. Verification
|
||||
- [x] Run focused tests.
|
||||
- [x] Run `dotnet test MeetingAssistant.slnx`.
|
||||
- [x] Run `openspec validate add-audio-headroom-and-azure-aec --strict`.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
## Why
|
||||
Summary agents need project-aware access to prior meeting summaries so current summaries can reuse past decisions and context without mutating historical summary notes.
|
||||
|
||||
## What Changes
|
||||
- Add read-only summary-agent tools to list paginated past meeting summaries scoped to the current meeting's projects.
|
||||
- Add a read-only tool to read a past project meeting summary with the same line-range behavior as other read tools.
|
||||
- Extend summary-agent search so project-scoped searches include both bound project files and past summary notes for the scoped projects.
|
||||
- Reject requested project scopes that are not assigned to the current meeting.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `meeting-summary`, `agent-project-tools`
|
||||
- Affected code: summary tools, summary agent tool registration, summary agent instructions, README
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Agents can use project context tools
|
||||
The summary agent SHALL expose these project and retrieval tools:
|
||||
|
||||
- `list_projects`
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `list_past_project_meetings`
|
||||
- `read_past_project_meeting_summary`
|
||||
- `search`
|
||||
|
||||
The `search` tool SHALL search the requested current-meeting project scope, or all projects assigned to the current meeting when no project scope is supplied.
|
||||
|
||||
The `search` tool SHALL search both configured project files for the scoped projects and past meeting summary notes in the configured summaries folder whose frontmatter projects intersect the scoped projects.
|
||||
|
||||
The `search` tool SHALL refuse requested project scopes that are not assigned to the current meeting.
|
||||
|
||||
#### Scenario: Agent searches past project meeting summaries
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches for a keyword
|
||||
- **THEN** Meeting Assistant returns matches from both `Project X` project files and matching past summary notes
|
||||
|
||||
#### Scenario: Agent search rejects out-of-scope project
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches with project scope `Project Z`
|
||||
- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Meeting Assistant generates meeting outputs
|
||||
The summary pipeline SHALL expose tools scoped to the current meeting:
|
||||
|
||||
- `read_meetingnote`
|
||||
- `read_transcript`
|
||||
- `read_context`
|
||||
- `read_usernotes`
|
||||
- `read_glossary`
|
||||
- `add_dictation_word`
|
||||
- `add_attendee`
|
||||
- `remove_attendee`
|
||||
- `override_speaker`
|
||||
- `delete_identity`
|
||||
- `write_summary`
|
||||
- `write_context`
|
||||
- `list_projects`
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `list_past_project_meetings`
|
||||
- `read_past_project_meeting_summary`
|
||||
- `search`
|
||||
|
||||
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
|
||||
|
||||
The summary agent SHALL be able to list paginated past meeting summary notes for projects assigned to the current meeting.
|
||||
|
||||
The past meeting summary listing SHALL read summary notes from the configured summaries folder only.
|
||||
|
||||
When no project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include past summaries assigned to any project assigned to the current meeting.
|
||||
|
||||
When a project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include only past summaries assigned to those requested projects and SHALL refuse requested projects not assigned to the current meeting.
|
||||
|
||||
The summary agent SHALL be able to read past project meeting summary files through a read-only tool. Meeting Assistant SHALL refuse reads for summary files outside the configured summaries folder, the current summary file, or summary files that do not belong to one of the current meeting's assigned projects.
|
||||
|
||||
The summary instructions SHALL clearly distinguish `write_summary`, which writes the current meeting's summary, from past meeting summary read tools, which are read-only historical context.
|
||||
|
||||
#### Scenario: Past project meetings are listed by current meeting project scope
|
||||
- **GIVEN** the current meeting note is assigned to `Project X` and `Project Y`
|
||||
- **AND** the summaries folder contains prior summary notes for `Project X`, `Project Y`, and `Project Z`
|
||||
- **WHEN** the summary agent lists past project meetings without a project filter
|
||||
- **THEN** Meeting Assistant returns prior summaries for `Project X` and `Project Y`
|
||||
- **AND** excludes prior summaries for `Project Z`
|
||||
- **AND** excludes the current meeting's summary file
|
||||
|
||||
#### Scenario: Past project meeting listing rejects out-of-scope projects
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **WHEN** the summary agent lists past project meetings for `Project Z`
|
||||
- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting
|
||||
|
||||
#### Scenario: Past project meeting summary is read-only historical context
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent reads that prior summary note with line bounds
|
||||
- **THEN** Meeting Assistant returns the clamped inclusive line range
|
||||
- **AND** exposes no tool to write that prior summary note
|
||||
@@ -0,0 +1,10 @@
|
||||
## Implementation
|
||||
- [x] Add behavior tests for listing scoped past project meeting summaries with pagination.
|
||||
- [x] Add behavior tests for rejecting out-of-scope project filters.
|
||||
- [x] Add behavior tests for reading scoped past summary files with line ranges.
|
||||
- [x] Add behavior tests for searching project files and scoped past summaries together.
|
||||
- [x] Implement read-only past project meeting summary tools.
|
||||
- [x] Extend generic search to include past project summaries.
|
||||
- [x] Register the new tools and update summary-agent instructions/docs.
|
||||
- [x] Run focused tests.
|
||||
- [x] Validate OpenSpec change.
|
||||
@@ -14,8 +14,16 @@ The summary agent SHALL expose these project tools:
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `list_past_project_meetings`
|
||||
- `read_past_project_meeting_summary`
|
||||
- `search`
|
||||
|
||||
The `search` tool SHALL search the requested current-meeting project scope, or all projects assigned to the current meeting when no project scope is supplied.
|
||||
|
||||
The `search` tool SHALL search both configured project files for the scoped projects and past meeting summary notes in the configured summaries folder whose frontmatter projects intersect the scoped projects.
|
||||
|
||||
The `search` tool SHALL refuse requested project scopes that are not assigned to the current meeting.
|
||||
|
||||
#### Scenario: Agent looks up meeting project context
|
||||
- **WHEN** a meeting note identifies a project
|
||||
- **THEN** the agent can retrieve relevant project context through Meeting Assistant tools
|
||||
@@ -29,9 +37,20 @@ The summary agent SHALL expose these project tools:
|
||||
- **THEN** `read_projectfile` clamps the requested range to the available file lines without failing
|
||||
|
||||
#### Scenario: Agent searches project knowledge
|
||||
- **WHEN** the agent calls `search` with ripgrep syntax keywords
|
||||
- **WHEN** the agent calls `search` with .NET regular expression keywords
|
||||
- **THEN** Meeting Assistant runs the search against the requested projects or the meeting-bound projects and returns matches as `filename:line text`
|
||||
|
||||
#### Scenario: Agent searches past project meeting summaries
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches for a keyword
|
||||
- **THEN** Meeting Assistant returns matches from both `Project X` project files and matching past summary notes
|
||||
|
||||
#### Scenario: Agent search rejects out-of-scope project
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches with project scope `Project Z`
|
||||
- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting
|
||||
|
||||
### Requirement: Agents can write files in existing projects
|
||||
Meeting Assistant SHALL expose a `write_projectfile` tool that can create or update files inside an existing project folder.
|
||||
|
||||
|
||||
@@ -35,10 +35,68 @@ When no recording is active, aborting SHALL leave the current recording status u
|
||||
### Requirement: Recording mode captures microphone and computer output
|
||||
Meeting Assistant SHALL capture microphone input and computer output and combine them into one audio stream for transcription.
|
||||
|
||||
Meeting Assistant SHALL capture audio as 16 kHz mono PCM chunks for the existing recording and transcription pipeline.
|
||||
|
||||
Meeting Assistant SHALL capture microphone and system loopback as separate input streams before producing the final mono chunks.
|
||||
|
||||
Meeting Assistant SHALL clean the microphone stream with a local acoustic echo cancellation stage that uses system loopback as the far-end reference.
|
||||
|
||||
Meeting Assistant SHALL produce final mono chunks by adding the cleaned microphone samples and system samples.
|
||||
|
||||
Meeting Assistant SHALL align microphone and system samples through per-source buffers before mixing and SHALL NOT emit normal live audio chunks that contain only one source while the other source is merely delayed.
|
||||
|
||||
When one source stays quiet beyond the alignment timeout, Meeting Assistant SHALL mix the available source with synthetic silence for the missing source instead of blocking transcription.
|
||||
|
||||
Meeting Assistant SHALL allow the final microphone/system mono mix to apply configurable microphone and system gain before combining samples.
|
||||
|
||||
Meeting Assistant SHALL use the active run or launch profile recording options when configuring capture format and final microphone/system gains.
|
||||
|
||||
Meeting Assistant SHALL clamp mixed samples after gain is applied.
|
||||
|
||||
Meeting Assistant SHALL write only the mixed stream to the temporary WAV used by transcription and finalization.
|
||||
|
||||
#### Scenario: Both sources produce audio
|
||||
- **WHEN** microphone and computer output audio chunks are available
|
||||
- **THEN** Meeting Assistant mixes them into one PCM stream before transcription
|
||||
|
||||
#### Scenario: Mixed audio uses cleaned microphone and system audio
|
||||
- **GIVEN** the echo canceller cleans a microphone chunk to sample `2000`
|
||||
- **AND** the matching system chunk has sample `10000`
|
||||
- **WHEN** microphone and system chunks are mixed with gains `1` and `1`
|
||||
- **THEN** the mixed sample is `12000`
|
||||
|
||||
#### Scenario: Temporary recording stores only the mixed stream
|
||||
- **GIVEN** Meeting Assistant has mixed microphone and system audio into one PCM chunk
|
||||
- **WHEN** Meeting Assistant appends the chunk to the temporary recording
|
||||
- **THEN** the main temporary WAV contains the mixed PCM
|
||||
- **AND** no microphone or system sidecar WAV is written
|
||||
|
||||
#### Scenario: Launch profile recording options configure capture and gains
|
||||
- **GIVEN** an active launch profile configures sample format and microphone/system mix gains
|
||||
- **WHEN** Meeting Assistant captures and mixes audio for that run
|
||||
- **THEN** the microphone and system capture sources receive that launch profile recording configuration
|
||||
- **AND** the mixed output uses that launch profile's microphone/system gains
|
||||
|
||||
#### Scenario: Delayed sources are buffered before mixing
|
||||
- **GIVEN** microphone audio arrives before matching system audio
|
||||
- **WHEN** matching system audio arrives after a short delay
|
||||
- **THEN** Meeting Assistant emits one mixed chunk for the aligned samples
|
||||
- **AND** it does not emit separate microphone-only and system-only chunks for that delayed pair
|
||||
|
||||
#### Scenario: Quiet system audio does not block microphone transcription
|
||||
- **GIVEN** microphone audio arrives
|
||||
- **AND** system loopback audio does not arrive within the alignment timeout
|
||||
- **WHEN** Meeting Assistant mixes the available audio
|
||||
- **THEN** it emits the microphone audio mixed with silent system audio
|
||||
- **AND** live transcription can continue while system loopback is quiet
|
||||
|
||||
#### Scenario: Continuous microphone audio does not suppress the alignment timeout
|
||||
- **GIVEN** microphone audio keeps arriving
|
||||
- **AND** system loopback audio stays unavailable past the alignment timeout
|
||||
- **WHEN** Meeting Assistant checks the buffered microphone audio
|
||||
- **THEN** it emits the buffered microphone audio mixed with silent system audio
|
||||
- **AND** it does not wait indefinitely for a loopback chunk
|
||||
|
||||
#### Scenario: Device-level capture cannot be verified in tests
|
||||
- **WHEN** automated tests run without live audio devices
|
||||
- **THEN** Meeting Assistant verifies the audio mixer through deterministic source abstractions rather than depending on physical microphone or speaker devices
|
||||
|
||||
@@ -42,10 +42,28 @@ The summary pipeline SHALL expose tools scoped to the current meeting:
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `list_past_project_meetings`
|
||||
- `read_past_project_meeting_summary`
|
||||
- `search`
|
||||
|
||||
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
|
||||
|
||||
The summary agent SHALL be able to list paginated past meeting summary notes for projects assigned to the current meeting.
|
||||
|
||||
The past meeting summary listing SHALL read summary notes from the configured summaries folder only.
|
||||
|
||||
The past meeting summary listing SHALL skip malformed or non-summary markdown files instead of failing the whole listing.
|
||||
|
||||
When no project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include past summaries assigned to any project assigned to the current meeting.
|
||||
|
||||
When a project filter is supplied to the past meeting summary listing, Meeting Assistant SHALL include only past summaries assigned to those requested projects and SHALL refuse requested projects not assigned to the current meeting.
|
||||
|
||||
The summary agent SHALL be able to read past project meeting summary files through a read-only tool. Meeting Assistant SHALL refuse reads for summary files outside the configured summaries folder, the current summary file, or summary files that do not belong to one of the current meeting's assigned projects.
|
||||
|
||||
When search results identify past meeting summaries with the `past-meetings/` prefix, the past meeting summary read tool SHALL accept that prefixed path.
|
||||
|
||||
The summary instructions SHALL clearly distinguish `write_summary`, which writes the current meeting's summary, from past meeting summary read tools, which are read-only historical context.
|
||||
|
||||
The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context.
|
||||
|
||||
The summary instructions SHALL tell the summary agent to include only the most relevant cropped screenshot links from assistant context in the summary, placing them near the related summary text.
|
||||
@@ -196,6 +214,41 @@ When `delete_identity` is called with an existing transcript speaker identity, M
|
||||
- **THEN** Meeting Assistant rewrites the transcript speaker label to `Removed-1`
|
||||
- **AND** records the deletion for final speaker identity processing
|
||||
|
||||
#### Scenario: Past project meetings are listed by current meeting project scope
|
||||
- **GIVEN** the current meeting note is assigned to `Project X` and `Project Y`
|
||||
- **AND** the summaries folder contains prior summary notes for `Project X`, `Project Y`, and `Project Z`
|
||||
- **WHEN** the summary agent lists past project meetings without a project filter
|
||||
- **THEN** Meeting Assistant returns prior summaries for `Project X` and `Project Y`
|
||||
- **AND** excludes prior summaries for `Project Z`
|
||||
- **AND** excludes the current meeting's summary file
|
||||
|
||||
#### Scenario: Past project meeting listing rejects out-of-scope projects
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **WHEN** the summary agent lists past project meetings for `Project Z`
|
||||
- **THEN** Meeting Assistant refuses the request because `Project Z` is not assigned to the current meeting
|
||||
|
||||
#### Scenario: Past project meeting listing skips malformed notes
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** the summaries folder contains one valid prior `Project X` summary note
|
||||
- **AND** the summaries folder contains one markdown file with malformed frontmatter
|
||||
- **WHEN** the summary agent lists past project meetings
|
||||
- **THEN** Meeting Assistant returns the valid prior summary
|
||||
- **AND** does not fail because of the malformed file
|
||||
|
||||
#### Scenario: Past project meeting summary is read-only historical context
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent reads that prior summary note with line bounds
|
||||
- **THEN** Meeting Assistant returns the clamped inclusive line range
|
||||
- **AND** exposes no tool to write that prior summary note
|
||||
|
||||
#### Scenario: Past project meeting search paths can be read
|
||||
- **GIVEN** the current meeting note is assigned to `Project X`
|
||||
- **AND** a prior summary note in the configured summaries folder is assigned to `Project X`
|
||||
- **WHEN** the summary agent searches and receives a `past-meetings/` prefixed summary path
|
||||
- **THEN** the summary agent can pass that prefixed path to `read_past_project_meeting_summary`
|
||||
- **AND** Meeting Assistant reads the prior summary note
|
||||
|
||||
### Requirement: Meeting screenshots are captured into assistant context
|
||||
Meeting Assistant SHALL expose a configurable screenshot hotkey.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user