Public Access
1744 lines
61 KiB
C#
1744 lines
61 KiB
C#
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;
|
|
|
|
namespace MeetingAssistant.Workflow;
|
|
|
|
public sealed class WorkflowRulesEditorTools
|
|
{
|
|
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
|
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
|
.IgnoreUnmatchedProperties()
|
|
.Build();
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = true
|
|
};
|
|
|
|
private readonly string? rulesPath;
|
|
private readonly string configPath;
|
|
private readonly string configDocsPath;
|
|
private readonly string logDirectory;
|
|
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);
|
|
this.logDirectory = logDirectory ?? MeetingAssistantLogFiles.DefaultLogDirectory;
|
|
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)
|
|
{
|
|
if (rulesPath is null)
|
|
{
|
|
return "Workflow rules file is not configured.";
|
|
}
|
|
|
|
if (!File.Exists(rulesPath))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(rulesPath), from, to, tail);
|
|
}
|
|
|
|
public async Task<string> WriteRules(string yaml, bool replace_file = false)
|
|
{
|
|
if (rulesPath is null)
|
|
{
|
|
return "Refused: workflow rules file is not configured.";
|
|
}
|
|
|
|
if (yaml is null)
|
|
{
|
|
return "Refused: yaml must not be null.";
|
|
}
|
|
|
|
var existing = File.Exists(rulesPath)
|
|
? await File.ReadAllTextAsync(rulesPath)
|
|
: "";
|
|
var updated = replace_file
|
|
? yaml
|
|
: AgentFileToolContent.AppendContent(existing, yaml);
|
|
var validation = ValidateYaml(updated);
|
|
if (validation is not null)
|
|
{
|
|
return validation;
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
|
|
await File.WriteAllTextAsync(rulesPath, updated);
|
|
return rulesPath;
|
|
}
|
|
|
|
public async Task<string> Search(string keywords, string? file_pattern = null)
|
|
{
|
|
if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (!AgentFileToolContent.MatchesGlob(Path.GetFileName(rulesPath), file_pattern))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var rgResult = await RunRipgrepAsync(rulesPath, keywords);
|
|
return rgResult is not null
|
|
? FormatRipgrepJson(rgResult)
|
|
: SearchWithRegexFallback(rulesPath, keywords);
|
|
}
|
|
|
|
public async Task<string> ReadConfig(int? from = null, int? to = null, int? tail = null)
|
|
{
|
|
if (!File.Exists(configPath))
|
|
{
|
|
return $"Configuration file was not found: {configPath}";
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configPath), from, to, tail);
|
|
}
|
|
|
|
public async Task<string> WriteConfig(string json)
|
|
{
|
|
if (json is null)
|
|
{
|
|
return "Refused: json must not be null.";
|
|
}
|
|
|
|
var validation = ValidateJson(json);
|
|
if (validation is not null)
|
|
{
|
|
return validation;
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
|
await File.WriteAllTextAsync(configPath, json);
|
|
return configPath;
|
|
}
|
|
|
|
public async Task<string> ReadConfigDocs(int? from = null, int? to = null, int? tail = null)
|
|
{
|
|
if (!File.Exists(configDocsPath))
|
|
{
|
|
return "Configuration documentation file docs/meeting-assistant-configuration.md was not available at runtime.";
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configDocsPath), from, to, tail);
|
|
}
|
|
|
|
public async Task<string> ReadLogs(
|
|
string? logFile = null,
|
|
int? from = null,
|
|
int? to = null,
|
|
int tail = 200)
|
|
{
|
|
var path = ResolveLogFilePath(logFile);
|
|
if (path is null)
|
|
{
|
|
return $"Log file was not found: {logFile ?? MeetingAssistantLogFiles.CurrentLogFileName}";
|
|
}
|
|
|
|
var content = await AgentFileToolContent.ReadAllTextSharedAsync(path);
|
|
if (from.HasValue || to.HasValue)
|
|
{
|
|
return AgentFileToolContent.ReadLines(content, from, to);
|
|
}
|
|
|
|
return AgentFileToolContent.TailLines(content, Math.Clamp(tail, 1, 2000));
|
|
}
|
|
|
|
public Task<string> SearchLogs(string keywords, int maxMatches = 100, string? file_pattern = null)
|
|
{
|
|
var sources = MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory)
|
|
.Where(path => AgentFileToolContent.MatchesGlob(Path.GetFileName(path), file_pattern))
|
|
.Select(path => new AgentFileSearchSource(path, Path.GetFileName(path)));
|
|
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
|
|
}
|
|
|
|
public async Task<string> ReadSpecFile(string path, int? from = null, int? to = null, int? tail = null)
|
|
{
|
|
var resolved = ResolveSpecFilePath(path);
|
|
if (resolved.Error is not null)
|
|
{
|
|
return resolved.Error;
|
|
}
|
|
|
|
if (!File.Exists(resolved.Path))
|
|
{
|
|
return $"Spec file was not found: {AgentFileToolContent.ToToolPath(path)}";
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(resolved.Path), from, to, tail);
|
|
}
|
|
|
|
public Task<string> SearchSpec(string keywords, int maxMatches = 100, string? file_pattern = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(specRootPath))
|
|
{
|
|
return Task.FromResult("");
|
|
}
|
|
|
|
var sources = Directory.EnumerateFiles(specRootPath, "*.md", SearchOption.AllDirectories)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.Select(path => new AgentFileSearchSource(
|
|
path,
|
|
AgentFileToolContent.ToToolPath(Path.GetRelativePath(specRootPath, path))))
|
|
.Where(source => AgentFileToolContent.MatchesGlob(source.DisplayPath, file_pattern));
|
|
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
|
|
}
|
|
|
|
public Task<string> ListProjects()
|
|
{
|
|
if (!Directory.Exists(projectsRootPath))
|
|
{
|
|
return Task.FromResult("");
|
|
}
|
|
|
|
var projects = Directory.EnumerateDirectories(projectsRootPath)
|
|
.Select(Path.GetFileName)
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Order(StringComparer.OrdinalIgnoreCase);
|
|
return Task.FromResult(string.Join('\n', projects));
|
|
}
|
|
|
|
public Task<string> ListProjectFiles(string project)
|
|
{
|
|
var projectRoot = ResolveExistingProjectRoot(project);
|
|
if (projectRoot is null)
|
|
{
|
|
return Task.FromResult("Refused: project does not exist.");
|
|
}
|
|
|
|
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
|
|
.Select(path => AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path)))
|
|
.Order(StringComparer.OrdinalIgnoreCase);
|
|
return Task.FromResult(string.Join('\n', files));
|
|
}
|
|
|
|
public async Task<string> ReadProjectFile(
|
|
string project,
|
|
string path,
|
|
int? from = null,
|
|
int? to = null,
|
|
int? tail = null)
|
|
{
|
|
var filePath = ResolveExistingProjectFilePath(project, path);
|
|
if (filePath is null)
|
|
{
|
|
return "Refused: project does not exist or the path escapes the project folder.";
|
|
}
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
return $"Project file was not found: {project}/{AgentFileToolContent.ToToolPath(path)}";
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), from, to, tail);
|
|
}
|
|
|
|
public async Task<string> WriteProjectFile(
|
|
string project,
|
|
string path,
|
|
string content,
|
|
int? from = null,
|
|
int? to = null,
|
|
int? insert = null,
|
|
bool replace_file = false)
|
|
{
|
|
var editMode = AgentFileEditMode.Create(from, to, insert, replace_file);
|
|
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 target = ResolveExistingProjectFileTarget(project, path);
|
|
if (target is null)
|
|
{
|
|
return "Refused: project does not exist or the path escapes the project folder.";
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
|
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
|
|
return $"{target.ProjectName}/{AgentFileToolContent.ToToolPath(path)}";
|
|
}
|
|
|
|
public Task<string> SearchProjects(string keywords, int maxMatches = 100, string? file_pattern = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(projectsRootPath))
|
|
{
|
|
return Task.FromResult("");
|
|
}
|
|
|
|
var sources = Directory.EnumerateDirectories(projectsRootPath)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.SelectMany(projectRoot =>
|
|
{
|
|
var projectName = Path.GetFileName(projectRoot);
|
|
return Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.Select(path =>
|
|
{
|
|
var relativePath = AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path));
|
|
return new AgentFileSearchSource(path, $"{projectName}/{relativePath}");
|
|
})
|
|
.Where(source => AgentFileToolContent.MatchesGlob(
|
|
source.DisplayPath[(source.DisplayPath.IndexOf('/', StringComparison.Ordinal) + 1)..],
|
|
file_pattern));
|
|
});
|
|
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
|
|
}
|
|
|
|
public async Task<string> CreateProject(string name, string seed = "recommended", string? agents_md = null)
|
|
{
|
|
var projectRoot = ResolveNewProjectRoot(name);
|
|
if (projectRoot.Error is not null)
|
|
{
|
|
return projectRoot.Error;
|
|
}
|
|
|
|
var normalizedSeed = string.IsNullOrWhiteSpace(seed) ? "recommended" : seed.Trim().ToLowerInvariant();
|
|
if (normalizedSeed is not ("recommended" or "agents_md" or "none"))
|
|
{
|
|
return "Refused: seed must be recommended, agents_md, or none.";
|
|
}
|
|
|
|
if (normalizedSeed == "agents_md" && string.IsNullOrWhiteSpace(agents_md))
|
|
{
|
|
return "Refused: agents_md seed requires AGENTS.md content.";
|
|
}
|
|
|
|
Directory.CreateDirectory(projectRoot.Path!);
|
|
switch (normalizedSeed)
|
|
{
|
|
case "recommended":
|
|
await WriteRecommendedProjectSeedAsync(projectRoot.Path!, projectRoot.Name!);
|
|
break;
|
|
case "agents_md":
|
|
await File.WriteAllTextAsync(Path.Combine(projectRoot.Path!, "AGENTS.md"), agents_md!);
|
|
break;
|
|
case "none":
|
|
break;
|
|
}
|
|
|
|
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);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var identities = await LoadIdentities(context)
|
|
.OrderBy(identity => identity.CanonicalName ?? "")
|
|
.ThenBy(identity => identity.Id)
|
|
.ToListAsync();
|
|
if (!string.IsNullOrWhiteSpace(query))
|
|
{
|
|
var needle = query.Trim();
|
|
identities = identities
|
|
.Where(identity => IdentityMatches(identity, needle))
|
|
.ToList();
|
|
}
|
|
|
|
return ToJson(identities
|
|
.Take(Math.Clamp(limit, 1, 100))
|
|
.Select(ToIdentitySummary)
|
|
.ToList());
|
|
}
|
|
}
|
|
|
|
public async Task<string> ReadIdentity(int identityId)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var identity = await LoadIdentities(context)
|
|
.SingleOrDefaultAsync(identity => identity.Id == identityId);
|
|
return identity is null
|
|
? $"Identity {identityId} was not found."
|
|
: ToJson(ToIdentityDetail(identity));
|
|
}
|
|
}
|
|
|
|
public async Task<string> CreateIdentity(
|
|
string? canonicalName = null,
|
|
string[]? aliases = null,
|
|
string[]? candidateNames = null)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var identity = new SpeakerIdentity
|
|
{
|
|
CanonicalName = NormalizeNullable(canonicalName),
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
Aliases = NormalizeNames(aliases)
|
|
.Select(name => new SpeakerAlias { Name = name })
|
|
.ToList(),
|
|
CandidateNames = NormalizeNames(candidateNames)
|
|
.Select(name => new SpeakerCandidateName { Name = name })
|
|
.ToList()
|
|
};
|
|
context.SpeakerIdentities.Add(identity);
|
|
await context.SaveChangesAsync();
|
|
return ToJson(ToIdentityDetail(identity));
|
|
}
|
|
}
|
|
|
|
public async Task<string> UpdateIdentity(
|
|
int identityId,
|
|
string? canonicalName = null,
|
|
string[]? aliases = null,
|
|
string[]? candidateNames = null)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var identity = await LoadIdentities(context)
|
|
.SingleOrDefaultAsync(identity => identity.Id == identityId);
|
|
if (identity is null)
|
|
{
|
|
return $"Identity {identityId} was not found.";
|
|
}
|
|
|
|
identity.CanonicalName = NormalizeNullable(canonicalName);
|
|
identity.Aliases.Clear();
|
|
identity.Aliases.AddRange(NormalizeNames(aliases)
|
|
.Select(name => new SpeakerAlias { Name = name }));
|
|
identity.CandidateNames.Clear();
|
|
identity.CandidateNames.AddRange(NormalizeNames(candidateNames)
|
|
.Select(name => new SpeakerCandidateName { Name = name }));
|
|
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await context.SaveChangesAsync();
|
|
return ToJson(ToIdentityDetail(identity));
|
|
}
|
|
}
|
|
|
|
public async Task<string> DeleteIdentity(int identityId)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var identity = await LoadIdentities(context)
|
|
.SingleOrDefaultAsync(identity => identity.Id == identityId);
|
|
if (identity is null)
|
|
{
|
|
return $"Identity {identityId} was not found.";
|
|
}
|
|
|
|
context.SpeakerIdentities.Remove(identity);
|
|
await context.SaveChangesAsync();
|
|
return $"Deleted identity {identityId}.";
|
|
}
|
|
}
|
|
|
|
public async Task<string> MergeIdentities(int targetIdentityId, int sourceIdentityId)
|
|
{
|
|
if (targetIdentityId == sourceIdentityId)
|
|
{
|
|
return "Refused: target and source identity are the same.";
|
|
}
|
|
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var target = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == targetIdentityId);
|
|
var source = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == sourceIdentityId);
|
|
if (target is null || source is null)
|
|
{
|
|
return $"Could not find target {targetIdentityId} or source {sourceIdentityId}.";
|
|
}
|
|
|
|
SpeakerIdentityMerger.MergeInto(
|
|
target,
|
|
source,
|
|
speakerOptions.MaxSnippetsPerSpeaker);
|
|
context.SpeakerIdentities.Remove(source);
|
|
await context.SaveChangesAsync();
|
|
return ToJson(ToIdentityDetail(target));
|
|
}
|
|
}
|
|
|
|
public async Task<string> ListIdentitySamples(int identityId)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var samples = await context.SpeakerSnippets
|
|
.Where(sample => sample.SpeakerIdentityId == identityId)
|
|
.ToListAsync();
|
|
return ToJson(samples
|
|
.OrderBy(sample => sample.CreatedAt)
|
|
.Select(ToIdentitySampleSummary)
|
|
.ToList());
|
|
}
|
|
}
|
|
|
|
public async Task<string> ReadIdentitySample(int sampleId)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
|
|
return sample is null
|
|
? $"Sample {sampleId} was not found."
|
|
: ToJson(new IdentitySampleDetail(
|
|
sample.Id,
|
|
sample.SpeakerIdentityId,
|
|
sample.CreatedAt,
|
|
sample.WavBytes.Length,
|
|
Convert.ToBase64String(sample.WavBytes)));
|
|
}
|
|
}
|
|
|
|
public async Task<string> DeleteIdentitySample(int sampleId)
|
|
{
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
|
|
if (sample is null)
|
|
{
|
|
return $"Sample {sampleId} was not found.";
|
|
}
|
|
|
|
context.SpeakerSnippets.Remove(sample);
|
|
await context.SaveChangesAsync();
|
|
return $"Deleted sample {sampleId}.";
|
|
}
|
|
}
|
|
|
|
public async Task<string> QueuePlayIdentitySample(int sampleId)
|
|
{
|
|
if (samplePlaybackQueue is null)
|
|
{
|
|
return "Sample playback queue is not configured.";
|
|
}
|
|
|
|
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
|
if (context is null)
|
|
{
|
|
return "Speaker identity database is not configured.";
|
|
}
|
|
|
|
await using (context)
|
|
{
|
|
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
|
|
return sample is null
|
|
? $"Sample {sampleId} was not found."
|
|
: await samplePlaybackQueue.QueueAsync(sample.Id, sample.WavBytes);
|
|
}
|
|
}
|
|
|
|
private static string? ValidateYaml(string yaml)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(yaml))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
_ = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml);
|
|
return null;
|
|
}
|
|
catch (YamlException exception)
|
|
{
|
|
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
|
|
}
|
|
}
|
|
|
|
private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName)
|
|
{
|
|
var agentsContent = File.Exists(projectAgentsTemplatePath)
|
|
? await File.ReadAllTextAsync(projectAgentsTemplatePath)
|
|
: DefaultProjectAgentsContent;
|
|
await File.WriteAllTextAsync(Path.Combine(projectRoot, "AGENTS.md"), agentsContent);
|
|
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), CreateDefaultProjectFile(projectName));
|
|
await File.WriteAllTextAsync(Path.Combine(projectRoot, "JOURNAL.md"), CreateDefaultJournalFile(projectName));
|
|
await File.WriteAllTextAsync(Path.Combine(projectRoot, "DECISIONS.md"), CreateDefaultDecisionsFile());
|
|
}
|
|
|
|
private static string CreateDefaultProjectFile(string projectName)
|
|
{
|
|
return $"""
|
|
# Executive Summary
|
|
|
|
Project knowledge base for {projectName}.
|
|
|
|
## Business Goals
|
|
|
|
## Priorities and High Level Constraints
|
|
|
|
## Current Phase & Status
|
|
|
|
## Next Milestones
|
|
|
|
## Open Risks
|
|
|
|
## Important Stakeholders
|
|
|
|
## Key Documents
|
|
|
|
Links to: Architecture Overview, Technical Onboarding, etc.
|
|
|
|
[[JOURNAL.md]]
|
|
[[DECISIONS.md]]
|
|
""";
|
|
}
|
|
|
|
private static string CreateDefaultJournalFile(string projectName)
|
|
{
|
|
return $"""
|
|
## {DateTimeOffset.Now:yyyy-MM-dd}
|
|
### Project Setup
|
|
|
|
The project folder was created for {projectName}.
|
|
""";
|
|
}
|
|
|
|
private static string CreateDefaultDecisionsFile()
|
|
{
|
|
return """
|
|
# Decisions
|
|
|
|
Important decisions belong here when they are expensive to change or materially affect timeline, goals, governance, team, or process.
|
|
|
|
## How are decisions recorded?
|
|
Important decisions are summarized with the question, the decision, and a source link to an ADR or meeting summary.
|
|
""";
|
|
}
|
|
|
|
private const string DefaultProjectAgentsContent = """
|
|
The project is split in 3 important files:
|
|
PROJECT.md
|
|
DECISIONS.md
|
|
JOURNAL.md
|
|
|
|
The idea is a hierarchical summary:
|
|
|
|
Meeting Summary -> detailed (what was discussed)
|
|
then Journal Entry -> compressed (why should I care this event happened?)
|
|
then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.)
|
|
|
|
The journal should always be appended and read using tail or searched.
|
|
|
|
Journal entries use this shape:
|
|
|
|
```markdown
|
|
## 2026-05-30
|
|
### Team Daily
|
|
|
|
New Blocker: HLS delayed
|
|
|
|
Impact: Release likely delayed, team implements Z-Levels in the meantime.
|
|
|
|
[[20260529-093030-6187486-summary|Summary]]
|
|
```
|
|
|
|
DECISIONS.md logs only important decisions in a compressed way, with a link to ADRs and/or meeting summaries for details.
|
|
Important decisions are architecture decisions that are expensive to change, or project decisions about timeline, goals, governance, team, or process.
|
|
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))
|
|
{
|
|
return "Refused: appsettings JSON must not be blank.";
|
|
}
|
|
|
|
try
|
|
{
|
|
using var _ = JsonDocument.Parse(json);
|
|
return null;
|
|
}
|
|
catch (JsonException exception)
|
|
{
|
|
return $"Refused: appsettings JSON is invalid. {exception.Message}";
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "rg",
|
|
WorkingDirectory = Path.GetDirectoryName(rulesPath)!,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
};
|
|
startInfo.ArgumentList.Add("--json");
|
|
startInfo.ArgumentList.Add("--line-number");
|
|
startInfo.ArgumentList.Add("--color");
|
|
startInfo.ArgumentList.Add("never");
|
|
startInfo.ArgumentList.Add("--");
|
|
startInfo.ArgumentList.Add(keywords);
|
|
startInfo.ArgumentList.Add(Path.GetFileName(rulesPath));
|
|
|
|
using var process = Process.Start(startInfo);
|
|
if (process is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var output = await process.StandardOutput.ReadToEndAsync();
|
|
await process.WaitForExitAsync();
|
|
return process.ExitCode is 0 or 1 ? output : null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static string FormatRipgrepJson(string output)
|
|
{
|
|
var matches = new List<string>();
|
|
using var reader = new StringReader(output);
|
|
string? line;
|
|
while ((line = reader.ReadLine()) is not null)
|
|
{
|
|
using var document = JsonDocument.Parse(line);
|
|
var root = document.RootElement;
|
|
if (!root.TryGetProperty("type", out var type) ||
|
|
type.GetString() != "match" ||
|
|
!root.TryGetProperty("data", out var data))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
|
|
var lineNumber = data.GetProperty("line_number").GetInt32();
|
|
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
|
|
matches.Add($"{AgentFileToolContent.ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
|
|
}
|
|
|
|
return string.Join('\n', matches);
|
|
}
|
|
|
|
private static string SearchWithRegexFallback(string rulesPath, string keywords)
|
|
{
|
|
try
|
|
{
|
|
var regex = new Regex(keywords, RegexOptions.IgnoreCase);
|
|
var matches = new List<string>();
|
|
var lines = File.ReadAllLines(rulesPath);
|
|
for (var index = 0; index < lines.Length; index++)
|
|
{
|
|
if (regex.IsMatch(lines[index]))
|
|
{
|
|
matches.Add($"{Path.GetFileName(rulesPath)}:{index + 1} {lines[index]}");
|
|
}
|
|
}
|
|
|
|
return string.Join('\n', matches);
|
|
}
|
|
catch
|
|
{
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private ProjectFileTarget? ResolveExistingProjectFileTarget(string project, string path)
|
|
{
|
|
var projectRoot = ResolveExistingProjectRoot(project);
|
|
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
|
|
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath)
|
|
? new ProjectFileTarget(Path.GetFileName(projectRoot), projectRoot, fullPath)
|
|
: null;
|
|
}
|
|
|
|
private string? ResolveExistingProjectFilePath(string project, string path)
|
|
{
|
|
return ResolveExistingProjectFileTarget(project, path)?.Path;
|
|
}
|
|
|
|
private string? ResolveExistingProjectRoot(string project)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(project) || !Directory.Exists(projectsRootPath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return Directory.EnumerateDirectories(projectsRootPath)
|
|
.FirstOrDefault(path => string.Equals(Path.GetFileName(path), project.Trim(), StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private (string? Path, string? Name, string? Error) ResolveNewProjectRoot(string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return (null, null, "Refused: project name must not be blank.");
|
|
}
|
|
|
|
var projectName = name.Trim();
|
|
if (projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
|
|
projectName.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
|
projectName.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
|
|
{
|
|
return (null, null, "Refused: project name must be a single folder name.");
|
|
}
|
|
|
|
var root = Path.GetFullPath(projectsRootPath);
|
|
var candidate = Path.GetFullPath(Path.Combine(root, projectName));
|
|
if (!AgentFileToolContent.IsWithinDirectory(root, candidate))
|
|
{
|
|
return (null, null, $"Refused: project path must stay inside {root}.");
|
|
}
|
|
|
|
if (Directory.Exists(candidate))
|
|
{
|
|
return (null, null, $"Refused: project already exists: {projectName}.");
|
|
}
|
|
|
|
return (candidate, projectName, null);
|
|
}
|
|
|
|
private string? ResolveLogFilePath(string? logFile)
|
|
{
|
|
var requestedFileName = string.IsNullOrWhiteSpace(logFile)
|
|
? MeetingAssistantLogFiles.CurrentLogFileName
|
|
: Path.GetFileName(logFile.Trim());
|
|
return MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory)
|
|
.SingleOrDefault(path => string.Equals(
|
|
Path.GetFileName(path),
|
|
requestedFileName,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static string ResolveConfigPath(string? configuredPath)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
|
{
|
|
return Path.GetFullPath(configuredPath);
|
|
}
|
|
|
|
var basePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
|
if (File.Exists(basePath))
|
|
{
|
|
return basePath;
|
|
}
|
|
|
|
return Path.GetFullPath("appsettings.json");
|
|
}
|
|
|
|
private static string ResolveConfigDocsPath(string? configuredPath)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
|
{
|
|
return Path.GetFullPath(configuredPath);
|
|
}
|
|
|
|
foreach (var path in RuntimeContentLocator.CandidateDocumentationPaths("meeting-assistant-configuration.md"))
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
return path;
|
|
}
|
|
}
|
|
|
|
return Path.Combine(AppContext.BaseDirectory, "docs", "meeting-assistant-configuration.md");
|
|
}
|
|
|
|
private static string ResolveSpecRootPath(string? configuredPath)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
|
{
|
|
return Path.GetFullPath(configuredPath);
|
|
}
|
|
|
|
foreach (var path in RuntimeContentLocator.CandidateDirectoryPaths("openspec", "specs"))
|
|
{
|
|
if (Directory.Exists(path))
|
|
{
|
|
return path;
|
|
}
|
|
}
|
|
|
|
return Path.Combine(AppContext.BaseDirectory, "openspec", "specs");
|
|
}
|
|
|
|
private static string ResolveProjectAgentsTemplatePath(string? configuredPath)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
|
{
|
|
return Path.GetFullPath(configuredPath);
|
|
}
|
|
|
|
foreach (var path in RuntimeContentLocator.CandidateContentPaths("Project-AGENTS.md"))
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
return path;
|
|
}
|
|
}
|
|
|
|
return Path.Combine(AppContext.BaseDirectory, "Content", "Project-AGENTS.md");
|
|
}
|
|
|
|
private (string? Path, string? Error) ResolveSpecFilePath(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
{
|
|
return (null, "Refused: spec path must not be blank.");
|
|
}
|
|
|
|
var root = Path.GetFullPath(specRootPath);
|
|
var candidate = Path.GetFullPath(Path.Combine(
|
|
root,
|
|
path.Replace('/', Path.DirectorySeparatorChar)
|
|
.Replace('\\', Path.DirectorySeparatorChar)));
|
|
var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
|
|
? root
|
|
: root + Path.DirectorySeparatorChar;
|
|
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) &&
|
|
!string.Equals(candidate, root, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return (null, $"Refused: spec path must stay inside {root}.");
|
|
}
|
|
|
|
if (!candidate.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return (null, "Refused: spec path must point to a markdown spec file.");
|
|
}
|
|
|
|
return (candidate, null);
|
|
}
|
|
|
|
private async Task<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
|
|
{
|
|
return dbContextFactory is null
|
|
? null
|
|
: await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task<SpeakerIdentityDbContext?> CreatePreparedIdentityContextAsync(CancellationToken cancellationToken)
|
|
{
|
|
var context = await CreateIdentityContextAsync(cancellationToken);
|
|
if (context is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
|
return context;
|
|
}
|
|
|
|
private static IQueryable<SpeakerIdentity> LoadIdentities(SpeakerIdentityDbContext context)
|
|
{
|
|
return context.SpeakerIdentities
|
|
.Include(identity => identity.Aliases)
|
|
.Include(identity => identity.CandidateNames)
|
|
.Include(identity => identity.Snippets)
|
|
.Include(identity => identity.References);
|
|
}
|
|
|
|
private static bool IdentityMatches(SpeakerIdentity identity, string query)
|
|
{
|
|
return new[] { identity.CanonicalName }
|
|
.Concat(identity.Aliases.Select(alias => alias.Name))
|
|
.Concat(identity.CandidateNames.Select(candidate => candidate.Name))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Any(value => value!.Contains(query, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static IdentitySummary ToIdentitySummary(SpeakerIdentity identity)
|
|
{
|
|
return new IdentitySummary(
|
|
identity.Id,
|
|
identity.CanonicalName,
|
|
identity.GetDisplayName(),
|
|
identity.Aliases.Select(alias => alias.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
|
|
identity.CandidateNames.Select(candidate => candidate.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
|
|
identity.Snippets.Count,
|
|
identity.References.Count,
|
|
identity.UpdatedAt);
|
|
}
|
|
|
|
private static IdentityDetail ToIdentityDetail(SpeakerIdentity identity)
|
|
{
|
|
return new IdentityDetail(
|
|
ToIdentitySummary(identity),
|
|
identity.References
|
|
.OrderByDescending(reference => reference.CreatedAt)
|
|
.Select(reference => new IdentityReferenceDetail(
|
|
reference.Id,
|
|
reference.MeetingNotePath,
|
|
reference.TranscriptPath,
|
|
reference.CreatedAt))
|
|
.ToArray(),
|
|
identity.Snippets
|
|
.OrderBy(sample => sample.CreatedAt)
|
|
.Select(ToIdentitySampleSummary)
|
|
.ToArray());
|
|
}
|
|
|
|
private static IdentitySampleSummary ToIdentitySampleSummary(SpeakerSnippet sample)
|
|
{
|
|
return new IdentitySampleSummary(
|
|
sample.Id,
|
|
sample.SpeakerIdentityId,
|
|
sample.CreatedAt,
|
|
sample.WavBytes.Length);
|
|
}
|
|
|
|
private static IReadOnlyList<string> NormalizeNames(IEnumerable<string>? names)
|
|
{
|
|
return names?
|
|
.Select(name => name.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray()
|
|
?? [];
|
|
}
|
|
|
|
private static string? NormalizeNullable(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
private static string ToJson<T>(T value)
|
|
{
|
|
return JsonSerializer.Serialize(value, JsonOptions);
|
|
}
|
|
|
|
private sealed record IdentitySummary(
|
|
int Id,
|
|
string? CanonicalName,
|
|
string? DisplayName,
|
|
IReadOnlyList<string> Aliases,
|
|
IReadOnlyList<string> CandidateNames,
|
|
int SampleCount,
|
|
int ReferenceCount,
|
|
DateTimeOffset UpdatedAt);
|
|
|
|
private sealed record IdentityDetail(
|
|
IdentitySummary Identity,
|
|
IReadOnlyList<IdentityReferenceDetail> References,
|
|
IReadOnlyList<IdentitySampleSummary> Samples);
|
|
|
|
private sealed record IdentityReferenceDetail(
|
|
int Id,
|
|
string MeetingNotePath,
|
|
string TranscriptPath,
|
|
DateTimeOffset CreatedAt);
|
|
|
|
private sealed record IdentitySampleSummary(
|
|
int Id,
|
|
int IdentityId,
|
|
DateTimeOffset CreatedAt,
|
|
int ByteCount);
|
|
|
|
private sealed record IdentitySampleDetail(
|
|
int Id,
|
|
int IdentityId,
|
|
DateTimeOffset CreatedAt,
|
|
int ByteCount,
|
|
string Base64Wav);
|
|
|
|
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; }
|
|
}
|
|
|
|
}
|