Files
meeting-assistant/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs
T
2026-06-02 10:22:15 +02:00

1749 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 = await ValidateYamlAsync(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 async Task<string?> ValidateYamlAsync(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
return null;
}
MeetingWorkflowRulesFile rulesFile;
try
{
rulesFile = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml) ?? new MeetingWorkflowRulesFile();
}
catch (YamlException exception)
{
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
}
var errors = await MeetingWorkflowRulesValidator.ValidateAsync(rulesFile);
return errors.Count == 0
? null
: "Refused: workflow rules are invalid. " + string.Join(" ", errors);
}
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; }
}
}