Generalize settings and logs assistant
PR and Push Build/Test / build-and-test (push) Successful in 16m43s

This commit is contained in:
2026-05-30 12:57:51 +02:00
parent 740f93f185
commit 250d3b7a1e
32 changed files with 2219 additions and 539 deletions
@@ -10,7 +10,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, project files, and scoped past project meeting summaries.
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important.
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important. write_summary returns the written summary filename so project journal entries can link to it.
write_summary is only for the current meeting's summary file. Past project meeting summaries are read-only historical context; use list_past_project_meetings and read_past_project_meeting_summary to inspect them, and never try to mutate them.
If the meeting note has no title, provide a concise title parameter to write_summary.
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
+42 -224
View File
@@ -35,22 +35,22 @@ public sealed class MeetingSummaryTools
projectResolver = new BoundMeetingProjectResolver(options);
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
public Task<string> ReadTranscript(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to);
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to, tail);
}
public Task<string> ReadMeetingNote(int? @from = null, int? to = null)
public Task<string> ReadMeetingNote(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to);
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to, tail);
}
public Task<string> ReadContext(int? @from = null, int? to = null)
public Task<string> ReadContext(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to);
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to, tail);
}
public async Task<string> ReadUserNotes(int? @from = null, int? to = null)
public async Task<string> ReadUserNotes(int? @from = null, int? to = null, int? tail = null)
{
if (!File.Exists(artifacts.MeetingNotePath))
{
@@ -60,12 +60,12 @@ public sealed class MeetingSummaryTools
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
var body = document.HasFrontmatter ? document.Body.Trim() : content;
return ReadLines(body, @from, to);
return AgentFileToolContent.ReadLines(body, @from, to, tail);
}
public Task<string> ReadGlossary(int? @from = null, int? to = null)
public Task<string> ReadGlossary(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to);
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to, tail);
}
public async Task<string> AddDictationWord(string word)
@@ -232,7 +232,7 @@ public sealed class MeetingSummaryTools
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
SummaryWasWritten = true;
return artifacts.SummaryPath;
return Path.GetFileName(artifacts.SummaryPath);
}
private static bool ContainsLineBreak(string value)
@@ -250,7 +250,7 @@ public sealed class MeetingSummaryTools
int? insert = null,
bool replace_file = false)
{
var editMode = FileLineEditMode.Create(@from, to, insert, replace_file);
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.";
@@ -263,7 +263,7 @@ public sealed class MeetingSummaryTools
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updatedBody = AgentFileToolContent.ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updated = string.IsNullOrWhiteSpace(frontmatter)
? updatedBody
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
@@ -293,12 +293,12 @@ public sealed class MeetingSummaryTools
}
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Select(path => ToToolPath(Path.GetRelativePath(projectRoot, path)))
.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)
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))
@@ -306,7 +306,7 @@ public sealed class MeetingSummaryTools
return "";
}
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), @from, to, tail);
}
public async Task<string> ListPastProjectMeetings(
@@ -338,7 +338,7 @@ public sealed class MeetingSummaryTools
return string.Join('\n', lines);
}
public async Task<string> ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null)
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)
@@ -346,7 +346,7 @@ public sealed class MeetingSummaryTools
return summary.Refusal;
}
return ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to);
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to, tail);
}
public async Task<string> WriteProjectFile(
@@ -358,7 +358,7 @@ public sealed class MeetingSummaryTools
int? insert = null,
bool replace_file = false)
{
var editMode = FileLineEditMode.Create(@from, to, insert, replace_file);
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.";
@@ -375,17 +375,17 @@ public sealed class MeetingSummaryTools
{
await writeAudit.CaptureFileWriteAsync(
target.Path,
() => WriteProjectFileContentAsync(target.Path, content, editMode));
() => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode));
}
else
{
await WriteProjectFileContentAsync(target.Path, content, editMode);
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
}
return $"{target.Project.Name}/{ToToolPath(path)}";
return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}";
}
public async Task<string> Search(string keywords, string[]? projects = null)
public async Task<string> Search(string keywords, string[]? projects = null, string? file_pattern = null)
{
if (string.IsNullOrWhiteSpace(keywords))
{
@@ -400,10 +400,10 @@ public sealed class MeetingSummaryTools
var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames);
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1);
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1, file_pattern);
}
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null, int? tail = null)
{
if (!File.Exists(path))
{
@@ -411,7 +411,7 @@ public sealed class MeetingSummaryTools
}
var content = await File.ReadAllTextAsync(path);
return ReadLines(content, from, to);
return AgentFileToolContent.ReadLines(content, from, to, tail);
}
private async Task<MeetingNote> ReadMeetingNoteAsync()
@@ -575,7 +575,7 @@ public sealed class MeetingSummaryTools
}
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
}
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
@@ -602,147 +602,11 @@ public sealed class MeetingSummaryTools
}
var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path));
return IsWithinDirectory(projectFolder.Path, fullPath)
return AgentFileToolContent.IsWithinDirectory(projectFolder.Path, fullPath)
? new ProjectFileTarget(projectFolder, fullPath)
: null;
}
private static async Task WriteProjectFileContentAsync(
string filePath,
string content,
FileLineEditMode editMode)
{
if (editMode.Kind == FileLineEditKind.Overwrite)
{
await File.WriteAllTextAsync(filePath, content);
return;
}
if (editMode.Kind == FileLineEditKind.Append)
{
var existing = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, AppendContent(existing, content));
return;
}
var lines = File.Exists(filePath)
? (await File.ReadAllLinesAsync(filePath)).ToList()
: [];
var replacementLines = SplitLines(content);
if (editMode.Kind == FileLineEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
return;
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
}
private static List<string> SplitLines(string content)
{
if (content.Length == 0)
{
return [];
}
return content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.ToList();
}
private static string ReadLines(string content, int? from = null, int? to = null)
{
if (!from.HasValue && !to.HasValue)
{
return content;
}
var lines = SplitLines(content);
if (lines.Count == 0)
{
return "";
}
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
if (end < start)
{
return "";
}
return string.Join('\n', lines.GetRange(start, end - start + 1));
}
private static string ApplyLineEdit(
string existingContent,
string replacementContent,
FileLineEditMode editMode)
{
if (editMode.Kind == FileLineEditKind.Overwrite)
{
return replacementContent;
}
if (editMode.Kind == FileLineEditKind.Append)
{
return AppendContent(existingContent, replacementContent);
}
var lines = SplitLines(existingContent);
var replacementLines = SplitLines(replacementContent);
if (editMode.Kind == FileLineEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
return string.Join('\n', lines);
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
return string.Join('\n', lines);
}
private static string AppendContent(string existingContent, string content)
{
if (string.IsNullOrEmpty(existingContent))
{
return content;
}
if (string.IsNullOrEmpty(content))
{
return existingContent;
}
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
? ""
: "\n";
return existingContent + separator + content;
}
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
{
return projectResolver.GetBoundProjectsAsync(artifacts);
@@ -757,7 +621,8 @@ public sealed class MeetingSummaryTools
IReadOnlyList<BoundMeetingProject> projects,
IReadOnlyList<PastMeetingSummary> summaries,
string keywords,
bool singleProject)
bool singleProject,
string? filePattern)
{
System.Text.RegularExpressions.Regex regex;
try
@@ -776,7 +641,13 @@ public sealed class MeetingSummaryTools
{
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
{
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
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));
}
}
@@ -805,14 +676,14 @@ public sealed class MeetingSummaryTools
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
matches.Add($"{AgentFileToolContent.ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
}
}
}
private static string FormatSearchPath(string path, bool singleProject)
{
var formatted = ToToolPath(path);
var formatted = AgentFileToolContent.ToToolPath(path);
if (!singleProject)
{
return formatted;
@@ -822,20 +693,6 @@ public sealed class MeetingSummaryTools
return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..];
}
private static bool IsWithinDirectory(string directory, string path)
{
var normalizedDirectory = Path.GetFullPath(directory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
}
private static string ToToolPath(string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private async Task<List<PastMeetingSummary>> GetScopedPastSummariesAsync(IReadOnlySet<string> projectNames)
{
if (projectNames.Count == 0)
@@ -885,7 +742,7 @@ public sealed class MeetingSummaryTools
var summariesRoot = GetSummariesRoot();
var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path));
if (!IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
if (!AgentFileToolContent.IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
{
return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder.");
}
@@ -911,7 +768,7 @@ public sealed class MeetingSummaryTools
private static string NormalizePastMeetingSummaryToolPath(string path)
{
var toolPath = ToToolPath(path.Trim());
var toolPath = AgentFileToolContent.ToToolPath(path.Trim());
const string pastMeetingPrefix = "past-meetings/";
return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase)
? toolPath[pastMeetingPrefix.Length..]
@@ -950,7 +807,7 @@ public sealed class MeetingSummaryTools
return new PastMeetingSummary(
path,
ToToolPath(Path.GetRelativePath(summariesRoot, path)),
AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRoot, path)),
string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(),
ParseDateTime(frontmatter.StartTime),
projects);
@@ -985,45 +842,6 @@ public sealed class MeetingSummaryTools
}
}
private sealed record FileLineEditMode(
FileLineEditKind Kind,
int? From = null,
int? To = null,
int? Insert = null)
{
public static FileLineEditMode? Create(int? from, int? to, int? insert, bool replaceFile)
{
if (replaceFile && (from.HasValue || to.HasValue || insert.HasValue))
{
return null;
}
if (insert.HasValue)
{
return from.HasValue || to.HasValue
? null
: new FileLineEditMode(FileLineEditKind.Insert, Insert: insert);
}
if (from.HasValue || to.HasValue)
{
return from.HasValue && to.HasValue && from.Value <= to.Value
? new FileLineEditMode(FileLineEditKind.Replace, From: from, To: to)
: null;
}
return new FileLineEditMode(replaceFile ? FileLineEditKind.Overwrite : FileLineEditKind.Append);
}
}
private enum FileLineEditKind
{
Append,
Overwrite,
Replace,
Insert
}
private sealed class MeetingNoteFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
@@ -127,19 +127,19 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadMeetingNote,
"read_meetingnote",
"Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadTranscript,
"read_transcript",
"Read the transcript markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the transcript markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadContext,
"read_context",
"Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadUserNotes,
"read_usernotes",
"Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. With from and to, read that clamped inclusive 1-based body line range."),
"Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
@@ -147,7 +147,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadGlossary,
"read_glossary",
"Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. May be empty."),
"Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. Supports from/to or tail. May be empty."),
AIFunctionFactory.Create(
tools.AddDictationWord,
"add_dictation_word",
@@ -171,7 +171,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
"Write the finished summary markdown to the configured summary note. Provide title when the meeting note has no title."),
"Write the finished summary markdown to the configured summary note and return the written summary filename. Provide title when the meeting note has no title."),
AIFunctionFactory.Create(
tools.ListProjects,
"list_projects",
@@ -183,7 +183,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadProjectFile,
"read_projectfile",
"Read a bound project file, optionally clamping to from and to inclusive line numbers."),
"Read a bound project file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.WriteProjectFile,
"write_projectfile",
@@ -195,11 +195,11 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadPastProjectMeetingSummary,
"read_past_project_meeting_summary",
"Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. With from and to, read that clamped inclusive 1-based line range. Cannot read or write the current meeting summary."),
"Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. Supports from/to or tail. Cannot read or write the current meeting summary."),
AIFunctionFactory.Create(
tools.Search,
"search",
"Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted.")
"Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted. Use file_pattern to limit project files by glob.")
];
}