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:
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user