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
+255
View File
@@ -0,0 +1,255 @@
using System.Text.RegularExpressions;
namespace MeetingAssistant;
internal static class AgentFileToolContent
{
public static string ReadLines(string content, int? from = null, int? to = null, int? tail = null)
{
if (!from.HasValue && !to.HasValue)
{
return tail.HasValue
? TailLines(content, Math.Clamp(tail.Value, 1, 2000))
: 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));
}
public static string TailLines(string content, int count)
{
var lines = SplitLines(content);
if (lines.Count > 0 && lines[^1].Length == 0)
{
lines.RemoveAt(lines.Count - 1);
}
if (lines.Count <= count)
{
return string.Join('\n', lines);
}
return string.Join('\n', lines.GetRange(lines.Count - count, count));
}
public static string ApplyLineEdit(
string existingContent,
string replacementContent,
AgentFileEditMode editMode)
{
if (editMode.Kind == AgentFileEditKind.Overwrite)
{
return replacementContent;
}
if (editMode.Kind == AgentFileEditKind.Append)
{
return AppendContent(existingContent, replacementContent);
}
var lines = SplitLines(existingContent);
var replacementLines = SplitLines(replacementContent);
if (editMode.Kind == AgentFileEditKind.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);
}
public static async Task WriteFileContentAsync(
string filePath,
string content,
AgentFileEditMode editMode)
{
if (editMode.Kind == AgentFileEditKind.Overwrite)
{
await File.WriteAllTextAsync(filePath, content);
return;
}
if (editMode.Kind == AgentFileEditKind.Append)
{
var existing = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, AppendContent(existing, content));
return;
}
var existingContent = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, ApplyLineEdit(existingContent, content, editMode));
}
public 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;
}
public 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();
}
public 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);
}
public static bool MatchesGlob(string path, string? pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return true;
}
var normalizedPath = ToToolPath(path);
var normalizedPattern = ToToolPath(pattern.Trim());
var placeholder = "\u0000";
var regexPattern = Regex.Escape(normalizedPattern)
.Replace("\\*\\*", placeholder, StringComparison.Ordinal)
.Replace("\\*", "[^/]*", StringComparison.Ordinal)
.Replace("\\?", "[^/]", StringComparison.Ordinal)
.Replace(placeholder, ".*", StringComparison.Ordinal);
return Regex.IsMatch(normalizedPath, $"^{regexPattern}$", RegexOptions.IgnoreCase);
}
public static string ToToolPath(string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
public static string SearchFiles(
string keywords,
IEnumerable<AgentFileSearchSource> sources,
int maxMatches = 100,
RegexOptions regexOptions = RegexOptions.IgnoreCase)
{
if (string.IsNullOrWhiteSpace(keywords))
{
return "";
}
try
{
var regex = new Regex(keywords, regexOptions);
var matches = new List<string>();
var limit = Math.Clamp(maxMatches, 1, 1000);
foreach (var source in sources)
{
var lines = File.ReadAllLines(source.Path);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{ToToolPath(source.DisplayPath)}:{index + 1} {lines[index]}");
}
if (matches.Count >= limit)
{
return string.Join('\n', matches);
}
}
}
return string.Join('\n', matches);
}
catch
{
return "";
}
}
}
internal sealed record AgentFileSearchSource(string Path, string DisplayPath);
internal sealed record AgentFileEditMode(
AgentFileEditKind Kind,
int? From = null,
int? To = null,
int? Insert = null)
{
public static AgentFileEditMode? 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 AgentFileEditMode(AgentFileEditKind.Insert, Insert: insert);
}
if (from.HasValue || to.HasValue)
{
return from.HasValue && to.HasValue && from.Value <= to.Value
? new AgentFileEditMode(AgentFileEditKind.Replace, From: from, To: to)
: null;
}
return new AgentFileEditMode(replaceFile ? AgentFileEditKind.Overwrite : AgentFileEditKind.Append);
}
}
internal enum AgentFileEditKind
{
Append,
Overwrite,
Replace,
Insert
}
@@ -0,0 +1,74 @@
The project is split in 3 important files:
PROJECT.md
DECISIONS.md
JOURNAL.md
The idea is a hierarchical summary:
Meeting Summary -> detailed (what was discussed)
then Journal Entry -> compressed (why should I care this event happened?)
then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.)
The journal should always be appended and read using tail or searched.
Example journal entry:
```markdown
## 2026-05-30
### Team Daily
New Blocker: HLS delayed
Impact: Release likely delayed, team implements Z-Levels in the meantime.
[[20260529-093030-6187486-summary|Summary]]
### Refinement
Calendar-Feature is refined, Golem feature needs more work.
[[20260529-103030-3465634-summary|Summary]]
```
PROJECT.md should use this structure:
```markdown
# Executive Summary
<Elevator Pitch>
## Business Goals
## Priorities and High Level Constraints
## Current Phase & Status
## Next Milestones
## Open Risks
## Important Stakeholders
## Key Documents
Links to: Architecture Overview, Technical Onboarding, etc.
[[JOURNAL.md]]
[[DECISIONS.md]]
```
DECISIONS.md logs decisions in the same compressed way that JOURNAL.md logs meetings, with links to ADRs and/or meeting summaries for details.
Only include important decisions: architecture decisions that are expensive to change, and project decisions about timeline, goals, governance, team, or process.
Do not log small implementation details such as how UI elements are aligned.
Example decision entries:
```markdown
## How do we handle project delays?
Communicate to <Stakeholder> first, let them decide further actions.
[[20260529-093030-6187486-summary|Source]]
## How will authentication be handled?
Company SSO with provided UI package.
[[ADR-0027-use-sso-and-ui-package-for-auth|Source]]
```
@@ -0,0 +1,192 @@
using System.Collections.Concurrent;
using System.Text;
namespace MeetingAssistant.Logging;
public static class MeetingAssistantLogFiles
{
public const string CurrentLogFileName = "meeting-assistant.log";
public const int RetainedRotatedLogFiles = 4;
public static string DefaultLogDirectory =>
Path.Combine(Path.GetTempPath(), "MeetingAssistant", "Logs");
public static string CurrentLogPath(string? directory = null)
{
return Path.Combine(directory ?? DefaultLogDirectory, CurrentLogFileName);
}
public static string GetRotatedLogFileName(int index)
{
return $"{CurrentLogFileName}.{index}";
}
public static IReadOnlyList<string> EnumerateLogPaths(string? directory = null)
{
var root = directory ?? DefaultLogDirectory;
return Enumerable.Range(0, RetainedRotatedLogFiles + 1)
.Select(index => index == 0
? Path.Combine(root, CurrentLogFileName)
: Path.Combine(root, GetRotatedLogFileName(index)))
.Where(File.Exists)
.ToArray();
}
public static void Rotate(string? directory = null)
{
var root = directory ?? DefaultLogDirectory;
Directory.CreateDirectory(root);
try
{
var oldest = Path.Combine(root, GetRotatedLogFileName(RetainedRotatedLogFiles));
if (File.Exists(oldest))
{
File.Delete(oldest);
}
for (var index = RetainedRotatedLogFiles - 1; index >= 1; index--)
{
var source = Path.Combine(root, GetRotatedLogFileName(index));
if (!File.Exists(source))
{
continue;
}
var target = Path.Combine(root, GetRotatedLogFileName(index + 1));
File.Move(source, target, overwrite: true);
}
var current = Path.Combine(root, CurrentLogFileName);
if (File.Exists(current))
{
File.Move(current, Path.Combine(root, GetRotatedLogFileName(1)), overwrite: true);
}
}
catch (IOException)
{
// Another app instance or test host may still have the log open.
// Logging should keep working, so append to the existing current log.
}
}
}
public sealed class MeetingAssistantFileLoggerProvider : ILoggerProvider
{
private readonly ConcurrentDictionary<string, MeetingAssistantFileLogger> loggers = new(StringComparer.Ordinal);
private readonly object sync = new();
private readonly StreamWriter writer;
private bool disposed;
public MeetingAssistantFileLoggerProvider(string? directory = null)
{
var logDirectory = directory ?? MeetingAssistantLogFiles.DefaultLogDirectory;
MeetingAssistantLogFiles.Rotate(logDirectory);
var stream = new FileStream(
MeetingAssistantLogFiles.CurrentLogPath(logDirectory),
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite);
writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true
};
}
public ILogger CreateLogger(string categoryName)
{
return loggers.GetOrAdd(categoryName, name => new MeetingAssistantFileLogger(name, Write));
}
public void Dispose()
{
lock (sync)
{
if (disposed)
{
return;
}
disposed = true;
writer.Dispose();
}
}
private void Write(
string categoryName,
LogLevel logLevel,
EventId eventId,
string message,
Exception? exception)
{
if (disposed)
{
return;
}
lock (sync)
{
if (disposed)
{
return;
}
writer.Write(DateTimeOffset.Now.ToString("O"));
writer.Write(' ');
writer.Write(logLevel);
writer.Write(" [");
writer.Write(categoryName);
writer.Write(']');
if (eventId.Id != 0 || !string.IsNullOrWhiteSpace(eventId.Name))
{
writer.Write(" (");
writer.Write(eventId.Id);
if (!string.IsNullOrWhiteSpace(eventId.Name))
{
writer.Write(':');
writer.Write(eventId.Name);
}
writer.Write(')');
}
writer.Write(' ');
writer.WriteLine(message);
if (exception is not null)
{
writer.WriteLine(exception);
}
}
}
private sealed class MeetingAssistantFileLogger(
string categoryName,
Action<string, LogLevel, EventId, string, Exception?> write) : ILogger
{
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return logLevel != LogLevel.None;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
write(categoryName, logLevel, eventId, formatter(state, exception), exception);
}
}
}
+4 -1
View File
@@ -41,7 +41,10 @@
<ItemGroup>
<Content Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="%(Filename)%(Extension)" />
<None Update="Assets\meeting-assistant.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\docs\meeting-assistant-configuration.md" Link="docs\meeting-assistant-configuration.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="Content\Project-AGENTS.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<None Include="..\openspec\specs\**\*.md" Link="openspec\specs\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
@@ -120,6 +120,8 @@ public sealed class RecordingOptions
public TimeSpan StopProcessingTimeout { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan MinimumCompletedMeetingDuration { get; set; } = TimeSpan.Zero;
public TimeSpan MetadataLookupTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan BackgroundMetadataLookupTimeout { get; set; } = TimeSpan.FromMinutes(1);
+8
View File
@@ -1,6 +1,7 @@
using MeetingAssistant;
using MeetingAssistant.Hotkeys;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Logging;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Screenshots;
@@ -13,6 +14,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddProvider(new MeetingAssistantFileLoggerProvider());
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
#if WINDOWS
@@ -232,6 +234,12 @@ app.MapPost("/diagnostics/workflow/rules-editor/show", (
rulesEditorWindow.Show();
return Results.Accepted();
});
app.MapPost("/diagnostics/settings-and-logs/show", (
IWorkflowRulesEditorWindowService rulesEditorWindow) =>
{
rulesEditorWindow.Show();
return Results.Accepted();
});
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
{
@@ -203,6 +203,7 @@ public sealed class MeetingRecordingCoordinator
pipeline,
pipelineOptions,
runOptions,
startedAt,
launchProfile.Name,
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
@@ -232,6 +233,7 @@ public sealed class MeetingRecordingCoordinator
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
RecordingRun run;
var abortAsTooShort = false;
await gate.WaitAsync(cancellationToken);
try
@@ -241,14 +243,29 @@ public sealed class MeetingRecordingCoordinator
return CurrentStatus;
}
currentRun.StopCapture();
run = currentRun;
abortAsTooShort = ShouldAbortRunOnStop(run, DateTimeOffset.Now);
if (abortAsTooShort)
{
run.Abort();
}
else
{
run.StopCapture();
}
}
finally
{
gate.Release();
}
if (abortAsTooShort)
{
await CompleteAbortedRunAsync(run, cancellationToken);
logger.LogInformation("Meeting recording was shorter than the configured minimum and was aborted");
return CurrentStatus;
}
try
{
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
@@ -271,7 +288,6 @@ public sealed class MeetingRecordingCoordinator
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
{
RecordingRun run;
MeetingSessionArtifacts artifacts;
await gate.WaitAsync(cancellationToken);
try
@@ -282,7 +298,6 @@ public sealed class MeetingRecordingCoordinator
}
run = currentRun;
artifacts = run.Artifacts;
run.Abort();
}
finally
@@ -290,6 +305,13 @@ public sealed class MeetingRecordingCoordinator
gate.Release();
}
await CompleteAbortedRunAsync(run, cancellationToken);
logger.LogInformation("Meeting recording aborted and artifacts deleted");
return CurrentStatus;
}
private async Task CompleteAbortedRunAsync(RecordingRun run, CancellationToken cancellationToken)
{
try
{
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
@@ -302,12 +324,10 @@ public sealed class MeetingRecordingCoordinator
}
await screenshotService.CancelPendingOcrAsync(
artifacts,
run.Artifacts,
run.Options.Screenshots.Ocr.GetEffectiveTimeout(),
cancellationToken);
await artifactCleaner.DeleteRunArtifactsAsync(artifacts, run.Options, cancellationToken);
logger.LogInformation("Meeting recording aborted and artifacts deleted");
return CurrentStatus;
await artifactCleaner.DeleteRunArtifactsAsync(run.Artifacts, run.Options, cancellationToken);
}
public Task<MeetingScreenshotCaptureResult> CaptureScreenshotAsync(CancellationToken cancellationToken)
@@ -1265,7 +1285,13 @@ public sealed class MeetingRecordingCoordinator
string artifactName)
{
var folder = VaultPath.Resolve(vault, configuredFolder);
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss-fffffff}-{artifactName}.md");
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmm}-{artifactName}.md");
}
private static bool ShouldAbortRunOnStop(RecordingRun run, DateTimeOffset stoppedAt)
{
var minimumDuration = run.Options.Recording.MinimumCompletedMeetingDuration;
return minimumDuration > TimeSpan.Zero && stoppedAt - run.StartedAt < minimumDuration;
}
private async Task CompleteRunAsync(RecordingRun run)
@@ -1318,6 +1344,7 @@ public sealed class MeetingRecordingCoordinator
ISpeechRecognitionPipeline pipeline,
SpeechRecognitionPipelineOptions pipelineOptions,
MeetingAssistantOptions options,
DateTimeOffset startedAt,
string launchProfileName,
TimeSpan liveSampleBufferDuration,
int maxSpeakerSamples)
@@ -1332,6 +1359,7 @@ public sealed class MeetingRecordingCoordinator
Pipeline = pipeline;
PipelineOptions = pipelineOptions;
Options = options;
StartedAt = startedAt;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(
@@ -1363,6 +1391,8 @@ public sealed class MeetingRecordingCoordinator
public MeetingAssistantOptions Options { get; private set; }
public DateTimeOffset StartedAt { get; }
public string LaunchProfileName { get; private set; }
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
+39
View File
@@ -0,0 +1,39 @@
namespace MeetingAssistant;
internal static class RuntimeContentLocator
{
public static IEnumerable<string> CandidateDocumentationPaths(string fileName)
{
foreach (var directory in EnumerateBaseDirectoryAndParents())
{
yield return Path.Combine(directory, "docs", fileName);
}
}
public static IEnumerable<string> CandidateDirectoryPaths(params string[] segments)
{
foreach (var directory in EnumerateBaseDirectoryAndParents())
{
yield return Path.Combine([directory, .. segments]);
}
}
public static IEnumerable<string> CandidateContentPaths(string fileName)
{
foreach (var directory in EnumerateBaseDirectoryAndParents())
{
yield return Path.Combine(directory, "MeetingAssistant", "Content", fileName);
yield return Path.Combine(directory, "Content", fileName);
}
}
private static IEnumerable<string> EnumerateBaseDirectoryAndParents(int maxParentDepth = 8)
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
for (var i = 0; i < maxParentDepth && current is not null; i++)
{
yield return current.FullName;
current = current.Parent;
}
}
}
@@ -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.")
];
}
@@ -30,7 +30,7 @@ public static class MeetingTaskbarMenuBuilder
{
var items = new List<MeetingTaskbarMenuItem>
{
new("Edit rules and identities", MeetingTaskbarAction.EditRules)
new("Settings and logs", MeetingTaskbarAction.EditRules)
};
if (status.IsRecording)
@@ -92,7 +92,7 @@ internal sealed class WorkflowRulesEditorMewWindow
input
.Height(92)
.Wrap(true)
.Placeholder("Ask to make a rule or to list identities")
.Placeholder("ask me what I can do for you")
.OnTextChanged(text => viewModel.Draft = text)
.OnKeyDown(args =>
{
@@ -108,7 +108,7 @@ internal sealed class WorkflowRulesEditorMewWindow
.OnClick(() => _ = SendAsync());
var window = new Window()
.Title("Edit rules and identities")
.Title("Settings and logs")
.Resizable(680, 760, 520, 460)
.Padding(0)
.OnLoaded(Render)
@@ -120,7 +120,7 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
AIFunctionFactory.Create(
tools.ReadRules,
"read_rules",
"Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."),
AIFunctionFactory.Create(
tools.WriteRules,
"write_rules",
@@ -128,7 +128,59 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
AIFunctionFactory.Create(
tools.Search,
"search",
"Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file."),
"Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file and supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.ReadConfig,
"read_config",
"Read the local appsettings JSON configuration file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."),
AIFunctionFactory.Create(
tools.WriteConfig,
"write_config",
"Replace the local appsettings JSON configuration file after validating that the supplied content is valid JSON. Configuration stores environment variable names, not secret values."),
AIFunctionFactory.Create(
tools.ReadConfigDocs,
"read_config_docs",
"Read Meeting Assistant configuration documentation from docs/meeting-assistant-configuration.md. Supports from/to or tail. Use this before explaining or changing unfamiliar configuration settings."),
AIFunctionFactory.Create(
tools.ReadLogs,
"read_logs",
"Read the application-owned log files from the temp log folder. Defaults to tailing the current log. Set logFile to meeting-assistant.log.1 through .4 for rotated files, or supply from/to for a clamped 1-based line range."),
AIFunctionFactory.Create(
tools.SearchLogs,
"search_logs",
"Search current and rotated application-owned log files with .NET regular expression syntax. Supports an optional file_pattern glob and returns filename:line text matches."),
AIFunctionFactory.Create(
tools.SearchSpec,
"search_spec",
"Search copied OpenSpec specification markdown files with .NET regular expression syntax. Use file_pattern to limit searched files by glob. Returns relative/path.md:line text matches scoped to openspec/specs."),
AIFunctionFactory.Create(
tools.ReadSpecFile,
"read_spec_file",
"Read one copied OpenSpec specification markdown file by path relative to openspec/specs. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.CreateProject,
"create_project",
"Create a new project folder under the configured projects folder. Use seed=recommended for PROJECT.md, JOURNAL.md, DECISIONS.md, and AGENTS.md from Project-AGENTS.md; seed=agents_md with agents_md to write AGENTS.md directly; or seed=none for an empty folder."),
AIFunctionFactory.Create(
tools.ListProjects,
"list_projects",
"List all existing project folders under the configured projects folder."),
AIFunctionFactory.Create(
tools.ListProjectFiles,
"list_projectfiles",
"List files inside an existing project folder."),
AIFunctionFactory.Create(
tools.ReadProjectFile,
"read_projectfile",
"Read a file inside an existing project folder. With no line arguments, read the whole file. With from/to, read that clamped inclusive 1-based line range. With tail, read the last lines."),
AIFunctionFactory.Create(
tools.WriteProjectFile,
"write_projectfile",
"Create or update a file inside an existing project folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
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.SearchIdentities,
"search_identities",
@@ -10,14 +10,20 @@ public interface IWorkflowRulesEditorInstructionBuilder
public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditorInstructionBuilder
{
private const string DefaultPrompt = """
You are the Meeting Assistant workflow rules and identities editor.
You are the Meeting Assistant settings and logs assistant.
Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant.
Your purpose is to help operate Meeting Assistant locally: edit the configured local workflow rules YAML file, manage the local speaker identity database, inspect and update appsettings configuration, and read application logs.
Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files.
Read the existing rules before making changes unless the user explicitly asks to replace the whole file.
write_rules appends by default. Pass replace_file=true only when you intentionally replace the complete rules file.
Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal.
When writing rules, prefer existing rule style and names.
Use read_config_docs before explaining or changing configuration settings unless the setting is already clear from the current conversation.
Use read_config before write_config. write_config replaces the complete appsettings JSON file and must preserve valid JSON. The appsettings file stores environment variable names and local paths, not secret values.
Use read_logs and search_logs to inspect application behavior. Prefer search_logs for known errors or keywords and read_logs tailing for recent startup or runtime context.
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 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.
@@ -45,6 +51,10 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
return configuredPrompt.Trim() + Environment.NewLine + Environment.NewLine +
$"Configured workflow rules file: {rulesPath}" + Environment.NewLine + Environment.NewLine +
"Configuration tools can read and replace the local appsettings JSON file. Use read_config_docs for the configuration reference." + Environment.NewLine + Environment.NewLine +
"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 +
"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 +
@@ -54,7 +64,7 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
private async Task<string> ReadWorkflowDocsAsync(CancellationToken cancellationToken)
{
foreach (var path in CandidateDocumentationPaths())
foreach (var path in RuntimeContentLocator.CandidateDocumentationPaths("meeting-workflow-engine.md"))
{
if (File.Exists(path))
{
@@ -66,16 +76,4 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
return "Workflow documentation file docs/meeting-workflow-engine.md was not available at runtime.";
}
private static IEnumerable<string> CandidateDocumentationPaths()
{
var baseDirectory = AppContext.BaseDirectory;
yield return Path.Combine(baseDirectory, "docs", "meeting-workflow-engine.md");
var current = new DirectoryInfo(baseDirectory);
for (var i = 0; i < 8 && current is not null; i++)
{
yield return Path.Combine(current.FullName, "docs", "meeting-workflow-engine.md");
current = current.Parent;
}
}
}
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using MeetingAssistant.Logging;
using MeetingAssistant.Speakers;
using Microsoft.EntityFrameworkCore;
using YamlDotNet.Core;
@@ -21,6 +22,12 @@ public sealed class WorkflowRulesEditorTools
};
private readonly string? rulesPath;
private readonly string configPath;
private readonly string configDocsPath;
private readonly string logDirectory;
private readonly string specRootPath;
private readonly string projectsRootPath;
private readonly string projectAgentsTemplatePath;
private readonly SpeakerIdentificationOptions speakerOptions;
private readonly IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
@@ -28,15 +35,26 @@ public sealed class WorkflowRulesEditorTools
public WorkflowRulesEditorTools(
MeetingAssistantOptions options,
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null,
string? configPath = null,
string? configDocsPath = null,
string? logDirectory = null,
string? specRootPath = null,
string? projectAgentsTemplatePath = null)
{
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
this.configPath = ResolveConfigPath(configPath);
this.configDocsPath = ResolveConfigDocsPath(configDocsPath);
this.logDirectory = logDirectory ?? MeetingAssistantLogFiles.DefaultLogDirectory;
this.specRootPath = ResolveSpecRootPath(specRootPath);
projectsRootPath = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
this.projectAgentsTemplatePath = ResolveProjectAgentsTemplatePath(projectAgentsTemplatePath);
speakerOptions = options.SpeakerIdentification;
this.dbContextFactory = dbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
}
public async Task<string> ReadRules(int? from = null, int? to = null)
public async Task<string> ReadRules(int? from = null, int? to = null, int? tail = null)
{
if (rulesPath is null)
{
@@ -48,7 +66,7 @@ public sealed class WorkflowRulesEditorTools
return "";
}
return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to);
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(rulesPath), from, to, tail);
}
public async Task<string> WriteRules(string yaml, bool replace_file = false)
@@ -68,7 +86,7 @@ public sealed class WorkflowRulesEditorTools
: "";
var updated = replace_file
? yaml
: AppendContent(existing, yaml);
: AgentFileToolContent.AppendContent(existing, yaml);
var validation = ValidateYaml(updated);
if (validation is not null)
{
@@ -80,19 +98,259 @@ public sealed class WorkflowRulesEditorTools
return rulesPath;
}
public async Task<string> Search(string keywords)
public async Task<string> Search(string keywords, string? file_pattern = null)
{
if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath))
{
return "";
}
if (!AgentFileToolContent.MatchesGlob(Path.GetFileName(rulesPath), file_pattern))
{
return "";
}
var rgResult = await RunRipgrepAsync(rulesPath, keywords);
return rgResult is not null
? FormatRipgrepJson(rgResult)
: SearchWithRegexFallback(rulesPath, keywords);
}
public async Task<string> ReadConfig(int? from = null, int? to = null, int? tail = null)
{
if (!File.Exists(configPath))
{
return $"Configuration file was not found: {configPath}";
}
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configPath), from, to, tail);
}
public async Task<string> WriteConfig(string json)
{
if (json is null)
{
return "Refused: json must not be null.";
}
var validation = ValidateJson(json);
if (validation is not null)
{
return validation;
}
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
await File.WriteAllTextAsync(configPath, json);
return configPath;
}
public async Task<string> ReadConfigDocs(int? from = null, int? to = null, int? tail = null)
{
if (!File.Exists(configDocsPath))
{
return "Configuration documentation file docs/meeting-assistant-configuration.md was not available at runtime.";
}
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(configDocsPath), from, to, tail);
}
public async Task<string> ReadLogs(
string? logFile = null,
int? from = null,
int? to = null,
int tail = 200)
{
var path = ResolveLogFilePath(logFile);
if (path is null)
{
return $"Log file was not found: {logFile ?? MeetingAssistantLogFiles.CurrentLogFileName}";
}
var content = await File.ReadAllTextAsync(path);
if (from.HasValue || to.HasValue)
{
return AgentFileToolContent.ReadLines(content, from, to);
}
return AgentFileToolContent.TailLines(content, Math.Clamp(tail, 1, 2000));
}
public Task<string> SearchLogs(string keywords, int maxMatches = 100, string? file_pattern = null)
{
var sources = MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory)
.Where(path => AgentFileToolContent.MatchesGlob(Path.GetFileName(path), file_pattern))
.Select(path => new AgentFileSearchSource(path, Path.GetFileName(path)));
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
}
public async Task<string> ReadSpecFile(string path, int? from = null, int? to = null, int? tail = null)
{
var resolved = ResolveSpecFilePath(path);
if (resolved.Error is not null)
{
return resolved.Error;
}
if (!File.Exists(resolved.Path))
{
return $"Spec file was not found: {AgentFileToolContent.ToToolPath(path)}";
}
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(resolved.Path), from, to, tail);
}
public Task<string> SearchSpec(string keywords, int maxMatches = 100, string? file_pattern = null)
{
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(specRootPath))
{
return Task.FromResult("");
}
var sources = Directory.EnumerateFiles(specRootPath, "*.md", SearchOption.AllDirectories)
.Order(StringComparer.OrdinalIgnoreCase)
.Select(path => new AgentFileSearchSource(
path,
AgentFileToolContent.ToToolPath(Path.GetRelativePath(specRootPath, path))))
.Where(source => AgentFileToolContent.MatchesGlob(source.DisplayPath, file_pattern));
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
}
public Task<string> ListProjects()
{
if (!Directory.Exists(projectsRootPath))
{
return Task.FromResult("");
}
var projects = Directory.EnumerateDirectories(projectsRootPath)
.Select(Path.GetFileName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Order(StringComparer.OrdinalIgnoreCase);
return Task.FromResult(string.Join('\n', projects));
}
public Task<string> ListProjectFiles(string project)
{
var projectRoot = ResolveExistingProjectRoot(project);
if (projectRoot is null)
{
return Task.FromResult("Refused: project does not exist.");
}
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Select(path => AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path)))
.Order(StringComparer.OrdinalIgnoreCase);
return Task.FromResult(string.Join('\n', files));
}
public async Task<string> ReadProjectFile(
string project,
string path,
int? from = null,
int? to = null,
int? tail = null)
{
var filePath = ResolveExistingProjectFilePath(project, path);
if (filePath is null)
{
return "Refused: project does not exist or the path escapes the project folder.";
}
if (!File.Exists(filePath))
{
return $"Project file was not found: {project}/{AgentFileToolContent.ToToolPath(path)}";
}
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), 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 = ResolveExistingProjectFileTarget(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)!);
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
return $"{target.ProjectName}/{AgentFileToolContent.ToToolPath(path)}";
}
public Task<string> SearchProjects(string keywords, int maxMatches = 100, string? file_pattern = null)
{
if (string.IsNullOrWhiteSpace(keywords) || !Directory.Exists(projectsRootPath))
{
return Task.FromResult("");
}
var sources = Directory.EnumerateDirectories(projectsRootPath)
.Order(StringComparer.OrdinalIgnoreCase)
.SelectMany(projectRoot =>
{
var projectName = Path.GetFileName(projectRoot);
return Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Order(StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
var relativePath = AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path));
return new AgentFileSearchSource(path, $"{projectName}/{relativePath}");
})
.Where(source => AgentFileToolContent.MatchesGlob(
source.DisplayPath[(source.DisplayPath.IndexOf('/', StringComparison.Ordinal) + 1)..],
file_pattern));
});
return Task.FromResult(AgentFileToolContent.SearchFiles(keywords, sources, maxMatches));
}
public async Task<string> CreateProject(string name, string seed = "recommended", string? agents_md = null)
{
var projectRoot = ResolveNewProjectRoot(name);
if (projectRoot.Error is not null)
{
return projectRoot.Error;
}
var normalizedSeed = string.IsNullOrWhiteSpace(seed) ? "recommended" : seed.Trim().ToLowerInvariant();
if (normalizedSeed is not ("recommended" or "agents_md" or "none"))
{
return "Refused: seed must be recommended, agents_md, or none.";
}
if (normalizedSeed == "agents_md" && string.IsNullOrWhiteSpace(agents_md))
{
return "Refused: agents_md seed requires AGENTS.md content.";
}
Directory.CreateDirectory(projectRoot.Path!);
switch (normalizedSeed)
{
case "recommended":
await WriteRecommendedProjectSeedAsync(projectRoot.Path!, projectRoot.Name!);
break;
case "agents_md":
await File.WriteAllTextAsync(Path.Combine(projectRoot.Path!, "AGENTS.md"), agents_md!);
break;
case "none":
break;
}
return projectRoot.Name!;
}
public async Task<string> SearchIdentities(string? query = null, int limit = 25)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
@@ -365,22 +623,115 @@ public sealed class WorkflowRulesEditorTools
}
}
private static string AppendContent(string existingContent, string content)
private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName)
{
if (string.IsNullOrEmpty(existingContent))
var agentsContent = File.Exists(projectAgentsTemplatePath)
? await File.ReadAllTextAsync(projectAgentsTemplatePath)
: DefaultProjectAgentsContent;
await File.WriteAllTextAsync(Path.Combine(projectRoot, "AGENTS.md"), agentsContent);
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), CreateDefaultProjectFile(projectName));
await File.WriteAllTextAsync(Path.Combine(projectRoot, "JOURNAL.md"), CreateDefaultJournalFile(projectName));
await File.WriteAllTextAsync(Path.Combine(projectRoot, "DECISIONS.md"), CreateDefaultDecisionsFile());
}
private static string CreateDefaultProjectFile(string projectName)
{
return $"""
# Executive Summary
Project knowledge base for {projectName}.
## Business Goals
## Priorities and High Level Constraints
## Current Phase & Status
## Next Milestones
## Open Risks
## Important Stakeholders
## Key Documents
Links to: Architecture Overview, Technical Onboarding, etc.
[[JOURNAL.md]]
[[DECISIONS.md]]
""";
}
private static string CreateDefaultJournalFile(string projectName)
{
return $"""
## {DateTimeOffset.Now:yyyy-MM-dd}
### Project Setup
The project folder was created for {projectName}.
""";
}
private static string CreateDefaultDecisionsFile()
{
return """
# Decisions
Important decisions belong here when they are expensive to change or materially affect timeline, goals, governance, team, or process.
## How are decisions recorded?
Important decisions are summarized with the question, the decision, and a source link to an ADR or meeting summary.
""";
}
private const string DefaultProjectAgentsContent = """
The project is split in 3 important files:
PROJECT.md
DECISIONS.md
JOURNAL.md
The idea is a hierarchical summary:
Meeting Summary -> detailed (what was discussed)
then Journal Entry -> compressed (why should I care this event happened?)
then PROJECT.md -> distilled (what do I absolutely have to know about this project EVERY TIME I work on it. This is an onboarding document that every agent should read.)
The journal should always be appended and read using tail or searched.
Journal entries use this shape:
```markdown
## 2026-05-30
### Team Daily
New Blocker: HLS delayed
Impact: Release likely delayed, team implements Z-Levels in the meantime.
[[20260529-093030-6187486-summary|Summary]]
```
DECISIONS.md logs only important decisions in a compressed way, with a link to ADRs and/or meeting summaries for details.
Important decisions are architecture decisions that are expensive to change, or project decisions about timeline, goals, governance, team, or process.
Do not log small implementation details such as how UI elements are aligned.
""";
private static string? ValidateJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return content;
return "Refused: appsettings JSON must not be blank.";
}
if (string.IsNullOrEmpty(content))
try
{
return existingContent;
using var _ = JsonDocument.Parse(json);
return null;
}
catch (JsonException exception)
{
return $"Refused: appsettings JSON is invalid. {exception.Message}";
}
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
? ""
: "\n";
return existingContent + separator + content;
}
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
@@ -438,7 +789,7 @@ public sealed class WorkflowRulesEditorTools
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
var lineNumber = data.GetProperty("line_number").GetInt32();
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
matches.Add($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
matches.Add($"{AgentFileToolContent.ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
}
return string.Join('\n', matches);
@@ -467,37 +818,175 @@ public sealed class WorkflowRulesEditorTools
}
}
private static string ReadLines(string content, int? from = null, int? to = null)
private ProjectFileTarget? ResolveExistingProjectFileTarget(string project, string path)
{
if (!from.HasValue && !to.HasValue)
var projectRoot = ResolveExistingProjectRoot(project);
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
{
return content;
return null;
}
var lines = content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.ToList();
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));
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath)
? new ProjectFileTarget(Path.GetFileName(projectRoot), projectRoot, fullPath)
: null;
}
private static string ToToolPath(string path)
private string? ResolveExistingProjectFilePath(string project, string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
return ResolveExistingProjectFileTarget(project, path)?.Path;
}
private string? ResolveExistingProjectRoot(string project)
{
if (string.IsNullOrWhiteSpace(project) || !Directory.Exists(projectsRootPath))
{
return null;
}
return Directory.EnumerateDirectories(projectsRootPath)
.FirstOrDefault(path => string.Equals(Path.GetFileName(path), project.Trim(), StringComparison.OrdinalIgnoreCase));
}
private (string? Path, string? Name, string? Error) ResolveNewProjectRoot(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return (null, null, "Refused: project name must not be blank.");
}
var projectName = name.Trim();
if (projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
projectName.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
projectName.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
{
return (null, null, "Refused: project name must be a single folder name.");
}
var root = Path.GetFullPath(projectsRootPath);
var candidate = Path.GetFullPath(Path.Combine(root, projectName));
if (!AgentFileToolContent.IsWithinDirectory(root, candidate))
{
return (null, null, $"Refused: project path must stay inside {root}.");
}
if (Directory.Exists(candidate))
{
return (null, null, $"Refused: project already exists: {projectName}.");
}
return (candidate, projectName, null);
}
private string? ResolveLogFilePath(string? logFile)
{
var requestedFileName = string.IsNullOrWhiteSpace(logFile)
? MeetingAssistantLogFiles.CurrentLogFileName
: Path.GetFileName(logFile.Trim());
return MeetingAssistantLogFiles.EnumerateLogPaths(logDirectory)
.SingleOrDefault(path => string.Equals(
Path.GetFileName(path),
requestedFileName,
StringComparison.OrdinalIgnoreCase));
}
private static string ResolveConfigPath(string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
var basePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (File.Exists(basePath))
{
return basePath;
}
return Path.GetFullPath("appsettings.json");
}
private static string ResolveConfigDocsPath(string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
foreach (var path in RuntimeContentLocator.CandidateDocumentationPaths("meeting-assistant-configuration.md"))
{
if (File.Exists(path))
{
return path;
}
}
return Path.Combine(AppContext.BaseDirectory, "docs", "meeting-assistant-configuration.md");
}
private static string ResolveSpecRootPath(string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
foreach (var path in RuntimeContentLocator.CandidateDirectoryPaths("openspec", "specs"))
{
if (Directory.Exists(path))
{
return path;
}
}
return Path.Combine(AppContext.BaseDirectory, "openspec", "specs");
}
private static string ResolveProjectAgentsTemplatePath(string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
foreach (var path in RuntimeContentLocator.CandidateContentPaths("Project-AGENTS.md"))
{
if (File.Exists(path))
{
return path;
}
}
return Path.Combine(AppContext.BaseDirectory, "Content", "Project-AGENTS.md");
}
private (string? Path, string? Error) ResolveSpecFilePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return (null, "Refused: spec path must not be blank.");
}
var root = Path.GetFullPath(specRootPath);
var candidate = Path.GetFullPath(Path.Combine(
root,
path.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar)));
var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(candidate, root, StringComparison.OrdinalIgnoreCase))
{
return (null, $"Refused: spec path must stay inside {root}.");
}
if (!candidate.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
return (null, "Refused: spec path must point to a markdown spec file.");
}
return (candidate, null);
}
private async Task<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
@@ -631,4 +1120,7 @@ public sealed class WorkflowRulesEditorTools
DateTimeOffset CreatedAt,
int ByteCount,
string Base64Wav);
private sealed record ProjectFileTarget(string ProjectName, string ProjectRoot, string Path);
}
+1
View File
@@ -20,6 +20,7 @@
"MicrophoneMixGain": 1,
"SystemAudioMixGain": 1,
"StopProcessingTimeout": "00:10:00",
"MinimumCompletedMeetingDuration": "00:01:00",
"MetadataLookupTimeout": "00:00:10",
"BackgroundMetadataLookupTimeout": "00:01:00",
"MaxMetadataAttendeeImportCount": 30,