Public Access
Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications. Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
@@ -32,7 +32,14 @@ Calendar-Feature is refined, Golem feature needs more work.
|
||||
|
||||
PROJECT.md should use this structure:
|
||||
|
||||
Maintain its YAML `name` as the project's display name and `description` as a grounded description of its purpose in at most 256 characters. These fields help summarizers identify missing project associations. Preserve other frontmatter and body content when updating them.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Project display name
|
||||
description: Concise project purpose and distinguishing context.
|
||||
---
|
||||
|
||||
# Executive Summary
|
||||
|
||||
<Elevator Pitch>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace MeetingAssistant.Notifications;
|
||||
|
||||
public sealed record AgentApprovalPrompt(string Title, string Message, TimeSpan Timeout);
|
||||
|
||||
public interface IAgentApprovalPromptService
|
||||
{
|
||||
Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopAgentApprovalPromptService : IAgentApprovalPromptService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
public sealed class AgentApprovalService
|
||||
{
|
||||
private readonly IAgentApprovalPromptService prompts;
|
||||
private readonly ILogger<AgentApprovalService> logger;
|
||||
private readonly TimeSpan timeout;
|
||||
|
||||
public AgentApprovalService(IAgentApprovalPromptService prompts, ILogger<AgentApprovalService> logger)
|
||||
: this(prompts, logger, TimeSpan.FromMinutes(10)) { }
|
||||
|
||||
internal AgentApprovalService(IAgentApprovalPromptService prompts, ILogger<AgentApprovalService> logger, TimeSpan timeout)
|
||||
{
|
||||
this.prompts = prompts;
|
||||
this.logger = logger;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(timeout);
|
||||
try
|
||||
{
|
||||
var accepted = await prompts.ConfirmAsync(new AgentApprovalPrompt(title, message, timeout), deadline.Token)
|
||||
.WaitAsync(deadline.Token);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return accepted && !deadline.IsCancellationRequested;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not obtain user approval for {Title}", title);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await deadline.CancelAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#if WINDOWS
|
||||
using System.Collections.Concurrent;
|
||||
using CommunityToolkit.WinUI.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Notifications;
|
||||
|
||||
public sealed class WindowsAgentApprovalPromptService(ILogger<WindowsAgentApprovalPromptService> logger)
|
||||
: IAgentApprovalPromptService, IDisposable
|
||||
{
|
||||
private const string Source = "summary-agent-approval";
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> pending = new();
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
private bool disposed;
|
||||
|
||||
public async Task<bool> ConfirmAsync(AgentApprovalPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) return false;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var response = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return false;
|
||||
if (!registered)
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated += OnActivated;
|
||||
registered = true;
|
||||
}
|
||||
pending[id] = response;
|
||||
new ToastContentBuilder()
|
||||
.AddArgument("source", Source).AddArgument("requestId", id)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
.SetToastDuration(ToastDuration.Long)
|
||||
.AddText(prompt.Title)
|
||||
.AddText(prompt.Message)
|
||||
.AddButton(new ToastButton().SetContent("Yes")
|
||||
.AddArgument("source", Source).AddArgument("requestId", id)
|
||||
.AddArgument("response", "yes").SetBackgroundActivation())
|
||||
.AddButton(new ToastButton().SetContent("No")
|
||||
.AddArgument("source", Source).AddArgument("requestId", id)
|
||||
.AddArgument("response", "no").SetBackgroundActivation())
|
||||
.Show(toast =>
|
||||
{
|
||||
toast.Tag = id;
|
||||
toast.Group = Source;
|
||||
toast.ExpirationTime = DateTimeOffset.Now.Add(prompt.Timeout);
|
||||
});
|
||||
}
|
||||
|
||||
return await response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.TryRemove(id, out _);
|
||||
try { ToastNotificationManagerCompat.History.Remove(id, Source); }
|
||||
catch (Exception exception) { logger.LogDebug(exception, "Could not remove approval notification {RequestId}", id); }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActivated(ToastNotificationActivatedEventArgsCompat args)
|
||||
{
|
||||
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
||||
if (arguments.GetValueOrDefault("source") != Source ||
|
||||
!arguments.TryGetValue("requestId", out var id) ||
|
||||
!pending.TryRemove(id, out var response)) return;
|
||||
response.TrySetResult(arguments.GetValueOrDefault("response") == "yes");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
disposed = true;
|
||||
if (registered) ToastNotificationManagerCompat.OnActivated -= OnActivated;
|
||||
registered = false;
|
||||
foreach (var response in pending.Values) response.TrySetResult(false);
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,6 +4,7 @@ using MeetingAssistant.Hotkeys;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Logging;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
@@ -28,6 +29,9 @@ builder.Services.AddSingleton<IMicrophoneDeviceProvider>(services =>
|
||||
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
|
||||
builder.Services.AddSingleton<IMicrophoneCaptureSourceFactory>(services =>
|
||||
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
|
||||
builder.Services.AddSingleton<IMicrophoneSwitchPromptService, WindowsMicrophoneSwitchPromptService>();
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, WindowsAgentApprovalPromptService>();
|
||||
builder.Services.AddSingleton<MicrophoneSwitchMonitor>();
|
||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||
builder.Services.AddSingleton<SystemAudioSource>();
|
||||
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
||||
@@ -39,6 +43,8 @@ builder.Services.AddSingleton<IMeetingAudioSource>(services => new CompositeMeet
|
||||
builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMetadataProvider>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IMeetingAudioSource, UnavailableMeetingAudioSource>();
|
||||
builder.Services.AddSingleton<IMicrophoneSwitchPromptService, NoopMicrophoneSwitchPromptService>();
|
||||
builder.Services.AddSingleton<IAgentApprovalPromptService, NoopAgentApprovalPromptService>();
|
||||
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
||||
@@ -146,6 +152,9 @@ builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRule
|
||||
#else
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<AgentApprovalService>();
|
||||
builder.Services.AddSingleton<WorkflowChangeRequestService>();
|
||||
builder.Services.AddHostedService(services => services.GetRequiredService<WorkflowChangeRequestService>());
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
|
||||
|
||||
@@ -2,5 +2,7 @@ namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
// The caller owns the returned source and disposes it when it implements IDisposable,
|
||||
// including when capture is canceled before enumeration starts.
|
||||
IMeetingAudioSource CreateCapture(MeetingAssistantOptions options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneSwitchPromptService
|
||||
{
|
||||
Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopMicrophoneSwitchPromptService : IMicrophoneSwitchPromptService
|
||||
{
|
||||
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
@@ -33,6 +33,17 @@ public interface ITranscriptStore
|
||||
string line,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
async Task AppendLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> lines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
await AppendLineAsync(session, line, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
|
||||
@@ -416,6 +416,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (run.ContextState is AssistantContextState.Summarizing or AssistantContextState.Finished or AssistantContextState.Error)
|
||||
{
|
||||
logger.LogInformation("Recording has reached summarization; continuing remaining processing in the background after the stop wait expired");
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
if (IsAzureSpeechRun(run))
|
||||
{
|
||||
logger.LogWarning("Timed out while draining Azure Speech transcription after recording stop; queueing offline transcription backlog item");
|
||||
|
||||
@@ -8,22 +8,30 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
private readonly IMicrophoneCaptureSourceFactory captureSources;
|
||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||
private readonly TimeSpan recoveryDelay;
|
||||
private readonly MicrophoneSwitchMonitor? switchMonitor;
|
||||
private readonly MicrophoneDeviceSelection? selection;
|
||||
|
||||
public MicrophoneAudioSource(
|
||||
IMicrophoneCaptureSourceFactory captureSources,
|
||||
ILogger<MicrophoneAudioSource> logger)
|
||||
: this(captureSources, logger, DefaultRecoveryDelay)
|
||||
ILogger<MicrophoneAudioSource> logger,
|
||||
MicrophoneSwitchMonitor? switchMonitor = null,
|
||||
MicrophoneDeviceSelection? selection = null)
|
||||
: this(captureSources, logger, DefaultRecoveryDelay, switchMonitor, selection)
|
||||
{
|
||||
}
|
||||
|
||||
internal MicrophoneAudioSource(
|
||||
IMicrophoneCaptureSourceFactory captureSources,
|
||||
ILogger<MicrophoneAudioSource> logger,
|
||||
TimeSpan recoveryDelay)
|
||||
TimeSpan recoveryDelay,
|
||||
MicrophoneSwitchMonitor? switchMonitor = null,
|
||||
MicrophoneDeviceSelection? selection = null)
|
||||
{
|
||||
this.captureSources = captureSources;
|
||||
this.logger = logger;
|
||||
this.recoveryDelay = recoveryDelay;
|
||||
this.switchMonitor = switchMonitor;
|
||||
this.selection = selection;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
@@ -34,21 +42,44 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using var session = new CaptureSession(selection, cancellationToken);
|
||||
var monitoring = switchMonitor?.RunAsync(session.SwitchTo, session.Token) ?? Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in CaptureWithRecoveryAsync(options, session, session.Token))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Stop();
|
||||
await monitoring;
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> CaptureWithRecoveryAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CaptureSession session,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var failedAttempts = 0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var captureToken = session.BeginCapture();
|
||||
IMeetingAudioSource? captureSource = null;
|
||||
IAsyncEnumerator<AudioChunk>? capture = null;
|
||||
Exception? failure = null;
|
||||
|
||||
try
|
||||
{
|
||||
capture = captureSources
|
||||
.CreateCapture(options)
|
||||
.CaptureAsync(options, cancellationToken)
|
||||
.GetAsyncEnumerator(cancellationToken);
|
||||
captureSource = captureSources.CreateCapture(options);
|
||||
capture = captureSource
|
||||
.CaptureAsync(options, captureToken)
|
||||
.GetAsyncEnumerator(captureToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -56,6 +87,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
failure = exception;
|
||||
}
|
||||
|
||||
using var captureOwner = captureSource as IDisposable;
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
@@ -65,14 +97,14 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
while (!captureToken.IsCancellationRequested)
|
||||
{
|
||||
var hasNext = false;
|
||||
try
|
||||
{
|
||||
hasNext = await capture.MoveNextAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -80,7 +112,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
failure = exception;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||
if (captureToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -102,7 +134,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -112,11 +144,19 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
}
|
||||
}
|
||||
|
||||
var switchRequested = captureToken.IsCancellationRequested;
|
||||
session.EndCapture();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (switchRequested)
|
||||
{
|
||||
logger.LogInformation("Replacing microphone capture after an accepted device-switch notification");
|
||||
continue;
|
||||
}
|
||||
|
||||
failedAttempts++;
|
||||
logger.LogWarning(
|
||||
failure,
|
||||
@@ -142,4 +182,53 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureSession(MicrophoneDeviceSelection? selection, CancellationToken cancellationToken) : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly CancellationTokenSource lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
private CancellationTokenSource? capture;
|
||||
|
||||
public CancellationToken Token => lifetime.Token;
|
||||
|
||||
public CancellationToken BeginCapture()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
capture = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
return capture.Token;
|
||||
}
|
||||
}
|
||||
|
||||
public void EndCapture()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
capture?.Dispose();
|
||||
capture = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SwitchTo(MicrophoneDevice device, CancellationToken promptCancellation)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (lifetime.IsCancellationRequested || promptCancellation.IsCancellationRequested || selection is null) return;
|
||||
selection.Select(device.Id);
|
||||
capture?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) lifetime.Cancel();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
EndCapture();
|
||||
lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneSwitchMonitor
|
||||
{
|
||||
private readonly IMicrophoneDeviceProvider devices;
|
||||
private readonly IMicrophoneSwitchPromptService prompts;
|
||||
private readonly ILogger<MicrophoneSwitchMonitor> logger;
|
||||
private readonly TimeSpan pollInterval;
|
||||
private readonly TimeSpan promptLifetime;
|
||||
|
||||
public MicrophoneSwitchMonitor(
|
||||
IMicrophoneDeviceProvider devices,
|
||||
IMicrophoneSwitchPromptService prompts,
|
||||
ILogger<MicrophoneSwitchMonitor> logger)
|
||||
: this(devices, prompts, logger, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))
|
||||
{
|
||||
}
|
||||
|
||||
internal MicrophoneSwitchMonitor(
|
||||
IMicrophoneDeviceProvider devices,
|
||||
IMicrophoneSwitchPromptService prompts,
|
||||
ILogger<MicrophoneSwitchMonitor> logger,
|
||||
TimeSpan pollInterval,
|
||||
TimeSpan promptLifetime)
|
||||
{
|
||||
this.devices = devices;
|
||||
this.prompts = prompts;
|
||||
this.logger = logger;
|
||||
this.pollInterval = pollInterval;
|
||||
this.promptLifetime = promptLifetime;
|
||||
}
|
||||
|
||||
public async Task RunAsync(Action<MicrophoneDevice, CancellationToken> switchMicrophone, CancellationToken cancellationToken)
|
||||
{
|
||||
var known = (ReadDevices() ?? []).Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var pending = new Dictionary<string, PendingPrompt>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(pollInterval, cancellationToken);
|
||||
var available = ReadDevices();
|
||||
if (available is null) continue;
|
||||
var availableIds = available.Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, prompt) in pending.ToArray())
|
||||
{
|
||||
if (!availableIds.Contains(id)) await prompt.Cancellation.CancelAsync();
|
||||
if (prompt.Task.IsCompleted)
|
||||
{
|
||||
prompt.Cancellation.Dispose();
|
||||
pending.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var device in available)
|
||||
{
|
||||
if (known.Add(device.Id))
|
||||
{
|
||||
var promptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
promptCancellation.CancelAfter(promptLifetime);
|
||||
pending.Add(device.Id, new PendingPrompt(promptCancellation,
|
||||
OfferAsync(device, switchMicrophone, promptCancellation.Token)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var prompt in pending.Values) await prompt.Cancellation.CancelAsync();
|
||||
await Task.WhenAll(pending.Values.Select(prompt => prompt.Task));
|
||||
foreach (var prompt in pending.Values) prompt.Cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OfferAsync(MicrophoneDevice device, Action<MicrophoneDevice, CancellationToken> switchMicrophone,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await prompts.ConfirmSwitchAsync(device, cancellationToken)
|
||||
&& !cancellationToken.IsCancellationRequested
|
||||
&& ReadDevices()?.Any(current => current.Id.Equals(device.Id, StringComparison.OrdinalIgnoreCase)) == true
|
||||
&& !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
switchMicrophone(device, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not offer microphone {MicrophoneName}", device.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<MicrophoneDevice>? ReadDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
return devices.GetAvailableMicrophones();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not check for newly available microphones");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingPrompt(CancellationTokenSource Cancellation, Task Task);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource, IDisposable
|
||||
{
|
||||
private readonly IWaveIn capture;
|
||||
private readonly string sourceName;
|
||||
private readonly ILogger logger;
|
||||
private int disposed;
|
||||
|
||||
public NaudioCaptureAudioSource(
|
||||
IWaveIn capture,
|
||||
@@ -22,13 +23,15 @@ internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureWith(capture, sourceName, logger, cancellationToken);
|
||||
return CaptureWith(cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
IWaveIn capture,
|
||||
string sourceName,
|
||||
ILogger logger,
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 0) capture.Dispose();
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<AudioChunk>();
|
||||
@@ -83,7 +86,7 @@ internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
}
|
||||
finally
|
||||
{
|
||||
capture.Dispose();
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,25 @@ public sealed class OfflineTranscriptionBacklogProcessor
|
||||
pipelineOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!finishedSegments.Any(segment =>
|
||||
segment.Kind == TranscriptionSegmentKind.Speech && !string.IsNullOrWhiteSpace(segment.Text)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Offline transcription replay returned no speech text; preserving the existing transcript and recovery files.");
|
||||
}
|
||||
|
||||
var lines = await TransformTranscriptLinesAsync(artifacts, runOptions, finishedSegments, cancellationToken);
|
||||
await transcriptStore.ReplaceLinesAsync(session, lines, cancellationToken);
|
||||
await transcriptStore.AppendLinesAsync(session,
|
||||
[
|
||||
"",
|
||||
"## Offline transcription recovery",
|
||||
"",
|
||||
"> The following transcript was recovered by replaying the entire recording. "
|
||||
+ "It may duplicate earlier transcript passages. "
|
||||
+ "Treat repeated passages as the same discussion when summarizing.",
|
||||
"",
|
||||
.. lines
|
||||
], cancellationToken);
|
||||
|
||||
meetingNote.Frontmatter.EndTime = item.InferredEndTime ?? DateTimeOffset.Now;
|
||||
var completedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
||||
|
||||
@@ -49,6 +49,25 @@ public sealed class VaultTranscriptStore : ITranscriptStore
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task AppendLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> lines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return WithFileGateAsync(
|
||||
session.TranscriptPath,
|
||||
() => File.AppendAllTextAsync(
|
||||
session.TranscriptPath,
|
||||
string.Concat(lines.Select(line => line + Environment.NewLine)),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#if WINDOWS
|
||||
using System.Collections.Concurrent;
|
||||
using CommunityToolkit.WinUI.Notifications;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class WindowsMicrophoneSwitchPromptService : IMicrophoneSwitchPromptService, IDisposable
|
||||
{
|
||||
private const string Source = "meeting-microphone-switch";
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> pending = new();
|
||||
private readonly ILogger<WindowsMicrophoneSwitchPromptService> logger;
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
|
||||
public WindowsMicrophoneSwitchPromptService(ILogger<WindowsMicrophoneSwitchPromptService> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) return false;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var response = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!registered)
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated += OnActivated;
|
||||
registered = true;
|
||||
}
|
||||
|
||||
pending[id] = response;
|
||||
new ToastContentBuilder()
|
||||
.AddArgument("source", Source)
|
||||
.AddArgument("promptId", id)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
.SetToastDuration(ToastDuration.Long)
|
||||
.AddText("New microphone available")
|
||||
.AddText($"Switch this meeting to {microphone.Name}?")
|
||||
.AddButton(new ToastButton().SetContent("Yes")
|
||||
.AddArgument("source", Source).AddArgument("promptId", id)
|
||||
.AddArgument("response", "yes").SetBackgroundActivation())
|
||||
.AddButton(new ToastButton().SetContent("No")
|
||||
.AddArgument("source", Source).AddArgument("promptId", id)
|
||||
.AddArgument("response", "no").SetBackgroundActivation())
|
||||
.Show(toast =>
|
||||
{
|
||||
toast.Tag = id;
|
||||
toast.Group = Source;
|
||||
toast.ExpirationTime = DateTimeOffset.Now.AddMinutes(1);
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("Offered newly available microphone {MicrophoneName}", microphone.Name);
|
||||
return await response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.TryRemove(id, out _);
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.History.Remove(id, Source);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not remove microphone notification {PromptId}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActivated(ToastNotificationActivatedEventArgsCompat args)
|
||||
{
|
||||
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
||||
if (arguments.GetValueOrDefault("source") != Source ||
|
||||
!arguments.TryGetValue("promptId", out var id) ||
|
||||
!pending.TryRemove(id, out var response)) return;
|
||||
|
||||
response.TrySetResult(arguments.GetValueOrDefault("response") == "yes");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (registered) ToastNotificationManagerCompat.OnActivated -= OnActivated;
|
||||
registered = false;
|
||||
foreach (var response in pending.Values) response.TrySetResult(false);
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -18,6 +18,12 @@ public sealed class BoundMeetingProjectResolver
|
||||
|
||||
public string ProjectsRoot => VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
|
||||
public List<BoundMeetingProject> GetAllProjects() => !Directory.Exists(ProjectsRoot) ? [] :
|
||||
Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public async Task<List<BoundMeetingProject>> GetBoundProjectsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -33,10 +39,8 @@ public sealed class BoundMeetingProjectResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
return GetAllProjects()
|
||||
.Where(project => projectNames.Contains(project.Name))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,20 @@ 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, 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.
|
||||
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. When embedding them, encode spaces in image-link targets as `%20`; never leave literal spaces in the link target. 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.
|
||||
""";
|
||||
|
||||
private const string ProjectAndRequestInstructions = """
|
||||
Project discovery and approval:
|
||||
list_projects returns every configured project's id, displayname, and description; only projects in the current meeting note's projects frontmatter are accessible for reads, writes, search, and historical summaries. Catalog visibility does not grant file access.
|
||||
Before finalizing the summary, use the catalog when meeting evidence suggests a missing project association. Call request_project_association with its exact id and a short evidence-based reason (one line, at most 100 characters). It waits for user approval for up to ten minutes. A denial or timeout grants no access; continue with the existing scope. Approval adds the project to the meeting and returns its current project instructions; follow them immediately.
|
||||
For each associated project, maintain a root PROJECT.md with YAML frontmatter name (display name) and description (at most 256 characters). Describe the project's purpose and distinguishing context so future summarizers can select it. Read existing content first and use write_projectfile to create missing metadata or update it from grounded knowledge, preserving other fields and body notes. Do not invent facts or write unassociated projects.
|
||||
If a workflow improvement would help, use request_workflow_change with a short one-line intention (at most 100 characters) and a detailed self-contained change prompt, preferably a scenario/specification with expected behavior. This returns requested immediately and does not mean approved or implemented. Continue summarizing; only user approval starts the separate settings agent. You cannot change workflows directly.
|
||||
""";
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
|
||||
@@ -50,6 +58,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
||||
? DefaultInitialPrompt
|
||||
: options.Agent.InitialPrompt.Trim();
|
||||
instructions = instructions.TrimEnd() + "\n\n" + ProjectAndRequestInstructions;
|
||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, options, cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(projectInstructions)
|
||||
? instructions
|
||||
@@ -60,44 +69,15 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, options, cancellationToken);
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var blocks = projects.Select(project =>
|
||||
$"# {project.Name}\n\n{project.Instructions.Trim()}");
|
||||
return "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolver = ReferenceEquals(options, this.options)
|
||||
? projectResolver
|
||||
: new BoundMeetingProjectResolver(options);
|
||||
var projects = new List<ProjectInstructions>();
|
||||
var blocks = new List<string>();
|
||||
foreach (var project in await resolver.GetBoundProjectsAsync(artifacts, cancellationToken))
|
||||
{
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (!File.Exists(agentsPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
projects.Add(new ProjectInstructions(project.Name, content));
|
||||
}
|
||||
blocks.Add(await ProjectMetadata.BuildContextAsync(project, cancellationToken));
|
||||
}
|
||||
|
||||
return projects;
|
||||
return blocks.Count == 0 ? "" : "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
private sealed record ProjectInstructions(string Name, string Instructions);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using System.Text.Json;
|
||||
using MeetingAssistant.Notifications;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
@@ -16,6 +19,8 @@ public sealed class MeetingSummaryTools
|
||||
private readonly SummaryAgentWriteAudit? writeAudit;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
private readonly AgentApprovalService? approvals;
|
||||
private readonly WorkflowChangeRequestService? workflowRequests;
|
||||
|
||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
||||
: this(artifacts, new MeetingAssistantOptions(), null)
|
||||
@@ -27,7 +32,9 @@ public sealed class MeetingSummaryTools
|
||||
MeetingAssistantOptions options,
|
||||
IDictationWordStore? dictationWordStore = null,
|
||||
SummaryAgentWriteAudit? writeAudit = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
|
||||
AgentApprovalService? approvals = null,
|
||||
WorkflowChangeRequestService? workflowRequests = null)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
this.options = options;
|
||||
@@ -35,6 +42,8 @@ public sealed class MeetingSummaryTools
|
||||
this.writeAudit = writeAudit;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
|
||||
projectResolver = new BoundMeetingProjectResolver(options);
|
||||
this.approvals = approvals;
|
||||
this.workflowRequests = workflowRequests;
|
||||
}
|
||||
|
||||
public Task<string> ReadTranscript(int? @from = null, int? to = null, int? tail = null)
|
||||
@@ -291,8 +300,71 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
public async Task<string> ListProjects()
|
||||
{
|
||||
var projects = await GetBoundProjectsAsync();
|
||||
return string.Join('\n', projects.Select(project => project.Name));
|
||||
var projects = await Task.WhenAll(projectResolver.GetAllProjects().Select(project => ProjectMetadata.ReadAsync(project)));
|
||||
return JsonSerializer.Serialize(projects.Select(project => new
|
||||
{
|
||||
id = project.Id, displayname = project.DisplayName, description = project.Description
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task<string> RequestProjectAssociation(string project_id, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason) || reason.Length > 100 || reason.Contains('\n') || reason.Contains('\r'))
|
||||
return "Refused: reason must be a nonempty single line of at most 100 characters.";
|
||||
var project = projectResolver.GetAllProjects()
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, project_id, StringComparison.OrdinalIgnoreCase));
|
||||
if (project is null) return "Refused: unknown project ID.";
|
||||
var associated = await projectResolver.ReadMeetingProjectNamesAsync(artifacts, cancellationToken);
|
||||
if (!associated.Contains(project.Name))
|
||||
{
|
||||
var metadata = await ProjectMetadata.ReadAsync(project, cancellationToken);
|
||||
if (approvals is null || !await approvals.ConfirmAsync("Project association requested",
|
||||
$"Associate {metadata.DisplayName} ({metadata.Id}) with {Path.GetFileName(artifacts.MeetingNotePath)}?\n{reason}",
|
||||
cancellationToken))
|
||||
return "Denied: project association was not approved.";
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!Directory.Exists(project.Path) || !File.Exists(artifacts.MeetingNotePath))
|
||||
return "Refused: the project or meeting note no longer exists.";
|
||||
await AddProjectAssociationAsync(project.Name, cancellationToken);
|
||||
}
|
||||
|
||||
return "Approved: project is associated with this meeting.\n\n"
|
||||
+ await ProjectMetadata.BuildContextAsync(project, cancellationToken);
|
||||
}
|
||||
|
||||
public string RequestWorkflowChange(string intention, string detailed_prompt)
|
||||
=> workflowRequests?.Request(intention, detailed_prompt, artifacts.MeetingNotePath)
|
||||
?? "Refused: workflow change requests are unavailable.";
|
||||
|
||||
private async Task AddProjectAssociationAsync(string projectId, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitRequired(content,
|
||||
"Meeting note requires frontmatter.", "Meeting note frontmatter is not closed.");
|
||||
var yaml = new YamlStream();
|
||||
yaml.Load(new StringReader(document.Frontmatter));
|
||||
var mapping = (YamlMappingNode)yaml.Documents.Single().RootNode;
|
||||
mapping.Children.TryGetValue(new YamlScalarNode("projects"), out var existing);
|
||||
var projects = existing as YamlSequenceNode ?? new YamlSequenceNode();
|
||||
if (existing is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value))
|
||||
projects.Add(new YamlScalarNode(scalar.Value));
|
||||
if (!projects.Children.OfType<YamlScalarNode>().Any(value => string.Equals(value.Value, projectId, StringComparison.OrdinalIgnoreCase)))
|
||||
projects.Add(new YamlScalarNode(projectId));
|
||||
mapping.Children[new YamlScalarNode("projects")] = projects;
|
||||
using var writer = new StringWriter();
|
||||
yaml.Save(writer, assignAnchors: false);
|
||||
var pendingPath = artifacts.MeetingNotePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(pendingPath, "---\n" + writer + "---\n\n" + document.Body, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Replace(pendingPath, artifacts.MeetingNotePath, destinationBackupFileName: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pendingPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ListProjectFiles(string project)
|
||||
@@ -375,10 +447,18 @@ public sealed class MeetingSummaryTools
|
||||
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
|
||||
}
|
||||
|
||||
var target = ResolveExistingProjectFilePath(project, path);
|
||||
var target = await ResolveBoundProjectFileTargetAsync(project, path);
|
||||
if (target is null)
|
||||
{
|
||||
return "Refused: project does not exist or the path escapes the project folder.";
|
||||
return "Refused: project is not assigned to this meeting, does not exist, or the path escapes the project folder.";
|
||||
}
|
||||
|
||||
var existingContent = File.Exists(target.Path) ? await File.ReadAllTextAsync(target.Path) : "";
|
||||
var updatedContent = AgentFileToolContent.ApplyLineEdit(existingContent, content, editMode);
|
||||
if (string.Equals(Path.GetRelativePath(target.Project.Path, target.Path), "PROJECT.md", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ProjectMetadata.IsValidContent(updatedContent))
|
||||
{
|
||||
return "Refused: PROJECT.md requires YAML frontmatter name and description; description must be at most 256 characters.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
||||
@@ -386,11 +466,11 @@ public sealed class MeetingSummaryTools
|
||||
{
|
||||
await writeAudit.CaptureFileWriteAsync(
|
||||
target.Path,
|
||||
() => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode));
|
||||
() => File.WriteAllTextAsync(target.Path, updatedContent));
|
||||
}
|
||||
else
|
||||
{
|
||||
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
|
||||
await File.WriteAllTextAsync(target.Path, updatedContent);
|
||||
}
|
||||
|
||||
return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}";
|
||||
@@ -589,7 +669,7 @@ public sealed class MeetingSummaryTools
|
||||
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
|
||||
}
|
||||
|
||||
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
|
||||
private async Task<ProjectFileTarget?> ResolveBoundProjectFileTargetAsync(string project, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project) ||
|
||||
string.IsNullOrWhiteSpace(path) ||
|
||||
@@ -598,15 +678,7 @@ public sealed class MeetingSummaryTools
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectsRoot = GetProjectsRoot();
|
||||
if (!Directory.Exists(projectsRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
|
||||
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
||||
var projectFolder = await ResolveBoundProjectAsync(project);
|
||||
if (projectFolder is null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -19,6 +20,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
|
||||
private readonly IDictationWordStore dictationWordStore;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly AgentApprovalService approvals;
|
||||
private readonly WorkflowChangeRequestService workflowRequests;
|
||||
|
||||
public OpenAiMeetingSummaryAgentPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
@@ -28,7 +31,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
IMeetingSummaryFailureWriter failureWriter,
|
||||
IMeetingSummaryInstructionBuilder instructionBuilder,
|
||||
IDictationWordStore dictationWordStore,
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine)
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine,
|
||||
AgentApprovalService approvals,
|
||||
WorkflowChangeRequestService workflowRequests)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
@@ -38,6 +43,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine;
|
||||
this.approvals = approvals;
|
||||
this.workflowRequests = workflowRequests;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
@@ -60,7 +67,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
options,
|
||||
dictationWordStore,
|
||||
writeAudit,
|
||||
meetingWorkflowEngine);
|
||||
meetingWorkflowEngine,
|
||||
approvals,
|
||||
workflowRequests);
|
||||
var tools = CreateTools(meetingTools);
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||
@@ -80,7 +89,11 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(
|
||||
loggerFactory,
|
||||
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
|
||||
client =>
|
||||
{
|
||||
client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync;
|
||||
client.AllowConcurrentInvocation = false;
|
||||
})
|
||||
.Build()
|
||||
.AsAIAgent(
|
||||
instructions,
|
||||
@@ -176,7 +189,15 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjects,
|
||||
"list_projects",
|
||||
"List the configured project folders bound to the current meeting note frontmatter."),
|
||||
"List all configured projects with id, displayname and description. Catalog visibility does not grant access; project files require current meeting association."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.RequestProjectAssociation,
|
||||
"request_project_association",
|
||||
"Request user approval to associate a project_id with this meeting. reason must be one line and at most 100 characters. Waits up to ten minutes; approval adds the project and returns its AGENTS.md if present."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.RequestWorkflowChange,
|
||||
"request_workflow_change",
|
||||
"Request a workflow change with a one-line intention (maximum 100 characters) and a detailed_prompt describing the desired scenario/specification. Returns requested immediately; user approval separately opens and starts the settings agent."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjectFiles,
|
||||
"list_projectfiles",
|
||||
@@ -188,7 +209,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
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."),
|
||||
"Write a file inside a project associated with the current meeting. Root PROJECT.md requires YAML name and description (maximum 256 characters). With no line arguments, append or create. Set replace_file=true only for intentional whole-file replacement. With from and to, replace that inclusive 1-based line range; with insert, insert at that line."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListPastProjectMeetings,
|
||||
"list_past_project_meetings",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed record ProjectMetadata(string Id, string DisplayName, string Description)
|
||||
{
|
||||
public static async Task<string> BuildContextAsync(BoundMeetingProject project, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = await ReadAsync(project, cancellationToken);
|
||||
var context = $"# {metadata.Id}\nDisplay name: {metadata.DisplayName}\nDescription: {metadata.Description}";
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (File.Exists(agentsPath))
|
||||
{
|
||||
var instructions = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(instructions)) context += "\n\n" + instructions.Trim();
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
public static bool IsValidContent(string content) => Parse(content) is not null;
|
||||
|
||||
public static async Task<ProjectMetadata> ReadAsync(BoundMeetingProject project, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = Path.Combine(project.Path, "PROJECT.md");
|
||||
var metadata = File.Exists(path) ? Parse(await File.ReadAllTextAsync(path, cancellationToken)) : null;
|
||||
return new(project.Name,
|
||||
string.IsNullOrWhiteSpace(metadata?.Name) ? project.Name : metadata.Name.Trim(),
|
||||
metadata?.Description?.Trim() ?? "");
|
||||
}
|
||||
|
||||
private static Frontmatter? Parse(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter) return null;
|
||||
var metadata = FrontmatterYamlDeserializer.Create().Deserialize<Frontmatter>(document.Frontmatter);
|
||||
return string.IsNullOrWhiteSpace(metadata?.Name) || metadata.Description is null || metadata.Description.Length > 256
|
||||
? null
|
||||
: metadata;
|
||||
}
|
||||
catch (YamlException) { return null; }
|
||||
catch (InvalidDataException) { return null; }
|
||||
}
|
||||
|
||||
private sealed class Frontmatter
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
[YamlMember(Alias = "description")]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Workflow;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed class WorkflowChangeRequestService(
|
||||
AgentApprovalService approvals,
|
||||
IWorkflowRulesEditorWindowService editor,
|
||||
ILogger<WorkflowChangeRequestService> logger) : BackgroundService
|
||||
{
|
||||
private readonly Channel<RequestDetails> requests = Channel.CreateUnbounded<RequestDetails>(
|
||||
new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public string Request(string intention, string detailedPrompt, string meetingNotePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(intention) || intention.Length > 100 || intention.Contains('\r') || intention.Contains('\n'))
|
||||
return "Refused: intention must be a nonempty single line of at most 100 characters.";
|
||||
if (string.IsNullOrWhiteSpace(detailedPrompt))
|
||||
return "Refused: supply a detailed change prompt, preferably a scenario or specification.";
|
||||
var request = new RequestDetails(Guid.NewGuid().ToString("N"), intention, detailedPrompt, meetingNotePath);
|
||||
return requests.Writer.TryWrite(request) ? $"Requested: {request.Id}. User approval is pending."
|
||||
: "Refused: application is stopping.";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var pending = new List<Task>();
|
||||
try
|
||||
{
|
||||
await foreach (var request in requests.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
pending.RemoveAll(task => task.IsCompleted);
|
||||
pending.Add(HandleAsync(request, stoppingToken));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
|
||||
finally
|
||||
{
|
||||
await Task.WhenAll(pending);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(RequestDetails request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await approvals.ConfirmAsync($"Workflow change requested: {request.Intention}",
|
||||
$"The summarizer for {Path.GetFileName(request.MeetingNotePath)} requests a workflow change. Open the settings agent to apply it?",
|
||||
cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
editor.ShowWithPrompt(request.DetailedPrompt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Workflow request {RequestId} could not be handled", request.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
requests.Writer.TryComplete();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record RequestDetails(string Id, string Intention, string DetailedPrompt, string MeetingNotePath);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ namespace MeetingAssistant.Workflow;
|
||||
public interface IWorkflowRulesEditorWindowService
|
||||
{
|
||||
void Show();
|
||||
void ShowWithPrompt(string initialPrompt);
|
||||
}
|
||||
|
||||
public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
@@ -18,4 +19,6 @@ public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorW
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is only available on Windows");
|
||||
}
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt) => Show();
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public async Task SendAsync(CancellationToken cancellationToken = default)
|
||||
public Task SendAsync(CancellationToken cancellationToken = default) => SendPromptAsync(Draft, cancellationToken);
|
||||
|
||||
public async Task SendPromptAsync(string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prompt = Draft.Trim();
|
||||
prompt = prompt.Trim();
|
||||
if (string.IsNullOrWhiteSpace(prompt) || IsThinking)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -37,6 +37,26 @@ internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditor
|
||||
dispatcher?.BeginInvoke(ShowOnUiThread);
|
||||
}
|
||||
|
||||
public void ShowWithPrompt(string initialPrompt)
|
||||
{
|
||||
EnsureUiThread();
|
||||
dispatcher?.BeginInvoke(new Action(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
|
||||
var requestedWindow = new WorkflowRulesEditorWpfWindow(viewModel, linkResolver, () => { });
|
||||
requestedWindow.Show();
|
||||
requestedWindow.Activate();
|
||||
await viewModel.SendPromptAsync(initialPrompt);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Could not start settings agent for an approved workflow request");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void EnsureUiThread()
|
||||
{
|
||||
lock (sync)
|
||||
|
||||
Reference in New Issue
Block a user