Expand settings and logs agent tools
PR and Push Build/Test / build-and-test (push) Successful in 9m36s

This commit is contained in:
2026-06-01 21:07:08 +02:00
parent abac46c691
commit 11c65ce669
12 changed files with 1160 additions and 45 deletions
@@ -24,7 +24,7 @@ internal static class MeetingSummaryFrontmatterFactory
artifacts.SummaryPath,
meetingNote,
cancellationToken);
frontmatter.Projects = CopyNonEmptyList(meetingNote.Frontmatter.Projects);
frontmatter.Projects = CopyNonEmptyProjectList(meetingNote.Frontmatter.Projects);
return frontmatter;
}
@@ -42,6 +42,16 @@ internal static class MeetingSummaryFrontmatterFactory
return values.Count == 0 ? null : values.ToList();
}
private static List<string>? CopyNonEmptyProjectList(IEnumerable<string> values)
{
var projects = values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return projects.Count == 0 ? null : projects;
}
private static async Task<List<string>?> ReadExistingSummaryAttendeesAsync(
string summaryPath,
CancellationToken cancellationToken)
@@ -1,4 +1,6 @@
#if WINDOWS
using System.Runtime.InteropServices;
using System.Text;
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
@@ -6,6 +8,8 @@ namespace MeetingAssistant.Workflow;
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
internal const string WindowTitle = "Settings and logs";
private readonly IServiceProvider services;
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
private readonly object sync = new();
@@ -26,7 +30,13 @@ public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditor
if (isRunning)
{
logger.LogInformation("Workflow rules editor UI is already running");
return;
if (WorkflowRulesEditorWindowActivation.TryActivate(WindowTitle))
{
return;
}
logger.LogWarning("Workflow rules editor UI was marked running, but no window could be activated; starting a new UI thread");
isRunning = false;
}
isRunning = true;
@@ -108,7 +118,7 @@ internal sealed class WorkflowRulesEditorMewWindow
.OnClick(() => _ = SendAsync());
var window = new Window()
.Title("Settings and logs")
.Title(MewUiWorkflowRulesEditorWindowService.WindowTitle)
.Resizable(680, 760, 520, 460)
.Padding(0)
.OnLoaded(Render)
@@ -238,14 +248,140 @@ internal sealed class WorkflowRulesEditorMewWindow
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
{
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
return new Border()
var copyButton = new Button()
.Content("⧉")
.Width(28)
.Height(24)
.ToolTip("Copy message")
.OnClick(() => WorkflowRulesEditorClipboard.SetText(message.Content));
copyButton.IsVisible = false;
var copyRow = new DockPanel()
.LastChildFill(false)
.Children(copyButton.DockRight());
var cardContent = new StackPanel()
.Vertical()
.Spacing(4)
.Children(
copyRow,
WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
var card = 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));
.OnMouseEnter(() => copyButton.IsVisible = true)
.OnMouseLeave(() => copyButton.IsVisible = false)
.Child(cardContent);
return card;
}
}
internal static class WorkflowRulesEditorWindowActivation
{
private const int SwRestore = 9;
public static bool TryActivate(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return false;
}
var handle = FindWindow(null, title);
if (handle == IntPtr.Zero)
{
return false;
}
ShowWindow(handle, SwRestore);
return SetForegroundWindow(handle);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr FindWindow(string? lpClassName, string lpWindowName);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
}
internal static class WorkflowRulesEditorClipboard
{
private const uint CfUnicodeText = 13;
private const uint GmemMoveable = 0x0002;
private const uint GmemZeroInit = 0x0040;
public static void SetText(string text)
{
var bytes = Encoding.Unicode.GetBytes((text ?? "") + '\0');
var handle = GlobalAlloc(GmemMoveable | GmemZeroInit, (UIntPtr)bytes.Length);
if (handle == IntPtr.Zero)
{
return;
}
var locked = GlobalLock(handle);
if (locked == IntPtr.Zero)
{
GlobalFree(handle);
return;
}
Marshal.Copy(bytes, 0, locked, bytes.Length);
GlobalUnlock(handle);
if (!OpenClipboard(IntPtr.Zero))
{
GlobalFree(handle);
return;
}
try
{
EmptyClipboard();
if (SetClipboardData(CfUnicodeText, handle) != IntPtr.Zero)
{
handle = IntPtr.Zero;
}
}
finally
{
CloseClipboard();
if (handle != IntPtr.Zero)
{
GlobalFree(handle);
}
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool EmptyClipboard();
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool CloseClipboard();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalLock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GlobalUnlock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalFree(IntPtr hMem);
}
#endif
@@ -1,7 +1,12 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Summary;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.AI;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Workflow;
@@ -15,6 +20,12 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue;
private readonly MeetingRecordingCoordinator recordingCoordinator;
private readonly IMeetingMetadataProvider meetingMetadataProvider;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly ISpeakerIdentityMergeService identityMergeService;
private readonly AsrDiagnosticService asrDiagnosticService;
private readonly IConfiguration configuration;
public WorkflowRulesEditorChatPipeline(
IOptions<MeetingAssistantOptions> options,
@@ -22,7 +33,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
ILogger<WorkflowRulesEditorChatPipeline> logger,
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue)
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue,
MeetingRecordingCoordinator recordingCoordinator,
IMeetingMetadataProvider meetingMetadataProvider,
ILaunchProfileOptionsProvider launchProfiles,
ISpeakerIdentityMergeService identityMergeService,
AsrDiagnosticService asrDiagnosticService,
IConfiguration configuration)
{
this.options = options.Value;
this.loggerFactory = loggerFactory;
@@ -30,6 +47,12 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
this.instructionBuilder = instructionBuilder;
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
this.recordingCoordinator = recordingCoordinator;
this.meetingMetadataProvider = meetingMetadataProvider;
this.launchProfiles = launchProfiles;
this.identityMergeService = identityMergeService;
this.asrDiagnosticService = asrDiagnosticService;
this.configuration = configuration;
}
public async Task<WorkflowRulesEditorChatResult> SendAsync(
@@ -47,7 +70,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
var tools = new WorkflowRulesEditorTools(
options,
speakerIdentityDbContextFactory,
samplePlaybackQueue);
samplePlaybackQueue,
recordingCoordinator,
meetingMetadataProvider,
launchProfiles,
identityMergeService,
asrDiagnosticService,
configuration);
var messages = conversation
.Select(ToChatMessage)
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
@@ -181,6 +210,102 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
tools.SearchProjects,
"search_projects",
"Search files in all existing projects with .NET regular expression syntax. Use file_pattern to limit searched files by glob, for example *.md or **/*.md."),
AIFunctionFactory.Create(
tools.ListRecentSummaries,
"list_recent_summaries",
"List recent meeting summary notes from the configured summaries folder, newest first. Returns summary, meeting note, transcript, and assistant context paths to pass to the read tools."),
AIFunctionFactory.Create(
tools.ReadSummary,
"read_summary",
"Read one meeting summary file by path relative to the configured summaries folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadTranscript,
"read_transcript",
"Read one transcript file by path relative to the configured transcripts folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadMeetingNote,
"read_meeting_note",
"Read one meeting note file by path relative to the configured meeting notes folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadContext,
"read_context",
"Read one assistant context file by path relative to the configured assistant context folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
"Create or update a meeting summary file by path relative to the configured summaries folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteTranscript,
"write_transcript",
"Create or update a transcript file by path relative to the configured transcripts folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteMeetingNote,
"write_meeting_note",
"Create or update a meeting note file by path relative to the configured meeting notes folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
"Create or update an assistant context file by path relative to the configured assistant context folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteSummaryFrontmatter,
"write_summary_frontmatter",
"Replace only the YAML frontmatter of a meeting summary file while preserving the body. The path is relative to the configured summaries folder."),
AIFunctionFactory.Create(
tools.WriteTranscriptFrontmatter,
"write_transcript_frontmatter",
"Replace only the YAML frontmatter of a transcript file while preserving the body. The path is relative to the configured transcripts folder."),
AIFunctionFactory.Create(
tools.WriteMeetingNoteFrontmatter,
"write_meeting_note_frontmatter",
"Replace only the YAML frontmatter of a meeting note file while preserving the body. The path is relative to the configured meeting notes folder."),
AIFunctionFactory.Create(
tools.WriteContextFrontmatter,
"write_context_frontmatter",
"Replace only the YAML frontmatter of an assistant context file while preserving the body. The path is relative to the configured assistant context folder."),
AIFunctionFactory.Create(
tools.SearchSummaries,
"search_summaries",
"Search meeting summary files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.SearchTranscripts,
"search_transcripts",
"Search transcript files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.SearchMeetingNotes,
"search_meeting_notes",
"Search meeting note files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.SearchContext,
"search_context",
"Search assistant context files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.GetHealth,
"get_health",
"Return the same basic health information as the local /health endpoint."),
AIFunctionFactory.Create(
tools.GetRecordingStatus,
"get_recording_status",
"Return the current recording, artifact, process, and launch profile status without making an HTTP call."),
AIFunctionFactory.Create(
tools.DiagnoseCurrentMeeting,
"diagnose_current_meeting",
"Run the current meeting metadata diagnostic. Optional launch_profile selects a configured profile, started_at is a date/time, and timeout_seconds overrides the metadata lookup timeout."),
AIFunctionFactory.Create(
tools.MergeRecentSpeakerIdentities,
"merge_recent_speaker_identities",
"Run the recent speaker identity merge diagnostic. Optional recent_days overrides the configured merge age window; optional launch_profile selects profile options."),
AIFunctionFactory.Create(
tools.ReloadWorkflowConfiguration,
"reload_workflow_configuration",
"Reload application configuration in-process and return the currently bound workflow rules path."),
AIFunctionFactory.Create(
tools.DiagnoseAsrTranscribeFile,
"diagnose_asr_transcribe_file",
"Run ASR diagnostics on a local WAV file and return live transcription segments. Optional launch_profile selects a configured ASR profile."),
AIFunctionFactory.Create(
tools.DiagnoseAsrDiarizeFile,
"diagnose_asr_diarize_file",
"Run ASR diarization diagnostics on a local WAV file and return finished segments. Optional num_speakers and launch_profile mirror the HTTP diagnostic endpoint."),
AIFunctionFactory.Create(
tools.SearchIdentities,
"search_identities",
@@ -24,6 +24,10 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
Use search_spec and read_spec_file to inspect the OpenSpec product requirements before making behavior claims or configuration changes that depend on intended behavior.
Use list_projects, list_projectfiles, read_projectfile, write_projectfile, and search_projects for project knowledge files under the configured projects folder. These tools are intentionally scoped to existing project folders.
Use create_project when the user wants a new project folder. Prefer seed=recommended unless the user explicitly asks for an empty project or provides AGENTS.md content directly.
Use list_recent_summaries, read_summary, read_transcript, read_meeting_note, read_context, and the matching search tools when the user wants help post-processing meeting notes or investigating past meeting artifacts.
Prefer list_recent_summaries first when the user refers to recent meetings without naming an exact file.
Use the matching write_summary, write_transcript, write_meeting_note, write_context, and *_frontmatter tools only to repair or post-process meeting artifacts the user asked you to change. Prefer the frontmatter-specific tools when only metadata needs repair.
Use get_health, get_recording_status, diagnose_current_meeting, merge_recent_speaker_identities, reload_workflow_configuration, diagnose_asr_transcribe_file, and diagnose_asr_diarize_file for diagnostics that would otherwise require local HTTP endpoints.
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.
@@ -55,6 +59,8 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
"Log tools can read and search the current application-owned log file and four rotated older files under the temp log folder." + Environment.NewLine + Environment.NewLine +
"Spec tools can search and read copied OpenSpec markdown files from openspec/specs." + Environment.NewLine + Environment.NewLine +
"Project tools can create project folders and read, write, list, and search files in configured projects." + Environment.NewLine + Environment.NewLine +
"Meeting artifact tools can list recent summaries and read/search/write summaries, transcripts, meeting notes, and assistant context files for note post-processing and repair. Prefer frontmatter-specific write tools for metadata-only fixes." + Environment.NewLine + Environment.NewLine +
"Diagnostic tools mirror the local health, recording status, Outlook metadata, speaker identity merge, workflow reload, and ASR diagnostic endpoints without requiring HTTP." + 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 +
@@ -67,23 +67,20 @@ internal static class WorkflowRulesEditorMarkdownRenderer
yield break;
}
foreach (var token in SplitInlineText(inline.Text))
var text = new TextBlock()
.Text(inline.Text)
.TextWrapping(TextWrapping.Wrap)
.Foreground(Color.FromRgb(230, 235, 243));
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
{
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;
text.FontWeight(FontWeight.Bold);
}
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
{
text.FontFamily("Segoe UI Italic");
}
yield return text;
}
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
@@ -175,28 +172,6 @@ internal static class WorkflowRulesEditorMarkdownRenderer
.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)
@@ -1,9 +1,14 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Logging;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -28,20 +33,38 @@ public sealed class WorkflowRulesEditorTools
private readonly string specRootPath;
private readonly string projectsRootPath;
private readonly string projectAgentsTemplatePath;
private readonly string summariesRootPath;
private readonly string transcriptsRootPath;
private readonly string meetingNotesRootPath;
private readonly string assistantContextRootPath;
private readonly MeetingAssistantOptions options;
private readonly SpeakerIdentificationOptions speakerOptions;
private readonly IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
private readonly MeetingRecordingCoordinator? recordingCoordinator;
private readonly IMeetingMetadataProvider? meetingMetadataProvider;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly ISpeakerIdentityMergeService? identityMergeService;
private readonly AsrDiagnosticService? asrDiagnosticService;
private readonly IConfiguration? configuration;
public WorkflowRulesEditorTools(
MeetingAssistantOptions options,
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null,
MeetingRecordingCoordinator? recordingCoordinator = null,
IMeetingMetadataProvider? meetingMetadataProvider = null,
ILaunchProfileOptionsProvider? launchProfiles = null,
ISpeakerIdentityMergeService? identityMergeService = null,
AsrDiagnosticService? asrDiagnosticService = null,
IConfiguration? configuration = null,
string? configPath = null,
string? configDocsPath = null,
string? logDirectory = null,
string? specRootPath = null,
string? projectAgentsTemplatePath = null)
{
this.options = options;
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
this.configPath = ResolveConfigPath(configPath);
this.configDocsPath = ResolveConfigDocsPath(configDocsPath);
@@ -49,9 +72,19 @@ public sealed class WorkflowRulesEditorTools
this.specRootPath = ResolveSpecRootPath(specRootPath);
projectsRootPath = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
this.projectAgentsTemplatePath = ResolveProjectAgentsTemplatePath(projectAgentsTemplatePath);
summariesRootPath = VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder);
transcriptsRootPath = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
meetingNotesRootPath = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
assistantContextRootPath = VaultPath.Resolve(options.Vault, options.Vault.AssistantContextFolder);
speakerOptions = options.SpeakerIdentification;
this.dbContextFactory = dbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
this.recordingCoordinator = recordingCoordinator;
this.meetingMetadataProvider = meetingMetadataProvider;
this.launchProfiles = launchProfiles;
this.identityMergeService = identityMergeService;
this.asrDiagnosticService = asrDiagnosticService;
this.configuration = configuration;
}
public async Task<string> ReadRules(int? from = null, int? to = null, int? tail = null)
@@ -351,6 +384,298 @@ public sealed class WorkflowRulesEditorTools
return projectRoot.Name!;
}
public async Task<string> ListRecentSummaries(int limit = 20)
{
if (!Directory.Exists(summariesRootPath))
{
return "";
}
var summaries = new List<MeetingArtifactSummary>();
foreach (var file in Directory.EnumerateFiles(summariesRootPath, "*.md", SearchOption.TopDirectoryOnly))
{
summaries.Add(await ReadMeetingArtifactSummaryAsync(file));
}
var lines = summaries
.OrderByDescending(summary => summary.SortTime)
.ThenByDescending(summary => summary.ToolPath, StringComparer.OrdinalIgnoreCase)
.Take(Math.Clamp(limit, 1, 200))
.Select(summary =>
$"{summary.ToolPath} | {summary.StartTimeText} | title: {summary.Title} | projects: {string.Join(", ", summary.Projects)} | meeting_note: {summary.MeetingNotePath} | transcript: {summary.TranscriptPath} | context: {summary.ContextPath}");
return string.Join('\n', lines);
}
public Task<string> ReadSummary(string path, int? from = null, int? to = null, int? tail = null)
{
return ReadMeetingArtifactFileAsync(summariesRootPath, path, "summary", from, to, tail);
}
public Task<string> ReadTranscript(string path, int? from = null, int? to = null, int? tail = null)
{
return ReadMeetingArtifactFileAsync(transcriptsRootPath, path, "transcript", from, to, tail);
}
public Task<string> ReadMeetingNote(string path, int? from = null, int? to = null, int? tail = null)
{
return ReadMeetingArtifactFileAsync(meetingNotesRootPath, path, "meeting note", from, to, tail);
}
public Task<string> ReadContext(string path, int? from = null, int? to = null, int? tail = null)
{
return ReadMeetingArtifactFileAsync(assistantContextRootPath, path, "assistant context", from, to, tail);
}
public Task<string> WriteSummary(
string path,
string content,
int? from = null,
int? to = null,
int? insert = null,
bool replace_file = false)
{
return WriteMeetingArtifactFileAsync(summariesRootPath, path, "summary", content, from, to, insert, replace_file);
}
public Task<string> WriteTranscript(
string path,
string content,
int? from = null,
int? to = null,
int? insert = null,
bool replace_file = false)
{
return WriteMeetingArtifactFileAsync(transcriptsRootPath, path, "transcript", content, from, to, insert, replace_file);
}
public Task<string> WriteMeetingNote(
string path,
string content,
int? from = null,
int? to = null,
int? insert = null,
bool replace_file = false)
{
return WriteMeetingArtifactFileAsync(meetingNotesRootPath, path, "meeting note", content, from, to, insert, replace_file);
}
public Task<string> WriteContext(
string path,
string content,
int? from = null,
int? to = null,
int? insert = null,
bool replace_file = false)
{
return WriteMeetingArtifactFileAsync(assistantContextRootPath, path, "assistant context", content, from, to, insert, replace_file);
}
public Task<string> WriteSummaryFrontmatter(string path, string yaml)
{
return WriteMeetingArtifactFrontmatterAsync(summariesRootPath, path, "summary", yaml);
}
public Task<string> WriteTranscriptFrontmatter(string path, string yaml)
{
return WriteMeetingArtifactFrontmatterAsync(transcriptsRootPath, path, "transcript", yaml);
}
public Task<string> WriteMeetingNoteFrontmatter(string path, string yaml)
{
return WriteMeetingArtifactFrontmatterAsync(meetingNotesRootPath, path, "meeting note", yaml);
}
public Task<string> WriteContextFrontmatter(string path, string yaml)
{
return WriteMeetingArtifactFrontmatterAsync(assistantContextRootPath, path, "assistant context", yaml);
}
public Task<string> SearchSummaries(string keywords, int maxMatches = 100, string? file_pattern = null)
{
return SearchMeetingArtifactFilesAsync(summariesRootPath, keywords, maxMatches, file_pattern);
}
public Task<string> SearchTranscripts(string keywords, int maxMatches = 100, string? file_pattern = null)
{
return SearchMeetingArtifactFilesAsync(transcriptsRootPath, keywords, maxMatches, file_pattern);
}
public Task<string> SearchMeetingNotes(string keywords, int maxMatches = 100, string? file_pattern = null)
{
return SearchMeetingArtifactFilesAsync(meetingNotesRootPath, keywords, maxMatches, file_pattern);
}
public Task<string> SearchContext(string keywords, int maxMatches = 100, string? file_pattern = null)
{
return SearchMeetingArtifactFilesAsync(assistantContextRootPath, keywords, maxMatches, file_pattern);
}
public string GetHealth()
{
return ToJson(new
{
service = "meeting-assistant",
status = "ok"
});
}
public string GetRecordingStatus()
{
return recordingCoordinator is null
? "Recording coordinator is not configured."
: ToJson(recordingCoordinator.CurrentStatus);
}
public async Task<string> DiagnoseCurrentMeeting(
string? launch_profile = null,
string? started_at = null,
double? timeout_seconds = null)
{
if (meetingMetadataProvider is null)
{
return "Meeting metadata provider is not configured.";
}
if (!string.IsNullOrWhiteSpace(started_at) &&
!DateTimeOffset.TryParse(started_at, out _))
{
return "Refused: started_at must be a valid date/time.";
}
try
{
var profileOptions = ResolveProfileOptions(launch_profile);
var lookupStartedAt = DateTimeOffset.TryParse(started_at, out var parsedStartedAt)
? parsedStartedAt
: DateTimeOffset.Now;
var timeout = timeout_seconds is > 0
? TimeSpan.FromSeconds(timeout_seconds.Value)
: profileOptions.Recording.MetadataLookupTimeout;
if (meetingMetadataProvider is IMeetingMetadataDiagnosticProvider diagnostics)
{
return ToJson(await diagnostics.DiagnoseCurrentMeetingAsync(
lookupStartedAt,
timeout,
CancellationToken.None));
}
var stopwatch = Stopwatch.StartNew();
var metadata = await meetingMetadataProvider.GetCurrentMeetingAsync(lookupStartedAt, CancellationToken.None);
stopwatch.Stop();
return ToJson(new MeetingMetadataDiagnosticResult(
meetingMetadataProvider.GetType().Name,
lookupStartedAt,
metadata is not null,
false,
stopwatch.ElapsedMilliseconds,
metadata,
metadata is null ? "No meeting metadata provider result was available." : null));
}
catch (Exception exception)
{
return $"Diagnostic failed: {exception.Message}";
}
}
public async Task<string> MergeRecentSpeakerIdentities(double? recent_days = null, string? launch_profile = null)
{
if (identityMergeService is null)
{
return "Speaker identity merge service is not configured.";
}
try
{
var profileOptions = ResolveProfileOptions(launch_profile);
var recentAge = recent_days is > 0
? TimeSpan.FromDays(recent_days.Value)
: profileOptions.SpeakerIdentification.MergeRecentIdentityAge;
return ToJson(await identityMergeService.MergeRecentIdentitiesAsync(recentAge, CancellationToken.None));
}
catch (Exception exception)
{
return $"Diagnostic failed: {exception.Message}";
}
}
public string ReloadWorkflowConfiguration()
{
if (configuration is not IConfigurationRoot root)
{
return "Application configuration does not support reload.";
}
root.Reload();
var reloadedOptions = new MeetingAssistantOptions();
configuration.GetSection("MeetingAssistant").Bind(reloadedOptions);
return ToJson(new
{
rulesPath = reloadedOptions.Automation.RulesPath
});
}
public async Task<string> DiagnoseAsrTranscribeFile(string path, string? launch_profile = null)
{
if (asrDiagnosticService is null)
{
return "ASR diagnostic service is not configured.";
}
if (string.IsNullOrWhiteSpace(path))
{
return "Refused: a WAV file path is required.";
}
try
{
return ToJson(await asrDiagnosticService.TranscribeWavAsync(path, launch_profile, CancellationToken.None));
}
catch (FileNotFoundException exception)
{
return exception.Message;
}
catch (InvalidDataException exception)
{
return exception.Message;
}
catch (Exception exception)
{
return $"ASR backend failed while processing the WAV file: {exception.Message}";
}
}
public async Task<string> DiagnoseAsrDiarizeFile(
string path,
int? num_speakers = null,
string? launch_profile = null)
{
if (asrDiagnosticService is null)
{
return "ASR diagnostic service is not configured.";
}
if (string.IsNullOrWhiteSpace(path))
{
return "Refused: a WAV file path is required.";
}
try
{
var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path));
return !File.Exists(fullPath)
? $"WAV file was not found at '{fullPath}'."
: ToJson(await asrDiagnosticService.DiarizeWavAsync(
fullPath,
new SpeechRecognitionPipelineOptions(num_speakers),
launch_profile,
CancellationToken.None));
}
catch (Exception exception)
{
return $"ASR diarization backend failed while processing the WAV file: {exception.Message}";
}
}
public async Task<string> SearchIdentities(string? query = null, int limit = 25)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
@@ -716,6 +1041,249 @@ public sealed class WorkflowRulesEditorTools
Do not log small implementation details such as how UI elements are aligned.
""";
private async Task<MeetingArtifactSummary> ReadMeetingArtifactSummaryAsync(string path)
{
var content = await File.ReadAllTextAsync(path);
var document = MarkdownDocumentParser.SplitOptional(content);
var frontmatter = new MeetingArtifactSummaryFrontmatter();
if (document.HasFrontmatter)
{
try
{
frontmatter = YamlDeserializer.Deserialize<MeetingArtifactSummaryFrontmatter>(document.Frontmatter)
?? new MeetingArtifactSummaryFrontmatter();
}
catch (YamlException)
{
frontmatter = new MeetingArtifactSummaryFrontmatter();
}
}
var startTime = ParseDateTime(frontmatter.StartTime);
var projects = (frontmatter.Projects ?? [])
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
return new MeetingArtifactSummary(
AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRootPath, path)),
string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(),
startTime?.ToString("O") ?? "",
startTime ?? File.GetLastWriteTimeUtc(path),
projects,
ToArtifactToolPath(frontmatter.Meeting, path, meetingNotesRootPath),
ToArtifactToolPath(frontmatter.Transcript, path, transcriptsRootPath),
ToArtifactToolPath(frontmatter.AssistantContext, path, assistantContextRootPath));
}
private static async Task<string> ReadMeetingArtifactFileAsync(
string root,
string path,
string artifactName,
int? from,
int? to,
int? tail)
{
var resolved = ResolveMeetingArtifactPath(root, path, artifactName);
if (resolved.Error is not null)
{
return resolved.Error;
}
if (!File.Exists(resolved.Path))
{
return $"{artifactName} file was not found: {AgentFileToolContent.ToToolPath(path)}";
}
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(resolved.Path), from, to, tail);
}
private static async Task<string> WriteMeetingArtifactFileAsync(
string root,
string path,
string artifactName,
string content,
int? from,
int? to,
int? insert,
bool replaceFile)
{
var editMode = AgentFileEditMode.Create(from, to, insert, replaceFile);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
}
var resolved = ResolveMeetingArtifactPath(root, path, artifactName);
if (resolved.Error is not null)
{
return resolved.Error;
}
Directory.CreateDirectory(Path.GetDirectoryName(resolved.Path!)!);
await AgentFileToolContent.WriteFileContentAsync(resolved.Path!, content, editMode);
return AgentFileToolContent.ToToolPath(Path.GetRelativePath(Path.GetFullPath(root), resolved.Path!));
}
private static async Task<string> WriteMeetingArtifactFrontmatterAsync(
string root,
string path,
string artifactName,
string yaml)
{
if (yaml is null)
{
return "Refused: frontmatter YAML must not be null.";
}
var validation = ValidateFrontmatterYaml(yaml);
if (validation is not null)
{
return validation;
}
var resolved = ResolveMeetingArtifactPath(root, path, artifactName);
if (resolved.Error is not null)
{
return resolved.Error;
}
var existing = File.Exists(resolved.Path!)
? await File.ReadAllTextAsync(resolved.Path!)
: "";
var document = MarkdownDocumentParser.SplitOptional(existing);
var body = document.HasFrontmatter
? document.Body.TrimStart('\r', '\n')
: existing.TrimStart('\r', '\n');
var frontmatter = yaml.Trim();
var updated = string.IsNullOrEmpty(body)
? $"---\n{frontmatter}\n---\n"
: $"---\n{frontmatter}\n---\n\n{body}";
Directory.CreateDirectory(Path.GetDirectoryName(resolved.Path!)!);
await File.WriteAllTextAsync(resolved.Path!, updated);
return AgentFileToolContent.ToToolPath(Path.GetRelativePath(Path.GetFullPath(root), resolved.Path!));
}
private static Task<string> SearchMeetingArtifactFilesAsync(
string root,
string keywords,
int maxMatches,
string? filePattern)
{
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(root))
{
return Task.FromResult("");
}
var sources = Directory.EnumerateFiles(root, "*.md", SearchOption.AllDirectories)
.Order(StringComparer.OrdinalIgnoreCase)
.Select(path => new AgentFileSearchSource(
path,
AgentFileToolContent.ToToolPath(Path.GetRelativePath(root, path))))
.Where(source => AgentFileToolContent.MatchesGlob(source.DisplayPath, filePattern));
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
}
private static (string? Path, string? Error) ResolveMeetingArtifactPath(
string root,
string path,
string artifactName)
{
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
{
return (null, $"Refused: {artifactName} path must be relative to its configured folder.");
}
var rootPath = Path.GetFullPath(root);
var candidate = Path.GetFullPath(Path.Combine(
rootPath,
path.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar)));
return AgentFileToolContent.IsWithinDirectory(rootPath, candidate)
? (candidate, null)
: (null, $"Refused: {artifactName} path must stay inside {rootPath}.");
}
private static string ToArtifactToolPath(string? link, string sourcePath, string artifactRoot)
{
var resolved = ResolveArtifactLink(link, sourcePath);
if (string.IsNullOrWhiteSpace(resolved))
{
return "";
}
var root = Path.GetFullPath(artifactRoot);
if (!AgentFileToolContent.IsWithinDirectory(root, resolved))
{
var byFileName = Path.GetFullPath(Path.Combine(root, Path.GetFileName(resolved)));
if (!File.Exists(byFileName) || !AgentFileToolContent.IsWithinDirectory(root, byFileName))
{
return "";
}
resolved = byFileName;
}
return AgentFileToolContent.ToToolPath(Path.GetRelativePath(root, resolved));
}
private static string ResolveArtifactLink(string? pathOrLink, string sourcePath)
{
if (string.IsNullOrWhiteSpace(pathOrLink))
{
return "";
}
var sourceFolder = Path.GetDirectoryName(sourcePath) ?? "";
var target = ExtractWikiLinkTarget(pathOrLink.Trim()) ?? pathOrLink.Trim();
target = target.Replace('/', Path.DirectorySeparatorChar);
if (!Path.HasExtension(target))
{
target += ".md";
}
return Path.GetFullPath(Path.IsPathRooted(target)
? target
: Path.Combine(sourceFolder, target));
}
private static string? ExtractWikiLinkTarget(string value)
{
if (!value.StartsWith("[[", StringComparison.Ordinal) ||
!value.EndsWith("]]", StringComparison.Ordinal))
{
return null;
}
var inner = value[2..^2];
var labelSeparator = inner.IndexOf('|', StringComparison.Ordinal);
return labelSeparator < 0 ? inner : inner[..labelSeparator];
}
private static DateTimeOffset? ParseDateTime(string? value)
{
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
private MeetingAssistantOptions ResolveProfileOptions(string? launchProfile)
{
if (launchProfiles is not null)
{
return launchProfiles.GetRequiredProfile(launchProfile).Options;
}
if (string.IsNullOrWhiteSpace(launchProfile) ||
launchProfile.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
{
return options;
}
throw new InvalidOperationException(
$"Launch profile '{launchProfile}' was requested, but no launch profile provider is registered.");
}
private static string? ValidateJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -734,6 +1302,24 @@ public sealed class WorkflowRulesEditorTools
}
}
private static string? ValidateFrontmatterYaml(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
return "Refused: frontmatter YAML must not be blank.";
}
try
{
_ = YamlDeserializer.Deserialize<object>(yaml);
return null;
}
catch (YamlException exception)
{
return $"Refused: frontmatter YAML is invalid. {exception.Message}";
}
}
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
{
try
@@ -1123,4 +1709,35 @@ public sealed class WorkflowRulesEditorTools
private sealed record ProjectFileTarget(string ProjectName, string ProjectRoot, string Path);
private sealed record MeetingArtifactSummary(
string ToolPath,
string Title,
string StartTimeText,
DateTimeOffset SortTime,
IReadOnlyList<string> Projects,
string MeetingNotePath,
string TranscriptPath,
string ContextPath);
private sealed class MeetingArtifactSummaryFrontmatter
{
[YamlMember(Alias = "title")]
public string? Title { get; set; }
[YamlMember(Alias = "start_time")]
public string? StartTime { get; set; }
[YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
[YamlMember(Alias = "meeting")]
public string? Meeting { get; set; }
[YamlMember(Alias = "transcript")]
public string? Transcript { get; set; }
[YamlMember(Alias = "assistant_context")]
public string? AssistantContext { get; set; }
}
}