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:
@@ -18,6 +18,12 @@ public sealed class BoundMeetingProjectResolver
|
||||
|
||||
public string ProjectsRoot => VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
|
||||
public List<BoundMeetingProject> GetAllProjects() => !Directory.Exists(ProjectsRoot) ? [] :
|
||||
Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public async Task<List<BoundMeetingProject>> GetBoundProjectsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -33,10 +39,8 @@ public sealed class BoundMeetingProjectResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
return GetAllProjects()
|
||||
.Where(project => projectNames.Contains(project.Name))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,20 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
|
||||
Use delete_identity only when you are certain that an existing speaker identity was wrongfully matched. Provide the exact identity name currently used in the transcript.
|
||||
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
|
||||
Use list_projects first to see which projects are bound to this meeting. Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope.
|
||||
Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope.
|
||||
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
|
||||
If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. When embedding them, encode spaces in image-link targets as `%20`; never leave literal spaces in the link target. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important.
|
||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||
""";
|
||||
|
||||
private const string ProjectAndRequestInstructions = """
|
||||
Project discovery and approval:
|
||||
list_projects returns every configured project's id, displayname, and description; only projects in the current meeting note's projects frontmatter are accessible for reads, writes, search, and historical summaries. Catalog visibility does not grant file access.
|
||||
Before finalizing the summary, use the catalog when meeting evidence suggests a missing project association. Call request_project_association with its exact id and a short evidence-based reason (one line, at most 100 characters). It waits for user approval for up to ten minutes. A denial or timeout grants no access; continue with the existing scope. Approval adds the project to the meeting and returns its current project instructions; follow them immediately.
|
||||
For each associated project, maintain a root PROJECT.md with YAML frontmatter name (display name) and description (at most 256 characters). Describe the project's purpose and distinguishing context so future summarizers can select it. Read existing content first and use write_projectfile to create missing metadata or update it from grounded knowledge, preserving other fields and body notes. Do not invent facts or write unassociated projects.
|
||||
If a workflow improvement would help, use request_workflow_change with a short one-line intention (at most 100 characters) and a detailed self-contained change prompt, preferably a scenario/specification with expected behavior. This returns requested immediately and does not mean approved or implemented. Continue summarizing; only user approval starts the separate settings agent. You cannot change workflows directly.
|
||||
""";
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
|
||||
@@ -50,6 +58,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
||||
? DefaultInitialPrompt
|
||||
: options.Agent.InitialPrompt.Trim();
|
||||
instructions = instructions.TrimEnd() + "\n\n" + ProjectAndRequestInstructions;
|
||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, options, cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(projectInstructions)
|
||||
? instructions
|
||||
@@ -60,44 +69,15 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, options, cancellationToken);
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var blocks = projects.Select(project =>
|
||||
$"# {project.Name}\n\n{project.Instructions.Trim()}");
|
||||
return "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolver = ReferenceEquals(options, this.options)
|
||||
? projectResolver
|
||||
: new BoundMeetingProjectResolver(options);
|
||||
var projects = new List<ProjectInstructions>();
|
||||
var blocks = new List<string>();
|
||||
foreach (var project in await resolver.GetBoundProjectsAsync(artifacts, cancellationToken))
|
||||
{
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (!File.Exists(agentsPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
projects.Add(new ProjectInstructions(project.Name, content));
|
||||
}
|
||||
blocks.Add(await ProjectMetadata.BuildContextAsync(project, cancellationToken));
|
||||
}
|
||||
|
||||
return projects;
|
||||
return blocks.Count == 0 ? "" : "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
private sealed record ProjectInstructions(string Name, string Instructions);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -19,6 +20,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
|
||||
private readonly IDictationWordStore dictationWordStore;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly AgentApprovalService approvals;
|
||||
private readonly WorkflowChangeRequestService workflowRequests;
|
||||
|
||||
public OpenAiMeetingSummaryAgentPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
@@ -28,7 +31,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
IMeetingSummaryFailureWriter failureWriter,
|
||||
IMeetingSummaryInstructionBuilder instructionBuilder,
|
||||
IDictationWordStore dictationWordStore,
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine)
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine,
|
||||
AgentApprovalService approvals,
|
||||
WorkflowChangeRequestService workflowRequests)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
@@ -38,6 +43,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine;
|
||||
this.approvals = approvals;
|
||||
this.workflowRequests = workflowRequests;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
@@ -60,7 +67,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
options,
|
||||
dictationWordStore,
|
||||
writeAudit,
|
||||
meetingWorkflowEngine);
|
||||
meetingWorkflowEngine,
|
||||
approvals,
|
||||
workflowRequests);
|
||||
var tools = CreateTools(meetingTools);
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||
@@ -80,7 +89,11 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(
|
||||
loggerFactory,
|
||||
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
|
||||
client =>
|
||||
{
|
||||
client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync;
|
||||
client.AllowConcurrentInvocation = false;
|
||||
})
|
||||
.Build()
|
||||
.AsAIAgent(
|
||||
instructions,
|
||||
@@ -176,7 +189,15 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjects,
|
||||
"list_projects",
|
||||
"List the configured project folders bound to the current meeting note frontmatter."),
|
||||
"List all configured projects with id, displayname and description. Catalog visibility does not grant access; project files require current meeting association."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.RequestProjectAssociation,
|
||||
"request_project_association",
|
||||
"Request user approval to associate a project_id with this meeting. reason must be one line and at most 100 characters. Waits up to ten minutes; approval adds the project and returns its AGENTS.md if present."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.RequestWorkflowChange,
|
||||
"request_workflow_change",
|
||||
"Request a workflow change with a one-line intention (maximum 100 characters) and a detailed_prompt describing the desired scenario/specification. Returns requested immediately; user approval separately opens and starts the settings agent."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjectFiles,
|
||||
"list_projectfiles",
|
||||
@@ -188,7 +209,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteProjectFile,
|
||||
"write_projectfile",
|
||||
"Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
|
||||
"Write a file inside a project associated with the current meeting. Root PROJECT.md requires YAML name and description (maximum 256 characters). With no line arguments, append or create. Set replace_file=true only for intentional whole-file replacement. With from and to, replace that inclusive 1-based line range; with insert, insert at that line."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListPastProjectMeetings,
|
||||
"list_past_project_meetings",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed record ProjectMetadata(string Id, string DisplayName, string Description)
|
||||
{
|
||||
public static async Task<string> BuildContextAsync(BoundMeetingProject project, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = await ReadAsync(project, cancellationToken);
|
||||
var context = $"# {metadata.Id}\nDisplay name: {metadata.DisplayName}\nDescription: {metadata.Description}";
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (File.Exists(agentsPath))
|
||||
{
|
||||
var instructions = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(instructions)) context += "\n\n" + instructions.Trim();
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
public static bool IsValidContent(string content) => Parse(content) is not null;
|
||||
|
||||
public static async Task<ProjectMetadata> ReadAsync(BoundMeetingProject project, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = Path.Combine(project.Path, "PROJECT.md");
|
||||
var metadata = File.Exists(path) ? Parse(await File.ReadAllTextAsync(path, cancellationToken)) : null;
|
||||
return new(project.Name,
|
||||
string.IsNullOrWhiteSpace(metadata?.Name) ? project.Name : metadata.Name.Trim(),
|
||||
metadata?.Description?.Trim() ?? "");
|
||||
}
|
||||
|
||||
private static Frontmatter? Parse(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter) return null;
|
||||
var metadata = FrontmatterYamlDeserializer.Create().Deserialize<Frontmatter>(document.Frontmatter);
|
||||
return string.IsNullOrWhiteSpace(metadata?.Name) || metadata.Description is null || metadata.Description.Length > 256
|
||||
? null
|
||||
: metadata;
|
||||
}
|
||||
catch (YamlException) { return null; }
|
||||
catch (InvalidDataException) { return null; }
|
||||
}
|
||||
|
||||
private sealed class Frontmatter
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
[YamlMember(Alias = "description")]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MeetingAssistant.Notifications;
|
||||
using MeetingAssistant.Workflow;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed class WorkflowChangeRequestService(
|
||||
AgentApprovalService approvals,
|
||||
IWorkflowRulesEditorWindowService editor,
|
||||
ILogger<WorkflowChangeRequestService> logger) : BackgroundService
|
||||
{
|
||||
private readonly Channel<RequestDetails> requests = Channel.CreateUnbounded<RequestDetails>(
|
||||
new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public string Request(string intention, string detailedPrompt, string meetingNotePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(intention) || intention.Length > 100 || intention.Contains('\r') || intention.Contains('\n'))
|
||||
return "Refused: intention must be a nonempty single line of at most 100 characters.";
|
||||
if (string.IsNullOrWhiteSpace(detailedPrompt))
|
||||
return "Refused: supply a detailed change prompt, preferably a scenario or specification.";
|
||||
var request = new RequestDetails(Guid.NewGuid().ToString("N"), intention, detailedPrompt, meetingNotePath);
|
||||
return requests.Writer.TryWrite(request) ? $"Requested: {request.Id}. User approval is pending."
|
||||
: "Refused: application is stopping.";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var pending = new List<Task>();
|
||||
try
|
||||
{
|
||||
await foreach (var request in requests.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
pending.RemoveAll(task => task.IsCompleted);
|
||||
pending.Add(HandleAsync(request, stoppingToken));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
|
||||
finally
|
||||
{
|
||||
await Task.WhenAll(pending);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(RequestDetails request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await approvals.ConfirmAsync($"Workflow change requested: {request.Intention}",
|
||||
$"The summarizer for {Path.GetFileName(request.MeetingNotePath)} requests a workflow change. Open the settings agent to apply it?",
|
||||
cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
editor.ShowWithPrompt(request.DetailedPrompt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Workflow request {RequestId} could not be handled", request.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
requests.Writer.TryComplete();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record RequestDetails(string Id, string Intention, string DetailedPrompt, string MeetingNotePath);
|
||||
}
|
||||
Reference in New Issue
Block a user