Public Access
Add taskbar controls and summary oneliner
This commit is contained in:
@@ -111,6 +111,23 @@ public sealed class LaunchProfileOptionsProviderTests
|
||||
Assert.DoesNotContain(hotkeys, hotkey => hotkey.ProfileName == "english");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProfilesReturnsDefaultAndConfiguredNamedProfiles()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
|
||||
});
|
||||
|
||||
var profiles = provider.GetProfiles();
|
||||
|
||||
Assert.Equal(["default", "english"], profiles.Select(profile => profile.Name));
|
||||
Assert.Equal("azure-speech", profiles.Single(profile => profile.Name == "english").Options.Recording.TranscriptionProvider);
|
||||
Assert.Equal("en-US", profiles.Single(profile => profile.Name == "english").Options.AzureSpeech.Language);
|
||||
}
|
||||
|
||||
private static ConfigurationLaunchProfileOptionsProvider CreateProvider(
|
||||
IReadOnlyDictionary<string, string?> values)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,9 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
Assert.Contains("merge=true", instructions);
|
||||
Assert.Contains("delete_identity", instructions);
|
||||
Assert.Contains("wrongfully matched", instructions);
|
||||
Assert.Contains("oneliner", instructions);
|
||||
Assert.Contains("very short", instructions);
|
||||
Assert.Contains("conciseness is more important", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -48,12 +48,13 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Equal("", await tools.ReadGlossary());
|
||||
|
||||
Assert.False(tools.SummaryWasWritten);
|
||||
var result = await tools.WriteSummary("# Summary\n\n- Done.");
|
||||
var result = await tools.WriteSummary("# Summary\n\n- Done.", "Done items");
|
||||
|
||||
Assert.Equal(artifacts.SummaryPath, result);
|
||||
Assert.True(tools.SummaryWasWritten);
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("title: Meeting", summary);
|
||||
Assert.Contains("oneliner: Done items", summary);
|
||||
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("attendees:", summary);
|
||||
@@ -101,7 +102,7 @@ public sealed class MeetingSummaryToolTests
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary");
|
||||
await tools.WriteSummary("# Summary", "Preserved attendee summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("attendees:", summary);
|
||||
@@ -144,7 +145,7 @@ public sealed class MeetingSummaryToolTests
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary");
|
||||
await tools.WriteSummary("# Summary", "Current project summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("projects:", summary);
|
||||
@@ -612,7 +613,7 @@ public sealed class MeetingSummaryToolTests
|
||||
audit);
|
||||
|
||||
await tools.WriteContext("owned context");
|
||||
await tools.WriteSummary("owned summary");
|
||||
await tools.WriteSummary("owned summary", "Owned summary");
|
||||
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO", from: 2, to: 2);
|
||||
await tools.AddDictationWord("PBI");
|
||||
await audit.AppendToAssistantContextAsync(CancellationToken.None);
|
||||
@@ -627,4 +628,30 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.DoesNotContain("owned summary", context);
|
||||
Assert.DoesNotContain("- Context before.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSummaryRefusesOneLinerWithLineBreaks()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
---
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.WriteSummary("# Summary", "First line\nsecond line");
|
||||
|
||||
Assert.Equal("Refused: oneliner must be a single line.", result);
|
||||
Assert.False(tools.SummaryWasWritten);
|
||||
Assert.False(File.Exists(artifacts.SummaryPath));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Taskbar;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class TaskbarIconTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdleMenuOffersStartForEachConfiguredProfile()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(),
|
||||
[Profile("default"), Profile("english")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Idle, menu.State);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "english");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
|
||||
[Profile("default"), Profile("english"), Profile("french")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "english");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "french");
|
||||
Assert.DoesNotContain(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "default");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StartRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessingMenuShowsSummarizingButAllowsStartingNewRecordings()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(state: RecordingProcessState.Summarizing, profile: "default"),
|
||||
[Profile("default"), Profile("english")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Summarizing, menu.State);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "english");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingStateWinsOverOlderSummarizingWork()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "english"),
|
||||
[Profile("default"), Profile("english")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
}
|
||||
|
||||
private static LaunchProfile Profile(string name)
|
||||
{
|
||||
return new LaunchProfile(name, new MeetingAssistantOptions());
|
||||
}
|
||||
|
||||
private static RecordingStatus Status(
|
||||
bool isRecording = false,
|
||||
RecordingProcessState state = RecordingProcessState.Idle,
|
||||
string? profile = null)
|
||||
{
|
||||
return new RecordingStatus(
|
||||
isRecording,
|
||||
state == RecordingProcessState.Idle ? null : "transcript.md",
|
||||
state == RecordingProcessState.Idle ? null : "meeting.md",
|
||||
state == RecordingProcessState.Idle ? null : "context.md",
|
||||
state == RecordingProcessState.Idle ? null : "summary.md",
|
||||
state,
|
||||
profile);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ public interface ILaunchProfileOptionsProvider
|
||||
{
|
||||
LaunchProfile GetRequiredProfile(string? name);
|
||||
|
||||
IReadOnlyList<LaunchProfile> GetProfiles();
|
||||
|
||||
IReadOnlyList<LaunchProfileHotkey> GetHotkeys();
|
||||
}
|
||||
|
||||
@@ -77,6 +79,13 @@ public sealed class ConfigurationLaunchProfileOptionsProvider : ILaunchProfileOp
|
||||
return hotkeys;
|
||||
}
|
||||
|
||||
public IReadOnlyList<LaunchProfile> GetProfiles()
|
||||
{
|
||||
return GetProfileNames()
|
||||
.Select(GetRequiredProfile)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<LaunchProfileHotkey> CreateHotkeys(string profileName)
|
||||
{
|
||||
var profile = GetRequiredProfile(profileName);
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-windows'">
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MeetingAssistant.Tests" />
|
||||
</ItemGroup>
|
||||
@@ -30,6 +34,7 @@
|
||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
<Compile Remove="Taskbar\WindowsTaskbarIconService.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class MeetingArtifactFrontmatter
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
public string? OneLiner { get; set; }
|
||||
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
@@ -38,6 +40,7 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("---");
|
||||
AppendScalar(builder, "title", frontmatter.Title);
|
||||
AppendScalarIfNotEmpty(builder, "oneliner", frontmatter.OneLiner);
|
||||
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
||||
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
||||
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
|
||||
@@ -124,6 +127,16 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
builder.AppendLine(string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value));
|
||||
}
|
||||
|
||||
private static void AppendScalarIfNotEmpty(StringBuilder builder, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppendScalar(builder, key, value);
|
||||
}
|
||||
|
||||
private static void AppendQuoted(StringBuilder builder, string key, string value)
|
||||
{
|
||||
builder.Append(key);
|
||||
|
||||
@@ -6,6 +6,7 @@ using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Taskbar;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -80,6 +81,7 @@ builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
||||
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddHostedService<GlobalHotkeyService>();
|
||||
builder.Services.AddHostedService<WindowsTaskbarIconService>();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -82,12 +82,26 @@ public sealed class MeetingRecordingCoordinator
|
||||
currentArtifacts?.TranscriptPath ?? currentSession?.TranscriptPath,
|
||||
currentArtifacts?.MeetingNotePath ?? currentMeetingNote?.Path,
|
||||
currentArtifacts?.AssistantContextPath,
|
||||
currentArtifacts?.SummaryPath);
|
||||
currentArtifacts?.SummaryPath,
|
||||
GetProcessState(currentRun),
|
||||
currentRun?.LaunchProfileName);
|
||||
|
||||
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
||||
|
||||
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
||||
|
||||
private static RecordingProcessState GetProcessState(RecordingRun? run)
|
||||
{
|
||||
if (run is null)
|
||||
{
|
||||
return RecordingProcessState.Idle;
|
||||
}
|
||||
|
||||
return run.IsCaptureStopping
|
||||
? RecordingProcessState.Summarizing
|
||||
: RecordingProcessState.Recording;
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await ToggleAsync(null, cancellationToken);
|
||||
@@ -1687,4 +1701,13 @@ public sealed record RecordingStatus(
|
||||
string? TranscriptPath,
|
||||
string? MeetingNotePath,
|
||||
string? AssistantContextPath,
|
||||
string? SummaryPath);
|
||||
string? SummaryPath,
|
||||
RecordingProcessState State = RecordingProcessState.Idle,
|
||||
string? LaunchProfile = null);
|
||||
|
||||
public enum RecordingProcessState
|
||||
{
|
||||
Idle,
|
||||
Recording,
|
||||
Summarizing
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
|
||||
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
|
||||
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. If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
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.
|
||||
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. Keep user-facing summary content in the summary note.
|
||||
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
|
||||
|
||||
@@ -206,8 +206,19 @@ public sealed class MeetingSummaryTools
|
||||
return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}.";
|
||||
}
|
||||
|
||||
public async Task<string> WriteSummary(string markdown, string? title = null)
|
||||
public async Task<string> WriteSummary(string markdown, string oneliner, string? title = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(oneliner))
|
||||
{
|
||||
return "Refused: oneliner must not be empty.";
|
||||
}
|
||||
|
||||
var trimmedOneLiner = oneliner.Trim();
|
||||
if (ContainsLineBreak(trimmedOneLiner))
|
||||
{
|
||||
return "Refused: oneliner must be a single line.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
var meetingNote = await ReadMeetingNoteAsync();
|
||||
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
||||
@@ -218,6 +229,7 @@ public sealed class MeetingSummaryTools
|
||||
meetingNote,
|
||||
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
||||
CancellationToken.None);
|
||||
frontmatter.OneLiner = trimmedOneLiner;
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
||||
@@ -225,6 +237,12 @@ public sealed class MeetingSummaryTools
|
||||
return artifacts.SummaryPath;
|
||||
}
|
||||
|
||||
private static bool ContainsLineBreak(string value)
|
||||
{
|
||||
return value.Contains('\n', StringComparison.Ordinal) ||
|
||||
value.Contains('\r', StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public bool SummaryWasWritten { get; private set; }
|
||||
|
||||
public async Task<string> WriteContext(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Taskbar;
|
||||
|
||||
public enum MeetingTaskbarAction
|
||||
{
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
AbortRecording,
|
||||
SwitchProfile
|
||||
}
|
||||
|
||||
public sealed record MeetingTaskbarMenu(
|
||||
RecordingProcessState State,
|
||||
string Tooltip,
|
||||
IReadOnlyList<MeetingTaskbarMenuItem> Items);
|
||||
|
||||
public sealed record MeetingTaskbarMenuItem(
|
||||
string Text,
|
||||
MeetingTaskbarAction Action,
|
||||
string? ProfileName = null);
|
||||
|
||||
public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
public static MeetingTaskbarMenu Build(
|
||||
RecordingStatus status,
|
||||
IReadOnlyList<LaunchProfile> launchProfiles)
|
||||
{
|
||||
var items = new List<MeetingTaskbarMenuItem>();
|
||||
|
||||
if (status.IsRecording)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Stop meeting recording and transcribe",
|
||||
MeetingTaskbarAction.StopRecording));
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Cancel meeting recording and discard",
|
||||
MeetingTaskbarAction.AbortRecording));
|
||||
|
||||
foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status)))
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
$"Switch to {profile.Name}",
|
||||
MeetingTaskbarAction.SwitchProfile,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var profile in launchProfiles)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
$"Start meeting recording ({profile.Name})",
|
||||
MeetingTaskbarAction.StartRecording,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
|
||||
return new MeetingTaskbarMenu(
|
||||
status.State,
|
||||
BuildTooltip(status),
|
||||
items);
|
||||
}
|
||||
|
||||
private static string BuildTooltip(RecordingStatus status)
|
||||
{
|
||||
return status.State switch
|
||||
{
|
||||
RecordingProcessState.Recording => string.IsNullOrWhiteSpace(status.LaunchProfile)
|
||||
? "Meeting Assistant - recording"
|
||||
: $"Meeting Assistant - recording ({status.LaunchProfile})",
|
||||
RecordingProcessState.Summarizing => "Meeting Assistant - summarizing",
|
||||
_ => "Meeting Assistant - idle"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsActiveProfile(
|
||||
LaunchProfile profile,
|
||||
RecordingStatus status)
|
||||
{
|
||||
return string.Equals(
|
||||
profile.Name,
|
||||
status.LaunchProfile,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Taskbar;
|
||||
|
||||
public sealed class WindowsTaskbarIconService : IHostedService, IDisposable
|
||||
{
|
||||
private readonly MeetingRecordingCoordinator coordinator;
|
||||
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
||||
private readonly ILogger<WindowsTaskbarIconService> logger;
|
||||
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private Thread? uiThread;
|
||||
private SynchronizationContext? uiContext;
|
||||
private NotifyIcon? notifyIcon;
|
||||
private ContextMenuStrip? contextMenu;
|
||||
private System.Windows.Forms.Timer? refreshTimer;
|
||||
private Icon? currentIcon;
|
||||
private RecordingProcessState? currentState;
|
||||
|
||||
public WindowsTaskbarIconService(
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
ILogger<WindowsTaskbarIconService> logger)
|
||||
{
|
||||
this.coordinator = coordinator;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
logger.LogInformation("Taskbar icon is only supported on Windows");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
uiThread = new Thread(RunMessageLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Meeting Assistant Taskbar Icon"
|
||||
};
|
||||
uiThread.SetApartmentState(ApartmentState.STA);
|
||||
uiThread.Start();
|
||||
|
||||
return started.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = uiContext;
|
||||
if (context is not null)
|
||||
{
|
||||
context.Post(_ => Application.ExitThread(), null);
|
||||
}
|
||||
|
||||
if (uiThread is not null)
|
||||
{
|
||||
while (uiThread.IsAlive && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(50, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
currentIcon?.Dispose();
|
||||
contextMenu?.Dispose();
|
||||
notifyIcon?.Dispose();
|
||||
refreshTimer?.Dispose();
|
||||
}
|
||||
|
||||
private void RunMessageLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
|
||||
SynchronizationContext.SetSynchronizationContext(uiContext);
|
||||
|
||||
contextMenu = new ContextMenuStrip();
|
||||
contextMenu.Opening += (_, _) => RebuildMenu();
|
||||
notifyIcon = new NotifyIcon
|
||||
{
|
||||
ContextMenuStrip = contextMenu,
|
||||
Visible = true
|
||||
};
|
||||
refreshTimer = new System.Windows.Forms.Timer
|
||||
{
|
||||
Interval = 1000
|
||||
};
|
||||
refreshTimer.Tick += (_, _) => RefreshVisualState();
|
||||
refreshTimer.Start();
|
||||
RefreshVisualState();
|
||||
started.TrySetResult();
|
||||
|
||||
Application.Run();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
started.TrySetException(exception);
|
||||
logger.LogError(exception, "Taskbar icon service failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
refreshTimer?.Stop();
|
||||
if (notifyIcon is not null)
|
||||
{
|
||||
notifyIcon.Visible = false;
|
||||
}
|
||||
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshVisualState()
|
||||
{
|
||||
if (notifyIcon is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var menu = BuildMenu();
|
||||
if (currentState != menu.State)
|
||||
{
|
||||
var nextIcon = CreateIcon(menu.State);
|
||||
notifyIcon.Icon = nextIcon;
|
||||
currentIcon?.Dispose();
|
||||
currentIcon = nextIcon;
|
||||
currentState = menu.State;
|
||||
}
|
||||
|
||||
notifyIcon.Text = TruncateTooltip(menu.Tooltip);
|
||||
}
|
||||
|
||||
private void RebuildMenu()
|
||||
{
|
||||
if (contextMenu is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var menu = BuildMenu();
|
||||
contextMenu.Items.Clear();
|
||||
foreach (var menuItem in menu.Items)
|
||||
{
|
||||
var item = new ToolStripMenuItem(menuItem.Text)
|
||||
{
|
||||
Tag = menuItem
|
||||
};
|
||||
item.Click += (_, _) => _ = ExecuteMenuItemAsync(menuItem);
|
||||
contextMenu.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private MeetingTaskbarMenu BuildMenu()
|
||||
{
|
||||
return MeetingTaskbarMenuBuilder.Build(
|
||||
coordinator.CurrentStatus,
|
||||
launchProfiles.GetProfiles());
|
||||
}
|
||||
|
||||
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (item.Action)
|
||||
{
|
||||
case MeetingTaskbarAction.StartRecording:
|
||||
await coordinator.StartAsync(item.ProfileName, CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.StopRecording:
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.AbortRecording:
|
||||
await coordinator.AbortAsync(CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.SwitchProfile:
|
||||
await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
|
||||
item.Action,
|
||||
item.ProfileName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateTooltip(string tooltip)
|
||||
{
|
||||
return tooltip.Length <= 63 ? tooltip : tooltip[..63];
|
||||
}
|
||||
|
||||
private static Icon CreateIcon(RecordingProcessState state)
|
||||
{
|
||||
var (color, text) = state switch
|
||||
{
|
||||
RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"),
|
||||
RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"),
|
||||
_ => (Color.FromArgb(75, 85, 99), "I")
|
||||
};
|
||||
|
||||
using var bitmap = new Bitmap(16, 16);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, 1, 1, 14, 14);
|
||||
using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel);
|
||||
using var textBrush = new SolidBrush(Color.White);
|
||||
var size = graphics.MeasureString(text, font);
|
||||
graphics.DrawString(
|
||||
text,
|
||||
font,
|
||||
textBrush,
|
||||
(16 - size.Width) / 2,
|
||||
(16 - size.Height) / 2);
|
||||
}
|
||||
|
||||
var handle = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
return (Icon)Icon.FromHandle(handle).Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(handle);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ The application is intended to:
|
||||
- toggle recording/transcription mode through a configurable global hotkey
|
||||
- switch the active transcription profile during a meeting with a profile-specific toggle hotkey
|
||||
- abort and discard an active recording through a configurable global hotkey
|
||||
- show a Windows taskbar notification icon with state-aware recording controls
|
||||
- capture active-window screenshots through a configurable global hotkey
|
||||
- capture microphone input and computer output into one transcription stream
|
||||
- transcribe meetings with speaker attribution
|
||||
@@ -227,6 +228,8 @@ Meeting notes link to separate transcript, assistant context, and summary markdo
|
||||
|
||||
Launch profile toggle hotkeys start a meeting when idle. When a different launch profile toggle is pressed during an active meeting, Meeting Assistant keeps the same meeting note, transcript, assistant context, and metadata; drains the current speech recognition pipeline without summarizing; appends a transcript marker; clears run-local speaker mappings; and continues transcription through the target profile. Pressing the same active profile toggle still stops the meeting normally.
|
||||
|
||||
On Windows, Meeting Assistant shows a taskbar notification icon. The icon indicates idle, recording, or post-recording summarizing/processing state. Its right-click menu can start a recording for any configured launch profile, stop and transcribe the active recording, cancel and discard the active recording, or switch an active recording to another configured launch profile. A newer active recording takes priority in the icon state even if an older stopped run is still summarizing.
|
||||
|
||||
Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary.
|
||||
|
||||
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
|
||||
@@ -245,7 +248,7 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va
|
||||
|
||||
`Screenshots:Ocr` optionally enables vision extraction for screenshots. When `Enabled` is true, Meeting Assistant sends the screenshot and prompt to an OpenAI-compatible Responses endpoint and replaces the screenshot OCR placeholder with the model output. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model, which keeps local OCR on the same LiteLLM backend as summarization. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. The default prompt asks the model to say whether visible participants are clearly the exact meeting participants or only a partial result, and to return pixel crop coordinates when it can confidently isolate only the presentation or shared-screen content; valid crop boxes are saved as `*-cropped.png` beside the original screenshot and linked before the OCR block. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`.
|
||||
|
||||
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear; partial participant evidence from screenshots should not be used to remove invitees by itself. It can also override a transcript speaker label with a named speaker only when the evidence is very certain, such as a user note, OCR at a correlating meeting timestamp, or a clear context cue. If that named speaker already exists in the transcript, `override_speaker` refuses by default; the agent must pass `merge=true` only when it is certain both labels are the same identity. When it is certain that a speaker identity was wrongfully matched, it can delete that identity, relabeling transcript speaker labels to `Removed-<n>` and removing the local speaker identity database row before future matching. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts.
|
||||
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks. The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear; partial participant evidence from screenshots should not be used to remove invitees by itself. It can also override a transcript speaker label with a named speaker only when the evidence is very certain, such as a user note, OCR at a correlating meeting timestamp, or a clear context cue. If that named speaker already exists in the transcript, `override_speaker` refuses by default; the agent must pass `merge=true` only when it is certain both labels are the same identity. When it is certain that a speaker identity was wrongfully matched, it can delete that identity, relabeling transcript speaker labels to `Removed-<n>` and removing the local speaker identity database row before future matching. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts.
|
||||
|
||||
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
Meeting summaries are useful as full notes, but downstream views and file lists also need a very short human-readable blurb without parsing the full markdown body.
|
||||
|
||||
## What Changes
|
||||
- Require the summary agent to provide a one-line summary when calling `write_summary`.
|
||||
- Reject one-line summaries containing line breaks.
|
||||
- Store the one-line summary in summary note frontmatter.
|
||||
- Update default summary-agent instructions to keep the one-line summary very short and concise.
|
||||
|
||||
## Impact
|
||||
- Changes the `write_summary` tool signature.
|
||||
- Adds summary frontmatter field `oneliner`.
|
||||
- Adds focused tests for validation, frontmatter output, and instructions.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Summary note includes a one-line blurb
|
||||
The `write_summary` tool SHALL require a one-line summary parameter in addition to the markdown summary body.
|
||||
|
||||
Meeting Assistant SHALL reject `write_summary` calls when the one-line summary contains line breaks.
|
||||
|
||||
Meeting Assistant SHALL write the one-line summary into summary note frontmatter as `oneliner`.
|
||||
|
||||
The summary-agent instructions SHALL tell the agent to keep the one-line summary very short, blurb-like, and concise, with complete sentences not required.
|
||||
|
||||
#### Scenario: Summary one-liner is written to frontmatter
|
||||
- **WHEN** the summary agent calls `write_summary` with markdown content and one-line summary `Architecture risks and next steps`
|
||||
- **THEN** Meeting Assistant writes `oneliner: Architecture risks and next steps` to the summary note frontmatter
|
||||
|
||||
#### Scenario: Multiline one-liner is rejected
|
||||
- **WHEN** the summary agent calls `write_summary` with a one-line summary containing a line break
|
||||
- **THEN** Meeting Assistant refuses the write
|
||||
- **AND** does not mark the summary as written
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Specification
|
||||
- [x] Add summary one-liner requirements and scenarios.
|
||||
|
||||
## 2. Behavior
|
||||
- [x] Require `oneliner` in the `write_summary` tool signature.
|
||||
- [x] Validate `oneliner` does not contain line breaks.
|
||||
- [x] Write `oneliner` to summary note frontmatter.
|
||||
- [x] Instruct the summary agent to keep the one-liner very short.
|
||||
|
||||
## 3. Verification
|
||||
- [x] Add focused behavior tests.
|
||||
- [x] Run relevant tests.
|
||||
- [x] Validate OpenSpec change.
|
||||
@@ -0,0 +1,17 @@
|
||||
## Why
|
||||
Meeting Assistant currently exposes recording controls through hotkeys and HTTP endpoints, but there is no persistent Windows taskbar/tray affordance showing what the assistant is doing.
|
||||
|
||||
## What Changes
|
||||
- Add a Windows taskbar notification icon for Meeting Assistant.
|
||||
- Show idle, recording, or summarizing/processing state through the icon and tooltip.
|
||||
- Prioritize the newest active recording over older stopped runs that may still be summarizing.
|
||||
- Add a right-click menu with state-aware recording controls:
|
||||
- start meeting recording per configured launch profile
|
||||
- stop active recording and continue transcription/summary
|
||||
- cancel active recording and discard artifacts
|
||||
- switch the active recording to another configured launch profile
|
||||
|
||||
## Impact
|
||||
- Adds a small Windows-only hosted UI service.
|
||||
- Extends recording status with state/profile information needed by the tray surface.
|
||||
- Adds focused tests for tray state/menu behavior and launch profile enumeration.
|
||||
@@ -0,0 +1,36 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Windows taskbar icon controls recording
|
||||
Meeting Assistant SHALL show a Windows taskbar notification icon when running on Windows.
|
||||
|
||||
The taskbar icon SHALL indicate whether the newest meeting process is idle, actively recording, or post-recording processing/summarizing.
|
||||
|
||||
When a new meeting is actively recording while an older stopped meeting is still transcribing, recognizing speakers, or summarizing, the taskbar icon SHALL show the new active recording state.
|
||||
|
||||
The taskbar icon right-click menu SHALL expose recording controls based on the current state and configured launch profiles.
|
||||
|
||||
When Meeting Assistant is idle or only processing older stopped meetings, the menu SHALL allow starting a meeting recording for each configured launch profile.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow stopping the recording and continuing transcription/summary generation.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow canceling the recording and discarding that run's artifacts.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow switching to each configured launch profile other than the current active profile.
|
||||
|
||||
#### Scenario: Idle tray menu can start configured profiles
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers start recording actions for `default` and `english`
|
||||
|
||||
#### Scenario: Recording tray menu exposes stop, cancel, and profile switches
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** a meeting is actively recording with profile `default`
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers stop and cancel actions
|
||||
- **AND** it offers switching to `english`
|
||||
- **AND** it does not offer switching to `default`
|
||||
|
||||
#### Scenario: Active recording has priority over older summarizing runs
|
||||
- **GIVEN** an older meeting is still summarizing
|
||||
- **WHEN** a newer meeting is actively recording
|
||||
- **THEN** the taskbar icon indicates recording
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Specification
|
||||
- [x] Add taskbar icon requirements and scenarios.
|
||||
|
||||
## 2. Behavior
|
||||
- [x] Expose enough recording status for idle/recording/summarizing and active profile display.
|
||||
- [x] Enumerate configured launch profiles for UI controls.
|
||||
- [x] Build state-aware tray menu model.
|
||||
- [x] Add Windows taskbar/tray hosted service with state icon and right-click commands.
|
||||
|
||||
## 3. Verification
|
||||
- [x] Add focused behavior tests for status/menu/profile behavior.
|
||||
- [x] Run relevant tests.
|
||||
- [x] Validate OpenSpec change.
|
||||
@@ -141,3 +141,39 @@ When switching profiles during an active meeting, Meeting Assistant SHALL clear
|
||||
- **WHEN** Meeting Assistant switches to another launch profile
|
||||
- **THEN** already-written transcript text keeps the named speaker
|
||||
- **AND** later transcript segments from the new pipeline do not reuse the old backend speaker label mapping
|
||||
|
||||
### Requirement: Windows taskbar icon controls recording
|
||||
Meeting Assistant SHALL show a Windows taskbar notification icon when running on Windows.
|
||||
|
||||
The taskbar icon SHALL indicate whether the newest meeting process is idle, actively recording, or post-recording processing/summarizing.
|
||||
|
||||
When a new meeting is actively recording while an older stopped meeting is still transcribing, recognizing speakers, or summarizing, the taskbar icon SHALL show the new active recording state.
|
||||
|
||||
The taskbar icon right-click menu SHALL expose recording controls based on the current state and configured launch profiles.
|
||||
|
||||
When Meeting Assistant is idle or only processing older stopped meetings, the menu SHALL allow starting a meeting recording for each configured launch profile.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow stopping the recording and continuing transcription/summary generation.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow canceling the recording and discarding that run's artifacts.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow switching to each configured launch profile other than the current active profile.
|
||||
|
||||
#### Scenario: Idle tray menu can start configured profiles
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers start recording actions for `default` and `english`
|
||||
|
||||
#### Scenario: Recording tray menu exposes stop, cancel, and profile switches
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** a meeting is actively recording with profile `default`
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers stop and cancel actions
|
||||
- **AND** it offers switching to `english`
|
||||
- **AND** it does not offer switching to `default`
|
||||
|
||||
#### Scenario: Active recording has priority over older summarizing runs
|
||||
- **GIVEN** an older meeting is still summarizing
|
||||
- **WHEN** a newer meeting is actively recording
|
||||
- **THEN** the taskbar icon indicates recording
|
||||
|
||||
@@ -258,3 +258,21 @@ When no bound projects have `AGENTS.md`, Meeting Assistant SHALL not append the
|
||||
- **AND** the configured project folder does not contain `AGENTS.md`
|
||||
- **WHEN** the summary agent is created
|
||||
- **THEN** no empty project instruction entry is appended for `Alpha`
|
||||
|
||||
### Requirement: Summary note includes a one-line blurb
|
||||
The `write_summary` tool SHALL require a one-line summary parameter in addition to the markdown summary body.
|
||||
|
||||
Meeting Assistant SHALL reject `write_summary` calls when the one-line summary contains line breaks.
|
||||
|
||||
Meeting Assistant SHALL write the one-line summary into summary note frontmatter as `oneliner`.
|
||||
|
||||
The summary-agent instructions SHALL tell the agent to keep the one-line summary very short, blurb-like, and concise, with complete sentences not required.
|
||||
|
||||
#### Scenario: Summary one-liner is written to frontmatter
|
||||
- **WHEN** the summary agent calls `write_summary` with markdown content and one-line summary `Architecture risks and next steps`
|
||||
- **THEN** Meeting Assistant writes `oneliner: Architecture risks and next steps` to the summary note frontmatter
|
||||
|
||||
#### Scenario: Multiline one-liner is rejected
|
||||
- **WHEN** the summary agent calls `write_summary` with a one-line summary containing a line break
|
||||
- **THEN** Meeting Assistant refuses the write
|
||||
- **AND** does not mark the summary as written
|
||||
|
||||
Reference in New Issue
Block a user