7 Commits
Author SHA1 Message Date
renovate-bot 0394db7011 Update dependency Microsoft.Agents.AI.OpenAI to 1.8.0
PR and Push Build/Test / build-and-test (push) Successful in 8m18s
PR and Push Build/Test / build-and-test (pull_request) Successful in 7m59s
2026-06-02 02:30:11 +00:00
codex e9825f5bf5 Improve settings logs agent and renderer
PR and Push Build/Test / build-and-test (push) Successful in 8m13s
2026-06-02 00:09:03 +02:00
codex 11c65ce669 Expand settings and logs agent tools
PR and Push Build/Test / build-and-test (push) Successful in 9m36s
2026-06-01 21:07:08 +02:00
codex abac46c691 Add repo TDD skill
PR and Push Build/Test / build-and-test (push) Successful in 15m19s
2026-05-30 22:02:05 +02:00
codex fd8c33f1ea Fix settings log tail reads
PR and Push Build/Test / build-and-test (push) Failing after 12m50s
2026-05-30 14:25:35 +02:00
DEEUSEW\DESCMANU 0342cff64f Fix dictionary path to use vault-relative config
PR and Push Build/Test / build-and-test (push) Failing after 15m40s
2026-05-30 13:23:30 +02:00
codex 250d3b7a1e Generalize settings and logs assistant
PR and Push Build/Test / build-and-test (push) Successful in 16m43s
2026-05-30 12:57:51 +02:00
45 changed files with 5054 additions and 951 deletions
+107
View File
@@ -0,0 +1,107 @@
---
name: tdd
description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.
---
# Test-Driven Development
## Philosophy
**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.
**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.
**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior.
See [tests.md](references/tests.md) for examples and [mocking.md](references/mocking.md) for mocking guidelines.
## Anti-Pattern: Horizontal Slices
**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code."
This produces **crap tests**:
- Tests written in bulk test _imagined_ behavior, not _actual_ behavior
- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
- You outrun your headlights, committing to test structure before understanding the implementation
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
```
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
...
```
## Workflow
### 1. Planning
Before writing any code:
- [ ] Confirm with user what interface changes are needed
- [ ] Confirm with user which behaviors to test (prioritize)
- [ ] Identify opportunities for [deep modules](references/deep-modules.md) (small interface, deep implementation)
- [ ] Design interfaces for [testability](references/interface-design.md)
- [ ] List the behaviors to test (not implementation steps)
- [ ] Get user approval on the plan
Ask: "What should the public interface look like? Which behaviors are most important to test?"
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
### 2. Tracer Bullet
Write ONE test that confirms ONE thing about the system:
```
RED: Write test for first behavior → test fails
GREEN: Write minimal code to pass → test passes
```
This is your tracer bullet - proves the path works end-to-end.
### 3. Incremental Loop
For each remaining behavior:
```
RED: Write next test → fails
GREEN: Minimal code to pass → passes
```
Rules:
- One test at a time
- Only enough code to pass current test
- Don't anticipate future tests
- Keep tests focused on observable behavior
### 4. Refactor
After all tests pass, look for [refactor candidates](references/refactoring.md):
- [ ] Extract duplication
- [ ] Deepen modules (move complexity behind simple interfaces)
- [ ] Apply SOLID principles where natural
- [ ] Consider what new code reveals about existing code
- [ ] Run tests after each refactor step
**Never refactor while RED.** Get to GREEN first.
## Checklist Per Cycle
```
[ ] Test describes behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added
```
@@ -0,0 +1,33 @@
# Deep Modules
From "A Philosophy of Software Design":
**Deep module** = small interface + lots of implementation
```
┌─────────────────────┐
│ Small Interface │ ← Few methods, simple params
├─────────────────────┤
│ │
│ │
│ Deep Implementation│ ← Complex logic hidden
│ │
│ │
└─────────────────────┘
```
**Shallow module** = large interface + little implementation (avoid)
```
┌─────────────────────────────────┐
│ Large Interface │ ← Many methods, complex params
├─────────────────────────────────┤
│ Thin Implementation │ ← Just passes through
└─────────────────────────────────┘
```
When designing interfaces, ask:
- Can I reduce the number of methods?
- Can I simplify the parameters?
- Can I hide more complexity inside?
@@ -0,0 +1,31 @@
# Interface Design for Testability
Good interfaces make testing natural:
1. **Accept dependencies, don't create them**
```typescript
// Testable
function processOrder(order, paymentGateway) {}
// Hard to test
function processOrder(order) {
const gateway = new StripeGateway();
}
```
2. **Return results, don't produce side effects**
```typescript
// Testable
function calculateDiscount(cart): Discount {}
// Hard to test
function applyDiscount(cart): void {
cart.total -= discount;
}
```
3. **Small surface area**
- Fewer methods = fewer tests needed
- Fewer params = simpler test setup
+59
View File
@@ -0,0 +1,59 @@
# When to Mock
Mock at **system boundaries** only:
- External APIs (payment, email, etc.)
- Databases (sometimes - prefer test DB)
- Time/randomness
- File system (sometimes)
Don't mock:
- Your own classes/modules
- Internal collaborators
- Anything you control
## Designing for Mockability
At system boundaries, design interfaces that are easy to mock:
**1. Use dependency injection**
Pass external dependencies in rather than creating them internally:
```typescript
// Easy to mock
function processPayment(order, paymentClient) {
return paymentClient.charge(order.total);
}
// Hard to mock
function processPayment(order) {
const client = new StripeClient(process.env.STRIPE_KEY);
return client.charge(order.total);
}
```
**2. Prefer SDK-style interfaces over generic fetchers**
Create specific functions for each external operation instead of one generic function with conditional logic:
```typescript
// GOOD: Each function is independently mockable
const api = {
getUser: (id) => fetch(`/users/${id}`),
getOrders: (userId) => fetch(`/users/${userId}/orders`),
createOrder: (data) => fetch('/orders', { method: 'POST', body: data }),
};
// BAD: Mocking requires conditional logic inside the mock
const api = {
fetch: (endpoint, options) => fetch(endpoint, options),
};
```
The SDK approach means:
- Each mock returns one specific shape
- No conditional logic in test setup
- Easier to see which endpoints a test exercises
- Type safety per endpoint
@@ -0,0 +1,10 @@
# Refactor Candidates
After TDD cycle, look for:
- **Duplication** → Extract function/class
- **Long methods** → Break into private helpers (keep tests on public interface)
- **Shallow modules** → Combine or deepen
- **Feature envy** → Move logic to where data lives
- **Primitive obsession** → Introduce value objects
- **Existing code** the new code reveals as problematic
+61
View File
@@ -0,0 +1,61 @@
# Good and Bad Tests
## Good Tests
**Integration-style**: Test through real interfaces, not mocks of internal parts.
```typescript
// GOOD: Tests observable behavior
test("user can checkout with valid cart", async () => {
const cart = createCart();
cart.add(product);
const result = await checkout(cart, paymentMethod);
expect(result.status).toBe("confirmed");
});
```
Characteristics:
- Tests behavior users/callers care about
- Uses public API only
- Survives internal refactors
- Describes WHAT, not HOW
- One logical assertion per test
## Bad Tests
**Implementation-detail tests**: Coupled to internal structure.
```typescript
// BAD: Tests implementation details
test("checkout calls paymentService.process", async () => {
const mockPayment = jest.mock(paymentService);
await checkout(cart, payment);
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
});
```
Red flags:
- Mocking internal collaborators
- Testing private methods
- Asserting on call counts/order
- Test breaks when refactoring without behavior change
- Test name describes HOW not WHAT
- Verifying through external means instead of interface
```typescript
// BAD: Bypasses interface to verify
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
// GOOD: Verifies through interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
```
+5 -1
View File
@@ -57,7 +57,7 @@ A spec/change is not considered done merely because code is merged, tests pass,
Before archiving any OpenSpec change, perform a refactoring pass over the code and specs touched by that change. The pass must inspect both the current diff and the surrounding implementation context, because a small diff may reveal repeated patterns or structural problems that only become obvious when compared with nearby code.
Do the refactoring pass in these distinct areas, first start a subagent for each area to identify potential improvements in parallel, then implement them:
Do the refactoring pass in these distinct areas in sequence so they are less likely to converge on the same issues, first start a subagent to identify potential improvements using `gpt-5.3-codex` if available, then implement them, then start the repeat for the next area:
1. Check for DRYness. Look for duplication introduced by the change and for existing nearby duplication that the change now makes worth consolidating. A change may be small on its own, but if it is the fifth copy of the same idea, it is a refactoring target.
2. Check for SOLID violations. Look for responsibilities that are mixed together, abstractions that are hard to replace or test, interface shapes that force unrelated dependencies, and code paths that require modifying stable code for each new variant.
@@ -85,6 +85,10 @@ If production or end-to-end verification is not possible, state exactly why and
## Implementation Notes
Meeting Assistant is Manuel's local meeting capture and knowledge system. It runs as a background .NET service with a Windows tray icon, captures microphone plus system audio, transcribes meetings, maintains Obsidian meeting artifacts, enriches meetings from Outlook metadata when available, applies local workflow rules, identifies speakers, captures screenshots, and runs agentic summarization against a LiteLLM/OpenAI-compatible endpoint.
The app is used during real meetings. Treat active recording, transcription finalization, OCR, and summarization as live user work. Prefer local endpoints, generated note files, and application logs for verification, and avoid restart or cleanup actions that could interrupt an active run unless the user explicitly requested them.
Keep source and deployment ownership separate:
- `Manuel/meeting-assistant` owns application source, tests, build configuration, container image publishing, and OpenSpec source changes.
@@ -50,7 +50,7 @@ public sealed class MeetingSummaryToolTests
Assert.False(tools.SummaryWasWritten);
var result = await tools.WriteSummary("# Summary\n\n- Done.", "Done items");
Assert.Equal(artifacts.SummaryPath, result);
Assert.Equal(Path.GetFileName(artifacts.SummaryPath), result);
Assert.True(tools.SummaryWasWritten);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Meeting", summary);
@@ -154,6 +154,40 @@ public sealed class MeetingSummaryToolTests
Assert.DoesNotContain("- Stale Project", summary);
}
[Fact]
public async Task WriteSummaryCopiesOnlyDistinctNonBlankMeetingProjects()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
projects:
- " Current Project "
- current project
- ""
- Second Project
---
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary", "Project summary");
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("projects:", summary);
Assert.Contains("- Current Project", summary);
Assert.Contains("- Second Project", summary);
Assert.DoesNotContain("- current project", summary);
Assert.DoesNotContain("- \"\"", summary);
}
[Fact]
public async Task ToolsCanAddAndRemoveMeetingAttendees()
{
@@ -478,6 +512,22 @@ public sealed class MeetingSummaryToolTests
Assert.Equal("", await tools.ReadTranscript(from: 3, to: 2));
}
[Fact]
public async Task ReadToolsSupportTail()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = new MeetingSessionArtifacts(
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
await File.WriteAllTextAsync(artifacts.TranscriptPath, "one\ntwo\nthree");
var tools = new MeetingSummaryTools(artifacts);
Assert.Equal("two\nthree", await tools.ReadTranscript(tail: 2));
}
[Fact]
public async Task ToolsWriteAssistantContextBodyWithLineModes()
{
@@ -121,6 +121,41 @@ public sealed class ProjectKnowledgeToolTests
Assert.StartsWith("Refused:", await tools.WriteProjectFile("MeetingAssistant", "notes.md", "content", from: 1, to: 1, replace_file: true));
}
[Fact]
public async Task SearchCanBeLimitedByProjectFilePattern()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant");
Directory.CreateDirectory(projectRoot);
await File.WriteAllTextAsync(Path.Combine(projectRoot, "PROJECT.md"), "durable needle");
await File.WriteAllTextAsync(Path.Combine(projectRoot, "scratch.txt"), "scratch needle");
var artifacts = CreateArtifacts(root);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
projects:
- MeetingAssistant
---
""");
var tools = new MeetingSummaryTools(
artifacts,
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
var search = await tools.Search("needle", file_pattern: "*.md");
Assert.Contains("PROJECT.md:1 durable needle", search);
Assert.DoesNotContain("scratch.txt", search);
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
{
return new MeetingSessionArtifacts(
@@ -145,6 +145,40 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopTreatsRunsShorterThanConfiguredMinimumAsAbort()
{
var audioSource = new ControlledAudioSource();
var artifactCleaner = new CapturingMeetingRunArtifactCleaner();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(new MeetingAssistantOptions
{
Recording =
{
MinimumCompletedMeetingDuration = TimeSpan.FromMinutes(1)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
artifactCleaner: artifactCleaner);
var started = await coordinator.StartAsync(CancellationToken.None);
var stopped = await coordinator.StopAsync(CancellationToken.None);
Assert.False(stopped.IsRecording);
Assert.Null(stopped.MeetingNotePath);
Assert.False(summaryPipeline.WasRun);
Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath);
}
[Fact]
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
{
@@ -2240,6 +2274,8 @@ public sealed class RecordingCoordinatorTests
public MeetingAssistantOptions? Options { get; private set; }
public bool WasRun => Artifacts is not null;
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
@@ -2262,6 +2298,23 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class CapturingMeetingRunArtifactCleaner : IMeetingRunArtifactCleaner
{
public MeetingSessionArtifacts? DeletedArtifacts { get; private set; }
public MeetingAssistantOptions? Options { get; private set; }
public Task DeleteRunArtifactsAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
DeletedArtifacts = artifacts;
Options = options;
return Task.CompletedTask;
}
}
private sealed class RecordingSummaryPipeline : IMeetingSummaryPipeline
{
public List<MeetingSessionArtifacts> ArtifactHistory { get; } = [];
+1 -1
View File
@@ -16,7 +16,7 @@ public sealed class TaskbarIconTests
Assert.Equal(RecordingProcessState.Idle, menu.State);
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.EditRules &&
item.Text == "Edit rules and identities");
item.Text == "Settings and logs");
Assert.Contains(menu.Items, item =>
item.Action == MeetingTaskbarAction.StartRecording &&
item.ProfileName == "default" &&
@@ -1,6 +1,9 @@
using MeetingAssistant.Workflow;
using MeetingAssistant.Logging;
using MeetingAssistant.Speakers;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
@@ -82,6 +85,403 @@ public sealed class WorkflowRulesEditorTests
Assert.DoesNotContain("other.yaml", searchResult);
}
[Fact]
public async Task SettingsToolsReadWriteAndValidateConfiguredAppsettingsFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var configPath = Path.Combine(root, "appsettings.json");
await File.WriteAllTextAsync(
configPath,
"""
{
"MeetingAssistant": {
"Agent": {
"Model": "old-model"
}
}
}
""");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
configPath: configPath);
var readResult = await tools.ReadConfig(from: 3, to: 5);
var writeResult = await tools.WriteConfig("""
{
"MeetingAssistant": {
"Agent": {
"Model": "new-model"
}
}
}
""");
var invalidResult = await tools.WriteConfig("{ invalid json");
Assert.Contains("\"Agent\"", readResult);
Assert.Equal(configPath, writeResult);
Assert.StartsWith("Refused: appsettings JSON is invalid.", invalidResult);
Assert.Contains("new-model", await File.ReadAllTextAsync(configPath));
Assert.DoesNotContain("invalid json", await File.ReadAllTextAsync(configPath));
}
[Fact]
public async Task SettingsToolsReadConfigurationDocumentation()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var docsPath = Path.Combine(root, "meeting-assistant-configuration.md");
await File.WriteAllTextAsync(docsPath, "# Config Docs\n\nAgent model settings.");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
configDocsPath: docsPath);
var docs = await tools.ReadConfigDocs();
Assert.Contains("Agent model settings", docs);
}
[Fact]
public async Task SettingsToolsReadAndSearchApplicationLogs()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(
Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName),
"first line\nsecond needle\nthird line\n");
await File.WriteAllTextAsync(
Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)),
"rotated needle\n");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
logDirectory: root);
using var provider = new MeetingAssistantFileLoggerProvider(root);
provider.CreateLogger("Test").LogInformation("locked needle");
var tail = await tools.ReadLogs(tail: 2);
var range = await tools.ReadLogs(from: 1, to: 1);
var search = await tools.SearchLogs("needle");
Assert.Contains("locked needle", tail);
Assert.Contains("locked needle", search);
Assert.Contains("locked needle", range);
Assert.Contains("meeting-assistant.log:1", search);
Assert.Contains("meeting-assistant.log.2:1 rotated needle", search);
}
[Fact]
public async Task SettingsToolsReadAndSearchOnlyCopiedSpecFiles()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var sessionSpec = Path.Combine(root, "meeting-session", "spec.md");
Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!);
await File.WriteAllTextAsync(
sessionSpec,
"""
# meeting-session Specification
Meeting Assistant creates notes.
""");
await File.WriteAllTextAsync(
Path.Combine(root, "root-spec.md"),
"Root spec mentions Assistant.");
var outside = Path.Combine(Path.GetDirectoryName(root)!, "outside.md");
await File.WriteAllTextAsync(outside, "outside Assistant");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
specRootPath: root);
var read = await tools.ReadSpecFile("meeting-session/spec.md", from: 3, to: 3);
var escaped = await tools.ReadSpecFile("../outside.md");
var search = await tools.SearchSpec("Assistant");
Assert.Equal("Meeting Assistant creates notes.", read.Trim());
Assert.StartsWith("Refused: spec path must stay inside", escaped);
Assert.Contains("meeting-session/spec.md:3 Meeting Assistant creates notes.", search);
Assert.Contains("root-spec.md:1 Root spec mentions Assistant.", search);
Assert.DoesNotContain("outside.md", search);
}
[Fact]
public async Task SettingsToolsReadTailAndSearchSpecsWithFilePattern()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var sessionSpec = Path.Combine(root, "meeting-session", "spec.md");
var otherSpec = Path.Combine(root, "other", "notes.md");
Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!);
Directory.CreateDirectory(Path.GetDirectoryName(otherSpec)!);
await File.WriteAllTextAsync(sessionSpec, "one\nsession needle\nthree\nfour");
await File.WriteAllTextAsync(otherSpec, "other needle");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
specRootPath: root);
var tail = await tools.ReadSpecFile("meeting-session/spec.md", tail: 2);
var search = await tools.SearchSpec("needle", file_pattern: "meeting-session/*.md");
Assert.Equal("three\nfour", tail);
Assert.Contains("meeting-session/spec.md:2 session needle", search);
Assert.DoesNotContain("other/notes.md", search);
}
[Fact]
public async Task SettingsToolsCreateProjectWithRecommendedSeedOrDirectAgentsFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var agentsTemplatePath = Path.Combine(root, "Project-AGENTS.md");
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(agentsTemplatePath, "Recommended project instructions.");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
},
projectAgentsTemplatePath: agentsTemplatePath);
var recommended = await tools.CreateProject("Alpha Project");
var direct = await tools.CreateProject(
"Beta Project",
seed: "agents_md",
agents_md: "Direct project instructions.");
var alphaRoot = Path.Combine(projectsRoot, "Alpha Project");
var betaRoot = Path.Combine(projectsRoot, "Beta Project");
Assert.Equal("Alpha Project", recommended);
Assert.Contains("Alpha Project", await tools.ListProjects());
Assert.Equal("Recommended project instructions.", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "AGENTS.md")));
Assert.Contains("# Executive Summary", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md")));
Assert.Contains("project folder was created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md")));
Assert.Contains("Important decisions", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "DECISIONS.md")));
Assert.Equal("Direct project instructions.", await File.ReadAllTextAsync(Path.Combine(betaRoot, "AGENTS.md")));
Assert.False(File.Exists(Path.Combine(betaRoot, "PROJECT.md")));
Assert.StartsWith("Refused:", await tools.CreateProject("../Escape"));
}
[Fact]
public async Task SettingsToolsReadWriteAndSearchExistingProjectFiles()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var alphaRoot = Path.Combine(projectsRoot, "Alpha");
var betaRoot = Path.Combine(projectsRoot, "Beta");
Directory.CreateDirectory(alphaRoot);
Directory.CreateDirectory(betaRoot);
await File.WriteAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md"), "alpha one\nalpha needle\nalpha three");
await File.WriteAllTextAsync(Path.Combine(alphaRoot, "notes.txt"), "alpha needle hidden from md search");
await File.WriteAllTextAsync(Path.Combine(betaRoot, "PROJECT.md"), "beta needle");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
var files = await tools.ListProjectFiles("Alpha");
var tail = await tools.ReadProjectFile("Alpha", "PROJECT.md", tail: 2);
var write = await tools.WriteProjectFile("Alpha", "JOURNAL.md", "created");
var search = await tools.SearchProjects("needle", file_pattern: "*.md");
Assert.Equal("notes.txt\nPROJECT.md", files);
Assert.Equal("alpha needle\nalpha three", tail);
Assert.Equal("Alpha/JOURNAL.md", write);
Assert.Equal("created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md")));
Assert.Contains("Alpha/PROJECT.md:2 alpha needle", search);
Assert.Contains("Beta/PROJECT.md:1 beta needle", search);
Assert.DoesNotContain("notes.txt", search);
Assert.StartsWith("Refused:", await tools.ReadProjectFile("Missing", "PROJECT.md"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "../escape.md", "bad"));
}
[Fact]
public async Task SettingsToolsListReadAndSearchMeetingArtifacts()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var summariesRoot = Path.Combine(root, "Summaries");
var transcriptsRoot = Path.Combine(root, "Transcripts");
var notesRoot = Path.Combine(root, "Notes");
var contextRoot = Path.Combine(root, "Context");
Directory.CreateDirectory(summariesRoot);
Directory.CreateDirectory(transcriptsRoot);
Directory.CreateDirectory(notesRoot);
Directory.CreateDirectory(contextRoot);
await File.WriteAllTextAsync(
Path.Combine(summariesRoot, "20260530-1000-summary.md"),
"""
---
title: Older Meeting
start_time: "2026-05-30T10:00:00+02:00"
meeting: "[[20260530-1000-note|Meeting Note]]"
transcript: "[[20260530-1000-transcript|Transcript]]"
assistant_context: "[[20260530-1000-context|Assistant Context]]"
projects:
- Alpha
---
older summary needle
""");
await File.WriteAllTextAsync(
Path.Combine(summariesRoot, "20260531-0900-summary.md"),
"""
---
title: Newer Meeting
start_time: "2026-05-31T09:00:00+02:00"
meeting: "[[20260531-0900-note|Meeting Note]]"
transcript: "[[20260531-0900-transcript|Transcript]]"
assistant_context: "[[20260531-0900-context|Assistant Context]]"
projects:
- Beta
---
newer summary needle
""");
await File.WriteAllTextAsync(Path.Combine(notesRoot, "20260531-0900-note.md"), "note one\nnote needle\nnote three");
await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "20260531-0900-transcript.md"), "transcript one\ntranscript needle\ntranscript three");
await File.WriteAllTextAsync(Path.Combine(contextRoot, "20260531-0900-context.md"), "context one\ncontext needle\ncontext three");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Vault =
{
SummariesFolder = summariesRoot,
TranscriptsFolder = transcriptsRoot,
MeetingNotesFolder = notesRoot,
AssistantContextFolder = contextRoot
}
});
var recent = await tools.ListRecentSummaries(limit: 2);
var summary = await tools.ReadSummary("20260531-0900-summary.md", tail: 1);
var transcript = await tools.ReadTranscript("20260531-0900-transcript.md", from: 2, to: 2);
var meetingNote = await tools.ReadMeetingNote("20260531-0900-note.md", tail: 2);
var context = await tools.ReadContext("20260531-0900-context.md", tail: 2);
var summarySearch = await tools.SearchSummaries("needle");
var transcriptSearch = await tools.SearchTranscripts("needle");
var noteSearch = await tools.SearchMeetingNotes("needle");
var contextSearch = await tools.SearchContext("needle");
Assert.True(recent.IndexOf("20260531-0900-summary.md", StringComparison.Ordinal) <
recent.IndexOf("20260530-1000-summary.md", StringComparison.Ordinal));
Assert.Contains("title: Newer Meeting", recent);
Assert.Contains("meeting_note: 20260531-0900-note.md", recent);
Assert.Contains("transcript: 20260531-0900-transcript.md", recent);
Assert.Contains("context: 20260531-0900-context.md", recent);
Assert.Equal("newer summary needle", summary.Trim());
Assert.Equal("transcript needle", transcript.Trim());
Assert.Equal("note needle\nnote three", meetingNote);
Assert.Equal("context needle\ncontext three", context);
Assert.Contains("20260531-0900-summary.md:", summarySearch);
Assert.Contains("20260531-0900-transcript.md:2 transcript needle", transcriptSearch);
Assert.Contains("20260531-0900-note.md:2 note needle", noteSearch);
Assert.Contains("20260531-0900-context.md:2 context needle", contextSearch);
Assert.StartsWith("Refused:", await tools.ReadSummary("../escape.md"));
}
[Fact]
public async Task SettingsToolsWriteMeetingArtifactsAndFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var summariesRoot = Path.Combine(root, "summaries");
var transcriptsRoot = Path.Combine(root, "transcripts");
var notesRoot = Path.Combine(root, "meetings");
var contextRoot = Path.Combine(root, "context");
Directory.CreateDirectory(summariesRoot);
Directory.CreateDirectory(transcriptsRoot);
Directory.CreateDirectory(notesRoot);
Directory.CreateDirectory(contextRoot);
await File.WriteAllTextAsync(
Path.Combine(notesRoot, "daily.md"),
"""
---
title: Old
---
Existing body.
""");
await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md"), "one\nthree");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Vault =
{
SummariesFolder = summariesRoot,
TranscriptsFolder = transcriptsRoot,
MeetingNotesFolder = notesRoot,
AssistantContextFolder = contextRoot
}
});
Assert.Equal("daily-summary.md", await tools.WriteSummary("daily-summary.md", "summary body", replace_file: true));
Assert.Equal("daily-transcript.md", await tools.WriteTranscript("daily-transcript.md", "two", insert: 2));
Assert.Equal("daily.md", await tools.WriteMeetingNoteFrontmatter(
"daily.md",
"""
title: Fixed
projects:
- Alpha
transcript: "[[daily-transcript|Transcript]]"
"""));
Assert.Equal("repair/context.md", await tools.WriteContext("repair/context.md", "context body"));
Assert.Equal("summary body", await File.ReadAllTextAsync(Path.Combine(summariesRoot, "daily-summary.md")));
Assert.Equal("one\ntwo\nthree", await File.ReadAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md")));
var note = await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md"));
Assert.Contains("title: Fixed", note);
Assert.Contains("projects:\n- Alpha", note);
Assert.Contains("Existing body.", note);
Assert.Equal("context body", await File.ReadAllTextAsync(Path.Combine(contextRoot, "repair", "context.md")));
Assert.StartsWith("Refused:", await tools.WriteSummary("../escape.md", "bad"));
Assert.StartsWith("Refused:", await tools.WriteMeetingNoteFrontmatter("daily.md", "title: ["));
}
[Fact]
public async Task SettingsToolsExposeDiagnosticsWithoutHttp()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:Automation:RulesPath"] = "rules.yaml"
})
.Build();
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
configuration: config);
Assert.Contains("meeting-assistant", tools.GetHealth());
Assert.Equal("Recording coordinator is not configured.", tools.GetRecordingStatus());
Assert.Contains("rules.yaml", tools.ReloadWorkflowConfiguration());
Assert.Equal("Meeting metadata provider is not configured.", await tools.DiagnoseCurrentMeeting());
Assert.Equal("Speaker identity merge service is not configured.", await tools.MergeRecentSpeakerIdentities());
Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrTranscribeFile("missing.wav"));
Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrDiarizeFile("missing.wav"));
}
[Fact]
public void FileLoggerRotatesCurrentLogAndKeepsFourOlderFiles()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName), "current");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)), "old1");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(2)), "old2");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(3)), "old3");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4)), "old4");
using (var provider = new MeetingAssistantFileLoggerProvider(root))
{
provider.CreateLogger("Test").LogInformation("fresh");
}
Assert.Contains("fresh", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName)));
Assert.Equal("current", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1))));
Assert.Equal("old3", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4))));
Assert.False(File.Exists(Path.Combine(root, "meeting-assistant.log.5")));
}
[Fact]
public async Task RulesEditorToolsAppendByDefaultAndReplaceOnlyWhenRequested()
{
@@ -157,6 +557,10 @@ public sealed class WorkflowRulesEditorTests
Assert.Contains("Meeting Workflow Engine", instructions);
Assert.Contains("Workflow rules reference documentation", instructions);
Assert.Contains("speaker identity database", instructions);
Assert.Contains("read_config", instructions);
Assert.Contains("read_logs", instructions);
Assert.Contains("read_spec_file", instructions);
Assert.Contains("list_recent_summaries", instructions);
}
[Fact]
@@ -300,7 +704,7 @@ public sealed class WorkflowRulesEditorTests
public void MarkdownParserRecognizesRequestedInlineStyles()
{
var inlines = WorkflowRulesEditorMarkdown.ParseInline(
"Normal *italic* **bold** and `code`.");
"Normal *italic* **bold** ***both*** ~~gone~~ [docs](https://example.test) <https://auto.test> <test@example.com> ![logo](C:/tmp/logo.png) and `code`.");
Assert.Equal(
[
@@ -308,6 +712,30 @@ public sealed class WorkflowRulesEditorTests
new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("both", WorkflowRulesEditorMarkdownInlineStyle.BoldItalic),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("gone", WorkflowRulesEditorMarkdownInlineStyle.Strikethrough),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"docs",
WorkflowRulesEditorMarkdownInlineStyle.Link,
"https://example.test"),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"https://auto.test",
WorkflowRulesEditorMarkdownInlineStyle.Link,
"https://auto.test"),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"test@example.com",
WorkflowRulesEditorMarkdownInlineStyle.Link,
"mailto:test@example.com"),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"logo",
WorkflowRulesEditorMarkdownInlineStyle.Image,
"C:/tmp/logo.png"),
new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code),
new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal)
@@ -315,6 +743,138 @@ public sealed class WorkflowRulesEditorTests
inlines);
}
[Fact]
public void MarkdownParserRecognizesHeadingsBlockquotesAndCheckboxes()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
# Main
## Detail
> quoted **line**
> second
- [x] done
- [ ] open
""");
Assert.Collection(
blocks,
block =>
{
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
Assert.Equal(1, heading.Level);
Assert.Equal("# Main", Assert.Single(heading.Inlines).Text);
},
block =>
{
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
Assert.Equal(2, heading.Level);
Assert.Equal("## Detail", Assert.Single(heading.Inlines).Text);
},
block =>
{
var quote = Assert.IsType<WorkflowRulesEditorMarkdownBlockQuote>(block);
Assert.Contains(quote.Inlines, inline => inline.Text.Contains("quoted", StringComparison.Ordinal));
Assert.Contains(quote.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
},
block =>
{
var tasks = Assert.IsType<WorkflowRulesEditorMarkdownTaskList>(block);
Assert.Collection(
tasks.Items,
item =>
{
Assert.True(item.IsChecked);
Assert.Equal("done", Assert.Single(item.Inlines).Text);
},
item =>
{
Assert.False(item.IsChecked);
Assert.Equal("open", Assert.Single(item.Inlines).Text);
});
});
}
[Fact]
public void MarkdownParserRecognizesStandaloneImageEmbeds()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
Before
![Architecture diagram](C:/tmp/architecture.png)
After
""");
Assert.Collection(
blocks,
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
block =>
{
var image = Assert.IsType<WorkflowRulesEditorMarkdownImage>(block);
Assert.Equal("Architecture diagram", image.AltText);
Assert.Equal("C:/tmp/architecture.png", image.Target);
},
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block));
}
[Fact]
public void MarkdownLinkResolverFindsScreenshotAttachmentsUnderArtifactRoots()
{
var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
try
{
var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context");
var attachmentPath = Path.Combine(
contextRoot,
"2026",
"06",
"Attachments",
"20260601-120000-001.png");
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
File.WriteAllBytes(attachmentPath, [1, 2, 3]);
var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]);
Assert.True(resolver.TryResolveImageUri("Attachments/20260601-120000-001.png", out var uri));
Assert.Equal(new Uri(attachmentPath), uri);
Assert.Equal(attachmentPath, resolver.ResolveOpenTarget("Attachments/20260601-120000-001.png"));
}
finally
{
if (Directory.Exists(temp))
{
Directory.Delete(temp, recursive: true);
}
}
}
[Fact]
public void MarkdownLinkResolverDoesNotCacheMissingFiles()
{
var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
try
{
var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context");
var attachmentPath = Path.Combine(contextRoot, "Attachments", "late.png");
var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]);
Assert.False(resolver.TryResolveImageUri("Attachments/late.png", out _));
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
File.WriteAllBytes(attachmentPath, [1, 2, 3]);
Assert.True(resolver.TryResolveImageUri("Attachments/late.png", out var uri));
Assert.Equal(new Uri(attachmentPath), uri);
}
finally
{
if (Directory.Exists(temp))
{
Directory.Delete(temp, recursive: true);
}
}
}
[Fact]
public void MarkdownParserPreservesParagraphLineBreaks()
{
+279
View File
@@ -0,0 +1,279 @@
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 = ReadAllLinesShared(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 "";
}
}
public static async Task<string> ReadAllTextSharedAsync(string path)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 4096,
useAsync: true);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
private static string[] ReadAllLinesShared(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
using var reader = new StreamReader(stream);
return SplitLines(reader.ReadToEnd()).ToArray();
}
}
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]]
```
+2
View File
@@ -0,0 +1,2 @@
global using System.IO;
global using System.Net.Http;
@@ -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);
}
}
}
+6 -3
View File
@@ -12,6 +12,7 @@
<OutputType>WinExe</OutputType>
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
<PlatformTarget>x64</PlatformTarget>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
@@ -34,14 +35,16 @@
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="Aprillz.MewUI.Windows" Version="0.15.2" />
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
</ItemGroup>
<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'">
@@ -63,7 +66,7 @@
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
<Compile Remove="Workflow\MewUiWorkflowRulesEditorWindowService.Windows.cs" />
<Compile Remove="Workflow\WpfWorkflowRulesEditorWindowService.Windows.cs" />
</ItemGroup>
</Project>
@@ -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);
+11 -1
View File
@@ -1,6 +1,7 @@
using MeetingAssistant;
using MeetingAssistant.Hotkeys;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Logging;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Screenshots;
@@ -13,6 +14,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddProvider(new MeetingAssistantFileLoggerProvider());
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
#if WINDOWS
@@ -69,7 +71,9 @@ builder.Services.AddSingleton<IWorkflowRulesEditorSamplePlaybackQueue, WorkflowR
builder.Services.AddSingleton<IWorkflowRulesEditorChatPipeline, WorkflowRulesEditorChatPipeline>();
builder.Services.AddTransient<WorkflowRulesEditorChatViewModel>();
#if WINDOWS
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MewUiWorkflowRulesEditorWindowService>();
builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolver.FromOptions(
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
#else
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
#endif
@@ -232,6 +236,12 @@ app.MapPost("/diagnostics/workflow/rules-editor/show", (
rulesEditorWindow.Show();
return Results.Accepted();
});
app.MapPost("/diagnostics/settings-and-logs/show", (
IWorkflowRulesEditorWindowService rulesEditorWindow) =>
{
rulesEditorWindow.Show();
return Results.Accepted();
});
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
{
@@ -203,6 +203,7 @@ public sealed class MeetingRecordingCoordinator
pipeline,
pipelineOptions,
runOptions,
startedAt,
launchProfile.Name,
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
@@ -232,6 +233,7 @@ public sealed class MeetingRecordingCoordinator
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
{
RecordingRun run;
var abortAsTooShort = false;
await gate.WaitAsync(cancellationToken);
try
@@ -241,14 +243,29 @@ public sealed class MeetingRecordingCoordinator
return CurrentStatus;
}
currentRun.StopCapture();
run = currentRun;
abortAsTooShort = ShouldAbortRunOnStop(run, DateTimeOffset.Now);
if (abortAsTooShort)
{
run.Abort();
}
else
{
run.StopCapture();
}
}
finally
{
gate.Release();
}
if (abortAsTooShort)
{
await CompleteAbortedRunAsync(run, cancellationToken);
logger.LogInformation("Meeting recording was shorter than the configured minimum and was aborted");
return CurrentStatus;
}
try
{
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
@@ -271,7 +288,6 @@ public sealed class MeetingRecordingCoordinator
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
{
RecordingRun run;
MeetingSessionArtifacts artifacts;
await gate.WaitAsync(cancellationToken);
try
@@ -282,7 +298,6 @@ public sealed class MeetingRecordingCoordinator
}
run = currentRun;
artifacts = run.Artifacts;
run.Abort();
}
finally
@@ -290,6 +305,13 @@ public sealed class MeetingRecordingCoordinator
gate.Release();
}
await CompleteAbortedRunAsync(run, cancellationToken);
logger.LogInformation("Meeting recording aborted and artifacts deleted");
return CurrentStatus;
}
private async Task CompleteAbortedRunAsync(RecordingRun run, CancellationToken cancellationToken)
{
try
{
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
@@ -302,12 +324,10 @@ public sealed class MeetingRecordingCoordinator
}
await screenshotService.CancelPendingOcrAsync(
artifacts,
run.Artifacts,
run.Options.Screenshots.Ocr.GetEffectiveTimeout(),
cancellationToken);
await artifactCleaner.DeleteRunArtifactsAsync(artifacts, run.Options, cancellationToken);
logger.LogInformation("Meeting recording aborted and artifacts deleted");
return CurrentStatus;
await artifactCleaner.DeleteRunArtifactsAsync(run.Artifacts, run.Options, cancellationToken);
}
public Task<MeetingScreenshotCaptureResult> CaptureScreenshotAsync(CancellationToken cancellationToken)
@@ -1265,7 +1285,13 @@ public sealed class MeetingRecordingCoordinator
string artifactName)
{
var folder = VaultPath.Resolve(vault, configuredFolder);
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss-fffffff}-{artifactName}.md");
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmm}-{artifactName}.md");
}
private static bool ShouldAbortRunOnStop(RecordingRun run, DateTimeOffset stoppedAt)
{
var minimumDuration = run.Options.Recording.MinimumCompletedMeetingDuration;
return minimumDuration > TimeSpan.Zero && stoppedAt - run.StartedAt < minimumDuration;
}
private async Task CompleteRunAsync(RecordingRun run)
@@ -1318,6 +1344,7 @@ public sealed class MeetingRecordingCoordinator
ISpeechRecognitionPipeline pipeline,
SpeechRecognitionPipelineOptions pipelineOptions,
MeetingAssistantOptions options,
DateTimeOffset startedAt,
string launchProfileName,
TimeSpan liveSampleBufferDuration,
int maxSpeakerSamples)
@@ -1332,6 +1359,7 @@ public sealed class MeetingRecordingCoordinator
Pipeline = pipeline;
PipelineOptions = pipelineOptions;
Options = options;
StartedAt = startedAt;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(
@@ -1363,6 +1391,8 @@ public sealed class MeetingRecordingCoordinator
public MeetingAssistantOptions Options { get; private set; }
public DateTimeOffset StartedAt { get; }
public string LaunchProfileName { get; private set; }
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
+39
View File
@@ -0,0 +1,39 @@
namespace MeetingAssistant;
internal static class RuntimeContentLocator
{
public static IEnumerable<string> CandidateDocumentationPaths(string fileName)
{
foreach (var directory in EnumerateBaseDirectoryAndParents())
{
yield return Path.Combine(directory, "docs", fileName);
}
}
public static IEnumerable<string> CandidateDirectoryPaths(params string[] segments)
{
foreach (var directory in EnumerateBaseDirectoryAndParents())
{
yield return Path.Combine([directory, .. segments]);
}
}
public static IEnumerable<string> CandidateContentPaths(string fileName)
{
foreach (var directory in EnumerateBaseDirectoryAndParents())
{
yield return Path.Combine(directory, "MeetingAssistant", "Content", fileName);
yield return Path.Combine(directory, "Content", fileName);
}
}
private static IEnumerable<string> EnumerateBaseDirectoryAndParents(int maxParentDepth = 8)
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
for (var i = 0; i < maxParentDepth && current is not null; i++)
{
yield return current.FullName;
current = current.Parent;
}
}
}
@@ -24,7 +24,7 @@ internal static class MeetingSummaryFrontmatterFactory
artifacts.SummaryPath,
meetingNote,
cancellationToken);
frontmatter.Projects = CopyNonEmptyList(meetingNote.Frontmatter.Projects);
frontmatter.Projects = CopyNonEmptyProjectList(meetingNote.Frontmatter.Projects);
return frontmatter;
}
@@ -42,6 +42,16 @@ internal static class MeetingSummaryFrontmatterFactory
return values.Count == 0 ? null : values.ToList();
}
private static List<string>? CopyNonEmptyProjectList(IEnumerable<string> values)
{
var projects = values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return projects.Count == 0 ? null : projects;
}
private static async Task<List<string>?> ReadExistingSummaryAttendeesAsync(
string summaryPath,
CancellationToken cancellationToken)
@@ -10,7 +10,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, project files, and scoped past project meeting summaries.
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important.
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important. write_summary returns the written summary filename so project journal entries can link to it.
write_summary is only for the current meeting's summary file. Past project meeting summaries are read-only historical context; use list_past_project_meetings and read_past_project_meeting_summary to inspect them, and never try to mutate them.
If the meeting note has no title, provide a concise title parameter to write_summary.
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
+42 -224
View File
@@ -35,22 +35,22 @@ public sealed class MeetingSummaryTools
projectResolver = new BoundMeetingProjectResolver(options);
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
public Task<string> ReadTranscript(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to);
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to, tail);
}
public Task<string> ReadMeetingNote(int? @from = null, int? to = null)
public Task<string> ReadMeetingNote(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to);
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to, tail);
}
public Task<string> ReadContext(int? @from = null, int? to = null)
public Task<string> ReadContext(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to);
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to, tail);
}
public async Task<string> ReadUserNotes(int? @from = null, int? to = null)
public async Task<string> ReadUserNotes(int? @from = null, int? to = null, int? tail = null)
{
if (!File.Exists(artifacts.MeetingNotePath))
{
@@ -60,12 +60,12 @@ public sealed class MeetingSummaryTools
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
var body = document.HasFrontmatter ? document.Body.Trim() : content;
return ReadLines(body, @from, to);
return AgentFileToolContent.ReadLines(body, @from, to, tail);
}
public Task<string> ReadGlossary(int? @from = null, int? to = null)
public Task<string> ReadGlossary(int? @from = null, int? to = null, int? tail = null)
{
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to);
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to, tail);
}
public async Task<string> AddDictationWord(string word)
@@ -232,7 +232,7 @@ public sealed class MeetingSummaryTools
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
SummaryWasWritten = true;
return artifacts.SummaryPath;
return Path.GetFileName(artifacts.SummaryPath);
}
private static bool ContainsLineBreak(string value)
@@ -250,7 +250,7 @@ public sealed class MeetingSummaryTools
int? insert = null,
bool replace_file = false)
{
var editMode = FileLineEditMode.Create(@from, to, insert, replace_file);
var editMode = AgentFileEditMode.Create(@from, to, insert, replace_file);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
@@ -263,7 +263,7 @@ public sealed class MeetingSummaryTools
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updatedBody = AgentFileToolContent.ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updated = string.IsNullOrWhiteSpace(frontmatter)
? updatedBody
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
@@ -293,12 +293,12 @@ public sealed class MeetingSummaryTools
}
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Select(path => ToToolPath(Path.GetRelativePath(projectRoot, path)))
.Select(path => AgentFileToolContent.ToToolPath(Path.GetRelativePath(projectRoot, path)))
.Order(StringComparer.Ordinal);
return string.Join('\n', files);
}
public async Task<string> ReadProjectFile(string project, string path, int? @from = null, int? to = null)
public async Task<string> ReadProjectFile(string project, string path, int? @from = null, int? to = null, int? tail = null)
{
var filePath = await ResolveBoundProjectFilePathAsync(project, path);
if (filePath is null || !File.Exists(filePath))
@@ -306,7 +306,7 @@ public sealed class MeetingSummaryTools
return "";
}
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(filePath), @from, to, tail);
}
public async Task<string> ListPastProjectMeetings(
@@ -338,7 +338,7 @@ public sealed class MeetingSummaryTools
return string.Join('\n', lines);
}
public async Task<string> ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null)
public async Task<string> ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null, int? tail = null)
{
var summary = await ResolvePastSummaryAsync(path);
if (summary.Refusal is not null)
@@ -346,7 +346,7 @@ public sealed class MeetingSummaryTools
return summary.Refusal;
}
return ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to);
return AgentFileToolContent.ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to, tail);
}
public async Task<string> WriteProjectFile(
@@ -358,7 +358,7 @@ public sealed class MeetingSummaryTools
int? insert = null,
bool replace_file = false)
{
var editMode = FileLineEditMode.Create(@from, to, insert, replace_file);
var editMode = AgentFileEditMode.Create(@from, to, insert, replace_file);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for append; set replace_file=true only for whole-file replacement.";
@@ -375,17 +375,17 @@ public sealed class MeetingSummaryTools
{
await writeAudit.CaptureFileWriteAsync(
target.Path,
() => WriteProjectFileContentAsync(target.Path, content, editMode));
() => AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode));
}
else
{
await WriteProjectFileContentAsync(target.Path, content, editMode);
await AgentFileToolContent.WriteFileContentAsync(target.Path, content, editMode);
}
return $"{target.Project.Name}/{ToToolPath(path)}";
return $"{target.Project.Name}/{AgentFileToolContent.ToToolPath(path)}";
}
public async Task<string> Search(string keywords, string[]? projects = null)
public async Task<string> Search(string keywords, string[]? projects = null, string? file_pattern = null)
{
if (string.IsNullOrWhiteSpace(keywords))
{
@@ -400,10 +400,10 @@ public sealed class MeetingSummaryTools
var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames);
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1);
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1, file_pattern);
}
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null, int? tail = null)
{
if (!File.Exists(path))
{
@@ -411,7 +411,7 @@ public sealed class MeetingSummaryTools
}
var content = await File.ReadAllTextAsync(path);
return ReadLines(content, from, to);
return AgentFileToolContent.ReadLines(content, from, to, tail);
}
private async Task<MeetingNote> ReadMeetingNoteAsync()
@@ -575,7 +575,7 @@ public sealed class MeetingSummaryTools
}
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
return AgentFileToolContent.IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
}
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
@@ -602,147 +602,11 @@ public sealed class MeetingSummaryTools
}
var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path));
return IsWithinDirectory(projectFolder.Path, fullPath)
return AgentFileToolContent.IsWithinDirectory(projectFolder.Path, fullPath)
? new ProjectFileTarget(projectFolder, fullPath)
: null;
}
private static async Task WriteProjectFileContentAsync(
string filePath,
string content,
FileLineEditMode editMode)
{
if (editMode.Kind == FileLineEditKind.Overwrite)
{
await File.WriteAllTextAsync(filePath, content);
return;
}
if (editMode.Kind == FileLineEditKind.Append)
{
var existing = File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: "";
await File.WriteAllTextAsync(filePath, AppendContent(existing, content));
return;
}
var lines = File.Exists(filePath)
? (await File.ReadAllLinesAsync(filePath)).ToList()
: [];
var replacementLines = SplitLines(content);
if (editMode.Kind == FileLineEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
return;
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
}
private static List<string> SplitLines(string content)
{
if (content.Length == 0)
{
return [];
}
return content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.ToList();
}
private static string ReadLines(string content, int? from = null, int? to = null)
{
if (!from.HasValue && !to.HasValue)
{
return content;
}
var lines = SplitLines(content);
if (lines.Count == 0)
{
return "";
}
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
if (end < start)
{
return "";
}
return string.Join('\n', lines.GetRange(start, end - start + 1));
}
private static string ApplyLineEdit(
string existingContent,
string replacementContent,
FileLineEditMode editMode)
{
if (editMode.Kind == FileLineEditKind.Overwrite)
{
return replacementContent;
}
if (editMode.Kind == FileLineEditKind.Append)
{
return AppendContent(existingContent, replacementContent);
}
var lines = SplitLines(existingContent);
var replacementLines = SplitLines(replacementContent);
if (editMode.Kind == FileLineEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
return string.Join('\n', lines);
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
return string.Join('\n', lines);
}
private static string AppendContent(string existingContent, string content)
{
if (string.IsNullOrEmpty(existingContent))
{
return content;
}
if (string.IsNullOrEmpty(content))
{
return existingContent;
}
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
? ""
: "\n";
return existingContent + separator + content;
}
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
{
return projectResolver.GetBoundProjectsAsync(artifacts);
@@ -757,7 +621,8 @@ public sealed class MeetingSummaryTools
IReadOnlyList<BoundMeetingProject> projects,
IReadOnlyList<PastMeetingSummary> summaries,
string keywords,
bool singleProject)
bool singleProject,
string? filePattern)
{
System.Text.RegularExpressions.Regex regex;
try
@@ -776,7 +641,13 @@ public sealed class MeetingSummaryTools
{
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
{
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
var projectRelative = AgentFileToolContent.ToToolPath(Path.GetRelativePath(project.Path, file));
if (!AgentFileToolContent.MatchesGlob(projectRelative, filePattern))
{
continue;
}
var relative = Path.Combine(project.Name, projectRelative);
AddFileMatches(matches, regex, file, FormatSearchPath(relative, singleProject));
}
}
@@ -805,14 +676,14 @@ public sealed class MeetingSummaryTools
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
matches.Add($"{AgentFileToolContent.ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
}
}
}
private static string FormatSearchPath(string path, bool singleProject)
{
var formatted = ToToolPath(path);
var formatted = AgentFileToolContent.ToToolPath(path);
if (!singleProject)
{
return formatted;
@@ -822,20 +693,6 @@ public sealed class MeetingSummaryTools
return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..];
}
private static bool IsWithinDirectory(string directory, string path)
{
var normalizedDirectory = Path.GetFullPath(directory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
}
private static string ToToolPath(string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private async Task<List<PastMeetingSummary>> GetScopedPastSummariesAsync(IReadOnlySet<string> projectNames)
{
if (projectNames.Count == 0)
@@ -885,7 +742,7 @@ public sealed class MeetingSummaryTools
var summariesRoot = GetSummariesRoot();
var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path));
if (!IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
if (!AgentFileToolContent.IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
{
return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder.");
}
@@ -911,7 +768,7 @@ public sealed class MeetingSummaryTools
private static string NormalizePastMeetingSummaryToolPath(string path)
{
var toolPath = ToToolPath(path.Trim());
var toolPath = AgentFileToolContent.ToToolPath(path.Trim());
const string pastMeetingPrefix = "past-meetings/";
return toolPath.StartsWith(pastMeetingPrefix, StringComparison.OrdinalIgnoreCase)
? toolPath[pastMeetingPrefix.Length..]
@@ -950,7 +807,7 @@ public sealed class MeetingSummaryTools
return new PastMeetingSummary(
path,
ToToolPath(Path.GetRelativePath(summariesRoot, path)),
AgentFileToolContent.ToToolPath(Path.GetRelativePath(summariesRoot, path)),
string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(),
ParseDateTime(frontmatter.StartTime),
projects);
@@ -985,45 +842,6 @@ public sealed class MeetingSummaryTools
}
}
private sealed record FileLineEditMode(
FileLineEditKind Kind,
int? From = null,
int? To = null,
int? Insert = null)
{
public static FileLineEditMode? Create(int? from, int? to, int? insert, bool replaceFile)
{
if (replaceFile && (from.HasValue || to.HasValue || insert.HasValue))
{
return null;
}
if (insert.HasValue)
{
return from.HasValue || to.HasValue
? null
: new FileLineEditMode(FileLineEditKind.Insert, Insert: insert);
}
if (from.HasValue || to.HasValue)
{
return from.HasValue && to.HasValue && from.Value <= to.Value
? new FileLineEditMode(FileLineEditKind.Replace, From: from, To: to)
: null;
}
return new FileLineEditMode(replaceFile ? FileLineEditKind.Overwrite : FileLineEditKind.Append);
}
}
private enum FileLineEditKind
{
Append,
Overwrite,
Replace,
Insert
}
private sealed class MeetingNoteFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
@@ -127,19 +127,19 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadMeetingNote,
"read_meetingnote",
"Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadTranscript,
"read_transcript",
"Read the transcript markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the transcript markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadContext,
"read_context",
"Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
"Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadUserNotes,
"read_usernotes",
"Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. With from and to, read that clamped inclusive 1-based body line range."),
"Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
@@ -147,7 +147,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadGlossary,
"read_glossary",
"Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. May be empty."),
"Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. Supports from/to or tail. May be empty."),
AIFunctionFactory.Create(
tools.AddDictationWord,
"add_dictation_word",
@@ -171,7 +171,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
"Write the finished summary markdown to the configured summary note. Provide title when the meeting note has no title."),
"Write the finished summary markdown to the configured summary note and return the written summary filename. Provide title when the meeting note has no title."),
AIFunctionFactory.Create(
tools.ListProjects,
"list_projects",
@@ -183,7 +183,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadProjectFile,
"read_projectfile",
"Read a bound project file, optionally clamping to from and to inclusive line numbers."),
"Read a bound project file. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.WriteProjectFile,
"write_projectfile",
@@ -195,11 +195,11 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
AIFunctionFactory.Create(
tools.ReadPastProjectMeetingSummary,
"read_past_project_meeting_summary",
"Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. With from and to, read that clamped inclusive 1-based line range. Cannot read or write the current meeting summary."),
"Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. Supports from/to or tail. Cannot read or write the current meeting summary."),
AIFunctionFactory.Create(
tools.Search,
"search",
"Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted.")
"Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted. Use file_pattern to limit project files by glob.")
];
}
@@ -30,7 +30,7 @@ public static class MeetingTaskbarMenuBuilder
{
var items = new List<MeetingTaskbarMenuItem>
{
new("Edit rules and identities", MeetingTaskbarAction.EditRules)
new("Settings and logs", MeetingTaskbarAction.EditRules)
};
if (status.IsRecording)
@@ -1,251 +0,0 @@
#if WINDOWS
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
namespace MeetingAssistant.Workflow;
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
private readonly IServiceProvider services;
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
private readonly object sync = new();
private bool isRunning;
public MewUiWorkflowRulesEditorWindowService(
IServiceProvider services,
ILogger<MewUiWorkflowRulesEditorWindowService> logger)
{
this.services = services;
this.logger = logger;
}
public void Show()
{
lock (sync)
{
if (isRunning)
{
logger.LogInformation("Workflow rules editor UI is already running");
return;
}
isRunning = true;
}
logger.LogInformation("Starting workflow rules editor UI thread");
var thread = new Thread(RunWindow)
{
IsBackground = true,
Name = "Meeting Assistant Rules Editor"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
private void RunWindow()
{
try
{
logger.LogInformation("Workflow rules editor UI thread started");
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
var window = new WorkflowRulesEditorMewWindow(viewModel);
window.Run();
logger.LogInformation("Workflow rules editor UI window closed");
}
catch (Exception exception)
{
logger.LogError(exception, "Workflow rules editor UI failed");
}
finally
{
lock (sync)
{
isRunning = false;
}
}
}
}
internal sealed class WorkflowRulesEditorMewWindow
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly StackPanel conversationPanel = new();
private readonly ScrollViewer conversationScroll = new();
private readonly MultiLineTextBox input = new();
private readonly Button sendButton = new();
private readonly EventHandler changedHandler;
public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel)
{
this.viewModel = viewModel;
changedHandler = (_, _) => RequestRender();
this.viewModel.Changed += changedHandler;
}
public void Run()
{
ConfigureTheme();
conversationPanel
.Vertical()
.Spacing(8);
input
.Height(92)
.Wrap(true)
.Placeholder("Ask to make a rule or to list identities")
.OnTextChanged(text => viewModel.Draft = text)
.OnKeyDown(args =>
{
if (args.Key == Key.Enter && !args.ShiftKey)
{
args.Handled = true;
_ = SendAsync();
}
});
sendButton
.Content("Send")
.Width(84)
.OnClick(() => _ = SendAsync());
var window = new Window()
.Title("Edit rules and identities")
.Resizable(680, 760, 520, 460)
.Padding(0)
.OnLoaded(Render)
.OnClosed(() => viewModel.Changed -= changedHandler)
.Content(
new DockPanel()
.LastChildFill()
.Padding(12)
.Spacing(10)
.Children(
new DockPanel()
.LastChildFill()
.Spacing(8)
.DockBottom()
.Children(
sendButton.DockRight(),
input),
conversationScroll
.AutoVerticalScroll()
.NoHorizontalScroll()
.Content(conversationPanel)));
var icon = LoadWindowIcon();
if (icon is not null)
{
window.Icon = icon;
}
Application.Create()
.UseTheme(ThemeVariant.Dark)
.UseWin32()
.UseDirect2D()
.Run(window);
}
private static IconSource? LoadWindowIcon()
{
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
return File.Exists(iconPath)
? IconSource.FromFile(iconPath)
: null;
}
private static void ConfigureTheme()
{
ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with
{
ButtonFace = Color.FromRgb(245, 245, 245)
};
ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with
{
ButtonFace = Color.FromRgb(42, 46, 54)
};
}
private async Task SendAsync()
{
var sendTask = viewModel.SendAsync();
input.Text = viewModel.Draft;
await sendTask;
}
private void RequestRender()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(Render);
}
private void Render()
{
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
conversationScroll.VerticalOffset,
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight);
conversationPanel.Clear();
foreach (var message in viewModel.Messages)
{
conversationPanel.Add(CreateMessageCard(message));
}
if (viewModel.IsThinking)
{
conversationPanel.Add(new TextBlock()
.Text("Thinking...")
.Foreground(Color.FromRgb(156, 166, 181))
.Margin(4, 2, 4, 2));
}
sendButton.IsEnabled = !viewModel.IsThinking;
if (shouldAutoScroll)
{
QueueScrollConversationToBottom();
}
}
private void QueueScrollConversationToBottom()
{
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(DispatcherPriority.Idle, () =>
{
ScrollConversationToBottom();
dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom);
});
}
private void ScrollConversationToBottom()
{
conversationScroll.SetScrollOffsets(
conversationScroll.HorizontalOffset,
WorkflowRulesEditorScrollPolicy.GetBottomOffset(
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight));
}
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
{
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
return new Border()
.Padding(10)
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
.CornerRadius(8)
.BorderThickness(1)
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
.Child(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
}
}
#endif
@@ -1,7 +1,12 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Summary;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.AI;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Workflow;
@@ -15,6 +20,12 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue;
private readonly MeetingRecordingCoordinator recordingCoordinator;
private readonly IMeetingMetadataProvider meetingMetadataProvider;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly ISpeakerIdentityMergeService identityMergeService;
private readonly AsrDiagnosticService asrDiagnosticService;
private readonly IConfiguration configuration;
public WorkflowRulesEditorChatPipeline(
IOptions<MeetingAssistantOptions> options,
@@ -22,7 +33,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
ILogger<WorkflowRulesEditorChatPipeline> logger,
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue)
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue,
MeetingRecordingCoordinator recordingCoordinator,
IMeetingMetadataProvider meetingMetadataProvider,
ILaunchProfileOptionsProvider launchProfiles,
ISpeakerIdentityMergeService identityMergeService,
AsrDiagnosticService asrDiagnosticService,
IConfiguration configuration)
{
this.options = options.Value;
this.loggerFactory = loggerFactory;
@@ -30,6 +47,12 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
this.instructionBuilder = instructionBuilder;
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
this.recordingCoordinator = recordingCoordinator;
this.meetingMetadataProvider = meetingMetadataProvider;
this.launchProfiles = launchProfiles;
this.identityMergeService = identityMergeService;
this.asrDiagnosticService = asrDiagnosticService;
this.configuration = configuration;
}
public async Task<WorkflowRulesEditorChatResult> SendAsync(
@@ -47,7 +70,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
var tools = new WorkflowRulesEditorTools(
options,
speakerIdentityDbContextFactory,
samplePlaybackQueue);
samplePlaybackQueue,
recordingCoordinator,
meetingMetadataProvider,
launchProfiles,
identityMergeService,
asrDiagnosticService,
configuration);
var messages = conversation
.Select(ToChatMessage)
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
@@ -120,7 +149,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 +157,155 @@ 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.ListRecentSummaries,
"list_recent_summaries",
"List recent meeting summary notes from the configured summaries folder, newest first. Returns summary, meeting note, transcript, and assistant context paths to pass to the read tools."),
AIFunctionFactory.Create(
tools.ReadSummary,
"read_summary",
"Read one meeting summary file by path relative to the configured summaries folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadTranscript,
"read_transcript",
"Read one transcript file by path relative to the configured transcripts folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadMeetingNote,
"read_meeting_note",
"Read one meeting note file by path relative to the configured meeting notes folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.ReadContext,
"read_context",
"Read one assistant context file by path relative to the configured assistant context folder. Supports from/to or tail."),
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
"Create or update a meeting summary file by path relative to the configured summaries folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteTranscript,
"write_transcript",
"Create or update a transcript file by path relative to the configured transcripts folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteMeetingNote,
"write_meeting_note",
"Create or update a meeting note file by path relative to the configured meeting notes folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
"Create or update an assistant context file by path relative to the configured assistant context folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
AIFunctionFactory.Create(
tools.WriteSummaryFrontmatter,
"write_summary_frontmatter",
"Replace only the YAML frontmatter of a meeting summary file while preserving the body. The path is relative to the configured summaries folder."),
AIFunctionFactory.Create(
tools.WriteTranscriptFrontmatter,
"write_transcript_frontmatter",
"Replace only the YAML frontmatter of a transcript file while preserving the body. The path is relative to the configured transcripts folder."),
AIFunctionFactory.Create(
tools.WriteMeetingNoteFrontmatter,
"write_meeting_note_frontmatter",
"Replace only the YAML frontmatter of a meeting note file while preserving the body. The path is relative to the configured meeting notes folder."),
AIFunctionFactory.Create(
tools.WriteContextFrontmatter,
"write_context_frontmatter",
"Replace only the YAML frontmatter of an assistant context file while preserving the body. The path is relative to the configured assistant context folder."),
AIFunctionFactory.Create(
tools.SearchSummaries,
"search_summaries",
"Search meeting summary files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.SearchTranscripts,
"search_transcripts",
"Search transcript files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.SearchMeetingNotes,
"search_meeting_notes",
"Search meeting note files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.SearchContext,
"search_context",
"Search assistant context files with .NET regular expression syntax. Supports an optional file_pattern glob."),
AIFunctionFactory.Create(
tools.GetHealth,
"get_health",
"Return the same basic health information as the local /health endpoint."),
AIFunctionFactory.Create(
tools.GetRecordingStatus,
"get_recording_status",
"Return the current recording, artifact, process, and launch profile status without making an HTTP call."),
AIFunctionFactory.Create(
tools.DiagnoseCurrentMeeting,
"diagnose_current_meeting",
"Run the current meeting metadata diagnostic. Optional launch_profile selects a configured profile, started_at is a date/time, and timeout_seconds overrides the metadata lookup timeout."),
AIFunctionFactory.Create(
tools.MergeRecentSpeakerIdentities,
"merge_recent_speaker_identities",
"Run the recent speaker identity merge diagnostic. Optional recent_days overrides the configured merge age window; optional launch_profile selects profile options."),
AIFunctionFactory.Create(
tools.ReloadWorkflowConfiguration,
"reload_workflow_configuration",
"Reload application configuration in-process and return the currently bound workflow rules path."),
AIFunctionFactory.Create(
tools.DiagnoseAsrTranscribeFile,
"diagnose_asr_transcribe_file",
"Run ASR diagnostics on a local WAV file and return live transcription segments. Optional launch_profile selects a configured ASR profile."),
AIFunctionFactory.Create(
tools.DiagnoseAsrDiarizeFile,
"diagnose_asr_diarize_file",
"Run ASR diarization diagnostics on a local WAV file and return finished segments. Optional num_speakers and launch_profile mirror the HTTP diagnostic endpoint."),
AIFunctionFactory.Create(
tools.SearchIdentities,
"search_identities",
@@ -10,14 +10,24 @@ 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 list_recent_summaries, read_summary, read_transcript, read_meeting_note, read_context, and the matching search tools when the user wants help post-processing meeting notes or investigating past meeting artifacts.
Prefer list_recent_summaries first when the user refers to recent meetings without naming an exact file.
Use the matching write_summary, write_transcript, write_meeting_note, write_context, and *_frontmatter tools only to repair or post-process meeting artifacts the user asked you to change. Prefer the frontmatter-specific tools when only metadata needs repair.
Use get_health, get_recording_status, diagnose_current_meeting, merge_recent_speaker_identities, reload_workflow_configuration, diagnose_asr_transcribe_file, and diagnose_asr_diarize_file for diagnostics that would otherwise require local HTTP endpoints.
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 +55,12 @@ 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 +
"Meeting artifact tools can list recent summaries and read/search/write summaries, transcripts, meeting notes, and assistant context files for note post-processing and repair. Prefer frontmatter-specific write tools for metadata-only fixes." + Environment.NewLine + Environment.NewLine +
"Diagnostic tools mirror the local health, recording status, Outlook metadata, speaker identity merge, workflow reload, and ASR diagnostic endpoints without requiring HTTP." + 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 +70,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 +82,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;
}
}
}
@@ -5,6 +5,10 @@ internal enum WorkflowRulesEditorMarkdownInlineStyle
Normal,
Italic,
Bold,
BoldItalic,
Strikethrough,
Link,
Image,
Code
}
@@ -13,6 +17,24 @@ internal abstract record WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownParagraph(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownHeading(
int Level,
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownBlockQuote(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownImage(
string AltText,
string Target) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTaskList(
IReadOnlyList<WorkflowRulesEditorMarkdownTaskItem> Items) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTaskItem(
bool IsChecked,
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines);
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
internal sealed record WorkflowRulesEditorMarkdownTable(
@@ -21,7 +43,8 @@ internal sealed record WorkflowRulesEditorMarkdownTable(
internal sealed record WorkflowRulesEditorMarkdownInline(
string Text,
WorkflowRulesEditorMarkdownInlineStyle Style);
WorkflowRulesEditorMarkdownInlineStyle Style,
string? LinkTarget = null);
internal static class WorkflowRulesEditorMarkdown
{
@@ -64,6 +87,50 @@ internal static class WorkflowRulesEditorMarkdown
continue;
}
if (TryParseStandaloneImage(line, out var image))
{
FlushParagraph(blocks, paragraph);
blocks.Add(image);
continue;
}
if (TryParseHeading(line, out var heading))
{
FlushParagraph(blocks, paragraph);
blocks.Add(heading);
continue;
}
if (IsBlockQuote(line))
{
FlushParagraph(blocks, paragraph);
var quote = new List<string>();
while (index < lines.Length && IsBlockQuote(lines[index]))
{
quote.Add(lines[index].TrimStart()[1..].TrimStart());
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownBlockQuote(ParseInline(string.Join('\n', quote))));
index--;
continue;
}
if (TryParseTaskLine(line, out _))
{
FlushParagraph(blocks, paragraph);
var tasks = new List<WorkflowRulesEditorMarkdownTaskItem>();
while (index < lines.Length && TryParseTaskLine(lines[index], out var task))
{
tasks.Add(task);
index++;
}
blocks.Add(new WorkflowRulesEditorMarkdownTaskList(tasks));
index--;
continue;
}
if (IsTableStart(lines, index))
{
FlushParagraph(blocks, paragraph);
@@ -128,6 +195,72 @@ internal static class WorkflowRulesEditorMarkdown
continue;
}
}
else if (text.AsSpan(next).StartsWith("![", StringComparison.Ordinal))
{
if (TryParseImage(text, next, out var altText, out var target, out var end))
{
AddInline(
result,
altText,
WorkflowRulesEditorMarkdownInlineStyle.Image,
target);
index = end + 1;
continue;
}
}
else if (text[next] == '[')
{
var endLabel = text.IndexOf(']', next + 1);
if (endLabel > next &&
endLabel + 1 < text.Length &&
text[endLabel + 1] == '(')
{
var endTarget = text.IndexOf(')', endLabel + 2);
if (endTarget > endLabel + 2)
{
AddInline(
result,
text[(next + 1)..endLabel],
WorkflowRulesEditorMarkdownInlineStyle.Link,
text[(endLabel + 2)..endTarget]);
index = endTarget + 1;
continue;
}
}
}
else if (text[next] == '<')
{
if (TryParseAutolink(text, next, out var label, out var target, out var end))
{
AddInline(
result,
label,
WorkflowRulesEditorMarkdownInlineStyle.Link,
target);
index = end + 1;
continue;
}
}
else if (text.AsSpan(next).StartsWith("***", StringComparison.Ordinal))
{
var end = text.IndexOf("***", next + 3, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 3)..end], WorkflowRulesEditorMarkdownInlineStyle.BoldItalic);
index = end + 3;
continue;
}
}
else if (text.AsSpan(next).StartsWith("~~", StringComparison.Ordinal))
{
var end = text.IndexOf("~~", next + 2, StringComparison.Ordinal);
if (end > next)
{
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Strikethrough);
index = end + 2;
continue;
}
}
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
{
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
@@ -169,6 +302,141 @@ internal static class WorkflowRulesEditorMarkdown
paragraph.Clear();
}
private static bool TryParseHeading(string line, out WorkflowRulesEditorMarkdownHeading heading)
{
heading = null!;
var trimmed = line.TrimStart();
var level = trimmed.StartsWith("# ", StringComparison.Ordinal)
? 1
: trimmed.StartsWith("## ", StringComparison.Ordinal)
? 2
: 0;
if (level == 0)
{
return false;
}
heading = new WorkflowRulesEditorMarkdownHeading(level, ParseInline(trimmed));
return true;
}
private static bool IsBlockQuote(string line)
{
return line.TrimStart().StartsWith('>');
}
private static bool TryParseTaskLine(string line, out WorkflowRulesEditorMarkdownTaskItem task)
{
task = null!;
var trimmedStart = line.TrimStart();
if (trimmedStart.StartsWith("- [x] ", StringComparison.OrdinalIgnoreCase))
{
task = new WorkflowRulesEditorMarkdownTaskItem(true, ParseInline(trimmedStart[6..]));
return true;
}
if (trimmedStart.StartsWith("- [ ] ", StringComparison.Ordinal))
{
task = new WorkflowRulesEditorMarkdownTaskItem(false, ParseInline(trimmedStart[6..]));
return true;
}
return false;
}
private static bool TryParseStandaloneImage(
string line,
out WorkflowRulesEditorMarkdownImage image)
{
image = null!;
var trimmed = line.Trim();
if (!TryParseImage(trimmed, 0, out var altText, out var target, out var end) ||
end != trimmed.Length - 1)
{
return false;
}
image = new WorkflowRulesEditorMarkdownImage(altText, target);
return true;
}
private static bool TryParseImage(
string text,
int start,
out string altText,
out string target,
out int end)
{
altText = "";
target = "";
end = -1;
if (!text.AsSpan(start).StartsWith("![", StringComparison.Ordinal))
{
return false;
}
var endLabel = text.IndexOf(']', start + 2);
if (endLabel <= start ||
endLabel + 1 >= text.Length ||
text[endLabel + 1] != '(')
{
return false;
}
var endTarget = text.IndexOf(')', endLabel + 2);
if (endTarget <= endLabel + 2)
{
return false;
}
altText = text[(start + 2)..endLabel];
target = text[(endLabel + 2)..endTarget];
end = endTarget;
return true;
}
private static bool TryParseAutolink(
string text,
int start,
out string label,
out string target,
out int end)
{
label = "";
target = "";
end = text.IndexOf('>', start + 1);
if (end <= start + 1)
{
return false;
}
label = text[(start + 1)..end];
if (label.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
label.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
label.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
{
target = label;
return true;
}
if (IsEmailAddress(label))
{
target = "mailto:" + label;
return true;
}
return false;
}
private static bool IsEmailAddress(string text)
{
var at = text.IndexOf('@');
return at > 0 &&
at < text.Length - 1 &&
text.IndexOf('.', at + 1) > at + 1 &&
!text.Any(char.IsWhiteSpace);
}
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
{
return index + 1 < lines.Count &&
@@ -215,33 +483,41 @@ internal static class WorkflowRulesEditorMarkdown
private static void AddInline(
List<WorkflowRulesEditorMarkdownInline> result,
string text,
WorkflowRulesEditorMarkdownInlineStyle style)
WorkflowRulesEditorMarkdownInlineStyle style,
string? linkTarget = null)
{
if (text.Length == 0)
{
return;
}
if (result.Count > 0 && result[^1].Style == style)
if (result.Count > 0 && result[^1].Style == style && result[^1].LinkTarget == linkTarget)
{
result[^1] = result[^1] with { Text = result[^1].Text + text };
return;
}
result.Add(new WorkflowRulesEditorMarkdownInline(text, style));
result.Add(new WorkflowRulesEditorMarkdownInline(text, style, linkTarget));
}
private static int FindNextMarker(string text, int start)
{
var code = text.IndexOf('`', start);
var image = text.IndexOf("![", start, StringComparison.Ordinal);
var link = text.IndexOf('[', start);
var autolink = text.IndexOf('<', start);
var boldItalic = text.IndexOf("***", start, StringComparison.Ordinal);
var strikethrough = text.IndexOf("~~", start, StringComparison.Ordinal);
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
var italic = text.IndexOf('*', start);
if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*')
while (italic >= 0 &&
italic + 1 < text.Length &&
text[italic + 1] == '*')
{
italic = text.IndexOf('*', italic + 2);
}
return new[] { code, bold, italic }
return new[] { code, image, link, autolink, boldItalic, strikethrough, bold, italic }
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
@@ -0,0 +1,180 @@
namespace MeetingAssistant.Workflow;
internal sealed class WorkflowRulesEditorMarkdownLinkResolver
{
private static readonly string[] ImageUriSchemes = ["http", "https", "file"];
private readonly IReadOnlyList<string> rootPaths;
private readonly IReadOnlyList<string> recursiveRootPaths;
private readonly Dictionary<string, string> resolvedFiles = new(StringComparer.OrdinalIgnoreCase);
public WorkflowRulesEditorMarkdownLinkResolver(
IEnumerable<string> rootPaths,
IEnumerable<string>? recursiveRootPaths = null)
{
this.rootPaths = rootPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(path => Path.GetFullPath(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
this.recursiveRootPaths = (recursiveRootPaths ?? [])
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(path => Path.GetFullPath(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static WorkflowRulesEditorMarkdownLinkResolver Empty { get; } = new([]);
public static WorkflowRulesEditorMarkdownLinkResolver FromOptions(MeetingAssistantOptions options)
{
var artifactRoots = new[]
{
VaultPath.Resolve(options.Vault, options.Vault.AssistantContextFolder),
VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder),
VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder),
VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder),
VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder)
};
return new WorkflowRulesEditorMarkdownLinkResolver([
..artifactRoots,
Environment.CurrentDirectory,
AppContext.BaseDirectory
], artifactRoots);
}
public bool TryResolveImageUri(string? target, out Uri uri)
{
uri = null!;
if (string.IsNullOrWhiteSpace(target))
{
return false;
}
if (Uri.TryCreate(target, UriKind.Absolute, out uri!) &&
ImageUriSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase))
{
return true;
}
if (TryResolveExistingFilePath(target, out var path))
{
uri = new Uri(path);
return true;
}
return false;
}
public string ResolveOpenTarget(string? target)
{
if (string.IsNullOrWhiteSpace(target))
{
return "";
}
if (Uri.TryCreate(target, UriKind.Absolute, out var uri))
{
return uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase)
? uri.LocalPath
: uri.ToString();
}
return TryResolveExistingFilePath(target, out var path)
? path
: target;
}
private bool TryResolveExistingFilePath(string target, out string path)
{
path = "";
if (Path.IsPathRooted(target) && File.Exists(target))
{
path = Path.GetFullPath(target);
return true;
}
if (resolvedFiles.TryGetValue(target, out var cachedPath))
{
path = cachedPath;
return true;
}
if (TryResolveDirectRelativePath(target, out path) ||
TryFindRelativePathInRoots(target, out path))
{
resolvedFiles[target] = path;
return true;
}
return false;
}
private bool TryResolveDirectRelativePath(string target, out string path)
{
foreach (var root in rootPaths)
{
try
{
var candidate = Path.GetFullPath(target, root);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
catch
{
}
}
path = "";
return false;
}
private bool TryFindRelativePathInRoots(string target, out string path)
{
path = "";
var normalizedSuffix = NormalizeRelativePath(target);
if (string.IsNullOrWhiteSpace(normalizedSuffix))
{
return false;
}
var fileName = Path.GetFileName(normalizedSuffix);
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
foreach (var root in recursiveRootPaths.Where(Directory.Exists))
{
try
{
var match = Directory
.EnumerateFiles(root, fileName, SearchOption.AllDirectories)
.Where(candidate => NormalizeRelativePath(Path.GetRelativePath(root, candidate))
.EndsWith(normalizedSuffix, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
if (match is not null)
{
path = match;
return true;
}
}
catch
{
}
}
return false;
}
private static string NormalizeRelativePath(string path)
{
return path
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
.TrimStart(Path.DirectorySeparatorChar);
}
}
@@ -1,131 +1,150 @@
#if WINDOWS
using Aprillz.MewUI;
using Aprillz.MewUI.Controls;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using static MeetingAssistant.Workflow.WorkflowRulesEditorWpfTheme;
namespace MeetingAssistant.Workflow;
internal static class WorkflowRulesEditorMarkdownRenderer
{
public static UIElement CreateContent(string content)
public static UIElement CreateContent(
string content,
WorkflowRulesEditorMarkdownLinkResolver? linkResolver = null)
{
var panel = new StackPanel()
.Vertical()
.Spacing(7);
foreach (var block in WorkflowRulesEditorMarkdown.Parse(content))
linkResolver ??= WorkflowRulesEditorMarkdownLinkResolver.Empty;
var panel = new StackPanel
{
panel.Add(block switch
Orientation = Orientation.Vertical
};
var blocks = WorkflowRulesEditorMarkdown.Parse(content);
for (var index = 0; index < blocks.Count; index++)
{
var element = blocks[index] switch
{
WorkflowRulesEditorMarkdownHeading heading => CreateHeading(heading, linkResolver),
WorkflowRulesEditorMarkdownBlockQuote quote => CreateBlockQuote(quote, linkResolver),
WorkflowRulesEditorMarkdownImage image => CreateImageBlock(image, linkResolver),
WorkflowRulesEditorMarkdownTaskList tasks => CreateTaskList(tasks, linkResolver),
WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock),
WorkflowRulesEditorMarkdownTable table => CreateTable(table),
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph),
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph, linkResolver),
_ => new TextBlock()
});
};
if (index < blocks.Count - 1)
{
element.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 0, 7));
}
panel.Children.Add(element);
}
return panel;
}
private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph)
private static UIElement CreateHeading(
WorkflowRulesEditorMarkdownHeading heading,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var lines = SplitInlineLines(paragraph.Inlines);
if (lines.Count == 1)
{
return CreateParagraphLine(lines[0]);
}
var paragraph = CreateInlineParagraph(heading.Inlines, linkResolver, heading.Level == 1 ? 22 : 20);
paragraph.FontWeight = FontWeights.SemiBold;
var panel = new StackPanel()
.Vertical()
.Spacing(3);
foreach (var line in lines)
{
panel.Add(CreateParagraphLine(line));
}
return panel;
return CreateSelectableDocument([paragraph], fontSize: heading.Level == 1 ? 16.5 : 15.5);
}
private static UIElement CreateParagraphLine(IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
private static UIElement CreateBlockQuote(
WorkflowRulesEditorMarkdownBlockQuote quote,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var linePanel = new WrapPanel()
.Spacing(1);
foreach (var inline in inlines)
{
foreach (var element in CreateInlineElements(inline))
{
linePanel.Add(element);
}
}
var paragraph = CreateInlineParagraph(quote.Inlines, linkResolver);
return linePanel;
return new Border
{
Padding = new Thickness(10, 7, 10, 7),
BorderThickness = new Thickness(3, 0, 0, 0),
BorderBrush = QuoteBorder,
Background = QuoteBackground,
Child = CreateSelectableDocument([paragraph])
};
}
private static IEnumerable<UIElement> CreateInlineElements(WorkflowRulesEditorMarkdownInline inline)
private static UIElement CreateParagraph(
WorkflowRulesEditorMarkdownParagraph paragraph,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
{
yield return CreateInlineCode(inline.Text);
yield break;
}
var flowParagraph = CreateInlineParagraph(paragraph.Inlines, linkResolver);
foreach (var token in SplitInlineText(inline.Text))
{
var text = new TextBlock()
.Text(token)
.TextWrapping(TextWrapping.Wrap)
.Foreground(Color.FromRgb(230, 235, 243));
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
{
text.FontWeight(FontWeight.Bold);
}
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
{
text.FontFamily("Segoe UI Italic");
}
yield return text;
}
return CreateSelectableDocument([flowParagraph]);
}
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
private static UIElement CreateImageBlock(
WorkflowRulesEditorMarkdownImage image,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
var current = new List<WorkflowRulesEditorMarkdownInline>();
foreach (var inline in inlines)
{
var parts = inline.Text.Split('\n');
for (var index = 0; index < parts.Length; index++)
{
if (parts[index].Length > 0)
{
current.Add(inline with { Text = parts[index] });
}
if (index < parts.Length - 1)
{
lines.Add(current);
current = [];
}
}
}
lines.Add(current);
return lines;
return CreateImagePreview(image.AltText, image.Target, linkResolver);
}
private static UIElement CreateInlineCode(string text)
private static UIElement CreateTaskList(
WorkflowRulesEditorMarkdownTaskList tasks,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
return new Border()
.Padding(4, 1, 4, 2)
.Margin(1, 0, 1, 0)
.CornerRadius(4)
.BorderThickness(1)
.BorderBrush(Color.FromRgb(86, 94, 108))
.Background(Color.FromRgb(24, 28, 35))
.Child(new TextBlock()
.Text(text)
.FontFamily("Consolas")
.Foreground(Color.FromRgb(237, 241, 247)));
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
for (var row = 0; row < tasks.Items.Count; row++)
{
var item = tasks.Items[row];
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var glyph = new TextBlock
{
Text = item.IsChecked ? "☑" : "☐",
FontFamily = new FontFamily("Segoe UI Symbol"),
FontSize = 17,
Foreground = PrimaryText,
LineHeight = 19,
Margin = new Thickness(0, 0, 6, 3),
VerticalAlignment = VerticalAlignment.Top
};
Grid.SetRow(glyph, row);
Grid.SetColumn(glyph, 0);
grid.Children.Add(glyph);
var paragraph = CreateInlineParagraph(item.Inlines, linkResolver);
var text = CreateSelectableDocument([paragraph]);
text.Margin = new Thickness(0, 0, 0, 3);
Grid.SetRow(text, row);
Grid.SetColumn(text, 1);
grid.Children.Add(text);
}
return grid;
}
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
{
var run = new Run(codeBlock.Text)
{
FontFamily = new FontFamily("Consolas")
};
var paragraph = CreateParagraphBlock();
paragraph.Inlines.Add(run);
var document = CreateSelectableDocument([paragraph], CodeText);
return new Border
{
Padding = new Thickness(9, 7, 9, 7),
CornerRadius = new CornerRadius(6),
BorderThickness = new Thickness(1),
BorderBrush = CodeBorder,
Background = CodeBackground,
Child = document
};
}
private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table)
@@ -133,68 +152,330 @@ internal static class WorkflowRulesEditorMarkdownRenderer
var rows = table.Rows
.Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count)))
.ToArray();
var gridView = new GridView()
.HeaderHeight(30)
.RowHeight(30)
.CellPadding(new Thickness(8, 4, 8, 4))
.ShowGridLines(true)
.ZebraStriping(true);
for (var index = 0; index < table.Header.Count; index++)
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
for (var column = 0; column < table.Header.Count; column++)
{
var columnIndex = index;
gridView.AddColumn<MarkdownTableRow>(
table.Header[index],
GetMarkdownTableColumnWidth(table, index),
_ => new TextBlock()
.TextWrapping(TextWrapping.NoWrap)
.Foreground(Color.FromRgb(230, 235, 243)),
(element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)),
minWidth: 64,
resizable: true);
grid.ColumnDefinitions.Add(new ColumnDefinition
{
Width = new GridLength(GetMarkdownTableColumnWidth(table, column))
});
AddTableCell(grid, 0, column, table.Header[column], TableHeaderBackground, FontWeights.SemiBold);
}
return gridView
.ItemsSource(rows)
.MinWidth(Math.Min(900, table.Header.Count * 120))
.Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30));
}
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
{
return new Border()
.Padding(9)
.CornerRadius(6)
.BorderThickness(1)
.BorderBrush(Color.FromRgb(86, 94, 108))
.Background(Color.FromRgb(20, 24, 31))
.Child(new TextBlock()
.Text(codeBlock.Text)
.TextWrapping(TextWrapping.Wrap)
.FontFamily("Consolas")
.Foreground(Color.FromRgb(237, 241, 247)));
}
private static IEnumerable<string> SplitInlineText(string text)
{
var start = 0;
while (start < text.Length)
for (var row = 0; row < rows.Length; row++)
{
var end = start;
var isWhitespace = char.IsWhiteSpace(text[start]);
while (end < text.Length && char.IsWhiteSpace(text[end]) == isWhitespace)
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
for (var column = 0; column < table.Header.Count; column++)
{
end++;
AddTableCell(grid, row + 1, column, rows[row].GetCell(column), TableCellBackground, FontWeights.Normal);
}
var token = text[start..end];
if (token.Length > 0)
{
yield return token;
}
start = end;
}
return new ScrollViewer
{
Content = grid,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
MaxHeight = 420
};
}
private static RichTextBox CreateSelectableDocument(
IEnumerable<Block> blocks,
Brush? foreground = null,
double fontSize = 14)
{
var document = new FlowDocument
{
PagePadding = new Thickness(0),
FontFamily = new FontFamily("Segoe UI"),
FontSize = fontSize,
Foreground = foreground ?? PrimaryText
};
foreach (var block in blocks)
{
document.Blocks.Add(block);
}
return new RichTextBox
{
Document = document,
IsReadOnly = true,
IsDocumentEnabled = true,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Foreground = foreground ?? PrimaryText,
Padding = new Thickness(0),
Margin = new Thickness(0),
MinHeight = 0,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
};
}
private static Paragraph CreateParagraphBlock(double lineHeight = 19)
{
return new Paragraph
{
Margin = new Thickness(0),
LineHeight = lineHeight
};
}
private static Paragraph CreateInlineParagraph(
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
double lineHeight = 19)
{
var paragraph = CreateParagraphBlock(lineHeight);
foreach (var inline in inlines)
{
paragraph.Inlines.Add(CreateInline(inline, linkResolver));
}
return paragraph;
}
private static Inline CreateInline(
WorkflowRulesEditorMarkdownInline inline,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Link)
{
return CreateLink(inline, linkResolver);
}
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Image)
{
return CreateImage(inline, linkResolver);
}
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
{
return CreateInlineCode(inline.Text);
}
var run = new Run(inline.Text);
switch (inline.Style)
{
case WorkflowRulesEditorMarkdownInlineStyle.Bold:
run.FontWeight = FontWeights.SemiBold;
break;
case WorkflowRulesEditorMarkdownInlineStyle.BoldItalic:
run.FontWeight = FontWeights.SemiBold;
run.FontStyle = FontStyles.Italic;
break;
case WorkflowRulesEditorMarkdownInlineStyle.Italic:
run.FontStyle = FontStyles.Italic;
break;
case WorkflowRulesEditorMarkdownInlineStyle.Strikethrough:
run.TextDecorations = TextDecorations.Strikethrough;
break;
}
return run;
}
private static Inline CreateLink(
WorkflowRulesEditorMarkdownInline inline,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var hyperlink = new Hyperlink(new Run(inline.Text))
{
Foreground = LinkText,
TextDecorations = TextDecorations.Underline,
ToolTip = inline.LinkTarget,
Cursor = Cursors.Hand
};
if (!string.IsNullOrWhiteSpace(inline.LinkTarget))
{
hyperlink.Click += (_, args) =>
{
OpenTarget(inline.LinkTarget, linkResolver);
args.Handled = true;
};
}
return hyperlink;
}
private static Inline CreateInlineCode(string text)
{
var code = new TextBlock
{
Text = text,
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
Foreground = CodeText,
Background = CodeBackground,
Padding = new Thickness(0),
Margin = new Thickness(0),
LineHeight = 16,
VerticalAlignment = VerticalAlignment.Center
};
return new InlineUIContainer(new Border
{
BorderThickness = new Thickness(1),
BorderBrush = CodeBorder,
CornerRadius = new CornerRadius(4),
Background = CodeBackground,
Padding = new Thickness(4, 1, 4, 1),
Margin = new Thickness(1, 0, 1, 0),
Child = code
})
{
BaselineAlignment = BaselineAlignment.Center
};
}
private static Inline CreateImage(
WorkflowRulesEditorMarkdownInline inline,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var preview = CreateImagePreview(inline.Text, inline.LinkTarget, linkResolver);
return new InlineUIContainer(preview)
{
BaselineAlignment = BaselineAlignment.Center
};
}
private static UIElement CreateImagePreview(
string altText,
string? target,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
var border = new Border
{
Width = 180,
Height = 112,
Margin = new Thickness(0, 4, 0, 4),
Padding = new Thickness(6),
BorderThickness = new Thickness(1),
BorderBrush = CodeBorder,
CornerRadius = new CornerRadius(6),
Background = ImagePlaceholderBackground,
ToolTip = target,
Cursor = Cursors.Hand
};
border.AddHandler(UIElement.MouseLeftButtonUpEvent, new MouseButtonEventHandler((_, args) =>
{
OpenTarget(target, linkResolver);
args.Handled = true;
}), handledEventsToo: true);
if (TryCreateBitmap(target, linkResolver, out var bitmap))
{
var image = new Image
{
Source = bitmap,
Stretch = Stretch.Uniform,
ToolTip = target
};
image.ImageFailed += (_, _) => border.Child = CreateImagePlaceholder(altText);
border.Child = image;
}
else
{
border.Child = CreateImagePlaceholder(altText);
}
return border;
}
private static UIElement CreateImagePlaceholder(string altText)
{
return new TextBlock
{
Text = string.IsNullOrWhiteSpace(altText) ? "Image" : altText,
Foreground = PrimaryText,
TextAlignment = TextAlignment.Center,
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
};
}
private static bool TryCreateBitmap(
string? target,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
out BitmapImage bitmap)
{
bitmap = null!;
if (string.IsNullOrWhiteSpace(target) || !linkResolver.TryResolveImageUri(target, out var uri))
{
return false;
}
try
{
bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.UriSource = uri;
bitmap.DecodePixelWidth = 360;
bitmap.EndInit();
bitmap.Freeze();
return true;
}
catch
{
return false;
}
}
private static void OpenTarget(
string? target,
WorkflowRulesEditorMarkdownLinkResolver linkResolver)
{
if (string.IsNullOrWhiteSpace(target))
{
return;
}
try
{
var openTarget = linkResolver.ResolveOpenTarget(target);
if (string.IsNullOrWhiteSpace(openTarget))
{
return;
}
Process.Start(new ProcessStartInfo(openTarget)
{
UseShellExecute = true
});
}
catch
{
}
}
private static void AddTableCell(
Grid grid,
int row,
int column,
string text,
Brush background,
FontWeight fontWeight)
{
var paragraph = CreateParagraphBlock();
paragraph.Inlines.Add(new Run(text) { FontWeight = fontWeight });
var document = CreateSelectableDocument([paragraph], PrimaryText);
document.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
var border = new Border
{
Padding = new Thickness(8, 4, 8, 4),
BorderThickness = new Thickness(0, 0, 1, 1),
BorderBrush = CodeBorder,
Background = background,
Child = document
};
Grid.SetRow(border, row);
Grid.SetColumn(border, column);
grid.Children.Add(border);
}
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
#if WINDOWS
using System.Windows.Media;
namespace MeetingAssistant.Workflow;
internal static class WorkflowRulesEditorWpfTheme
{
public static readonly Brush WindowBackground = Brush(29, 31, 36);
public static readonly Brush UserCardBackground = Brush(26, 48, 78);
public static readonly Brush AgentCardBackground = Brush(35, 39, 46);
public static readonly Brush UserCardBorder = Brush(68, 95, 132);
public static readonly Brush AgentCardBorder = Brush(69, 75, 86);
public static readonly Brush PrimaryText = Brush(230, 235, 243);
public static readonly Brush MutedText = Brush(156, 166, 181);
public static readonly Brush InputBackground = Brush(20, 22, 26);
public static readonly Brush InputBorder = Brush(68, 95, 132);
public static readonly Brush InputFocusedBorder = Brush(84, 135, 214);
public static readonly Brush ButtonBackground = Brush(42, 46, 54);
public static readonly Brush ButtonHoverBackground = Brush(50, 56, 68);
public static readonly Brush ButtonPressedBackground = Brush(31, 36, 45);
public static readonly Brush DisabledButtonBackground = Brush(32, 35, 41);
public static readonly Brush DisabledButtonBorder = Brush(52, 57, 66);
public static readonly Brush DisabledText = Brush(115, 124, 139);
public static readonly Brush CodeText = Brush(237, 241, 247);
public static readonly Brush CodeBackground = Brush(20, 24, 31);
public static readonly Brush CodeBorder = Brush(86, 94, 108);
public static readonly Brush TableHeaderBackground = Brush(42, 46, 54);
public static readonly Brush TableCellBackground = Brush(31, 35, 42);
public static readonly Brush LinkText = Brush(119, 166, 255);
public static readonly Brush QuoteBorder = Brush(92, 111, 140);
public static readonly Brush QuoteBackground = Brush(31, 35, 43);
public static readonly Brush ImagePlaceholderBackground = Brush(24, 28, 35);
private static SolidColorBrush Brush(byte red, byte green, byte blue)
{
var brush = new SolidColorBrush(Color.FromRgb(red, green, blue));
brush.Freeze();
return brush;
}
}
#endif
@@ -0,0 +1,404 @@
#if WINDOWS
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using static MeetingAssistant.Workflow.WorkflowRulesEditorWpfTheme;
namespace MeetingAssistant.Workflow;
internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
internal const string WindowTitle = "Settings and logs";
private readonly IServiceProvider services;
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
private readonly ILogger<WpfWorkflowRulesEditorWindowService> logger;
private readonly object sync = new();
private Dispatcher? dispatcher;
private WorkflowRulesEditorWpfWindow? window;
private bool isStarted;
public WpfWorkflowRulesEditorWindowService(
IServiceProvider services,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
ILogger<WpfWorkflowRulesEditorWindowService> logger)
{
this.services = services;
this.linkResolver = linkResolver;
this.logger = logger;
}
public void Show()
{
EnsureUiThread();
dispatcher?.BeginInvoke(ShowOnUiThread);
}
private void EnsureUiThread()
{
lock (sync)
{
if (isStarted)
{
return;
}
isStarted = true;
var ready = new ManualResetEventSlim();
var thread = new Thread(() => RunApplication(ready))
{
IsBackground = true,
Name = "Meeting Assistant Settings and Logs"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
if (!ready.Wait(TimeSpan.FromSeconds(10)))
{
isStarted = false;
throw new TimeoutException("Settings and logs UI thread did not start.");
}
}
}
private void RunApplication(ManualResetEventSlim ready)
{
try
{
logger.LogInformation("Settings and logs WPF thread started");
var application = new Application
{
ShutdownMode = ShutdownMode.OnExplicitShutdown
};
dispatcher = application.Dispatcher;
ready.Set();
application.Run();
}
catch (Exception exception)
{
logger.LogError(exception, "Settings and logs WPF thread failed");
ready.Set();
}
finally
{
lock (sync)
{
dispatcher = null;
window = null;
isStarted = false;
}
}
}
private void ShowOnUiThread()
{
try
{
window ??= new WorkflowRulesEditorWpfWindow(
services.GetRequiredService<WorkflowRulesEditorChatViewModel>(),
linkResolver,
() => window = null);
window.Show();
if (window.WindowState == WindowState.Minimized)
{
window.WindowState = WindowState.Normal;
}
window.Activate();
}
catch (Exception exception)
{
logger.LogError(exception, "Settings and logs WPF window failed");
}
}
}
internal sealed class WorkflowRulesEditorWpfWindow : Window
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
private readonly Action closed;
private readonly StackPanel conversationPanel = new() { Orientation = Orientation.Vertical };
private readonly ScrollViewer conversationScroll = new();
private readonly Border inputFrame = new();
private readonly TextBox input = new();
private readonly TextBlock inputPlaceholder = new();
private readonly Button sendButton = new();
private readonly EventHandler changedHandler;
public WorkflowRulesEditorWpfWindow(
WorkflowRulesEditorChatViewModel viewModel,
WorkflowRulesEditorMarkdownLinkResolver linkResolver,
Action closed)
{
this.viewModel = viewModel;
this.linkResolver = linkResolver;
this.closed = closed;
changedHandler = (_, _) => RequestRender();
this.viewModel.Changed += changedHandler;
Title = WpfWorkflowRulesEditorWindowService.WindowTitle;
Width = 840;
Height = 760;
MinWidth = 520;
MinHeight = 460;
Background = WindowBackground;
Content = CreateLayout();
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
if (File.Exists(iconPath))
{
Icon = System.Windows.Media.Imaging.BitmapFrame.Create(new Uri(iconPath));
}
Closed += (_, _) =>
{
viewModel.Changed -= changedHandler;
closed();
};
ConfigureInput();
Render();
}
private UIElement CreateLayout()
{
var root = new Grid
{
Margin = new Thickness(12)
};
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
conversationScroll.Content = conversationPanel;
conversationScroll.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
conversationScroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
conversationScroll.Padding = new Thickness(0, 0, 4, 0);
Grid.SetRow(conversationScroll, 0);
root.Children.Add(conversationScroll);
var inputRow = new Grid { Margin = new Thickness(0, 10, 0, 0) };
inputRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
inputRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetRow(inputRow, 1);
Grid.SetColumn(inputFrame, 0);
inputRow.Children.Add(inputFrame);
Grid.SetColumn(sendButton, 1);
inputRow.Children.Add(sendButton);
root.Children.Add(inputRow);
return root;
}
private void ConfigureInput()
{
input.AcceptsReturn = true;
input.TextWrapping = TextWrapping.Wrap;
input.MinHeight = 86;
input.MaxHeight = 124;
input.Padding = new Thickness(12, 9, 12, 9);
input.Background = Brushes.Transparent;
input.Foreground = PrimaryText;
input.BorderThickness = new Thickness(0);
input.CaretBrush = PrimaryText;
input.FontSize = 14;
input.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
input.TextChanged += (_, _) =>
{
viewModel.Draft = input.Text;
inputPlaceholder.Visibility = string.IsNullOrEmpty(input.Text)
? Visibility.Visible
: Visibility.Collapsed;
};
input.PreviewKeyDown += OnInputPreviewKeyDown;
input.GotKeyboardFocus += (_, _) => inputFrame.BorderBrush = InputFocusedBorder;
input.LostKeyboardFocus += (_, _) => inputFrame.BorderBrush = InputBorder;
inputPlaceholder.Text = "ask me what I can do for you";
inputPlaceholder.Foreground = MutedText;
inputPlaceholder.Margin = new Thickness(13, 9, 13, 9);
inputPlaceholder.IsHitTestVisible = false;
var inputGrid = new Grid();
inputGrid.Children.Add(input);
inputGrid.Children.Add(inputPlaceholder);
inputFrame.Child = inputGrid;
inputFrame.Margin = new Thickness(0, 0, 10, 0);
inputFrame.Background = InputBackground;
inputFrame.BorderBrush = InputBorder;
inputFrame.BorderThickness = new Thickness(1);
inputFrame.CornerRadius = new CornerRadius(7);
sendButton.Content = "Send";
sendButton.Width = 104;
sendButton.MinHeight = 86;
sendButton.Background = ButtonBackground;
sendButton.Foreground = PrimaryText;
sendButton.BorderBrush = InputBorder;
sendButton.BorderThickness = new Thickness(1);
sendButton.FontWeight = FontWeights.SemiBold;
sendButton.Cursor = Cursors.Hand;
sendButton.Style = CreateSendButtonStyle();
sendButton.Click += async (_, _) => await SendAsync();
}
private async void OnInputPreviewKeyDown(object sender, KeyEventArgs args)
{
if (IsEnterKey(args) && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) is false)
{
args.Handled = true;
await SendAsync();
}
}
private static bool IsEnterKey(KeyEventArgs args)
{
return args.Key is Key.Enter or Key.Return ||
args.SystemKey is Key.Enter or Key.Return ||
args.ImeProcessedKey is Key.Enter or Key.Return;
}
private async Task SendAsync()
{
var sendTask = viewModel.SendAsync();
input.Text = viewModel.Draft;
await sendTask;
}
private void RequestRender()
{
if (Dispatcher.CheckAccess())
{
Render();
return;
}
Dispatcher.BeginInvoke(Render);
}
private void Render()
{
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
conversationScroll.VerticalOffset,
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight);
conversationPanel.Children.Clear();
foreach (var message in viewModel.Messages)
{
conversationPanel.Children.Add(CreateMessageCard(message));
}
if (viewModel.IsThinking)
{
conversationPanel.Children.Add(new TextBlock
{
Text = "Thinking...",
Foreground = MutedText,
Margin = new Thickness(4, 2, 4, 2)
});
}
sendButton.IsEnabled = !viewModel.IsThinking;
sendButton.Cursor = sendButton.IsEnabled ? Cursors.Hand : Cursors.Arrow;
if (shouldAutoScroll)
{
Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, ScrollConversationToBottom);
Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, ScrollConversationToBottom);
}
}
private void ScrollConversationToBottom()
{
conversationScroll.ScrollToVerticalOffset(WorkflowRulesEditorScrollPolicy.GetBottomOffset(
conversationScroll.ViewportHeight,
conversationPanel.ActualHeight));
}
private UIElement CreateMessageCard(WorkflowRulesEditorChatMessage message)
{
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
var cardContent = new StackPanel { Orientation = Orientation.Vertical };
cardContent.Children.Add(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content, linkResolver));
var card = new Border
{
Padding = new Thickness(12, 9, 12, 9),
Margin = isUser ? new Thickness(42, 0, 4, 8) : new Thickness(0, 0, 42, 8),
CornerRadius = new CornerRadius(8),
BorderThickness = new Thickness(1),
BorderBrush = isUser ? UserCardBorder : AgentCardBorder,
Background = isUser ? UserCardBackground : AgentCardBackground,
Child = cardContent
};
return card;
}
private static Style CreateSendButtonStyle()
{
var style = new Style(typeof(Button));
style.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(12, 0, 12, 0)));
style.Setters.Add(new Setter(Control.TemplateProperty, CreateSendButtonTemplate()));
style.Triggers.Add(new Trigger
{
Property = UIElement.IsMouseOverProperty,
Value = true,
Setters =
{
new Setter(Control.BackgroundProperty, ButtonHoverBackground)
}
});
style.Triggers.Add(new Trigger
{
Property = ButtonBase.IsPressedProperty,
Value = true,
Setters =
{
new Setter(Control.BackgroundProperty, ButtonPressedBackground)
}
});
style.Triggers.Add(new Trigger
{
Property = UIElement.IsEnabledProperty,
Value = false,
Setters =
{
new Setter(Control.BackgroundProperty, DisabledButtonBackground),
new Setter(Control.BorderBrushProperty, DisabledButtonBorder),
new Setter(Control.ForegroundProperty, DisabledText),
new Setter(UIElement.OpacityProperty, 1.0)
}
});
return style;
}
private static ControlTemplate CreateSendButtonTemplate()
{
var border = new FrameworkElementFactory(typeof(Border));
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(7));
border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty));
border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Control.BorderBrushProperty));
border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Control.BorderThicknessProperty));
var content = new FrameworkElementFactory(typeof(TextBlock));
content.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center);
content.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
content.SetValue(TextBlock.TextProperty, new TemplateBindingExtension(ContentControl.ContentProperty));
content.SetValue(TextBlock.ForegroundProperty, new TemplateBindingExtension(Control.ForegroundProperty));
content.SetValue(TextBlock.FontWeightProperty, new TemplateBindingExtension(Control.FontWeightProperty));
border.AppendChild(content);
return new ControlTemplate(typeof(Button))
{
VisualTree = border
};
}
}
#endif
+2 -1
View File
@@ -11,7 +11,7 @@
"AssistantContextFolder": "Meetings\\Assistant Context",
"SummariesFolder": "Meetings\\Summaries",
"ProjectsFolder": "Projects",
"DictationWordsPath": "C:\\Users\\masc3\\OpenCloud\\Persönlich\\Vault\\Exxeta\\Meetings\\Dictionary.md"
"DictationWordsPath": "Meetings\\Dictionary.md"
},
"Recording": {
"TranscriptionProvider": "azure-speech",
@@ -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,
+3 -232
View File
@@ -75,6 +75,7 @@ POST /recording/abort
POST /asr/transcribe-file
POST /asr/diarize-file
POST /diagnostics/workflow/reload
POST /diagnostics/settings-and-logs/show
POST /meetings/current/summary/run
POST /meetings/summary/retry
GET /meetings/summary/retry?summaryPath=...
@@ -82,239 +83,9 @@ GET /meetings/summary/retry?summaryPath=...
## Configuration
Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` section:
Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` section. The repository `appsettings.json` stores local paths, endpoint URLs, model names, and environment variable names; secret values should stay in environment variables referenced by `KeyEnv` settings.
```json
{
"MeetingAssistant": {
"Hotkey": {
"Toggle": "Ctrl+Alt+M",
"Abort": "Ctrl+Alt+Z"
},
"Vault": {
"TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts",
"MeetingNotesFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Notes",
"AssistantContextFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Assistant Context",
"SummariesFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Summaries",
"ProjectsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Projects",
"DictationWordsPath": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\dictation-words.md"
},
"Recording": {
"TranscriptionProvider": "funasr",
"SampleRate": 16000,
"Channels": 1,
"MicrophoneMixGain": 1,
"SystemAudioMixGain": 1,
"StopProcessingTimeout": "00:10:00",
"MaxMetadataAttendeeImportCount": 30,
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings"
},
"FunAsr": {
"Endpoint": "ws://127.0.0.1:10095",
"Mode": "2pass",
"ChunkSize": [5, 10, 5],
"ChunkInterval": 10,
"EncoderChunkLookBack": 4,
"DecoderChunkLookBack": 1,
"Itn": true,
"EmitOnlinePartials": false,
"WavName": "meeting",
"FinalResultTimeout": "00:00:10",
"Backend": {
"Enabled": true,
"DockerCommand": "docker",
"Image": "registry.cn-hangzhou.aliyuncs.com/funasr_repo/funasr:funasr-runtime-sdk-online-cpu-0.1.13",
"ContainerName": "meeting-assistant-funasr",
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\FunASR\\models",
"ContainerPort": 10095,
"Privileged": true,
"CommandTimeout": "00:15:00",
"StartupTimeout": "00:10:00",
"StopOnDispose": true,
"ServerCommand": "cd /workspace/FunASR/runtime && bash run_server_2pass.sh --download-model-dir /workspace/models --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx --model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx --online-model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online-onnx --punc-dir damo/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727-onnx --itn-dir thuduj12/fst_itn_zh --certfile 0 --hotword /workspace/models/hotwords.txt && tail -f /dev/null"
},
"Diarization": {
"Enabled": true,
"AsrModel": "paraformer-zh",
"VadModel": "fsmn-vad",
"PunctuationModel": "ct-punc",
"SpeakerModel": "cam++",
"BatchSizeSeconds": 300,
"BatchSizeThresholdSeconds": 60,
"CommandTimeout": "00:30:00"
}
},
"WhisperLocal": {
"ModelPath": "models\\ggml-base.bin",
"Language": "auto",
"WindowSeconds": 15,
"Diarization": {
"Enabled": true,
"DockerCommand": "docker",
"BaseImage": "python:3.11-slim",
"Image": "meeting-assistant-pyannote:local",
"BuildImage": true,
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"AnnotationSource": "SpeakerDiarization",
"AlignmentMode": "PyannoteTurns",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "00:30:00"
}
},
"AzureSpeech": {
"Endpoint": "https://germanywestcentral.api.cognitive.microsoft.com/",
"Region": "germanywestcentral",
"Language": "en-US",
"KeyEnv": "AZURE_SPEECH_KEY",
"RecognitionStopTimeout": "00:03:00",
"DiarizeIntermediateResults": true
},
"SpeakerIdentification": {
"MinimumSampleSpeechDuration": "00:00:30",
"MaximumSampleSegmentGap": "00:00:01",
"PyannoteValidation": {
"Enabled": true,
"MinimumSingleSpeakerCoverage": 0.9,
"MinimumMatchingKnownSnippetRatio": 0.5,
"Diarization": {
"Enabled": false,
"DockerCommand": "docker",
"Image": "meeting-assistant-pyannote:local",
"BuildImage": true,
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "01:00:00"
}
}
},
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
},
"WorkflowRulesEditor": {
"Endpoint": "",
"KeyEnv": "",
"Model": ""
},
"Screenshots": {
"Hotkey": "Ctrl+Alt+S",
"AttachmentsFolder": "Attachments",
"Ocr": {
"Enabled": false,
"Endpoint": "",
"KeyEnv": "LITELLM_API_KEY",
"Model": "",
"Timeout": "00:02:00",
"Prompt": "This screenshot was captured during a meeting..."
}
},
"Agent": {
"Endpoint": "https://litellm.schweigert.cloud",
"KeyEnv": "LITELLM_API_KEY",
"Model": "chatgpt/gpt-5.5",
"EnableThinking": true,
"ReasoningEffort": "Medium",
"ReconnectionAttempts": 2,
"ReconnectionDelay": "00:00:02",
"ContextWindowTokens": 128000,
"MaxOutputTokens": 8192,
"EnableCompaction": true,
"CompactionRemainingRatio": 0.10,
"ResponsesCompactPath": "responses/compact"
},
"Api": {
"PublicBaseUrl": "http://localhost:5090"
}
}
}
```
`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime: on application startup it begins a non-blocking warm-up for the default launch profile and any named launch profile that selects `funasr`. That warm-up pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops it when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy. Docker commands are bounded by `CommandTimeout`, and backend WebSocket readiness is bounded by `StartupTimeout`. FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`.
During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription. The active launch profile's `Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during that final mix and default to `1`, because the signals are expected to be distinct after echo cancellation. The recorder writes only the mixed stream to the temporary WAV under `TemporaryRecordingsFolder`. After capture stops and the streaming ASR provider drains the already-captured audio, FunASR Python `AutoModel` can run with VAD, punctuation, and CAMPPlus (`spk_model="cam++"`) over that temporary WAV. If sentence-level speaker labels are returned, the transcript markdown is rewritten with the final speaker-attributed segments. If final diarization is disabled or returns no segments, the live streaming transcript is kept. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts.
`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `ModelPath` before using live transcription. When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text. When `BuildImage` is true, Meeting Assistant builds the configured local pyannote Docker image if Docker does not already have it, so Python packages are installed once instead of during every diarization call. The configured `ModelsFolder` is mounted as the persistent pyannote model cache and stores Hugging Face, torch, and pip cache artifacts so model downloads are reused across runs. Active pyannote runtimes are also warmed up on application start so image setup and model download do not wait for the first diarization request. If pyannote is disabled, has no token, or returns no turns, the live Whisper transcript is kept.
`azure-speech` streams Meeting Assistant's captured PCM chunks to Azure AI Speech using the Speech SDK `ConversationTranscriber`; it does not use an Azure-owned microphone capture or MAS/AEC input path. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed; the default is 3 minutes so Azure has time to finalize longer speaker-recognition samples without letting diagnostic runs wait indefinitely. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote.
Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization, reads its Hugging Face token from the nested diarization options, warms that runtime on application start, and defaults the validation command timeout to 1 hour because the local model can need substantial setup time.
ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider.
`POST /asr/transcribe-file` is a local diagnostic endpoint for the configured ASR backend. Send `{ "path": "C:\\path\\to\\sample.wav" }` with a 16-bit PCM WAV file to stream it through the configured speech recognition pipeline and return the emitted live transcript segments.
For real meeting recordings, Meeting Assistant derives the optional finished-transcript `numSpeakers` hint from the meeting note `attendees` frontmatter. The hint is set to the attendee count only when at least two attendees are listed; empty and single-person attendee lists are ignored. For pyannote-backed finalization, that hint is passed to pyannote as `num_speakers`.
`POST /asr/diarize-file` streams a local 16-bit PCM WAV through the configured speech recognition pipeline and returns the finished transcript. This is useful for checking the FunASR CAMPPlus path or the local Whisper plus pyannote path without starting a live recording. The request can include an optional speaker-count hint: `{ "path": "C:\\path\\to\\sample.wav", "numSpeakers": 5 }`.
`DictationWordsPath` is reserved for providers that can accept vocabulary hints. It points to a markdown word list containing unusual project, person, product, or domain terms that should be biased during transcription when the provider supports it.
Meeting notes link to separate transcript, assistant context, and summary markdown artifacts using the configured vault folders.
`Hotkey:Abort` configures a global discard shortcut. The default is `Ctrl+Alt+Z`. Aborting an active recording stops capture/transcription, deletes the meeting note, transcript, assistant context, summary file if present, and linked screenshot attachments for that run, and skips automatic summary generation.
Launch profile toggle hotkeys start a meeting when idle. When a different launch profile toggle is pressed during an active meeting, Meeting Assistant keeps the same meeting note, transcript, assistant context, and metadata; drains the current speech recognition pipeline without summarizing; appends a transcript marker; clears run-local speaker mappings; and continues transcription through the target profile. Pressing the same active profile toggle still stops the meeting normally.
On Windows, Meeting Assistant shows a taskbar notification icon. The icon indicates idle, recording, or post-recording summarizing/processing state. Its right-click menu can start a recording for any configured launch profile, stop and transcribe the active recording, cancel and discard the active recording, or switch an active recording to another configured launch profile. A newer active recording takes priority in the icon state even if an older stopped run is still summarizing.
Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary.
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
Assistant context notes keep their artifact links in frontmatter and expose a lifecycle `state`: `collecting metadata`, `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`. Meeting Assistant updates this state as metadata lookup, recording, final speaker recognition, and summary generation progress.
`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter.
`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`. The initial supported steps are `add_attendee`, `remove_attendee`, `set_property`, `add_context`, and `add_project`.
See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes.
`POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options.
`Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context. Launch profiles can override the screenshot hotkey with `LaunchProfiles:{profile}:Screenshots:Hotkey`; only explicitly configured profile hotkeys are registered, so profile-specific hotkeys do not silently inherit and collide with the default profile.
`Screenshots:Ocr` optionally enables vision extraction for screenshots. When `Enabled` is true, Meeting Assistant sends the screenshot and prompt to an OpenAI-compatible Responses endpoint and replaces the screenshot OCR placeholder with the model output. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model, which keeps local OCR on the same LiteLLM backend as summarization. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. The default prompt asks the model to say whether visible participants are clearly the exact meeting participants or only a partial result, and to return pixel crop coordinates when it can confidently isolate only the presentation or shared-screen content; valid crop boxes are saved as `*-cropped.png` beside the original screenshot and linked before the OCR block. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`.
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks. The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear; partial participant evidence from screenshots should not be used to remove invitees by itself. It can also override a transcript speaker label with a named speaker only when the evidence is very certain, such as a user note, OCR at a correlating meeting timestamp, or a clear context cue. If that named speaker already exists in the transcript, `override_speaker` refuses by default; the agent must pass `merge=true` only when it is certain both labels are the same identity. When it is certain that a speaker identity was wrongfully matched, it can delete that identity, relabeling transcript speaker labels to `Removed-<n>` and removing the local speaker identity database row before future matching. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts.
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget.
The summary endpoints are:
```text
POST /meetings/current/summary/run
POST /meetings/summary/retry
GET /meetings/summary/retry?summaryPath=...
```
`POST /meetings/summary/retry` accepts `{ "summaryPath": "20260519-124110-summary.md" }` or an absolute path under the configured summaries folder. It resolves the linked meeting note from frontmatter and reruns the summary pipeline, overwriting the existing summary file with either the new summary or a new failure report.
Failure summary notes include a clickable retry link using `Api:PublicBaseUrl`, for example `http://localhost:5090/meetings/summary/retry?summaryPath=20260519-124110-summary.md`. The GET endpoint exists for Obsidian/browser clicks and performs the same overwrite behavior as the POST endpoint. Meeting note bodies stay reserved for user-authored notes.
The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, project files, and past project meeting summaries. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts and past summaries incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using append-by-default, explicit whole-file replacement, replace, and insert modes. Project lookup, past-meeting lookup, and search tools are constrained to the projects listed in the meeting note frontmatter:
When assistant context contains cropped screenshot links produced by OCR, the summary instructions tell the agent to include only the most relevant cropped screenshots in the summary and markdown-link them near the related summary text.
- `read_meetingnote`
- `list_projects`
- `list_projectfiles`
- `read_projectfile`
- `write_projectfile`
- `list_past_project_meetings`
- `read_past_project_meeting_summary`
- `search`
- `write_context`
- `add_attendee`
- `remove_attendee`
- `override_speaker`
- `delete_identity`
`list_past_project_meetings` reads only summary notes from the configured summaries folder and lists prior summaries assigned to the current meeting's projects, excluding the current summary file. It supports pagination through `page` and `page_size` and can optionally filter to a subset of the current meeting's projects. If the requested project is not assigned to the current meeting, the tool refuses the request. `read_past_project_meeting_summary` reads one of those prior summary files as read-only historical context, using the same `from` and `to` line range behavior as other read tools. It cannot write or mutate past summaries; `write_summary` is only for the current meeting's summary.
`search` accepts .NET regular expression syntax and returns matches as `filename:line text`. It searches both project files and past meeting summaries for the requested current-meeting project scope. If one project is searched, project-file paths are relative to that project; if multiple projects are searched, project-file paths include the project folder name. Past summary matches are prefixed with `past-meetings/`, and `read_past_project_meeting_summary` accepts those prefixed paths directly.
`write_projectfile` writes inside an existing project folder. With no line arguments it appends or creates the file. With `replace_file: true` it replaces the whole file. With `from` and `to` it replaces that inclusive 1-based line range. With `insert` it inserts content at that 1-based line position.
See `docs/meeting-assistant-configuration.md` for the detailed configuration reference, including recording providers, FunASR, Whisper, Azure Speech, speaker identification, automation rules, screenshots, summary agents, and the settings/logs assistant.
## Spec Workflow
+339
View File
@@ -0,0 +1,339 @@
# Meeting Assistant Configuration
Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` section. The checked-in `appsettings.json` stores local paths, model names, endpoints, and environment variable names. It should not store secret values; use the configured `KeyEnv` fields for API keys.
## Example
This example is abbreviated so the most common shape is readable. The checked-in [appsettings.json](../MeetingAssistant/appsettings.json) is the canonical full example.
```json
{
"MeetingAssistant": {
"Hotkey": {
"Toggle": "Ctrl+Alt+M",
"Abort": "Ctrl+Alt+Z"
},
"Vault": {
"BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta",
"TranscriptsFolder": "Meetings\\Transcripts",
"MeetingNotesFolder": "Meetings\\Notes",
"AssistantContextFolder": "Meetings\\Assistant Context",
"SummariesFolder": "Meetings\\Summaries",
"ProjectsFolder": "Projects",
"DictationWordsPath": "Meetings\\dictation-words.md"
},
"Recording": {
"TranscriptionProvider": "funasr",
"SampleRate": 16000,
"Channels": 1,
"MicrophoneMixGain": 1,
"SystemAudioMixGain": 1,
"StopProcessingTimeout": "00:10:00",
"MinimumCompletedMeetingDuration": "00:01:00",
"MaxMetadataAttendeeImportCount": 30,
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings"
},
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
},
"WorkflowRulesEditor": {
"Endpoint": "",
"KeyEnv": "",
"Model": ""
},
"Screenshots": {
"Hotkey": "Ctrl+Alt+S",
"AttachmentsFolder": "Attachments"
},
"Agent": {
"Endpoint": "https://litellm.schweigert.cloud",
"KeyEnv": "LITELLM_API_KEY",
"Model": "chatgpt/gpt-5.5"
},
"Api": {
"PublicBaseUrl": "http://localhost:5090"
},
"LaunchProfiles": {
"english": {
"Hotkey": {
"Toggle": "Ctrl+Alt+E"
},
"AzureSpeech": {
"Language": "en-US",
"AutoDetectLanguages": [ "en-US" ]
}
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
## Paths And Overrides
Meeting Assistant expands environment variables such as `%USERPROFILE%` and supports `~` at the start of paths.
Relative vault paths are resolved under `Vault:BaseFolder`. Absolute paths bypass `BaseFolder`. For example, `Vault:MeetingNotesFolder` set to `Meetings\Notes` resolves below `BaseFolder`, while `C:\Notes` is used directly.
Launch profiles live under `MeetingAssistant:LaunchProfiles:{profileName}`. A profile starts with the default `MeetingAssistant` options and then binds its profile section over those defaults. This allows profile-specific language, provider, hotkey, screenshot, or agent overrides without duplicating the whole configuration.
The default profile is always named `default`. Non-default profile hotkeys are registered only when the specific profile explicitly configures that hotkey section. This avoids accidental hotkey collisions from inherited defaults. Array options currently need explicit profile override handling for `AzureSpeech:AutoDetectLanguages` and `FunAsr:ChunkSize`; both are supported.
## Vault
`Vault` configures where Meeting Assistant writes meeting notes, transcripts, assistant context notes, generated summaries, project knowledge, and dictation words. Meeting notes link to separate transcript, assistant context, and summary markdown artifacts using these configured folders.
`BaseFolder` is the root for relative vault paths. Use relative paths for files that should move with the vault root, and absolute paths for external files.
`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter.
`DictationWordsPath` is reserved for providers that can accept vocabulary hints. It points to a markdown word list containing unusual project, person, product, or domain terms that should be biased during transcription when the provider supports it.
## Recording
`Recording:TranscriptionProvider` selects the speech recognition pipeline. Supported values include `funasr`, `whisper-local`, and `azure-speech`.
| Setting | Purpose |
| --- | --- |
| `SampleRate` | PCM sample rate for captured audio. The current providers expect 16000 Hz. |
| `Channels` | PCM channel count for the mixed stream. The current providers expect mono (`1`). |
| `MicrophoneMixGain` | Gain applied to cleaned microphone samples before final mixing. |
| `SystemAudioMixGain` | Gain applied to system loopback samples before final mixing. |
| `StopProcessingTimeout` | Maximum time to wait for post-stop processing such as transcription drain, finalization, OCR wait, and summary handoff. |
| `MinimumCompletedMeetingDuration` | Runs stopped before this duration are treated like aborts, so accidental same-minute starts do not produce meeting artifacts. |
| `MetadataLookupTimeout` | Foreground timeout for initial metadata lookup when starting a meeting. |
| `BackgroundMetadataLookupTimeout` | Longer background timeout for metadata that can continue after transcription starts. |
| `MaxMetadataAttendeeImportCount` | Maximum Outlook attendee count that will be copied into meeting frontmatter. |
| `OpenMeetingNoteDelay` | Delay before opening the newly created Obsidian note, giving the file system and app time to settle. |
| `TemporaryRecordingsFolder` | Folder for temporary mixed WAV files while a run is active. |
During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription.
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts.
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
`Hotkey:Abort` configures a global discard shortcut. The default is `Ctrl+Alt+Z`. Aborting an active recording stops capture/transcription, deletes the meeting note, transcript, assistant context, summary file if present, and linked screenshot attachments for that run, and skips automatic summary generation.
Launch profile toggle hotkeys start a meeting when idle. When a different launch profile toggle is pressed during an active meeting, Meeting Assistant keeps the same meeting note, transcript, assistant context, and metadata; drains the current speech recognition pipeline without summarizing; appends a transcript marker; clears run-local speaker mappings; and continues transcription through the target profile.
## FunASR
`funasr` streams 16 kHz PCM audio to the configured FunASR WebSocket endpoint. When `FunAsr:Backend:Enabled` is true, Meeting Assistant manages a local Docker-backed FunASR runtime. On application startup it begins a non-blocking warm-up for the default launch profile and any named launch profile that selects `funasr`.
| Setting | Purpose |
| --- | --- |
| `Endpoint` | WebSocket endpoint for the FunASR streaming server. |
| `Mode` | FunASR mode sent in streaming requests, usually `2pass`. |
| `ChunkSize` | Three-part FunASR chunk size array. |
| `ChunkInterval` | FunASR chunk interval sent with streaming requests. |
| `EncoderChunkLookBack` | Encoder lookback count for streaming context. |
| `DecoderChunkLookBack` | Decoder lookback count for streaming context. |
| `Itn` | Enables inverse text normalization when the backend supports it. |
| `EmitOnlinePartials` | Emits online partial transcripts instead of waiting only for final two-pass text. |
| `WavName` | Logical WAV name sent to FunASR. |
| `FinalResultTimeout` | Time to wait for a final FunASR response after the stream ends. |
The warm-up pulls the configured online streaming image, prepares the mounted model and hotword folder, replaces any stale container with the configured name, starts the two-pass WebSocket server mapped to the endpoint port, keeps the container alive after the FunASR startup script backgrounds the server process, and stops it when the app shuts down. Recording can start while the backend is still warming up; captured audio is queued and then streamed once the WebSocket backend is healthy.
`FunAsr:Backend` controls the managed Docker runtime:
| Setting | Purpose |
| --- | --- |
| `Enabled` | Starts and warms up the Docker-backed backend from Meeting Assistant. |
| `DockerCommand` | Docker executable name or path. |
| `Image` | FunASR runtime image. |
| `ContainerName` | Container name used for replacement, startup, and shutdown. |
| `ModelsFolder` | Host folder mounted as persistent model and hotword storage. |
| `ContainerPort` | Container and host port used by the WebSocket endpoint. |
| `Privileged` | Runs the container with Docker privileged mode when true. |
| `CommandTimeout` | Timeout for Docker commands such as pull/run/build. |
| `StartupTimeout` | Timeout for backend WebSocket readiness. |
| `StopOnDispose` | Stops the managed container when the app shuts down. |
| `ServerCommand` | Shell command executed inside the container to start FunASR. |
FunASR response fields such as `spk_name`, `spk`, and sentence-level speaker metadata are preserved in transcript segments; missing speaker fields are written as `Unknown`.
If `FunAsr:Diarization:Enabled` is true, FunASR Python `AutoModel` can run with VAD, punctuation, and CAMPPlus over the temporary WAV after capture stops and the streaming ASR provider drains the already-captured audio. If sentence-level speaker labels are returned, the transcript markdown is rewritten with the final speaker-attributed segments. If final diarization is disabled or returns no segments, the live streaming transcript is kept.
`FunAsr:Diarization` controls that final pass:
| Setting | Purpose |
| --- | --- |
| `Enabled` | Runs the final FunASR diarization/finalization pass over the temporary WAV. |
| `AsrModel` | FunASR ASR model name for finalization. |
| `VadModel` | FunASR VAD model name. |
| `PunctuationModel` | FunASR punctuation model name. |
| `SpeakerModel` | FunASR speaker model, for example `cam++`. |
| `BatchSizeSeconds` | Audio batch size for finalization. |
| `BatchSizeThresholdSeconds` | Threshold used by the finalizer before batching. |
| `PresetSpeakerCount` | Optional fixed speaker-count hint for diarization. |
| `CommandTimeout` | Timeout for the final FunASR command. |
## Whisper Local
`whisper-local` uses Whisper.NET with a local ggml model and remains available by setting `Recording:TranscriptionProvider` to `whisper-local`. The model file is not committed to this repository; place it at the configured `WhisperLocal:ModelPath` before using live transcription.
| Setting | Purpose |
| --- | --- |
| `ModelPath` | Path to the local ggml Whisper model file. |
| `Language` | Whisper language hint, or `auto` for automatic detection. |
| `WindowSeconds` | Window size used when streaming audio into local Whisper. |
When `WhisperLocal:Diarization:Enabled` is true, the final post-processing pass runs pyannote in Docker over the temporary WAV and reads the Hugging Face token from `WhisperLocal:Diarization:Token` or `TokenEnv` (`HF_TOKEN` by default). `AnnotationSource` selects pyannote's `speaker_diarization` or `exclusive_speaker_diarization` output. `AlignmentMode` selects whether Meeting Assistant assigns the best-overlap pyannote speaker label to each Whisper segment or rebuilds the finished transcript as pyannote speaker-turn segments with matching Whisper text.
Active pyannote runtimes are warmed up on application start so image setup and model download do not wait for the first diarization request.
Pyannote diarization settings are shared by local Whisper finalization and speaker-identification validation:
| Setting | Purpose |
| --- | --- |
| `Enabled` | Enables the pyannote-backed pass. |
| `DockerCommand` | Docker executable name or path. |
| `BaseImage` | Python base image used when building the local pyannote image. |
| `Image` | Local pyannote Docker image tag. |
| `BuildImage` | Builds the local image when it is missing. |
| `ModelsFolder` | Host folder mounted as Hugging Face, torch, and pip cache. |
| `Model` | Pyannote model name. |
| `AnnotationSource` | Selects `SpeakerDiarization` or `ExclusiveSpeakerDiarization`. |
| `AlignmentMode` | Selects `BestOverlap` or `PyannoteTurns` transcript alignment. |
| `Token` | Optional Hugging Face token value. Prefer `TokenEnv` for normal use. |
| `TokenEnv` | Environment variable name that contains the Hugging Face token. |
| `CommandTimeout` | Timeout for the pyannote command. |
## Azure Speech
`azure-speech` streams Meeting Assistant's captured PCM chunks to Azure AI Speech using the Speech SDK `ConversationTranscriber`; it does not use an Azure-owned microphone capture or MAS/AEC input path.
Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results.
| Setting | Purpose |
| --- | --- |
| `Endpoint` | Optional Azure Speech endpoint. When blank, `Region` plus subscription key is used. |
| `Region` | Azure Speech region used for subscription-based config. |
| `Language` | Fixed recognition language. Set to `auto` to enable Azure source language auto-detection. |
| `AutoDetectLanguages` | Candidate languages used only when `Language` is `auto`. |
| `LanguageIdMode` | Azure language identification mode; `AtStart` or `Continuous`. Other values normalize to `Continuous`. |
| `Key` | Optional inline Azure Speech key. Prefer `KeyEnv`. |
| `KeyEnv` | Environment variable name that contains the Azure Speech key. |
| `RecognitionStopTimeout` | Timeout while stopping the ConversationTranscriber after closing the audio stream. |
| `DiarizeIntermediateResults` | Requests diarized intermediate results from Azure. |
| `PostProcessingOption` | Accepted by config but currently skipped for the live `ConversationTranscriber` path; the app logs a warning if set. |
| `PhraseListWeight` | Weight applied to dictation-word phrase-list hints. |
Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed; the default is 3 minutes so Azure has time to finalize longer speaker-recognition samples.
## Speaker Identification
Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span.
| Setting | Purpose |
| --- | --- |
| `Enabled` | Enables live speaker identity matching and sample collection. |
| `DatabasePath` | SQLite database path for identities, aliases, references, and samples. |
| `InitialDelay` | Delay after recording starts before the first live identity pass. |
| `Interval` | Interval between live identity passes. |
| `MatchBatchSize` | Number of identities processed per model matching batch. |
| `MaxMatchCandidates` | Maximum known identities considered during one match pass. |
| `MatchIdentityActiveAge` | Age window for identities considered active enough for automatic matching. |
| `MaxSnippetsPerSpeaker` | Maximum stored voice snippets retained per speaker identity. |
| `MinimumSampleSpeechDuration` | Minimum uninterrupted same-speaker speech span needed before storing a sample. |
| `MaximumSampleSegmentGap` | Maximum gap allowed when combining adjacent same-speaker segments into one sample. |
| `SilenceBetweenSnippetsSeconds` | Silence padding inserted between snippets during Azure identity matching. |
| `LiveSampleBufferDuration` | How long live transcript/audio material is retained for extracting samples. |
| `MergeRecentIdentityAge` | Age window used by diagnostics that merge recent duplicate identities. |
| `MatchTimeout` | Timeout for an Azure-backed identity matching request. |
`SpeakerIdentification:AzureSpeech` is an advanced nested override for speaker identity matching. It uses the same shape as `AzureSpeech` and lets identity matching use different Azure language, endpoint, or key settings than live transcription when needed. If it is left unset, the normal Azure Speech settings remain the practical default.
`SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization and defaults the validation command timeout to 1 hour because local model setup can take substantial time.
| Setting | Purpose |
| --- | --- |
| `MinimumSingleSpeakerCoverage` | Required fraction of the tested sample that pyannote must attribute to a single speaker. |
| `MinimumMatchingKnownSnippetRatio` | Required pyannote agreement ratio between the new sample and known snippets for an accepted identity. |
| `Diarization` | Nested pyannote settings used for this validation pass. |
## Automation
`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`.
See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes.
`POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options.
## Screenshots
`Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context.
`Screenshots:Ocr` optionally enables vision extraction for screenshots. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`.
| Setting | Purpose |
| --- | --- |
| `Enabled` | Sends screenshots to the OCR/vision model when true. |
| `Endpoint` | Optional OpenAI-compatible Responses endpoint override. |
| `Key` | Optional inline API key. Prefer `KeyEnv`. |
| `KeyEnv` | Environment variable name for the OCR API key. |
| `Model` | Optional OCR model override. Blank falls back to `Agent:Model`. |
| `Prompt` | OCR instruction prompt. The default asks for visible speakers/presenters, slide text as markdown, diagrams as Mermaid, scene descriptions, participant certainty, and presentation/shared-screen crop coordinates when confidently isolatable. |
| `Timeout` | Maximum time to wait for OCR before summarization continues. |
## Agent And Summary
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`.
| Setting | Purpose |
| --- | --- |
| `Endpoint` | OpenAI-compatible endpoint base URL. |
| `Key` | Optional inline API key. Prefer `KeyEnv`. |
| `KeyEnv` | Environment variable name for the agent API key. |
| `Model` | Model id sent to the endpoint. |
| `EnableThinking` | Enables reasoning options in requests when supported by the model/backend. |
| `ReasoningEffort` | Reasoning effort: `None`, `Low`, `Medium`, `High`, or `ExtraHigh`. |
| `ReconnectionAttempts` | Retry count for transient model endpoint failures. |
| `ReconnectionDelay` | Delay between retry attempts. |
| `ContextWindowTokens` | Estimated context-window size used by compaction decisions. |
| `MaxOutputTokens` | Maximum output tokens for agent responses. |
| `EnableCompaction` | Enables conversation compaction before requests exceed the configured context budget. |
| `CompactionRemainingRatio` | Remaining-context ratio that triggers compaction. |
| `ResponsesCompactPath` | Relative Responses compaction path, usually `responses/compact`. |
| `InitialPrompt` | Optional complete replacement for the default summary-agent system instructions. |
After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks.
The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear. It can override transcript speaker labels only when the evidence is very certain, and it can delete wrongfully matched identities. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes.
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left.
## Workflow Rules Editor
`WorkflowRulesEditor` configures the tray-launched settings/logs assistant. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden.
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, inspect application logs, create/search/read/write project files, and post-process past meeting artifacts by listing recent summaries and reading, writing, or searching summaries, transcripts, meeting notes, and assistant context files. For artifact metadata repairs, it has frontmatter-specific write tools that preserve the markdown body.
It also has in-process diagnostic tools that mirror the local HTTP diagnostics for health, recording status, current Outlook meeting lookup, recent speaker identity merging, workflow configuration reload, and ASR transcribe/diarize checks.
The `search_spec` and `read_spec_file` tools are scoped to the copied `openspec/specs` markdown tree. Spec files are copied into build and publish output through an MSBuild glob, so newly added folders under `openspec/specs` are included automatically.
## Logs
The top-level `Logging` section is standard ASP.NET Core logging configuration and controls framework/console log levels, for example `Logging:LogLevel:Microsoft.AspNetCore`.
Independently from stdout and stderr redirection, Meeting Assistant writes an application-owned log under:
```text
%TEMP%\MeetingAssistant\Logs\meeting-assistant.log
```
On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The settings/logs assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked.
## API
`Api:PublicBaseUrl` is used to build clickable local links, such as summary retry links in generated failure notes.
+1 -1
View File
@@ -22,7 +22,7 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
The tray menu includes `Edit rules and identities`, which opens a small MewUI chat editor for this configured rules file and the local speaker identity database. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
The tray menu includes `Settings and logs`, which opens a small MewUI chat assistant for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
```json
{
@@ -0,0 +1,16 @@
# Generalize settings and logs assistant
## Why
The tray-launched rules and identities chat is becoming the local operational assistant for Meeting Assistant. It should be presented as a broader settings and logs window and should be able to inspect and edit local configuration, explain configuration from docs, and inspect runtime logs without relying on external stdout redirection.
## What Changes
- Rename the tray/menu/window surface from `Edit rules and identities` to `Settings and logs` and generalize its placeholder text.
- Add agent tools to read and edit the local appsettings configuration file, read configuration documentation, tail/read logs, and search logs.
- Move detailed configuration documentation out of `README.md` into a dedicated docs file that is copied with the application.
- Add application-owned file logging under the temp folder with four rotated older files.
- Update repository agent instructions with concise Meeting Assistant context for future agents.
## Impact
- Existing rules and speaker identity tools remain available.
- The local chat assistant can now help with configuration and logs in addition to rules and identities.
- Runtime logs are available even when stdout/stderr redirection changes.
@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: Agents can use project context tools
Meeting Assistant SHALL expose tools that allow agents to look up project information, retrieve keyword-relevant context, and inspect meeting-derived knowledge.
Project tools SHALL treat each direct subfolder of the configured projects folder as one project. A meeting note binds projects by listing those subfolder names in the `projects` frontmatter field.
The summary agent SHALL expose these project tools:
- `list_projects`
- `list_projectfiles`
- `read_projectfile`
- `write_projectfile`
- `list_past_project_meetings`
- `read_past_project_meeting_summary`
- `search`
The settings/logs agent SHALL expose project tools that operate across all existing project folders under the configured projects folder:
- `list_projects`
- `list_projectfiles`
- `read_projectfile`
- `write_projectfile`
- `search_projects`
- `create_project`
The settings/logs `create_project` tool SHALL create one new direct project subfolder by project name.
The settings/logs `create_project` tool SHALL support a recommended seed that writes `AGENTS.md` from the bundled `Project-AGENTS.md` content file and creates starter `PROJECT.md`, `JOURNAL.md`, and `DECISIONS.md` files.
The settings/logs `create_project` tool SHALL support writing caller-supplied `AGENTS.md` content directly instead of the recommended seed.
The settings/logs project read/write/search tools SHALL refuse project names that do not already exist, except for `create_project`.
The `search` and `search_projects` tools SHALL support an optional glob file pattern that limits searched project files.
#### Scenario: Settings/logs agent creates a recommended project
- **WHEN** the settings/logs agent creates project `Alpha` with the recommended seed
- **THEN** Meeting Assistant creates the `Alpha` project folder
- **AND** writes `AGENTS.md` from the bundled `Project-AGENTS.md`
- **AND** writes starter `PROJECT.md`, `JOURNAL.md`, and `DECISIONS.md` files
#### Scenario: Settings/logs agent writes direct project instructions
- **WHEN** the settings/logs agent creates project `Beta` with direct `AGENTS.md` content
- **THEN** Meeting Assistant creates the `Beta` project folder
- **AND** writes that content to `AGENTS.md`
#### Scenario: Settings/logs agent searches existing projects by file glob
- **GIVEN** existing projects contain markdown and non-markdown files
- **WHEN** the settings/logs agent searches projects with file pattern `*.md`
- **THEN** Meeting Assistant returns matches from matching markdown files
- **AND** excludes non-matching files
@@ -0,0 +1,18 @@
## MODIFIED Requirements
### Requirement: Recording mode is controlled by a configurable hotkey
Meeting Assistant SHALL let the user start and stop recording mode through a configurable global hotkey.
Meeting Assistant SHALL treat recordings stopped before the configured `Recording:MinimumCompletedMeetingDuration` as aborted runs, deleting run artifacts and skipping summary generation.
Meeting Assistant SHALL name generated summary and assistant-context artifacts with minute precision so routine completed meetings do not include seconds or sub-second randomness in those filenames.
#### Scenario: Same-minute run is treated as abort
- **GIVEN** `Recording:MinimumCompletedMeetingDuration` is `00:01:00`
- **WHEN** a recording is started and stopped before one minute has elapsed
- **THEN** Meeting Assistant deletes the run artifacts
- **AND** skips summary generation
#### Scenario: Summary artifact names use minute precision
- **WHEN** a recording starts at `2026-05-30T09:30:30`
- **THEN** Meeting Assistant uses `20260530-0930-summary.md` for the summary artifact filename
- **AND** uses `20260530-0930-assistant-context.md` for the assistant context artifact filename
@@ -0,0 +1,141 @@
## MODIFIED Requirements
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
Meeting Assistant SHALL expose a `Settings and logs` item from the tray icon menu.
The tray icon SHALL be implemented through the Uno notification icon stack.
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
When the user selects `Settings and logs`, Meeting Assistant SHALL open a basic WPF chat window for editing the configured workflow rules file, managing speaker identities, inspecting configuration, and reading logs.
The chat window SHALL be titled `Settings and logs`, SHALL display user and assistant messages as visually distinct cards, SHALL render message text with selectable rich text, SHALL display basic markdown emphasis including bold, italic, bold-italic, and strikethrough text, clickable markdown links and autolinks with URL tooltips, clickable markdown image embeds with URL tooltips and a small preview or placeholder, boxed inline code, fenced code blocks, blockquotes, level-one and level-two headings with modestly larger text, markdown task checkboxes as aligned Unicode checkbox glyphs, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text `ask me what I can do for you`, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
The Windows executable and settings/logs editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon.
The settings/logs agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults.
The settings/logs agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
The rules editor `write_rules` tool SHALL append to the configured workflow rules file by default and SHALL validate the complete resulting YAML document before writing it.
The rules editor `write_rules` tool SHALL replace the whole configured workflow rules file only when `replace_file` is true.
The rules editor `write_rules` tool SHALL refuse invalid YAML without changing the configured workflow rules file.
The settings/logs agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
The settings/logs agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
The settings/logs agent SHALL receive tools to read the local appsettings configuration file, write a complete valid JSON replacement for that file, read Meeting Assistant configuration documentation, read current or rotated application log files by tail or 1-based line range, search current or rotated application log files, search copied OpenSpec specification files, and read copied OpenSpec specification files by relative path and optional 1-based line range.
The settings/logs agent SHALL receive in-process diagnostic tools for health, recording status, current meeting metadata lookup, recent speaker identity merging, workflow configuration reload, ASR file transcription, and ASR file diarization so those diagnostics do not require HTTP calls.
Meeting Assistant SHALL copy specification markdown files from `openspec/specs` into build and publish output through a recursive include so future spec folders are included automatically.
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
Meeting Assistant SHALL provide a diagnostic endpoint that opens the settings/logs editor through the same window service used by the tray menu.
#### Scenario: Tray menu opens the editor
- **WHEN** the user opens the tray icon menu
- **THEN** the menu includes `Settings and logs`
- **WHEN** the user selects `Settings and logs`
- **THEN** Meeting Assistant opens the settings/logs editor chat window
#### Scenario: Tray menu shows configured profile hotkeys
- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M`
- **AND** the `english` launch profile uses `Ctrl+Alt+E`
- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle
- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M`
- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E`
#### Scenario: Settings and logs editor can be opened diagnostically
- **WHEN** a diagnostic caller requests the settings/logs editor to open
- **THEN** Meeting Assistant invokes the settings/logs editor window service
#### Scenario: Rules editor is scoped to the configured rules file
- **GIVEN** a configured workflow rules file
- **WHEN** the settings/logs agent runs
- **THEN** its read, write, and search tools can access only that configured workflow rules file
- **AND** its system prompt includes the workflow engine documentation
#### Scenario: Settings and logs assistant reads and writes configuration
- **GIVEN** a local appsettings JSON file
- **WHEN** the settings/logs agent reads configuration
- **THEN** it receives the file content or requested clamped line range
- **WHEN** it writes a valid JSON replacement
- **THEN** Meeting Assistant replaces the configuration file
- **WHEN** it writes invalid JSON
- **THEN** Meeting Assistant refuses the write and preserves the existing file
#### Scenario: Settings and logs assistant reads configuration docs
- **WHEN** the settings/logs agent requests configuration documentation
- **THEN** Meeting Assistant returns the dedicated configuration documentation markdown
#### Scenario: Settings and logs assistant reads and searches logs
- **GIVEN** Meeting Assistant has written application-owned logs under the temp log folder
- **WHEN** the settings/logs agent reads logs with a tail count or line range
- **THEN** it receives matching log lines from the selected log file
- **WHEN** the settings/logs agent searches logs
- **THEN** it receives matching `filename:line text` entries from current and rotated log files
#### Scenario: Settings and logs assistant reads and searches copied specs
- **GIVEN** OpenSpec specification markdown files are copied into the application output
- **WHEN** the settings/logs agent searches specs
- **THEN** it receives matching `relative/spec.md:line text` entries scoped to `openspec/specs`
- **WHEN** the settings/logs agent reads a spec file by relative path
- **THEN** it receives the requested file content or clamped 1-based line range
- **AND** paths outside the copied specs tree are refused
#### Scenario: Settings and logs assistant runs diagnostics without HTTP
- **WHEN** the settings/logs agent requests health, recording status, current meeting metadata, speaker identity merge, workflow reload, or ASR file diagnostics
- **THEN** Meeting Assistant runs the same in-process services used by the local diagnostic endpoints
- **AND** returns the diagnostic result to the agent
#### Scenario: New spec folders are included in output
- **WHEN** a new markdown spec file is added under `openspec/specs`
- **THEN** the application build includes it in the output under `openspec/specs`
- **AND** publish includes it in the published output under `openspec/specs`
#### Scenario: Application keeps its own rotating log files
- **WHEN** Meeting Assistant starts
- **THEN** it writes application logs to the configured temp log folder independently from stdout and stderr redirection
- **AND** it preserves at most four older rotated log files
#### Scenario: Rules editor appends by default
- **GIVEN** a configured workflow rules file with existing valid rules
- **WHEN** the rules editor writes an additional valid rule without `replace_file`
- **THEN** Meeting Assistant appends the new rule
- **AND** preserves the existing rules
#### Scenario: Rules editor replaces only when requested
- **GIVEN** a configured workflow rules file with existing rules
- **WHEN** the rules editor writes valid YAML with `replace_file` set to true
- **THEN** Meeting Assistant replaces the complete rules file with the supplied YAML
#### Scenario: Rules editor refuses invalid YAML
- **GIVEN** a configured workflow rules file with valid existing rules
- **WHEN** the rules editor writes YAML that would make the file invalid
- **THEN** Meeting Assistant refuses the write
- **AND** keeps the existing rules file unchanged
#### Scenario: Rules editor can manage speaker identities
- **GIVEN** the speaker identity database contains speaker identities and samples
- **WHEN** the rules editor agent runs
- **THEN** it can search, read, create, update, delete, and merge speaker identities
- **AND** it can list, read, delete, and queue playback of identity samples
#### Scenario: User sends a rules-editing chat turn
- **GIVEN** the settings/logs editor chat window is open
- **WHEN** the user types a prompt and presses Enter
- **THEN** the user message appears in the conversation
- **AND** a plain `Thinking...` line appears while the agent is running
- **AND** the first model request for that turn is marked as user-initiated
- **AND** the final assistant response replaces the thinking line
#### Scenario: Chat auto-scrolls after appended content
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
- **WHEN** a new user or assistant message is appended
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
@@ -0,0 +1,57 @@
## MODIFIED Requirements
### Requirement: Meeting Assistant generates meeting outputs
Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context.
All summary-agent read tools that return file-like content SHALL support reading the whole file, reading a clamped inclusive 1-based line range when `from` and `to` are supplied, or reading the last `tail` lines when `tail` is supplied without a line range.
The `write_summary` tool SHALL return the written summary filename after writing the summary note.
The summary-agent `search` tool SHALL support an optional glob file pattern that limits searched project files.
The settings/logs agent SHALL be able to list recent meeting summary notes from the configured summaries folder ordered newest first by meeting start time, falling back to file modification time when summary frontmatter has no parseable start time.
The settings/logs agent SHALL be able to read and search meeting summaries, transcripts, meeting notes, and assistant context files from their configured vault folders so it can help post-process notes for the user.
The settings/logs agent SHALL be able to create and update meeting summaries, transcripts, meeting notes, and assistant context files inside their configured vault folders.
The settings/logs agent SHALL provide frontmatter-specific write tools for meeting summaries, transcripts, meeting notes, and assistant context files that replace YAML frontmatter while preserving the markdown body.
Settings/logs meeting artifact read tools SHALL support reading the whole file, reading a clamped inclusive 1-based line range when `from` and `to` are supplied, or reading the last `tail` lines when `tail` is supplied without a line range.
Settings/logs meeting artifact search tools SHALL support an optional glob file pattern that limits searched markdown files.
#### Scenario: Summary read tools support tail
- **WHEN** the summary agent reads a transcript with `tail` set to `2`
- **THEN** Meeting Assistant returns the last two transcript lines
#### Scenario: Summary write returns filename
- **WHEN** the summary agent writes the current meeting summary
- **THEN** Meeting Assistant returns the written summary filename
#### Scenario: Summary search is limited by file pattern
- **GIVEN** a bound project has `PROJECT.md` and `scratch.txt`
- **WHEN** the summary agent searches with file pattern `*.md`
- **THEN** Meeting Assistant searches `PROJECT.md`
- **AND** excludes `scratch.txt`
#### Scenario: Settings/logs agent lists and reads recent meeting artifacts
- **GIVEN** the configured summary folder contains multiple summary notes with start times
- **WHEN** the settings/logs agent lists recent summaries
- **THEN** Meeting Assistant returns the summary notes newest first
- **AND** includes relative paths for the linked meeting note, transcript, and assistant context when those links resolve to configured artifact folders
- **WHEN** the settings/logs agent reads one of those artifact paths with `tail`
- **THEN** Meeting Assistant returns only the requested last lines
#### Scenario: Settings/logs agent searches meeting artifacts
- **GIVEN** meeting summaries, transcripts, notes, and assistant context files contain searchable text
- **WHEN** the settings/logs agent searches the relevant artifact class
- **THEN** Meeting Assistant returns relative `path:line` matches from that configured artifact folder
- **AND** refuses read paths that escape their configured artifact folder
#### Scenario: Settings/logs agent repairs meeting artifact files
- **GIVEN** an existing meeting note with YAML frontmatter and a markdown body
- **WHEN** the settings/logs agent writes replacement frontmatter for that note
- **THEN** Meeting Assistant validates the supplied frontmatter YAML
- **AND** replaces only the frontmatter while preserving the body
- **WHEN** the settings/logs agent writes a summary, transcript, meeting note, or assistant context path outside the configured folder
- **THEN** Meeting Assistant refuses the write
@@ -0,0 +1,26 @@
## 1. Specification
- [x] 1.1 Add OpenSpec scenarios for the generalized settings/logs assistant.
- [x] 1.2 Move configuration docs into `docs/meeting-assistant-configuration.md` and keep README as an overview.
- [x] 1.3 Update AGENTS.md with application context for future agents.
## 2. Tests
- [x] 2.1 Cover user-facing tray/window/placeholder rename behavior.
- [x] 2.2 Cover config read/write validation tools.
- [x] 2.3 Cover config docs tool.
- [x] 2.4 Cover log tail/range/search tools and file logging rotation.
- [x] 2.5 Cover copied spec read/search tools and scoped path handling.
- [x] 2.6 Cover project creation, project read/write/search, read tail, search glob, summary filename return, and short-run abort behavior.
- [x] 2.7 Cover settings/logs recent-summary listing and meeting artifact read/search tools.
- [x] 2.8 Cover settings/logs meeting artifact write/frontmatter repair tools and in-process diagnostic tool availability.
- [x] 2.9 Cover summary frontmatter copying clean, distinct meeting projects.
## 3. Implementation
- [x] 3.1 Add app-owned temp-file logging with four older rotated files.
- [x] 3.2 Add settings/logs tools to the editor agent pipeline.
- [x] 3.3 Update instructions, UI title/menu text, and docs copy settings.
- [x] 3.4 Add recursive spec markdown copy to build and publish output.
- [x] 3.5 Run focused tests and strict OpenSpec validation.
- [x] 3.6 Add settings/logs project tools, bundled Project-AGENTS.md seed content, summary filename return, minute-level summary/context names, and configurable short-run abort.
- [x] 3.7 Add settings/logs tools for recent summaries and summary/transcript/note/context read/search.
- [x] 3.8 Add settings/logs tools for summary/transcript/note/context writes, frontmatter repair, and diagnostics that previously required HTTP.
- [x] 3.9 Normalize copied meeting projects when writing summary frontmatter.