Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s

Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications.

Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
2026-09-25 11:58:34 +02:00
parent e011e36ef7
commit 7b2bcd3631
50 changed files with 2188 additions and 113 deletions
+89 -17
View File
@@ -2,6 +2,9 @@ using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using System.Text.Json;
using MeetingAssistant.Notifications;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
@@ -16,6 +19,8 @@ public sealed class MeetingSummaryTools
private readonly SummaryAgentWriteAudit? writeAudit;
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
private readonly BoundMeetingProjectResolver projectResolver;
private readonly AgentApprovalService? approvals;
private readonly WorkflowChangeRequestService? workflowRequests;
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
: this(artifacts, new MeetingAssistantOptions(), null)
@@ -27,7 +32,9 @@ public sealed class MeetingSummaryTools
MeetingAssistantOptions options,
IDictationWordStore? dictationWordStore = null,
SummaryAgentWriteAudit? writeAudit = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
AgentApprovalService? approvals = null,
WorkflowChangeRequestService? workflowRequests = null)
{
this.artifacts = artifacts;
this.options = options;
@@ -35,6 +42,8 @@ public sealed class MeetingSummaryTools
this.writeAudit = writeAudit;
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
projectResolver = new BoundMeetingProjectResolver(options);
this.approvals = approvals;
this.workflowRequests = workflowRequests;
}
public Task<string> ReadTranscript(int? @from = null, int? to = null, int? tail = null)
@@ -291,8 +300,71 @@ public sealed class MeetingSummaryTools
public async Task<string> ListProjects()
{
var projects = await GetBoundProjectsAsync();
return string.Join('\n', projects.Select(project => project.Name));
var projects = await Task.WhenAll(projectResolver.GetAllProjects().Select(project => ProjectMetadata.ReadAsync(project)));
return JsonSerializer.Serialize(projects.Select(project => new
{
id = project.Id, displayname = project.DisplayName, description = project.Description
}));
}
public async Task<string> RequestProjectAssociation(string project_id, string reason, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(reason) || reason.Length > 100 || reason.Contains('\n') || reason.Contains('\r'))
return "Refused: reason must be a nonempty single line of at most 100 characters.";
var project = projectResolver.GetAllProjects()
.FirstOrDefault(candidate => string.Equals(candidate.Name, project_id, StringComparison.OrdinalIgnoreCase));
if (project is null) return "Refused: unknown project ID.";
var associated = await projectResolver.ReadMeetingProjectNamesAsync(artifacts, cancellationToken);
if (!associated.Contains(project.Name))
{
var metadata = await ProjectMetadata.ReadAsync(project, cancellationToken);
if (approvals is null || !await approvals.ConfirmAsync("Project association requested",
$"Associate {metadata.DisplayName} ({metadata.Id}) with {Path.GetFileName(artifacts.MeetingNotePath)}?\n{reason}",
cancellationToken))
return "Denied: project association was not approved.";
cancellationToken.ThrowIfCancellationRequested();
if (!Directory.Exists(project.Path) || !File.Exists(artifacts.MeetingNotePath))
return "Refused: the project or meeting note no longer exists.";
await AddProjectAssociationAsync(project.Name, cancellationToken);
}
return "Approved: project is associated with this meeting.\n\n"
+ await ProjectMetadata.BuildContextAsync(project, cancellationToken);
}
public string RequestWorkflowChange(string intention, string detailed_prompt)
=> workflowRequests?.Request(intention, detailed_prompt, artifacts.MeetingNotePath)
?? "Refused: workflow change requests are unavailable.";
private async Task AddProjectAssociationAsync(string projectId, CancellationToken cancellationToken)
{
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath, cancellationToken);
var document = MarkdownDocumentParser.SplitRequired(content,
"Meeting note requires frontmatter.", "Meeting note frontmatter is not closed.");
var yaml = new YamlStream();
yaml.Load(new StringReader(document.Frontmatter));
var mapping = (YamlMappingNode)yaml.Documents.Single().RootNode;
mapping.Children.TryGetValue(new YamlScalarNode("projects"), out var existing);
var projects = existing as YamlSequenceNode ?? new YamlSequenceNode();
if (existing is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value))
projects.Add(new YamlScalarNode(scalar.Value));
if (!projects.Children.OfType<YamlScalarNode>().Any(value => string.Equals(value.Value, projectId, StringComparison.OrdinalIgnoreCase)))
projects.Add(new YamlScalarNode(projectId));
mapping.Children[new YamlScalarNode("projects")] = projects;
using var writer = new StringWriter();
yaml.Save(writer, assignAnchors: false);
var pendingPath = artifacts.MeetingNotePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
await File.WriteAllTextAsync(pendingPath, "---\n" + writer + "---\n\n" + document.Body, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
File.Replace(pendingPath, artifacts.MeetingNotePath, destinationBackupFileName: null);
}
finally
{
File.Delete(pendingPath);
}
}
public async Task<string> ListProjectFiles(string project)
@@ -375,10 +447,18 @@ public sealed class MeetingSummaryTools
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 = ResolveExistingProjectFilePath(project, path);
var target = await ResolveBoundProjectFileTargetAsync(project, path);
if (target is null)
{
return "Refused: project does not exist or the path escapes the project folder.";
return "Refused: project is not assigned to this meeting, does not exist, or the path escapes the project folder.";
}
var existingContent = File.Exists(target.Path) ? await File.ReadAllTextAsync(target.Path) : "";
var updatedContent = AgentFileToolContent.ApplyLineEdit(existingContent, content, editMode);
if (string.Equals(Path.GetRelativePath(target.Project.Path, target.Path), "PROJECT.md", StringComparison.OrdinalIgnoreCase)
&& !ProjectMetadata.IsValidContent(updatedContent))
{
return "Refused: PROJECT.md requires YAML frontmatter name and description; description must be at most 256 characters.";
}
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
@@ -386,11 +466,11 @@ public sealed class MeetingSummaryTools
{
await writeAudit.CaptureFileWriteAsync(
target.Path,
() => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode));
() => File.WriteAllTextAsync(target.Path, updatedContent));
}
else
{
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
await File.WriteAllTextAsync(target.Path, updatedContent);
}
return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}";
@@ -589,7 +669,7 @@ public sealed class MeetingSummaryTools
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
}
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
private async Task<ProjectFileTarget?> ResolveBoundProjectFileTargetAsync(string project, string path)
{
if (string.IsNullOrWhiteSpace(project) ||
string.IsNullOrWhiteSpace(path) ||
@@ -598,15 +678,7 @@ public sealed class MeetingSummaryTools
return null;
}
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
return null;
}
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
var projectFolder = await ResolveBoundProjectAsync(project);
if (projectFolder is null)
{
return null;