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