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:
@@ -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