Public Access
Generalize settings and logs assistant
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user