Files
codex b22754ce5d
PR and Push Build/Test / build-and-test (push) Successful in 8m49s
Normalize scalar frontmatter lists
2026-06-11 16:00:05 +02:00

1714 lines
60 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 Microsoft.Extensions.Logging;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Workflow;
public sealed class WorkflowRulesEditorTools
{
private static readonly IDeserializer YamlDeserializer = FrontmatterYamlDeserializer.CreateWithUnderscoredNaming();
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;
private readonly ILogger? logger;
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,
ILogger? logger = 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;
this.logger = logger;
}
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 LogRefusal(nameof(WriteRules), "Refused: workflow rules file is not configured.");
}
if (yaml is null)
{
return LogRefusal(nameof(WriteRules), "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 LogRefusal(nameof(WriteRules), 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 LogRefusal(nameof(WriteConfig), "Refused: json must not be null.");
}
var validation = ValidateJson(json);
if (validation is not null)
{
return LogRefusal(nameof(WriteConfig), validation);
}
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
await File.WriteAllTextAsync(configPath, json);
return configPath;
}
private string LogRefusal(string toolName, string refusal)
{
logger?.LogWarning(
"Workflow rules editor tool {ToolName} refused request: {Refusal}",
toolName,
refusal);
return refusal;
}
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.";
}
if (normalizedSeed == "recommended" && !File.Exists(projectAgentsTemplatePath))
{
return $"Refused: recommended project AGENTS.md template was not found: {projectAgentsTemplatePath}.";
}
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 Task<string> CreateIdentity(
string? canonicalName = null,
string[]? aliases = null,
string[]? candidateNames = null)
{
return Task.FromResult(
"Refused: speaker identities require at least one audio sample. " +
"Use a speaker override from an existing transcript speaker sample, or update/merge an existing sampled 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.";
}
var sampleCount = await context.SpeakerSnippets.CountAsync(candidate =>
candidate.SpeakerIdentityId == sample.SpeakerIdentityId);
if (sampleCount <= 1)
{
return $"Refused: sample {sampleId} is the last sample for identity {sample.SpeakerIdentityId}. Delete the identity instead.";
}
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 = await File.ReadAllTextAsync(projectAgentsTemplatePath);
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 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; }
}
}