Public Access
Expand settings and logs agent tools
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user