3 Commits
Author SHA1 Message Date
renovate-bot 1207155120 Update dependency Microsoft.Agents.AI.OpenAI to 1.7.0
PR and Push Build/Test / build-and-test (push) Failing after 8m33s
PR and Push Build/Test / build-and-test (pull_request) Successful in 6m43s
2026-05-28 02:24:36 +00:00
codex 693f52afee Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
2026-05-28 01:28:16 +02:00
codex 71f1e6a0b8 Add taskbar controls and summary oneliner 2026-05-27 23:31:40 +02:00
46 changed files with 3597 additions and 77 deletions
@@ -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));
}
}
+109
View File
@@ -0,0 +1,109 @@
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", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+E")]);
Assert.Equal(RecordingProcessState.Idle, menu.State);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.EditRules &&
item.Text == "Edit rules and identities");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "default" &&
item.Text == "Start meeting recording (default)\tCtrl+Alt+M");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "english" &&
item.Text == "Start meeting recording (english)\tCtrl+Alt+E");
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", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+E"), Profile("french", "Ctrl+Alt+F")]);
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" &&
item.Text == "Switch to english\tCtrl+Alt+E");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.SwitchProfile &&
item.ProfileName == "french" &&
item.Text == "Switch to french\tCtrl+Alt+F");
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, string hotkey = "")
{
return new LaunchProfile(name, new MeetingAssistantOptions
{
Hotkey =
{
Toggle = hotkey
}
});
}
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);
}
}
@@ -0,0 +1,492 @@
using MeetingAssistant.Workflow;
using MeetingAssistant.Speakers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
namespace MeetingAssistant.Tests;
public sealed class WorkflowRulesEditorTests
{
[Fact]
public void EditorOptionsInheritSummarizerDefaultsAndOverrideConfiguredValues()
{
var defaults = new AgentOptions
{
Endpoint = "https://summary.example",
Key = "summary-key",
KeyEnv = "SUMMARY_KEY",
Model = "summary-model",
EnableThinking = true,
ReasoningEffort = ReasoningEffortOption.High,
ReconnectionAttempts = 7,
ReconnectionDelay = TimeSpan.FromSeconds(7),
ContextWindowTokens = 1000,
MaxOutputTokens = 100,
EnableCompaction = true,
CompactionRemainingRatio = 0.2,
ResponsesCompactPath = "responses/compact"
};
var editor = new WorkflowRulesEditorOptions
{
Model = "editor-model",
EnableThinking = false,
MaxOutputTokens = 50
};
var effective = editor.ToEffectiveAgentOptions(defaults);
Assert.Equal("https://summary.example", effective.Endpoint);
Assert.Equal("summary-key", effective.Key);
Assert.Equal("SUMMARY_KEY", effective.KeyEnv);
Assert.Equal("editor-model", effective.Model);
Assert.False(effective.EnableThinking);
Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort);
Assert.Equal(7, effective.ReconnectionAttempts);
Assert.Equal(TimeSpan.FromSeconds(7), effective.ReconnectionDelay);
Assert.Equal(1000, effective.ContextWindowTokens);
Assert.Equal(50, effective.MaxOutputTokens);
Assert.True(effective.EnableCompaction);
Assert.Equal(0.2, effective.CompactionRemainingRatio);
Assert.Equal("responses/compact", effective.ResponsesCompactPath);
}
[Fact]
public async Task RulesEditorToolsReadWriteAndSearchOnlyConfiguredRulesFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var rulesPath = Path.Combine(root, "rules.yaml");
var unrelatedPath = Path.Combine(root, "other.yaml");
await File.WriteAllTextAsync(unrelatedPath, "rules:\n- name: do-not-find\n");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Automation = new AutomationOptions { RulesPath = rulesPath }
});
var writeResult = await tools.WriteRules("""
rules:
- name: add-me
on:
- created: {}
steps:
- uses: add_attendee
value: Manuel
""");
var readResult = await tools.ReadRules();
var searchResult = await tools.Search("add-me");
Assert.Equal(rulesPath, writeResult);
Assert.Contains("name: add-me", readResult);
Assert.Contains("rules.yaml:", searchResult);
Assert.DoesNotContain("other.yaml", searchResult);
}
[Fact]
public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var rulesPath = Path.Combine(root, "rules.yaml");
await File.WriteAllTextAsync(rulesPath, "rules: []");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Automation = new AutomationOptions { RulesPath = rulesPath }
});
var result = await tools.WriteRules("rules:\n- name: [");
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
}
[Fact]
public async Task InstructionBuilderIncludesConfiguredRulesPathAndWorkflowDocs()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var rulesPath = Path.Combine(root, "rules.yaml");
var builder = new WorkflowRulesEditorInstructionBuilder(
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
var instructions = await builder.BuildAsync(new MeetingAssistantOptions
{
Automation = new AutomationOptions { RulesPath = rulesPath }
}, CancellationToken.None);
Assert.Contains(rulesPath, instructions);
Assert.Contains("Meeting Workflow Engine", instructions);
Assert.Contains("Workflow rules reference documentation", instructions);
Assert.Contains("speaker identity database", instructions);
}
[Fact]
public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var tools = fixture.CreateTools();
var createResult = await tools.CreateIdentity(
"Sabrina",
["Sabi"],
["Guest-01"]);
using var created = JsonDocument.Parse(createResult);
var identityId = created.RootElement
.GetProperty("identity")
.GetProperty("id")
.GetInt32();
var searchResult = await tools.SearchIdentities("sabi");
var readResult = await tools.ReadIdentity(identityId);
var updateResult = await tools.UpdateIdentity(
identityId,
"Sabrina M.",
["Sabrina"],
["Guest-02"]);
var deleteResult = await tools.DeleteIdentity(identityId);
Assert.Contains("\"displayName\": \"Sabrina\"", searchResult);
Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult);
Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult);
Assert.Equal($"Deleted identity {identityId}.", deleteResult);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
[Fact]
public async Task RulesEditorToolsMergeSpeakerIdentitiesIntoTarget()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var target = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], sample: [1]);
var source = await fixture.AddIdentityAsync("Sabine", aliases: ["Guest Name"], candidates: ["Guest-01"], sample: [2, 3]);
var tools = fixture.CreateTools();
var result = await tools.MergeIdentities(target.Id, source.Id);
Assert.Contains("\"canonicalName\": \"Sabrina\"", result);
var saved = await fixture.LoadIdentityAsync(target.Id);
Assert.Equal(["Guest Name", "Guest-01", "Sabi", "Sabine"], saved.Aliases.Select(alias => alias.Name).Order());
var snippets = saved.Snippets.Select(sample => sample.WavBytes).OrderBy(bytes => bytes.Length).ToArray();
Assert.Equal([1], snippets[0]);
Assert.Equal([2, 3], snippets[1]);
Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == source.Id));
}
[Fact]
public async Task RulesEditorToolsListReadQueueAndDeleteSpeakerSamples()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]);
var sampleId = identity.Snippets.Single().Id;
var queue = new CapturingSamplePlaybackQueue();
var tools = fixture.CreateTools(queue);
var listResult = await tools.ListIdentitySamples(identity.Id);
var readResult = await tools.ReadIdentitySample(sampleId);
var playResult = await tools.QueuePlayIdentitySample(sampleId);
var deleteResult = await tools.DeleteIdentitySample(sampleId);
Assert.Contains($"\"id\": {sampleId}", listResult);
Assert.Contains(Convert.ToBase64String([4, 5, 6]), readResult);
Assert.Equal($"Queued sample {sampleId}.", playResult);
var queued = queue.Queued.Single();
Assert.Equal(sampleId, queued.SampleId);
Assert.Equal([4, 5, 6], queued.WavBytes);
Assert.Equal($"Deleted sample {sampleId}.", deleteResult);
Assert.Empty(await fixture.Context.SpeakerSnippets.ToListAsync());
}
[Fact]
public async Task ViewModelShowsUserMessageThinkingStateAndFinalAgentMessage()
{
var pipeline = new BlockingRulesEditorPipeline("Updated rules.");
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
{
Draft = "Rename ABS Daily"
};
var sendTask = viewModel.SendAsync();
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(viewModel.IsThinking);
Assert.Equal("", viewModel.Draft);
Assert.Equal(
[new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")],
viewModel.Messages.ToArray());
pipeline.Release.SetResult();
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(viewModel.IsThinking);
Assert.Equal(2, viewModel.Messages.Count);
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
}
[Theory]
[InlineData(0, 500, 0, true)]
[InlineData(0, 500, 400, true)]
[InlineData(492, 500, 1000, true)]
[InlineData(300, 500, 1000, false)]
public void ScrollPolicyAutoScrollsOnlyWhenAlreadyNearBottom(
double verticalOffset,
double viewportHeight,
double contentHeight,
bool expected)
{
Assert.Equal(
expected,
WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
verticalOffset,
viewportHeight,
contentHeight));
}
[Fact]
public void ScrollPolicyAutoScrollsWhenPreviousContentFitBeforeNewCardOverflows()
{
Assert.True(WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
verticalOffset: 0,
viewportHeight: 500,
previousContentHeight: 500));
}
[Fact]
public void ScrollPolicyComputesNonNegativeBottomOffset()
{
Assert.Equal(500, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 1000));
Assert.Equal(0, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 400));
}
[Fact]
public void MarkdownParserRecognizesRequestedInlineStyles()
{
var inlines = WorkflowRulesEditorMarkdown.ParseInline(
"Normal *italic* **bold** and `code`.");
Assert.Equal(
[
new WorkflowRulesEditorMarkdownInline("Normal ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold),
new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code),
new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal)
],
inlines);
}
[Fact]
public void MarkdownParserPreservesParagraphLineBreaks()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
First line
Second line with `code`
""");
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(Assert.Single(blocks));
Assert.Contains(paragraph.Inlines, inline =>
inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Normal &&
inline.Text.Contains('\n'));
Assert.Contains(paragraph.Inlines, inline =>
inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code &&
inline.Text == "code");
}
[Fact]
public void MarkdownParserRecognizesPipeTables()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
Current identities:
| ID | Name | Samples |
|----|------|---------|
| 1 | Manuel | 2 |
| 2 | Sabrina | 1 |
""");
Assert.Collection(
blocks,
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
block =>
{
var table = Assert.IsType<WorkflowRulesEditorMarkdownTable>(block);
Assert.Equal(["ID", "Name", "Samples"], table.Header);
Assert.Equal(["1", "Manuel", "2"], table.Rows[0]);
Assert.Equal(["2", "Sabrina", "1"], table.Rows[1]);
});
}
[Fact]
public void MarkdownParserRecognizesFencedCodeBlocks()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
Before
```yaml
rules:
- name: example
```
After **bold**
""");
Assert.Collection(
blocks,
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
block =>
{
var code = Assert.IsType<WorkflowRulesEditorMarkdownCodeBlock>(block);
Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text);
},
block =>
{
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block);
Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
});
}
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly string response;
public BlockingRulesEditorPipeline(string response)
{
this.response = response;
}
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken)
{
Started.SetResult();
await Release.Task.WaitAsync(cancellationToken);
return new WorkflowRulesEditorChatResult(
response,
conversation
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
.ToList());
}
}
private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable
{
private readonly string tempDirectory;
private readonly string dbPath;
private WorkflowRulesEditorIdentityFixture(
string tempDirectory,
string dbPath,
SpeakerIdentityDbContext context)
{
this.tempDirectory = tempDirectory;
this.dbPath = dbPath;
Context = context;
}
public SpeakerIdentityDbContext Context { get; }
public static async Task<WorkflowRulesEditorIdentityFixture> CreateAsync()
{
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDirectory);
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
return new WorkflowRulesEditorIdentityFixture(tempDirectory, dbPath, context);
}
public WorkflowRulesEditorTools CreateTools(
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
{
return new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
new TestSpeakerIdentityDbContextFactory(dbPath),
samplePlaybackQueue);
}
public async Task<SpeakerIdentity> AddIdentityAsync(
string canonicalName,
IReadOnlyList<string>? aliases = null,
IReadOnlyList<string>? candidates = null,
byte[]? sample = null)
{
var now = DateTimeOffset.UtcNow;
var identity = new SpeakerIdentity
{
CanonicalName = canonicalName,
CreatedAt = now,
UpdatedAt = now,
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [],
Snippets = sample is null
? []
:
[
new SpeakerSnippet
{
WavBytes = sample,
CreatedAt = now
}
]
};
Context.SpeakerIdentities.Add(identity);
await Context.SaveChangesAsync();
return identity;
}
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
{
Context.ChangeTracker.Clear();
return Context.SpeakerIdentities
.Include(identity => identity.Aliases)
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Snippets)
.SingleAsync(identity => identity.Id == id);
}
public async ValueTask DisposeAsync()
{
await Context.DisposeAsync();
if (Directory.Exists(tempDirectory))
{
Directory.Delete(tempDirectory, recursive: true);
}
}
}
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
{
private readonly string dbPath;
public TestSpeakerIdentityDbContextFactory(string dbPath)
{
this.dbPath = dbPath;
}
public SpeakerIdentityDbContext CreateDbContext()
{
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
}
}
private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue
{
public List<(int SampleId, byte[] WavBytes)> Queued { get; } = [];
public Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default)
{
Queued.Add((sampleId, wavBytes));
return Task.FromResult($"Queued sample {sampleId}.");
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -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);
+27 -1
View File
@@ -7,6 +7,11 @@
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<OutputType>WinExe</OutputType>
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MeetingAssistant.Tests" />
</ItemGroup>
@@ -25,11 +30,32 @@
<PackageReference Include="YamlDotNet" Version="17.1.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0-windows'">
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="Aprillz.MewUI.Windows" Version="0.15.2" />
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="%(Filename)%(Extension)" />
<None Update="Assets\meeting-assistant.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<MeetingAssistantRootConfig Include="$(MSBuildProjectDirectory)\appsettings*.json" />
</ItemGroup>
<Target Name="CopyMeetingAssistantConfigToPublishRoot" AfterTargets="Publish">
<Copy SourceFiles="@(MeetingAssistantRootConfig)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
</Target>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
<Compile Remove="Recording\NaudioCaptureSource.cs" />
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
<Compile Remove="Workflow\MewUiWorkflowRulesEditorWindowService.Windows.cs" />
</ItemGroup>
</Project>
@@ -22,6 +22,8 @@ public sealed class MeetingAssistantOptions
public AgentOptions Agent { get; set; } = new();
public WorkflowRulesEditorOptions WorkflowRulesEditor { get; set; } = new();
public ApiOptions Api { get; set; } = new();
}
@@ -333,6 +335,60 @@ public sealed class AgentOptions
public string? InitialPrompt { get; set; }
}
public sealed class WorkflowRulesEditorOptions
{
public string? Endpoint { get; set; }
public string? Key { get; set; }
public string? KeyEnv { get; set; }
public string? Model { get; set; }
public bool? EnableThinking { get; set; }
public ReasoningEffortOption? ReasoningEffort { get; set; }
public int? ReconnectionAttempts { get; set; }
public TimeSpan? ReconnectionDelay { get; set; }
public int? ContextWindowTokens { get; set; }
public int? MaxOutputTokens { get; set; }
public bool? EnableCompaction { get; set; }
public double? CompactionRemainingRatio { get; set; }
public string? ResponsesCompactPath { get; set; }
public string? InitialPrompt { get; set; }
public AgentOptions ToEffectiveAgentOptions(AgentOptions defaults)
{
return new AgentOptions
{
Endpoint = string.IsNullOrWhiteSpace(Endpoint) ? defaults.Endpoint : Endpoint,
Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key,
KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!,
Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!,
EnableThinking = EnableThinking ?? defaults.EnableThinking,
ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort,
ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts,
ReconnectionDelay = ReconnectionDelay ?? defaults.ReconnectionDelay,
ContextWindowTokens = ContextWindowTokens ?? defaults.ContextWindowTokens,
MaxOutputTokens = MaxOutputTokens ?? defaults.MaxOutputTokens,
EnableCompaction = EnableCompaction ?? defaults.EnableCompaction,
CompactionRemainingRatio = CompactionRemainingRatio ?? defaults.CompactionRemainingRatio,
ResponsesCompactPath = string.IsNullOrWhiteSpace(ResponsesCompactPath)
? defaults.ResponsesCompactPath
: ResponsesCompactPath!,
InitialPrompt = string.IsNullOrWhiteSpace(InitialPrompt) ? null : InitialPrompt
};
}
}
public sealed class ApiOptions
{
public string PublicBaseUrl { get; set; } = "http://localhost:5090";
@@ -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);
+17
View File
@@ -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;
@@ -60,6 +61,15 @@ builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgent
builder.Services.AddSingleton<IMeetingSummaryRetryRunner, MeetingSummaryRetryRunner>();
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
builder.Services.AddSingleton<IWorkflowRulesEditorInstructionBuilder, WorkflowRulesEditorInstructionBuilder>();
builder.Services.AddSingleton<IWorkflowRulesEditorSamplePlaybackQueue, WorkflowRulesEditorSamplePlaybackQueue>();
builder.Services.AddSingleton<IWorkflowRulesEditorChatPipeline, WorkflowRulesEditorChatPipeline>();
builder.Services.AddTransient<WorkflowRulesEditorChatViewModel>();
#if WINDOWS
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MewUiWorkflowRulesEditorWindowService>();
#else
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
#endif
builder.Services.AddSingleton<AsrDiagnosticService>();
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
@@ -80,6 +90,7 @@ builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
#if WINDOWS
builder.Services.AddHostedService<GlobalHotkeyService>();
builder.Services.AddHostedService<UnoTaskbarIconService>();
#endif
var app = builder.Build();
@@ -211,6 +222,12 @@ static async Task<IResult> MergeSpeakerIdentitiesAsync(
}
app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) =>
Results.Ok(ReloadWorkflowConfiguration(configuration)));
app.MapPost("/diagnostics/workflow/rules-editor/show", (
IWorkflowRulesEditorWindowService rulesEditorWindow) =>
{
rulesEditorWindow.Show();
return Results.Accepted();
});
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
{
@@ -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
}
@@ -110,7 +110,10 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
var targetName = target.GetDisplayName() ?? $"identity-{target.Id}";
var sourceName = source.GetDisplayName() ?? $"identity-{source.Id}";
MergeIdentities(target, source);
SpeakerIdentityMerger.MergeInto(
target,
source,
options.MaxSnippetsPerSpeaker);
await SpeakerIdentityTranscriptAudit.AppendMergedAsync(
target.References,
targetName,
@@ -161,60 +164,4 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
.FirstOrDefault(snippet => excludedSnippet is null || snippet.Id != excludedSnippet.Id);
}
private void MergeIdentities(SpeakerIdentity target, SpeakerIdentity source)
{
AddAlias(target, source.CanonicalName);
foreach (var alias in source.Aliases)
{
AddAlias(target, alias.Name);
}
foreach (var candidate in source.CandidateNames)
{
AddAlias(target, candidate.Name);
}
target.UpdatedAt = DateTimeOffset.UtcNow;
foreach (var reference in source.References)
{
SpeakerIdentityReferences.AddIfMissing(target, reference);
}
var retainedSnippets = target.Snippets
.Concat(source.Snippets)
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
.Take(Math.Max(1, options.MaxSnippetsPerSpeaker))
.Select(snippet => new SpeakerSnippet
{
WavBytes = snippet.WavBytes,
CreatedAt = snippet.CreatedAt
})
.ToList();
target.Snippets.Clear();
target.Snippets.AddRange(retainedSnippets);
}
private static void AddAlias(SpeakerIdentity identity, string? alias)
{
if (string.IsNullOrWhiteSpace(alias) ||
string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) ||
identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase)))
{
return;
}
identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() });
}
private static int StableSnippetKey(byte[] snippet)
{
var hash = new HashCode();
foreach (var value in snippet)
{
hash.Add(value);
}
return hash.ToHashCode();
}
}
@@ -0,0 +1,63 @@
namespace MeetingAssistant.Speakers;
internal static class SpeakerIdentityMerger
{
public static void MergeInto(
SpeakerIdentity target,
SpeakerIdentity source,
int maxSnippets)
{
AddAlias(target, source.CanonicalName);
foreach (var alias in source.Aliases)
{
AddAlias(target, alias.Name);
}
foreach (var candidate in source.CandidateNames)
{
AddAlias(target, candidate.Name);
}
foreach (var reference in source.References)
{
SpeakerIdentityReferences.AddIfMissing(target, reference);
}
var retainedSnippets = target.Snippets
.Concat(source.Snippets)
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
.Take(Math.Max(1, maxSnippets))
.Select(snippet => new SpeakerSnippet
{
WavBytes = snippet.WavBytes,
CreatedAt = snippet.CreatedAt
})
.ToList();
target.Snippets.Clear();
target.Snippets.AddRange(retainedSnippets);
target.UpdatedAt = DateTimeOffset.UtcNow;
}
private static void AddAlias(SpeakerIdentity identity, string? alias)
{
if (string.IsNullOrWhiteSpace(alias) ||
string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) ||
identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase)))
{
return;
}
identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() });
}
private static int StableSnippetKey(byte[] snippet)
{
var hash = new HashCode();
foreach (var value in snippet)
{
hash.Add(value);
}
return hash.ToHashCode();
}
}
@@ -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,98 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Taskbar;
public enum MeetingTaskbarAction
{
EditRules,
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>
{
new("Edit rules and identities", MeetingTaskbarAction.EditRules)
};
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(
AppendHotkey($"Switch to {profile.Name}", profile.Options.Hotkey.Toggle),
MeetingTaskbarAction.SwitchProfile,
profile.Name));
}
}
else
{
foreach (var profile in launchProfiles)
{
items.Add(new MeetingTaskbarMenuItem(
AppendHotkey($"Start meeting recording ({profile.Name})", profile.Options.Hotkey.Toggle),
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);
}
private static string AppendHotkey(string text, string hotkey)
{
return string.IsNullOrWhiteSpace(hotkey)
? text
: $"{text}\t{hotkey.Trim()}";
}
}
@@ -0,0 +1,292 @@
#if WINDOWS
using System.Drawing;
using System.Runtime.InteropServices;
using H.NotifyIcon.Core;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
using MeetingAssistant.Workflow;
namespace MeetingAssistant.Taskbar;
public sealed class UnoTaskbarIconService : IHostedService, IDisposable
{
private static readonly Guid TrayIconId = Guid.Parse("91F3D8B7-E61F-4E73-98F5-29A665991C67");
private readonly MeetingRecordingCoordinator coordinator;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly IWorkflowRulesEditorWindowService rulesEditorWindow;
private readonly ILogger<UnoTaskbarIconService> logger;
private readonly object sync = new();
private CancellationTokenSource? refreshCancellation;
private Task? refreshTask;
private TrayIconWithContextMenu? trayIcon;
private Icon? currentIcon;
private RecordingProcessState? currentState;
private string? currentProfilesSignature;
private string? currentMenuSignature;
public UnoTaskbarIconService(
MeetingRecordingCoordinator coordinator,
ILaunchProfileOptionsProvider launchProfiles,
IWorkflowRulesEditorWindowService rulesEditorWindow,
ILogger<UnoTaskbarIconService> logger)
{
this.coordinator = coordinator;
this.launchProfiles = launchProfiles;
this.rulesEditorWindow = rulesEditorWindow;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindows())
{
logger.LogInformation("Taskbar icon is only supported on Windows");
return Task.CompletedTask;
}
var menu = BuildMenu();
currentMenuSignature = BuildMenuSignature(menu);
currentIcon = CreateIcon(menu.State);
currentState = menu.State;
trayIcon = new TrayIconWithContextMenu(TrayIconId)
{
Icon = currentIcon.Handle,
ToolTip = menu.Tooltip,
ContextMenu = BuildContextMenu(menu)
};
trayIcon.Created += (_, _) => logger.LogInformation(
"Taskbar icon native create event received: isCreated={IsCreated}",
trayIcon.IsCreated);
trayIcon.Removed += (_, _) => logger.LogInformation("Taskbar icon native remove event received");
trayIcon.Create();
trayIcon.Show();
logger.LogInformation(
"Taskbar icon created through H.NotifyIcon: isCreated={IsCreated}, state={State}, tooltip={Tooltip}",
trayIcon.IsCreated,
menu.State,
menu.Tooltip);
refreshCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
refreshTask = Task.Run(() => RefreshLoopAsync(refreshCancellation.Token), CancellationToken.None);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
refreshCancellation?.Cancel();
if (refreshTask is not null)
{
try
{
await refreshTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
}
DisposeTrayIcon();
}
public void Dispose()
{
refreshCancellation?.Cancel();
refreshCancellation?.Dispose();
DisposeTrayIcon();
}
private async Task RefreshLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
try
{
RefreshVisualState();
}
catch (Exception exception)
{
logger.LogError(exception, "Taskbar icon refresh failed");
}
}
}
private void RefreshVisualState()
{
lock (sync)
{
if (trayIcon is null)
{
return;
}
var menu = BuildMenu();
var menuSignature = BuildMenuSignature(menu);
if (currentState != menu.State)
{
var previousIcon = currentIcon;
var nextIcon = CreateIcon(menu.State);
if (!trayIcon.UpdateIcon(nextIcon.Handle))
{
logger.LogWarning("Taskbar icon update returned false for state {State}", menu.State);
}
currentIcon = nextIcon;
currentState = menu.State;
previousIcon?.Dispose();
logger.LogInformation("Taskbar icon state changed to {State}", menu.State);
}
trayIcon.UpdateToolTip(menu.Tooltip);
if (!string.Equals(currentMenuSignature, menuSignature, StringComparison.Ordinal))
{
trayIcon.ContextMenu = BuildContextMenu(menu);
currentMenuSignature = menuSignature;
logger.LogInformation("Taskbar menu changed: {MenuItems}", menuSignature);
}
}
}
private void DisposeTrayIcon()
{
lock (sync)
{
trayIcon?.Dispose();
trayIcon = null;
currentIcon?.Dispose();
currentIcon = null;
currentState = null;
currentMenuSignature = null;
}
}
private MeetingTaskbarMenu BuildMenu()
{
var profiles = launchProfiles.GetProfiles();
var profilesSignature = string.Join(
", ",
profiles.Select(profile => $"{profile.Name}:{profile.Options.Hotkey.Toggle}"));
if (!string.Equals(currentProfilesSignature, profilesSignature, StringComparison.Ordinal))
{
currentProfilesSignature = profilesSignature;
logger.LogInformation("Taskbar menu launch profiles: {LaunchProfiles}", profilesSignature);
}
return MeetingTaskbarMenuBuilder.Build(
coordinator.CurrentStatus,
profiles);
}
private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu)
{
var popupMenu = new PopupMenu();
for (var index = 0; index < menu.Items.Count; index++)
{
if (index == 1)
{
popupMenu.Items.Add(new PopupMenuSeparator());
}
var menuItem = menu.Items[index];
popupMenu.Items.Add(new PopupMenuItem(
menuItem.Text,
(_, _) => _ = ExecuteMenuItemAsync(menuItem)));
}
return popupMenu;
}
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
{
try
{
logger.LogInformation(
"Taskbar menu action {Action} selected for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
switch (item.Action)
{
case MeetingTaskbarAction.EditRules:
rulesEditorWindow.Show();
break;
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;
}
RefreshVisualState();
logger.LogInformation(
"Taskbar menu action {Action} completed for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
}
}
private static string BuildMenuSignature(MeetingTaskbarMenu menu)
{
return string.Join(
"|",
menu.Items.Select(item => $"{item.Action}:{item.ProfileName}:{item.Text}"));
}
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);
}
#endif
@@ -32,7 +32,12 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv
return [];
}
var path = ResolvePath(options.Automation.RulesPath);
var path = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
if (path is null)
{
return [];
}
if (!File.Exists(path))
{
logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path);
@@ -49,12 +54,4 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv
?? new MeetingWorkflowRulesFile();
return rulesFile.Rules;
}
private static string ResolvePath(string configuredPath)
{
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
return Path.IsPathRooted(expanded)
? Path.GetFullPath(expanded)
: Path.GetFullPath(expanded);
}
}
@@ -0,0 +1,21 @@
namespace MeetingAssistant.Workflow;
public interface IWorkflowRulesEditorWindowService
{
void Show();
}
public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
private readonly ILogger<NoopWorkflowRulesEditorWindowService> logger;
public NoopWorkflowRulesEditorWindowService(ILogger<NoopWorkflowRulesEditorWindowService> logger)
{
this.logger = logger;
}
public void Show()
{
logger.LogInformation("Workflow rules editor UI is only available on Windows");
}
}
@@ -0,0 +1,251 @@
#if WINDOWS
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
namespace MeetingAssistant.Workflow;
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
private readonly IServiceProvider services;
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
private readonly object sync = new();
private bool isRunning;
public MewUiWorkflowRulesEditorWindowService(
IServiceProvider services,
ILogger<MewUiWorkflowRulesEditorWindowService> logger)
{
this.services = services;
this.logger = logger;
}
public void Show()
{
lock (sync)
{
if (isRunning)
{
logger.LogInformation("Workflow rules editor UI is already running");
return;
}
isRunning = true;
}
logger.LogInformation("Starting workflow rules editor UI thread");
var thread = new Thread(RunWindow)
{
IsBackground = true,
Name = "Meeting Assistant Rules Editor"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
private void RunWindow()
{
try
{
logger.LogInformation("Workflow rules editor UI thread started");
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
var window = new WorkflowRulesEditorMewWindow(viewModel);
window.Run();
logger.LogInformation("Workflow rules editor UI window closed");
}
catch (Exception exception)
{
logger.LogError(exception, "Workflow rules editor UI failed");
}
finally
{
lock (sync)
{
isRunning = false;
}
}
}
}
internal sealed class WorkflowRulesEditorMewWindow
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly StackPanel conversationPanel = new();
private readonly ScrollViewer conversationScroll = new();
private readonly MultiLineTextBox input = new();
private readonly Button sendButton = new();
private readonly EventHandler changedHandler;
public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel)
{
this.viewModel = viewModel;
changedHandler = (_, _) => RequestRender();
this.viewModel.Changed += changedHandler;
}
public void Run()
{
ConfigureTheme();
conversationPanel
.Vertical()
.Spacing(8);
input
.Height(92)
.Wrap(true)
.Placeholder("Ask to make a rule or to list identities")
.OnTextChanged(text => viewModel.Draft = text)
.OnKeyDown(args =>
{
if (args.Key == Key.Enter && !args.ShiftKey)
{
args.Handled = true;
_ = SendAsync();
}
});
sendButton
.Content("Send")
.Width(84)
.OnClick(() => _ = SendAsync());
var window = new Window()
.Title("Edit rules and identities")
.Resizable(680, 760, 520, 460)
.Padding(0)
.OnLoaded(Render)
.OnClosed(() => viewModel.Changed -= changedHandler)
.Content(
new DockPanel()
.LastChildFill()
.Padding(12)
.Spacing(10)
.Children(
new DockPanel()
.LastChildFill()
.Spacing(8)
.DockBottom()
.Children(
sendButton.DockRight(),
input),
conversationScroll
.AutoVerticalScroll()
.NoHorizontalScroll()
.Content(conversationPanel)));
var icon = LoadWindowIcon();
if (icon is not null)
{
window.Icon = icon;
}
Application.Create()
.UseTheme(ThemeVariant.Dark)
.UseWin32()
.UseDirect2D()
.Run(window);
}
private static IconSource? LoadWindowIcon()
{
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
return File.Exists(iconPath)
? IconSource.FromFile(iconPath)
: null;
}
private static void ConfigureTheme()
{
ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with
{
ButtonFace = Color.FromRgb(245, 245, 245)
};
ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with
{
ButtonFace = Color.FromRgb(42, 46, 54)
};
}
private async Task SendAsync()
{
var sendTask = viewModel.SendAsync();
input.Text = viewModel.Draft;
await sendTask;
}
private void RequestRender()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(Render);
}
private void Render()
{
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
conversationScroll.VerticalOffset,
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight);
conversationPanel.Clear();
foreach (var message in viewModel.Messages)
{
conversationPanel.Add(CreateMessageCard(message));
}
if (viewModel.IsThinking)
{
conversationPanel.Add(new TextBlock()
.Text("Thinking...")
.Foreground(Color.FromRgb(156, 166, 181))
.Margin(4, 2, 4, 2));
}
sendButton.IsEnabled = !viewModel.IsThinking;
if (shouldAutoScroll)
{
QueueScrollConversationToBottom();
}
}
private void QueueScrollConversationToBottom()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(DispatcherPriority.Idle, () =>
{
ScrollConversationToBottom();
dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom);
});
}
private void ScrollConversationToBottom()
{
conversationScroll.SetScrollOffsets(
conversationScroll.HorizontalOffset,
WorkflowRulesEditorScrollPolicy.GetBottomOffset(
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight));
}
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
{
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
return new Border()
.Padding(10)
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
.CornerRadius(8)
.BorderThickness(1)
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
.Child(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
}
}
#endif
@@ -0,0 +1,23 @@
namespace MeetingAssistant.Workflow;
public enum WorkflowRulesEditorChatRole
{
User,
Agent
}
public sealed record WorkflowRulesEditorChatMessage(
WorkflowRulesEditorChatRole Role,
string Content);
public sealed record WorkflowRulesEditorChatResult(
string Response,
IReadOnlyList<WorkflowRulesEditorChatMessage> Conversation);
public interface IWorkflowRulesEditorChatPipeline
{
Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken);
}
@@ -0,0 +1,239 @@
using MeetingAssistant.Summary;
using MeetingAssistant.Speakers;
using Microsoft.Extensions.AI;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Workflow;
#pragma warning disable MAAI001
public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPipeline
{
private readonly MeetingAssistantOptions options;
private readonly ILoggerFactory loggerFactory;
private readonly ILogger<WorkflowRulesEditorChatPipeline> logger;
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue;
public WorkflowRulesEditorChatPipeline(
IOptions<MeetingAssistantOptions> options,
ILoggerFactory loggerFactory,
ILogger<WorkflowRulesEditorChatPipeline> logger,
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue)
{
this.options = options.Value;
this.loggerFactory = loggerFactory;
this.logger = logger;
this.instructionBuilder = instructionBuilder;
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
}
public async Task<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
string userMessage,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(userMessage))
{
return new WorkflowRulesEditorChatResult("", conversation);
}
var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
var key = ResolveApiKey(agentOptions, "workflow rules editor");
var tools = new WorkflowRulesEditorTools(
options,
speakerIdentityDbContextFactory,
samplePlaybackQueue);
var messages = conversation
.Select(ToChatMessage)
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
.ToList();
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
key,
agentOptions.Model,
agentOptions.EnableThinking,
ToReasoningEffortValue(agentOptions.ReasoningEffort),
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions: null,
logger,
firstRequestIsUser: false);
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
using var chatClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
key,
agentOptions.Model,
agentOptions.EnableThinking,
ToReasoningEffortValue(agentOptions.ReasoningEffort),
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions,
logger,
firstRequestIsUser: true);
var functionClient = chatClient
.AsBuilder()
.UseFunctionInvocation(loggerFactory)
.Build();
var response = await functionClient.GetResponseAsync(
messages,
CreateChatOptions(agentOptions, tools, instructions),
cancellationToken);
var responseText = string.IsNullOrWhiteSpace(response.Text)
? "(No response text returned.)"
: response.Text.Trim();
var nextConversation = conversation
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim()))
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText))
.ToList();
return new WorkflowRulesEditorChatResult(responseText, nextConversation);
}
private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message)
{
return new ChatMessage(
message.Role == WorkflowRulesEditorChatRole.Agent ? ChatRole.Assistant : ChatRole.User,
message.Content);
}
private static ChatOptions CreateChatOptions(
AgentOptions options,
WorkflowRulesEditorTools tools,
string instructions)
{
return new ChatOptions
{
ModelId = options.Model,
Instructions = instructions,
MaxOutputTokens = options.MaxOutputTokens,
AllowMultipleToolCalls = true,
ToolMode = ChatToolMode.Auto,
Tools =
[
AIFunctionFactory.Create(
tools.ReadRules,
"read_rules",
"Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
AIFunctionFactory.Create(
tools.WriteRules,
"write_rules",
"Overwrite the configured workflow rules YAML file after validating that it parses as a workflow rules document."),
AIFunctionFactory.Create(
tools.Search,
"search",
"Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file."),
AIFunctionFactory.Create(
tools.SearchIdentities,
"search_identities",
"Search or list speaker identities. Optional query matches canonical names, aliases, and candidate names. Returns JSON summaries."),
AIFunctionFactory.Create(
tools.ReadIdentity,
"read_identity",
"Read one speaker identity by numeric identity id, including aliases, candidate names, references, and sample metadata."),
AIFunctionFactory.Create(
tools.CreateIdentity,
"create_identity",
"Create a speaker identity with optional canonicalName, aliases, and candidateNames. Returns the created identity JSON."),
AIFunctionFactory.Create(
tools.UpdateIdentity,
"update_identity",
"Replace a speaker identity's canonicalName, aliases, and candidateNames by numeric identity id. Omitted aliases/candidateNames become empty."),
AIFunctionFactory.Create(
tools.DeleteIdentity,
"delete_identity",
"Delete a speaker identity and all linked aliases, candidate names, references, and samples by numeric identity id."),
AIFunctionFactory.Create(
tools.MergeIdentities,
"merge_identities",
"Merge sourceIdentityId into targetIdentityId, preserving target canonical name, adding source names as aliases, moving references and samples, then deleting the source."),
AIFunctionFactory.Create(
tools.ListIdentitySamples,
"list_identity_samples",
"List audio samples for a speaker identity by numeric identity id. Returns sample ids and metadata, not audio bytes."),
AIFunctionFactory.Create(
tools.ReadIdentitySample,
"read_identity_sample",
"Read one audio sample by numeric sample id. Returns metadata and base64 WAV bytes."),
AIFunctionFactory.Create(
tools.DeleteIdentitySample,
"delete_identity_sample",
"Delete one audio sample by numeric sample id."),
AIFunctionFactory.Create(
tools.QueuePlayIdentitySample,
"queue_play_identity_sample",
"Queue one audio sample by numeric sample id for local playback. This is asynchronous and does not block until playback finishes.")
],
Reasoning = options.EnableThinking
? new ReasoningOptions { Effort = ToReasoningEffort(options.ReasoningEffort) }
: null
};
}
private static LiteLlmResponsesCompactionOptions CreateCompactionOptions(
AgentOptions options,
IChatClient? summaryClient)
{
return new LiteLlmResponsesCompactionOptions
{
Enabled = options.EnableCompaction,
ContextWindowTokens = options.ContextWindowTokens,
MaxOutputTokens = options.MaxOutputTokens,
RemainingRatio = options.CompactionRemainingRatio,
CompactPath = options.ResponsesCompactPath,
FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests(
options.ContextWindowTokens,
options.MaxOutputTokens,
options.CompactionRemainingRatio,
summaryClient)
};
}
private static string ResolveApiKey(AgentOptions options, string agentName)
{
if (!string.IsNullOrWhiteSpace(options.Key))
{
return options.Key;
}
var value = Environment.GetEnvironmentVariable(options.KeyEnv);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new InvalidOperationException(
$"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'.");
}
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
{
return effort switch
{
ReasoningEffortOption.None => "none",
ReasoningEffortOption.Low => "low",
ReasoningEffortOption.High => "high",
ReasoningEffortOption.ExtraHigh => "xhigh",
_ => "medium"
};
}
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
{
return effort switch
{
ReasoningEffortOption.None => ReasoningEffort.None,
ReasoningEffortOption.Low => ReasoningEffort.Low,
ReasoningEffortOption.High => ReasoningEffort.High,
ReasoningEffortOption.ExtraHigh => ReasoningEffort.ExtraHigh,
_ => ReasoningEffort.Medium
};
}
}
#pragma warning restore MAAI001
@@ -0,0 +1,65 @@
using System.Collections.ObjectModel;
namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorChatViewModel
{
private readonly IWorkflowRulesEditorChatPipeline pipeline;
public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline)
{
this.pipeline = pipeline;
}
public ObservableCollection<WorkflowRulesEditorChatMessage> Messages { get; } = [];
public string Draft { get; set; } = "";
public bool IsThinking { get; private set; }
public event EventHandler? Changed;
public async Task SendAsync(CancellationToken cancellationToken = default)
{
var prompt = Draft.Trim();
if (string.IsNullOrWhiteSpace(prompt) || IsThinking)
{
return;
}
Draft = "";
var priorConversation = Messages.ToList();
Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt));
IsThinking = true;
OnChanged();
try
{
var result = await pipeline.SendAsync(
priorConversation,
prompt,
cancellationToken);
Messages.Clear();
foreach (var message in result.Conversation)
{
Messages.Add(message);
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
Messages.Add(new WorkflowRulesEditorChatMessage(
WorkflowRulesEditorChatRole.Agent,
$"Rules editor failed: {exception.Message}"));
}
finally
{
IsThinking = false;
OnChanged();
}
}
private void OnChanged()
{
Changed?.Invoke(this, EventArgs.Empty);
}
}
@@ -0,0 +1,80 @@
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Workflow;
public interface IWorkflowRulesEditorInstructionBuilder
{
Task<string> BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken);
}
public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditorInstructionBuilder
{
private const string DefaultPrompt = """
You are the Meeting Assistant workflow rules and identities editor.
Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant.
Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files.
Read the existing rules before making changes unless the user explicitly asks to replace the whole file.
Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal.
When writing rules, prefer existing rule style and names.
Use search_identities and read_identity before changing identities unless the user gives an exact identity id.
Prefer updating identities by id, not by guessed name. If a user asks about names, search first and confirm ambiguity in your final response.
For identity merges, merge the duplicate/source identity into the identity that should remain as target.
Use list_identity_samples before playing, reading, or deleting samples. Use queue_play_identity_sample to let the user hear a sample; do not claim you heard it yourself.
Use read_identity_sample only when the raw base64 WAV is actually useful; prefer list_identity_samples for ordinary inspection.
Delete identities or samples only when the user clearly asked for deletion or cleanup.
Explain the final change briefly after the tools finish.
""";
private readonly ILogger<WorkflowRulesEditorInstructionBuilder> logger;
public WorkflowRulesEditorInstructionBuilder(ILogger<WorkflowRulesEditorInstructionBuilder> logger)
{
this.logger = logger;
}
public async Task<string> BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken)
{
var editorOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
var configuredPrompt = string.IsNullOrWhiteSpace(editorOptions.InitialPrompt)
? DefaultPrompt
: editorOptions.InitialPrompt!;
var rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath) ?? "<not configured>";
var docs = await ReadWorkflowDocsAsync(cancellationToken);
return configuredPrompt.Trim() + Environment.NewLine + Environment.NewLine +
$"Configured workflow rules file: {rulesPath}" + Environment.NewLine + Environment.NewLine +
"Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine +
"Workflow rules reference documentation:" + Environment.NewLine +
"```markdown" + Environment.NewLine +
docs.Trim() + Environment.NewLine +
"```";
}
private async Task<string> ReadWorkflowDocsAsync(CancellationToken cancellationToken)
{
foreach (var path in CandidateDocumentationPaths())
{
if (File.Exists(path))
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
}
logger.LogWarning("Workflow rules editor could not find docs/meeting-workflow-engine.md for its prompt");
return "Workflow documentation file docs/meeting-workflow-engine.md was not available at runtime.";
}
private static IEnumerable<string> CandidateDocumentationPaths()
{
var baseDirectory = AppContext.BaseDirectory;
yield return Path.Combine(baseDirectory, "docs", "meeting-workflow-engine.md");
var current = new DirectoryInfo(baseDirectory);
for (var i = 0; i < 8 && current is not null; i++)
{
yield return Path.Combine(current.FullName, "docs", "meeting-workflow-engine.md");
current = current.Parent;
}
}
}
@@ -0,0 +1,258 @@
namespace MeetingAssistant.Workflow;
internal enum WorkflowRulesEditorMarkdownInlineStyle
{
Normal,
Italic,
Bold,
Code
}
internal abstract record WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownParagraph(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTable(
IReadOnlyList<string> Header,
IReadOnlyList<IReadOnlyList<string>> Rows) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownInline(
string Text,
WorkflowRulesEditorMarkdownInlineStyle Style);
internal static class WorkflowRulesEditorMarkdown
{
public static IReadOnlyList<WorkflowRulesEditorMarkdownBlock> Parse(string text)
{
if (string.IsNullOrEmpty(text))
{
return [];
}
var blocks = new List<WorkflowRulesEditorMarkdownBlock>();
var paragraph = new List<string>();
var code = new List<string>();
var inCodeBlock = false;
var lines = ReadLines(text).ToArray();
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index];
if (line.TrimStart().StartsWith("```", StringComparison.Ordinal))
{
if (inCodeBlock)
{
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
code.Clear();
inCodeBlock = false;
}
else
{
FlushParagraph(blocks, paragraph);
inCodeBlock = true;
}
continue;
}
if (inCodeBlock)
{
code.Add(line);
continue;
}
if (IsTableStart(lines, index))
{
FlushParagraph(blocks, paragraph);
var header = ParseTableRow(lines[index]);
index += 2;
var rows = new List<IReadOnlyList<string>>();
while (index < lines.Length && IsTableRow(lines[index]))
{
rows.Add(ParseTableRow(lines[index]));
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownTable(header, rows));
index--;
continue;
}
if (string.IsNullOrWhiteSpace(line))
{
FlushParagraph(blocks, paragraph);
}
else
{
paragraph.Add(line);
}
}
if (inCodeBlock)
{
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
}
FlushParagraph(blocks, paragraph);
return blocks;
}
public static IReadOnlyList<WorkflowRulesEditorMarkdownInline> ParseInline(string text)
{
var result = new List<WorkflowRulesEditorMarkdownInline>();
var index = 0;
while (index < text.Length)
{
var next = FindNextMarker(text, index);
if (next < 0)
{
AddInline(result, text[index..], WorkflowRulesEditorMarkdownInlineStyle.Normal);
break;
}
if (next > index)
{
AddInline(result, text[index..next], WorkflowRulesEditorMarkdownInlineStyle.Normal);
}
if (text[next] == '`')
{
var end = text.IndexOf('`', next + 1);
if (end > next)
{
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Code);
index = end + 1;
continue;
}
}
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
{
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Bold);
index = end + 2;
continue;
}
}
else if (text[next] == '*')
{
var end = text.IndexOf('*', next + 1);
if (end > next)
{
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Italic);
index = end + 1;
continue;
}
}
AddInline(result, text[next].ToString(), WorkflowRulesEditorMarkdownInlineStyle.Normal);
index = next + 1;
}
return result;
}
private static void FlushParagraph(
List<WorkflowRulesEditorMarkdownBlock> blocks,
List<string> paragraph)
{
if (paragraph.Count == 0)
{
return;
}
blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph))));
paragraph.Clear();
}
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
{
return index + 1 < lines.Count &&
IsTableRow(lines[index]) &&
IsTableSeparator(lines[index + 1]);
}
private static bool IsTableRow(string line)
{
return !string.IsNullOrWhiteSpace(line) && line.Contains('|', StringComparison.Ordinal);
}
private static bool IsTableSeparator(string line)
{
var cells = ParseTableRow(line);
return cells.Count > 0 &&
cells.All(cell =>
{
var trimmed = cell.Trim();
return trimmed.Contains('-', StringComparison.Ordinal) &&
trimmed.All(character => character is '-' or ':' or ' ');
});
}
private static IReadOnlyList<string> ParseTableRow(string line)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('|'))
{
trimmed = trimmed[1..];
}
if (trimmed.EndsWith('|'))
{
trimmed = trimmed[..^1];
}
return trimmed
.Split('|')
.Select(cell => cell.Trim())
.ToArray();
}
private static void AddInline(
List<WorkflowRulesEditorMarkdownInline> result,
string text,
WorkflowRulesEditorMarkdownInlineStyle style)
{
if (text.Length == 0)
{
return;
}
if (result.Count > 0 && result[^1].Style == style)
{
result[^1] = result[^1] with { Text = result[^1].Text + text };
return;
}
result.Add(new WorkflowRulesEditorMarkdownInline(text, style));
}
private static int FindNextMarker(string text, int start)
{
var code = text.IndexOf('`', start);
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
var italic = text.IndexOf('*', start);
if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*')
{
italic = text.IndexOf('*', italic + 2);
}
return new[] { code, bold, italic }
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
}
private static IEnumerable<string> ReadLines(string text)
{
using var reader = new StringReader(text);
while (reader.ReadLine() is { } line)
{
yield return line;
}
}
}
@@ -0,0 +1,233 @@
#if WINDOWS
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
namespace MeetingAssistant.Workflow;
internal static class WorkflowRulesEditorMarkdownRenderer
{
public static UIElement CreateContent(string content)
{
var panel = new StackPanel()
.Vertical()
.Spacing(7);
foreach (var block in WorkflowRulesEditorMarkdown.Parse(content))
{
panel.Add(block switch
{
WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock),
WorkflowRulesEditorMarkdownTable table => CreateTable(table),
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph),
_ => new TextBlock()
});
}
return panel;
}
private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph)
{
var lines = SplitInlineLines(paragraph.Inlines);
if (lines.Count == 1)
{
return CreateParagraphLine(lines[0]);
}
var panel = new StackPanel()
.Vertical()
.Spacing(3);
foreach (var line in lines)
{
panel.Add(CreateParagraphLine(line));
}
return panel;
}
private static UIElement CreateParagraphLine(IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
{
var linePanel = new WrapPanel()
.Spacing(1);
foreach (var inline in inlines)
{
foreach (var element in CreateInlineElements(inline))
{
linePanel.Add(element);
}
}
return linePanel;
}
private static IEnumerable<UIElement> CreateInlineElements(WorkflowRulesEditorMarkdownInline inline)
{
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
{
yield return CreateInlineCode(inline.Text);
yield break;
}
foreach (var token in SplitInlineText(inline.Text))
{
var text = new TextBlock()
.Text(token)
.TextWrapping(TextWrapping.Wrap)
.Foreground(Color.FromRgb(230, 235, 243));
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
{
text.FontWeight(FontWeight.Bold);
}
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
{
text.FontFamily("Segoe UI Italic");
}
yield return text;
}
}
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
{
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
var current = new List<WorkflowRulesEditorMarkdownInline>();
foreach (var inline in inlines)
{
var parts = inline.Text.Split('\n');
for (var index = 0; index < parts.Length; index++)
{
if (parts[index].Length > 0)
{
current.Add(inline with { Text = parts[index] });
}
if (index < parts.Length - 1)
{
lines.Add(current);
current = [];
}
}
}
lines.Add(current);
return lines;
}
private static UIElement CreateInlineCode(string text)
{
return new Border()
.Padding(4, 1, 4, 2)
.Margin(1, 0, 1, 0)
.CornerRadius(4)
.BorderThickness(1)
.BorderBrush(Color.FromRgb(86, 94, 108))
.Background(Color.FromRgb(24, 28, 35))
.Child(new TextBlock()
.Text(text)
.FontFamily("Consolas")
.Foreground(Color.FromRgb(237, 241, 247)));
}
private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table)
{
var rows = table.Rows
.Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count)))
.ToArray();
var gridView = new GridView()
.HeaderHeight(30)
.RowHeight(30)
.CellPadding(new Thickness(8, 4, 8, 4))
.ShowGridLines(true)
.ZebraStriping(true);
for (var index = 0; index < table.Header.Count; index++)
{
var columnIndex = index;
gridView.AddColumn<MarkdownTableRow>(
table.Header[index],
GetMarkdownTableColumnWidth(table, index),
_ => new TextBlock()
.TextWrapping(TextWrapping.NoWrap)
.Foreground(Color.FromRgb(230, 235, 243)),
(element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)),
minWidth: 64,
resizable: true);
}
return gridView
.ItemsSource(rows)
.MinWidth(Math.Min(900, table.Header.Count * 120))
.Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30));
}
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
{
return new Border()
.Padding(9)
.CornerRadius(6)
.BorderThickness(1)
.BorderBrush(Color.FromRgb(86, 94, 108))
.Background(Color.FromRgb(20, 24, 31))
.Child(new TextBlock()
.Text(codeBlock.Text)
.TextWrapping(TextWrapping.Wrap)
.FontFamily("Consolas")
.Foreground(Color.FromRgb(237, 241, 247)));
}
private static IEnumerable<string> SplitInlineText(string text)
{
var start = 0;
while (start < text.Length)
{
var end = start;
var isWhitespace = char.IsWhiteSpace(text[start]);
while (end < text.Length && char.IsWhiteSpace(text[end]) == isWhitespace)
{
end++;
}
var token = text[start..end];
if (token.Length > 0)
{
yield return token;
}
start = end;
}
}
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
{
if (cells.Count >= count)
{
return cells;
}
return cells
.Concat(Enumerable.Repeat("", count - cells.Count))
.ToArray();
}
private static double GetMarkdownTableColumnWidth(
WorkflowRulesEditorMarkdownTable table,
int index)
{
var maxLength = new[] { table.Header[index].Length }
.Concat(table.Rows.Select(row => index < row.Count ? row[index].Length : 0))
.DefaultIfEmpty(0)
.Max();
return Math.Clamp(maxLength * 8 + 28, 72, 260);
}
private sealed record MarkdownTableRow(IReadOnlyList<string> Cells)
{
public string GetCell(int index)
{
return index >= 0 && index < Cells.Count
? Cells[index]
: "";
}
}
}
#endif
@@ -0,0 +1,100 @@
using System.Threading.Channels;
using NAudio.Wave;
namespace MeetingAssistant.Workflow;
public interface IWorkflowRulesEditorSamplePlaybackQueue
{
Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default);
}
public sealed class WorkflowRulesEditorSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue, IDisposable
{
private readonly Channel<QueuedSample> channel = Channel.CreateUnbounded<QueuedSample>();
private readonly ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger;
private readonly CancellationTokenSource cancellation = new();
private readonly Task playbackTask;
public WorkflowRulesEditorSamplePlaybackQueue(ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger)
{
this.logger = logger;
playbackTask = Task.Run(PlayQueuedSamplesAsync);
}
public async Task<string> QueueAsync(
int sampleId,
byte[] wavBytes,
CancellationToken cancellationToken = default)
{
if (wavBytes.Length == 0)
{
return $"Sample {sampleId} is empty and was not queued.";
}
await channel.Writer.WriteAsync(new QueuedSample(sampleId, wavBytes), cancellationToken);
return $"Queued sample {sampleId} for playback.";
}
public void Dispose()
{
cancellation.Cancel();
channel.Writer.TryComplete();
try
{
playbackTask.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
cancellation.Dispose();
}
private async Task PlayQueuedSamplesAsync()
{
try
{
await foreach (var sample in channel.Reader.ReadAllAsync(cancellation.Token))
{
try
{
await PlayAsync(sample, cancellation.Token);
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not play queued speaker identity sample {SampleId}",
sample.SampleId);
}
}
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
}
}
private static async Task PlayAsync(QueuedSample sample, CancellationToken cancellationToken)
{
await using var stream = new MemoryStream(sample.WavBytes, writable: false);
using var reader = new WaveFileReader(stream);
using var output = new WaveOutEvent();
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
output.PlaybackStopped += (_, _) => completed.TrySetResult();
output.Init(reader);
output.Play();
await using var registration = cancellationToken.Register(() =>
{
output.Stop();
completed.TrySetCanceled(cancellationToken);
});
await completed.Task;
}
private sealed record QueuedSample(int SampleId, byte[] WavBytes);
}
@@ -0,0 +1,31 @@
namespace MeetingAssistant.Workflow;
internal static class WorkflowRulesEditorScrollPolicy
{
private const double BottomTolerance = 8;
public static bool ShouldAutoScroll(
double verticalOffset,
double viewportHeight,
double previousContentHeight)
{
if (viewportHeight <= 0 || previousContentHeight <= 0)
{
return true;
}
if (previousContentHeight <= viewportHeight)
{
return true;
}
return verticalOffset + viewportHeight >= previousContentHeight - BottomTolerance;
}
public static double GetBottomOffset(
double viewportHeight,
double contentHeight)
{
return Math.Max(0, contentHeight - viewportHeight);
}
}
@@ -0,0 +1,610 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using MeetingAssistant.Speakers;
using Microsoft.EntityFrameworkCore;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly string? rulesPath;
private readonly SpeakerIdentificationOptions speakerOptions;
private readonly IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
public WorkflowRulesEditorTools(
MeetingAssistantOptions options,
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
{
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
speakerOptions = options.SpeakerIdentification;
this.dbContextFactory = dbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
}
public async Task<string> ReadRules(int? from = null, int? to = null)
{
if (rulesPath is null)
{
return "Workflow rules file is not configured.";
}
if (!File.Exists(rulesPath))
{
return "";
}
return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to);
}
public async Task<string> WriteRules(string yaml)
{
if (rulesPath is null)
{
return "Refused: workflow rules file is not configured.";
}
if (yaml is null)
{
return "Refused: yaml must not be null.";
}
var validation = ValidateYaml(yaml);
if (validation is not null)
{
return validation;
}
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
await File.WriteAllTextAsync(rulesPath, yaml);
return rulesPath;
}
public async Task<string> Search(string keywords)
{
if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath))
{
return "";
}
var rgResult = await RunRipgrepAsync(rulesPath, keywords);
return rgResult is not null
? FormatRipgrepJson(rgResult)
: SearchWithRegexFallback(rulesPath, keywords);
}
public async Task<string> SearchIdentities(string? query = null, int limit = 25)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identities = await LoadIdentities(context)
.OrderBy(identity => identity.CanonicalName ?? "")
.ThenBy(identity => identity.Id)
.ToListAsync();
if (!string.IsNullOrWhiteSpace(query))
{
var needle = query.Trim();
identities = identities
.Where(identity => IdentityMatches(identity, needle))
.ToList();
}
return ToJson(identities
.Take(Math.Clamp(limit, 1, 100))
.Select(ToIdentitySummary)
.ToList());
}
}
public async Task<string> ReadIdentity(int identityId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identity = await LoadIdentities(context)
.SingleOrDefaultAsync(identity => identity.Id == identityId);
return identity is null
? $"Identity {identityId} was not found."
: ToJson(ToIdentityDetail(identity));
}
}
public async Task<string> CreateIdentity(
string? canonicalName = null,
string[]? aliases = null,
string[]? candidateNames = null)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var now = DateTimeOffset.UtcNow;
var identity = new SpeakerIdentity
{
CanonicalName = NormalizeNullable(canonicalName),
CreatedAt = now,
UpdatedAt = now,
Aliases = NormalizeNames(aliases)
.Select(name => new SpeakerAlias { Name = name })
.ToList(),
CandidateNames = NormalizeNames(candidateNames)
.Select(name => new SpeakerCandidateName { Name = name })
.ToList()
};
context.SpeakerIdentities.Add(identity);
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(identity));
}
}
public async Task<string> UpdateIdentity(
int identityId,
string? canonicalName = null,
string[]? aliases = null,
string[]? candidateNames = null)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identity = await LoadIdentities(context)
.SingleOrDefaultAsync(identity => identity.Id == identityId);
if (identity is null)
{
return $"Identity {identityId} was not found.";
}
identity.CanonicalName = NormalizeNullable(canonicalName);
identity.Aliases.Clear();
identity.Aliases.AddRange(NormalizeNames(aliases)
.Select(name => new SpeakerAlias { Name = name }));
identity.CandidateNames.Clear();
identity.CandidateNames.AddRange(NormalizeNames(candidateNames)
.Select(name => new SpeakerCandidateName { Name = name }));
identity.UpdatedAt = DateTimeOffset.UtcNow;
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(identity));
}
}
public async Task<string> DeleteIdentity(int identityId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var identity = await LoadIdentities(context)
.SingleOrDefaultAsync(identity => identity.Id == identityId);
if (identity is null)
{
return $"Identity {identityId} was not found.";
}
context.SpeakerIdentities.Remove(identity);
await context.SaveChangesAsync();
return $"Deleted identity {identityId}.";
}
}
public async Task<string> MergeIdentities(int targetIdentityId, int sourceIdentityId)
{
if (targetIdentityId == sourceIdentityId)
{
return "Refused: target and source identity are the same.";
}
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var target = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == targetIdentityId);
var source = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == sourceIdentityId);
if (target is null || source is null)
{
return $"Could not find target {targetIdentityId} or source {sourceIdentityId}.";
}
SpeakerIdentityMerger.MergeInto(
target,
source,
speakerOptions.MaxSnippetsPerSpeaker);
context.SpeakerIdentities.Remove(source);
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(target));
}
}
public async Task<string> ListIdentitySamples(int identityId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var samples = await context.SpeakerSnippets
.Where(sample => sample.SpeakerIdentityId == identityId)
.ToListAsync();
return ToJson(samples
.OrderBy(sample => sample.CreatedAt)
.Select(ToIdentitySampleSummary)
.ToList());
}
}
public async Task<string> ReadIdentitySample(int sampleId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
return sample is null
? $"Sample {sampleId} was not found."
: ToJson(new IdentitySampleDetail(
sample.Id,
sample.SpeakerIdentityId,
sample.CreatedAt,
sample.WavBytes.Length,
Convert.ToBase64String(sample.WavBytes)));
}
}
public async Task<string> DeleteIdentitySample(int sampleId)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
if (sample is null)
{
return $"Sample {sampleId} was not found.";
}
context.SpeakerSnippets.Remove(sample);
await context.SaveChangesAsync();
return $"Deleted sample {sampleId}.";
}
}
public async Task<string> QueuePlayIdentitySample(int sampleId)
{
if (samplePlaybackQueue is null)
{
return "Sample playback queue is not configured.";
}
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
return sample is null
? $"Sample {sampleId} was not found."
: await samplePlaybackQueue.QueueAsync(sample.Id, sample.WavBytes);
}
}
private static string? ValidateYaml(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
return null;
}
try
{
_ = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml);
return null;
}
catch (YamlException exception)
{
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
}
}
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "rg",
WorkingDirectory = Path.GetDirectoryName(rulesPath)!,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("--json");
startInfo.ArgumentList.Add("--line-number");
startInfo.ArgumentList.Add("--color");
startInfo.ArgumentList.Add("never");
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add(keywords);
startInfo.ArgumentList.Add(Path.GetFileName(rulesPath));
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode is 0 or 1 ? output : null;
}
catch
{
return null;
}
}
private static string FormatRipgrepJson(string output)
{
var matches = new List<string>();
using var reader = new StringReader(output);
string? line;
while ((line = reader.ReadLine()) is not null)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var type) ||
type.GetString() != "match" ||
!root.TryGetProperty("data", out var data))
{
continue;
}
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
var lineNumber = data.GetProperty("line_number").GetInt32();
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
matches.Add($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
}
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(string rulesPath, string keywords)
{
try
{
var regex = new Regex(keywords, RegexOptions.IgnoreCase);
var matches = new List<string>();
var lines = File.ReadAllLines(rulesPath);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{Path.GetFileName(rulesPath)}:{index + 1} {lines[index]}");
}
}
return string.Join('\n', matches);
}
catch
{
return "";
}
}
private static string ReadLines(string content, int? from = null, int? to = null)
{
if (!from.HasValue && !to.HasValue)
{
return content;
}
var lines = content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.ToList();
if (lines.Count == 0)
{
return "";
}
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
if (end < start)
{
return "";
}
return string.Join('\n', lines.GetRange(start, end - start + 1));
}
private static string ToToolPath(string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private async Task<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
{
return dbContextFactory is null
? null
: await dbContextFactory.CreateDbContextAsync(cancellationToken);
}
private async Task<SpeakerIdentityDbContext?> CreatePreparedIdentityContextAsync(CancellationToken cancellationToken)
{
var context = await CreateIdentityContextAsync(cancellationToken);
if (context is null)
{
return null;
}
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
return context;
}
private static IQueryable<SpeakerIdentity> LoadIdentities(SpeakerIdentityDbContext context)
{
return context.SpeakerIdentities
.Include(identity => identity.Aliases)
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Snippets)
.Include(identity => identity.References);
}
private static bool IdentityMatches(SpeakerIdentity identity, string query)
{
return new[] { identity.CanonicalName }
.Concat(identity.Aliases.Select(alias => alias.Name))
.Concat(identity.CandidateNames.Select(candidate => candidate.Name))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Any(value => value!.Contains(query, StringComparison.OrdinalIgnoreCase));
}
private static IdentitySummary ToIdentitySummary(SpeakerIdentity identity)
{
return new IdentitySummary(
identity.Id,
identity.CanonicalName,
identity.GetDisplayName(),
identity.Aliases.Select(alias => alias.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
identity.CandidateNames.Select(candidate => candidate.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
identity.Snippets.Count,
identity.References.Count,
identity.UpdatedAt);
}
private static IdentityDetail ToIdentityDetail(SpeakerIdentity identity)
{
return new IdentityDetail(
ToIdentitySummary(identity),
identity.References
.OrderByDescending(reference => reference.CreatedAt)
.Select(reference => new IdentityReferenceDetail(
reference.Id,
reference.MeetingNotePath,
reference.TranscriptPath,
reference.CreatedAt))
.ToArray(),
identity.Snippets
.OrderBy(sample => sample.CreatedAt)
.Select(ToIdentitySampleSummary)
.ToArray());
}
private static IdentitySampleSummary ToIdentitySampleSummary(SpeakerSnippet sample)
{
return new IdentitySampleSummary(
sample.Id,
sample.SpeakerIdentityId,
sample.CreatedAt,
sample.WavBytes.Length);
}
private static IReadOnlyList<string> NormalizeNames(IEnumerable<string>? names)
{
return names?
.Select(name => name.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray()
?? [];
}
private static string? NormalizeNullable(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static string ToJson<T>(T value)
{
return JsonSerializer.Serialize(value, JsonOptions);
}
private sealed record IdentitySummary(
int Id,
string? CanonicalName,
string? DisplayName,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> CandidateNames,
int SampleCount,
int ReferenceCount,
DateTimeOffset UpdatedAt);
private sealed record IdentityDetail(
IdentitySummary Identity,
IReadOnlyList<IdentityReferenceDetail> References,
IReadOnlyList<IdentitySampleSummary> Samples);
private sealed record IdentityReferenceDetail(
int Id,
string MeetingNotePath,
string TranscriptPath,
DateTimeOffset CreatedAt);
private sealed record IdentitySampleSummary(
int Id,
int IdentityId,
DateTimeOffset CreatedAt,
int ByteCount);
private sealed record IdentitySampleDetail(
int Id,
int IdentityId,
DateTimeOffset CreatedAt,
int ByteCount,
string Base64Wav);
}
@@ -0,0 +1,17 @@
namespace MeetingAssistant.Workflow;
public static class WorkflowRulesPathResolver
{
public static string? Resolve(string? configuredPath)
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
return null;
}
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
return Path.IsPathRooted(expanded)
? Path.GetFullPath(expanded)
: Path.GetFullPath(expanded);
}
}
+6 -1
View File
@@ -111,6 +111,11 @@
"Automation": {
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
},
"WorkflowRulesEditor": {
"Endpoint": "",
"KeyEnv": "",
"Model": ""
},
"Screenshots": {
"Hotkey": "Ctrl+Alt+S",
"AttachmentsFolder": "Attachments",
@@ -123,7 +128,7 @@
}
},
"Agent": {
"Endpoint": "https://litellm.schweigert.cloud",
"Endpoint": "http://127.0.0.1:4021",
"KeyEnv": "LITELLM_API_KEY",
"Model": "chatgpt/gpt-5.5",
"EnableThinking": true,
+9 -1
View File
@@ -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
@@ -170,6 +171,11 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
},
"WorkflowRulesEditor": {
"Endpoint": "",
"KeyEnv": "",
"Model": ""
},
"Screenshots": {
"Hotkey": "Ctrl+Alt+S",
"AttachmentsFolder": "Attachments",
@@ -227,6 +233,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 +253,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.
+33
View File
@@ -22,6 +22,35 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
The tray menu includes `Edit rules`, which opens a small MewUI chat editor for this configured rules file. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
```json
{
"MeetingAssistant": {
"WorkflowRulesEditor": {
"Endpoint": "",
"KeyEnv": "",
"Model": "",
"EnableThinking": null,
"ReasoningEffort": null,
"MaxOutputTokens": null,
"EnableCompaction": null,
"CompactionRemainingRatio": null,
"ResponsesCompactPath": "",
"InitialPrompt": ""
}
}
}
```
Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor on the same LiteLLM Responses endpoint and model as the summarizer unless a value is explicitly configured. Each user-submitted editor turn sends its first model request with `X-Initiator: user`; follow-up model requests in the same turn, such as tool-call continuations, use `X-Initiator: agent`.
The editor agent prompt includes this document and is restricted to these tools:
- `read_rules`: read the configured rules file, optionally by inclusive 1-based line range.
- `write_rules`: overwrite the configured rules file after validating that the YAML parses as a workflow rules document.
- `search`: search only the configured rules file with ripgrep-style syntax.
`POST /diagnostics/workflow/reload` reloads application configuration without restarting the process. Use it after changing workflow automation configuration such as `MeetingAssistant:Automation:RulesPath`. The endpoint returns the currently bound rules path:
```json
@@ -302,9 +331,13 @@ Core files:
- `MeetingAssistant/Workflow/MeetingWorkflowModels.cs`
- `MeetingAssistant/Workflow/MeetingWorkflowEvent.cs`
- `MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs`
- `MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs`
- `MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs`
- `MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs`
- `MeetingAssistant/Recording/MeetingRecordingCoordinator.cs`
- `MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs`
- `MeetingAssistant.Tests/MeetingWorkflowDiagnosticEndpointTests.cs`
- `MeetingAssistant.Tests/WorkflowRulesEditorTests.cs`
When extending the workflow engine:
@@ -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.
@@ -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.
@@ -0,0 +1,18 @@
# Add workflow rules editor UI
## Why
Workflow rules are currently edited by opening the local YAML file manually. A small tray-launched assistant UI should make rule edits faster while keeping the agent constrained to the configured rules file and documented workflow format.
## What Changes
- Add an `Edit rules` tray menu item.
- Replace the tray icon implementation with the Uno notification icon stack.
- Add a MewUI chat window for editing workflow rules.
- Add a workflow-rules editor agent pipeline with read, write, and search tools scoped to the configured rules file.
- Allow the editor agent model configuration to inherit summarizer settings by default and be overridden independently.
## Impact
- Affected specs: `meeting-session`
- Affected code: taskbar host, workflow automation, agent client configuration, Windows UI package references
@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.
The tray icon SHALL be implemented through the Uno notification icon stack.
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
The Windows executable and rules-and-identities editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon.
The rules editor agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults.
The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow rules editor through the same window service used by the tray menu.
#### Scenario: Tray menu opens the editor
- **WHEN** the user opens the tray icon menu
- **THEN** the menu includes `Edit rules and identities`
- **WHEN** the user selects `Edit rules and identities`
- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window
#### Scenario: Tray menu shows configured profile hotkeys
- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M`
- **AND** the `english` launch profile uses `Ctrl+Alt+E`
- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle
- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M`
- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E`
#### Scenario: Rules editor can be opened diagnostically
- **WHEN** a diagnostic caller requests the workflow rules editor to open
- **THEN** Meeting Assistant invokes the workflow rules editor window service
#### Scenario: Rules editor is scoped to the configured rules file
- **GIVEN** a configured workflow rules file
- **WHEN** the rules editor agent runs
- **THEN** its read, write, and search tools can access only that configured workflow rules file
- **AND** its system prompt includes the workflow engine documentation
#### Scenario: Rules editor can manage speaker identities
- **GIVEN** the speaker identity database contains speaker identities and samples
- **WHEN** the rules editor agent runs
- **THEN** it can search, read, create, update, delete, and merge speaker identities
- **AND** it can list, read, delete, and queue playback of identity samples
#### Scenario: User sends a rules-editing chat turn
- **GIVEN** the rules editor chat window is open
- **WHEN** the user types a prompt and presses Enter
- **THEN** the user message appears in the conversation
- **AND** a plain `Thinking...` line appears while the agent is running
- **AND** the first model request for that turn is marked as user-initiated
- **AND** the final assistant response replaces the thinking line
#### Scenario: Chat auto-scrolls after appended content
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
- **WHEN** a new user or assistant message is appended
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
@@ -0,0 +1,16 @@
## 1. Requirements
- [x] Add meeting-session requirement scenarios for tray-launched rules and identities editing.
## 2. Implementation
- [x] Add scoped workflow-rules and speaker-identity editor tools and prompt builder.
- [x] Add workflow-rules editor chat pipeline with compaction and user-initiated requests.
- [x] Add MewUI chat window.
- [x] Add a Windows application/window icon for the MewUI editor taskbar button.
- [x] Replace WinForms tray icon with Uno notification icon host and add `Edit rules and identities`.
## 3. Verification
- [x] Add focused tests for options fallback, tool scoping, identity tools, prompt content, and view-model chat behavior.
- [x] Run relevant tests and strict OpenSpec validation.
+36
View File
@@ -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
+70
View File
@@ -182,3 +182,73 @@ Meeting Assistant SHALL support these initial rule steps:
- **GIVEN** a configured state-transition rule that matches a title containing a configured marker
- **WHEN** the rule runs
- **THEN** Meeting Assistant can update the meeting title through `set_property`
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.
The tray icon SHALL be implemented through the Uno notification icon stack.
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
The Windows executable and rules-and-identities editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon.
The rules editor agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults.
The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow rules editor through the same window service used by the tray menu.
#### Scenario: Tray menu opens the editor
- **WHEN** the user opens the tray icon menu
- **THEN** the menu includes `Edit rules and identities`
- **WHEN** the user selects `Edit rules and identities`
- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window
#### Scenario: Tray menu shows configured profile hotkeys
- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M`
- **AND** the `english` launch profile uses `Ctrl+Alt+E`
- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle
- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M`
- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E`
#### Scenario: Rules editor can be opened diagnostically
- **WHEN** a diagnostic caller requests the workflow rules editor to open
- **THEN** Meeting Assistant invokes the workflow rules editor window service
#### Scenario: Rules editor is scoped to the configured rules file
- **GIVEN** a configured workflow rules file
- **WHEN** the rules editor agent runs
- **THEN** its read, write, and search tools can access only that configured workflow rules file
- **AND** its system prompt includes the workflow engine documentation
#### Scenario: Rules editor can manage speaker identities
- **GIVEN** the speaker identity database contains speaker identities and samples
- **WHEN** the rules editor agent runs
- **THEN** it can search, read, create, update, delete, and merge speaker identities
- **AND** it can list, read, delete, and queue playback of identity samples
#### Scenario: User sends a rules-editing chat turn
- **GIVEN** the rules editor chat window is open
- **WHEN** the user types a prompt and presses Enter
- **THEN** the user message appears in the conversation
- **AND** a plain `Thinking...` line appears while the agent is running
- **AND** the first model request for that turn is marked as user-initiated
- **AND** the final assistant response replaces the thinking line
#### Scenario: Chat auto-scrolls after appended content
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
- **WHEN** a new user or assistant message is appended
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
+18
View File
@@ -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