Public Access
Expand settings and logs agent tools
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
This commit is contained in:
@@ -154,6 +154,40 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.DoesNotContain("- Stale Project", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSummaryCopiesOnlyDistinctNonBlankMeetingProjects()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
projects:
|
||||
- " Current Project "
|
||||
- current project
|
||||
- ""
|
||||
- Second Project
|
||||
---
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary", "Project summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("projects:", summary);
|
||||
Assert.Contains("- Current Project", summary);
|
||||
Assert.Contains("- Second Project", summary);
|
||||
Assert.DoesNotContain("- current project", summary);
|
||||
Assert.DoesNotContain("- \"\"", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsCanAddAndRemoveMeetingAttendees()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MeetingAssistant.Workflow;
|
||||
using MeetingAssistant.Logging;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -298,6 +299,167 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "../escape.md", "bad"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsToolsListReadAndSearchMeetingArtifacts()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var summariesRoot = Path.Combine(root, "Summaries");
|
||||
var transcriptsRoot = Path.Combine(root, "Transcripts");
|
||||
var notesRoot = Path.Combine(root, "Notes");
|
||||
var contextRoot = Path.Combine(root, "Context");
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
Directory.CreateDirectory(transcriptsRoot);
|
||||
Directory.CreateDirectory(notesRoot);
|
||||
Directory.CreateDirectory(contextRoot);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(summariesRoot, "20260530-1000-summary.md"),
|
||||
"""
|
||||
---
|
||||
title: Older Meeting
|
||||
start_time: "2026-05-30T10:00:00+02:00"
|
||||
meeting: "[[20260530-1000-note|Meeting Note]]"
|
||||
transcript: "[[20260530-1000-transcript|Transcript]]"
|
||||
assistant_context: "[[20260530-1000-context|Assistant Context]]"
|
||||
projects:
|
||||
- Alpha
|
||||
---
|
||||
|
||||
older summary needle
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(summariesRoot, "20260531-0900-summary.md"),
|
||||
"""
|
||||
---
|
||||
title: Newer Meeting
|
||||
start_time: "2026-05-31T09:00:00+02:00"
|
||||
meeting: "[[20260531-0900-note|Meeting Note]]"
|
||||
transcript: "[[20260531-0900-transcript|Transcript]]"
|
||||
assistant_context: "[[20260531-0900-context|Assistant Context]]"
|
||||
projects:
|
||||
- Beta
|
||||
---
|
||||
|
||||
newer summary needle
|
||||
""");
|
||||
await File.WriteAllTextAsync(Path.Combine(notesRoot, "20260531-0900-note.md"), "note one\nnote needle\nnote three");
|
||||
await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "20260531-0900-transcript.md"), "transcript one\ntranscript needle\ntranscript three");
|
||||
await File.WriteAllTextAsync(Path.Combine(contextRoot, "20260531-0900-context.md"), "context one\ncontext needle\ncontext three");
|
||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
SummariesFolder = summariesRoot,
|
||||
TranscriptsFolder = transcriptsRoot,
|
||||
MeetingNotesFolder = notesRoot,
|
||||
AssistantContextFolder = contextRoot
|
||||
}
|
||||
});
|
||||
|
||||
var recent = await tools.ListRecentSummaries(limit: 2);
|
||||
var summary = await tools.ReadSummary("20260531-0900-summary.md", tail: 1);
|
||||
var transcript = await tools.ReadTranscript("20260531-0900-transcript.md", from: 2, to: 2);
|
||||
var meetingNote = await tools.ReadMeetingNote("20260531-0900-note.md", tail: 2);
|
||||
var context = await tools.ReadContext("20260531-0900-context.md", tail: 2);
|
||||
var summarySearch = await tools.SearchSummaries("needle");
|
||||
var transcriptSearch = await tools.SearchTranscripts("needle");
|
||||
var noteSearch = await tools.SearchMeetingNotes("needle");
|
||||
var contextSearch = await tools.SearchContext("needle");
|
||||
|
||||
Assert.True(recent.IndexOf("20260531-0900-summary.md", StringComparison.Ordinal) <
|
||||
recent.IndexOf("20260530-1000-summary.md", StringComparison.Ordinal));
|
||||
Assert.Contains("title: Newer Meeting", recent);
|
||||
Assert.Contains("meeting_note: 20260531-0900-note.md", recent);
|
||||
Assert.Contains("transcript: 20260531-0900-transcript.md", recent);
|
||||
Assert.Contains("context: 20260531-0900-context.md", recent);
|
||||
Assert.Equal("newer summary needle", summary.Trim());
|
||||
Assert.Equal("transcript needle", transcript.Trim());
|
||||
Assert.Equal("note needle\nnote three", meetingNote);
|
||||
Assert.Equal("context needle\ncontext three", context);
|
||||
Assert.Contains("20260531-0900-summary.md:", summarySearch);
|
||||
Assert.Contains("20260531-0900-transcript.md:2 transcript needle", transcriptSearch);
|
||||
Assert.Contains("20260531-0900-note.md:2 note needle", noteSearch);
|
||||
Assert.Contains("20260531-0900-context.md:2 context needle", contextSearch);
|
||||
Assert.StartsWith("Refused:", await tools.ReadSummary("../escape.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsToolsWriteMeetingArtifactsAndFrontmatter()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var summariesRoot = Path.Combine(root, "summaries");
|
||||
var transcriptsRoot = Path.Combine(root, "transcripts");
|
||||
var notesRoot = Path.Combine(root, "meetings");
|
||||
var contextRoot = Path.Combine(root, "context");
|
||||
Directory.CreateDirectory(summariesRoot);
|
||||
Directory.CreateDirectory(transcriptsRoot);
|
||||
Directory.CreateDirectory(notesRoot);
|
||||
Directory.CreateDirectory(contextRoot);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(notesRoot, "daily.md"),
|
||||
"""
|
||||
---
|
||||
title: Old
|
||||
---
|
||||
|
||||
Existing body.
|
||||
""");
|
||||
await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md"), "one\nthree");
|
||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
SummariesFolder = summariesRoot,
|
||||
TranscriptsFolder = transcriptsRoot,
|
||||
MeetingNotesFolder = notesRoot,
|
||||
AssistantContextFolder = contextRoot
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("daily-summary.md", await tools.WriteSummary("daily-summary.md", "summary body", replace_file: true));
|
||||
Assert.Equal("daily-transcript.md", await tools.WriteTranscript("daily-transcript.md", "two", insert: 2));
|
||||
Assert.Equal("daily.md", await tools.WriteMeetingNoteFrontmatter(
|
||||
"daily.md",
|
||||
"""
|
||||
title: Fixed
|
||||
projects:
|
||||
- Alpha
|
||||
transcript: "[[daily-transcript|Transcript]]"
|
||||
"""));
|
||||
Assert.Equal("repair/context.md", await tools.WriteContext("repair/context.md", "context body"));
|
||||
|
||||
Assert.Equal("summary body", await File.ReadAllTextAsync(Path.Combine(summariesRoot, "daily-summary.md")));
|
||||
Assert.Equal("one\ntwo\nthree", await File.ReadAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md")));
|
||||
var note = await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md"));
|
||||
Assert.Contains("title: Fixed", note);
|
||||
Assert.Contains("projects:\n- Alpha", note);
|
||||
Assert.Contains("Existing body.", note);
|
||||
Assert.Equal("context body", await File.ReadAllTextAsync(Path.Combine(contextRoot, "repair", "context.md")));
|
||||
Assert.StartsWith("Refused:", await tools.WriteSummary("../escape.md", "bad"));
|
||||
Assert.StartsWith("Refused:", await tools.WriteMeetingNoteFrontmatter("daily.md", "title: ["));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsToolsExposeDiagnosticsWithoutHttp()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Automation:RulesPath"] = "rules.yaml"
|
||||
})
|
||||
.Build();
|
||||
var tools = new WorkflowRulesEditorTools(
|
||||
new MeetingAssistantOptions(),
|
||||
configuration: config);
|
||||
|
||||
Assert.Contains("meeting-assistant", tools.GetHealth());
|
||||
Assert.Equal("Recording coordinator is not configured.", tools.GetRecordingStatus());
|
||||
Assert.Contains("rules.yaml", tools.ReloadWorkflowConfiguration());
|
||||
Assert.Equal("Meeting metadata provider is not configured.", await tools.DiagnoseCurrentMeeting());
|
||||
Assert.Equal("Speaker identity merge service is not configured.", await tools.MergeRecentSpeakerIdentities());
|
||||
Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrTranscribeFile("missing.wav"));
|
||||
Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrDiarizeFile("missing.wav"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileLoggerRotatesCurrentLogAndKeepsFourOlderFiles()
|
||||
{
|
||||
@@ -398,6 +560,7 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Contains("read_config", instructions);
|
||||
Assert.Contains("read_logs", instructions);
|
||||
Assert.Contains("read_spec_file", instructions);
|
||||
Assert.Contains("list_recent_summaries", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -24,7 +24,7 @@ internal static class MeetingSummaryFrontmatterFactory
|
||||
artifacts.SummaryPath,
|
||||
meetingNote,
|
||||
cancellationToken);
|
||||
frontmatter.Projects = CopyNonEmptyList(meetingNote.Frontmatter.Projects);
|
||||
frontmatter.Projects = CopyNonEmptyProjectList(meetingNote.Frontmatter.Projects);
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ internal static class MeetingSummaryFrontmatterFactory
|
||||
return values.Count == 0 ? null : values.ToList();
|
||||
}
|
||||
|
||||
private static List<string>? CopyNonEmptyProjectList(IEnumerable<string> values)
|
||||
{
|
||||
var projects = values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return projects.Count == 0 ? null : projects;
|
||||
}
|
||||
|
||||
private static async Task<List<string>?> ReadExistingSummaryAttendeesAsync(
|
||||
string summaryPath,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#if WINDOWS
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
|
||||
@@ -6,6 +8,8 @@ namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
internal const string WindowTitle = "Settings and logs";
|
||||
|
||||
private readonly IServiceProvider services;
|
||||
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
|
||||
private readonly object sync = new();
|
||||
@@ -26,7 +30,13 @@ public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditor
|
||||
if (isRunning)
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is already running");
|
||||
return;
|
||||
if (WorkflowRulesEditorWindowActivation.TryActivate(WindowTitle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning("Workflow rules editor UI was marked running, but no window could be activated; starting a new UI thread");
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
@@ -108,7 +118,7 @@ internal sealed class WorkflowRulesEditorMewWindow
|
||||
.OnClick(() => _ = SendAsync());
|
||||
|
||||
var window = new Window()
|
||||
.Title("Settings and logs")
|
||||
.Title(MewUiWorkflowRulesEditorWindowService.WindowTitle)
|
||||
.Resizable(680, 760, 520, 460)
|
||||
.Padding(0)
|
||||
.OnLoaded(Render)
|
||||
@@ -238,14 +248,140 @@ internal sealed class WorkflowRulesEditorMewWindow
|
||||
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
|
||||
return new Border()
|
||||
var copyButton = new Button()
|
||||
.Content("⧉")
|
||||
.Width(28)
|
||||
.Height(24)
|
||||
.ToolTip("Copy message")
|
||||
.OnClick(() => WorkflowRulesEditorClipboard.SetText(message.Content));
|
||||
copyButton.IsVisible = false;
|
||||
|
||||
var copyRow = new DockPanel()
|
||||
.LastChildFill(false)
|
||||
.Children(copyButton.DockRight());
|
||||
var cardContent = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(4)
|
||||
.Children(
|
||||
copyRow,
|
||||
WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
|
||||
|
||||
var card = new Border()
|
||||
.Padding(10)
|
||||
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
|
||||
.CornerRadius(8)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
|
||||
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
|
||||
.Child(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
|
||||
.OnMouseEnter(() => copyButton.IsVisible = true)
|
||||
.OnMouseLeave(() => copyButton.IsVisible = false)
|
||||
.Child(cardContent);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class WorkflowRulesEditorWindowActivation
|
||||
{
|
||||
private const int SwRestore = 9;
|
||||
|
||||
public static bool TryActivate(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var handle = FindWindow(null, title);
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ShowWindow(handle, SwRestore);
|
||||
return SetForegroundWindow(handle);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr FindWindow(string? lpClassName, string lpWindowName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
}
|
||||
|
||||
internal static class WorkflowRulesEditorClipboard
|
||||
{
|
||||
private const uint CfUnicodeText = 13;
|
||||
private const uint GmemMoveable = 0x0002;
|
||||
private const uint GmemZeroInit = 0x0040;
|
||||
|
||||
public static void SetText(string text)
|
||||
{
|
||||
var bytes = Encoding.Unicode.GetBytes((text ?? "") + '\0');
|
||||
var handle = GlobalAlloc(GmemMoveable | GmemZeroInit, (UIntPtr)bytes.Length);
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var locked = GlobalLock(handle);
|
||||
if (locked == IntPtr.Zero)
|
||||
{
|
||||
GlobalFree(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
Marshal.Copy(bytes, 0, locked, bytes.Length);
|
||||
GlobalUnlock(handle);
|
||||
|
||||
if (!OpenClipboard(IntPtr.Zero))
|
||||
{
|
||||
GlobalFree(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EmptyClipboard();
|
||||
if (SetClipboardData(CfUnicodeText, handle) != IntPtr.Zero)
|
||||
{
|
||||
handle = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseClipboard();
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
GlobalFree(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool EmptyClipboard();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool CloseClipboard();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
@@ -15,6 +20,12 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
|
||||
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;
|
||||
|
||||
public WorkflowRulesEditorChatPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
@@ -22,7 +33,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
ILogger<WorkflowRulesEditorChatPipeline> logger,
|
||||
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
|
||||
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
|
||||
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue)
|
||||
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue,
|
||||
MeetingRecordingCoordinator recordingCoordinator,
|
||||
IMeetingMetadataProvider meetingMetadataProvider,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
ISpeakerIdentityMergeService identityMergeService,
|
||||
AsrDiagnosticService asrDiagnosticService,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
@@ -30,6 +47,12 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
|
||||
this.samplePlaybackQueue = samplePlaybackQueue;
|
||||
this.recordingCoordinator = recordingCoordinator;
|
||||
this.meetingMetadataProvider = meetingMetadataProvider;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.identityMergeService = identityMergeService;
|
||||
this.asrDiagnosticService = asrDiagnosticService;
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
@@ -47,7 +70,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
var tools = new WorkflowRulesEditorTools(
|
||||
options,
|
||||
speakerIdentityDbContextFactory,
|
||||
samplePlaybackQueue);
|
||||
samplePlaybackQueue,
|
||||
recordingCoordinator,
|
||||
meetingMetadataProvider,
|
||||
launchProfiles,
|
||||
identityMergeService,
|
||||
asrDiagnosticService,
|
||||
configuration);
|
||||
var messages = conversation
|
||||
.Select(ToChatMessage)
|
||||
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
|
||||
@@ -181,6 +210,102 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
tools.SearchProjects,
|
||||
"search_projects",
|
||||
"Search files in all existing projects with .NET regular expression syntax. Use file_pattern to limit searched files by glob, for example *.md or **/*.md."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListRecentSummaries,
|
||||
"list_recent_summaries",
|
||||
"List recent meeting summary notes from the configured summaries folder, newest first. Returns summary, meeting note, transcript, and assistant context paths to pass to the read tools."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadSummary,
|
||||
"read_summary",
|
||||
"Read one meeting summary file by path relative to the configured summaries folder. Supports from/to or tail."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadTranscript,
|
||||
"read_transcript",
|
||||
"Read one transcript file by path relative to the configured transcripts folder. Supports from/to or tail."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadMeetingNote,
|
||||
"read_meeting_note",
|
||||
"Read one meeting note file by path relative to the configured meeting notes folder. Supports from/to or tail."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadContext,
|
||||
"read_context",
|
||||
"Read one assistant context file by path relative to the configured assistant context folder. Supports from/to or tail."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteSummary,
|
||||
"write_summary",
|
||||
"Create or update a meeting summary file by path relative to the configured summaries folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteTranscript,
|
||||
"write_transcript",
|
||||
"Create or update a transcript file by path relative to the configured transcripts folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteMeetingNote,
|
||||
"write_meeting_note",
|
||||
"Create or update a meeting note file by path relative to the configured meeting notes folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteContext,
|
||||
"write_context",
|
||||
"Create or update an assistant context file by path relative to the configured assistant context folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteSummaryFrontmatter,
|
||||
"write_summary_frontmatter",
|
||||
"Replace only the YAML frontmatter of a meeting summary file while preserving the body. The path is relative to the configured summaries folder."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteTranscriptFrontmatter,
|
||||
"write_transcript_frontmatter",
|
||||
"Replace only the YAML frontmatter of a transcript file while preserving the body. The path is relative to the configured transcripts folder."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteMeetingNoteFrontmatter,
|
||||
"write_meeting_note_frontmatter",
|
||||
"Replace only the YAML frontmatter of a meeting note file while preserving the body. The path is relative to the configured meeting notes folder."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteContextFrontmatter,
|
||||
"write_context_frontmatter",
|
||||
"Replace only the YAML frontmatter of an assistant context file while preserving the body. The path is relative to the configured assistant context folder."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchSummaries,
|
||||
"search_summaries",
|
||||
"Search meeting summary files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchTranscripts,
|
||||
"search_transcripts",
|
||||
"Search transcript files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchMeetingNotes,
|
||||
"search_meeting_notes",
|
||||
"Search meeting note files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchContext,
|
||||
"search_context",
|
||||
"Search assistant context files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.GetHealth,
|
||||
"get_health",
|
||||
"Return the same basic health information as the local /health endpoint."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.GetRecordingStatus,
|
||||
"get_recording_status",
|
||||
"Return the current recording, artifact, process, and launch profile status without making an HTTP call."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DiagnoseCurrentMeeting,
|
||||
"diagnose_current_meeting",
|
||||
"Run the current meeting metadata diagnostic. Optional launch_profile selects a configured profile, started_at is a date/time, and timeout_seconds overrides the metadata lookup timeout."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.MergeRecentSpeakerIdentities,
|
||||
"merge_recent_speaker_identities",
|
||||
"Run the recent speaker identity merge diagnostic. Optional recent_days overrides the configured merge age window; optional launch_profile selects profile options."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReloadWorkflowConfiguration,
|
||||
"reload_workflow_configuration",
|
||||
"Reload application configuration in-process and return the currently bound workflow rules path."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DiagnoseAsrTranscribeFile,
|
||||
"diagnose_asr_transcribe_file",
|
||||
"Run ASR diagnostics on a local WAV file and return live transcription segments. Optional launch_profile selects a configured ASR profile."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DiagnoseAsrDiarizeFile,
|
||||
"diagnose_asr_diarize_file",
|
||||
"Run ASR diarization diagnostics on a local WAV file and return finished segments. Optional num_speakers and launch_profile mirror the HTTP diagnostic endpoint."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchIdentities,
|
||||
"search_identities",
|
||||
|
||||
@@ -24,6 +24,10 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
|
||||
Use search_spec and read_spec_file to inspect the OpenSpec product requirements before making behavior claims or configuration changes that depend on intended behavior.
|
||||
Use list_projects, list_projectfiles, read_projectfile, write_projectfile, and search_projects for project knowledge files under the configured projects folder. These tools are intentionally scoped to existing project folders.
|
||||
Use create_project when the user wants a new project folder. Prefer seed=recommended unless the user explicitly asks for an empty project or provides AGENTS.md content directly.
|
||||
Use list_recent_summaries, read_summary, read_transcript, read_meeting_note, read_context, and the matching search tools when the user wants help post-processing meeting notes or investigating past meeting artifacts.
|
||||
Prefer list_recent_summaries first when the user refers to recent meetings without naming an exact file.
|
||||
Use the matching write_summary, write_transcript, write_meeting_note, write_context, and *_frontmatter tools only to repair or post-process meeting artifacts the user asked you to change. Prefer the frontmatter-specific tools when only metadata needs repair.
|
||||
Use get_health, get_recording_status, diagnose_current_meeting, merge_recent_speaker_identities, reload_workflow_configuration, diagnose_asr_transcribe_file, and diagnose_asr_diarize_file for diagnostics that would otherwise require local HTTP endpoints.
|
||||
Use search_identities and read_identity before changing identities unless the user gives an exact identity id.
|
||||
Prefer updating identities by id, not by guessed name. If a user asks about names, search first and confirm ambiguity in your final response.
|
||||
For identity merges, merge the duplicate/source identity into the identity that should remain as target.
|
||||
@@ -55,6 +59,8 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
|
||||
"Log tools can read and search the current application-owned log file and four rotated older files under the temp log folder." + Environment.NewLine + Environment.NewLine +
|
||||
"Spec tools can search and read copied OpenSpec markdown files from openspec/specs." + Environment.NewLine + Environment.NewLine +
|
||||
"Project tools can create project folders and read, write, list, and search files in configured projects." + Environment.NewLine + Environment.NewLine +
|
||||
"Meeting artifact tools can list recent summaries and read/search/write summaries, transcripts, meeting notes, and assistant context files for note post-processing and repair. Prefer frontmatter-specific write tools for metadata-only fixes." + Environment.NewLine + Environment.NewLine +
|
||||
"Diagnostic tools mirror the local health, recording status, Outlook metadata, speaker identity merge, workflow reload, and ASR diagnostic endpoints without requiring HTTP." + Environment.NewLine + Environment.NewLine +
|
||||
"Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine +
|
||||
"Workflow rules reference documentation:" + Environment.NewLine +
|
||||
"```markdown" + Environment.NewLine +
|
||||
|
||||
@@ -67,23 +67,20 @@ internal static class WorkflowRulesEditorMarkdownRenderer
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var token in SplitInlineText(inline.Text))
|
||||
var text = new TextBlock()
|
||||
.Text(inline.Text)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243));
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
|
||||
{
|
||||
var text = new TextBlock()
|
||||
.Text(token)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243));
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
|
||||
{
|
||||
text.FontWeight(FontWeight.Bold);
|
||||
}
|
||||
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
|
||||
{
|
||||
text.FontFamily("Segoe UI Italic");
|
||||
}
|
||||
|
||||
yield return text;
|
||||
text.FontWeight(FontWeight.Bold);
|
||||
}
|
||||
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
|
||||
{
|
||||
text.FontFamily("Segoe UI Italic");
|
||||
}
|
||||
|
||||
yield return text;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
|
||||
@@ -175,28 +172,6 @@ internal static class WorkflowRulesEditorMarkdownRenderer
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitInlineText(string text)
|
||||
{
|
||||
var start = 0;
|
||||
while (start < text.Length)
|
||||
{
|
||||
var end = start;
|
||||
var isWhitespace = char.IsWhiteSpace(text[start]);
|
||||
while (end < text.Length && char.IsWhiteSpace(text[end]) == isWhitespace)
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
var token = text[start..end];
|
||||
if (token.Length > 0)
|
||||
{
|
||||
yield return token;
|
||||
}
|
||||
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
|
||||
{
|
||||
if (cells.Count >= count)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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 YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
@@ -28,20 +33,38 @@ public sealed class WorkflowRulesEditorTools
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
this.options = options;
|
||||
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||
this.configPath = ResolveConfigPath(configPath);
|
||||
this.configDocsPath = ResolveConfigDocsPath(configDocsPath);
|
||||
@@ -49,9 +72,19 @@ public sealed class WorkflowRulesEditorTools
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task<string> ReadRules(int? from = null, int? to = null, int? tail = null)
|
||||
@@ -351,6 +384,298 @@ public sealed class WorkflowRulesEditorTools
|
||||
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);
|
||||
@@ -716,6 +1041,249 @@ public sealed class WorkflowRulesEditorTools
|
||||
Do not log small implementation details such as how UI elements are aligned.
|
||||
""";
|
||||
|
||||
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))
|
||||
@@ -734,6 +1302,24 @@ public sealed class WorkflowRulesEditorTools
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1123,4 +1709,35 @@ public sealed class WorkflowRulesEditorTools
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -316,7 +316,9 @@ The summary agent can add and remove meeting-note attendees when transcript or O
|
||||
|
||||
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
|
||||
|
||||
The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, and inspect application logs.
|
||||
The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, inspect application logs, create/search/read/write project files, and post-process past meeting artifacts by listing recent summaries and reading, writing, or searching summaries, transcripts, meeting notes, and assistant context files. For artifact metadata repairs, it has frontmatter-specific write tools that preserve the markdown body.
|
||||
|
||||
It also has in-process diagnostic tools that mirror the local HTTP diagnostics for health, recording status, current Outlook meeting lookup, recent speaker identity merging, workflow configuration reload, and ASR transcribe/diarize checks.
|
||||
|
||||
The `search_spec` and `read_spec_file` tools are scoped to the copied `openspec/specs` markdown tree. Spec files are copied into build and publish output through an MSBuild glob, so newly added folders under `openspec/specs` are included automatically.
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ The settings/logs agent SHALL receive speaker sample tools to list, read, delete
|
||||
|
||||
The settings/logs agent SHALL receive tools to read the local appsettings configuration file, write a complete valid JSON replacement for that file, read Meeting Assistant configuration documentation, read current or rotated application log files by tail or 1-based line range, search current or rotated application log files, search copied OpenSpec specification files, and read copied OpenSpec specification files by relative path and optional 1-based line range.
|
||||
|
||||
The settings/logs agent SHALL receive in-process diagnostic tools for health, recording status, current meeting metadata lookup, recent speaker identity merging, workflow configuration reload, ASR file transcription, and ASR file diarization so those diagnostics do not require HTTP calls.
|
||||
|
||||
Meeting Assistant SHALL copy specification markdown files from `openspec/specs` into build and publish output through a recursive include so future spec folders are included automatically.
|
||||
|
||||
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
|
||||
@@ -87,6 +89,11 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the settings/lo
|
||||
- **THEN** it receives the requested file content or clamped 1-based line range
|
||||
- **AND** paths outside the copied specs tree are refused
|
||||
|
||||
#### Scenario: Settings and logs assistant runs diagnostics without HTTP
|
||||
- **WHEN** the settings/logs agent requests health, recording status, current meeting metadata, speaker identity merge, workflow reload, or ASR file diagnostics
|
||||
- **THEN** Meeting Assistant runs the same in-process services used by the local diagnostic endpoints
|
||||
- **AND** returns the diagnostic result to the agent
|
||||
|
||||
#### Scenario: New spec folders are included in output
|
||||
- **WHEN** a new markdown spec file is added under `openspec/specs`
|
||||
- **THEN** the application build includes it in the output under `openspec/specs`
|
||||
|
||||
@@ -8,6 +8,18 @@ The `write_summary` tool SHALL return the written summary filename after writing
|
||||
|
||||
The summary-agent `search` tool SHALL support an optional glob file pattern that limits searched project files.
|
||||
|
||||
The settings/logs agent SHALL be able to list recent meeting summary notes from the configured summaries folder ordered newest first by meeting start time, falling back to file modification time when summary frontmatter has no parseable start time.
|
||||
|
||||
The settings/logs agent SHALL be able to read and search meeting summaries, transcripts, meeting notes, and assistant context files from their configured vault folders so it can help post-process notes for the user.
|
||||
|
||||
The settings/logs agent SHALL be able to create and update meeting summaries, transcripts, meeting notes, and assistant context files inside their configured vault folders.
|
||||
|
||||
The settings/logs agent SHALL provide frontmatter-specific write tools for meeting summaries, transcripts, meeting notes, and assistant context files that replace YAML frontmatter while preserving the markdown body.
|
||||
|
||||
Settings/logs meeting artifact read tools SHALL support reading the whole file, reading a clamped inclusive 1-based line range when `from` and `to` are supplied, or reading the last `tail` lines when `tail` is supplied without a line range.
|
||||
|
||||
Settings/logs meeting artifact search tools SHALL support an optional glob file pattern that limits searched markdown files.
|
||||
|
||||
#### Scenario: Summary read tools support tail
|
||||
- **WHEN** the summary agent reads a transcript with `tail` set to `2`
|
||||
- **THEN** Meeting Assistant returns the last two transcript lines
|
||||
@@ -21,3 +33,25 @@ The summary-agent `search` tool SHALL support an optional glob file pattern that
|
||||
- **WHEN** the summary agent searches with file pattern `*.md`
|
||||
- **THEN** Meeting Assistant searches `PROJECT.md`
|
||||
- **AND** excludes `scratch.txt`
|
||||
|
||||
#### Scenario: Settings/logs agent lists and reads recent meeting artifacts
|
||||
- **GIVEN** the configured summary folder contains multiple summary notes with start times
|
||||
- **WHEN** the settings/logs agent lists recent summaries
|
||||
- **THEN** Meeting Assistant returns the summary notes newest first
|
||||
- **AND** includes relative paths for the linked meeting note, transcript, and assistant context when those links resolve to configured artifact folders
|
||||
- **WHEN** the settings/logs agent reads one of those artifact paths with `tail`
|
||||
- **THEN** Meeting Assistant returns only the requested last lines
|
||||
|
||||
#### Scenario: Settings/logs agent searches meeting artifacts
|
||||
- **GIVEN** meeting summaries, transcripts, notes, and assistant context files contain searchable text
|
||||
- **WHEN** the settings/logs agent searches the relevant artifact class
|
||||
- **THEN** Meeting Assistant returns relative `path:line` matches from that configured artifact folder
|
||||
- **AND** refuses read paths that escape their configured artifact folder
|
||||
|
||||
#### Scenario: Settings/logs agent repairs meeting artifact files
|
||||
- **GIVEN** an existing meeting note with YAML frontmatter and a markdown body
|
||||
- **WHEN** the settings/logs agent writes replacement frontmatter for that note
|
||||
- **THEN** Meeting Assistant validates the supplied frontmatter YAML
|
||||
- **AND** replaces only the frontmatter while preserving the body
|
||||
- **WHEN** the settings/logs agent writes a summary, transcript, meeting note, or assistant context path outside the configured folder
|
||||
- **THEN** Meeting Assistant refuses the write
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- [x] 2.4 Cover log tail/range/search tools and file logging rotation.
|
||||
- [x] 2.5 Cover copied spec read/search tools and scoped path handling.
|
||||
- [x] 2.6 Cover project creation, project read/write/search, read tail, search glob, summary filename return, and short-run abort behavior.
|
||||
- [x] 2.7 Cover settings/logs recent-summary listing and meeting artifact read/search tools.
|
||||
- [x] 2.8 Cover settings/logs meeting artifact write/frontmatter repair tools and in-process diagnostic tool availability.
|
||||
- [x] 2.9 Cover summary frontmatter copying clean, distinct meeting projects.
|
||||
|
||||
## 3. Implementation
|
||||
- [x] 3.1 Add app-owned temp-file logging with four older rotated files.
|
||||
@@ -18,3 +21,6 @@
|
||||
- [x] 3.4 Add recursive spec markdown copy to build and publish output.
|
||||
- [x] 3.5 Run focused tests and strict OpenSpec validation.
|
||||
- [x] 3.6 Add settings/logs project tools, bundled Project-AGENTS.md seed content, summary filename return, minute-level summary/context names, and configurable short-run abort.
|
||||
- [x] 3.7 Add settings/logs tools for recent summaries and summary/transcript/note/context read/search.
|
||||
- [x] 3.8 Add settings/logs tools for summary/transcript/note/context writes, frontmatter repair, and diagnostics that previously required HTTP.
|
||||
- [x] 3.9 Normalize copied meeting projects when writing summary frontmatter.
|
||||
|
||||
Reference in New Issue
Block a user