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
@@ -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);
}