Public Access
899 lines
32 KiB
C#
899 lines
32 KiB
C#
using MeetingAssistant.MeetingNotes;
|
|
using MeetingAssistant.Speakers;
|
|
using MeetingAssistant.Transcription;
|
|
using YamlDotNet.Serialization;
|
|
|
|
namespace MeetingAssistant.Summary;
|
|
|
|
public sealed class MeetingSummaryTools
|
|
{
|
|
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
|
.IgnoreUnmatchedProperties()
|
|
.Build();
|
|
|
|
private readonly MeetingSessionArtifacts artifacts;
|
|
private readonly MeetingAssistantOptions options;
|
|
private readonly IDictationWordStore? dictationWordStore;
|
|
private readonly SummaryAgentWriteAudit? writeAudit;
|
|
private readonly BoundMeetingProjectResolver projectResolver;
|
|
|
|
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
|
: this(artifacts, new MeetingAssistantOptions(), null)
|
|
{
|
|
}
|
|
|
|
public MeetingSummaryTools(
|
|
MeetingSessionArtifacts artifacts,
|
|
MeetingAssistantOptions options,
|
|
IDictationWordStore? dictationWordStore = null,
|
|
SummaryAgentWriteAudit? writeAudit = null)
|
|
{
|
|
this.artifacts = artifacts;
|
|
this.options = options;
|
|
this.dictationWordStore = dictationWordStore;
|
|
this.writeAudit = writeAudit;
|
|
projectResolver = new BoundMeetingProjectResolver(options);
|
|
}
|
|
|
|
public Task<string> ReadTranscript(int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to, tail);
|
|
}
|
|
|
|
public Task<string> ReadMeetingNote(int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to, tail);
|
|
}
|
|
|
|
public Task<string> ReadContext(int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to, tail);
|
|
}
|
|
|
|
public async Task<string> ReadUserNotes(int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
if (!File.Exists(artifacts.MeetingNotePath))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
var body = document.HasFrontmatter ? document.Body.Trim() : content;
|
|
return AgentFileToolContent.ReadLines(body, @from, to, tail);
|
|
}
|
|
|
|
public Task<string> ReadGlossary(int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to, tail);
|
|
}
|
|
|
|
public async Task<string> AddDictationWord(string word)
|
|
{
|
|
if (dictationWordStore is null)
|
|
{
|
|
return "Dictation word store is not available.";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(word))
|
|
{
|
|
return "Refused: word must not be empty.";
|
|
}
|
|
|
|
if (writeAudit is not null)
|
|
{
|
|
IReadOnlyList<string>? capturedWords = null;
|
|
await writeAudit.CaptureFileWriteAsync(
|
|
VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath),
|
|
async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None));
|
|
return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s).";
|
|
}
|
|
|
|
var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None);
|
|
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
|
}
|
|
|
|
public async Task<string> AddAttendee(string attendee)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attendee))
|
|
{
|
|
return "Refused: attendee must not be empty.";
|
|
}
|
|
|
|
var displayName = attendee.Trim();
|
|
var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName);
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
{
|
|
return "Refused: attendee must not be empty.";
|
|
}
|
|
|
|
var note = await ReadMeetingNoteAsync();
|
|
if (note.Frontmatter.Attendees.Any(existing => string.Equals(
|
|
MeetingAttendeeNames.NormalizeDisplayName(existing),
|
|
normalized,
|
|
StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
return $"Attendee {normalized} already exists.";
|
|
}
|
|
|
|
note.Frontmatter.Attendees.Add(displayName);
|
|
await WriteMeetingNoteAsync(note);
|
|
return $"Added attendee {displayName}.";
|
|
}
|
|
|
|
public async Task<string> RemoveAttendee(string attendee)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attendee))
|
|
{
|
|
return "Refused: attendee must not be empty.";
|
|
}
|
|
|
|
var normalized = MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
{
|
|
return "Refused: attendee must not be empty.";
|
|
}
|
|
|
|
var note = await ReadMeetingNoteAsync();
|
|
var removed = note.Frontmatter.Attendees.RemoveAll(existing => string.Equals(
|
|
MeetingAttendeeNames.NormalizeDisplayName(existing),
|
|
normalized,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
if (removed == 0)
|
|
{
|
|
return $"Attendee {normalized} was not present.";
|
|
}
|
|
|
|
await WriteMeetingNoteAsync(note);
|
|
return $"Removed attendee {normalized}.";
|
|
}
|
|
|
|
public async Task<string> OverrideSpeaker(string speaker, string replacement, bool merge = false)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(speaker) || string.IsNullOrWhiteSpace(replacement))
|
|
{
|
|
return "Refused: speaker and replacement must not be empty.";
|
|
}
|
|
|
|
if (!File.Exists(artifacts.AssistantContextPath))
|
|
{
|
|
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
|
|
}
|
|
|
|
var speakerOverride = new SpeakerOverride(speaker.Trim(), replacement.Trim(), merge);
|
|
if (string.Equals(speakerOverride.From, speakerOverride.To, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Refused: speaker and replacement must be different.";
|
|
}
|
|
|
|
if (!merge && await SpeakerOverrideArtifacts.TranscriptContainsSpeakerAsync(
|
|
artifacts.TranscriptPath,
|
|
speakerOverride.To,
|
|
CancellationToken.None))
|
|
{
|
|
return $"Refused: transcript already contains speaker {speakerOverride.To}. Call override_speaker with merge=true only when you are certain both speakers are the same identity.";
|
|
}
|
|
|
|
await SpeakerOverrideArtifacts.ApplyToTranscriptAsync(artifacts.TranscriptPath, speakerOverride);
|
|
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(artifacts.AssistantContextPath, speakerOverride);
|
|
return merge
|
|
? $"Merged speaker {speakerOverride.From} into {speakerOverride.To}."
|
|
: $"Overrode speaker {speakerOverride.From} with {speakerOverride.To}.";
|
|
}
|
|
|
|
public async Task<string> DeleteIdentity(string identity)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(identity))
|
|
{
|
|
return "Refused: identity must not be empty.";
|
|
}
|
|
|
|
if (!File.Exists(artifacts.AssistantContextPath))
|
|
{
|
|
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
|
|
}
|
|
|
|
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
|
|
artifacts.TranscriptPath,
|
|
identity,
|
|
CancellationToken.None);
|
|
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
|
|
artifacts.AssistantContextPath,
|
|
deletion,
|
|
CancellationToken.None);
|
|
return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}.";
|
|
}
|
|
|
|
public async Task<string> WriteSummary(string markdown, string oneliner, string? title = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(oneliner))
|
|
{
|
|
return "Refused: oneliner must not be empty.";
|
|
}
|
|
|
|
var trimmedOneLiner = oneliner.Trim();
|
|
if (ContainsLineBreak(trimmedOneLiner))
|
|
{
|
|
return "Refused: oneliner must be a single line.";
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
|
var meetingNote = await ReadMeetingNoteAsync();
|
|
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
|
? title
|
|
: meetingNote.Frontmatter.Title;
|
|
var frontmatter = await MeetingSummaryFrontmatterFactory.CreateAsync(
|
|
artifacts,
|
|
meetingNote,
|
|
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
|
CancellationToken.None);
|
|
frontmatter.OneLiner = trimmedOneLiner;
|
|
await File.WriteAllTextAsync(
|
|
artifacts.SummaryPath,
|
|
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
|
SummaryWasWritten = true;
|
|
return Path.GetFileName(artifacts.SummaryPath);
|
|
}
|
|
|
|
private static bool ContainsLineBreak(string value)
|
|
{
|
|
return value.Contains('\n', StringComparison.Ordinal) ||
|
|
value.Contains('\r', StringComparison.Ordinal);
|
|
}
|
|
|
|
public bool SummaryWasWritten { get; private set; }
|
|
|
|
public async Task<string> WriteContext(
|
|
string content,
|
|
int? @from = null,
|
|
int? to = null,
|
|
int? insert = null,
|
|
bool replace_file = false)
|
|
{
|
|
var editMode = AgentFileEditMode.Create(@from, to, insert, replace_file);
|
|
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.";
|
|
}
|
|
|
|
if (!File.Exists(artifacts.AssistantContextPath))
|
|
{
|
|
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
|
|
}
|
|
|
|
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
|
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
|
|
var updatedBody = AgentFileToolContent.ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
|
|
var updated = string.IsNullOrWhiteSpace(frontmatter)
|
|
? updatedBody
|
|
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
|
|
|
|
await File.WriteAllTextAsync(artifacts.AssistantContextPath, updated);
|
|
return artifacts.AssistantContextPath;
|
|
}
|
|
|
|
private static string StripAccidentalFrontmatter(string content)
|
|
{
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
return document.HasFrontmatter ? document.Body : content;
|
|
}
|
|
|
|
public async Task<string> ListProjects()
|
|
{
|
|
var projects = await GetBoundProjectsAsync();
|
|
return string.Join('\n', projects.Select(project => project.Name));
|
|
}
|
|
|
|
public async Task<string> ListProjectFiles(string project)
|
|
{
|
|
var projectRoot = await ResolveBoundProjectRootAsync(project);
|
|
if (projectRoot is null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
|
|
.Select(path => AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path)))
|
|
.Order(StringComparer.Ordinal);
|
|
return string.Join('\n', files);
|
|
}
|
|
|
|
public async Task<string> ReadProjectFile(string project, string path, int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
var filePath = await ResolveBoundProjectFilePathAsync(project, path);
|
|
if (filePath is null || !File.Exists(filePath))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), @from, to, tail);
|
|
}
|
|
|
|
public async Task<string> ListPastProjectMeetings(
|
|
string[]? projects = null,
|
|
int page = 1,
|
|
int page_size = 20)
|
|
{
|
|
var scope = await ResolveCurrentMeetingProjectScopeAsync(projects);
|
|
if (scope.Refusal is not null)
|
|
{
|
|
return scope.Refusal;
|
|
}
|
|
|
|
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
|
|
var effectivePageSize = Math.Clamp(page_size, 1, 100);
|
|
var requestedPage = Math.Max(1, page);
|
|
var totalPages = Math.Max(1, (int)Math.Ceiling(summaries.Count / (double)effectivePageSize));
|
|
var effectivePage = Math.Min(requestedPage, totalPages);
|
|
var pageItems = summaries
|
|
.Skip((effectivePage - 1) * effectivePageSize)
|
|
.Take(effectivePageSize)
|
|
.ToList();
|
|
var lines = new List<string>
|
|
{
|
|
$"page {effectivePage}/{totalPages}, page_size {effectivePageSize}, total {summaries.Count}"
|
|
};
|
|
lines.AddRange(pageItems.Select(summary =>
|
|
$"{summary.ToolPath} | {FormatSummaryStartTime(summary.StartTime)} | {summary.Title} | projects: {string.Join(", ", summary.Projects)}"));
|
|
return string.Join('\n', lines);
|
|
}
|
|
|
|
public async Task<string> ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null, int? tail = null)
|
|
{
|
|
var summary = await ResolvePastSummaryAsync(path);
|
|
if (summary.Refusal is not null)
|
|
{
|
|
return summary.Refusal;
|
|
}
|
|
|
|
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to, tail);
|
|
}
|
|
|
|
public async Task<string> WriteProjectFile(
|
|
string project,
|
|
string path,
|
|
string content,
|
|
int? @from = null,
|
|
int? to = null,
|
|
int? insert = null,
|
|
bool replace_file = false)
|
|
{
|
|
var editMode = AgentFileEditMode.Create(@from, to, insert, replace_file);
|
|
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 target = ResolveExistingProjectFilePath(project, path);
|
|
if (target is null)
|
|
{
|
|
return "Refused: project does not exist or the path escapes the project folder.";
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
|
if (writeAudit is not null)
|
|
{
|
|
await writeAudit.CaptureFileWriteAsync(
|
|
target.Path,
|
|
() => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode));
|
|
}
|
|
else
|
|
{
|
|
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
|
|
}
|
|
|
|
return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}";
|
|
}
|
|
|
|
public async Task<string> Search(string keywords, string[]? projects = null, string? file_pattern = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(keywords))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var scope = await ResolveCurrentMeetingProjectScopeAsync(projects);
|
|
if (scope.Refusal is not null)
|
|
{
|
|
return scope.Refusal;
|
|
}
|
|
|
|
var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames);
|
|
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
|
|
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1, file_pattern);
|
|
}
|
|
|
|
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null, int? tail = null)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(path);
|
|
return AgentFileToolContent.ReadLines(content, from, to, tail);
|
|
}
|
|
|
|
private async Task<MeetingNote> ReadMeetingNoteAsync()
|
|
{
|
|
if (!File.Exists(artifacts.MeetingNotePath))
|
|
{
|
|
return new MeetingNote("", new MeetingNoteFrontmatter(), "");
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
var frontmatter = new MeetingNoteFrontmatter();
|
|
if (document.HasFrontmatter)
|
|
{
|
|
var note = YamlDeserializer.Deserialize<MeetingNoteFrontmatterYaml>(document.Frontmatter) ?? new MeetingNoteFrontmatterYaml();
|
|
frontmatter.Title = note.Title ?? "";
|
|
frontmatter.StartTime = ParseDateTime(note.StartTime);
|
|
frontmatter.EndTime = ParseDateTime(note.EndTime);
|
|
frontmatter.Attendees = note.Attendees ?? [];
|
|
frontmatter.Projects = note.Projects ?? [];
|
|
frontmatter.Transcript = note.Transcript ?? "";
|
|
frontmatter.AssistantContext = note.AssistantContext ?? "";
|
|
frontmatter.Summary = note.Summary ?? "";
|
|
}
|
|
|
|
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
|
|
}
|
|
|
|
private async Task WriteMeetingNoteAsync(MeetingNote note)
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
|
await File.WriteAllTextAsync(
|
|
artifacts.MeetingNotePath,
|
|
RenderMeetingNote(note.Frontmatter, note.UserNotes));
|
|
}
|
|
|
|
private static string RenderMeetingNote(MeetingNoteFrontmatter frontmatter, string userNotes)
|
|
{
|
|
var lines = new List<string>
|
|
{
|
|
"---",
|
|
$"title: {EscapeScalar(frontmatter.Title)}",
|
|
$"start_time: {EscapeNullableDateTime(frontmatter.StartTime)}",
|
|
$"end_time: {EscapeNullableDateTime(frontmatter.EndTime)}",
|
|
"attendees:"
|
|
};
|
|
|
|
lines.AddRange(frontmatter.Attendees.Select(attendee => $"- {EscapeListItem(attendee)}"));
|
|
lines.Add("projects:");
|
|
lines.AddRange(frontmatter.Projects.Select(project => $"- {EscapeListItem(project)}"));
|
|
lines.Add($"transcript: {EscapeQuoted(frontmatter.Transcript)}");
|
|
lines.Add($"assistant_context: {EscapeQuoted(frontmatter.AssistantContext)}");
|
|
lines.Add($"summary: {EscapeQuoted(frontmatter.Summary)}");
|
|
lines.Add("---");
|
|
lines.Add("");
|
|
lines.Add(userNotes);
|
|
return string.Join(Environment.NewLine, lines);
|
|
}
|
|
|
|
private static string EscapeScalar(string value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
|
|
}
|
|
|
|
private static string EscapeQuoted(string value)
|
|
{
|
|
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
|
}
|
|
|
|
private static string EscapeNullableDateTime(DateTimeOffset? value)
|
|
{
|
|
return value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O"));
|
|
}
|
|
|
|
private static string EscapeListItem(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return "\"\"";
|
|
}
|
|
|
|
return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)
|
|
? EscapeQuoted(value)
|
|
: value;
|
|
}
|
|
|
|
private async Task<ProjectScope> ResolveCurrentMeetingProjectScopeAsync(string[]? projects)
|
|
{
|
|
var requested = (projects ?? [])
|
|
.Where(project => !string.IsNullOrWhiteSpace(project))
|
|
.Select(project => project.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var currentProjects = await ReadCurrentMeetingProjectNamesAsync();
|
|
if (requested.Count == 0)
|
|
{
|
|
return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
|
}
|
|
|
|
var outOfScope = requested
|
|
.Where(project => !currentProjects.Contains(project))
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
if (outOfScope.Count > 0)
|
|
{
|
|
return new ProjectScope(
|
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
|
$"Refused: project(s) not assigned to current meeting: {string.Join(", ", outOfScope)}.");
|
|
}
|
|
|
|
return new ProjectScope(requested.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private async Task<HashSet<string>> ReadCurrentMeetingProjectNamesAsync()
|
|
{
|
|
return await projectResolver.ReadMeetingProjectNamesAsync(artifacts);
|
|
}
|
|
|
|
private async Task<List<BoundMeetingProject>> GetExistingProjectsInScopeAsync(IReadOnlySet<string> projectNames)
|
|
{
|
|
if (projectNames.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var projectsRoot = GetProjectsRoot();
|
|
if (!Directory.Exists(projectsRoot))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return Directory.EnumerateDirectories(projectsRoot)
|
|
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
|
.Where(project => projectNames.Contains(project.Name))
|
|
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<BoundMeetingProject?> ResolveBoundProjectAsync(string project)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(project))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return (await GetBoundProjectsAsync())
|
|
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private async Task<string?> ResolveBoundProjectRootAsync(string project)
|
|
{
|
|
return (await ResolveBoundProjectAsync(project))?.Path;
|
|
}
|
|
|
|
private async Task<string?> ResolveBoundProjectFilePathAsync(string project, string path)
|
|
{
|
|
var projectRoot = await ResolveBoundProjectRootAsync(project);
|
|
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
|
|
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
|
|
}
|
|
|
|
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(project) ||
|
|
string.IsNullOrWhiteSpace(path) ||
|
|
Path.IsPathRooted(path))
|
|
{
|
|
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));
|
|
if (projectFolder is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path));
|
|
return AgentFileToolContent.IsWithinDirectory(projectFolder.Path, fullPath)
|
|
? new ProjectFileTarget(projectFolder, fullPath)
|
|
: null;
|
|
}
|
|
|
|
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
|
|
{
|
|
return projectResolver.GetBoundProjectsAsync(artifacts);
|
|
}
|
|
|
|
private string GetProjectsRoot()
|
|
{
|
|
return projectResolver.ProjectsRoot;
|
|
}
|
|
|
|
private static string SearchSources(
|
|
IReadOnlyList<BoundMeetingProject> projects,
|
|
IReadOnlyList<PastMeetingSummary> summaries,
|
|
string keywords,
|
|
bool singleProject,
|
|
string? filePattern)
|
|
{
|
|
System.Text.RegularExpressions.Regex regex;
|
|
try
|
|
{
|
|
regex = new System.Text.RegularExpressions.Regex(keywords);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return $"Search failed: invalid .NET regular expression: {ex.Message}";
|
|
}
|
|
|
|
try
|
|
{
|
|
var matches = new List<string>();
|
|
foreach (var project in projects)
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
|
|
{
|
|
var projectRelative = AgentFileToolContent.ToToolPath(Path.GetRelativePath(project.Path, file));
|
|
if (!AgentFileToolContent.MatchesGlob(projectRelative, filePattern))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var relative = Path.Combine(project.Name, projectRelative);
|
|
AddFileMatches(matches, regex, file, FormatSearchPath(relative, singleProject));
|
|
}
|
|
}
|
|
|
|
foreach (var summary in summaries)
|
|
{
|
|
AddFileMatches(matches, regex, summary.Path, $"past-meetings/{summary.ToolPath}");
|
|
}
|
|
|
|
return string.Join('\n', matches);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
return $"Search failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private static void AddFileMatches(
|
|
List<string> matches,
|
|
System.Text.RegularExpressions.Regex regex,
|
|
string file,
|
|
string formattedPath)
|
|
{
|
|
var lines = File.ReadAllLines(file);
|
|
for (var index = 0; index < lines.Length; index++)
|
|
{
|
|
if (regex.IsMatch(lines[index]))
|
|
{
|
|
matches.Add($"{AgentFileToolContent.ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string FormatSearchPath(string path, bool singleProject)
|
|
{
|
|
var formatted = AgentFileToolContent.ToToolPath(path);
|
|
if (!singleProject)
|
|
{
|
|
return formatted;
|
|
}
|
|
|
|
var slashIndex = formatted.IndexOf('/', StringComparison.Ordinal);
|
|
return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..];
|
|
}
|
|
|
|
private async Task<List<PastMeetingSummary>> GetScopedPastSummariesAsync(IReadOnlySet<string> projectNames)
|
|
{
|
|
if (projectNames.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var summariesRoot = GetSummariesRoot();
|
|
if (!Directory.Exists(summariesRoot))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var summaries = new List<PastMeetingSummary>();
|
|
foreach (var file in Directory.EnumerateFiles(summariesRoot, "*.md", SearchOption.TopDirectoryOnly))
|
|
{
|
|
var summary = await TryReadPastSummaryAsync(file, summariesRoot);
|
|
if (summary is null ||
|
|
IsSamePath(summary.Path, artifacts.SummaryPath) ||
|
|
!summary.Projects.Any(projectNames.Contains))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
summaries.Add(summary);
|
|
}
|
|
|
|
return summaries
|
|
.OrderByDescending(summary => summary.StartTime)
|
|
.ThenByDescending(summary => summary.ToolPath, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<PastMeetingSummaryResolution> ResolvePastSummaryAsync(string path)
|
|
{
|
|
path = NormalizePastMeetingSummaryToolPath(path);
|
|
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
|
|
{
|
|
return PastMeetingSummaryResolution.Refused("Refused: summary path must be relative to the configured summaries folder.");
|
|
}
|
|
|
|
var scope = await ResolveCurrentMeetingProjectScopeAsync(null);
|
|
if (scope.Refusal is not null)
|
|
{
|
|
return PastMeetingSummaryResolution.Refused(scope.Refusal);
|
|
}
|
|
|
|
var summariesRoot = GetSummariesRoot();
|
|
var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path));
|
|
if (!AgentFileToolContent.IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
|
|
{
|
|
return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder.");
|
|
}
|
|
|
|
if (IsSamePath(fullPath, artifacts.SummaryPath))
|
|
{
|
|
return PastMeetingSummaryResolution.Refused("Refused: read_past_project_meeting_summary cannot read the current meeting summary; use write_summary only for the current meeting summary.");
|
|
}
|
|
|
|
var summary = await TryReadPastSummaryAsync(fullPath, summariesRoot);
|
|
if (summary is null)
|
|
{
|
|
return PastMeetingSummaryResolution.Refused("Refused: file is not a summary note with project frontmatter.");
|
|
}
|
|
|
|
if (!summary.Projects.Any(scope.ProjectNames.Contains))
|
|
{
|
|
return PastMeetingSummaryResolution.Refused("Refused: summary file is not assigned to a project in the current meeting scope.");
|
|
}
|
|
|
|
return new PastMeetingSummaryResolution(summary);
|
|
}
|
|
|
|
private static string NormalizePastMeetingSummaryToolPath(string path)
|
|
{
|
|
var toolPath = AgentFileToolContent.ToToolPath(path.Trim());
|
|
const string pastMeetingPrefix = "past-meetings/";
|
|
return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase)
|
|
? toolPath[pastMeetingPrefix.Length..]
|
|
: path;
|
|
}
|
|
|
|
private async Task<PastMeetingSummary?> TryReadPastSummaryAsync(string path, string summariesRoot)
|
|
{
|
|
var content = await File.ReadAllTextAsync(path);
|
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
if (!document.HasFrontmatter)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
SummaryFrontmatterYaml frontmatter;
|
|
try
|
|
{
|
|
frontmatter = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter)
|
|
?? new SummaryFrontmatterYaml();
|
|
}
|
|
catch (YamlDotNet.Core.YamlException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var projects = (frontmatter.Projects ?? [])
|
|
.Where(project => !string.IsNullOrWhiteSpace(project))
|
|
.Select(project => project.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
if (projects.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new PastMeetingSummary(
|
|
path,
|
|
AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRoot, path)),
|
|
string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(),
|
|
ParseDateTime(frontmatter.StartTime),
|
|
projects);
|
|
}
|
|
|
|
private string GetSummariesRoot()
|
|
{
|
|
return VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder);
|
|
}
|
|
|
|
private static string FormatSummaryStartTime(DateTimeOffset? startTime)
|
|
{
|
|
return startTime?.ToString("O") ?? "";
|
|
}
|
|
|
|
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
|
|
|
|
private sealed record ProjectScope(IReadOnlySet<string> ProjectNames, string? Refusal = null);
|
|
|
|
private sealed record PastMeetingSummary(
|
|
string Path,
|
|
string ToolPath,
|
|
string Title,
|
|
DateTimeOffset? StartTime,
|
|
IReadOnlyList<string> Projects);
|
|
|
|
private sealed record PastMeetingSummaryResolution(PastMeetingSummary? Summary, string? Refusal = null)
|
|
{
|
|
public static PastMeetingSummaryResolution Refused(string message)
|
|
{
|
|
return new PastMeetingSummaryResolution(null, message);
|
|
}
|
|
}
|
|
|
|
private sealed class MeetingNoteFrontmatterYaml
|
|
{
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
|
public string? Title { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
|
|
public string? StartTime { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "end_time")]
|
|
public string? EndTime { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "attendees")]
|
|
public List<string>? Attendees { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
|
public List<string>? Projects { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "transcript")]
|
|
public string? Transcript { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "assistant_context")]
|
|
public string? AssistantContext { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "summary")]
|
|
public string? Summary { get; set; }
|
|
}
|
|
|
|
private sealed class SummaryFrontmatterYaml
|
|
{
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
|
public string? Title { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
|
|
public string? StartTime { get; set; }
|
|
|
|
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
|
public List<string>? Projects { get; set; }
|
|
}
|
|
|
|
private static DateTimeOffset? ParseDateTime(string? value)
|
|
{
|
|
return DateTimeOffset.TryParse(value, out var parsed)
|
|
? parsed
|
|
: null;
|
|
}
|
|
|
|
private static bool IsSamePath(string first, string second)
|
|
{
|
|
return string.Equals(
|
|
Path.GetFullPath(first),
|
|
Path.GetFullPath(second),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|