Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
+36 -57
View File
@@ -15,6 +15,8 @@ public sealed class MeetingSummaryTools
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
private readonly IDictationWordStore? dictationWordStore;
private readonly SummaryAgentWriteAudit? writeAudit;
private readonly BoundMeetingProjectResolver projectResolver;
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
: this(artifacts, new MeetingAssistantOptions(), null)
@@ -24,11 +26,14 @@ public sealed class MeetingSummaryTools
public MeetingSummaryTools(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
IDictationWordStore? dictationWordStore = null)
IDictationWordStore? dictationWordStore = null,
SummaryAgentWriteAudit? writeAudit = null)
{
this.artifacts = artifacts;
this.options = options;
this.dictationWordStore = dictationWordStore;
this.writeAudit = writeAudit;
projectResolver = new BoundMeetingProjectResolver(options);
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
@@ -76,7 +81,16 @@ public sealed class MeetingSummaryTools
return "Refused: word must not be empty.";
}
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
if (writeAudit is not null)
{
IReadOnlyList<string>? capturedWords = null;
await writeAudit.CaptureFileWriteAsync(
VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath),
async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None));
return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s).";
}
var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None);
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
}
@@ -176,7 +190,17 @@ public sealed class MeetingSummaryTools
}
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
await WriteProjectFileContentAsync(target.Path, content, editMode);
if (writeAudit is not null)
{
await writeAudit.CaptureFileWriteAsync(
target.Path,
() => WriteProjectFileContentAsync(target.Path, content, editMode));
}
else
{
await WriteProjectFileContentAsync(target.Path, content, editMode);
}
return $"{target.Project.Name}/{ToToolPath(path)}";
}
@@ -237,7 +261,7 @@ public sealed class MeetingSummaryTools
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
}
private async Task<List<ProjectFolder>> GetSearchProjectsAsync(string[]? projects)
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
{
var boundProjects = await GetBoundProjectsAsync();
if (projects is null || projects.Length == 0)
@@ -253,7 +277,7 @@ public sealed class MeetingSummaryTools
.ToList();
}
private async Task<ProjectFolder?> ResolveBoundProjectAsync(string project)
private async Task<BoundMeetingProject?> ResolveBoundProjectAsync(string project)
{
if (string.IsNullOrWhiteSpace(project))
{
@@ -297,7 +321,7 @@ public sealed class MeetingSummaryTools
}
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
.Select(candidate => new ProjectFolder(Path.GetFileName(candidate), candidate))
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
if (projectFolder is null)
{
@@ -414,56 +438,19 @@ public sealed class MeetingSummaryTools
return string.Join('\n', lines);
}
private async Task<List<ProjectFolder>> GetBoundProjectsAsync()
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
{
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
return [];
}
var projectNames = await ReadMeetingProjectNamesAsync();
if (projectNames.Count == 0)
{
return [];
}
return Directory.EnumerateDirectories(projectsRoot)
.Select(path => new ProjectFolder(Path.GetFileName(path), path))
.Where(project => projectNames.Contains(project.Name))
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private async Task<HashSet<string>> ReadMeetingProjectNamesAsync()
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return [];
}
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
if (!document.HasFrontmatter)
{
return [];
}
var note = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter) ?? new ProjectFrontmatter();
return (note.Projects ?? [])
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return projectResolver.GetBoundProjectsAsync(artifacts);
}
private string GetProjectsRoot()
{
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
return projectResolver.ProjectsRoot;
}
private static async Task<string?> RunRipgrepAsync(
string projectsRoot,
IReadOnlyList<ProjectFolder> projects,
IReadOnlyList<BoundMeetingProject> projects,
string keywords)
{
try
@@ -528,7 +515,7 @@ public sealed class MeetingSummaryTools
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(IReadOnlyList<ProjectFolder> projects, string keywords, bool singleProject)
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
{
try
{
@@ -584,9 +571,7 @@ public sealed class MeetingSummaryTools
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private sealed record ProjectFolder(string Name, string Path);
private sealed record ProjectFileTarget(ProjectFolder Project, string Path);
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
private sealed record FileLineEditMode(
FileLineEditKind Kind,
@@ -621,12 +606,6 @@ public sealed class MeetingSummaryTools
Insert
}
private sealed class ProjectFrontmatter
{
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
}
private sealed class MeetingNoteFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]