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
@@ -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; }
}
}