Public Access
Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user